BrainCue

22 · Local speech-to-text

On-device streaming transcription. With the local engine nothing a person says leaves the machine: audio goes from the capture stream to a recognizer running on the CPU in a child process, and the text comes back the same way the cloud transcriber’s does — deltas while someone speaks, a final when they pause. The rest of the pipeline (trigger, grounding, generation, the Cue Card) does not know which engine produced the words.

The engine choice and the model catalog are shared/stt.ts (SttPrefs, LOCAL_STT_MODELS). Everything that runs is src/main/services/stt/.

Why these models, why this runtime

Nemotron, not Whisper. Whisper (and whisper.cpp) is an offline model: it transcribes a finished window of audio, so a “streaming” Whisper is a loop of overlapping 5–30 s windows with the partial text re-decoded every pass — high CPU, text that rewrites itself, and a final only after the window closes. NVIDIA’s Nemotron speech models are cache-aware streaming transducers (FastConformer encoder + RNN-T decoder): they consume audio in 560 ms chunks, carry state between chunks, and emit tokens as they are heard. Partial text appears within one chunk of speech, finals come from the recognizer’s own endpoint rules, and the encoder does not re-read what it already processed. For a live companion that acts on completed turns, that difference is the whole latency story. Both models ship with punctuation and capitalisation.

sherpa-onnx, not a bespoke ONNX Runtime harness. sherpa-onnx (k2-fsa) is the runtime the Nemotron streaming exports are published for; it owns the feature extraction (128-bin fbank), the chunked encoder cache, greedy transducer decoding, endpointing, and a linear resampler — all behind a Node addon (sherpa-onnx-node, prebuilt sherpa-onnx-win-x64 etc.). Writing that in-house would be months of work to reach parity on a path that is not the product.

id Model Languages Size (INT8) Notes
nemotron-speech-streaming-en-0.6b Nemotron Speech Streaming EN 0.6B English 662 MB Recommended default. Strongest English streaming model available on-device.
nemotron-3.5-asr-streaming-0.6b Nemotron 3.5 ASR Streaming 0.6B 40, auto-detected or pinned 682 MB Same size and speed. Reads a per-stream language option — the profile language is passed in.

Both are the INT8 exports (encoder / decoder / joiner ONNX + tokens.txt) published on Hugging Face. Each file’s byte size is pinned in the catalog (from the blob listing on 2026-09-06). That pin is the manifest: it drives the progress bar without a round-trip and is the install check (below).

On disk

<userData>/models/stt/<modelId>/
  encoder.int8.onnx
  decoder.int8.onnx
  joiner.int8.onnx
  tokens.txt
  encoder.int8.onnx.part       # only while a download is in flight or was interrupted

modelStore.ts is the only module that knows this layout:

isLocalModelInstalled and sttReady in services/stt/index.ts are the read-side seams the rest of main uses (settings:get exposes sttReady).

Download, resume, verify (downloader.ts)

stt:download { modelId } returns { started: true } at once and streams SttDownloadProgress on stt:download-progress:

{ modelId, state: 'downloading' | 'verifying' | 'done' | 'error' | 'cancelled',
  receivedBytes, totalBytes, percent, file?, error? }

The recognizer runs out of process (worker.ts)

sherpa-onnx-node is native code. It runs in an Electron utility process forked by localRealtimeStt.ts (utilityProcess.fork(out/main/stt-worker.js), a second rollup input in electron.vite.config.ts). A crash there cannot take main — the DB, the overlay, the shortcuts — with it. The parent restarts the worker once per session and the Cue Card shows reconnecting; a second death ends the session with an error.

Protocol (protocol.ts, structured-clone messages over parentPort):

Direction Message Meaning
→ worker { type:'load', sid, modelDir, files, language, multilingual } Build (or reuse) the recognizer, open a stream for sid (sid 0 = warm-up, no stream). language is set on the stream only for the multilingual model.
→ worker { type:'audio', sid, pcm: ArrayBuffer } PCM16 mono 24 kHz as the app captures it, for the stream sid. The worker converts to Float32 and resamples to 16 kHz (LinearResampler per stream, or the pure fallback in pcm.ts).
→ worker { type:'stop', sid } inputFinished() on that stream only, decode what is left, flush it as a final, forget it. Always answered with stopped.
← worker ready · loaded {sid} · delta {sid,text} · final {sid,text} · stopped {sid} · error {sid,message} Every output echoes the stream id so the host routes it to that stream’s callbacks and a late message from a stream already closed is dropped.

Recognizer config (all models in the catalog share it):

{ featConfig: { sampleRate: 16000, featureDim: 128 },
  modelConfig: { transducer: { encoder, decoder, joiner }, tokens, numThreads: 2, provider: 'cpu', debug: 0 },
  decodingMethod: 'greedy_search', enableEndpoint: true,
  rule1MinTrailingSilence: 2.4, rule2MinTrailingSilence: 1.4, rule3MinUtteranceLength: 20 }

Per audio message: acceptWaveformwhile (isReady) decodegetResult().text; if the text changed, delta with only the words added since the last delta (the cloud transcriber’s contract — the dashboard store and the Cue Card append deltas into one in-flight line, so a full-text delta would read “MorningMorning. Have you had…”; greedy decoding never retracts a token, so the result always extends the previous one); if isEndpoint, final with the whole text and reset(stream). The endpoint rules are sherpa-onnx’s: rule 1 fires after 2.4 s of silence when nothing has been recognised yet (a breath, not a turn), rule 2 after 1.4 s of silence once there is text (this is the one that ends a normal utterance), rule 3 caps an utterance at 20 s so a monologue still produces finals the pipeline can act on.

Why not the cloud transcriber’s 600 ms? Both catalog models decode in 560 ms chunks, and tokens for a chunk only appear once the whole chunk is decoded, so the “trailing silence” the endpoint detector sees includes up to one chunk of speech that has not been decoded yet. At 0.6 s the endpoint fired mid-sentence (the 2026-09-07 live test cut “the roadmap update” to “the road” and lost the closing “?”) and reset threw away the audio still in the stream. Rule 2 must be at least two chunks, rule 1 at least four. The trade is ~0.8 s more latency per turn; the unpunctuated-question heuristic in meetingHeuristics.ts gets that back on the trigger path, since a final without a “?” no longer pays a classifier round trip.

The recognizer stays loaded between sessions (a load takes seconds and ~1 GB of RAM); it is rebuilt only when the model directory changes. A language change is just a new stream.

Several streams on one recognizer (2026-09-07)

A live session hears the call AND the user’s microphone (06 §realtime), so the worker decodes several streams at once on the one loaded recognizer. The stream table (streams.ts, pure — tested with a fake recognizer in streams.test.ts) maps sid → { stream, lastText, resampler }: every stream keeps its own decoder state, its own stateful LinearResampler (two streams’ frames interleave, and the resampler carries fractional phase between frames) and its own “last published text”, so the deltas, endpoints and finals of the call and the mic never bleed into each other. In the protocol this means:

The provider (localRealtimeStt.ts)

localRealtimeStt implements RealtimeSttProvider — the same interface the OpenAI Realtime transcriber implements — and is registered as provider local for the realtimeStt capability. The host (SttWorkerHost) holds a map sid → session and allows any number of concurrent sessions on the one worker — a live session opens two (the call and the mic); each open() gets its own sid, load and callbacks. applySttSelection() (startup, every settings:set with stt, download done, model deleted) selects local only when prefs.engine === 'local' and the model is installed; otherwise openai. So a half-downloaded model never produces a session that cannot start — it quietly uses the cloud engine, and sttReady tells the UI.

Known limits

Settings UI

Settings → Speech-to-Text (/settings/speech, renderer/dashboard/pages/settings/SpeechPanel.tsx) is the whole user surface. An Engine chooser (Cloud Providers / Local) writes stt.engine; the header badge shows sttReady — choosing Local with nothing installed is allowed and reads not ready rather than being refused, and the panel says to download a model. The Local tab lists LOCAL_STT_MODELS from the catalog with install state from stt:list-models: Download (not installed), a percentage + Cancel (in flight, from EVENTS.sttDownloadProgress), Active (installed and selected — stt.localModelId), Use (installed, not selected), and a two-click delete. Terminal progress states refresh the list and re-read settings so the badge follows. Because a download outlives the page, the shell also shows it: renderer/dashboard/DownloadStrip.tsx under the title bar (seeded from the same list on mount) with the file caption, a real bar, received / total, Cancel, a brief Model ready, and an error row with Retry.

Adding a model

  1. Find the sherpa-onnx export on Hugging Face (encoder/decoder/joiner + tokens; a transducer with 128-bin features at 16 kHz — the worker config is shared).
  2. Add an entry to LOCAL_STT_MODELS in shared/stt.ts: a stable id (never renamed — it is the directory name on disk and the value in SttPrefs), the four files with resolve/main URLs and their exact byte sizes from the blob listing, languages, chunkMs.
  3. If the model is multilingual, languages: 'multilingual' makes the provider pass the profile language as the stream option.
  4. Nothing else changes: the catalog drives Settings, the downloader, the install check and the worker’s file paths.

Tests

src/main/services/stt/*.test.ts — the downloader against a local http.createServer fixture with Range support (full download through a redirect, resume from a .part, 200-instead-of-206 restart, size mismatch, cancel mid-stream keeps the .part and the next run resumes it, retry after a dropped connection, retry budget exhausted, HTTP status surfaced); installed() / bytesOnDisk() / one-download-per-model / remove() refusal with electron.app.getPath mocked; the PCM16 → Float32 and 24k → 16k helpers in pcm.ts, which are pure so they test without the addon; and the worker’s stream table (streams.test.ts) driven with a scripted recognizer — two sids interleaving deltas, an endpoint on one stream finalling and resetting only that stream, stop flushing one and leaving the other decoding.