Files
learnharness/components/dictation.tsx
T
amosblomqvist 4dbbfa6fe7 feat: dictation round 1 — primary-input routing, no interims, optimistic meter, wide button
Owner's first live mic test (2026-09-17) drove four changes:

- ONE primary transcription input at all times: while a quiz/ask
  question waits, finals land in its field (text mode → 'Your answer',
  select modes → 'Your thinking') via a module-level claim registry
  (useDictationTarget in dictation.tsx); the composer is the fallback.
  Esc in a question field also stops dictation (threaded through
  DockOverlay → QuestionDock).
- No interim results: live words were distracting and shifted the meter.
  Params now match the pi dictate extension exactly (no interim_results,
  endpointing=300, no utterance_end_ms); the meter's width stays fixed.
- Optimistic meter: the click enters the meter view immediately (Stop
  pill + meter within one frame, verified at 150ms in the dev shell);
  token mint and mic acquisition run concurrently behind it.
- Wide idle button: the stats-bar middle is one full-width affordance —
  a quiet bordered 'Dictate' button when idle (failure text replaces the
  label), Stop pill + fixed meter while live.

Live-verified in the Electron dev shell: wide button renders on home
(167px) and chat (358px), meter + Stop appear at 150ms after click,
flat 2px bars in silence, clean return to 'Dictate' on stop.
2026-09-17 19:59:14 +03:00

414 lines
15 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"use client";
import { useCallback, useEffect, useRef, useState } from "react";
import { Loader2Icon, MicIcon } from "lucide-react";
/**
* Voice dictation — the cloud engine (STT wave 1, 2026-09-17).
*
* Architecture (docs/research/stt.md): the renderer captures mic audio in
* an AudioWorklet (public/worklets/pcm-capture.js → linear16 16kHz mono),
* opens the Deepgram streaming WebSocket DIRECTLY (audio never loops
* through our server) with a short-lived token minted by /api/stt/token
* (the real key stays server-side), and streams frames while it listens.
* Finals append into the PRIMARY INPUT (see below).
*
* Ported patterns from the owner's pi `dictate` extension (his words:
* "very well tuned") — see ~/.pi/agent/extensions/dictate:
* - generation counter: every start/stop increments it; all async
* handlers no-op when stale, so a dying session's socket can never
* tear down the current one.
* - meter: latest-RMS-never-resets — the level ref keeps the last
* observed value, so a tick with no fresh audio re-renders the last
* level instead of dropping to silence (60ms tick samples the ref).
* - dB mapping floor −50 / ceiling −10: the sweet spot for showing
* conversational speech as mid-height bars without pegging.
* - stop: mic dies FIRST (no more audio enqueues), then CloseStream,
* then a 3s safety net in case Deepgram never closes the socket.
* - interim_results=false, endpointing=300: his live-tuned params — no
* partial hypotheses, finals commit ~300ms after a pause (owner
* round 1: live words are distracting and shift the meter; the
* meter's width is FIXED while recording).
*
* Owner round 1 (2026-09-17, first live mic test):
* - ONE primary transcription input at all times: the pending
* quiz/ask question's field while one waits (text mode → the answer
* field, select modes → "Your thinking"), else the page's composer.
* Implemented as a module-level registry the question form claims on
* mount — see useDictationTarget below.
* - Click → meter IMMEDIATELY (optimistic): the meter strip renders
* from the click, connection + TCC happen behind it. The token mint
* and the mic acquisition run CONCURRENTLY (Promise.all) to shrink
* the real gap.
*
* Meter rendering lives in <DictationBar> (own 60ms timer over the level
* ref) so the 16-renders/sec never touch the page component — only the
* tiny bar strip repaints.
*/
/*
* Deepgram streaming params (the owner's live-tuned dictate config): nova-3,
* endpointing=300, no interims. Silence streams too (the worklet never
* pauses), so the socket never idles — no KeepAlive needed.
*/
const DG_URL =
"wss://api.deepgram.com/v1/listen" +
"?model=nova-3" +
"&encoding=linear16" +
"&sample_rate=16000" +
"&channels=1" +
"&smart_format=true" +
"&punctuate=true" +
"&endpointing=300";
/** Meter tuning — the owner's tested values, see the file header. */
const METER_FLOOR_DB = -50;
const METER_CEILING_DB = -10;
export const METER_TICK_MS = 60;
/** Rolling history: 48 cells × 60ms ≈ 2.9s of recent loudness. */
export const METER_CELLS = 48;
/** RMS (0..1) → normalized 0..1 across the audible dB window. */
export function rmsToMeter(rms: number): number {
if (rms <= 0) return 0;
const db = 20 * Math.log10(rms);
const t = (db - METER_FLOOR_DB) / (METER_CEILING_DB - METER_FLOOR_DB);
return Math.max(0, Math.min(1, t));
}
/**
* The primary-input registry: at most ONE non-composer input can claim the
* dictation target while mounted — the pending question's answer/thinking
* field (question-dock.tsx). The page's `append` (the composer) is the
* fallback when nobody claims. Module-level by design: the claimant is a
* sibling subtree far from the hook's owner, and there is exactly one
* window, one pending question, one target at a time.
*/
let questionTarget: ((text: string) => void) | null = null;
/** Join a dictated final onto the current text (shared separator rule). */
export function joinDictation(current: string, final: string): string {
return current ? current.replace(/\s+$/, "") + " " + final : final;
}
/**
* Claim the dictation target while mounted (and `claimed` is true). The
* unmount guard is identity-based so dev-mode double-invoked effects
* can't clear another claimant.
*/
export function useDictationTarget(append: (text: string) => void, claimed: boolean) {
useEffect(() => {
if (!claimed) return;
questionTarget = append;
return () => {
if (questionTarget === append) questionTarget = null;
};
}, [append, claimed]);
}
export type DictationPhase = "idle" | "connecting" | "recording" | "stopping";
export type Dictation = {
phase: DictationPhase;
/** Terminal-state explanation, shown inside the wide button (cleared on the next start). */
error: string | null;
/** Latest observed RMS, 0..1 — sampled by the meter's own tick, never reset. */
levelRef: { current: number };
/** Not idle — from the click until the stop completes (the optimistic window). */
active: boolean;
toggle: () => void;
stop: () => void;
};
/**
* One dictation instance per composer-bearing page. `append` is the
* composer fallback; the mounted question form's claim wins while a
* question waits.
*/
export function useDictation({ append }: { append: (text: string) => void }): Dictation {
const [phase, setPhase] = useState<DictationPhase>("idle");
const [error, setError] = useState<string | null>(null);
/** Latest RMS from the worklet — the meter samples this (dictate pattern). */
const levelRef = useRef(0);
/** The page's fallback append — kept fresh per render so a mid-recording
* retarget (composer↔question) is honored on the very next final. */
const appendRef = useRef(append);
appendRef.current = append;
// Live machinery (non-render values). AudioContext/MediaStream are
// closed on stop; the socket is recreated per run.
const wsRef = useRef<WebSocket | null>(null);
const ctxRef = useRef<AudioContext | null>(null);
const streamRef = useRef<MediaStream | null>(null);
const genRef = useRef(0); // stale-handler guard (dictate pattern)
const safetyRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const reconnectsRef = useRef(0);
/** Full teardown of one run's machinery. Idempotent; generation-guarded by callers. */
const cleanup = useCallback(() => {
genRef.current++;
if (safetyRef.current) {
clearTimeout(safetyRef.current);
safetyRef.current = null;
}
for (const track of streamRef.current?.getTracks() ?? []) track.stop();
streamRef.current = null;
void ctxRef.current?.close().catch(() => {});
ctxRef.current = null;
const ws = wsRef.current;
wsRef.current = null;
if (ws && (ws.readyState === WebSocket.OPEN || ws.readyState === WebSocket.CONNECTING)) {
try {
ws.close();
} catch {}
}
setPhase("idle");
}, []);
/** Mint a short-lived token (the route holds the real key server-side). */
const mintToken = useCallback(async () => {
const res = await fetch("/api/stt/token");
const data = (await res.json()) as { token?: string; message?: string };
if (!res.ok || !data.token) throw new Error(data.message ?? `token route ${res.status}`);
return data.token;
}, []);
/** Open the Deepgram socket and wire its messages. `gen` guards staleness. */
const openSocket = useCallback(
(gen: number, token: string) => {
// Minted JWTs authenticate via the "bearer" subprotocol — ["token",
// jwt] is rejected at the handshake (verified live; "token" is for
// raw API keys only, as in the owner's pi dictate extension).
const ws = new WebSocket(DG_URL, ["bearer", token]);
wsRef.current = ws;
ws.addEventListener("open", () => {
if (gen !== genRef.current) return;
setPhase("recording");
});
ws.addEventListener("message", (ev) => {
if (gen !== genRef.current) return;
let msg: any;
try {
msg = JSON.parse(ev.data as string);
} catch {
return;
}
if (msg.type === "Results") {
const text: string | undefined = msg.channel?.alternatives?.[0]?.transcript;
// Only finals stream (interim_results=false): each Results
// message is a committed utterance — route it to the
// primary input, question claim first, composer fallback.
if (msg.is_final && text) {
(questionTarget ?? appendRef.current)(text);
}
}
});
ws.addEventListener("close", (ev) => {
if (gen !== genRef.current) return;
if (ev.code === 1000) {
// Our own CloseStream landing: finalize now — the stop path's
// safety net stays as the no-close backstop only.
cleanup();
return;
}
// Abnormal close mid-run: ONE reconnect with a fresh token (the
// research pass: resume is our job, no SDK to lean on).
if (reconnectsRef.current < 1) {
reconnectsRef.current++;
mintToken()
.then((t) => {
if (gen === genRef.current) openSocket(gen, t);
})
.catch(() => {
if (gen === genRef.current) {
setError("Deepgram reconnect failed");
cleanup();
}
});
} else {
setError(`Deepgram connection lost (${ev.code})`);
cleanup();
}
});
},
[cleanup, mintToken],
);
const start = useCallback(async () => {
setError(null);
setPhase("connecting");
reconnectsRef.current = 0;
const gen = ++genRef.current;
// Token + mic CONCURRENTLY (owner round 1: the sequential wait was the
// felt delay) — the optimistic meter already covers the gap. 127.0.0.1
// is a secure context, so getUserMedia works without TLS; macOS TCC
// prompts on first use.
const mic = navigator.mediaDevices.getUserMedia({
audio: { channelCount: 1, echoCancellation: true, noiseSuppression: true, autoGainControl: true },
});
let token: string;
try {
const [t, stream] = await Promise.all([mintToken(), mic]);
token = t;
if (gen !== genRef.current) {
for (const track of stream.getTracks()) track.stop();
return;
}
streamRef.current = stream;
const ctx = new AudioContext({ sampleRate: 16000 });
ctxRef.current = ctx;
await ctx.audioWorklet.addModule("/worklets/pcm-capture.js");
const worklet = new AudioWorkletNode(ctx, "pcm-capture");
worklet.port.onmessage = (e) => {
const msg = e.data as { type: string; rms?: number; buffer?: ArrayBuffer };
if (msg.type === "level") {
levelRef.current = msg.rms ?? 0; // never reset to zero (dictate pattern)
} else if (msg.type === "audio") {
const ws = wsRef.current;
if (ws && ws.readyState === WebSocket.OPEN) ws.send(msg.buffer!);
}
};
ctx.createMediaStreamSource(stream).connect(worklet);
// Pull the graph with silence out (the worklet writes no output) —
// an unconnected worklet node is never clocked.
worklet.connect(ctx.destination);
} catch (e) {
if (gen !== genRef.current) return;
const msg =
e instanceof DOMException && (e.name === "NotAllowedError" || e.name === "SecurityError")
? "Microphone permission denied"
: e instanceof Error
? e.message
: "mic unavailable";
setError(msg);
cleanup();
return;
}
if (gen !== genRef.current) return;
openSocket(gen, token);
}, [openSocket, cleanup, mintToken]);
const stop = useCallback(() => {
const gen = genRef.current;
if (phase !== "recording" && phase !== "connecting") return;
setPhase("stopping");
// Mic dies FIRST — no more audio enqueues (dictate pattern).
for (const track of streamRef.current?.getTracks() ?? []) track.stop();
streamRef.current = null;
void ctxRef.current?.close().catch(() => {});
ctxRef.current = null;
const ws = wsRef.current;
if (ws && ws.readyState === WebSocket.OPEN) {
try {
ws.send(JSON.stringify({ type: "CloseStream" }));
} catch {
cleanup();
return;
}
// Deepgram flushes remaining finals then closes; if it never
// does, the safety net ends the run anyway (dictate pattern).
safetyRef.current = setTimeout(() => {
if (gen === genRef.current) cleanup();
}, 3000);
} else {
cleanup();
}
}, [phase, cleanup]);
const toggle = useCallback(() => {
if (phase === "idle") void start();
else if (phase === "recording" || phase === "connecting") stop();
// "stopping" presses are ignored — the flush is in flight.
}, [phase, start, stop]);
return { phase, error, levelRef, active: phase !== "idle", toggle, stop };
}
/**
* The stats-bar dictation slot (owner placement, round 1): the row's middle
* is one strip that is always the full free width —
*
* idle a wide, quiet "Dictate" button (mic + label, grayed out — the
* whole middle is the affordance, per the owner).
* active optimistic from the CLICK: red stop pill + the fixed-width
* meter (no interim text ever shifts it). Spinner while
* Deepgram flushes the last final.
* error the wide button again, label swapped for the failure reason
* (full text in the tooltip), cleared on the next click.
*/
export function DictationBar({ dictation }: { dictation: Dictation }) {
const { phase, error, levelRef, toggle, active } = dictation;
const [levels, setLevels] = useState<number[]>(() => new Array(METER_CELLS).fill(0));
const recording = phase === "recording" || phase === "stopping";
// The meter's own heartbeat — 60ms samples of the level ref into a
// rolling ring. Only THIS strip re-renders; the page never does.
useEffect(() => {
if (!active) return;
setLevels(new Array(METER_CELLS).fill(0));
const id = setInterval(() => {
setLevels((ring) => {
const next = ring.slice(1);
next.push(rmsToMeter(levelRef.current));
return next;
});
}, METER_TICK_MS);
return () => clearInterval(id);
}, [active, levelRef]);
if (!active) {
return (
<button
type="button"
onClick={toggle}
title={error ?? "Dictate into the composer"}
aria-label={error ? `Dictation failed: ${error}` : "Start dictation"}
data-testid="dictation-toggle"
className="flex h-7 w-full items-center justify-center gap-1.5 rounded-md border border-border/50 text-xs text-muted-foreground/70 transition-colors hover:border-border hover:text-muted-foreground"
>
<MicIcon className="size-3.5" />
<span className="max-w-full truncate">{error ?? "Dictate"}</span>
</button>
);
}
return (
<div className="flex w-full min-w-0 items-center gap-2" data-testid="dictation-bar">
<button
type="button"
onClick={toggle}
disabled={phase === "stopping"}
title="Stop dictation (Esc)"
aria-label="Stop dictation"
data-testid="dictation-toggle"
className="flex h-7 shrink-0 items-center gap-1.5 rounded-md bg-destructive/10 px-2 text-xs font-medium text-destructive transition-colors hover:bg-destructive/20"
>
{phase === "stopping" ? (
<Loader2Icon className="size-3.5 animate-spin" />
) : (
<MicIcon className="size-3.5" />
)}
Stop
</button>
<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
(no interims, owner round 1). Bars flex to the row's free space. */}
<div className="flex h-4 min-w-0 flex-1 items-center gap-[2px]" data-testid="dictation-meter">
{levels.map((v, i) => (
<span
key={i}
className="min-w-[2px] flex-1 rounded-full bg-foreground/40"
style={{ height: `${2 + Math.round(v * 14)}px` }}
/>
))}
</div>
</div>
);
}