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: []) } }