'use client'; import { useCallback, useEffect, useRef, useState } from 'react'; import { voiceApi } from '@/lib/api/voice'; import type { ApiError } from '@/types/api'; import type { VoiceExtractionResult, VoicePhase } from '@/types/voice'; import { blobToBase64, isMediaRecorderSupported, mimeTypeToFormat, pickRecordingMimeType, } from './audioFormat'; export interface UseVoiceCaptureOptions { /** null means uncapped; otherwise the recorder auto-stops here. */ maxMs: number | null; onExtracted: (result: VoiceExtractionResult) => void; onError: (error: unknown) => void; } export interface VoiceCaptureState { phase: VoicePhase; elapsedMs: number; /** 0..1, for the level meter — proves the microphone is actually hearing something. */ level: number; maxMs: number | null; onStart: () => void; onStop: () => void; onCancel: () => void; } const LEVEL_POLL_MS = 100; /** * Client-side failures must be ApiError-shaped or getUserFacingError cannot resolve them * and every one renders the generic fallback, leaving errors.VOICE_MIC_DENIED dead. */ function clientError(code: string): ApiError { return { statusCode: 0, code }; } /** * Microphone capture for treatment voice entry. * * Lives in lib/ rather than in the editor: TreatmentDetailsEditor stays presentational * and receives only a `voice` prop, so MediaRecorder and the API call never enter ui/. */ export function useVoiceCapture({ maxMs, onExtracted, onError, }: UseVoiceCaptureOptions): VoiceCaptureState { const [phase, setPhase] = useState('idle'); const [elapsedMs, setElapsedMs] = useState(0); const [level, setLevel] = useState(0); const recorderRef = useRef(null); const streamRef = useRef(null); const chunksRef = useRef([]); const startedAtRef = useRef(0); const timerRef = useRef | null>(null); const audioContextRef = useRef(null); const abortRef = useRef(null); /** Set when the user cancels, so the recorder's stop handler discards instead of sending. */ const cancelledRef = useRef(false); /** getUserMedia is async; without this a permission granted after unmount leaks the mic. */ const mountedRef = useRef(true); /** * Set synchronously on click. `phase` does not become 'recording' until getUserMedia * resolves, so without this a second click during the permission prompt would start a * second stream and orphan the first — mic indicator lit, interval leaked. */ const startingRef = useRef(false); const teardown = useCallback(() => { if (timerRef.current) { clearInterval(timerRef.current); timerRef.current = null; } streamRef.current?.getTracks().forEach((track) => track.stop()); streamRef.current = null; void audioContextRef.current?.close().catch(() => undefined); audioContextRef.current = null; recorderRef.current = null; setLevel(0); }, []); // Releasing the microphone on unmount matters: the browser shows a recording indicator // for as long as the track is live, and an orphaned one looks like the app is listening. useEffect(() => { // Re-armed on every mount: React StrictMode runs mount → unmount → mount in dev, and // a ref that is only ever set false would leave the hook permanently "unmounted". mountedRef.current = true; return () => { mountedRef.current = false; cancelledRef.current = true; abortRef.current?.abort(); try { recorderRef.current?.stop(); } catch { // already stopped } teardown(); }; }, [teardown]); const send = useCallback( async (blob: Blob, mimeType: string, durationMs: number) => { setPhase('processing'); const controller = new AbortController(); abortRef.current = controller; try { const audio = await blobToBase64(blob); const response = await voiceApi.extract( { audio, format: mimeTypeToFormat(mimeType), timeZone: Intl.DateTimeFormat().resolvedOptions().timeZone, durationMs, }, controller.signal, ); if (cancelledRef.current) return; onExtracted(response.data); } catch (error) { if (cancelledRef.current || controller.signal.aborted) return; onError(error); } finally { abortRef.current = null; setPhase('idle'); setElapsedMs(0); } }, [onExtracted, onError], ); const stop = useCallback(() => { try { recorderRef.current?.stop(); } catch { teardown(); setPhase('idle'); } }, [teardown]); const onStart = useCallback(() => { if (phase !== 'idle' || startingRef.current) return; if (!isMediaRecorderSupported()) { onError(clientError('VOICE_MIC_DENIED')); return; } cancelledRef.current = false; startingRef.current = true; void (async () => { try { let stream: MediaStream; try { stream = await navigator.mediaDevices.getUserMedia({ audio: true }); } catch { // Permission refused, or no input device. Never a server round-trip. onError(clientError('VOICE_MIC_DENIED')); return; } if (!mountedRef.current) { // Permission resolved after the component went away — release it immediately // rather than leaving the browser's recording indicator lit. stream.getTracks().forEach((track) => track.stop()); return; } const mimeType = pickRecordingMimeType(); if (mimeType === null) { stream.getTracks().forEach((track) => track.stop()); onError(clientError('VOICE_MIC_DENIED')); return; } streamRef.current = stream; chunksRef.current = []; const recorder = new MediaRecorder(stream, mimeType ? { mimeType } : undefined); recorderRef.current = recorder; recorder.ondataavailable = (event) => { if (event.data.size > 0) chunksRef.current.push(event.data); }; recorder.onstop = () => { const durationMs = Date.now() - startedAtRef.current; const blob = new Blob(chunksRef.current, { type: recorder.mimeType || mimeType }); teardown(); if (cancelledRef.current || blob.size === 0) { setPhase('idle'); setElapsedMs(0); return; } // Prefer what the recorder actually produced, then the blob's own type. Old // Safari accepts no mimeType hint, and defaulting to webm would mislabel its // mp4/aac clips as something they are not. void send(blob, recorder.mimeType || blob.type || mimeType || 'audio/webm', durationMs); }; attachLevelMeter(stream, audioContextRef, setLevel); startedAtRef.current = Date.now(); recorder.start(); setPhase('recording'); setElapsedMs(0); timerRef.current = setInterval(() => { const elapsed = Date.now() - startedAtRef.current; setElapsedMs(elapsed); // Auto-stop proceeds to processing with what was captured; discarding two // minutes of dictation because a timer expired would be the worst failure. if (maxMs != null && elapsed >= maxMs) stop(); }, LEVEL_POLL_MS); } finally { startingRef.current = false; } })(); }, [maxMs, onError, phase, send, stop, teardown]); const onCancel = useCallback(() => { cancelledRef.current = true; // Aborting closes the connection, which aborts the vendor call server-side. It is // metered per minute, so letting it settle costs money for a result nobody sees. abortRef.current?.abort(); try { recorderRef.current?.stop(); } catch { // already stopped } teardown(); setPhase('idle'); setElapsedMs(0); }, [teardown]); return { phase, elapsedMs, level, maxMs, onStart, onStop: stop, onCancel }; } /** Drives the level meter from the live stream; failure here must not stop recording. */ function attachLevelMeter( stream: MediaStream, contextRef: React.MutableRefObject, setLevel: (value: number) => void, ) { try { const AudioContextCtor = window.AudioContext ?? (window as { webkitAudioContext?: typeof AudioContext }).webkitAudioContext; if (!AudioContextCtor) return; const context = new AudioContextCtor(); contextRef.current = context; const source = context.createMediaStreamSource(stream); const analyser = context.createAnalyser(); analyser.fftSize = 512; source.connect(analyser); const data = new Uint8Array(analyser.frequencyBinCount); const tick = () => { if (contextRef.current !== context || context.state === 'closed') return; analyser.getByteTimeDomainData(data); let peak = 0; for (const sample of data) peak = Math.max(peak, Math.abs(sample - 128)); setLevel(Math.min(1, peak / 128)); requestAnimationFrame(tick); }; requestAnimationFrame(tick); } catch { // A missing or blocked AudioContext costs the meter, not the recording. } }