119 lines
4.8 KiB
Swift
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)
|
|
}
|
|
}
|