From 6a4c0cb1bbca7c4301528b9d7ac074e168857df5 Mon Sep 17 00:00:00 2001 From: Amin Mousavi Date: Fri, 21 Aug 2026 05:04:30 +0800 Subject: [PATCH] fix(frontend): stop the level meter re-rendering the whole workspace MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The meter wrote React state from a requestAnimationFrame loop, and the hook lives in TreatmentWorkspace — so every frame re-rendered the details editor, the FDI chart, the lab panel and the history rail. About 7,200 whole-tree renders across a two-minute recording, while the user is dictating. Now samples every frame but publishes at LEVEL_POLL_MS, the rate the elapsed timer already used. Peaks between publishes are carried forward, so the meter stays responsive to transients rather than sampling at 10 Hz. Also adds the catch the start path never had: new MediaRecorder() and recorder.start() both throw on some browsers, and by then the stream is live. The rejection went unhandled, the UI sat at 'idle' showing nothing, and the browser's recording indicator stayed lit until unmount. Co-Authored-By: Claude Opus 5 (1M context) --- frontend/src/lib/voice/useVoiceCapture.ts | 23 +++++++++++++++++++++-- 1 file changed, 21 insertions(+), 2 deletions(-) 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);