diff --git a/app/api/stt/token/route.ts b/app/api/stt/token/route.ts index 3ff213f..8e021a7 100644 --- a/app/api/stt/token/route.ts +++ b/app/api/stt/token/route.ts @@ -35,11 +35,11 @@ export async function GET() { { status: 502 }, ); } - const data = (await res.json()) as { token?: string }; - if (!data.token) { + const data = (await res.json()) as { access_token?: string }; + if (!data.access_token) { return Response.json({ message: "Deepgram grant returned no token" }, { status: 502 }); } - return Response.json({ token: data.token }); + return Response.json({ token: data.access_token }); } catch { return Response.json({ message: "Deepgram unreachable" }, { status: 502 }); } diff --git a/components/dictation.tsx b/components/dictation.tsx index a7ec9bc..5f17e25 100644 --- a/components/dictation.tsx +++ b/components/dictation.tsx @@ -137,7 +137,10 @@ export function useDictation({ append }: { append: (text: string) => void }): Di /** Open the Deepgram socket and wire its messages. `gen` guards staleness. */ const openSocket = useCallback( (gen: number, token: string) => { - const ws = new WebSocket(DG_URL, ["token", token]); + // 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", () => { diff --git a/docs/research/stt.md b/docs/research/stt.md index 85d080d..bf9f9ec 100644 --- a/docs/research/stt.md +++ b/docs/research/stt.md @@ -30,8 +30,18 @@ against our codebase. Decisions at the bottom. **Alternatives (why still Deepgram)**: OpenAI live-transcribe $0.017/min (~3.5×), no word timestamps/confidence, 60-min session cap. AssemblyAI Universal-Streaming $0.0025/min is cheaper but smaller ecosystem. Speechmatics best for non-English but ~2×. GCP is service-account-shaped — bad BYOK fit. Verdict: Deepgram stays the pragmatic default. +## Cloud: Deepgram (the default) — landed + +**Live-verified facts (2026-09-17, cloud wave)** + +- The **grant response field is `access_token`**, not `token` — `POST /v1/auth/grant` returns `{ "access_token": "" }`. +- **Minted JWTs authenticate over the WebSocket via the `bearer` subprotocol**: `new WebSocket(url, ["bearer", jwt])`. The `token` subprotocol is for **raw API keys only** (the pi dictate extension's path) — `["token", jwt]` is rejected at the handshake (TLS-level close, no 401). +- **The key needs ≥ Member permission** (console → API Keys → Create Key → *Advanced* options → permissions dropdown → Member). A default key can transcribe but gets `FORBIDDEN` from `/auth/grant`. +- **Env precedence gotcha**: a real `DEEPGRAM_API_KEY` exported in the shell (e.g. `.zshrc`) **beats `.env.local`** in both `next dev` and the desktop shell's `authEnv()` seam. Stale exports silently override the file — don't export the key in shell rc files; `.env.local`/auth.json are the canonical seams. + ## Local: whisper.cpp (the Advanced option) + **Verified facts** - **Ship the whisper.cpp CLI (v1.9.4, MIT) as a child process** — the 2026 community norm; every maintained Node wrapper (nodejs-whisper, whisper-node) is just a CLI spawner anyway, and native bindings (smart-whisper last published Oct 2024; the official `addon.node` is a self-compiled reference demo) add electron-rebuild/ABI churn for nothing. Our server already spawns children; per-utterance spawn is fine (model load <1s, stateless).