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 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-listen — Voice input for the Pi coding agent" width="100%" />
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
- **Hold-to-talk voice input for [Pi](https://github.com/earendil-works/pi-coding-agent).** Cloud streaming via Deepgram or fully offline with local models.
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
  [![npm version](https://img.shields.io/npm/v/pi-voicekit.svg)](https://www.npmjs.com/package/pi-voicekit)
15
17
  [![license](https://img.shields.io/npm/l/pi-voicekit.svg)](https://github.com/CyFeng16/pi-voicekit/blob/main/LICENSE)
16
18
  [![original author](https://img.shields.io/badge/original_author-@baanditeagle-1DA1F2?logo=x&logoColor=white)](https://x.com/baanditeagle)
17
19
 
18
- > **v7.0.0 — World-class TTS UX** — pick models from `/voice-settings` Speak
19
- > tab (no more JSON editing), auto-download on selection with progress, voice
20
- > picker for every backend, first-run onboarding with smart-default
21
- > recommendation by your system locale, and `ttsAutoSpeak: true` finally
22
- > works — auto-speaks the agent's responses with code-block stripping and
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-listen.mp4">
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-listen supports two transcription backends:
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) | Free forever |
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 (fully offline)
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-listen checks your setup and tells you what's ready:
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-listen auto-detects your audio tool. No manual install needed if you already have sox or ffmpeg.
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`. Four tabs cover everything you need.
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` (≥1.2s) | Release to finalize. Pre-records during warmup so you don't miss words. |
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-autosubmit` `[on | off]` | Toggle: STT text auto-sent to the agent |
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, ffmpeg, arecord in order |
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 as you speak |
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
- extensions/voice.ts Main extension — state machine, recording, UI, settings panel
264
- extensions/voice/config.ts Config loading, saving, migration
265
- extensions/voice/onboarding.ts First-run wizard, language picker
266
- extensions/voice/deepgram.ts Deepgram URL builder, API key resolver
267
- extensions/voice/local.ts Model catalog (21 models), in-process transcription
268
- extensions/voice/device.ts Device profiling — RAM, GPU, CPU, container detection
269
- extensions/voice/model-download.ts Download manager — resume, progress, verification, Handy import
270
- extensions/voice/sherpa-engine.ts sherpa-onnx bindings — recognizer lifecycle, inference
271
- extensions/voice/settings-panel.ts Settings panel — Component interface, overlay, 4 tabs
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": 2,
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": 2 }
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-listen does not collect or transmit usage data
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 = 2;
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-voice] Warning: failed to read ${filePath}: ${err instanceof Error ? err.message : err}\n`
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
- function migrateConfig(rawVoice: any, source: VoiceConfigSource): VoiceConfig {
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: typeof rawVoice.deepgramApiKey === "string" ? rawVoice.deepgramApiKey : undefined,
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: typeof rawVoice.localEndpoint === "string" ? rawVoice.localEndpoint : undefined,
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-voice] Warning: invalid toggleShortcut "${candidate}" in settings, using default "${fallback}"\n`
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
- fs.mkdirSync(path.dirname(settingsPath), { recursive: true });
375
- // Atomic write: temp file + rename prevents corruption from partial writes
376
- const tmpPath = `${settingsPath}.${process.pid}.tmp`;
377
- try {
378
- fs.writeFileSync(tmpPath, JSON.stringify(settings, null, 2) + "\n");
379
- fs.renameSync(tmpPath, settingsPath);
380
- } finally {
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
- if (fs.existsSync(tmpPath)) fs.unlinkSync(tmpPath);
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-voice now?", ["Start voice setup", "Remind me later"]);
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-voice settings be saved?", [
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
  ]);