Skip to content

feat(voice): start capture at the press, and an opt-in ready microphone - #81

Merged
ANT1OFF merged 2 commits into
mainfrom
feat/voice-instant-start
Nov 2, 2026
Merged

ANT1OFF merged 2 commits into
mainfrom
feat/voice-instant-start

Conversation

@ANT1OFF

@ANT1OFF ANT1OFF commented Nov 2, 2026 •

Copy link
Copy Markdown
Owner

What

Dictation starts sooner, and with an opt-in setting it hears the moment before you press.

  • (a) Overlap. getUserMedia now 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 says model-missing, so a fallback never sticks. The microphone permission is remembered once granted; getUserMedia still reports a later revoke. A press therefore no longer waits on up to three speechStatus IPCs and a permission IPC before it opens the mic.
  • (b) Graph kept. One AudioContext and 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 between start and stop, so no part-frame carries over to the next dictation.
  • (c) Keep microphone ready, opt-in and off by default. After a composer's focus or the last dictation, it holds the stream for 2 minutes, then lets go. Each focus or dictation restarts the 2 minutes, a dictation in progress never times out, and turning it off, or turning voice input off, lets go at once. While held, it keeps the last 4 frames plus the worklet's part-frame (400–500 ms) in memory only, replaced as they age, and hands them to a press as pre-roll. Focus never makes the first permission ask; that stays with a press. A stream that ended while held (device unplugged) is reopened on the press.
  • Settings copy. The row's always-visible detail says the mic stays open for 2 minutes and the macOS mic indicator stays lit ("your system's" off macOS). With it on, the Microphone row no longer says "listens only while you dictate".
  • Usage description changed. NSMicrophoneUsageDescription said "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."
  • (d) Model idle and prewarm: no change, measured. The model loaded in 3.6–4.0 s here (load average ~30; service.ts says 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 until ready, so a cold start only delays the text. Keeping ~0.9 GB resident for longer than the current 5-minute SPEECH_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.

min p25 p50 p75 max
Before (real mic) 409 458 496 634 1339 ms
After, default (real mic) 319 351 540 773 1401 ms
After, Keep microphone ready (real mic) 27 32 36 43 68 ms
Before (Chromium fake device) 200 323 366 434 490 ms
After, default (fake device) 148 156 162 271 345 ms
  • Default path. The reliable change is that audio is now captured from the moment the mic opens, however long the session takes. Press to first frame on the real mic is not measurably faster under this load. A standalone Chromium microbenchmark explains it: getUserMedia alone 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.
  • Keep microphone ready. The first frame arrives in ~36 ms and carries the ~0.4–0.5 s before the press, so a word that starts with the press is not clipped.

Verification

  • Real microphone: the MacBook Pro mic through Playwright Chromium (--use-fake-ui-for-media-stream only 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.
  • Unit: capture.test.ts runs the real worklet over fake Web Audio:
    • the graph is warmed once, stays suspended and no mic is opened on focus
    • no carried-over part-frame
    • pre-roll order and size, and the 2-minute hold
    • a focus extends the hold, turning it off releases at once
    • no timeout under a dictation, an ended track reopens, and a takeover between composers
  • Unit: session.test.ts covers 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.ts covers the remembered target (never a fallback or a no, forgotten on a lifecycle change or model-missing). settings.test.ts covers the new setting.
  • e2e dictation.spec.ts, 14 passed:
    • new: the mic is not opened on focus, opens on the press and closes on the stop
    • new: Keep microphone ready holds the mic from focus, gives the press ≥ 4 pre-roll frames, keeps the stream for the next press, and turning it off in Settings stops the track
  • Commands: vitest dictation + settings + contracts, typecheck, lint, fmt:check, check:scaffold, check:docs, e2e dictation.spec.ts.

Known limits

  • Default-path latency is not shown to improve beyond noise (see above).
  • With the opt-in on, a composer focus opens the mic (indicator lit) even if you don't dictate. That is the trade the setting describes.
  • The hold timer is per window; two windows each hold their own stream.

Screenshots

Settings → Advanced → Voice input, with voice input on (dark)

Before After
settings-voice-input-dark before settings-voice-input-dark after

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
ANT1OFF merged commit 505d240 into main Nov 2, 2026
23 checks passed
@ANT1OFF
ANT1OFF deleted the feat/voice-instant-start branch November 2, 2026 07:27
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 |
|---|---|
| ![before
settings](https://github.com/user-attachments/assets/43a25efa-b5ad-4d98-95d2-dd1a46b9b7b9)
| ![after
settings](https://github.com/user-attachments/assets/1064d42c-9f8a-48b2-890d-16690e42db9a)
|
| ![before
consent](https://github.com/user-attachments/assets/f59c5d8a-0025-42e6-b2ea-f58b27e5092f)
| ![after
consent](https://github.com/user-attachments/assets/348b0e58-cbf2-4f3a-8f38-4a124a012f85)
|
| | ![accurate
notice](https://github.com/user-attachments/assets/883c044e-80a1-45ee-b17f-966d119be172)
|
| | ![accurate
ready](https://github.com/user-attachments/assets/6277485e-c0cb-4d8e-bd5d-1f72def4515a)
|

## 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>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant