import { useCallback, useEffect, useRef, useState } from 'react'; import type { ScribePresignRefusal, ScribePresignResult } from '../ScribeService'; import type { RecordingUnsettled } from './recordingDisposition'; import { CaptureState, DEFAULT_CAPTURE_GATE_OPTIONS, INITIAL_CAPTURE_STATE, capturedDurationMs, captureReducer, isCapturing, } from './captureGate'; /** * S4 - the browser half of the ambient scribe: microphone in, one WebM out. * * THE ONE THING THAT MAKES SILENCE-STRIPPING REAL * * `captureGate.ts` decides WHEN the room is worth recording. This hook is what makes that decision cost * money or save it, and the mechanism is **`MediaRecorder.pause()` / `.resume()`** - which produce ONE * valid WebM with the paused time simply absent. * * The obvious alternative - stop the recorder on silence and start a new one on speech - DOES NOT * WORK. Each `start()` emits its own WebM header, and concatenating those blobs yields a file whose * first header claims the whole length; players and Transcribe read only the first fragment. That * failure is silent: you get a file, it plays for a few seconds, and the rest of the consultation is * gone. Pause/resume is not a convenience here, it is the only correct construction. * * Consequence worth stating: the emitted WebM has NO gaps, so its timeline is the COMPRESSED clock. * `captureGate.toOriginalMs` is what maps a transcript offset back to when it actually happened, and * nothing else can - which is why the segment map is carried, not discarded. * * WHAT THIS HOOK DELIBERATELY DOES NOT DO * * It does not decide whether recording is ALLOWED. Consent is the server's decision and the caller * passes the answer in; a recorder that could talk itself into starting would be the whole point missed. */ export type RecorderError = | 'no-microphone' | 'permission-denied' | 'unsupported-browser' /** S3 answered, and the answer was no. We know nothing was stored. */ | 'upload-failed' /** * ๐Ÿšจ THE UPLOAD MAY WELL HAVE SUCCEEDED โ€” we simply could not read the answer. * * Separate from `upload-failed` because the two support different sentences, and the wrong one is a * falsehood about PHI: a `fetch` carrying `FormData` is a CORS-simple request, so a missing CORS rule * does NOT stop the browser sending it โ€” S3 stores the object and only the RESPONSE is withheld. * Card 255 watched us report "nothing was transcribed, and no note was drafted" while the audio sat * in the bucket and a real note was being drafted from it. */ | 'upload-unconfirmed' /** Finished with too little speech to transcribe; nothing was sent. */ | 'too-short' /** * ๐Ÿšจ THREE SEPARATE REFUSALS, AND THEY MUST NOT COLLAPSE INTO `upload-failed`. * * Each calls for a different action from the practitioner standing in the room: re-ask the patient ยท * nothing, wait and try again ยท stop recording this month and talk to whoever owns the subscription. * Rendering a decision as a fault sends them to fix something that is not broken * โ‡’ [[reference-authz-denial-never-renders-as-empty-data]]. */ | 'consent-refused' | 'consent-unverifiable' | 'ceiling-reached' | 'visit-finishing'; export interface ScribeRecorderOptions { /** Live FHIR Encounter.status. The gate refuses to capture unless this is `in-progress`. */ encounterStatus?: string; /** Mints the upload policy, carrying the server's REASON when it refuses. */ presign: (jobId?: string) => Promise; /** * TELEHEALTH. The far side of a video call, as one MediaStream. * * WITHOUT THIS, A TELEHEALTH RECORDING IS ONE-SIDED - AND NOT BY ACCIDENT. The patient's voice * reaches the practitioner through the SPEAKERS, and the browser's echo canceller exists precisely * to subtract speaker output from the microphone. So a mic-only capture of a video visit does not * merely under-record the patient, it **actively removes them**, and the note is then drafted from * one half of a conversation with nothing looking wrong. * * Called REPEATEDLY, not once: a practitioner may start recording before the patient joins, and a * participant can arrive mid-visit. Each new stream is mixed in when it appears. * * Omitted for an in-person visit, where the microphone already carries the whole room. */ getRemoteAudioStream?: () => MediaStream | null; /** Called once when a session reaches the hour of recorded audio; the caller stops and sends it. */ onSessionCap?: () => void; /** False while this visit is being finished: no new recording may begin under a Finish. */ mayStart?: () => boolean; /** False once the owner's opening has ended (another viewer, practice or visit), before its cleanup has run. */ alive?: () => boolean; } /** * What became of the audio when the session ended. * * `unconfirmed` is NOT a synonym for failure and must not be treated as one - it means the POST left * the browser and we could not read the answer, so a transcription job may well be running. The surface * arms its wait for a draft on `unconfirmed` exactly as it does on `uploaded`, because in both cases a * note may be minutes away, and declaring nothing happened is the falsehood caught. */ export type ScribeStopOutcome = 'uploaded' | 'unconfirmed' | 'not-sent'; export interface ScribeRecorderApi { phase: CaptureState['phase']; autoPauseCause: CaptureState['autoPauseCause']; /** Speech-only milliseconds captured so far - what we will actually be billed for. */ capturedMs: number; /** True once the ambient floor has been measured. */ calibrated: boolean; /** * True once the far side of a video call is actually on the tape. * * Surfaced because the practitioner cannot otherwise tell, and the difference is not cosmetic: on a * telehealth visit a false reading here means the note will be drafted from the practitioner's half of * the conversation with nothing on screen looking wrong. It is set from the successful connection, not * from "a call is running", so it says what happened rather than what should have. */ remoteMixed: boolean; /** * The consent record has not caught up with a grant yet, and we are still asking. * * Distinct from `uploading` because it means something different to the person watching: nothing is * wrong, nothing is lost, and the correct action is to wait. It exists so the surface can say * *"just granted - one moment"* instead of *"this patient has not consented"* - which, in the seconds * after they agreed, is a statement about a real person that is simply untrue. */ awaitingConsent: boolean; askingForMicrophone: boolean; error: RecorderError | null; uploading: boolean; start: () => Promise; hardPause: () => void; hardResume: () => void; /** * Finish and send. Resolves with what actually became of the audio, so the surface can arm the * wait for a draft - including on `unconfirmed`, which is precisely the case where a draft may still * be on its way. */ stop: () => Promise; /** * End the visit WITHOUT transcribing it. The audio never leaves the device: no upload, no job, no * draft, no spend. The exit a mistaken start and a mid-visit consent withdrawal both need. */ discard: () => Promise; /** A finished recording that did not reach the server, kept in memory for a resend or a discard. */ /** `mayHaveArrived`: an earlier send went unanswered, so discarding this copy cannot undo it. */ held: { canRetry: boolean; mayHaveArrived: boolean } | null; /** Send the held recording again, up to MAX_SEND_ATTEMPTS sends in all. */ retry: () => Promise; discardHeld: () => void; /** What this recorder is doing now, read from its live refs rather than from the last render. */ unsettled: () => RecordingUnsettled | null; } const LEVEL_SAMPLE_MS = 100; /** Sends of one finished recording, the first included. */ export const MAX_SEND_ATTEMPTS = 3; /** Refusals a resend can clear; which refusals keep the audio is KEPT_REFUSALS. */ const RESENDABLE_REFUSALS = new Set(['failed', 'consent-unverifiable']); // Refused for consent the audio is kept, unsendable until consent is recorded, so it is sent or discarded on purpose. const KEPT_REFUSALS = new Set([...RESENDABLE_REFUSALS, 'consent-not-recorded']); /** One session records at most an hour of audio; the server refuses a longer file before transcribing it. */ export const SCRIBE_SESSION_CAP_MS = 60 * 60 * 1000; /** * Below this much captured audio, nothing is uploaded. * * NOT an arbitrary tidy-up. The gate deliberately captures THROUGHOUT calibration so the opening * words of a consultation are never lost, which means a recorder that is started and immediately * stopped still banks the ~1.5s calibration window. Uploading that starts a PAID transcription job over * silence and - worse - produces a draft whose note is legitimately empty, which the review surface then * shows as "here is the note from your recording". An accidental tap must not manufacture a clinical * artefact. Two seconds is comfortably below any real utterance and above the calibration window. */ const MIN_UPLOAD_MS = 2_000; /** * THE CONSENT-LAG RETRY. Waits between presign attempts, in order. * * `GET /scribe/consent` and the recording gate are **two separate HealthLake searches**, and for a few * seconds after a grant they disagree. Measured on staging: a * permit became visible to the gate after **1s**, a deny after **13s**. During that window the server * answers `CONSENT_NOT_RECORDED` - which the recorder would otherwise report as * *"this patient has not consented"*, **a statement about a real person that is momentarily false**, made * seconds after that person agreed in front of the practitioner. * * ~30 seconds total, which is deliberately the same threshold tells the walker to use: past * 30s it is no longer the lag and IS a finding, so a retry that ran longer would hide the real bug. * Only the two transient refusals are retried. A patient-boundary 403 or a transport failure is NOT * retried - we do not know those are transient, and stalling a practitioner for 30 seconds in front of a * patient to re-ask a question we already have the answer to is its own harm. */ const PRESIGN_RETRY_DELAYS_MS = [2_000, 4_000, 8_000, 16_000]; const sleep = (ms: number) => new Promise((resolve) => { setTimeout(resolve, ms); }); /** What a refusal the retry could not clear is reported as. Exhaustive by construction. */ const REFUSAL_ERRORS: Record = { 'consent-not-recorded': 'consent-refused', 'consent-unverifiable': 'consent-unverifiable', 'ceiling-reached': 'ceiling-reached', failed: 'upload-failed', }; /** RMS of the current window, 0..1 - the same scale `thresholdCeiling` is expressed in. */ function rms(buffer: Float32Array): number { let sum = 0; for (let i = 0; i < buffer.length; i += 1) sum += buffer[i] * buffer[i]; return Math.sqrt(sum / buffer.length); } export function pickMimeType(): string | null { // Opus in WebM is what every supporting browser produces and what the launcher declares. const candidates = ['audio/webm;codecs=opus', 'audio/webm']; const supported = typeof MediaRecorder !== 'undefined' && typeof MediaRecorder.isTypeSupported === 'function'; if (!supported) return null; return candidates.find((type) => MediaRecorder.isTypeSupported(type)) ?? null; } export function useScribeRecorder(options: ScribeRecorderOptions): ScribeRecorderApi { const [state, setState] = useState(INITIAL_CAPTURE_STATE); const [error, setError] = useState(null); const [uploading, setUploading] = useState(false); const [remoteMixed, setRemoteMixed] = useState(false); const [awaitingConsent, setAwaitingConsent] = useState(false); const [askingForMicrophone, setAskingForMicrophone] = useState(false); /** Set on unmount so a retry in flight cannot keep waking a component that is gone. */ const abortedRef = useRef(false); /** * Which lifetime of this recorder is current; moves on every unmount, and a replayed effect's cleanup counts. * * A START ASKS FOR THE MICROPHONE AND MAY WAIT ON IT. If the owner is gone by the time the browser answers * (the visit, the viewer or the call changed while the permission prompt was up), nothing on screen owns * that answer any more: starting a recorder then would capture the room with no control anywhere to stop it. * So `start` notes the lifetime it began in and re-checks it after every wait, and a late stream or context * is released instead of recorded. */ const lifetimeRef = useRef(0); // Gone once unmounted or once its opening ended; captured per continuation so a later owner never counts. const retiredSince = useCallback(() => { const lifetime = lifetimeRef.current; return () => abortedRef.current || lifetimeRef.current !== lifetime || optionsRef.current.alive?.() === false; }, []); const stateRef = useRef(state); stateRef.current = state; // Read through a ref for the same reason the panels do: a caller re-rendering must not restart the // microphone. const optionsRef = useRef(options); optionsRef.current = options; const recorderRef = useRef(null); const streamRef = useRef(null); const audioContextRef = useRef(null); const analyserRef = useRef(null); const tickRef = useRef | null>(null); const chunksRef = useRef([]); const sessionCapFiredRef = useRef(false); // One recording keeps one job across its sends, so a resend can never pay for a second transcription. const heldRef = useRef<{ blob: Blob; attempts: number; jobId?: string; mayHaveArrived: boolean } | null>(null); const sendingRef = useRef(false); // The start attempt under way, set before its first wait, so a Finish cannot slip in while the microphone is asked. const startingRef = useRef(0); const startAttemptRef = useRef(0); const [held, setHeld] = useState<{ canRetry: boolean; mayHaveArrived: boolean } | null>(null); /** * The mixing bus. Every source connects HERE, and both the recorder and the VAD hang off it. * * THE VAD MUST ANALYSE THE MIX, NOT THE MICROPHONE. On a video visit the practitioner is often * silent while the patient talks; a gate watching only the mic would hear that as silence, auto-pause * mid-answer, and drop exactly the part of the consultation the note is about. * * A GainNode rather than reading back the recorder's own MediaStream: tapping the same node means the * analyser and the tape are provably the same signal, with no round-trip through a second stream. */ const busRef = useRef(null); /** Streams already on the bus, so a repeated lookup cannot connect one twice. */ const mixedRef = useRef>(new Set()); const dispatch = useCallback((event: Parameters[1]) => { setState((previous) => { const next = captureReducer(previous, event, DEFAULT_CAPTURE_GATE_OPTIONS); // THE GATE DRIVES THE TAPE. Every transition into `capturing` resumes the recorder and // every transition out of it pauses - so the emitted WebM contains speech and nothing else. const recorder = recorderRef.current; if (recorder && recorder.state !== 'inactive') { const wasCapturing = isCapturing(previous); const nowCapturing = isCapturing(next); if (!wasCapturing && nowCapturing && recorder.state === 'paused') recorder.resume(); if (wasCapturing && !nowCapturing && recorder.state === 'recording') recorder.pause(); } return next; }); }, []); /** * Mix in the far side of a video call, if there is one and it is not already mixed. * * Idempotent by stream id, because it is called on every level tick. Connecting the same stream * twice would double its amplitude in the mix, which would drag the VAD threshold calibration off * and make the patient sound like two people to diarization. */ const attachRemoteAudio = useCallback(() => { const context = audioContextRef.current; const bus = busRef.current; if (!context || !bus) return; const remote = optionsRef.current.getRemoteAudioStream?.() ?? null; // A stream with no audio track yet is not an error - the call may still be connecting. if (!remote || remote.getAudioTracks().length === 0) return; if (mixedRef.current.has(remote.id)) return; try { context.createMediaStreamSource(remote).connect(bus); mixedRef.current.add(remote.id); setRemoteMixed(true); } catch (cause) { // Never fatal: losing the far side degrades the note, refusing to record loses the visit. // Marked as mixed either way, so a stream that cannot be connected is not retried 10x/second. mixedRef.current.add(remote.id); console.error('scribe: could not mix the remote audio into the recording', cause); } }, []); const teardown = useCallback(() => { if (tickRef.current) { clearInterval(tickRef.current); tickRef.current = null; } analyserRef.current = null; busRef.current = null; mixedRef.current = new Set(); void audioContextRef.current?.close().catch(() => {}); audioContextRef.current = null; streamRef.current?.getTracks().forEach((track) => track.stop()); streamRef.current = null; }, []); useEffect(() => { abortedRef.current = false; return () => { abortedRef.current = true; lifetimeRef.current += 1; teardown(); }; }, [teardown]); // The encounter's status is an INPUT to the gate, not a suggestion: leaving `in-progress` auto-pauses // capture, which is what stops a recorder running on after the visit ends. useEffect(() => { if (!recorderRef.current) return; dispatch({ type: 'ENCOUNTER_STATUS', status: options.encounterStatus ?? '', atMs: Date.now() }); }, [options.encounterStatus, dispatch]); const begin = useCallback(async () => { setError(null); // The gate ignores every event once stopped, so each recording starts from a fresh one. setState(INITIAL_CAPTURE_STATE); const mimeType = pickMimeType(); if (!mimeType || typeof navigator === 'undefined' || !navigator.mediaDevices?.getUserMedia) { setError('unsupported-browser'); return; } const ownerGone = retiredSince(); let stream: MediaStream; try { // A REQUEST THAT NEVER SETTLES LOOKS LIKE A DEAD BUTTON: say we are asking while it is out. setAskingForMicrophone(true); stream = await navigator.mediaDevices.getUserMedia({ audio: true }); } catch (cause) { if (ownerGone()) return; setAskingForMicrophone(false); setError((cause as { name?: string })?.name === 'NotAllowedError' ? 'permission-denied' : 'no-microphone'); return; } // Granted after the owner went: the microphone is released, never recorded. if (ownerGone()) { stream.getTracks().forEach((track) => track.stop()); return; } // Granted after this visit's Finish was admitted: nothing is recorded under it. if (optionsRef.current.mayStart?.() === false) { stream.getTracks().forEach((track) => track.stop()); setAskingForMicrophone(false); setError('visit-finishing'); return; } setAskingForMicrophone(false); streamRef.current = stream; chunksRef.current = []; sessionCapFiredRef.current = false; mixedRef.current = new Set(); setRemoteMixed(false); // Everything is mixed first and read once. For an in-person visit the bus carries only the // microphone; for a video visit it carries the microphone AND the far side of the call. Recording // and analysing the SAME node is what keeps the two paths from ever disagreeing. const context = new AudioContext(); // A SUSPENDED CONTEXT RECORDS SILENCE. Now that the tape comes off the graph rather than // straight off the microphone, an autoplay-suspended context yields a valid WebM containing // nothing - the failure mode with no symptom. `start()` runs from a click so this is normally // already running; resuming costs nothing and removes the case entirely. if (context.state === 'suspended') await context.resume().catch(() => {}); // Resumed after the owner went: its teardown already released the stream it saw; this context and // stream are released here, and no recorder or sample timer is started. if (ownerGone()) { stream.getTracks().forEach((track) => track.stop()); if (streamRef.current === stream) streamRef.current = null; void context.close().catch(() => {}); return; } audioContextRef.current = context; const bus = context.createGain(); busRef.current = bus; const mix = context.createMediaStreamDestination(); bus.connect(mix); const analyser = context.createAnalyser(); analyser.fftSize = 2048; bus.connect(analyser); analyserRef.current = analyser; context.createMediaStreamSource(stream).connect(bus); mixedRef.current.add(stream.id); const recorder = new MediaRecorder(mix.stream, { mimeType }); recorder.ondataavailable = (event) => { if (event.data.size > 0) chunksRef.current.push(event.data); }; recorderRef.current = recorder; // Started and IMMEDIATELY paused: nothing is on the tape until the gate says the room is worth // recording. Starting in the recording state would capture the silence before the first word. recorder.start(); recorder.pause(); const buffer = new Float32Array(analyser.fftSize); tickRef.current = setInterval(() => { // Pick up the far side whenever it appears. Checked on every tick rather than once at // start, because a practitioner routinely opens the visit and starts recording BEFORE the // patient joins - and a participant can arrive mid-consultation. A one-shot lookup would // record those visits mic-only, which is the exact failure this exists to prevent. attachRemoteAudio(); const node = analyserRef.current; if (!node) return; node.getFloatTimeDomainData(buffer); dispatch({ type: 'LEVEL', amplitude: rms(buffer), atMs: Date.now() }); if (!sessionCapFiredRef.current && capturedDurationMs(stateRef.current, Date.now()) >= SCRIBE_SESSION_CAP_MS) { sessionCapFiredRef.current = true; optionsRef.current.onSessionCap?.(); } }, LEVEL_SAMPLE_MS); // Seed the gate with the encounter status so it can refuse from the very first sample. dispatch({ type: 'ENCOUNTER_STATUS', status: optionsRef.current.encounterStatus ?? '', atMs: Date.now() }); }, [dispatch, attachRemoteAudio, retiredSince]); const start = useCallback(async () => { // A held recording is sent or discarded first, and nothing is recorded over a send in flight. if (sendingRef.current || heldRef.current || startingRef.current) return; if (optionsRef.current.alive?.() === false) return; if (optionsRef.current.mayStart?.() === false) { setError('visit-finishing'); return; } const attempt = ++startAttemptRef.current; startingRef.current = attempt; try { await begin(); } finally { if (startingRef.current === attempt) startingRef.current = 0; } }, [begin]); const hardPause = useCallback(() => dispatch({ type: 'HARD_PAUSE', atMs: Date.now() }), [dispatch]); const hardResume = useCallback(() => dispatch({ type: 'HARD_RESUME', atMs: Date.now() }), [dispatch]); /** * Ask for an upload policy, retrying ONLY while the answer is one the server itself calls temporary. * * Retrying is cheap and safe here, and that is a property of WHERE this sits: the audio is already * recorded and held in memory, so a retry re-asks a question - it never re-records anything and never * asks the practitioner to do it again. */ const presignWithRetry = useCallback(async (jobId: string | undefined, ownerGone: () => boolean): Promise => { let result = await optionsRef.current.presign(jobId); for (const delay of PRESIGN_RETRY_DELAYS_MS) { if (result.ok) return result; if (result.reason !== 'consent-not-recorded' && result.reason !== 'consent-unverifiable') { return result; } // Only now does the UI say anything: on the FIRST attempt a refusal may well be final, and // "one moment" shown before we have any reason to expect a different answer is a guess. setAwaitingConsent(true); if (ownerGone()) return result; await sleep(delay); if (ownerGone()) return result; result = await optionsRef.current.presign(jobId); } return result; }, []); const hold = useCallback((blob: Blob | null, attempts: number, jobId?: string, unanswered = false) => { // Once a send of this recording went unanswered, a copy of it may already be on the server. const mayHaveArrived = unanswered || (blob !== null && heldRef.current?.blob === blob && heldRef.current.mayHaveArrived); heldRef.current = blob ? { blob, attempts, jobId, mayHaveArrived } : null; setHeld(blob ? { canRetry: attempts < MAX_SEND_ATTEMPTS, mayHaveArrived } : null); }, []); const send = useCallback(async (blob: Blob, attempts: number, knownJobId?: string): Promise => { let jobId = knownJobId; const ownerGone = retiredSince(); sendingRef.current = true; setError(null); setUploading(true); try { // Nothing is asked for an owner already gone, and nothing it is answered leaves for one. if (ownerGone()) return 'not-sent'; const result = await presignWithRetry(jobId, ownerGone); if (!result.ok) { setError(REFUSAL_ERRORS[result.reason]); hold(KEPT_REFUSALS.has(result.reason) ? blob : null, attempts, jobId); return 'not-sent'; } jobId = result.jobId ?? jobId; // The owner went while the upload was authorised: nothing new leaves for a recording it released. if (ownerGone()) return 'not-sent'; const form = new FormData(); // Policy fields must precede the file, or S3 rejects the POST. for (const [key, value] of Object.entries(result.post.fields)) form.append(key, value); form.append('file', blob, 'encounter.webm'); // A rejected fetch means we do not know: a FormData POST is CORS-simple, so S3 can store the object // while the browser withholds the response. Only a readable refusal proves nothing was stored. const response = await fetch(result.post.url, { method: 'POST', body: form }); // A response we could actually read IS knowledge: S3 refused, and nothing was stored. if (!response.ok) { setError('upload-failed'); hold(blob, attempts, jobId); return 'not-sent'; } hold(null, 0); return 'uploaded'; } catch { setError('upload-unconfirmed'); hold(blob, attempts, jobId, true); return 'unconfirmed'; } finally { sendingRef.current = false; setUploading(false); setAwaitingConsent(false); } }, [presignWithRetry, hold, retiredSince]); /** * End the session. `upload: false` is DISCARD. * * DISCARD EXISTS BECAUSE "FINISH" IS NOT THE ONLY WAY A RECORDING ENDS, AND THE OTHER WAYS COST * MONEY. A clinician who starts recording by mistake, or whose patient withdraws consent mid-visit, * previously had no exit that did not spend the transcription minutes - the only button that stopped * the recorder was the one that paid for it. On a metered feature that is a cost-control gap, and on a * withdrawal it is worse than that: the correct action was unavailable, so the practitioner's only * route was the one that sends the audio. * * The property that makes this the right shape: the audio NEVER LEAVES THE DEVICE. There is no * object to delete, no job to cancel, no server state to reconcile - the blob is dropped and the * microphone released. Nothing downstream can have seen it. */ const finaliseCapture = useCallback(async (upload: boolean): Promise => { const recorder = recorderRef.current; const ownerGone = retiredSince(); const stoppedAt = Date.now(); // THE FINAL STATE IS COMPUTED HERE, NOT READ BACK FROM THE REF AFTER DISPATCHING. // `stateRef` only updates on the next render, so reading it immediately after `dispatch` returns // the state as it was BEFORE the stop - with the last run still open and therefore zero captured // milliseconds banked. The upload would then be suppressed as "nothing was captured" on every // recording that ended while someone was still talking, which is most of them. The reducer is // pure, so calling it directly is exact rather than approximate. const finalState = captureReducer( stateRef.current, { type: 'STOP', atMs: stoppedAt }, DEFAULT_CAPTURE_GATE_OPTIONS, ); setState(finalState); if (!recorder || recorder.state === 'inactive') { chunksRef.current = []; teardown(); return 'not-sent'; } const blob: Blob = await new Promise((resolve) => { recorder.onstop = () => resolve(new Blob(chunksRef.current, { type: recorder.mimeType })); recorder.stop(); }); recorderRef.current = null; teardown(); chunksRef.current = []; // Closed after the owner went: its teardown released the visit, and nothing is sent for it. if (ownerGone()) return 'not-sent'; // THE DISCARD BRANCH, AND IT IS DELIBERATELY BEFORE `presign()`. Nothing is minted, nothing is // POSTed, no job is started and no draft can exist - the recording ends where it was made. if (!upload) return 'not-sent'; // Nothing was ever captured - the gate never opened. Uploading an empty container would start a // paid transcription job over silence. if (blob.size === 0 || capturedDurationMs(finalState) < MIN_UPLOAD_MS) { setError('too-short'); return 'not-sent'; } return send(blob, 1); }, [teardown, send, retiredSince]); // A finish counts as sending from the moment it is pressed, so nothing starts while the tape closes. const finalise = useCallback(async (upload: boolean): Promise => { if (!upload) return finaliseCapture(false); sendingRef.current = true; setUploading(true); try { return await finaliseCapture(true); } finally { sendingRef.current = false; setUploading(false); } }, [finaliseCapture]); const stop = useCallback(() => finalise(true), [finalise]); const discard = useCallback(async () => { await finalise(false); }, [finalise]); const retry = useCallback(async (): Promise => { const kept = heldRef.current; if (!kept || sendingRef.current || kept.attempts >= MAX_SEND_ATTEMPTS) return 'not-sent'; return send(kept.blob, kept.attempts + 1, kept.jobId); }, [send]); const discardHeld = useCallback(() => { if (sendingRef.current) return; hold(null, 0); setError(null); }, [hold]); const unsettled = useCallback((): RecordingUnsettled | null => { if (sendingRef.current) return 'sending'; // A recorder still closing is still holding the visit's audio. if (startingRef.current || recorderRef.current) return 'recording'; return heldRef.current ? 'held' : null; }, []); return { phase: state.phase, autoPauseCause: state.autoPauseCause, capturedMs: capturedDurationMs(state, Date.now()), calibrated: state.speechThreshold !== null, remoteMixed, awaitingConsent, askingForMicrophone, error, uploading, start, hardPause, hardResume, stop, discard, held, retry, discardHeld, unsettled, }; }