Files

119 lines
4.8 KiB
Swift

import Foundation
import RemoteCore
/// Proposed normalized thresholds; these are not calibrated motion measurements.
public struct GestureConfiguration: Sendable, Equatable {
public let activationThreshold: Double
public let neutralThreshold: Double
public let minimumInterval: TimeInterval
public let maximumArmedDuration: TimeInterval
public let axisDominanceRatio: Double
public init() {
activationThreshold = 0.7
neutralThreshold = 0.25
minimumInterval = 0.3
maximumArmedDuration = 10
axisDominanceRatio = 0.6
}
public enum ConfigurationError: Error { case invalidValues }
public init(activationThreshold: Double, neutralThreshold: Double,
minimumInterval: TimeInterval, maximumArmedDuration: TimeInterval,
axisDominanceRatio: Double) throws {
guard activationThreshold.isFinite, neutralThreshold.isFinite,
minimumInterval.isFinite, maximumArmedDuration.isFinite,
axisDominanceRatio.isFinite,
activationThreshold > 0, activationThreshold <= 1,
neutralThreshold >= 0, neutralThreshold < activationThreshold,
minimumInterval >= 0, maximumArmedDuration > 0,
axisDominanceRatio > 0, axisDominanceRatio < 1 else {
throw ConfigurationError.invalidValues
}
self.activationThreshold = activationThreshold
self.neutralThreshold = neutralThreshold
self.minimumInterval = minimumInterval
self.maximumArmedDuration = maximumArmedDuration
self.axisDominanceRatio = axisDominanceRatio
}
}
public struct GestureIntent: Sendable, Equatable {
public let sessionID: UUID
public let command: RemoteCommand
/// Monotonic local seconds, never serialize this as a cross-device wall clock.
public let timestamp: TimeInterval
}
/// A deterministic gate over already-normalized directional input, NOT a classifier
/// of accelerometer/gyroscope samples or an implementation of Apple system gestures.
/// x positive = right; y positive = up. An adapter must establish wrist orientation.
public struct DirectionalGestureGate: Sendable {
public let configuration: GestureConfiguration
public private(set) var sessionID: UUID?
private var armedAt: TimeInterval?
private var lastSampleAt: TimeInterval?
private var lastEmissionAt: TimeInterval?
private var ready = false
public init(configuration: GestureConfiguration = GestureConfiguration()) {
self.configuration = configuration
}
/// Deliberate foreground action only. After arming, a neutral sample is required.
public mutating func arm(sessionID: UUID, at timestamp: TimeInterval) {
disarm()
guard timestamp.isFinite else { return }
self.sessionID = sessionID
armedAt = timestamp
}
/// Call on app deactivation, reachability loss, TV/session change, or user cancel.
public mutating func disarm() {
sessionID = nil
armedAt = nil
lastSampleAt = nil
lastEmissionAt = nil
ready = false
}
public mutating func process(x: Double, y: Double, at timestamp: TimeInterval) -> GestureIntent? {
guard let sessionID, let armedAt else { return nil }
guard timestamp.isFinite, timestamp >= armedAt,
timestamp - armedAt < configuration.maximumArmedDuration else {
disarm()
return nil
}
guard lastSampleAt.map({ timestamp > $0 }) ?? true else { return nil }
lastSampleAt = timestamp
guard x.isFinite, y.isFinite, abs(x) <= 1, abs(y) <= 1 else {
// Invalid input requires neutral recovery and cannot preserve readiness.
ready = false
return nil
}
let magnitude = max(abs(x), abs(y))
if magnitude <= configuration.neutralThreshold {
ready = true
return nil
}
guard ready, magnitude >= configuration.activationThreshold else { return nil }
// Every threshold excursion consumes readiness, including ambiguous diagonals
// and debounce suppression. Holding an input can never trigger a late event.
ready = false
guard lastEmissionAt.map({ timestamp - $0 >= configuration.minimumInterval }) ?? true else {
return nil
}
let command: RemoteCommand
if abs(x) > abs(y), abs(y) <= abs(x) * configuration.axisDominanceRatio {
command = x > 0 ? .right : .left
} else if abs(y) > abs(x), abs(x) <= abs(y) * configuration.axisDominanceRatio {
command = y > 0 ? .up : .down
} else {
return nil
}
lastEmissionAt = timestamp
return GestureIntent(sessionID: sessionID, command: command, timestamp: timestamp)
}
}