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

888 lines
32 KiB
Swift
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import CoreMedia
import Foundation
import NitroModules
import VisionCamera
/// Nitro entry point for camera PPG processing.
///
/// A `HybridFrameSpec` and its `CMSampleBuffer` exist only as local values during
/// `processFrame(frame:)`. Persistent state contains compact numeric metrics only.
final class HybridHaptikaPPGFrameProcessor: HybridHaptikaPPGFrameProcessorSpec {
private enum SessionPhase {
case idle
case active
case thermalStopped
case finishing
}
private struct MeasurementConfiguration: Sendable {
let sessionPurpose: PPGSessionPurpose
let targetSampleRate: Double
let minimumFrequencyHz: Double
let maximumFrequencyHz: Double
let stabilizationSeconds: Double
let measurementSeconds: Double
let emitEveryFrames: Int
let minimumContactConfidence: Double
let maximumInterpolationGapSeconds: Double
let longGapLimitSeconds: Double
let maximumBufferedSampleCount: Int
init(native: NativePPGConfiguration) throws {
guard native.targetSampleRate.isFinite,
native.targetSampleRate >= 20,
native.targetSampleRate <= 120
else {
throw HaptikaPPGFrameProcessorError.invalidConfiguration(
"targetSampleRate must be between 20 and 120 Hz"
)
}
guard native.minimumFrequencyHz.isFinite,
native.maximumFrequencyHz.isFinite,
native.minimumFrequencyHz > 0,
native.maximumFrequencyHz > native.minimumFrequencyHz,
native.maximumFrequencyHz < native.targetSampleRate / 2
else {
throw HaptikaPPGFrameProcessorError.invalidConfiguration(
"the physiological band must be ordered and below Nyquist"
)
}
guard native.stabilizationSeconds.isFinite,
native.stabilizationSeconds >= 0,
native.stabilizationSeconds <= 30
else {
throw HaptikaPPGFrameProcessorError.invalidConfiguration(
"stabilizationSeconds must be between 0 and 30"
)
}
let maximumDuration = PPGCoherencePolicy.maximumDurationSeconds(
isCoherence: native.sessionPurpose == .coherence
)
guard native.measurementSeconds.isFinite,
native.measurementSeconds >= 20,
native.measurementSeconds <= maximumDuration
else {
throw HaptikaPPGFrameProcessorError.invalidConfiguration(
"measurementSeconds exceeds the limit for this session purpose"
)
}
guard native.emitEveryFrames.isFinite,
native.emitEveryFrames >= 1,
native.emitEveryFrames.rounded() == native.emitEveryFrames,
native.emitEveryFrames <= 10_000
else {
throw HaptikaPPGFrameProcessorError.invalidConfiguration(
"emitEveryFrames must be a positive integer"
)
}
guard native.minimumContactConfidence.isFinite,
(0...1).contains(native.minimumContactConfidence)
else {
throw HaptikaPPGFrameProcessorError.invalidConfiguration(
"minimumContactConfidence must be between 0 and 1"
)
}
sessionPurpose = native.sessionPurpose
targetSampleRate = native.targetSampleRate
minimumFrequencyHz = native.minimumFrequencyHz
maximumFrequencyHz = native.maximumFrequencyHz
stabilizationSeconds = native.stabilizationSeconds
measurementSeconds = native.measurementSeconds
emitEveryFrames = Int(native.emitEveryFrames)
minimumContactConfidence = native.minimumContactConfidence
maximumInterpolationGapSeconds = 0.25
longGapLimitSeconds = 0.80
// This bound permits a full measurement plus recovery time after contact interruptions.
let maximumBufferedDuration = sessionPurpose == .coherence
? PPGCoherencePolicy.coherenceBufferDurationSeconds
: stabilizationSeconds + measurementSeconds + 60
maximumBufferedSampleCount = max(
Int((targetSampleRate * maximumBufferedDuration).rounded(.up)),
256
)
}
var coreConfiguration: PPGProcessingConfiguration {
PPGProcessingConfiguration(
targetSampleRateHz: targetSampleRate,
minimumFrequencyHz: minimumFrequencyHz,
maximumFrequencyHz: maximumFrequencyHz,
// The UI may finish as soon as 50 clean beats have been collected.
// Keep a 45 s floor for HRV while allowing completion before the 90 s
// safety limit on a clean signal.
minimumAnalysisDurationSeconds: min(max(30, measurementSeconds * 0.75), 45),
maximumInterpolationGapSeconds: maximumInterpolationGapSeconds,
longGapLimitSeconds: longGapLimitSeconds,
minimumContactConfidence: minimumContactConfidence,
maximumSaturationRatio: 0.95,
maximumMotionArtifactScore: 0.95,
minimumPeakProminence: 0.35,
minimumPeakAmplitude: 0.10,
refractoryPeriodSeconds: 0.25,
maximumMethodDisagreementBPM: 8,
minimumQualityScore: 0.55,
minimumIntervalsForPRV: 35,
maximumRejectedIntervalRatioForPRV: 0.45
)
}
}
private struct SessionSnapshot: Sendable {
let configuration: MeasurementConfiguration
let samples: [PPGFrameSample]
let startedAtMonotonicSeconds: Double
let endedAtMonotonicSeconds: Double
let processedFrameCount: Int
let droppedFrameCount: Int
let acquisitionDroppedFrameCount: Int
let usableDurationSeconds: Double
let maximumAcquisitionGapSeconds: Double
let bufferLimitReached: Bool
let terminalReason: String?
}
private let stateLock = NSLock()
private let metricsExtractor = PPGFrameMetricsExtractor()
private let thermalMonitor = PPGThermalMonitor()
private var phase: SessionPhase = .idle
private var configuration: MeasurementConfiguration?
private var contactDetector: PPGFingerContactDetector?
private var terminalThermalState: ThermalState?
private var numericSamples: [PPGFrameSample] = []
private var startedAtMonotonicSeconds = 0.0
private var terminalReason: String?
private var bufferLimitReached = false
private var processedFrameCount = 0
private var droppedFrameCount = 0
private var acquisitionDroppedFrameCount = 0
private var lastObservedTimestamp: Double?
private var recentFrameIntervals: [Double] = []
private var maximumAcquisitionGapSeconds = 0.0
private var validContactStartedAt: Double?
private var acquisitionStartedAt: Double?
private var lastUsableTimestamp: Double?
private var usableDurationSeconds = 0.0
private var lastMetrics: PPGFrameColorMetrics?
private var lastContactEvaluation: PPGContactEvaluation?
private var liveBpm: Double?
private var liveIpiMs: Double?
private var liveBeatSequence = 0
private var lastLiveAnalysisFrame = 0
private var liveBeatTracker = PPGLiveBeatTracker()
private var pendingLiveBeats: [NativeAcceptedBeat] = []
private var liveWindowQuality: Double?
private var totalCollectedSampleCount = 0
override init() {
recentFrameIntervals.reserveCapacity(90)
super.init()
}
func startMeasurement(configuration nativeConfiguration: NativePPGConfiguration) throws {
let parsedConfiguration = try MeasurementConfiguration(native: nativeConfiguration)
let thermalState = thermalMonitor.currentState()
guard !Self.isUnsafe(thermalState) else {
throw HaptikaPPGFrameProcessorError.unsafeThermalState(thermalState.stringValue)
}
stateLock.lock()
defer { stateLock.unlock() }
resetLocked()
configuration = parsedConfiguration
contactDetector = PPGFingerContactDetector(
minimumConfidence: parsedConfiguration.minimumContactConfidence
)
numericSamples.reserveCapacity(
min(
parsedConfiguration.maximumBufferedSampleCount,
Int((parsedConfiguration.targetSampleRate * parsedConfiguration.measurementSeconds).rounded(.up))
)
)
startedAtMonotonicSeconds = ProcessInfo.processInfo.systemUptime
phase = .active
}
func processFrame(frame: any HybridFrameSpec) throws -> PPGFrameUpdate? {
stateLock.lock()
defer { stateLock.unlock() }
let thermalState = thermalMonitor.currentState()
if phase == .thermalStopped {
// Keep notifying the worklet until React Native acknowledges the terminal state and tears
// the capture session down. A single callback could be lost while the JS runtime is busy.
return makeFrameUpdate(
timestampSeconds: Self.finiteTimestamp(frame.timestamp, fallback: lastObservedTimestamp),
thermalState: terminalThermalState ?? thermalState,
metrics: lastMetrics,
evaluation: lastContactEvaluation
)
}
guard phase == .active, let configuration, let contactDetector else {
// Camera teardown and Nitro calls can overlap by a frame; ignoring it is safe and avoids
// retaining an already-finished frame.
return nil
}
if Self.isUnsafe(thermalState) {
phase = .thermalStopped
terminalThermalState = thermalState
terminalReason = "thermal_\(thermalState.stringValue)"
processedFrameCount += 1
return makeFrameUpdate(
timestampSeconds: Self.finiteTimestamp(frame.timestamp, fallback: lastObservedTimestamp),
thermalState: thermalState,
metrics: lastMetrics,
evaluation: lastContactEvaluation
)
}
guard frame.isValid,
let nativeFrame = frame as? any NativeFrame,
let sampleBuffer = nativeFrame.sampleBuffer
else {
throw HaptikaPPGFrameProcessorError.frameUnavailable
}
// The sample buffer is intentionally scoped to this call. The extractor returns value-only
// metrics and never stores the CMSampleBuffer or CVPixelBuffer.
let metrics = try metricsExtractor.extract(from: sampleBuffer)
processedFrameCount += 1
let shouldEmit = processedFrameCount.isMultiple(of: configuration.emitEveryFrames)
guard recordFrameTimingLocked(
timestampSeconds: metrics.timestampSeconds,
configuration: configuration
) else {
if shouldEmit {
return makeFrameUpdate(
timestampSeconds: lastObservedTimestamp ?? metrics.timestampSeconds,
thermalState: thermalState,
metrics: lastMetrics,
evaluation: lastContactEvaluation
)
}
return nil
}
let evaluation = contactDetector.evaluate(metrics)
lastMetrics = metrics
lastContactEvaluation = evaluation
updateAcquisitionLocked(
metrics: metrics,
evaluation: evaluation,
configuration: configuration
)
updateLiveEstimateLocked(configuration: configuration)
guard shouldEmit else { return nil }
return makeFrameUpdate(
timestampSeconds: metrics.timestampSeconds,
thermalState: thermalState,
metrics: metrics,
evaluation: evaluation
)
}
func finishMeasurement() throws -> NativePPGProcessingResult {
let snapshot: SessionSnapshot
stateLock.lock()
switch phase {
case .active, .thermalStopped:
guard let configuration else {
stateLock.unlock()
throw HaptikaPPGFrameProcessorError.measurementNotStarted
}
phase = .finishing
snapshot = SessionSnapshot(
configuration: configuration,
samples: numericSamples,
startedAtMonotonicSeconds: startedAtMonotonicSeconds,
endedAtMonotonicSeconds: ProcessInfo.processInfo.systemUptime,
processedFrameCount: processedFrameCount,
droppedFrameCount: droppedFrameCount,
acquisitionDroppedFrameCount: acquisitionDroppedFrameCount,
usableDurationSeconds: usableDurationSeconds,
maximumAcquisitionGapSeconds: maximumAcquisitionGapSeconds,
bufferLimitReached: bufferLimitReached,
terminalReason: terminalReason
)
stateLock.unlock()
case .idle, .finishing:
stateLock.unlock()
throw HaptikaPPGFrameProcessorError.measurementNotStarted
}
defer { resetAfterFinishing() }
if let terminalReason = snapshot.terminalReason {
return makeInvalidResult(
snapshot: snapshot,
reason: terminalReason,
issue: "durationInsufficient"
)
}
if snapshot.bufferLimitReached {
return makeInvalidResult(
snapshot: snapshot,
reason: "The numeric measurement buffer reached its safety limit.",
issue: "nonFiniteProcessingResult"
)
}
do {
let processor = try PPGSignalProcessor(
configuration: snapshot.configuration.coreConfiguration
)
let analysis = try processor.analyze(samples: snapshot.samples)
return makeNativeResult(analysis: analysis, snapshot: snapshot)
} catch {
let mapped = Self.mapProcessingError(error)
return makeInvalidResult(
snapshot: snapshot,
reason: mapped.reason,
issue: mapped.issue
)
}
}
func cancelMeasurement() throws {
stateLock.lock()
resetLocked()
stateLock.unlock()
}
func getThermalState() throws -> ThermalState {
thermalMonitor.currentState()
}
private func recordFrameTimingLocked(
timestampSeconds: Double,
configuration: MeasurementConfiguration
) -> Bool {
guard timestampSeconds.isFinite, timestampSeconds >= 0 else {
droppedFrameCount += 1
if acquisitionStartedAt != nil { acquisitionDroppedFrameCount += 1 }
return false
}
guard let previousTimestamp = lastObservedTimestamp else {
lastObservedTimestamp = timestampSeconds
return true
}
let interval = timestampSeconds - previousTimestamp
guard interval > 0 else {
droppedFrameCount += 1
if acquisitionStartedAt != nil { acquisitionDroppedFrameCount += 1 }
return false
}
if acquisitionStartedAt != nil {
maximumAcquisitionGapSeconds = max(maximumAcquisitionGapSeconds, interval)
}
if recentFrameIntervals.count >= 5 {
let expectedInterval = SignalStatistics.median(recentFrameIntervals)
if expectedInterval > 0, interval > expectedInterval * 1.5 {
let estimatedMissing = min(
max((interval / expectedInterval).rounded() - 1, 0),
100_000
)
let missingCount = Int(estimatedMissing)
droppedFrameCount += missingCount
if acquisitionStartedAt != nil { acquisitionDroppedFrameCount += missingCount }
}
}
if interval <= max(configuration.maximumInterpolationGapSeconds, 0.25) {
recentFrameIntervals.append(interval)
if recentFrameIntervals.count > 90 {
recentFrameIntervals.removeFirst(recentFrameIntervals.count - 90)
}
}
lastObservedTimestamp = timestampSeconds
return true
}
private func updateAcquisitionLocked(
metrics: PPGFrameColorMetrics,
evaluation: PPGContactEvaluation,
configuration: MeasurementConfiguration
) {
// Acquisition is intentionally clock-based rather than contact-gated. A
// contact detector is useful feedback, but making it a hard gate can leave
// a complete minute with zero samples on cameras whose color response is
// different. The signal processor still rejects low-SNR, saturated and
// motion-corrupted portions at the end.
// Store compact numeric samples from the first camera frame. The final
// processor decides which portions are usable. Do not derive a camera
// frame count from the 100 Hz resampling target, and do not throw away a
// good signal merely because the contact heuristic is conservative.
if acquisitionStartedAt == nil {
acquisitionStartedAt = metrics.timestampSeconds
lastUsableTimestamp = nil
}
guard acquisitionStartedAt != nil else { return }
let sample = PPGFrameSample(
timestampSeconds: metrics.timestampSeconds,
red: metrics.redMean,
green: metrics.greenMean,
blue: metrics.blueMean,
luminance: metrics.luminanceMean,
contactConfidence: evaluation.confidence,
saturationRatio: metrics.saturationRatio,
motionArtifactScore: evaluation.motionArtifactScore
)
totalCollectedSampleCount += 1
if configuration.sessionPurpose == .coherence {
numericSamples.append(sample)
if numericSamples.count > configuration.maximumBufferedSampleCount {
numericSamples.removeFirst(numericSamples.count - configuration.maximumBufferedSampleCount)
}
} else if numericSamples.count < configuration.maximumBufferedSampleCount {
numericSamples.append(sample)
} else {
bufferLimitReached = true
}
let opticallyUsable = evaluation.confidence >= 0.35
&& metrics.saturationRatio <= 0.95
&& evaluation.motionArtifactScore <= 0.90
if opticallyUsable {
if let previousTimestamp = lastUsableTimestamp {
let interval = metrics.timestampSeconds - previousTimestamp
if interval > 0, interval <= configuration.maximumInterpolationGapSeconds {
usableDurationSeconds += interval
}
}
lastUsableTimestamp = metrics.timestampSeconds
} else {
// Never bridge quality time across a finger removal or a large movement.
lastUsableTimestamp = nil
}
}
private func makeFrameUpdate(
timestampSeconds: Double,
thermalState: ThermalState,
metrics: PPGFrameColorMetrics?,
evaluation: PPGContactEvaluation?
) -> PPGFrameUpdate {
let signalLevel = min(max((evaluation?.pulsatileAmplitude ?? 0) / 0.03, 0), 1)
let waveformValue: Double
if let metrics {
waveformValue = metrics.greenMean / max(metrics.redMean + metrics.greenMean + metrics.blueMean, 1)
} else {
waveformValue = 0
}
return PPGFrameUpdate(
timestampSeconds: timestampSeconds,
contactState: evaluation?.state ?? .absent,
contactConfidence: min(max(evaluation?.confidence ?? 0, 0), 1),
signalLevel: signalLevel,
saturationRatio: min(max(metrics?.saturationRatio ?? 0, 0), 1),
motionArtifactScore: min(max(evaluation?.motionArtifactScore ?? 0, 0), 1),
collectedSampleCount: Double(totalCollectedSampleCount),
usableDurationSeconds: usableDurationSeconds,
droppedFrameCount: Double(droppedFrameCount),
thermalState: thermalState,
waveformValue: waveformValue,
liveBpm: liveBpm,
liveIpiMs: liveIpiMs,
beatSequence: Double(liveBeatSequence),
acceptedBeats: takePendingLiveBeatsLocked(),
liveWindowQuality: liveWindowQuality
)
}
private func takePendingLiveBeatsLocked() -> [NativeAcceptedBeat] {
let beats = pendingLiveBeats
pendingLiveBeats.removeAll(keepingCapacity: true)
return beats
}
private func updateLiveEstimateLocked(configuration: MeasurementConfiguration) {
// Camera input is constrained to 30 fps. The final 100 Hz configuration
// is a resampling timebase and must not delay live BPM warm-up to 23 s.
let liveInputSampleRate = 30.0
guard numericSamples.count >= Int(liveInputSampleRate * 7),
processedFrameCount - lastLiveAnalysisFrame >= 15
else { return }
lastLiveAnalysisFrame = processedFrameCount
let maximumWindowCount = Int(liveInputSampleRate * 15)
let window = Array(numericSamples.suffix(maximumWindowCount))
let liveConfiguration = PPGProcessingConfiguration(
// A lightweight 30 Hz pass is enough for the live display. The final
// analysis still uses the regular 100 Hz timebase.
targetSampleRateHz: min(configuration.targetSampleRate, 30),
minimumFrequencyHz: configuration.minimumFrequencyHz,
maximumFrequencyHz: configuration.maximumFrequencyHz,
minimumAnalysisDurationSeconds: 6,
maximumInterpolationGapSeconds: configuration.maximumInterpolationGapSeconds,
longGapLimitSeconds: configuration.longGapLimitSeconds,
minimumContactConfidence: min(configuration.minimumContactConfidence, 0.45),
maximumSaturationRatio: 0.55,
maximumMotionArtifactScore: 0.65,
minimumPeakProminence: 0.28,
minimumPeakAmplitude: 0.08,
refractoryPeriodSeconds: 0.30,
maximumMethodDisagreementBPM: 12,
minimumQualityScore: 0.35,
minimumIntervalsForPRV: 4,
maximumRejectedIntervalRatioForPRV: 0.45,
filterTapCount: 31
)
guard let analysis = try? PPGSignalProcessor(configuration: liveConfiguration)
.analyze(samples: window)
else { return }
liveWindowQuality = analysis.quality.score
if let bpm = analysis.bpm.bpm ?? analysis.bpm.temporalBpm,
bpm.isFinite, (35...220).contains(bpm) {
liveBpm = bpm
}
let acceptedIntervals = analysis.intervals.filter(\.isAccepted)
if let interval = acceptedIntervals.last,
interval.durationMilliseconds.isFinite {
liveIpiMs = interval.durationMilliseconds
}
// The live analysis uses an overlapping window. Count every newly seen
// accepted interval instead of incrementing only once per analysis pass;
// otherwise a 90 BPM pulse sampled every 0.5 s loses real beats.
let newlyAccepted = liveBeatTracker.ingest(acceptedIntervals)
for beat in newlyAccepted {
liveBeatSequence = beat.sequence
pendingLiveBeats.append(
NativeAcceptedBeat(
sequence: Double(beat.sequence),
timestampSeconds: beat.timestampSeconds,
intervalMs: beat.intervalMs,
confidence: beat.confidence
)
)
}
}
private func makeNativeResult(
analysis: PPGAnalysisResult,
snapshot: SessionSnapshot
) -> NativePPGProcessingResult {
var issues = analysis.quality.issues.map(Self.nativeIssue)
if analysis.bpm.rejectedPeakCount > max(3, analysis.bpm.validPeakCount / 4) {
Self.appendUnique("tooManyRejectedPeaks", to: &issues)
}
let observedDroppedRatio = snapshot.samples.isEmpty
? 0
: Double(snapshot.acquisitionDroppedFrameCount)
/ Double(snapshot.samples.count + snapshot.acquisitionDroppedFrameCount)
let droppedFrameRatio = min(
max(max(analysis.quality.droppedFrameRatio, observedDroppedRatio), 0),
1
)
if droppedFrameRatio > 0.12 {
Self.appendUnique("droppedFrames", to: &issues)
}
let timestampJitterRatio = Self.timestampJitterRatio(analysis.timebase)
let longestGapSeconds = max(
analysis.timebase.maximumGapSeconds,
snapshot.maximumAcquisitionGapSeconds
)
let isValid = analysis.status == .valid
let invalidReason = isValid ? nil : Self.invalidReason(for: issues)
let resampledSampleCount = max(
Int((analysis.timebase.durationSeconds * snapshot.configuration.targetSampleRate).rounded(.down)) + 1,
0
)
return NativePPGProcessingResult(
valid: isValid,
invalidReason: invalidReason,
bpm: NativeBPMEstimate(
bpm: analysis.bpm.bpm,
temporalBpm: analysis.bpm.temporalBpm,
frequencyBpm: analysis.bpm.frequencyBpm,
methodAgreementBpm: analysis.bpm.methodAgreementBpm,
validPeakCount: Double(analysis.bpm.validPeakCount),
rejectedPeakCount: Double(analysis.bpm.rejectedPeakCount),
confidence: analysis.bpm.confidence,
selectedChannel: analysis.bpm.selectedChannel.rawValue
),
prv: NativePRVMetrics(
estimatedRmssdMs: analysis.prv.estimatedRmssdMs,
estimatedSdnnMs: analysis.prv.estimatedSdnnMs,
meanIpiMs: analysis.prv.meanIpiMs,
medianIpiMs: analysis.prv.medianIpiMs,
pnn50Percent: analysis.prv.pnn50Percent,
rangeIpiMs: analysis.prv.rangeIpiMs,
modeIpiMs: analysis.prv.modeIpiMs,
amo50Percent: analysis.prv.amo50Percent,
coefficientOfVariation: analysis.prv.coefficientOfVariation,
validIntervalCount: Double(analysis.prv.validIntervalCount),
rejectedIntervalCount: Double(analysis.prv.rejectedIntervalCount),
rejectedIntervalRatio: analysis.prv.rejectedIntervalRatio,
reliable: analysis.prv.reliable
),
quality: NativeSignalQualityResult(
score: analysis.quality.score,
level: analysis.quality.level.rawValue,
usableDurationSeconds: analysis.quality.usableDurationSeconds,
droppedFrameRatio: droppedFrameRatio,
saturationRatio: analysis.quality.saturationRatio,
rejectedIntervalRatio: analysis.quality.rejectedIntervalRatio,
signalToNoiseRatio: analysis.quality.signalToNoiseRatio,
motionArtifactRatio: analysis.quality.motionArtifactRatio,
methodAgreementScore: analysis.quality.methodAgreementScore,
timestampJitterRatio: timestampJitterRatio,
longestGapSeconds: longestGapSeconds,
issues: issues
),
startedAtMonotonicSeconds: snapshot.startedAtMonotonicSeconds,
endedAtMonotonicSeconds: snapshot.endedAtMonotonicSeconds,
rawSampleCount: Double(snapshot.samples.count),
resampledSampleCount: Double(resampledSampleCount)
)
}
private func makeInvalidResult(
snapshot: SessionSnapshot,
reason: String,
issue: String
) -> NativePPGProcessingResult {
let intervals = zip(snapshot.samples, snapshot.samples.dropFirst()).compactMap { pair -> Double? in
let interval = pair.1.timestampSeconds - pair.0.timestampSeconds
return interval.isFinite && interval > 0 ? interval : nil
}
let estimatedRate = intervals.isEmpty ? 0 : 1 / max(SignalStatistics.median(intervals), 1e-9)
let intervalMAD = intervals.isEmpty ? 0 : SignalStatistics.mad(intervals)
let jitterRatio = estimatedRate > 0 ? intervalMAD * estimatedRate : 0
let saturation = snapshot.samples.isEmpty
? 0
: SignalStatistics.mean(snapshot.samples.map(\.saturationRatio))
let motion = snapshot.samples.isEmpty
? 0
: SignalStatistics.mean(snapshot.samples.map(\.motionArtifactScore))
let droppedRatio = snapshot.samples.isEmpty
? 0
: Double(snapshot.acquisitionDroppedFrameCount)
/ Double(snapshot.samples.count + snapshot.acquisitionDroppedFrameCount)
return NativePPGProcessingResult(
valid: false,
invalidReason: reason,
bpm: NativeBPMEstimate(
bpm: nil,
temporalBpm: nil,
frequencyBpm: nil,
methodAgreementBpm: nil,
validPeakCount: 0,
rejectedPeakCount: 0,
confidence: 0,
selectedChannel: "none"
),
prv: NativePRVMetrics(
estimatedRmssdMs: nil,
estimatedSdnnMs: nil,
meanIpiMs: nil,
medianIpiMs: nil,
pnn50Percent: nil,
rangeIpiMs: nil,
modeIpiMs: nil,
amo50Percent: nil,
coefficientOfVariation: nil,
validIntervalCount: 0,
rejectedIntervalCount: 0,
rejectedIntervalRatio: snapshot.samples.isEmpty ? 1 : 0,
reliable: false
),
quality: NativeSignalQualityResult(
score: 0,
level: "invalid",
usableDurationSeconds: snapshot.usableDurationSeconds,
droppedFrameRatio: min(max(droppedRatio, 0), 1),
saturationRatio: min(max(saturation, 0), 1),
rejectedIntervalRatio: snapshot.samples.isEmpty ? 1 : 0,
signalToNoiseRatio: nil,
motionArtifactRatio: min(max(motion, 0), 1),
methodAgreementScore: 0,
timestampJitterRatio: max(jitterRatio, 0),
longestGapSeconds: snapshot.maximumAcquisitionGapSeconds,
issues: [issue]
),
startedAtMonotonicSeconds: snapshot.startedAtMonotonicSeconds,
endedAtMonotonicSeconds: snapshot.endedAtMonotonicSeconds,
rawSampleCount: Double(snapshot.samples.count),
resampledSampleCount: 0
)
}
private func resetAfterFinishing() {
stateLock.lock()
resetLocked()
stateLock.unlock()
}
private func resetLocked() {
contactDetector?.reset()
contactDetector = nil
configuration = nil
terminalThermalState = nil
numericSamples.removeAll(keepingCapacity: false)
phase = .idle
startedAtMonotonicSeconds = 0
terminalReason = nil
bufferLimitReached = false
processedFrameCount = 0
droppedFrameCount = 0
acquisitionDroppedFrameCount = 0
lastObservedTimestamp = nil
recentFrameIntervals.removeAll(keepingCapacity: true)
maximumAcquisitionGapSeconds = 0
validContactStartedAt = nil
acquisitionStartedAt = nil
lastUsableTimestamp = nil
usableDurationSeconds = 0
lastMetrics = nil
lastContactEvaluation = nil
liveBpm = nil
liveIpiMs = nil
liveBeatSequence = 0
liveBeatTracker.reset()
lastLiveAnalysisFrame = 0
pendingLiveBeats.removeAll(keepingCapacity: false)
liveWindowQuality = nil
totalCollectedSampleCount = 0
}
private static func isUnsafe(_ state: ThermalState) -> Bool {
PPGThermalSafetyPolicy.shouldStopMeasurement(forStateName: state.stringValue)
}
private static func finiteTimestamp(_ timestamp: Double, fallback: Double?) -> Double {
if timestamp.isFinite, timestamp >= 0 { return timestamp }
return fallback ?? 0
}
private static func timestampJitterRatio(_ diagnostics: TimebaseDiagnostics) -> Double {
guard diagnostics.estimatedSampleRateHz > 0 else { return 0 }
return max(diagnostics.frameIntervalMADSeconds * diagnostics.estimatedSampleRateHz, 0)
}
private static func nativeIssue(_ issue: SignalQualityIssue) -> String {
switch issue {
case .insufficientDuration:
return "durationInsufficient"
case .invalidTimestamps, .irregularTimestamps:
return "timestampJitter"
case .longInterruption:
return "longGap"
case .tooManyDroppedFrames:
return "droppedFrames"
case .poorFingerContact:
return "contactInsufficient"
case .excessiveSaturation:
return "saturation"
case .motionArtifacts:
return "motionArtifacts"
case .lowPulsatileAmplitude:
return "lowPulsatileAmplitude"
case .lowSignalToNoise:
return "lowSignalToNoiseRatio"
case .tooFewPeaks:
return "tooFewPeaks"
case .inconsistentBPMMethods:
return "methodDisagreement"
case .tooManyRejectedIntervals:
return "tooManyRejectedIntervals"
case .unstableChannel:
return "channelInstability"
case .nonFiniteInput:
return "nonFiniteProcessingResult"
}
}
private static func appendUnique(_ issue: String, to issues: inout [String]) {
if !issues.contains(issue) { issues.append(issue) }
}
private static func invalidReason(for issues: [String]) -> String {
guard let issue = issues.first else {
return "La qualité du signal est insuffisante."
}
switch issue {
case "contactInsufficient", "contactInterrupted":
return "Le contact du doigt a été insuffisant ou interrompu."
case "durationInsufficient":
return "La durée de signal exploitable est insuffisante."
case "droppedFrames", "timestampJitter", "longGap":
return "La capture comporte trop d’interruptions ou d’images manquantes."
case "saturation":
return "Le signal caméra est trop saturé."
case "motionArtifacts":
return "Trop de mouvements ont perturbé la mesure."
case "lowPulsatileAmplitude", "lowSignalToNoiseRatio":
return "Le signal pulsatile est trop faible ou trop bruité."
case "tooFewPeaks", "tooManyRejectedPeaks", "tooManyRejectedIntervals":
return "Trop peu de pulsations fiables ont été détectées."
case "methodDisagreement":
return "Les estimations temporelle et fréquentielle ne concordent pas."
case "channelInstability":
return "Aucun canal couleur n’est suffisamment stable."
default:
return "Le traitement numérique du signal a produit un résultat invalide."
}
}
private static func mapProcessingError(_ error: Error) -> (reason: String, issue: String) {
guard let signalError = error as? PPGSignalError else {
return ("Le traitement numérique du signal a échoué.", "nonFiniteProcessingResult")
}
switch signalError {
case .insufficientSamples, .insufficientDuration:
return ("La durée de signal exploitable est insuffisante.", "durationInsufficient")
case .invalidTimestamps:
return ("Les horodatages caméra sont invalides.", "timestampJitter")
case .noUsableChannel:
return ("Aucun canal couleur n’est exploitable.", "channelInstability")
case .invalidConfiguration:
return ("La configuration du traitement du signal est invalide.", "nonFiniteProcessingResult")
}
}
}
private enum HaptikaPPGFrameProcessorError: LocalizedError {
case invalidConfiguration(String)
case unsafeThermalState(String)
case frameUnavailable
case measurementNotStarted
var errorDescription: String? {
switch self {
case .invalidConfiguration(let reason):
return "Invalid PPG configuration: \(reason)."
case .unsafeThermalState(let state):
return "PPG measurement refused because the iPhone thermal state is \(state)."
case .frameUnavailable:
return "The VisionCamera frame is invalid or does not expose a CMSampleBuffer."
case .measurementNotStarted:
return "No PPG measurement is currently active."
}
}
}