Files
learnharness/docs/research/stt.md
T
amosblomqvist 2d3f0e0412 feat: dictation round 2 — deferred transcript, adaptive meter
Owner's second live test:

- DEFERRED transcript: finals accumulate in a buffer during the run and
  flush into the primary input once, when dictation ends (Stop →
  CloseStream → Deepgram's last finals → socket close → one joined
  append; the 3s safety net covers a missing close). Nothing textual
  renders while recording — the meter is the only live feedback. Live
  output may return as a toggleable setting at the settings milestone;
  deferred is the default behavior.
- ADAPTIVE meter: cell count derives from the measured strip width (4px
  pitch, 8-48 cells, ResizeObserver; cells drop their minimum width) so
  narrow rows beside the knowledge-context slider fit — verified in the
  dev shell: draft 20 cells/83px, chat 48 cells/273px, no overlap.
2026-09-17 20:06:07 +03:00

75 lines
9.2 KiB
Markdown
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.
# STT research — verified findings (2026-09-17)
Two researcher passes over current (Sept 2026) sources, cross-checked
against our codebase. Decisions at the bottom.
## Cloud: Deepgram (the default)
**Verified facts**
- Streaming is a **WebSocket API** (`wss://api.deepgram.com/v1/listen`); REST is batch-only. Query params carry config; binary audio frames up, JSON `Results` down; client messages `KeepAlive` / `Finalize` / `CloseStream`.
- JS SDK: `@deepgram/sdk` **v5** (v5.11.0, Sept 2026) — browser-capable WebSocket (live transcription works in the renderer directly; only REST needs a CORS proxy). `listen.v1` socket = Nova; `listen.v2` = Flux (conversational, turn-taking — overkill for dictation, skip).
- Model: **`nova-3`** (still the flagship general/dictation model as of Sept 2026; no Nova-4). `language=multi` exists for code-switching.
- **Auth: API key via `Authorization: token` header.** For client-side use, Deepgram recommends **short-lived access tokens** (`auth.v1.tokens.grant()`) instead of embedding the key — fits our architecture exactly: key stays in auth.json → server env; `/api/stt/token` mints a token; renderer opens `wss` directly with it. No audio proxying through our server, no `ws` dependency on the server.
- Pricing: streaming Nova-3 **$0.0048/min promotional** (regular $0.0077), pre-recorded $0.0043/min; per-second billing. **$200 one-time free credit**, no card. Diarization +$0.0020/min streaming — skip (single-user dictation).
- `http://127.0.0.1` is a **secure context** (potentially trustworthy origin per the Secure Contexts spec) — `getUserMedia` works without TLS. Our permission handlers (request + check, media-only, loopback-origin-only) are already the documented required pattern.
- macOS: `NSMicrophoneUsageDescription` already in the packaged build. TCC bites fully only post-signing — test the packaged signed app later. Call `app.focus()` / `win.show()` before first capture so the TCC prompt isn't behind the window.
**Capture: AudioWorklet → linear16, 16 kHz mono** (not MediaRecorder)
- `getUserMedia({ audio: { sampleRate: 16000, channelCount: 1, echoCancellation: true } })`, Float32→Int16 in an **AudioWorklet** (off-main-thread), stream linear16 frames with `encoding=linear16&sample_rate=16000&channels=1`. Deterministic format, no container surprises (webm/opus streaming has unresolved community reports of empty transcripts), no encoder lookahead, ~100–200ms chunks. This is what Deepgram's own browser layer does. The worklet module ships as a static file under `public/` — rides the standalone bundle for free.
- Zero new npm deps for the cloud path: getUserMedia + Worklet + WebSocket are platform APIs.
**Gotchas that shape the design**
- **10s idle timeout** (server closes with NET-0001): send `KeepAlive` text frames every 3–5s during silence. Timestamps resume contiguously — long pauses in a dictation run are safe.
- **Interim results**: `interim_results=true` → partials (`is_final=false`) to render live; finals (`speech_final=true` / `is_final=true`) commit. `speech_final` (endpointing-driven) and `is_final` (model-driven) are different flags — check both.
- **Endpointing + UtteranceEnd**: endpointing uses Deepgram's server-side VAD (`endpointing=500` for a real pause threshold; the documented "10 ms default" is a doc quirk — always set it explicitly). Add `utterance_end_ms=1000` as the noise-robust backstop (word-timing-gap based, needs `interim_results=true`).
- `smart_format=true` (punctuation, caps, numbers/dates) — but it can *delay* endpointing while formatting e.g. phone numbers; keep UtteranceEnd on.
- **No server-side buffering on disconnect**: reconnect + resume is our job (SDK v5 auto-reconnect, cancellable via `abortSignal`).
**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": "<jwt>" }`.
- **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).
- **Metal-accelerated** on Apple Silicon: RTF on M-series with Metal — tiny 0.02, base 0.04, small 0.08 (every Apple Silicon Mac runs `small` faster than real time; ~4.4× slower on CPU-only).
- Models (ggml, MIT): **base.en 142 MB** (WER ~5.5%), **small.en 466 MB** (~3.4%) — our default + quality tier. Skip medium (1.5 GB / 2.5 GB RAM). RAM ≈ 3–5× file size. Cache under `<LH_DATA_DIR>/models/` (our existing model-cache seam), download on first use, checksum-verify; distil variants need self-hosting — skip for v1.
- **transformers.js Whisper is a non-goal for v1**: no Metal-verified Node path, no credible Apple-Silicon ONNX benchmarks, weaker ASR catalog — but keep it noted as the "zero new native binary" fallback since we already ship transformers.js + onnxruntime-node.
- Apple Speech (SFSpeechRecognizer/SpeechAnalyzer): 1-min cap, 1k req/h, second permission prompt, macOS-26 gating for the good path, WER ~5.5% vs whisper.cpp small 3.4% — defer.
**Local flow (per-utterance batch, honest trade-off vs cloud streaming)**: energy VAD in the worklet → ~1s silence closes the utterance → POST the buffered PCM to the server → 44-byte WAV header, no ffmpeg → spawn `whisper-cli --model <path>` → text appended to the composer.
## Decisions (owner, 2026-09-17)
- **Activation: toggle mic** — click to start a dictation run, click again (or Esc) to end; no push-to-talk before the shortcuts milestone.
- **Wave scope: cloud first, local second** — capture component + Deepgram path ship and get live-smoked as one wave; whisper.cpp follows as a second wave.
- Renderer never holds the real key (server mints short-lived Deepgram tokens); audio never loops through our server (renderer → wss direct).
- Engine picker = small popover off the mic button (localStorage for now; settings milestone absorbs it).
- Bands: 1) capture + mic states (no engine), 2) cloud end-to-end, 3) local end-to-end, 4) smoke + docs.
## Round-1 refinements (owner, 2026-09-17 — first live mic test)
- **No interims.** Live words beside the meter are distracting AND shift the meter around — the meter's width stays fixed while recording. Params now match the pi dictate extension exactly: `interim_results` omitted (false), `endpointing=300`, no `utterance_end_ms`.
- **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") — never the hidden composer. Implemented as a module-level claim registry (`useDictationTarget`); the composer is the fallback when nothing claims.
- **Optimistic meter**: the click enters the meter view immediately (Stop pill + meter within one frame); the token mint and mic acquisition run concurrently (Promise.all) behind it. Esc or click-again stops.
- **Wide idle button**: the stats-bar middle is one full-width affordance — a quiet bordered "Dictate" button when idle (error text replaces the label on failure), the Stop pill + fixed meter while live.
- Final-appending keeps the dictate-flush separator rule (`joinDictation`).
## Round-2 refinements (owner, 2026-09-17 — second live test)
- **Deferred transcript**: finals buffer during the run and flush into the primary input ONCE at the end (socket-close → one joined append). Nothing textual renders while recording. Live output may return as a toggleable setting at the settings milestone; deferred is the default.
- **Adaptive meter**: cell count derives from the meter's measured width (4px pitch, clamped 8–48, ResizeObserver; cells have no minimum width). Draft/home rows beside the knowledge-context slider show ~20 cells; chat shows the full 48. No more overflowing under neighbors.