Files
haptika/packages/react-native-haptika-ppg/ios/Haptics/HybridHaptikaCardiacMirror.swift
T
2026-07-31 16:20:10 +02:00

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