Files
learnharness/app/api/stt/token/route.ts
T
amosblomqvist e3bdbc2f2c 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.
2026-09-17 19:31:25 +03:00

47 lines
1.7 KiB
TypeScript

/**
* POST-less BYOK mint: the renderer never holds the real Deepgram key.
*
* The key lives server-side only (auth.json → process.env in the desktop
* app; .env.local in dev — same seam as OPENROUTER_API_KEY). A dictation
* run starts here: mint a short-lived access token (TTL 300s — a run can
* outlast the 30s default), the renderer opens its WebSocket to Deepgram
* with it, and audio flows renderer → Deepgram directly, never through
* this server. The token is only checked at the WebSocket handshake, so
* long dictation sessions survive the TTL fine (Deepgram docs).
*
* NOTE: the key needs ≥ Member permission in the Deepgram console to call
* /auth/grant (their docs, token-based auth FAQ) — plain read/transcribe
* keys return FORBIDDEN here.
*/
export async function GET() {
const key = process.env.DEEPGRAM_API_KEY;
if (!key) {
return Response.json(
{ message: "No DEEPGRAM_API_KEY — add it to auth.json (desktop) or .env.local (dev)." },
{ status: 503 },
);
}
try {
const res = await fetch("https://api.deepgram.com/v1/auth/grant", {
method: "POST",
headers: { Authorization: `Token ${key}`, "Content-Type": "application/json" },
body: JSON.stringify({ ttl_seconds: 300 }),
});
if (!res.ok) {
const detail = await res.text();
return Response.json(
{ message: `Deepgram token grant failed (${res.status}): ${detail.slice(0, 200)}` },
{ status: 502 },
);
}
const data = (await res.json()) as { token?: string };
if (!data.token) {
return Response.json({ message: "Deepgram grant returned no token" }, { status: 502 });
}
return Response.json({ token: data.token });
} catch {
return Response.json({ message: "Deepgram unreachable" }, { status: 502 });
}
}