feat: implement Haptika PPG and Watch V1
This commit is contained in:
@@ -0,0 +1,303 @@
|
||||
import CoreHaptics
|
||||
import Foundation
|
||||
import NitroModules
|
||||
|
||||
/// HAPTIKA.CARDIAC_MIRROR_BEAT v1.0.0
|
||||
///
|
||||
/// This pattern is deliberately isolated from generic notification haptics. A
|
||||
/// short transient with a controlled sharpness gives the user a repeatable
|
||||
/// “heartbeat” texture without pretending to reproduce a clinical waveform.
|
||||
final class HybridHaptikaCardiacMirror: HybridHaptikaCardiacMirrorSpec {
|
||||
private static let patternID = "HAPTIKA.CARDIAC_MIRROR_BEAT"
|
||||
private static let patternVersion = "1.0.0"
|
||||
// Kept at zero for Nitro ABI compatibility. Public Watch workout APIs expose
|
||||
// cadence updates, not exact beat events, so Haptika advertises no latency
|
||||
// SLA between a biological beat and the reconstructed iPhone pulse.
|
||||
private static let latencyBudgetMs = 0.0
|
||||
|
||||
private let lock = NSLock()
|
||||
private var engine: CHHapticEngine?
|
||||
private var patternPlayer: CHHapticPatternPlayer?
|
||||
private var regulationPlayer: CHHapticAdvancedPatternPlayer?
|
||||
private var supported: Bool
|
||||
|
||||
override init() {
|
||||
self.supported = CHHapticEngine.capabilitiesForHardware().supportsHaptics
|
||||
super.init()
|
||||
guard supported else { return }
|
||||
configureEngine()
|
||||
}
|
||||
|
||||
func isSupported() throws -> Bool {
|
||||
return supported
|
||||
}
|
||||
|
||||
func patternInfo() throws -> NativeCardiacMirrorPatternInfo {
|
||||
return NativeCardiacMirrorPatternInfo(
|
||||
id: Self.patternID,
|
||||
version: Self.patternVersion,
|
||||
latencyBudgetMs: Self.latencyBudgetMs
|
||||
)
|
||||
}
|
||||
|
||||
func start(targetBpm: Double) throws {
|
||||
guard supported else { return }
|
||||
lock.lock()
|
||||
defer { lock.unlock() }
|
||||
do {
|
||||
try engine?.start()
|
||||
if patternPlayer == nil {
|
||||
patternPlayer = try makePatternPlayer()
|
||||
}
|
||||
} catch {
|
||||
// Haptics are optional and must never block the visual experience.
|
||||
patternPlayer = nil
|
||||
}
|
||||
}
|
||||
|
||||
func pulse() throws {
|
||||
guard supported else { return }
|
||||
lock.lock()
|
||||
defer { lock.unlock() }
|
||||
do {
|
||||
try engine?.start()
|
||||
if patternPlayer == nil {
|
||||
patternPlayer = try makePatternPlayer()
|
||||
}
|
||||
try patternPlayer?.start(atTime: CHHapticTimeImmediate)
|
||||
} catch {
|
||||
patternPlayer = nil
|
||||
}
|
||||
}
|
||||
|
||||
func stop() throws {
|
||||
lock.lock()
|
||||
defer { lock.unlock() }
|
||||
patternPlayer = nil
|
||||
regulationPlayer = nil
|
||||
engine?.stop(completionHandler: nil)
|
||||
}
|
||||
|
||||
func playRegulationProtocol(kind: String) throws -> Double {
|
||||
guard supported else { return 0 }
|
||||
lock.lock()
|
||||
defer { lock.unlock() }
|
||||
|
||||
do {
|
||||
try engine?.start()
|
||||
try regulationPlayer?.stop(atTime: CHHapticTimeImmediate)
|
||||
|
||||
let configuration = try makeRegulationPattern(kind: kind)
|
||||
guard let engine else { return 0 }
|
||||
let player = try engine.makeAdvancedPlayer(with: configuration.pattern)
|
||||
player.loopEnabled = configuration.loops
|
||||
regulationPlayer = player
|
||||
try player.start(atTime: CHHapticTimeImmediate)
|
||||
return configuration.durationSeconds
|
||||
} catch {
|
||||
regulationPlayer = nil
|
||||
return 0
|
||||
}
|
||||
}
|
||||
|
||||
func stopRegulationProtocol() throws {
|
||||
lock.lock()
|
||||
defer { lock.unlock() }
|
||||
try? regulationPlayer?.stop(atTime: CHHapticTimeImmediate)
|
||||
regulationPlayer = nil
|
||||
}
|
||||
|
||||
func startWatchSession() throws {
|
||||
HaptikaWatchConnectivityService.shared.activate()
|
||||
}
|
||||
|
||||
func watchReachable() throws -> Bool {
|
||||
return HaptikaWatchConnectivityService.shared.state().reachable
|
||||
}
|
||||
|
||||
func latestHeartbeat() throws -> NativeCardiacMirrorHeartbeat? {
|
||||
guard let sample = HaptikaWatchConnectivityService.shared.latestHeartRate() else {
|
||||
return nil
|
||||
}
|
||||
return NativeCardiacMirrorHeartbeat(
|
||||
timestampSeconds: sample.timestampSeconds,
|
||||
bpm: sample.bpm
|
||||
)
|
||||
}
|
||||
|
||||
private func configureEngine() {
|
||||
do {
|
||||
let hapticEngine = try CHHapticEngine()
|
||||
hapticEngine.resetHandler = { [weak self] in
|
||||
self?.lock.lock()
|
||||
defer { self?.lock.unlock() }
|
||||
self?.patternPlayer = nil
|
||||
}
|
||||
hapticEngine.stoppedHandler = { [weak self] _ in
|
||||
self?.lock.lock()
|
||||
defer { self?.lock.unlock() }
|
||||
self?.patternPlayer = nil
|
||||
}
|
||||
engine = hapticEngine
|
||||
} catch {
|
||||
supported = false
|
||||
}
|
||||
}
|
||||
|
||||
private func makePatternPlayer() throws -> CHHapticPatternPlayer {
|
||||
guard let engine else {
|
||||
throw NSError(domain: "HaptikaCardiacMirror", code: 1)
|
||||
}
|
||||
let intensity = CHHapticEventParameter(
|
||||
parameterID: .hapticIntensity,
|
||||
value: 0.78
|
||||
)
|
||||
let sharpness = CHHapticEventParameter(
|
||||
parameterID: .hapticSharpness,
|
||||
value: 0.32
|
||||
)
|
||||
let event = CHHapticEvent(
|
||||
eventType: .hapticTransient,
|
||||
parameters: [intensity, sharpness],
|
||||
relativeTime: 0
|
||||
)
|
||||
let pattern = try CHHapticPattern(events: [event], parameters: [])
|
||||
return try engine.makePlayer(with: pattern)
|
||||
}
|
||||
|
||||
private func makeRegulationPattern(
|
||||
kind: String
|
||||
) throws -> (pattern: CHHapticPattern, durationSeconds: Double, loops: Bool) {
|
||||
switch kind {
|
||||
case "coherence55":
|
||||
return (try makeBreathingPattern(inhale: 5, exhale: 5), 10, true)
|
||||
case "relax46":
|
||||
return (try makeBreathingPattern(inhale: 4, exhale: 6), 10, true)
|
||||
case "box4444":
|
||||
return (try makePhasedBreathingPattern(inhale: 4, hold: 4, exhale: 4, emptyHold: 4), 16, true)
|
||||
case "breathing478":
|
||||
return (try makePhasedBreathingPattern(inhale: 4, hold: 7, exhale: 8, emptyHold: 0), 19, true)
|
||||
case "sosStress":
|
||||
return (try makeSettlingPattern(intensity: 0.25, duration: 60), 62.2, false)
|
||||
case "microAnchor":
|
||||
return (try makeSettlingPattern(intensity: 0.15, duration: 35), 37, false)
|
||||
default:
|
||||
throw NSError(domain: "HaptikaRegulation", code: 2)
|
||||
}
|
||||
}
|
||||
|
||||
private func makeBreathingPattern(
|
||||
inhale: TimeInterval,
|
||||
exhale: TimeInterval
|
||||
) throws -> CHHapticPattern {
|
||||
let inhaleEvent = CHHapticEvent(
|
||||
eventType: .hapticContinuous,
|
||||
parameters: [
|
||||
CHHapticEventParameter(parameterID: .hapticIntensity, value: 0.55),
|
||||
CHHapticEventParameter(parameterID: .hapticSharpness, value: 0.08)
|
||||
],
|
||||
relativeTime: 0,
|
||||
duration: inhale
|
||||
)
|
||||
let transition = CHHapticEvent(
|
||||
eventType: .hapticTransient,
|
||||
parameters: [
|
||||
CHHapticEventParameter(parameterID: .hapticIntensity, value: 0.7),
|
||||
CHHapticEventParameter(parameterID: .hapticSharpness, value: 0.65)
|
||||
],
|
||||
relativeTime: inhale
|
||||
)
|
||||
let exhaleEvent = CHHapticEvent(
|
||||
eventType: .hapticContinuous,
|
||||
parameters: [
|
||||
CHHapticEventParameter(parameterID: .hapticIntensity, value: 0.24),
|
||||
CHHapticEventParameter(parameterID: .hapticSharpness, value: 0.04)
|
||||
],
|
||||
relativeTime: inhale + 0.08,
|
||||
duration: exhale - 0.08
|
||||
)
|
||||
return try CHHapticPattern(
|
||||
events: [inhaleEvent, transition, exhaleEvent],
|
||||
parameters: []
|
||||
)
|
||||
}
|
||||
|
||||
private func makePhasedBreathingPattern(
|
||||
inhale: TimeInterval,
|
||||
hold: TimeInterval,
|
||||
exhale: TimeInterval,
|
||||
emptyHold: TimeInterval
|
||||
) throws -> CHHapticPattern {
|
||||
let inhaleEvent = CHHapticEvent(
|
||||
eventType: .hapticContinuous,
|
||||
parameters: [
|
||||
CHHapticEventParameter(parameterID: .hapticIntensity, value: 0.48),
|
||||
CHHapticEventParameter(parameterID: .hapticSharpness, value: 0.08)
|
||||
],
|
||||
relativeTime: 0,
|
||||
duration: inhale
|
||||
)
|
||||
let fullHoldMarker = CHHapticEvent(
|
||||
eventType: .hapticTransient,
|
||||
parameters: [
|
||||
CHHapticEventParameter(parameterID: .hapticIntensity, value: 0.42),
|
||||
CHHapticEventParameter(parameterID: .hapticSharpness, value: 0.52)
|
||||
],
|
||||
relativeTime: inhale
|
||||
)
|
||||
let exhaleEvent = CHHapticEvent(
|
||||
eventType: .hapticContinuous,
|
||||
parameters: [
|
||||
CHHapticEventParameter(parameterID: .hapticIntensity, value: 0.20),
|
||||
CHHapticEventParameter(parameterID: .hapticSharpness, value: 0.03)
|
||||
],
|
||||
relativeTime: inhale + hold,
|
||||
duration: exhale
|
||||
)
|
||||
var events = [inhaleEvent, fullHoldMarker, exhaleEvent]
|
||||
if emptyHold > 0 {
|
||||
events.append(
|
||||
CHHapticEvent(
|
||||
eventType: .hapticTransient,
|
||||
parameters: [
|
||||
CHHapticEventParameter(parameterID: .hapticIntensity, value: 0.25),
|
||||
CHHapticEventParameter(parameterID: .hapticSharpness, value: 0.25)
|
||||
],
|
||||
relativeTime: inhale + hold + exhale + emptyHold - 0.02
|
||||
)
|
||||
)
|
||||
}
|
||||
return try CHHapticPattern(events: events, parameters: [])
|
||||
}
|
||||
|
||||
private func makeSettlingPattern(
|
||||
intensity: Float,
|
||||
duration: TimeInterval
|
||||
) throws -> CHHapticPattern {
|
||||
var events: [CHHapticEvent] = []
|
||||
for index in 0..<3 {
|
||||
events.append(
|
||||
CHHapticEvent(
|
||||
eventType: .hapticTransient,
|
||||
parameters: [
|
||||
CHHapticEventParameter(parameterID: .hapticIntensity, value: 0.62),
|
||||
CHHapticEventParameter(parameterID: .hapticSharpness, value: 0.42)
|
||||
],
|
||||
relativeTime: TimeInterval(index) * 0.6
|
||||
)
|
||||
)
|
||||
}
|
||||
events.append(
|
||||
CHHapticEvent(
|
||||
eventType: .hapticContinuous,
|
||||
parameters: [
|
||||
CHHapticEventParameter(parameterID: .hapticIntensity, value: intensity),
|
||||
CHHapticEventParameter(parameterID: .hapticSharpness, value: 0.04)
|
||||
],
|
||||
relativeTime: 2,
|
||||
duration: duration
|
||||
)
|
||||
)
|
||||
return try CHHapticPattern(events: events, parameters: [])
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user