Create Wristivo iPhone and Apple Watch foundation
This commit is contained in:
@@ -0,0 +1,118 @@
|
||||
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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user