304 lines
9.2 KiB
Swift
304 lines
9.2 KiB
Swift
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: [])
|
|
}
|
|
}
|