pi-voicekit 0.1.3 → 0.2.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/README.md +144 -41
- package/extensions/voice/config.ts +157 -16
- package/extensions/voice/onboarding.ts +2 -2
- package/extensions/voice/post-process-context.ts +185 -0
- package/extensions/voice/post-process-prompt.ts +95 -0
- package/extensions/voice/post-process.ts +219 -0
- package/extensions/voice/settings-panel.ts +267 -6
- package/extensions/voice/tts-local-models.ts +1 -1
- package/extensions/voice/tts-onboarding-overlay.ts +7 -3
- package/extensions/voice/tts-playback.ts +2 -2
- package/extensions/voice/ui-aura.ts +1 -1
- package/extensions/voice/ui-help-overlay.ts +2 -2
- package/extensions/voice/ui-locale-labels.ts +1 -1
- package/extensions/voice/ui-width.ts +1 -1
- package/extensions/voice.ts +683 -43
- package/package.json +6 -6
package/README.md
CHANGED
|
@@ -6,32 +6,30 @@
|
|
|
6
6
|
> Not affiliated with the original author. Old name: `pi-listen`.
|
|
7
7
|
|
|
8
8
|
<p align="center">
|
|
9
|
-
<img src="https://raw.githubusercontent.com/CyFeng16/pi-voicekit/main/assets/banner.png" alt="pi-
|
|
9
|
+
<img src="https://raw.githubusercontent.com/CyFeng16/pi-voicekit/main/assets/brand/banner-en.png" alt="pi-voicekit — Voice input and output for the Pi coding agent" width="100%" />
|
|
10
10
|
</p>
|
|
11
11
|
|
|
12
|
-
**
|
|
12
|
+
**Voice in and voice out for [Pi](https://github.com/earendil-works/pi-coding-agent).**
|
|
13
|
+
Hold-to-talk STT — Deepgram streaming (cloud) or 21 offline models — plus TTS that
|
|
14
|
+
speaks the agent's replies (Kitten, Kokoro, Piper, or Deepgram Aura).
|
|
13
15
|
|
|
14
16
|
[](https://www.npmjs.com/package/pi-voicekit)
|
|
15
17
|
[](https://github.com/CyFeng16/pi-voicekit/blob/main/LICENSE)
|
|
16
18
|
[](https://x.com/baanditeagle)
|
|
17
19
|
|
|
18
|
-
> **
|
|
19
|
-
>
|
|
20
|
-
>
|
|
21
|
-
>
|
|
22
|
-
>
|
|
23
|
-
> rate limiting. Diagnostic command `/voice-speak-info` shows everything.
|
|
24
|
-
> Resume-on-interrupt downloads. Plus all v6 features (14 local models from
|
|
25
|
-
> 25 MB Kitten Nano up, Deepgram Aura cloud, region-strict language matching,
|
|
26
|
-
> sentence-aware chunking). [Full changelog →](CHANGELOG.md)
|
|
20
|
+
> **v0.1.3 — current release** — audio capture prefers `ffmpeg` when
|
|
21
|
+
> `PULSE_SERVER` is set (SSH audio tunnel / remote PulseAudio), so remote
|
|
22
|
+
> microphones record reliably. Voice in **and** voice out: 21 offline STT models,
|
|
23
|
+
> 20 local TTS voices plus Deepgram Aura, driven by one `/voice-settings` panel
|
|
24
|
+
> with 6 tabs. The 0.1.x line is documented in the [changelog](CHANGELOG.md).
|
|
27
25
|
|
|
28
26
|
---
|
|
29
27
|
|
|
30
28
|
## See How It Works
|
|
31
29
|
|
|
32
30
|
<p align="center">
|
|
33
|
-
<a href="https://github.com/CyFeng16/pi-voicekit/blob/main/assets/pi-
|
|
34
|
-
<img src="https://raw.githubusercontent.com/CyFeng16/pi-voicekit/main/assets/banner.png" alt="Watch demo video" width="600" />
|
|
31
|
+
<a href="https://github.com/CyFeng16/pi-voicekit/blob/main/assets/demo/pi-voicekit-demo.mp4">
|
|
32
|
+
<img src="https://raw.githubusercontent.com/CyFeng16/pi-voicekit/main/assets/brand/banner-en.png" alt="Watch demo video" width="600" />
|
|
35
33
|
</a>
|
|
36
34
|
<br>
|
|
37
35
|
<em>Click to watch the demo video</em>
|
|
@@ -50,16 +48,16 @@ pi install npm:pi-voicekit
|
|
|
50
48
|
|
|
51
49
|
### 2. Choose your backend
|
|
52
50
|
|
|
53
|
-
pi-
|
|
51
|
+
pi-voicekit supports two transcription backends:
|
|
54
52
|
|
|
55
|
-
| | Deepgram (cloud) | Local models (offline)
|
|
53
|
+
| | Deepgram (cloud) | Local models (offline recognition) |
|
|
56
54
|
| ---------------- | -------------------------------------------------------- | --------------------------------------------------- |
|
|
57
55
|
| **How it works** | Live streaming — text appears as you speak | Batch mode — transcribes after you finish recording |
|
|
58
56
|
| **Setup** | API key required | No API key, models auto-download on first use |
|
|
59
|
-
| **Internet** | Required | Not required after model download
|
|
57
|
+
| **Internet** | Required | Not required after model download for recognition; the polish step may use the network |
|
|
60
58
|
| **Latency** | Real-time interim results | 2–10 seconds after recording stops |
|
|
61
59
|
| **Languages** | 56+ with live streaming | Depends on model (1–57 languages) |
|
|
62
|
-
| **Cost** | $200 free credit (lasts 6–12 months for most developers) |
|
|
60
|
+
| **Cost** | $200 free credit (lasts 6–12 months for most developers) | Recognition is free; the polish step may cost money |
|
|
63
61
|
|
|
64
62
|
Run `/voice-settings` inside Pi to choose your backend and configure everything from one panel.
|
|
65
63
|
|
|
@@ -71,7 +69,7 @@ Sign up at [dpgr.am/pi-voice](https://dpgr.am/pi-voice) — $200 free credit, no
|
|
|
71
69
|
export DEEPGRAM_API_KEY="your-key-here" # add to ~/.zshrc or ~/.bashrc
|
|
72
70
|
```
|
|
73
71
|
|
|
74
|
-
#### Option B: Local models (
|
|
72
|
+
#### Option B: Local models (offline recognition)
|
|
75
73
|
|
|
76
74
|
No setup needed — run `/voice-settings`, switch backend to Local, and select a model. It downloads automatically.
|
|
77
75
|
|
|
@@ -79,7 +77,7 @@ No setup needed — run `/voice-settings`, switch backend to Local, and select a
|
|
|
79
77
|
|
|
80
78
|
### 3. Open Pi
|
|
81
79
|
|
|
82
|
-
On first launch, pi-
|
|
80
|
+
On first launch, pi-voicekit checks your setup and tells you what's ready:
|
|
83
81
|
|
|
84
82
|
- Backend configured (Deepgram key or local model)
|
|
85
83
|
- Audio capture tool detected (sox, ffmpeg, or arecord)
|
|
@@ -87,7 +85,7 @@ On first launch, pi-listen checks your setup and tells you what's ready:
|
|
|
87
85
|
|
|
88
86
|
### Audio capture
|
|
89
87
|
|
|
90
|
-
pi-
|
|
88
|
+
pi-voicekit auto-detects your audio tool. No manual install needed if you already have sox or ffmpeg.
|
|
91
89
|
|
|
92
90
|
| Priority | Tool | Platforms | Install |
|
|
93
91
|
| -------- | --------------- | --------------------- | ------------------------------------------------------------ |
|
|
@@ -95,36 +93,57 @@ pi-listen auto-detects your audio tool. No manual install needed if you already
|
|
|
95
93
|
| 2 | **ffmpeg** | macOS, Linux, Windows | `brew install ffmpeg` / `apt install ffmpeg` |
|
|
96
94
|
| 3 | **arecord** | Linux only | Pre-installed (ALSA) |
|
|
97
95
|
|
|
96
|
+
> When `PULSE_SERVER` is set (SSH audio tunnel or remote PulseAudio) the order
|
|
97
|
+
> becomes **ffmpeg → sox → arecord** — network Pulse sources need ffmpeg.
|
|
98
|
+
|
|
98
99
|
---
|
|
99
100
|
|
|
100
101
|
## Settings Panel
|
|
101
102
|
|
|
102
|
-
All configuration lives in one place: `/voice-settings`.
|
|
103
|
+
All configuration lives in one place: `/voice-settings`. Six tabs cover everything you need.
|
|
103
104
|
|
|
104
105
|
### General — backend, language, scope
|
|
105
106
|
|
|
106
|
-
<img src="https://raw.githubusercontent.com/CyFeng16/pi-voicekit/main/assets/settings-general.png" alt="General settings — backend, model, language, scope, voice toggle" width="600" />
|
|
107
|
+
<img src="https://raw.githubusercontent.com/CyFeng16/pi-voicekit/main/assets/screenshots/settings-general.png" alt="General settings — backend, model, language, scope, voice toggle" width="600" />
|
|
107
108
|
|
|
108
109
|
Toggle between Deepgram (cloud, live streaming) and Local (offline, batch mode). Change language, scope, and enable/disable voice — all with keyboard shortcuts.
|
|
109
110
|
|
|
110
111
|
### Models — browse, search, install
|
|
111
112
|
|
|
112
|
-
<img src="https://raw.githubusercontent.com/CyFeng16/pi-voicekit/main/assets/settings-models.png" alt="Models tab — browse 21 models with accuracy/speed ratings" width="600" />
|
|
113
|
+
<img src="https://raw.githubusercontent.com/CyFeng16/pi-voicekit/main/assets/screenshots/settings-models.png" alt="Models tab — browse 21 models with accuracy/speed ratings" width="600" />
|
|
113
114
|
|
|
114
115
|
Browse 21 models from Parakeet, Whisper, Moonshine, SenseVoice, GigaAM, Paraformer, and Qwen3. Each model shows accuracy and speed ratings (●●●●○/●●●●○), fitness badges, and download status. Fuzzy search to find models fast. Press Enter to activate and download.
|
|
115
116
|
|
|
116
117
|
### Downloaded — manage installed models
|
|
117
118
|
|
|
118
|
-
<img src="https://raw.githubusercontent.com/CyFeng16/pi-voicekit/main/assets/settings-downloaded.png" alt="Downloaded tab — manage installed models, activate or delete" width="600" />
|
|
119
|
+
<img src="https://raw.githubusercontent.com/CyFeng16/pi-voicekit/main/assets/screenshots/settings-downloaded.png" alt="Downloaded tab — manage installed models, activate or delete" width="600" />
|
|
119
120
|
|
|
120
121
|
See what's installed, total disk usage, and which model is active. Press Enter to activate, `x` to delete. Models from [Handy](https://github.com/cjpais/handy) are auto-detected and can be imported without re-downloading.
|
|
121
122
|
|
|
123
|
+
### Speak — TTS models and voices
|
|
124
|
+
|
|
125
|
+
Pick a TTS backend (local sherpa-onnx or Deepgram Aura), browse 20 local voices
|
|
126
|
+
from ~13 MB, download on selection, and choose a voice per backend. Auto-speak of
|
|
127
|
+
agent replies is toggled here.
|
|
128
|
+
|
|
122
129
|
### Device — hardware profile and dependencies
|
|
123
130
|
|
|
124
|
-
<img src="https://raw.githubusercontent.com/CyFeng16/pi-voicekit/main/assets/settings-device.png" alt="Device tab — hardware profile, dependencies, disk space" width="600" />
|
|
131
|
+
<img src="https://raw.githubusercontent.com/CyFeng16/pi-voicekit/main/assets/screenshots/settings-device.png" alt="Device tab — hardware profile, dependencies, disk space" width="600" />
|
|
125
132
|
|
|
126
133
|
See your hardware profile (RAM, CPU, GPU), dependency status (sherpa-onnx runtime), available disk space, and total downloaded models. Model recommendations are based on this profile.
|
|
127
134
|
|
|
135
|
+
### Polish — transcript cleanup
|
|
136
|
+
|
|
137
|
+
Optional post-ASR cleanup, on by default. Toggle it, pick the model, set how many
|
|
138
|
+
recent conversation turns accompany the transcript (0–10), and cap how long one
|
|
139
|
+
pass may take (`1000`–`30000` ms). The last row shows the most recent polished
|
|
140
|
+
dictation as a `RAW` / `POLISHED` pair. `/voice-polish last` prints the newest
|
|
141
|
+
dictation a pass ran on — including one whose result was discarded — with its
|
|
142
|
+
`STATUS`, `RAW`, and `WRITTEN` text, or says that nothing was written.
|
|
143
|
+
|
|
144
|
+
`/voice-polish` takes `on`, `off`, `model`, `turns <0-10>`, `last` and `restore`;
|
|
145
|
+
run it with no argument for the current status.
|
|
146
|
+
|
|
128
147
|
---
|
|
129
148
|
|
|
130
149
|
## Usage
|
|
@@ -133,7 +152,7 @@ See your hardware profile (RAM, CPU, GPU), dependency status (sherpa-onnx runtim
|
|
|
133
152
|
|
|
134
153
|
| Action | Key | Notes |
|
|
135
154
|
| -------------------- | -------------------- | ----------------------------------------------------------------------- |
|
|
136
|
-
| **Record to editor** | Hold `SPACE` (≥
|
|
155
|
+
| **Record to editor** | Hold `SPACE` (≥0.7s) | Release to finalize. Pre-records during warmup so you don't miss words. |
|
|
137
156
|
| **Toggle recording** | `Ctrl+Shift+V` | Works in all terminals — press to start, press again to stop. |
|
|
138
157
|
| **Clear editor** | `Escape` × 2 | Double-tap within 500ms to clear all text. |
|
|
139
158
|
|
|
@@ -150,10 +169,16 @@ See your hardware profile (RAM, CPU, GPU), dependency status (sherpa-onnx runtim
|
|
|
150
169
|
| ------------------------ | --------------------------------------------------------- |
|
|
151
170
|
| `/voice-settings` | Settings panel — backend, models, language, scope, device |
|
|
152
171
|
| `/voice-models` | Settings panel (Models tab) |
|
|
172
|
+
| `/voice-setup` | Run the first-run setup wizard |
|
|
173
|
+
| `/voice-language` | Open the settings panel to change language |
|
|
153
174
|
| `/voice-speak <text>` | Speak text out loud (TTS) |
|
|
154
175
|
| `/voice-speak-test` | Speak a sample sentence |
|
|
155
176
|
| `/voice-speak-toggle` | Enable / disable TTS |
|
|
156
|
-
| `/voice-
|
|
177
|
+
| `/voice-stream` | Toggle Deepgram streaming TTS (cloud) |
|
|
178
|
+
| `/voice-speak-stop` | Stop in-flight TTS playback |
|
|
179
|
+
| `/voice-autosubmit` | Toggle: STT text auto-sent to the agent (`on`/`off`) |
|
|
180
|
+
| `/voice-polish [sub]` | Transcript polish: on, off, model, turns, last, restore |
|
|
181
|
+
| `/voice-hold-delay` | Set hold-to-talk delay (200-3000 ms, default 700) |
|
|
157
182
|
| `/voice-speak-models` | Browse / install TTS voice models |
|
|
158
183
|
| `/voice-speak-info` | Diagnose TTS state |
|
|
159
184
|
| `/voice-help` | Keyboard + command reference (or press `F1`) |
|
|
@@ -245,10 +270,11 @@ Models from [Handy](https://github.com/cjpais/handy) (`~/Library/Application Sup
|
|
|
245
270
|
| **Device-aware recommendations** | Scores models against your hardware. Only best-in-class models get [recommended]. |
|
|
246
271
|
| **Enterprise download pipeline** | Pre-checks (disk, network, permissions), live progress with speed/ETA, post-verification |
|
|
247
272
|
| **Handy integration** | Auto-detects models from Handy app, imports via symlink |
|
|
248
|
-
| **Audio fallback chain** | Tries sox
|
|
273
|
+
| **Audio fallback chain** | Tries sox → ffmpeg → arecord in order — ffmpeg first when `PULSE_SERVER` is set |
|
|
249
274
|
| **Pre-recording** | Audio capture starts during warmup — you never miss the first word |
|
|
250
275
|
| **Tail recording** | Keeps recording 1.5s after release so your last word isn't clipped |
|
|
251
|
-
| **Live streaming** | Deepgram Nova 3 WebSocket — interim transcripts
|
|
276
|
+
| **Live streaming** | Deepgram Nova 3 WebSocket (Nova 2 for Chinese locales) — live interim transcripts |
|
|
277
|
+
| **Transcript polish** | Optional post-ASR cleanup — every dictation makes one extra model call; the last N conversation turns (default 2) are sent with it, and no conversation context at all when the turn count is zero. Disable with `/voice-polish off` |
|
|
252
278
|
| **56+ languages** | Deepgram: 56+ with live streaming. Local: up to 57 depending on model. |
|
|
253
279
|
| **Continuous dictation** | `/voice dictate` for long-form input without holding keys |
|
|
254
280
|
| **Typing cooldown** | Space holds within 400ms of typing are ignored |
|
|
@@ -260,15 +286,52 @@ Models from [Handy](https://github.com/cjpais/handy) (`~/Library/Application Sup
|
|
|
260
286
|
## Architecture
|
|
261
287
|
|
|
262
288
|
```
|
|
263
|
-
|
|
264
|
-
extensions/voice
|
|
265
|
-
extensions/voice/
|
|
266
|
-
extensions/voice/
|
|
267
|
-
extensions/voice/
|
|
268
|
-
extensions/voice/
|
|
269
|
-
extensions/voice/
|
|
270
|
-
|
|
271
|
-
|
|
289
|
+
# core
|
|
290
|
+
extensions/voice.ts Main extension — state machine, recording, UI, command surface
|
|
291
|
+
extensions/voice/config.ts Config loading, saving, migration
|
|
292
|
+
extensions/voice/onboarding.ts First-run wizard, language picker
|
|
293
|
+
extensions/voice/audio-tool.ts Capture tool detection (sox / ffmpeg / arecord)
|
|
294
|
+
extensions/voice/hold-to-talk.ts Hold detection, Kitty and non-Kitty terminals
|
|
295
|
+
extensions/voice/release-controller.ts Recording lifecycle, release handling
|
|
296
|
+
|
|
297
|
+
# speech-to-text
|
|
298
|
+
extensions/voice/deepgram.ts Deepgram URL builder, API key resolver
|
|
299
|
+
extensions/voice/local.ts Model catalog (21 models), in-process transcription
|
|
300
|
+
extensions/voice/sherpa-engine.ts sherpa-onnx bindings — recognizer lifecycle, inference
|
|
301
|
+
extensions/voice/sherpa-loader.ts Lazy native module loading
|
|
302
|
+
extensions/voice/model-download.ts Download manager — resume, progress, verification, Handy import
|
|
303
|
+
extensions/voice/device.ts Device profiling — RAM, GPU, CPU, container detection
|
|
304
|
+
|
|
305
|
+
# transcript post-processing
|
|
306
|
+
extensions/voice/post-process.ts Polish pass — fail-open guardrails, model resolution, bounded call
|
|
307
|
+
extensions/voice/post-process-context.ts Context assembly — recent turns and character caps
|
|
308
|
+
extensions/voice/post-process-prompt.ts Fixed polish prompt and request shape
|
|
309
|
+
|
|
310
|
+
# text-to-speech
|
|
311
|
+
extensions/voice/speak.ts Speak entry point, auto-speak wiring
|
|
312
|
+
extensions/voice/tts-engine.ts sherpa-onnx TTS synthesis
|
|
313
|
+
extensions/voice/tts-deepgram.ts Deepgram Aura voices (cloud)
|
|
314
|
+
extensions/voice/tts-local-models.ts Local TTS catalog — 20 voices (Kitten, Kokoro, Piper)
|
|
315
|
+
extensions/voice/tts-playback.ts Playback, buffering, player detection
|
|
316
|
+
extensions/voice/tts-text-filter.ts Code-block stripping, sentence prep
|
|
317
|
+
extensions/voice/tts-onboarding.ts TTS onboarding flow
|
|
318
|
+
extensions/voice/tts-onboarding-overlay.ts TTS onboarding overlay
|
|
319
|
+
extensions/voice/tts-install-progress.ts Model install progress widget
|
|
320
|
+
extensions/voice/tts-playback-indicator.ts Speaking indicator widget
|
|
321
|
+
|
|
322
|
+
# settings and UI
|
|
323
|
+
extensions/voice/settings-panel.ts Settings panel — overlay, 6 tabs
|
|
324
|
+
extensions/voice/ui-picker.ts Generic list picker
|
|
325
|
+
extensions/voice/ui-help-overlay.ts Keyboard and command reference
|
|
326
|
+
extensions/voice/ui-aura.ts Visual primitives (Liquid Braille, Aurora)
|
|
327
|
+
extensions/voice/ui-widget-base.ts Widget registry and base class
|
|
328
|
+
extensions/voice/ui-render-ticker.ts Shared render ticker
|
|
329
|
+
extensions/voice/ui-icons.ts Glyph and icon set
|
|
330
|
+
extensions/voice/ui-width.ts CJK-aware visual width helpers
|
|
331
|
+
extensions/voice/ui-locale-labels.ts Native language and voice labels
|
|
332
|
+
|
|
333
|
+
# types
|
|
334
|
+
extensions/voice/sherpa-onnx-node.d.ts Type declarations for the optional native module
|
|
272
335
|
```
|
|
273
336
|
|
|
274
337
|
---
|
|
@@ -285,13 +348,13 @@ Settings stored in Pi's settings files under the `voice` key:
|
|
|
285
348
|
```json
|
|
286
349
|
{
|
|
287
350
|
"voice": {
|
|
288
|
-
"version":
|
|
351
|
+
"version": 3,
|
|
289
352
|
"enabled": true,
|
|
290
353
|
"language": "en",
|
|
291
354
|
"backend": "local",
|
|
292
355
|
"localModel": "parakeet-v3",
|
|
293
356
|
"scope": "global",
|
|
294
|
-
"onboarding": { "completed": true, "schemaVersion":
|
|
357
|
+
"onboarding": { "completed": true, "schemaVersion": 3 }
|
|
295
358
|
}
|
|
296
359
|
}
|
|
297
360
|
```
|
|
@@ -300,6 +363,45 @@ Settings stored in Pi's settings files under the `voice` key:
|
|
|
300
363
|
into `~/.pi/agent/settings.json`. If you paste a key during onboarding, that is
|
|
301
364
|
an explicit save and it still goes to `~/.env.secrets` or `~/.zshrc`.
|
|
302
365
|
|
|
366
|
+
Hold-to-talk delay defaults to **700 ms** (`/voice-hold-delay` accepts 200–3000 ms).
|
|
367
|
+
|
|
368
|
+
### Transcript polish
|
|
369
|
+
|
|
370
|
+
Transcript polish is on by default: every dictation runs one extra model call. When
|
|
371
|
+
the selected model is a cloud provider, the text that leaves your machine is:
|
|
372
|
+
|
|
373
|
+
- the transcript of the dictation;
|
|
374
|
+
- the last N conversation turns of user and assistant text, where N is
|
|
375
|
+
`postProcessContextTurns` (default `2`; `0` sends no conversation context);
|
|
376
|
+
- nothing else. The compaction summary is deliberately not sent: it is a digest built from
|
|
377
|
+
earlier messages, so it can carry residues of thinking and tool output, and it measured no
|
|
378
|
+
gain over the turns alone.
|
|
379
|
+
|
|
380
|
+
One measured behaviour is worth knowing: a model that thinks before it answers may normalise a
|
|
381
|
+
spoken operator into its symbol — `select star` comes back as `select *`. The information is
|
|
382
|
+
unchanged, there is no setting for it, and `/voice-polish off` is the way to keep the words verbatim.
|
|
383
|
+
|
|
384
|
+
Assistant text can contain anything the conversation contained — file paths,
|
|
385
|
+
identifiers, values the agent echoed. The character limits bound how much is sent,
|
|
386
|
+
not how sensitive it is. With the local backend, nothing else leaves your machine,
|
|
387
|
+
and audio never does: recognition runs on this machine with no API key. Turn the
|
|
388
|
+
feature off with `/voice-polish off` or the Polish tab's Enabled row.
|
|
389
|
+
|
|
390
|
+
| Setting | Scope | Default | Notes |
|
|
391
|
+
| ------------------------- | ------------------ | ----------- | ------------------------------------------------------- |
|
|
392
|
+
| `postProcessEnabled` | global only | `true` | Master switch. A project `voice` block cannot flip it. |
|
|
393
|
+
| `postProcessModel` | global only | `"session"` | Reuses the session model, or `provider/modelId`. |
|
|
394
|
+
| `postProcessContextTurns` | global and project | `2` | Conversation turns sent with the transcript, `0`–`10`. |
|
|
395
|
+
| `postProcessTimeoutMs` | global and project | `12000` | Per-pass timeout in milliseconds, `1000`–`30000`. |
|
|
396
|
+
|
|
397
|
+
The global-only fields resolve from `~/.pi/agent/settings.json` even when a
|
|
398
|
+
repository provides its own `voice` block, so a cloned repo can neither turn the
|
|
399
|
+
feature on nor redirect where dictated text goes. The model is chosen from a
|
|
400
|
+
picker (`/voice-polish model`), never typed: a hand-typed reference is refused,
|
|
401
|
+
and an unavailable or malformed model keeps the raw transcript instead of
|
|
402
|
+
switching provider. `postProcessNoticeShown` is machine-local bookkeeping for the
|
|
403
|
+
one-time notice, not a user setting.
|
|
404
|
+
|
|
303
405
|
---
|
|
304
406
|
|
|
305
407
|
## Troubleshooting
|
|
@@ -310,6 +412,7 @@ Run `/voice test` inside Pi for full diagnostics.
|
|
|
310
412
|
| ------------------------------------------------ | --------------------------------------------------------------------------------------------------------------- |
|
|
311
413
|
| "DEEPGRAM_API_KEY not set" | [Get a key](https://dpgr.am/pi-voice) → `export DEEPGRAM_API_KEY="..."` in `~/.zshrc` |
|
|
312
414
|
| "No audio capture tool found" | `brew install sox` or `brew install ffmpeg` |
|
|
415
|
+
| Remote microphone records silence | Audio over PulseAudio/SSH — install ffmpeg on the Pi side (capture then prefers ffmpeg) |
|
|
313
416
|
| Space doesn't activate voice | Run `/voice-settings` — voice may be disabled |
|
|
314
417
|
| Local model not transcribing | Check `/voice-settings` → Device tab for sherpa-onnx status |
|
|
315
418
|
| Download failed | Partial downloads auto-resume on retry. Check disk space in Device tab. |
|
|
@@ -321,7 +424,7 @@ Run `/voice test` inside Pi for full diagnostics.
|
|
|
321
424
|
|
|
322
425
|
- **Cloud STT** — audio is sent to Deepgram for transcription (Deepgram backend only)
|
|
323
426
|
- **Local STT** — audio never leaves your machine (local backend)
|
|
324
|
-
- **No telemetry** — pi-
|
|
427
|
+
- **No telemetry** — pi-voicekit does not collect or transmit usage data
|
|
325
428
|
- **API key** — stored in env var or Pi settings, never logged
|
|
326
429
|
|
|
327
430
|
See [SECURITY.md](SECURITY.md) for vulnerability reporting.
|
|
@@ -7,7 +7,7 @@ function getAgentDir(): string {
|
|
|
7
7
|
}
|
|
8
8
|
|
|
9
9
|
export const SETTINGS_KEY = "voice";
|
|
10
|
-
export const VOICE_CONFIG_VERSION =
|
|
10
|
+
export const VOICE_CONFIG_VERSION = 3;
|
|
11
11
|
|
|
12
12
|
export type VoiceSettingsScope = "global" | "project";
|
|
13
13
|
export type VoiceConfigSource = VoiceSettingsScope | "default";
|
|
@@ -40,6 +40,27 @@ export interface VoiceConfig {
|
|
|
40
40
|
/** Global-only shortcut used to toggle recording without hold-to-talk */
|
|
41
41
|
toggleShortcut?: string;
|
|
42
42
|
|
|
43
|
+
// ─── Post-processing (optional transcript polish) — new in v3 ─────
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Master switch for the post-ASR polish pass. Global-only — the
|
|
47
|
+
* enablement decides whether dictated text makes an extra model call,
|
|
48
|
+
* so a project file must not be able to flip it.
|
|
49
|
+
*/
|
|
50
|
+
postProcessEnabled?: boolean;
|
|
51
|
+
/**
|
|
52
|
+
* Model used by the polish pass — "session" reuses the active session
|
|
53
|
+
* model, or "<provider>/<modelId>". Global-only: the value decides
|
|
54
|
+
* where dictated text is sent.
|
|
55
|
+
*/
|
|
56
|
+
postProcessModel?: string;
|
|
57
|
+
/** How many recent conversation turns accompany the transcript. Honoured in both scopes; clamped to [0, 10]. */
|
|
58
|
+
postProcessContextTurns?: number;
|
|
59
|
+
/** Upper bound in milliseconds for one polish pass. Honoured in both scopes; clamped to [1000, 30000]. */
|
|
60
|
+
postProcessTimeoutMs?: number;
|
|
61
|
+
/** Set once the one-time default-on notice has been shown. Global-only — it describes this machine, not the repository. */
|
|
62
|
+
postProcessNoticeShown?: boolean;
|
|
63
|
+
|
|
43
64
|
// ─── TTS (text-to-speech) ─────────────────────────────────────────
|
|
44
65
|
// All TTS fields are opt-in (default: TTS disabled). New in v6.0.0.
|
|
45
66
|
|
|
@@ -125,6 +146,12 @@ export const DEFAULT_CONFIG: VoiceConfig = {
|
|
|
125
146
|
localModel: undefined,
|
|
126
147
|
localEndpoint: undefined,
|
|
127
148
|
toggleShortcut: "ctrl+shift+v",
|
|
149
|
+
// Post-processing defaults — on by default (D5), reusing the session model
|
|
150
|
+
postProcessEnabled: true,
|
|
151
|
+
postProcessModel: "session",
|
|
152
|
+
postProcessContextTurns: 2,
|
|
153
|
+
postProcessTimeoutMs: 12000,
|
|
154
|
+
postProcessNoticeShown: false,
|
|
128
155
|
// TTS defaults — all opt-in
|
|
129
156
|
ttsEnabled: false,
|
|
130
157
|
ttsBackend: "local",
|
|
@@ -150,7 +177,7 @@ export function readJsonFile(filePath: string): Record<string, unknown> {
|
|
|
150
177
|
return JSON.parse(fs.readFileSync(filePath, "utf8"));
|
|
151
178
|
} catch (err) {
|
|
152
179
|
process.stderr.write(
|
|
153
|
-
`[pi-
|
|
180
|
+
`[pi-voicekit] Warning: failed to read ${filePath}: ${err instanceof Error ? err.message : err}\n`
|
|
154
181
|
);
|
|
155
182
|
return {};
|
|
156
183
|
}
|
|
@@ -176,11 +203,46 @@ function normalizeOnboarding(input: any, fallbackCompleted: boolean): VoiceOnboa
|
|
|
176
203
|
};
|
|
177
204
|
}
|
|
178
205
|
|
|
179
|
-
|
|
206
|
+
/** Clamp an integer config value; non-numeric or non-finite input takes the default. */
|
|
207
|
+
function clampInt(value: unknown, min: number, max: number, fallback: number): number {
|
|
208
|
+
if (typeof value !== "number" || !Number.isFinite(value) || !Number.isInteger(value)) return fallback;
|
|
209
|
+
return Math.max(min, Math.min(max, value));
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
function migrateConfig(rawVoice: any, source: VoiceConfigSource, globalVoice?: unknown): VoiceConfig {
|
|
180
213
|
if (!rawVoice || typeof rawVoice !== "object") {
|
|
181
214
|
return structuredClone(DEFAULT_CONFIG);
|
|
182
215
|
}
|
|
183
216
|
|
|
217
|
+
// D7: model selection, enablement and the notice flag are global-only, and a
|
|
218
|
+
// project file must not be able to inject an API key or point audio at a
|
|
219
|
+
// non-loopback host. These fields resolve from the global block in BOTH scopes —
|
|
220
|
+
// falling back to DEFAULT_CONFIG would let a cloned repository re-enable a
|
|
221
|
+
// feature the maintainer turned off globally (spec §4.2).
|
|
222
|
+
const projectScoped = source === "project";
|
|
223
|
+
const globalRaw: Record<string, unknown> =
|
|
224
|
+
globalVoice && typeof globalVoice === "object" ? (globalVoice as Record<string, unknown>) : {};
|
|
225
|
+
const globalOnly = (key: string): unknown => (projectScoped ? globalRaw[key] : rawVoice[key]);
|
|
226
|
+
const asString = (value: unknown): string | undefined => (typeof value === "string" && value ? value : undefined);
|
|
227
|
+
const asBoolean = (value: unknown, fallbackValue: boolean): boolean =>
|
|
228
|
+
typeof value === "boolean" ? value : fallbackValue;
|
|
229
|
+
|
|
230
|
+
for (const key of ["postProcessEnabled", "postProcessModel", "deepgramApiKey", "localEndpoint"]) {
|
|
231
|
+
if (!projectScoped || rawVoice[key] === undefined) continue;
|
|
232
|
+
// A loopback project endpoint is honoured, not ignored — reporting it would
|
|
233
|
+
// cry wolf on the safe case and weaken the signal for the discarded ones.
|
|
234
|
+
if (
|
|
235
|
+
key === "localEndpoint" &&
|
|
236
|
+
typeof rawVoice.localEndpoint === "string" &&
|
|
237
|
+
isLoopbackEndpoint(rawVoice.localEndpoint)
|
|
238
|
+
) {
|
|
239
|
+
continue;
|
|
240
|
+
}
|
|
241
|
+
// Never print a key, even one that is being ignored.
|
|
242
|
+
const shown = key === "deepgramApiKey" ? "<redacted>" : JSON.stringify(rawVoice[key]);
|
|
243
|
+
process.stderr.write(`[pi-voicekit] Ignoring project-scoped voice.${key} (${shown}); using the global value\n`);
|
|
244
|
+
}
|
|
245
|
+
|
|
184
246
|
// Legacy configs may have backend+model — treat that as completed onboarding
|
|
185
247
|
const hasMeaningfulLegacySetup =
|
|
186
248
|
(typeof rawVoice.backend === "string" && typeof rawVoice.model === "string") ||
|
|
@@ -192,14 +254,26 @@ function migrateConfig(rawVoice: any, source: VoiceConfigSource): VoiceConfig {
|
|
|
192
254
|
enabled: typeof rawVoice.enabled === "boolean" ? rawVoice.enabled : DEFAULT_CONFIG.enabled,
|
|
193
255
|
language: typeof rawVoice.language === "string" ? rawVoice.language : DEFAULT_CONFIG.language,
|
|
194
256
|
scope: (rawVoice.scope as VoiceSettingsScope | undefined) ?? (source === "project" ? "project" : "global"),
|
|
195
|
-
deepgramApiKey:
|
|
257
|
+
deepgramApiKey: asString(globalOnly("deepgramApiKey")),
|
|
196
258
|
backend: rawVoice.backend === "local" ? "local" : undefined,
|
|
197
259
|
localModel: typeof rawVoice.localModel === "string" ? rawVoice.localModel : undefined,
|
|
198
|
-
localEndpoint:
|
|
260
|
+
localEndpoint: projectScoped
|
|
261
|
+
? typeof rawVoice.localEndpoint === "string" && isLoopbackEndpoint(rawVoice.localEndpoint)
|
|
262
|
+
? rawVoice.localEndpoint
|
|
263
|
+
: asString(globalRaw.localEndpoint)
|
|
264
|
+
: asString(rawVoice.localEndpoint),
|
|
199
265
|
toggleShortcut:
|
|
200
266
|
source !== "project" && typeof rawVoice.toggleShortcut === "string"
|
|
201
267
|
? rawVoice.toggleShortcut
|
|
202
268
|
: DEFAULT_CONFIG.toggleShortcut,
|
|
269
|
+
// Post-processing fields (v3). Model selection, enablement and the
|
|
270
|
+
// notice flag resolve through `globalOnly`; the two numeric knobs are
|
|
271
|
+
// honoured in both scopes.
|
|
272
|
+
postProcessEnabled: asBoolean(globalOnly("postProcessEnabled"), DEFAULT_CONFIG.postProcessEnabled ?? true),
|
|
273
|
+
postProcessModel: asString(globalOnly("postProcessModel")) ?? DEFAULT_CONFIG.postProcessModel,
|
|
274
|
+
postProcessContextTurns: clampInt(rawVoice.postProcessContextTurns, 0, 10, DEFAULT_CONFIG.postProcessContextTurns!),
|
|
275
|
+
postProcessTimeoutMs: clampInt(rawVoice.postProcessTimeoutMs, 1000, 30000, DEFAULT_CONFIG.postProcessTimeoutMs!),
|
|
276
|
+
postProcessNoticeShown: asBoolean(globalOnly("postProcessNoticeShown"), false),
|
|
203
277
|
// TTS fields — type-validated; mismatched persisted values fall
|
|
204
278
|
// back to safe defaults so a hand-edited config can't poison the
|
|
205
279
|
// engine. Notably: ttsLocalVoiceId rejects strings (would crash
|
|
@@ -251,7 +325,7 @@ export function loadConfigWithSource(cwd: string, options: ConfigPathOptions = {
|
|
|
251
325
|
|
|
252
326
|
if (projectVoice && typeof projectVoice === "object") {
|
|
253
327
|
return {
|
|
254
|
-
config: migrateConfig(projectVoice, "project"),
|
|
328
|
+
config: migrateConfig(projectVoice, "project", globalVoice),
|
|
255
329
|
source: "project",
|
|
256
330
|
globalSettingsPath,
|
|
257
331
|
projectSettingsPath,
|
|
@@ -303,7 +377,7 @@ export function loadGlobalToggleShortcut(options: ConfigPathOptions = {}): strin
|
|
|
303
377
|
const candidate = (globalVoice as any).toggleShortcut;
|
|
304
378
|
if (isValidShortcut(candidate)) return candidate;
|
|
305
379
|
process.stderr.write(
|
|
306
|
-
`[pi-
|
|
380
|
+
`[pi-voicekit] Warning: invalid toggleShortcut "${candidate}" in settings, using default "${fallback}"\n`
|
|
307
381
|
);
|
|
308
382
|
}
|
|
309
383
|
} catch {
|
|
@@ -353,6 +427,11 @@ function serializeConfig(config: VoiceConfig, scope: VoiceSettingsScope): VoiceC
|
|
|
353
427
|
scope === "project" && config.localEndpoint && !isLoopbackEndpoint(config.localEndpoint)
|
|
354
428
|
? undefined
|
|
355
429
|
: config.localEndpoint,
|
|
430
|
+
// D7: model selection and enablement are global-only, and the notice flag
|
|
431
|
+
// describes this machine, not this repository.
|
|
432
|
+
postProcessEnabled: scope === "project" ? undefined : config.postProcessEnabled,
|
|
433
|
+
postProcessModel: scope === "project" ? undefined : config.postProcessModel,
|
|
434
|
+
postProcessNoticeShown: scope === "project" ? undefined : config.postProcessNoticeShown,
|
|
356
435
|
// Shortcut registration is static at extension load time — project-scoped overrides cannot apply
|
|
357
436
|
toggleShortcut: scope === "project" ? undefined : config.toggleShortcut,
|
|
358
437
|
onboarding: {
|
|
@@ -362,6 +441,23 @@ function serializeConfig(config: VoiceConfig, scope: VoiceSettingsScope): VoiceC
|
|
|
362
441
|
};
|
|
363
442
|
}
|
|
364
443
|
|
|
444
|
+
/**
|
|
445
|
+
* Atomic settings write: temp file + rename prevents corruption from partial
|
|
446
|
+
* writes. Shared by every writer in this module so the path cannot diverge.
|
|
447
|
+
*/
|
|
448
|
+
function writeSettingsFile(settingsPath: string, settings: Record<string, unknown>): void {
|
|
449
|
+
fs.mkdirSync(path.dirname(settingsPath), { recursive: true });
|
|
450
|
+
const tmpPath = `${settingsPath}.${process.pid}.tmp`;
|
|
451
|
+
try {
|
|
452
|
+
fs.writeFileSync(tmpPath, JSON.stringify(settings, null, 2) + "\n");
|
|
453
|
+
fs.renameSync(tmpPath, settingsPath);
|
|
454
|
+
} finally {
|
|
455
|
+
try {
|
|
456
|
+
if (fs.existsSync(tmpPath)) fs.unlinkSync(tmpPath);
|
|
457
|
+
} catch {}
|
|
458
|
+
}
|
|
459
|
+
}
|
|
460
|
+
|
|
365
461
|
export function saveConfig(
|
|
366
462
|
config: VoiceConfig,
|
|
367
463
|
scope: VoiceSettingsScope,
|
|
@@ -371,17 +467,62 @@ export function saveConfig(
|
|
|
371
467
|
const settingsPath = scope === "project" ? getProjectSettingsPath(cwd) : getGlobalSettingsPath(options);
|
|
372
468
|
const settings = readJsonFile(settingsPath);
|
|
373
469
|
settings[SETTINGS_KEY] = serializeConfig(config, scope);
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
470
|
+
writeSettingsFile(settingsPath, settings);
|
|
471
|
+
return settingsPath;
|
|
472
|
+
}
|
|
473
|
+
|
|
474
|
+
/**
|
|
475
|
+
* The global-only keys `saveGlobalVoiceFields` accepts. A project block cannot
|
|
476
|
+
* carry them — `serializeConfig` strips them and the loader ignores them — so
|
|
477
|
+
* they always belong in the global file.
|
|
478
|
+
*/
|
|
479
|
+
type GlobalVoiceFieldKey = "postProcessEnabled" | "postProcessModel" | "postProcessNoticeShown";
|
|
480
|
+
|
|
481
|
+
/**
|
|
482
|
+
* Field-level writer for the global-only voice settings (R26).
|
|
483
|
+
*
|
|
484
|
+
* `saveConfig(config, "global", …)` renders the WHOLE in-memory config, and in a
|
|
485
|
+
* project-scoped session that object also carries project and default values —
|
|
486
|
+
* writing it globally silently resets every unrelated machine-global setting
|
|
487
|
+
* (TTS speed, auto-submit, hold threshold, …). This writer instead reads the
|
|
488
|
+
* existing global file and merges only the named keys into its `voice` block:
|
|
489
|
+
*
|
|
490
|
+
* - an existing `version` is preserved; a block created here gets the current
|
|
491
|
+
* schema version,
|
|
492
|
+
* - no other key is created, changed or removed,
|
|
493
|
+
* - a missing file or `voice` block is created,
|
|
494
|
+
* - a file that exists but cannot be parsed is refused, not overwritten,
|
|
495
|
+
* - the write is atomic (temp file + rename), like `saveConfig`.
|
|
496
|
+
*/
|
|
497
|
+
export function saveGlobalVoiceFields(
|
|
498
|
+
fields: Partial<Pick<VoiceConfig, GlobalVoiceFieldKey>>,
|
|
499
|
+
options: ConfigPathOptions = {}
|
|
500
|
+
): string {
|
|
501
|
+
const settingsPath = getGlobalSettingsPath(options);
|
|
502
|
+
// The shared reader reports a file it cannot parse exactly like a missing one, so this
|
|
503
|
+
// writer has to tell them apart itself: merging into `{}` would replace a damaged
|
|
504
|
+
// settings file with a fresh object and lose every other key. An existing file that
|
|
505
|
+
// cannot be read is logged and left untouched; throwing hands the failure to the
|
|
506
|
+
// caller's guard instead of silently reporting a write that never happened.
|
|
507
|
+
let settings: Record<string, unknown> = {};
|
|
508
|
+
if (fs.existsSync(settingsPath)) {
|
|
381
509
|
try {
|
|
382
|
-
|
|
383
|
-
} catch {
|
|
510
|
+
settings = JSON.parse(fs.readFileSync(settingsPath, "utf8"));
|
|
511
|
+
} catch (err) {
|
|
512
|
+
const reason = err instanceof Error ? err.message : String(err);
|
|
513
|
+
process.stderr.write(`[pi-voicekit] Warning: not writing ${settingsPath}: ${reason}\n`);
|
|
514
|
+
throw new Error(`Refusing to overwrite an unreadable settings file: ${settingsPath}`);
|
|
515
|
+
}
|
|
516
|
+
}
|
|
517
|
+
const existing = settings[SETTINGS_KEY];
|
|
518
|
+
const voice: Record<string, unknown> =
|
|
519
|
+
existing && typeof existing === "object" ? { ...(existing as Record<string, unknown>) } : {};
|
|
520
|
+
if (typeof voice.version !== "number") voice.version = VOICE_CONFIG_VERSION;
|
|
521
|
+
for (const [key, value] of Object.entries(fields)) {
|
|
522
|
+
if (value !== undefined) voice[key] = value;
|
|
384
523
|
}
|
|
524
|
+
settings[SETTINGS_KEY] = voice;
|
|
525
|
+
writeSettingsFile(settingsPath, settings);
|
|
385
526
|
return settingsPath;
|
|
386
527
|
}
|
|
387
528
|
|
|
@@ -314,7 +314,7 @@ export function finalizeOnboardingConfig(
|
|
|
314
314
|
}
|
|
315
315
|
|
|
316
316
|
export async function promptFirstRunOnboarding(ctx: VoiceUiContext): Promise<FirstRunDecision> {
|
|
317
|
-
const choice = await ctx.ui.select("Set up pi-
|
|
317
|
+
const choice = await ctx.ui.select("Set up pi-voicekit now?", ["Start voice setup", "Remind me later"]);
|
|
318
318
|
|
|
319
319
|
return { action: choice === "Start voice setup" ? "start" : "later" };
|
|
320
320
|
}
|
|
@@ -665,7 +665,7 @@ export async function runVoiceOnboarding(
|
|
|
665
665
|
}
|
|
666
666
|
|
|
667
667
|
// ─── Choose scope ────────────────────────────────────────
|
|
668
|
-
const scopeChoice = await ctx.ui.select("Where should pi-
|
|
668
|
+
const scopeChoice = await ctx.ui.select("Where should pi-voicekit settings be saved?", [
|
|
669
669
|
"Global (all projects)",
|
|
670
670
|
"Project only (this repo)",
|
|
671
671
|
]);
|