/** * S4 - the capture gate for the encounter ambient scribe. * * A PURE reducer. It takes audio level samples, encounter status changes and pause commands, and decides * whether audio should be written. It deliberately knows nothing about Web Audio, MediaRecorder or React - * the adapter that owns the ring buffer and the recorder calls into this and does what it says. That split * exists so the safety-critical decisions below are unit-testable without a microphone. * * It exists because Transcribe bills per MINUTE OF AUDIO DURATION. Not sending silence is the single * largest cost lever in the feature - but three of the four rules here are about safety, not money, and * they are the reason this is a reviewed module rather than an `if (volume > 5)`. * * THE FOUR RULES * * 1. PRE-ROLL. Cutting on silence and resuming on sound loses the first fraction of the utterance that * triggered the resume - which is how "No, I'm not allergic to penicillin" becomes "...allergic to * penicillin". On resume the gate reports an effective start EARLIER than the trigger, and the adapter * flushes that much of its ring buffer. * * 2. PRE-ROLL MAY ONLY REACH BACK ACROSS SILENCE - NEVER ACROSS A DELIBERATE PAUSE. If a patient says * "stop recording for a minute", the audio after that instant must never be written, and a resume * minutes later must not let the pre-roll window quietly scoop up the end of what they asked you not to * record. The same applies to a status pause: an encounter put on hold may be the practitioner stepping * out to speak to somebody else. `preRollFloorMs` is the mechanism. * * 3. THE THRESHOLD IS CALIBRATED TO THE ROOM, AND CLAMPED. A patient disclosing abuse, substance use or * suicidal ideation speaks QUIETLY - the clinically heaviest content is the softest audio in the room. * A fixed dB threshold drops it, and so does a threshold calibrated in a noisy clinic. The floor is * measured from ambient, and the resulting threshold is capped so a loud room can never raise the bar * above ordinary speech. Every ambiguous case resolves toward KEEPING audio: wasted minutes cost cents, * a dropped disclosure is unrecoverable. * * 4. TWO PAUSE STATES. `auto-paused` (silence, or the encounter is not in progress) resumes on its own. * `hard-paused` (a human asked) resumes ONLY on an explicit human command - silence detection cannot * lift it, and neither can the encounter status. Conflating the two is how you record somebody who * asked you not to. * * AND THE BOOKKEEPING THAT MAKES IT AUDITABLE * * Removing silence means the transcript's timestamps describe the COMPRESSED timeline, not wall clock. * `segments` is the translation table, and `toOriginalMs` reads it. Without that, the evidence citations * on a generated note ("show me where this sentence came from") point at the wrong moment of the visit. * It is cheap here and expensive to retrofit, which is why it is in the first version. */ /** FHIR Encounter.status value that permits capture. Anything else pauses. */ export const CAPTURING_ENCOUNTER_STATUS = 'in-progress'; export type CapturePhase = 'idle' | 'capturing' | 'auto-paused' | 'hard-paused' | 'stopped'; /** Why the gate is currently auto-paused. Kept so the UI can say which, and so resume rules can differ. */ export type AutoPauseCause = 'silence' | 'encounter-status'; export interface CaptureSegment { /** Wall-clock window of the encounter this run covers. */ originalStartMs: number; originalEndMs: number; /** Where the same audio lands in the silence-stripped recording handed to Transcribe. */ compressedStartMs: number; compressedEndMs: number; } export interface CaptureGateOptions { /** How far back a resume may reach to recover the onset of the triggering utterance. */ preRollMs: number; /** Sustained silence required before auto-pausing. */ silenceHoldMs: number; /** Kept after the last speech so a word ending is not clipped. */ tailMs: number; /** Ambient sampling window. Capture runs throughout - calibration never costs audio. */ calibrationMs: number; /** Speech threshold as a multiple of the measured ambient floor. */ thresholdMargin: number; /** * Hard ceiling on the computed threshold. This is the quiet-speech protection: calibrating in a * noisy room yields a high floor, and without this cap the bar would rise above ordinary speech and * silently drop the softest - and most clinically important - audio in the room. */ thresholdCeiling: number; /** * FLOOR ON THE COMPUTED THRESHOLD, AND IT IS THE MUTED-MICROPHONE PROTECTION. * * The threshold is derived from the measured ambient level, so a room that measures **exactly zero** * - a muted mic, a disconnected input, a browser handing back a silent track - produces a threshold * of zero. Every subsequent sample is then `>= 0`, i.e. SPEECH, the gate never closes, and the * recorder captures the entire visit continuously. That is the precise inverse of what this module * exists to do, and it costs money on every minute of it. * * It also fails in the direction that hides itself: the recorder looks like it is working * perfectly. Found by the S4 recorder tests, where a silent fake analyser opened a run. */ thresholdFloor: number; } export const DEFAULT_CAPTURE_GATE_OPTIONS: CaptureGateOptions = { preRollMs: 1_000, silenceHoldMs: 2_500, tailMs: 500, calibrationMs: 1_500, thresholdMargin: 2.5, thresholdCeiling: 0.05, // Well below quiet speech (~0.02 RMS) and well above digital silence, so it only ever engages when // the measured ambient is implausibly low. thresholdFloor: 0.005, }; export interface CaptureState { phase: CapturePhase; /** Cause of the current auto-pause; null unless phase is 'auto-paused'. */ autoPauseCause: AutoPauseCause | null; /** Measured ambient level. null until calibration completes. */ noiseFloor: number | null; /** Level at or above which the room counts as speech. null until calibrated - see isCalibrated. */ speechThreshold: number | null; /** Ambient samples gathered so far; discarded once the threshold is set. */ calibrationSamples: readonly number[]; /** When calibration began, on the original clock. */ calibrationStartedMs: number | null; /** Start of the open capture run, original clock. null when not capturing. */ runStartMs: number | null; /** Captured duration of all CLOSED runs - the offset of the compressed clock. */ compressedElapsedMs: number; /** Closed runs, oldest first. THE SEGMENT MAP. */ segments: readonly CaptureSegment[]; /** Earliest original time a pre-roll may reach back to. Never crosses a deliberate pause. */ preRollFloorMs: number; /** Most recent sample at or above the threshold. */ lastSpeechMs: number | null; /** Last reported FHIR Encounter.status. */ encounterStatus: string | null; } export type CaptureEvent = | { type: 'ENCOUNTER_STATUS'; status: string; atMs: number } | { type: 'LEVEL'; amplitude: number; atMs: number } | { type: 'HARD_PAUSE'; atMs: number } | { type: 'HARD_RESUME'; atMs: number } | { type: 'STOP'; atMs: number }; export const INITIAL_CAPTURE_STATE: CaptureState = { phase: 'idle', autoPauseCause: null, noiseFloor: null, speechThreshold: null, calibrationSamples: [], calibrationStartedMs: null, runStartMs: null, compressedElapsedMs: 0, segments: [], preRollFloorMs: 0, lastSpeechMs: null, encounterStatus: null, }; /** Calibration is complete once a threshold exists. Until then the gate NEVER auto-pauses. */ export const isCalibrated = (state: CaptureState): boolean => state.speechThreshold !== null; /** * Capture requires an encounter that is actually in progress - the sole authority on whether recording * is permitted at all. Nothing else may open a run: not speech, not a resume, not a calibrated threshold. * * This guard is why the gate does not start on sound alone. A microphone live in an exam room before the * visit is marked in progress is recording a patient who has consented to nothing, and audio after the * encounter finishes belongs to whoever walks in next. */ const statusPermitsCapture = (state: CaptureState): boolean => state.encounterStatus === CAPTURING_ENCOUNTER_STATUS; /** True while audio should be written to the recording. */ export const isCapturing = (state: CaptureState): boolean => state.phase === 'capturing'; /** * Median rather than mean: a cough, a door, or a throat-clear during the calibration window would drag a * mean upward and raise the threshold - the unsafe direction. */ function median(values: readonly number[]): number { if (values.length === 0) { return 0; } const sorted = [...values].sort((a, b) => a - b); const mid = Math.floor(sorted.length / 2); return sorted.length % 2 === 0 ? (sorted[mid - 1] + sorted[mid]) / 2 : sorted[mid]; } /** Open a capture run at `startMs`, never earlier than the pre-roll floor. */ function openRun(state: CaptureState, startMs: number): CaptureState { return { ...state, phase: 'capturing', autoPauseCause: null, runStartMs: Math.max(startMs, state.preRollFloorMs), }; } /** * Close the open run at `endMs` and record it in the segment map. `preRollFloorMs` is where a later * resume is permitted to reach back to: for silence it is the cut point (recovering the onset is the whole * purpose of pre-roll), for a deliberate pause it is set by the caller to the resume instant instead. */ function closeRun(state: CaptureState, endMs: number): CaptureState { if (state.runStartMs === null) { return state; } const start = state.runStartMs; const end = Math.max(endMs, start); const duration = end - start; return { ...state, runStartMs: null, compressedElapsedMs: state.compressedElapsedMs + duration, segments: [ ...state.segments, { originalStartMs: start, originalEndMs: end, compressedStartMs: state.compressedElapsedMs, compressedEndMs: state.compressedElapsedMs + duration, }, ], preRollFloorMs: end, }; } export function captureReducer( state: CaptureState, event: CaptureEvent, options: CaptureGateOptions = DEFAULT_CAPTURE_GATE_OPTIONS, ): CaptureState { if (state.phase === 'stopped') { return state; } switch (event.type) { case 'ENCOUNTER_STATUS': { const next = { ...state, encounterStatus: event.status }; const permitted = event.status === CAPTURING_ENCOUNTER_STATUS; // A hard pause outranks the encounter status in BOTH directions. The encounter returning to // in-progress must not lift a pause a human asked for. if (state.phase === 'hard-paused') { return next; } if (permitted) { if (state.phase === 'capturing') { return next; } // Resuming from a status pause deliberately grants NO pre-roll: the held period may be the // practitioner out of the room with somebody else, and pre-roll would scoop up its tail. return openRun({ ...next, preRollFloorMs: event.atMs }, event.atMs); } if (state.phase === 'capturing') { return { ...closeRun(next, event.atMs), phase: 'auto-paused', autoPauseCause: 'encounter-status', // Same reasoning as above, applied at the moment of the pause. preRollFloorMs: event.atMs, }; } return { ...next, phase: 'auto-paused', autoPauseCause: 'encounter-status' }; } case 'HARD_PAUSE': { if (state.phase === 'hard-paused') { return state; } const closed = state.phase === 'capturing' ? closeRun(state, event.atMs) : state; return { ...closed, phase: 'hard-paused', autoPauseCause: null, // The floor moves to the pause instant so nothing after it can ever be pre-rolled in. preRollFloorMs: event.atMs, }; } case 'HARD_RESUME': { if (state.phase !== 'hard-paused') { return state; } // A hard pause survives an encounter that drifted out of in-progress meanwhile. if (state.encounterStatus !== null && state.encounterStatus !== CAPTURING_ENCOUNTER_STATUS) { return { ...state, phase: 'auto-paused', autoPauseCause: 'encounter-status', preRollFloorMs: event.atMs }; } // No pre-roll across a hard pause: capture begins exactly at the human's instruction. return openRun({ ...state, preRollFloorMs: event.atMs, lastSpeechMs: event.atMs }, event.atMs); } case 'STOP': { const closed = state.phase === 'capturing' ? closeRun(state, event.atMs) : state; return { ...closed, phase: 'stopped', autoPauseCause: null }; } case 'LEVEL': { let next = state; // Calibration runs WHILE capturing, so the opening seconds of the encounter are never lost to // it, and the gate cannot auto-pause until it knows what the room sounds like. if (!isCalibrated(next)) { const startedMs = next.calibrationStartedMs ?? event.atMs; const samples = [...next.calibrationSamples, event.amplitude]; next = { ...next, calibrationStartedMs: startedMs, calibrationSamples: samples }; if (event.atMs - startedMs >= options.calibrationMs) { const floor = median(samples); next = { ...next, noiseFloor: floor, // Clamped at BOTH ends, and the two clamps guard opposite failures: // ceiling - a noisy room must not raise the bar above ordinary speech (rule 3); // floor - a SILENT room must not drop the bar to zero, which would make every // sample count as speech and record the visit end to end. speechThreshold: Math.max( options.thresholdFloor, Math.min(floor * options.thresholdMargin, options.thresholdCeiling), ), calibrationSamples: [], }; } } const threshold = next.speechThreshold; const isSpeech = threshold === null ? true : event.amplitude >= threshold; if (isSpeech) { next = { ...next, lastSpeechMs: event.atMs }; } // Silence can never lift a deliberate pause, and neither can speech. if (next.phase === 'hard-paused') { return next; } if (next.phase === 'auto-paused' && next.autoPauseCause === 'encounter-status') { return next; } if (next.phase === 'capturing') { if (!isSpeech && threshold !== null && next.lastSpeechMs !== null) { const silentFor = event.atMs - next.lastSpeechMs; if (silentFor >= options.silenceHoldMs) { // Cut just after the last speech rather than now, so the gap itself is dropped - // but keep a tail so the final word is not clipped. const cutAt = next.lastSpeechMs + options.tailMs; return { ...closeRun(next, cutAt), phase: 'auto-paused', autoPauseCause: 'silence', }; } } return next; } // Only a silence pause is liftable by sound, and only while the encounter permits capture. // 'idle' is deliberately NOT resumable here: before the encounter is in progress the only // thing that may open the first run is the status event itself. if (next.phase === 'auto-paused' && next.autoPauseCause === 'silence') { if (!isSpeech || !statusPermitsCapture(next)) { return next; } // Speech after silence: reach back to recover the onset, bounded by the pre-roll floor so a // deliberate pause is never crossed. const effectiveStart = Math.max(event.atMs - options.preRollMs, next.preRollFloorMs); return openRun(next, effectiveStart); } return next; } default: return state; } } /** * Translate an offset in the silence-stripped recording back to wall-clock time in the encounter. * * This is what makes an evidence citation on a generated note point at the right moment. Returns null for * an offset past the end of the captured audio rather than guessing - a wrong timestamp on a clinical note * is worse than an absent one. */ export function toOriginalMs(state: CaptureState, compressedMs: number): number | null { if (compressedMs < 0) { return null; } for (const segment of state.segments) { if (compressedMs >= segment.compressedStartMs && compressedMs <= segment.compressedEndMs) { return segment.originalStartMs + (compressedMs - segment.compressedStartMs); } } return null; } /** * Total audio actually captured, i.e. what Transcribe will be billed for. * * `nowMs` is what includes the OPEN run. Without it this counts closed runs only - which is correct * for a pure question ("what has been banked") but makes a live timer sit still for minutes at a time * while someone is actively speaking, and then jump. The reducer has no clock of its own, so the caller * has to supply one; omitting it keeps the original, purely-derived answer. */ export function capturedDurationMs(state: CaptureState, nowMs?: number): number { const openRunMs = state.runStartMs !== null && nowMs !== undefined ? Math.max(0, nowMs - state.runStartMs) : 0; return state.compressedElapsedMs + openRunMs; }