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." } } }