diff --git a/frontend/src/lib/voice/useVoiceCapture.ts b/frontend/src/lib/voice/useVoiceCapture.ts index 5008fbf..f17a74e 100644 --- a/frontend/src/lib/voice/useVoiceCapture.ts +++ b/frontend/src/lib/voice/useVoiceCapture.ts @@ -219,6 +219,14 @@ export function useVoiceCapture({ // minutes of dictation because a timer expired would be the worst failure. if (maxMs != null && elapsed >= maxMs) stop(); }, LEVEL_POLL_MS); + } catch { + // `new MediaRecorder(...)` and `recorder.start()` both throw on some browsers, + // and by then the stream is already live. Without this the promise rejects + // unhandled, the UI sits at 'idle' with nothing shown, and the browser's + // recording indicator stays lit until the workspace unmounts. + teardown(); + setPhase('idle'); + onError(clientError('VOICE_MIC_DENIED')); } finally { startingRef.current = false; } @@ -262,12 +270,23 @@ function attachLevelMeter( source.connect(analyser); const data = new Uint8Array(analyser.frequencyBinCount); - const tick = () => { + // Sample every frame so a transient is not missed, but publish at LEVEL_POLL_MS. + // This hook lives in TreatmentWorkspace, so an unthrottled setLevel re-renders the + // details editor, the FDI chart and the lab panel on every animation frame — about + // 7,200 whole-tree renders across a two-minute recording. + let peakSinceEmit = 0; + let lastEmit = 0; + const tick = (now: number) => { 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)); + peakSinceEmit = Math.max(peakSinceEmit, peak); + if (now - lastEmit >= LEVEL_POLL_MS) { + lastEmit = now; + setLevel(Math.min(1, peakSinceEmit / 128)); + peakSinceEmit = 0; + } requestAnimationFrame(tick); }; requestAnimationFrame(tick);