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:
@@ -5,6 +5,7 @@ import { useRouter } from "next/navigation";
|
||||
import { CornerDownLeftIcon } from "lucide-react";
|
||||
import { GraphView } from "@/components/graph-view";
|
||||
import { GraphContextControl, effectiveContextTokens } from "@/components/graph-context-control";
|
||||
import { DictationBar, useDictation } from "@/components/dictation";
|
||||
import { SidebarToggle } from "@/components/sidebar";
|
||||
import { StatsBar } from "@/components/stats-bar";
|
||||
import type { ChatSettings, TurnUsage } from "@/src/core";
|
||||
@@ -18,6 +19,11 @@ import type { ChatSettings, TurnUsage } from "@/src/core";
|
||||
export function Home() {
|
||||
const router = useRouter();
|
||||
const [topic, setTopic] = useState("");
|
||||
// Voice dictation (STT wave, 2026-09-17): finals append at the end of the
|
||||
// topic draft; the strip lives in the stats bar's middle slot.
|
||||
const dictation = useDictation({
|
||||
append: (t) => setTopic((v) => (v ? v.replace(/\s+$/, "") + " " + t : t)),
|
||||
});
|
||||
const [busy, setBusy] = useState(false);
|
||||
// The model+thinking selection made pre-chat — lifted from the StatsBar's
|
||||
// ModelControl, carried to the chat POST then PUT-on-create (the model
|
||||
@@ -97,6 +103,12 @@ export function Home() {
|
||||
value={topic}
|
||||
onChange={(e) => setTopic(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
// Esc ends dictation (the dictate decisions: click-again or Esc).
|
||||
if (e.key === "Escape" && dictation.active) {
|
||||
e.preventDefault();
|
||||
dictation.stop();
|
||||
return;
|
||||
}
|
||||
if (e.key === "Enter" && !e.shiftKey) {
|
||||
e.preventDefault();
|
||||
void start();
|
||||
@@ -121,6 +133,7 @@ export function Home() {
|
||||
budget (session-data slot), while the RIGHT keeps the model +
|
||||
thinking control (deferred save). */}
|
||||
<StatsBar
|
||||
dictationSlot={<DictationBar dictation={dictation} />}
|
||||
turns={[] as TurnUsage[]}
|
||||
chatId=""
|
||||
settings={settings}
|
||||
|
||||
Reference in New Issue
Block a user