feat: dictation round 2 — deferred transcript, adaptive meter

Owner's second live test:

- DEFERRED transcript: finals accumulate in a buffer during the run and
  flush into the primary input once, when dictation ends (Stop →
  CloseStream → Deepgram's last finals → socket close → one joined
  append; the 3s safety net covers a missing close). Nothing textual
  renders while recording — the meter is the only live feedback. Live
  output may return as a toggleable setting at the settings milestone;
  deferred is the default behavior.
- ADAPTIVE meter: cell count derives from the measured strip width (4px
  pitch, 8-48 cells, ResizeObserver; cells drop their minimum width) so
  narrow rows beside the knowledge-context slider fit — verified in the
  dev shell: draft 20 cells/83px, chat 48 cells/273px, no overlap.
This commit is contained in:
amosblomqvist
2026-09-17 20:06:07 +03:00
parent 4dbbfa6fe7
commit 2d3f0e0412
2 changed files with 65 additions and 10 deletions
+60 -10
View File
@@ -30,6 +30,18 @@ import { Loader2Icon, MicIcon } from "lucide-react";
* round 1: live words are distracting and shift the meter; the * round 1: live words are distracting and shift the meter; the
* meter's width is FIXED while recording). * meter's width is FIXED while recording).
* *
* Owner round 2 (2026-09-17, second live test):
* - DEFERRED transcript: finals accumulate in a buffer during the run
* and flush into the primary input ONCE, when the dictation ends
* (click-again/Esc → Deepgram flushes its last finals → socket close
* → one joined append). Nothing textual renders while recording —
* only the meter. Live output may return as a toggleable setting at
* the settings milestone; deferred is the only behavior for now.
* - ADAPTIVE meter: the strip's cell count derives from its measured
* width (4px pitch, 8–48 cells, ResizeObserver) so narrow rows (draft/
* home, where the knowledge-context slider eats the middle) show
* fewer bars instead of overflowing under their neighbors.
*
* Owner round 1 (2026-09-17, first live mic test): * Owner round 1 (2026-09-17, first live mic test):
* - ONE primary transcription input at all times: the pending * - ONE primary transcription input at all times: the pending
* quiz/ask question's field while one waits (text mode → the answer * quiz/ask question's field while one waits (text mode → the answer
@@ -143,10 +155,24 @@ export function useDictation({ append }: { append: (text: string) => void }): Di
const genRef = useRef(0); // stale-handler guard (dictate pattern) const genRef = useRef(0); // stale-handler guard (dictate pattern)
const safetyRef = useRef<ReturnType<typeof setTimeout> | null>(null); const safetyRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const reconnectsRef = useRef(0); const reconnectsRef = useRef(0);
/** The deferred transcript (owner round 2): finals land here during the
* run and flush into the primary input once, at teardown. */
const bufferRef = useRef<string[]>([]);
/** Flush the buffered transcript into the primary input — question claim
* first, composer fallback. Called exactly once per run, at cleanup; the
* buffer clears itself so double-cleanup is a no-op. */
const flush = useCallback(() => {
if (bufferRef.current.length === 0) return;
const text = bufferRef.current.join(" ");
bufferRef.current = [];
(questionTarget ?? appendRef.current)(text);
}, []);
/** Full teardown of one run's machinery. Idempotent; generation-guarded by callers. */ /** Full teardown of one run's machinery. Idempotent; generation-guarded by callers. */
const cleanup = useCallback(() => { const cleanup = useCallback(() => {
genRef.current++; genRef.current++;
flush(); // the deferred transcript appears when the run ends (owner round 2)
if (safetyRef.current) { if (safetyRef.current) {
clearTimeout(safetyRef.current); clearTimeout(safetyRef.current);
safetyRef.current = null; safetyRef.current = null;
@@ -196,12 +222,10 @@ export function useDictation({ append }: { append: (text: string) => void }): Di
} }
if (msg.type === "Results") { if (msg.type === "Results") {
const text: string | undefined = msg.channel?.alternatives?.[0]?.transcript; const text: string | undefined = msg.channel?.alternatives?.[0]?.transcript;
// Only finals stream (interim_results=false): each Results // Only finals stream (interim_results=false). DEFERRED (owner
// message is a committed utterance — route it to the // round 2): they buffer here and reach the input once, at
// primary input, question claim first, composer fallback. // teardown — the meter is the only live feedback.
if (msg.is_final && text) { if (msg.is_final && text) bufferRef.current.push(text);
(questionTarget ?? appendRef.current)(text);
}
} }
}); });
ws.addEventListener("close", (ev) => { ws.addEventListener("close", (ev) => {
@@ -239,6 +263,7 @@ export function useDictation({ append }: { append: (text: string) => void }): Di
setError(null); setError(null);
setPhase("connecting"); setPhase("connecting");
reconnectsRef.current = 0; reconnectsRef.current = 0;
bufferRef.current = []; // fresh run, fresh buffer (round 2)
const gen = ++genRef.current; const gen = ++genRef.current;
// Token + mic CONCURRENTLY (owner round 1: the sequential wait was the // Token + mic CONCURRENTLY (owner round 1: the sequential wait was the
@@ -346,6 +371,11 @@ export function DictationBar({ dictation }: { dictation: Dictation }) {
const { phase, error, levelRef, toggle, active } = dictation; const { phase, error, levelRef, toggle, active } = dictation;
const [levels, setLevels] = useState<number[]>(() => new Array(METER_CELLS).fill(0)); const [levels, setLevels] = useState<number[]>(() => new Array(METER_CELLS).fill(0));
const recording = phase === "recording" || phase === "stopping"; const recording = phase === "recording" || phase === "stopping";
/** Adaptive cell count (owner round 2): derived from the meter's
* measured width — narrow rows (draft/home beside the knowledge-context
* slider) show fewer bars instead of overflowing under neighbors. */
const [cellCount, setCellCount] = useState(METER_CELLS);
const meterRef = useRef<HTMLDivElement | null>(null);
// The meter's own heartbeat — 60ms samples of the level ref into a // The meter's own heartbeat — 60ms samples of the level ref into a
// rolling ring. Only THIS strip re-renders; the page never does. // rolling ring. Only THIS strip re-renders; the page never does.
@@ -362,6 +392,25 @@ export function DictationBar({ dictation }: { dictation: Dictation }) {
return () => clearInterval(id); return () => clearInterval(id);
}, [active, levelRef]); }, [active, levelRef]);
// Fit the cell count to the available width (4px pitch per cell:
// 2px bar + 2px gap, clamped 8..48). The meter div's width is set by
// the row's free space — cells carry no minimum width — so measuring
// it once per activation + on resize keeps the strip honest in the
// draft/home rows where the slider leaves little middle.
useEffect(() => {
if (!active) return;
const el = meterRef.current;
if (!el) return;
const fit = () => {
const w = el.clientWidth;
setCellCount(Math.max(8, Math.min(METER_CELLS, Math.floor(w / 4))));
};
fit();
const ro = new ResizeObserver(fit);
ro.observe(el);
return () => ro.disconnect();
}, [active]);
if (!active) { if (!active) {
return ( return (
<button <button
@@ -398,12 +447,13 @@ export function DictationBar({ dictation }: { dictation: Dictation }) {
</button> </button>
<span className="size-1.5 shrink-0 animate-pulse rounded-full bg-destructive" aria-hidden /> <span className="size-1.5 shrink-0 animate-pulse rounded-full bg-destructive" aria-hidden />
{/* Fixed width while recording — nothing ever appends text beside it {/* Fixed width while recording — nothing ever appends text beside it
(no interims, owner round 1). Bars flex to the row's free space. */} (no interims, owner round 1). Cells carry no minimum width; the
<div className="flex h-4 min-w-0 flex-1 items-center gap-[2px]" data-testid="dictation-meter"> COUNT adapts to the measured row space (owner round 2). */}
{levels.map((v, i) => ( <div ref={meterRef} className="flex h-4 min-w-0 flex-1 items-center gap-[2px]" data-testid="dictation-meter">
{levels.slice(-cellCount).map((v, i) => (
<span <span
key={i} key={i}
className="min-w-[2px] flex-1 rounded-full bg-foreground/40" className="min-w-0 flex-1 rounded-full bg-foreground/40"
style={{ height: `${2 + Math.round(v * 14)}px` }} style={{ height: `${2 + Math.round(v * 14)}px` }}
/> />
))} ))}
+5
View File
@@ -67,3 +67,8 @@ against our codebase. Decisions at the bottom.
- **Optimistic meter**: the click enters the meter view immediately (Stop pill + meter within one frame); the token mint and mic acquisition run concurrently (Promise.all) behind it. Esc or click-again stops. - **Optimistic meter**: the click enters the meter view immediately (Stop pill + meter within one frame); the token mint and mic acquisition run concurrently (Promise.all) behind it. Esc or click-again stops.
- **Wide idle button**: the stats-bar middle is one full-width affordance — a quiet bordered "Dictate" button when idle (error text replaces the label on failure), the Stop pill + fixed meter while live. - **Wide idle button**: the stats-bar middle is one full-width affordance — a quiet bordered "Dictate" button when idle (error text replaces the label on failure), the Stop pill + fixed meter while live.
- Final-appending keeps the dictate-flush separator rule (`joinDictation`). - Final-appending keeps the dictate-flush separator rule (`joinDictation`).
## Round-2 refinements (owner, 2026-09-17 — second live test)
- **Deferred transcript**: finals buffer during the run and flush into the primary input ONCE at the end (socket-close → one joined append). Nothing textual renders while recording. Live output may return as a toggleable setting at the settings milestone; deferred is the default.
- **Adaptive meter**: cell count derives from the meter's measured width (4px pitch, clamped 8–48, ResizeObserver; cells have no minimum width). Draft/home rows beside the knowledge-context slider show ~20 cells; chat shows the full 48. No more overflowing under neighbors.