import Foundation import RemoteCore public enum WristSide: String, Codable, Sendable { case left, right } public enum CrownOrientation: String, Codable, Sendable { case towardHand, awayFromHand } /// A normalized, user-relative sample. Values are dimensionless and bounded by /// the motion adapter; this type intentionally contains no sensor or TV data. public struct NormalizedMotionSample: Sendable, Equatable { public let timestamp: TimeInterval public let x: Double public let y: Double public let quality: Double public init(timestamp: TimeInterval, x: Double, y: Double, quality: Double = 1) { self.timestamp = timestamp; self.x = x; self.y = y; self.quality = quality } } public struct GestureRecognition: Sendable, Equatable { public let sessionID: UUID public let command: RemoteCommand public let confidence: Double public let timestamp: TimeInterval } public struct GestureRecognitionConfiguration: Sendable, Equatable { public var activationMagnitude: Double = 0.55 public var neutralMagnitude: Double = 0.18 public var minimumNeutralDwell: TimeInterval = 0.20 public var candidateWindow: ClosedRange = 0.12...0.65 public var cooldown: TimeInterval = 0.40 public var stableNeutralDwell: TimeInterval = 0.20 public var maximumSampleGap: TimeInterval = 0.12 public var dominantAxisRatio: Double = 1.5 public var minimumQuality: Double = 0.65 public init() {} } /// A deterministic classifier over calibrated, user-relative samples. It is /// deliberately conservative: mixed axes, poor quality, gaps and slow drift /// produce no intent. One accepted excursion consumes readiness until a stable /// neutral pose is observed after cooldown. public struct CalibratedGestureRecognizer: Sendable { public let configuration: GestureRecognitionConfiguration public private(set) var sessionID: UUID? private var armedAt: TimeInterval? private var lastTimestamp: TimeInterval? private var neutralSince: TimeInterval? private var candidateStart: TimeInterval? private var candidatePeakX = 0.0 private var candidatePeakY = 0.0 private var candidateQuality = 1.0 private var cooldownUntil: TimeInterval? private var waitingForNeutral = true public init(configuration: GestureRecognitionConfiguration = .init()) { self.configuration = configuration } public mutating func arm(sessionID: UUID, at timestamp: TimeInterval) { disarm() guard timestamp.isFinite else { return } self.sessionID = sessionID; armedAt = timestamp; lastTimestamp = nil waitingForNeutral = true } public mutating func disarm() { sessionID = nil; armedAt = nil; lastTimestamp = nil neutralSince = nil; candidateStart = nil; candidatePeakX = 0; candidatePeakY = 0 candidateQuality = 1; cooldownUntil = nil; waitingForNeutral = true } public mutating func process(_ sample: NormalizedMotionSample) -> GestureRecognition? { guard let sessionID, let armedAt, sample.timestamp.isFinite, sample.timestamp >= armedAt, sample.x.isFinite, sample.y.isFinite, sample.quality.isFinite, sample.quality >= 0, sample.quality <= 1 else { resetCandidate(); waitingForNeutral = true; neutralSince = nil return nil } if let lastTimestamp, sample.timestamp <= lastTimestamp { return nil } if let lastTimestamp, sample.timestamp - lastTimestamp > configuration.maximumSampleGap { resetCandidate(); waitingForNeutral = true; neutralSince = nil } lastTimestamp = sample.timestamp let magnitude = hypot(sample.x, sample.y) guard magnitude <= 1.0 else { resetCandidate(); waitingForNeutral = true; return nil } if magnitude <= configuration.neutralMagnitude { if neutralSince == nil { neutralSince = sample.timestamp } if let cooldownUntil, sample.timestamp < cooldownUntil { return nil } if sample.timestamp - (neutralSince ?? sample.timestamp) >= configuration.stableNeutralDwell { waitingForNeutral = false; resetCandidate() } return nil } neutralSince = nil guard sample.quality >= configuration.minimumQuality else { resetCandidate(); waitingForNeutral = true; return nil } guard !waitingForNeutral else { return nil } if candidateStart == nil { guard magnitude >= configuration.neutralMagnitude else { return nil } candidateStart = sample.timestamp; candidatePeakX = sample.x; candidatePeakY = sample.y candidateQuality = sample.quality return nil } guard let start = candidateStart else { return nil } let duration = sample.timestamp - start guard duration <= configuration.candidateWindow.upperBound else { resetCandidate(); waitingForNeutral = true; return nil } candidatePeakX = abs(sample.x) > abs(candidatePeakX) ? sample.x : candidatePeakX candidatePeakY = abs(sample.y) > abs(candidatePeakY) ? sample.y : candidatePeakY candidateQuality = min(candidateQuality, sample.quality) let peakX = abs(candidatePeakX), peakY = abs(candidatePeakY) let peak = max(peakX, peakY) guard duration >= configuration.candidateWindow.lowerBound, peak >= configuration.activationMagnitude else { return nil } let dominant = max(peakX, peakY) / max(0.0001, min(peakX, peakY)) guard dominant >= configuration.dominantAxisRatio else { resetCandidate(); waitingForNeutral = true; return nil } let command: RemoteCommand if peakX > peakY { command = candidatePeakX > 0 ? .right : .left } else { command = candidatePeakY > 0 ? .up : .down } let confidence = min(1, 0.5 * (peak / max(0.0001, configuration.activationMagnitude)) + 0.3 * min(1, dominant / 3) + 0.2 * candidateQuality) resetCandidate(); waitingForNeutral = true; cooldownUntil = sample.timestamp + configuration.cooldown return GestureRecognition(sessionID: sessionID, command: command, confidence: confidence, timestamp: sample.timestamp) } private mutating func resetCandidate() { candidateStart = nil; candidatePeakX = 0; candidatePeakY = 0; candidateQuality = 1 } }