Files
learnharness/public/worklets/pcm-capture.js
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

56 lines
2.3 KiB
JavaScript
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.
/*
* pcm-capture — the dictation audio path (STT wave, 2026-09-17).
*
* Runs OFF the main thread (AudioWorklet): converts the mic's Float32
* samples to 16-bit little-endian PCM (linear16) and hands buffers to the
* main thread in two streams:
*
* { type: "level", rms } — every render quantum (128 samples ≈ 8ms at
* 16kHz): the quantum's RMS level, 0..1. This
* is the meter's heartbeat; posting per quantum
* is what makes the meter feel instant (the
* dominant latency in the owner's terminal
* version was a 256ms IO buffer — see
* ~/.pi/agent/extensions/dictate).
* { type: "audio", buffer } — Int16Array frames of 2048 samples (128ms),
* zero-copy (transferred). Deepgram recommends
* ~100–200ms chunks; silence is streamed too,
* so the socket never idles (no KeepAlive
* needed — the 10s NET-0001 timeout only bites
* a stream that stops SENDING).
*
* Ships as a static file under public/ so it rides the standalone bundle.
* Plain JS by necessity (worklets run outside the module graph).
*/
class PcmCaptureProcessor extends AudioWorkletProcessor {
constructor() {
super();
this.frame = new Int16Array(2048);
this.count = 0;
}
process(inputs) {
const channel = inputs[0] && inputs[0][0];
if (!channel) return true; // no input yet — stay alive
// RMS for the meter (float domain, before conversion).
let sumSquares = 0;
for (let i = 0; i < channel.length; i++) sumSquares += channel[i] * channel[i];
this.port.postMessage({ type: "level", rms: Math.sqrt(sumSquares / channel.length) });
// Float32 → Int16 (clamp first: a hot mic can exceed ±1.0).
for (let i = 0; i < channel.length; i++) {
const s = Math.max(-1, Math.min(1, channel[i]));
this.frame[this.count++] = s < 0 ? s * 0x8000 : s * 0x7fff;
if (this.count === this.frame.length) {
this.port.postMessage({ type: "audio", buffer: this.frame }, [this.frame.buffer]);
this.frame = new Int16Array(2048);
this.count = 0;
}
}
return true;
}
}
registerProcessor("pcm-capture", PcmCaptureProcessor);