feat(treatment): add voice detail entry #65
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user