Repository navigation
feat(voice): start capture at the press, and an opt-in ready microphone - #81
Merged
Merged
Conversation
The microphone now opens at the same time as the session, and frames heard before the port is there are queued, not lost. The installed-model lookup and the microphone permission are remembered, so a press no longer waits on three IPCs. The audio graph is built once at the composer's focus and kept, suspended, between dictations. "Keep microphone ready" (off by default) holds the microphone open for 2 minutes after a composer's focus or the last dictation, and hands a press the ~0.4-0.5 s before it as pre-roll. Settings says plainly that the mic and its indicator stay on, and the macOS usage description no longer claims "only while you dictate".
ANT1OFF
added a commit
that referenced
this pull request
Nov 2, 2026
Adds an **Accurate** voice-input mode. Nemotron still streams the live words at the caret. At each 1.2 s pause, and on stop, the speech process trims the utterance's audio with **Silero VAD v5** and runs **Parakeet TDT 0.6B v2** (int8, sherpa-onnx `OfflineRecognizer`) over it. It then commits that text, with punctuation and capitals, in place of the streamed words. **Live** is the old behaviour. Accurate is the default. ## Measurements Apple M5 Pro (18 cores, 24 GB), sherpa-onnx 2.0.0 CPU, 2 threads, run in Node 26 through the same addon the app loads. Other checkouts were running on this machine (load average 10–40), so the timings are pessimistic. The study's WAVs aren't in the tree, so I used two corpora: - **Real read speech:** `hf-internal-testing/librispeech_asr_dummy`, 73 clips, 481 s. - **Synthetic:** macOS `say`, 20 developer-prompt sentences × 4 voices, 375 s. **Neither is the user's voice or microphone.** WER is case- and punctuation-insensitive. | | Nemotron 560 ms (today) | **Parakeet v2 int8** | Parakeet v3 int8 | |---|---|---|---| | WER, LibriSpeech (1150 words) | 6.43 % | **3.04 %** | 4.17 % | | WER, `say` (1216 words) | 0.49 % | 0.41 % | 0.49 % | | Decode, 4–6 s clip, p50 / p95 (load ~10) | (streams) | **240 / 307 ms** | 277 / 388 ms | | Decode, 4–6 s clip, p50 / p95 (load ~40) | (streams) | 613 / 1520 ms | 765 / 3964 ms | | RTF over the corpus (load ~10) | 0.14–0.22 | 0.05 | 0.057 | | Load | 0.9–1.7 s | 1.6–3.4 s (5.5 s at load 40) | 2.3–3.7 s | | Peak memory, alone | 2.2–2.3 GB | 1.9–2.3 GB | 2.0–2.1 GB | - **Both models resident in one process:** 3.0–3.1 GB steady footprint, 3.38 GB peak; Nemotron alone is ~1.7 GB steady. - **Threads for the pass:** 1 thread p50 734 ms; 2 threads 203–240 ms; 4 threads 218 ms; 6 threads 310 ms. So it uses 2. - **CoreML** (`provider: "coreml"`): p50 2016 ms, p95 4749 ms, 12.7 s load, 3.3 GB peak, same WER. **Rejected.** - **Silero VAD v5**, real-time factor 0.008. On LibriSpeech clips with 0.5 s / 1.2 s of −60 dBFS noise around them: | Speech level | WER untrimmed | WER trimmed | Clips missed | |---|---|---|---| | Normal | 3.74 % | 3.04 % | 0 | | 20 dB quieter | 4.26 % | 3.30 % | 0 | | 30 dB quieter | 5.83 % | 6.17 % | 0 | Digital silence and white noise at −60…−20 dBFS gave no text from either model, so the "thank you" hallucination did not reproduce with stationary noise. The VAD skip guards against it anyway. ## Design - **Speech process:** `service.ts` keeps each utterance's audio, including the 300 ms lead and the stop tail. On an endpoint or stop it: - runs `planClip`: keeps 250 ms around the VAD's speech, and returns nothing when no speech was found; - transcribes the clip with `decodeAsync`, off the main thread, so a cancel still lands; - commits the result. - **Bounds:** - Clips are cut at 30 s. When speech runs into the cut, the clip ends at the last pause and the rest carries into the next utterance. - A pass that takes over 4 s (load included) commits the streamed text instead. That is short of the page's 5 s stop wait. - Frames queue while an utterance commits, so the next partials never overtake its `final`. - **Loading:** Parakeet and Silero load on the first Accurate prewarm or session, and are freed with the process on the existing 5-minute idle timer, or by `release`. - **Fallback:** an Accurate session whose models aren't installed runs Live; main decides this from `verified.json`, never from the page. - **Logs:** an `accurate pass` line carries `audioMs`, `ms` and `fellBack` only, never text. A test asserts this. - **Contracts** (additive): - `SpeechOpenRequest.mode`, and a `mode` argument on `DesktopSpeech.prewarm`; - `voice.mode` in settings (`speechModeOf`, Accurate by default); - `SpeechStatus.totalMemoryBytes`; - `SpeechRecognizer` is now a union with `sherpa-onnx-offline-transducer` and `sherpa-onnx-silero-vad`; - an optional `SpeechModelSource.url`. - **No new port message:** `final` already replaces the partial; its doc now says the text can differ from the last partial. ## Why Accurate is the default Parakeet halves the word errors on real speech (6.43 → 3.04 %) and adds punctuation. On an idle machine it costs about a quarter of a second after each pause. Accurate is offered only with consent, and it degrades to Live when its models are missing, too slow or broken. Machines under 16 GB see only the Live download on the first-run consent screen, because both models together take about 3 GB. ## Models and consent - **Manifest:** `parakeet-tdt-0.6b-v2-en-int8` (csukuangfj @ `1ab9323…`, 661 MB, from `nvidia/parakeet-tdt-0.6b-v2` @ `ae9ad07…`) and `silero-vad-v5` (`csukuangfj/vad` @ `fba88cd…`, 2.3 MB). Each file has a pinned size and the sha256 that Hugging Face's LFS serves; the Silero file's sha256 also equals upstream `files/silero_vad.onnx` at tag v5.0. - **Consent:** download, resume, verification and consent use the existing store. - The first-run screen lists all three models, each with its own licence link and attribution: NVIDIA Open Model License, CC BY 4.0 (author, licence, and "converted to int8 ONNX"), and MIT. It offers "Download 1.3 GB" or "Live only, 662 MB". - Adding Accurate later has its own consent screen. - Turning voice off deletes all of it. - **Notices:** `packages/shell/src/modules/speech/MODEL-NOTICES.md` (with the full Silero MIT text) is now a kept notice in `tools/notices`. A test fails if any pinned model's attribution or licence URL is missing from it. ## Screenshots (mock shell) | Before | After | |---|---| |  |  | |  |  | | |  | | |  | ## Checks - `bun run typecheck`, `bun run lint`, `bun run fmt:check`, `bun run check:scaffold`: pass. - `vitest` over the speech process, the speech module, contracts, web settings/dictation and tools/notices: 1024 tests pass. - `apps/web/e2e/dictation.spec.ts`: 13 pass, including a new Accurate consent/download/mode test. - **Live, real runtime** (`GRAM_LIVE_SPEECH=1`, with `GRAM_LIVE_SPEECH_ACCURATE` and `GRAM_LIVE_SPEECH_VAD`): both live tests pass on the Nemotron export's `test_wavs/0.wav` followed by 3 s of noise. The first final is Parakeet's punctuated "yellow lamps" sentence; the noise commits nothing. - I didn't run my own full `bun run check`. ## Known limits / not verified - **Not tested with a microphone or a real voice, and not in the packaged Electron utility process.** The live test runs the same addon in Node. `Vad.front(false)` is used because Electron refuses external buffers; that is untested inside Electron. - WER is on read LibriSpeech and TTS clips, not dictated developer prompts. - **English only.** Parakeet v3 (25 European languages) is a drop-in manifest entry, but Nemotron's live partials are English-only, so a language choice is a follow-up. - **Linux:** the code is platform-neutral (Node-API `OfflineRecognizer` and `Vad`, CPU provider, no CoreML) and the Linux voice gate is untouched. The path is ready once a TTS-free `sherpa-onnx-linux-x64` build exists; the real-time factor on Linux x64 is unmeasured. - Rebased onto #81 (Keep microphone ready): the Settings rows sit side by side, and `use-dictation.ts` passes `mode` through to `start` and `prewarm`. After the rebase: typecheck, lint, fmt, scaffold and 1039 vitest pass; `e2e/dictation.spec.ts` 15/15.
ANT1OFF
added a commit
that referenced
this pull request
Nov 2, 2026
Desktop release metadata proposal only. Merging this PR does not publish or tag Gram; follow docs/RELEASING.md to release. --- ## [1.4.0](v1.3.1...v1.4.0) (2026-11-02) ### Features * **agents:** identify Gram's Claude processes as the gram entrypoint ([#91](#91)) ([f7dd703](f7dd703)) * **desktop:** run on Linux ([#82](#82)) ([1d37deb](1d37deb)) * **desktop:** turn voice input off on Linux with one check ([#79](#79)) ([470ff8b](470ff8b)) * **orchestration:** a person's PR comment wakes the worker that owns it ([#86](#86)) ([19bdc1c](19bdc1c)) * **orchestration:** a wrap-up turn when a worker stops without reporting ([#88](#88)) ([36a07fb](36a07fb)) * **orchestration:** workers say what they ruled out and check before repeating ([#90](#90)) ([8bdce01](8bdce01)) * **release:** publish and update Linux AppImage and .deb ([#95](#95)) ([c33c464](c33c464)) * **voice:** an accurate mode with a Parakeet pass on each pause ([#83](#83)) ([2b04a52](2b04a52)) * **voice:** hold one key to talk, with a tone at each end ([#87](#87)) ([1a9b3ef](1a9b3ef)) * **voice:** start capture at the press, and an opt-in ready microphone ([#81](#81)) ([505d240](505d240)) * **web:** a Done section that keeps the last 7 days ([#65](#65)) ([8c7bd49](8c7bd49)) * **web:** Done lists every finished chat ([#67](#67)) ([c91d9ac](c91d9ac)) * **web:** one attention count, quieter rows, a sidebar and board that agree ([#92](#92)) ([f193da4](f193da4)) * **web:** orchestrator → workers in the sidebar, a board that matches ([#80](#80)) ([686e737](686e737)) ### Bug Fixes * **desktop:** harden the Linux key vault and autostart entry ([#85](#85)) ([4b35eac](4b35eac)) * **e2e:** hold the t-flaky turn while the smoke test reads its opening line ([#93](#93)) ([2edc4ac](2edc4ac)) * **e2e:** release the dictation tap as soon as a word is heard ([#94](#94)) ([7781194](7781194)) * prepare only approved local-check sources ([#32](#32)) ([af3fab7](af3fab7)) * repair main after [#32](#32) ([#68](#68)) ([ce352e6](ce352e6)) * **voice:** keep the last word after release ([#78](#78)) ([cc40a47](cc40a47)) --- This PR was generated with [Release Please](https://github.com/googleapis/release-please). See [documentation](https://github.com/googleapis/release-please#release-please). --------- Co-authored-by: gram-release-please[bot] <351321620+gram-release-please[bot]@users.noreply.github.com> Co-authored-by: Johan <32440065+ANT1OFF@users.noreply.github.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
What
Dictation starts sooner, and with an opt-in setting it hears the moment before you press.
getUserMedianow starts at the same time as the session opens. Frames heard before the port arrives are queued and sent after the lead silence, so a slow open (a cold model, IPC) costs no audio. The installed-model lookup is remembered, but only for the slider's own model and only until Settings changes what's installed or the speech process saysmodel-missing, so a fallback never sticks. The microphone permission is remembered once granted;getUserMediastill reports a later revoke. A press therefore no longer waits on up to threespeechStatusIPCs and a permission IPC before it opens the mic.AudioContextand worklet per window, built at the composer's focus (beside the model prewarm) and kept suspended between dictations, so no audio device stays open. The worklet gathers only betweenstartandstop, so no part-frame carries over to the next dictation.NSMicrophoneUsageDescriptionsaid "only while you dictate", which is untrue with the opt-in on. It now reads: "Gram uses the microphone while you dictate into the composer, and between dictations only if you turn on Keep microphone ready. Speech is transcribed on this Mac and never leaves it."service.tssays about 1 s on an idle machine) and the process sits at ~0.8–0.95 GB RSS loaded. With (a), a cold model no longer loses audio: the speech process queues frames untilready, so a cold start only delays the text. Keeping ~0.9 GB resident for longer than the current 5-minuteSPEECH_IDLE_MS, or loading it on every window focus, isn't worth that. Composer-focus prewarm stays as it is.Latency: press to first frame
Dev app (Vite + mock speech process) in Playwright Chromium. A press of ⌘⇧Space is timed to the first non-silence frame reaching the session port. 12 presses per run, the first (cold) press dropped. The real-mic rows are 2 rounds alternating before and after; the fake-device rows are one run each, not alternated. The machine was heavily loaded: load average 30–60 from other checkouts.
getUserMediaalone takes ~270–290 ms (median) on this Mac's built-in mic, and the audio graph after it only ~60–80 ms, whether built per press or kept. What (a) and (b) remove (the IPCs and the graph build) is small next to that, and the run-to-run noise at load 30–60 hides it. I expect a gain of tens of ms on an idle machine, but I haven't shown it.Verification
--use-fake-ui-for-media-streamonly grants the permission) for the latency rows above. Recognition used the mock process, not the model, and nobody spoke. I did not check the macOS mic indicator by eye or run the packaged app.capture.test.tsruns the real worklet over fake Web Audio:session.test.tscovers frames queued while the session opens, a cancel during the open releasing the mic at once, and each side released when the other fails.use-dictation.test.tscovers the remembered target (never a fallback or a no, forgotten on a lifecycle change ormodel-missing).settings.test.tscovers the new setting.dictation.spec.ts, 14 passed:vitestdictation + settings + contracts,typecheck,lint,fmt:check,check:scaffold,check:docs,e2e dictation.spec.ts.Known limits
Screenshots
Settings → Advanced → Voice input, with voice input on (dark)