fix(frontend): stop the level meter re-rendering the whole workspace
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) <noreply@anthropic.com>
This commit is contained in:
@@ -219,6 +219,14 @@ export function useVoiceCapture({
|
|||||||
// minutes of dictation because a timer expired would be the worst failure.
|
// minutes of dictation because a timer expired would be the worst failure.
|
||||||
if (maxMs != null && elapsed >= maxMs) stop();
|
if (maxMs != null && elapsed >= maxMs) stop();
|
||||||
}, LEVEL_POLL_MS);
|
}, 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 {
|
} finally {
|
||||||
startingRef.current = false;
|
startingRef.current = false;
|
||||||
}
|
}
|
||||||
@@ -262,12 +270,23 @@ function attachLevelMeter(
|
|||||||
source.connect(analyser);
|
source.connect(analyser);
|
||||||
|
|
||||||
const data = new Uint8Array(analyser.frequencyBinCount);
|
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;
|
if (contextRef.current !== context || context.state === 'closed') return;
|
||||||
analyser.getByteTimeDomainData(data);
|
analyser.getByteTimeDomainData(data);
|
||||||
let peak = 0;
|
let peak = 0;
|
||||||
for (const sample of data) peak = Math.max(peak, Math.abs(sample - 128));
|
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);
|
||||||
};
|
};
|
||||||
requestAnimationFrame(tick);
|
requestAnimationFrame(tick);
|
||||||
|
|||||||
Reference in New Issue
Block a user