feat: voice dictation (STT wave 1 — Deepgram cloud)
Two-band architecture from docs/research/stt.md: renderer captures mic audio in an AudioWorklet (linear16 16kHz mono, ~128ms frames, per-quantum RMS for the meter) and streams it directly to Deepgram over WebSocket — audio never loops through our server. The real key stays server-side; /api/stt/token mints a short-lived grant token (TTL 300s) per run. Patterns ported from the owner's pi dictate extension (tuned in real use): generation counter guarding every async handler, the latest-RMS-never-resets meter (-50..-10dB window, 60ms tick), mic-first stop + CloseStream + 3s safety net, endpointing=300 with the utterance_end_ms=1000 backstop. UI (owner placement): mic button in the stats bar's middle slot on all three surfaces (home graph-view, chat, draft); while recording the meter unfurls to fill the row's free width with the interim ghost at its flank. Esc ends dictation from the composer. Token mint comes before getUserMedia so a keyless app fails fast without a TCC prompt; one automatic reconnect on abnormal socket close, errors surface inline. Live-smoked the full error path in the dev shell (bar renders on all three pages, 403 grant failure shows inline, mic untouched). The full audio path needs a Deepgram key with Member permission to mint tokens.
This commit is contained in:
@@ -20,6 +20,7 @@ export function Composer({
|
||||
canSend,
|
||||
autoFocus,
|
||||
verdict,
|
||||
onEscape,
|
||||
}: {
|
||||
busy: boolean;
|
||||
value: string;
|
||||
@@ -32,6 +33,9 @@ export function Composer({
|
||||
* word, the send square becomes two stacked buttons — green accept,
|
||||
* red decline. Typed text rides the verdict as his note. */
|
||||
verdict?: { onAccept: () => void; onDecline: () => void };
|
||||
/** Escape while a dictation runs stops it (STT wave, 2026-09-17). No-op
|
||||
* when absent — Esc otherwise means nothing in the composer. */
|
||||
onEscape?: () => void;
|
||||
}) {
|
||||
return (
|
||||
<form
|
||||
@@ -47,6 +51,13 @@ export function Composer({
|
||||
value={value}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
// Esc ends dictation before anything else Esc might someday mean
|
||||
// here (the dictate decisions: click-again or Esc to stop).
|
||||
if (e.key === "Escape" && onEscape) {
|
||||
e.preventDefault();
|
||||
onEscape();
|
||||
return;
|
||||
}
|
||||
// Chat convention: Enter sends, Shift+Enter is a newline.
|
||||
// ⌘/Ctrl+Enter also sends — the universal submit shortcut
|
||||
// across every input in the app (thinking field, answers).
|
||||
|
||||
Reference in New Issue
Block a user