@sfgartland/better-pi-voice 0.0.0-stage → 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +403 -2
- package/index.ts +426 -0
- package/package.json +71 -5
- package/src/backends/deepgram.ts +440 -0
- package/src/backends/parakeet.ts +840 -0
- package/src/capture.ts +245 -0
- package/src/clock.ts +19 -0
- package/src/controller.ts +710 -0
- package/src/first-run.ts +97 -0
- package/src/formats.ts +216 -0
- package/src/keyterms/correct.ts +170 -0
- package/src/keyterms/index.ts +76 -0
- package/src/keyterms/mine.ts +135 -0
- package/src/keyterms/sanitize.ts +109 -0
- package/src/keyterms/seed.ts +60 -0
- package/src/model-download.ts +195 -0
- package/src/overview.ts +89 -0
- package/src/runtime.ts +252 -0
- package/src/settings.ts +544 -0
- package/src/setup.ts +284 -0
- package/src/try-it.ts +128 -0
- package/src/types.ts +80 -0
- package/src/ui.ts +105 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Severin Gartland
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
CHANGED
|
@@ -1,3 +1,404 @@
|
|
|
1
|
-
#
|
|
1
|
+
# @sfgartland/better-pi-voice
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
Streaming voice input for pi. Hold a key, talk, and the words land in the editor —
|
|
4
|
+
in the right spelling, including the jargon and filenames generic speech
|
|
5
|
+
recognition gets wrong.
|
|
6
|
+
|
|
7
|
+
Two interchangeable backends behind one pipeline:
|
|
8
|
+
|
|
9
|
+
| | Backend | Biasing | Needs |
|
|
10
|
+
|---|---|---|---|
|
|
11
|
+
| Cloud | **Deepgram `nova-3`** streaming | `keyterm` prompting (native) | a Deepgram API key |
|
|
12
|
+
| Local | **Parakeet** via `transcribe-cpp` | post-hoc correction pass only | a Parakeet GGUF |
|
|
13
|
+
|
|
14
|
+
## Why this exists
|
|
15
|
+
|
|
16
|
+
Generic dictation is tuned for English prose, not for a terminal. It writes
|
|
17
|
+
"sub agent" and "work tree", guesses at filenames, turns `tsconfig` into "T's
|
|
18
|
+
config", and has no idea what your project is even called. That is not mostly a
|
|
19
|
+
model-quality problem — it is a missing-context problem, and it is fixable: tell
|
|
20
|
+
the recogniser the words the project actually uses. Cloud dictation is also a
|
|
21
|
+
black box: when the socket drops mid-sentence, most tools lose the sentence.
|
|
22
|
+
Local dictation usually trades accuracy for privacy and gives up on both.
|
|
23
|
+
|
|
24
|
+
`better-pi-voice` attacks all three: vocabulary biasing mined from the repo and
|
|
25
|
+
session, a recovery layer that will not drop a sentence, and a real local option
|
|
26
|
+
behind the same pipeline.
|
|
27
|
+
|
|
28
|
+
Claude Code's dictation is unusually good, and it isn't the model. Reverse-engineering
|
|
29
|
+
it (`claude-code@2.1.295`) turned up three things worth stealing:
|
|
30
|
+
|
|
31
|
+
1. **Vocabulary biasing from the repository.** Before every session it pushes the
|
|
32
|
+
project name, the paths already in play, and a hardcoded list of developer jargon
|
|
33
|
+
(`subagent`, `worktree`, `OAuth`, `gRPC`…) to the recogniser. This is the single
|
|
34
|
+
biggest accuracy lever and it costs nothing.
|
|
35
|
+
2. **Server-side endpointing** at 16 kHz mono, so finals land promptly instead of
|
|
36
|
+
waiting for you to press stop.
|
|
37
|
+
3. **A recovery layer that never loses a sentence** — one retry before the first
|
|
38
|
+
transcript, replaying buffered audio when a stream silently drops, salvaging a
|
|
39
|
+
partial transcript when the socket dies, promoting an unreported interim to a
|
|
40
|
+
final, and a circuit breaker so a broken microphone fails loudly.
|
|
41
|
+
|
|
42
|
+
All three are reproduced in this extension. The design document with the full extraction notes is
|
|
43
|
+
at `docs/superpowers/specs/2026-10-15-pi-voice-input-design.md`.
|
|
44
|
+
|
|
45
|
+
## What it actually buys you
|
|
46
|
+
|
|
47
|
+
A fixed sentence containing five terms, transcribed twice through the live Deepgram
|
|
48
|
+
API — once with no keyterms, once with the repository's terms:
|
|
49
|
+
|
|
50
|
+
```
|
|
51
|
+
without keyterms … so the sub agent can use the Worktree T's config, then check
|
|
52
|
+
the Marginalia OAuth webhook.
|
|
53
|
+
with keyterms … so the subagent can use the worktree tsconfig, then check the
|
|
54
|
+
marginalia OAuth webhook.
|
|
55
|
+
```
|
|
56
|
+
|
|
57
|
+
`tsconfig` → "T's config" is the failure mode in miniature: correct English, wrong
|
|
58
|
+
words. Two of the five terms came back wrong without biasing and all five were right
|
|
59
|
+
with it.
|
|
60
|
+
|
|
61
|
+
## How it compares to the other pi voice extensions
|
|
62
|
+
|
|
63
|
+
Three voice extensions are in play, and they optimise for different things. This
|
|
64
|
+
one is for **accurate dictation of code vocabulary**, from the cloud or locally:
|
|
65
|
+
|
|
66
|
+
| | [`@earendil-works/pi-voice`](https://github.com/earendil-works/pi-voice) | [`pi-voice-stt`](https://github.com/cgarrot/pi-voice-stt) | **better-pi-voice** |
|
|
67
|
+
|---|---|---|---|
|
|
68
|
+
| Recognition | local only (`transcribe-cpp`) | several cloud providers + OpenAI-compatible | **Deepgram `nova-3` streaming** or local Parakeet |
|
|
69
|
+
| Repo-aware vocabulary biasing | no | no | **yes** — keyterms mined from the repo and session, native on Deepgram |
|
|
70
|
+
| Never-lose-a-sentence recovery | no | no | **yes** — retry, silent-drop replay, partial salvage, circuit breaker |
|
|
71
|
+
| Capture | bundled `pvrecorder` | `ffmpeg` (or a Mac bridge) | `arecord`, falling back to SoX `rec` |
|
|
72
|
+
| Transcribe an audio file | yes (`transcribe_file`) | yes (`transcribe_audio`) | no — this extension is for live dictation |
|
|
73
|
+
| Guided setup | `/voice-settings` onboarding, model download | config file / environment | `/voice setup` wizard, model download |
|
|
74
|
+
| Best for | a fully local, offline dictation tool | choosing among many cloud providers | dictating code accurately, with a private fallback |
|
|
75
|
+
|
|
76
|
+
All three are MIT and coexist as pi packages, so there is no need to pick
|
|
77
|
+
ideologically: use `pi-voice` if you want everything strictly local, `pi-voice-stt`
|
|
78
|
+
if you want a particular provider, and this one if the words that come back are the
|
|
79
|
+
thing you care about.
|
|
80
|
+
|
|
81
|
+
## Install
|
|
82
|
+
|
|
83
|
+
```bash
|
|
84
|
+
pi install /path/to/better-pi-voice # from a local checkout
|
|
85
|
+
pi install npm:@sfgartland/better-pi-voice # once published
|
|
86
|
+
```
|
|
87
|
+
|
|
88
|
+
Then add a Deepgram key. The easiest way is `/voice key`:
|
|
89
|
+
|
|
90
|
+
- `/voice key <path-to-dotenv>` — use a key from an existing dotenv file (it checks one is
|
|
91
|
+
actually in there first), then switches to Deepgram.
|
|
92
|
+
- `/voice key` — paste the key at the prompt; it is written to `~/.pi/agent/better-pi-voice.key`
|
|
93
|
+
with `0600` permissions and referenced from the settings, so the secret never lands in
|
|
94
|
+
`better-pi-voice.json` (which is the file people sync into dotfiles repos).
|
|
95
|
+
- `/voice key --inline` — store the key in `better-pi-voice.json` itself. Explicit opt-in:
|
|
96
|
+
only do this if that file is not tracked by git.
|
|
97
|
+
|
|
98
|
+
You can also export it, which overrides everything the settings point at:
|
|
99
|
+
|
|
100
|
+
```bash
|
|
101
|
+
DEEPGRAM_API_KEY=... pi
|
|
102
|
+
```
|
|
103
|
+
|
|
104
|
+
or set `deepgram.apiKeyFile` to any dotenv by hand. Precedence is: environment variable,
|
|
105
|
+
then `deepgram.apiKey` (inline), then `deepgram.apiKeyFile`.
|
|
106
|
+
|
|
107
|
+
## Guided setup
|
|
108
|
+
|
|
109
|
+
Run `/voice setup`, or just press the shortcut on a fresh install and it walks you
|
|
110
|
+
through it. The wizard asks, in order: how voice should run (Deepgram cloud or local
|
|
111
|
+
Parakeet), how to supply the key or which model to use, whether to hold or tap, and
|
|
112
|
+
which shortcut. Then it saves, and ends with an **overview** pane: the settings it
|
|
113
|
+
wrote, how to use them (hold or press the shortcut, where the text goes), and how to
|
|
114
|
+
change any of it later with `/voice setup`. Enter closes it; Esc goes back and
|
|
115
|
+
re-runs the wizard. There is no live microphone test to sit through — try the
|
|
116
|
+
shortcut yourself when you are ready.
|
|
117
|
+
|
|
118
|
+
For the key, if `DEEPGRAM_API_KEY` is already in your environment the wizard offers to
|
|
119
|
+
just use it; otherwise you can paste a key (kept `0600`, never in settings) or point at
|
|
120
|
+
a dotenv file. For the local backend, the model menu lists the known GGUFs best-first
|
|
121
|
+
with their download size — **selecting one downloads it** (checksum-verified, with a
|
|
122
|
+
progress line) — then falls back to any GGUF already on disk or a path you give.
|
|
123
|
+
|
|
124
|
+
If the model you chose can stream, the wizard then asks how it should run and spells out
|
|
125
|
+
the tradeoff: **Batch** transcribes in short slices while you talk (works with every
|
|
126
|
+
model, and faster on most machines) versus **Streaming**, which decodes continuously for
|
|
127
|
+
lower delay but needs a fast CPU to keep up in real time. A batch-only model skips the
|
|
128
|
+
question and says why. See [Batch mode](#batch-mode-the-default) below for the detail.
|
|
129
|
+
|
|
130
|
+
## Use
|
|
131
|
+
|
|
132
|
+
The default binding is `ctrl+alt+z`; **hold it while you speak and release**. The
|
|
133
|
+
status line names the active backend and model (`● deepgram nova-3: listening`,
|
|
134
|
+
`transcribing with parakeet parakeet-unified-en-0.6b…`) and keeps showing it while
|
|
135
|
+
idle, so it is always obvious which one is listening. An audio-level meter appears
|
|
136
|
+
above the editor while recording.
|
|
137
|
+
|
|
138
|
+
| Mode | Start | Stop | Safety |
|
|
139
|
+
|---|---|---|---|
|
|
140
|
+
| `hold` (default) | press | release | falls back if the terminal never reports release |
|
|
141
|
+
| `tap` | press | press again | 15 s of silence, 120 s hard cap |
|
|
142
|
+
|
|
143
|
+
A hard cap applies in both modes so a stuck key cannot leave the microphone open.
|
|
144
|
+
|
|
145
|
+
Commands:
|
|
146
|
+
|
|
147
|
+
- `/voice` — backend, model, mode, shortcut, keyterm count, whether the key is visible, state
|
|
148
|
+
- `/voice setup` — guided setup (backend, key/model, mode, shortcut, then an overview of the result)
|
|
149
|
+
- `/voice backend` — pick a backend from a menu
|
|
150
|
+
- `/voice backend deepgram|parakeet` — switch directly, then reload
|
|
151
|
+
- `/voice model` — pick a local GGUF already in the Hugging Face cache, then reload
|
|
152
|
+
- `/voice model /path/to/model.gguf` — use a specific model file
|
|
153
|
+
- `/voice key <path-to-dotenv>` — use a key from a dotenv file, then switch to Deepgram
|
|
154
|
+
- `/voice key` — paste a key; stored `0600` in `~/.pi/agent/better-pi-voice.key`
|
|
155
|
+
- `/voice key --inline` — store the key in the settings file (explicit opt-in)
|
|
156
|
+
- `/voice set <key> <value>` — set `backend`, `model`, `mode`, `language`, `correction`,
|
|
157
|
+
`submitOnStop`, `chunkSeconds`, `silenceLevel`, `shortcut`, `apiKeyEnv` or `apiKeyFile`, then reload
|
|
158
|
+
- `/voice-keyterms` — list custom terms
|
|
159
|
+
- `/voice-keyterms add <term>` / `remove <term>`
|
|
160
|
+
|
|
161
|
+
The backend is **Deepgram by default**. Switching rewrites `better-pi-voice.json`
|
|
162
|
+
(fully expanded, so it stops looking like the minimal file you hand-wrote) and then
|
|
163
|
+
reloads the extension so the change takes effect immediately. You can also just edit
|
|
164
|
+
`"backend"` in the settings file and restart.
|
|
165
|
+
|
|
166
|
+
On a first run with no settings file, voice configures itself: Deepgram when an API
|
|
167
|
+
key is already reachable, otherwise the best local model already on disk. It then tells
|
|
168
|
+
you which backend it chose and which key to hold. If neither is available it writes
|
|
169
|
+
nothing and says what to set.
|
|
170
|
+
|
|
171
|
+
## Keyterms
|
|
172
|
+
|
|
173
|
+
Terms are gathered from four places, most specific first:
|
|
174
|
+
|
|
175
|
+
1. `keyterms.user` — your own list
|
|
176
|
+
2. a built-in seed list of developer jargon
|
|
177
|
+
3. the project directory name and the git repository name
|
|
178
|
+
4. files touched by `read`/`write`/`edit` tool calls and paths mentioned in your messages
|
|
179
|
+
|
|
180
|
+
They are normalised (camelCase split, separators split, 3–20 characters, printable
|
|
181
|
+
ASCII, deduplicated) and capped well inside Deepgram's documented 100-term / 500-token
|
|
182
|
+
limit. A comma-joined keyterm list is accepted by Deepgram and silently boosts
|
|
183
|
+
*nothing*, so terms are sent as repeated query parameters — there is a test for that.
|
|
184
|
+
|
|
185
|
+
## The local backend, honestly
|
|
186
|
+
|
|
187
|
+
**Parakeet cannot be vocabulary-biased.** `transcribe-cpp` 0.4.0 does have a
|
|
188
|
+
`vocabulary` input, but only for models that advertise `supports("vocabulary")`, and
|
|
189
|
+
the Parakeet GGUFs do not — loading `parakeet-tdt_ctc-110m`, the recommended model,
|
|
190
|
+
returns `false` for both `vocabulary` and `context_prompt`. So locally you get live
|
|
191
|
+
interims *or* native biasing, not both.
|
|
192
|
+
|
|
193
|
+
This extension takes interims, and makes up the difference with a deliberately dumb
|
|
194
|
+
deterministic repair pass that runs on finals only. It does two things:
|
|
195
|
+
|
|
196
|
+
1. **Rejoin identifiers the recogniser split.** `tsconfig` comes back as "TS config".
|
|
197
|
+
Two adjacent tokens are joined only when their concatenation is *exactly* a keyterm,
|
|
198
|
+
they are separated by exactly one space, and the shorter one is an abbreviation
|
|
199
|
+
(uppercase, or at most two characters). "TS config" qualifies; "back end" and
|
|
200
|
+
"work tree" deliberately do not, because in the next sentence those are two words.
|
|
201
|
+
2. **Repair a single misheard token**, within one edit of a keyterm for words up to 7
|
|
202
|
+
characters or two edits for longer ones, and never for tokens under 5 characters.
|
|
203
|
+
Distance is Damerau, so an adjacent swap counts once — that is how ASR mangles a
|
|
204
|
+
technical term most often (`subagent` → `subagnet`).
|
|
205
|
+
|
|
206
|
+
On the real output of the local backend for the test sentence, that turns
|
|
207
|
+
`the Worktree TS config` into `the Worktree tsconfig`. It does **not** rescue
|
|
208
|
+
`DRAM backend` from `deepgram backend` — too many edits, and below the length floor.
|
|
209
|
+
|
|
210
|
+
```jsonc
|
|
211
|
+
"correction": "safe" // "off" | "safe" | "report"
|
|
212
|
+
```
|
|
213
|
+
|
|
214
|
+
`"report"` changes nothing and logs what it *would* change — use it to find out
|
|
215
|
+
whether the pass is earning its keep before trusting it.
|
|
216
|
+
|
|
217
|
+
If you want true local biasing instead, a whisper-family model with `initialPrompt`
|
|
218
|
+
plus a batch re-pass at finalize is the route; this extension does not implement it.
|
|
219
|
+
|
|
220
|
+
**It is also the slow part.** How fast it runs depends on the machine; the numbers below
|
|
221
|
+
come from one reference machine — an 8-core x86_64 laptop using the bundled
|
|
222
|
+
`linux-x64-cpu-vulkan` build on the CPU path (its GPU was not usable there):
|
|
223
|
+
|
|
224
|
+
| | measured |
|
|
225
|
+
|---|---|
|
|
226
|
+
| first model load (698 MB, cold page cache) | ~2.7 s |
|
|
227
|
+
| warm reload (file already cached) | ~0.4 s |
|
|
228
|
+
| **streaming decode** | **~0.9× real-time — only just keeps up** |
|
|
229
|
+
| **batch** transcribe of the same audio | **3.5× real-time** |
|
|
230
|
+
| first interim out of the stream | ~0.6 s |
|
|
231
|
+
|
|
232
|
+
So live streaming works, but without headroom: the decoder consumes audio about as
|
|
233
|
+
fast as you speak it, and text drifts further behind over a long utterance. The batch
|
|
234
|
+
path is nearly four times faster, which is why a record-then-transcribe mode is worth
|
|
235
|
+
considering (see below). Treat the local backend as the private/offline option rather
|
|
236
|
+
than the fast one until it is running on usable compute.
|
|
237
|
+
|
|
238
|
+
### Which local models can stream
|
|
239
|
+
|
|
240
|
+
`transcribe-cpp` ships 27 models; only these four can do live dictation at all:
|
|
241
|
+
|
|
242
|
+
| model | size | languages |
|
|
243
|
+
|---|---|---|
|
|
244
|
+
| `parakeet-unified-en-0.6b` | 731 MB | English |
|
|
245
|
+
| `multitalker-parakeet-streaming-0.6b-v1` | 734 MB | English |
|
|
246
|
+
| `nemotron-3.5-asr-streaming-0.6b` | 751 MB | 32 languages |
|
|
247
|
+
| `Voxtral-Mini-4B-Realtime-2602` | 4.7 GB | 13 languages |
|
|
248
|
+
|
|
249
|
+
There is **no streaming model lighter than parakeet-0.6b**. The genuinely small ones —
|
|
250
|
+
`medasr` (128 MB), `parakeet-tdt_ctc-110m` (135 MB), `canary-180m-flash` (218 MB),
|
|
251
|
+
`SenseVoiceSmall` (253 MB), `whisper-small` (270 MB) — are all batch-only, so using one
|
|
252
|
+
means giving up live text and transcribing after you release the key. With batch running
|
|
253
|
+
at 3.5× real-time that is a reasonable trade, and the pipeline already retains the whole
|
|
254
|
+
utterance, but it is not implemented yet.
|
|
255
|
+
|
|
256
|
+
For non-English dictation, `nemotron-3.5-asr-streaming-0.6b` is the only streaming option
|
|
257
|
+
that is not parakeet, at the same size.
|
|
258
|
+
|
|
259
|
+
### Batch mode (the default)
|
|
260
|
+
|
|
261
|
+
`parakeet.mode` chooses how the local backend works, and **`batch` is the default**
|
|
262
|
+
because it is typically several times faster than streaming, and because it is the only
|
|
263
|
+
option for the small models, none of which can stream.
|
|
264
|
+
|
|
265
|
+
In batch mode the audio is transcribed in pieces while you are still speaking, so only
|
|
266
|
+
the tail is left when you release the key:
|
|
267
|
+
|
|
268
|
+
- `parakeet.chunkSeconds` (default 30) is a **target, not a limit**, and only its lower
|
|
269
|
+
70% matters: the audio is cut at the **first real pause** at or after that point and
|
|
270
|
+
transcribed in the background. A pause arriving later than the target still triggers a
|
|
271
|
+
cut, and unbroken speech simply grows a longer chunk
|
|
272
|
+
- `parakeet.chunkSeconds: 0` disables chunking and transcribes once, at the end
|
|
273
|
+
- a chunk is only cut where the signal is quiet enough to be a pause. The threshold is
|
|
274
|
+
**derived from each recording** (`parakeet.silenceLevel: "auto"`): the noise floor is the
|
|
275
|
+
second-lowest level in the buffer, speech is the 75th percentile, and a boundary counts
|
|
276
|
+
as a pause when it falls in the lowest 30% of that range. The floor is a near-minimum
|
|
277
|
+
rather than a low percentile because pauses are brief — two silent chunks in forty leave
|
|
278
|
+
a percentile reporting pure speech, while the minimum still finds them. A fixed number
|
|
279
|
+
cannot span rooms — of two real recordings, one never dropped below 0.05
|
|
280
|
+
(music playing) and the other paused around 0.3. Pin a number to override.
|
|
281
|
+
- when the level barely varies there is no pause structure to find, so nothing is cut and
|
|
282
|
+
the whole utterance is transcribed in one piece. That is the safe answer for unbroken
|
|
283
|
+
speech and for steady background noise.
|
|
284
|
+
- **the default is calibrated for a model that tolerates a chunk starting mid-word**,
|
|
285
|
+
which the recommended `parakeet-tdt_ctc-110m` does. It matters: `canary-180m-flash`
|
|
286
|
+
returns an *empty* transcript for any clip that starts mid-utterance — in testing, at
|
|
287
|
+
offsets 4, 6, 8 and 10 s into a 24 s clip it produced 0 characters every time, while
|
|
288
|
+
parakeet-tdt_ctc-110m produced 292, 253, 215 and 192. If you use canary, set
|
|
289
|
+
`silenceLevel` to 0.05 so cuts only happen at true silence.
|
|
290
|
+
|
|
291
|
+
That last rule is not fussiness. Cutting mid-utterance loses words outright: canary
|
|
292
|
+
returns an **empty transcript** for any clip that begins mid-sentence, so a chunk cut at
|
|
293
|
+
merely the *quietest* moment silently discarded everything in it. Requiring real silence
|
|
294
|
+
costs tail latency on unbroken speech instead of costing speech.
|
|
295
|
+
|
|
296
|
+
### Keeping the model loaded
|
|
297
|
+
|
|
298
|
+
The model is **released after every recording by default**, because a warm reload is only
|
|
299
|
+
about 0.4 s and holding ~700 MB between recordings is not a good trade on a laptop. Set
|
|
300
|
+
`parakeet.idleUnloadMs` to keep it resident instead — useful for larger models, where a
|
|
301
|
+
reload costs real time. When residency is on, the model is released by whichever of these
|
|
302
|
+
happens first:
|
|
303
|
+
|
|
304
|
+
- the idle window expires (`parakeet.idleUnloadMs`)
|
|
305
|
+
- the machine gets busy — one-minute load average per CPU reaching **0.8**
|
|
306
|
+
(`parakeet.loadShedRatio`), checked once a minute
|
|
307
|
+
|
|
308
|
+
A new recording cancels a pending release, and session shutdown releases immediately.
|
|
309
|
+
Note that the load-shedding half only matters while residency is on: at the default of 0
|
|
310
|
+
the memory is already back after each recording, so there is nothing to shed.
|
|
311
|
+
|
|
312
|
+
## Settings
|
|
313
|
+
|
|
314
|
+
`~/.pi/agent/better-pi-voice.json`. Every field is optional; anything missing or
|
|
315
|
+
malformed falls back to a default and warns rather than breaking the microphone.
|
|
316
|
+
|
|
317
|
+
```jsonc
|
|
318
|
+
{
|
|
319
|
+
"backend": "deepgram", // "deepgram" | "parakeet"
|
|
320
|
+
"mode": "hold", // "hold" | "tap"
|
|
321
|
+
"shortcut": "ctrl+alt+z",
|
|
322
|
+
"language": "en",
|
|
323
|
+
"deepgram": {
|
|
324
|
+
"apiKeyEnv": "DEEPGRAM_API_KEY",
|
|
325
|
+
"apiKey": null, // inline key, only written by /voice key --inline
|
|
326
|
+
"apiKeyFile": null, // dotenv file, used when the env var is unset
|
|
327
|
+
"model": "nova-3",
|
|
328
|
+
"smartFormat": true,
|
|
329
|
+
"endpointingMs": 300, // silence before an utterance is finalised
|
|
330
|
+
"utteranceEndMs": 1000
|
|
331
|
+
},
|
|
332
|
+
"parakeet": {
|
|
333
|
+
"modelPath": null, // null = read pi-voice's config if present
|
|
334
|
+
"mode": "batch", // "batch" (faster, chunked) | "streaming"
|
|
335
|
+
"chunkSeconds": 30, // cut at a pause near this length; 0 = only at the end
|
|
336
|
+
"silenceLevel": "auto", // "auto" derives the pause threshold per recording; a number pins it
|
|
337
|
+
"idleUnloadMs": 0, // 0 = release after each recording (see above)
|
|
338
|
+
"loadShedRatio": 0.8 // with residency on, release early once load per CPU reaches this
|
|
339
|
+
},
|
|
340
|
+
"keyterms": {
|
|
341
|
+
"enabled": true,
|
|
342
|
+
"seed": true,
|
|
343
|
+
"mineSessionFiles": true,
|
|
344
|
+
"maxTerms": 60,
|
|
345
|
+
"user": []
|
|
346
|
+
},
|
|
347
|
+
"correction": "safe",
|
|
348
|
+
"submitOnStop": false // false = leave the text in the editor
|
|
349
|
+
}
|
|
350
|
+
```
|
|
351
|
+
|
|
352
|
+
## When something goes wrong
|
|
353
|
+
|
|
354
|
+
Failures are reported with a cause rather than "check your network":
|
|
355
|
+
|
|
356
|
+
- **The microphone starts first.** Capture begins as soon as the key goes down, and
|
|
357
|
+
audio is buffered and then sent the instant the backend is ready — so the first
|
|
358
|
+
syllable is not lost while a model loads or a websocket connects. The microphone is
|
|
359
|
+
also probed at session start, so a missing device is reported then rather than when you
|
|
360
|
+
first hold the key.
|
|
361
|
+
- **Connection failures are diagnosed.** When the websocket fails, the extension makes
|
|
362
|
+
a small authenticated REST call to Deepgram and reports which it was: `Deepgram
|
|
363
|
+
rejected the API key`, `could not reach Deepgram (getaddrinfo ENOTFOUND ...)`, or
|
|
364
|
+
`Deepgram returned HTTP 503`. The websocket close code alone cannot distinguish those.
|
|
365
|
+
- **Errors carry their detail.** Whatever the socket's error event holds is included
|
|
366
|
+
in the message instead of a bare "connection error".
|
|
367
|
+
- **Speech is never lost.** One retry before the first transcript, replay of the
|
|
368
|
+
retained audio when a stream silently drops, partial transcripts salvaged, unreported
|
|
369
|
+
interims promoted to finals, and a circuit breaker after three fast failures.
|
|
370
|
+
|
|
371
|
+
## Privacy
|
|
372
|
+
|
|
373
|
+
**Cloud mode sends your microphone audio to Deepgram.** It is billed per minute of
|
|
374
|
+
audio, it transcribes on their servers, and it is the default because it is the more
|
|
375
|
+
accurate of the two. Local mode sends nothing anywhere; use it for anything sensitive.
|
|
376
|
+
Buffered audio is held in memory only, is never written to disk, and is discarded when
|
|
377
|
+
the session ends.
|
|
378
|
+
|
|
379
|
+
## Requirements
|
|
380
|
+
|
|
381
|
+
- Linux with `arecord` (alsa-utils), or SoX's `rec` as a fallback. macOS/Windows use
|
|
382
|
+
`rec` and are a degraded path — untested by us.
|
|
383
|
+
- Hold-to-talk needs Kitty keyboard protocol flag 2 for key *release*. It is verified
|
|
384
|
+
working on Kitty. Everywhere else it falls back to tap-to-toggle and says so once.
|
|
385
|
+
- The local backend needs `transcribe-cpp` and a Parakeet GGUF.
|
|
386
|
+
|
|
387
|
+
## Development
|
|
388
|
+
|
|
389
|
+
```bash
|
|
390
|
+
bun test # no network or microphone required
|
|
391
|
+
bunx tsc --noEmit
|
|
392
|
+
bunx biome check .
|
|
393
|
+
|
|
394
|
+
# live check against Deepgram, with and without keyterms
|
|
395
|
+
DEEPGRAM_API_KEY=... bun scripts/deepgram-smoke.ts speech.pcm
|
|
396
|
+
DEEPGRAM_API_KEY=... bun scripts/deepgram-smoke.ts speech.pcm subagent,worktree,tsconfig
|
|
397
|
+
|
|
398
|
+
# live check of the local backend (loads the GGUF, so it is slow)
|
|
399
|
+
bun scripts/parakeet-smoke.ts speech.pcm
|
|
400
|
+
```
|
|
401
|
+
|
|
402
|
+
The pipeline is tested without a microphone, a network, or a model: capture, the
|
|
403
|
+
backend, and time are all injected seams, so every recovery rule has a test that runs
|
|
404
|
+
in milliseconds.
|