Files
openchamber/packages/ui/src/components/dictation/DictationWaveform.tsx
T
Bohdan Triapitsyn 23928d342c feat(dictation): transcribe after recording instead of live
Parakeet is an offline model trained on whole utterances, so re-decoding
the growing buffer to animate a live transcript cost O(n^2) work for a
result the final decode replaced. Sessions now decode once per committed
segment, and the composer shows a scrolling waveform of the mic level
instead of running text.

Long dictations split at a pause once past 60s (hard cap 90s) instead of
on a blind 15s timer, so cuts no longer land mid-word. Committed segments
decode while the user is still speaking: a 185s dictation returns 4.1s
after stop instead of 11.0s, with identical text (816 vs 817 words).

Also fixes two ways the stream manager could silently drop transcribed
audio. It now counts the commits it issued instead of trusting the
session's echoed events, so a commit still in flight when the client
finishes can no longer be left out of the final text. And segment
byte/peak accounting is reset where the commit is issued rather than when
the event arrives, which could mistake the tail of a dictation for
silence and clear it.
2026-08-22 01:10:19 +03:00

131 lines
4.6 KiB
TypeScript

/**
* Scrolling microphone level history for the dictation overlay.
*
* Newest sample is at the right edge and the history scrolls left, so the row
* reads as a live recording trace rather than a single level bar. Silence
* renders as a dot, speech as a rounded bar.
*
* Drawn on a canvas fed by a level subscription: the level updates ~12 times a
* second, and routing that through React state would re-render the whole
* dictation overlay at the same rate.
*/
import React from 'react';
import { useThemeSystem } from '@/contexts/useThemeSystem';
import type { DictationLevelListener } from '@/lib/dictation/use-dictation-audio-source';
interface DictationWaveformProps {
subscribeLevel: (listener: DictationLevelListener) => () => void;
className?: string;
}
const BAR_WIDTH = 2;
const BAR_GAP = 3;
const BAR_PITCH = BAR_WIDTH + BAR_GAP;
/** One sample per bar; ~16 bars/s scrolls at a readable speed. */
const SAMPLE_INTERVAL_MS = 60;
/** Raise quiet speech so normal talking uses most of the height. */
const LEVEL_CURVE = 0.65;
export const DictationWaveform: React.FC<DictationWaveformProps> = ({ subscribeLevel, className }) => {
const { currentTheme } = useThemeSystem();
const canvasRef = React.useRef<HTMLCanvasElement | null>(null);
const barColor = currentTheme.colors.surface.mutedForeground;
React.useEffect(() => {
const canvas = canvasRef.current;
if (!canvas) {
return;
}
const context = canvas.getContext('2d');
if (!context) {
return;
}
// Peak-hold between samples: a short loud syllable must not be missed
// just because it landed between two frames.
let peakSinceSample = 0;
const unsubscribe = subscribeLevel((level) => {
peakSinceSample = Math.max(peakSinceSample, level);
});
const bars: number[] = [];
let cssWidth = 0;
let cssHeight = 0;
const resize = () => {
const rect = canvas.getBoundingClientRect();
const dpr = window.devicePixelRatio || 1;
cssWidth = rect.width;
cssHeight = rect.height;
canvas.width = Math.max(1, Math.round(cssWidth * dpr));
canvas.height = Math.max(1, Math.round(cssHeight * dpr));
context.setTransform(dpr, 0, 0, dpr, 0, 0);
};
resize();
const observer = new ResizeObserver(resize);
observer.observe(canvas);
const draw = () => {
const capacity = Math.max(1, Math.floor(cssWidth / BAR_PITCH));
while (bars.length > capacity) {
bars.shift();
}
context.clearRect(0, 0, cssWidth, cssHeight);
context.strokeStyle = barColor;
context.fillStyle = barColor;
context.lineWidth = BAR_WIDTH;
context.lineCap = 'round';
const centerY = cssHeight / 2;
const maxHeight = Math.max(BAR_WIDTH, cssHeight);
// Anchor the newest bar to the right edge; older bars trail left.
const rightX = cssWidth - BAR_WIDTH / 2;
for (let i = 0; i < bars.length; i++) {
const x = rightX - (bars.length - 1 - i) * BAR_PITCH;
if (x < BAR_WIDTH / 2) {
continue;
}
const height = BAR_WIDTH + (maxHeight - BAR_WIDTH) * Math.pow(bars[i], LEVEL_CURVE);
// Round caps add BAR_WIDTH/2 past each end of the stroke, so the
// stroke itself is the height minus one cap diameter.
const half = (height - BAR_WIDTH) / 2;
context.beginPath();
if (half < 0.25) {
context.arc(x, centerY, BAR_WIDTH / 2, 0, Math.PI * 2);
context.fill();
} else {
context.moveTo(x, centerY - half);
context.lineTo(x, centerY + half);
context.stroke();
}
}
};
let frame = 0;
let lastSampleAt = 0;
const tick = (now: number) => {
frame = requestAnimationFrame(tick);
if (now - lastSampleAt < SAMPLE_INTERVAL_MS) {
return;
}
lastSampleAt = now;
bars.push(peakSinceSample);
peakSinceSample = 0;
draw();
};
frame = requestAnimationFrame(tick);
return () => {
cancelAnimationFrame(frame);
observer.disconnect();
unsubscribe();
};
}, [subscribeLevel, barColor]);
return <canvas ref={canvasRef} className={className} aria-hidden="true" />;
};