pi-voicekit 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.
Files changed (34) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +341 -0
  3. package/extensions/voice/config.ts +395 -0
  4. package/extensions/voice/deepgram.ts +33 -0
  5. package/extensions/voice/device.ts +382 -0
  6. package/extensions/voice/hold-to-talk.ts +69 -0
  7. package/extensions/voice/local.ts +1143 -0
  8. package/extensions/voice/model-download.ts +636 -0
  9. package/extensions/voice/onboarding.ts +739 -0
  10. package/extensions/voice/release-controller.ts +55 -0
  11. package/extensions/voice/settings-panel.ts +1602 -0
  12. package/extensions/voice/sherpa-engine.ts +464 -0
  13. package/extensions/voice/sherpa-loader.ts +143 -0
  14. package/extensions/voice/sherpa-onnx-node.d.ts +4 -0
  15. package/extensions/voice/speak.ts +430 -0
  16. package/extensions/voice/tts-deepgram.ts +454 -0
  17. package/extensions/voice/tts-engine.ts +653 -0
  18. package/extensions/voice/tts-install-progress.ts +257 -0
  19. package/extensions/voice/tts-local-models.ts +1255 -0
  20. package/extensions/voice/tts-onboarding-overlay.ts +186 -0
  21. package/extensions/voice/tts-onboarding.ts +87 -0
  22. package/extensions/voice/tts-playback-indicator.ts +127 -0
  23. package/extensions/voice/tts-playback.ts +675 -0
  24. package/extensions/voice/tts-text-filter.ts +404 -0
  25. package/extensions/voice/ui-aura.ts +272 -0
  26. package/extensions/voice/ui-help-overlay.ts +161 -0
  27. package/extensions/voice/ui-icons.ts +124 -0
  28. package/extensions/voice/ui-locale-labels.ts +110 -0
  29. package/extensions/voice/ui-picker.ts +209 -0
  30. package/extensions/voice/ui-render-ticker.ts +171 -0
  31. package/extensions/voice/ui-widget-base.ts +219 -0
  32. package/extensions/voice/ui-width.ts +112 -0
  33. package/extensions/voice.ts +3644 -0
  34. package/package.json +75 -0
@@ -0,0 +1,395 @@
1
+ import * as fs from "node:fs";
2
+ import * as os from "node:os";
3
+ import * as path from "node:path";
4
+
5
+ function getAgentDir(): string {
6
+ return path.join(os.homedir(), ".pi", "agent");
7
+ }
8
+
9
+ export const SETTINGS_KEY = "voice";
10
+ export const VOICE_CONFIG_VERSION = 2;
11
+
12
+ export type VoiceSettingsScope = "global" | "project";
13
+ export type VoiceConfigSource = VoiceSettingsScope | "default";
14
+
15
+ export interface VoiceOnboardingState {
16
+ completed: boolean;
17
+ schemaVersion: number;
18
+ completedAt?: string;
19
+ lastValidatedAt?: string;
20
+ source?: "first-run" | "setup-command" | "migration" | "repair";
21
+ skippedAt?: string;
22
+ }
23
+
24
+ export type VoiceBackend = "deepgram" | "local";
25
+
26
+ export interface VoiceConfig {
27
+ version: number;
28
+ enabled: boolean;
29
+ language: string;
30
+ scope: VoiceSettingsScope;
31
+ onboarding: VoiceOnboardingState;
32
+ /** Deepgram API key — stored in config so it's available even when env var isn't set */
33
+ deepgramApiKey?: string;
34
+ /** Transcription backend — "deepgram" (cloud streaming) or "local" (batch via local server) */
35
+ backend?: VoiceBackend;
36
+ /** Local model ID (e.g. "whisper-small", "whisper-turbo", "parakeet-v3") */
37
+ localModel?: string;
38
+ /** Local transcription server URL (default: http://localhost:8080) */
39
+ localEndpoint?: string;
40
+ /** Global-only shortcut used to toggle recording without hold-to-talk */
41
+ toggleShortcut?: string;
42
+
43
+ // ─── TTS (text-to-speech) ─────────────────────────────────────────
44
+ // All TTS fields are opt-in (default: TTS disabled). New in v6.0.0.
45
+
46
+ /** Master TTS toggle. When false, /voice-speak is a no-op. */
47
+ ttsEnabled?: boolean;
48
+ /** "local" (sherpa-onnx, offline) or "deepgram" (cloud REST). */
49
+ ttsBackend?: "local" | "deepgram";
50
+ /** Active local TTS model id (e.g. "kitten-nano-en-v0_2"). */
51
+ ttsLocalModel?: string;
52
+ /**
53
+ * Local backend speaker id (numeric `sid` per the model's voice
54
+ * catalog). Type-validated at config load — strings get rejected and
55
+ * fall back to the model's defaultSid.
56
+ */
57
+ ttsLocalVoiceId?: number;
58
+ /**
59
+ * Deepgram backend voice id (e.g. "aura-asteria-en"). Type-validated
60
+ * at config load — numbers get rejected and fall back to
61
+ * "aura-asteria-en".
62
+ */
63
+ ttsDeepgramVoiceId?: string;
64
+ /** Speech rate multiplier; range 0.5–2.0. Default 1.0. */
65
+ ttsSpeed?: number;
66
+ /**
67
+ * If true, the agent's responses are spoken automatically after each
68
+ * turn. v7.1.1: defaults to true so users get audio responses out of
69
+ * the box once TTS is enabled — disable via /voice-settings or
70
+ * `voice.ttsAutoSpeak = false` in settings.json.
71
+ */
72
+ ttsAutoSpeak?: boolean;
73
+ /**
74
+ * v7.1.1: If true, an STT transcription is automatically submitted to
75
+ * the agent (turn triggered) instead of just being placed in the
76
+ * editor. The user-spoken text bypasses the manual `[enter]` step.
77
+ * Defaults to false — preserves the v7.0.x behavior so existing
78
+ * users aren't surprised.
79
+ */
80
+ autoSubmitOnSpeak?: boolean;
81
+ /**
82
+ * v7.1.3: hold-to-talk activation delay in milliseconds. Range
83
+ * [200, 3000]. Default 700ms — snappy without being trigger-happy.
84
+ * Override via `/voice-hold-delay <ms>` or settings.json.
85
+ */
86
+ holdThresholdMs?: number;
87
+ /**
88
+ * BCP-47 language tag for TTS (overrides `language`). Useful when
89
+ * STT and TTS should use different languages — e.g. user dictates in
90
+ * English but wants Spanish read-back.
91
+ */
92
+ ttsLanguage?: string;
93
+ /**
94
+ * v6.1 feature flag — opt-in WebSocket streaming for sub-200ms TTFB
95
+ * on the Deepgram backend. v6.0 ships REST only; this is documented
96
+ * but ignored.
97
+ */
98
+ ttsDeepgramStreaming?: boolean;
99
+ /**
100
+ * Set to true after `tts-onboarding.maybeShowTtsOnboarding()` has
101
+ * shown its first-run hint, so subsequent /voice-speak-toggle calls
102
+ * don't re-spam the same notification. New in v7.0.0.
103
+ */
104
+ ttsOnboardingShown?: boolean;
105
+ }
106
+
107
+ export interface LoadedVoiceConfig {
108
+ config: VoiceConfig;
109
+ source: VoiceConfigSource;
110
+ globalSettingsPath: string;
111
+ projectSettingsPath: string;
112
+ }
113
+
114
+ export interface ConfigPathOptions {
115
+ agentDir?: string;
116
+ }
117
+
118
+ export const DEFAULT_CONFIG: VoiceConfig = {
119
+ version: VOICE_CONFIG_VERSION,
120
+ enabled: true,
121
+ language: "en",
122
+ scope: "global",
123
+ deepgramApiKey: undefined,
124
+ backend: undefined, // undefined = "deepgram" (default)
125
+ localModel: undefined,
126
+ localEndpoint: undefined,
127
+ toggleShortcut: "ctrl+shift+v",
128
+ // TTS defaults — all opt-in
129
+ ttsEnabled: false,
130
+ ttsBackend: "local",
131
+ ttsLocalModel: "kitten-nano-en-v0_2",
132
+ ttsLocalVoiceId: 0,
133
+ ttsDeepgramVoiceId: "aura-asteria-en",
134
+ ttsSpeed: 1.0,
135
+ ttsAutoSpeak: true,
136
+ autoSubmitOnSpeak: false,
137
+ holdThresholdMs: 700,
138
+ ttsLanguage: undefined,
139
+ ttsDeepgramStreaming: false,
140
+ ttsOnboardingShown: false,
141
+ onboarding: {
142
+ completed: false,
143
+ schemaVersion: VOICE_CONFIG_VERSION,
144
+ },
145
+ };
146
+
147
+ export function readJsonFile(filePath: string): Record<string, unknown> {
148
+ try {
149
+ if (!fs.existsSync(filePath)) return {};
150
+ return JSON.parse(fs.readFileSync(filePath, "utf8"));
151
+ } catch (err) {
152
+ process.stderr.write(
153
+ `[pi-voice] Warning: failed to read ${filePath}: ${err instanceof Error ? err.message : err}\n`
154
+ );
155
+ return {};
156
+ }
157
+ }
158
+
159
+ export function getGlobalSettingsPath(options: ConfigPathOptions = {}): string {
160
+ return path.join(options.agentDir ?? getAgentDir(), "settings.json");
161
+ }
162
+
163
+ export function getProjectSettingsPath(cwd: string): string {
164
+ return path.join(cwd, ".pi", "settings.json");
165
+ }
166
+
167
+ function normalizeOnboarding(input: any, fallbackCompleted: boolean): VoiceOnboardingState {
168
+ const completed = typeof input?.completed === "boolean" ? input.completed : fallbackCompleted;
169
+ return {
170
+ completed,
171
+ schemaVersion: Number.isFinite(input?.schemaVersion) ? Number(input.schemaVersion) : VOICE_CONFIG_VERSION,
172
+ completedAt: typeof input?.completedAt === "string" ? input.completedAt : undefined,
173
+ lastValidatedAt: typeof input?.lastValidatedAt === "string" ? input.lastValidatedAt : undefined,
174
+ source: typeof input?.source === "string" ? input.source : fallbackCompleted ? "migration" : undefined,
175
+ skippedAt: typeof input?.skippedAt === "string" ? input.skippedAt : undefined,
176
+ };
177
+ }
178
+
179
+ function migrateConfig(rawVoice: any, source: VoiceConfigSource): VoiceConfig {
180
+ if (!rawVoice || typeof rawVoice !== "object") {
181
+ return structuredClone(DEFAULT_CONFIG);
182
+ }
183
+
184
+ // Legacy configs may have backend+model — treat that as completed onboarding
185
+ const hasMeaningfulLegacySetup =
186
+ (typeof rawVoice.backend === "string" && typeof rawVoice.model === "string") ||
187
+ rawVoice.onboarding?.completed === true;
188
+ const fallbackCompleted = hasMeaningfulLegacySetup;
189
+
190
+ return {
191
+ version: VOICE_CONFIG_VERSION,
192
+ enabled: typeof rawVoice.enabled === "boolean" ? rawVoice.enabled : DEFAULT_CONFIG.enabled,
193
+ language: typeof rawVoice.language === "string" ? rawVoice.language : DEFAULT_CONFIG.language,
194
+ scope: (rawVoice.scope as VoiceSettingsScope | undefined) ?? (source === "project" ? "project" : "global"),
195
+ deepgramApiKey: typeof rawVoice.deepgramApiKey === "string" ? rawVoice.deepgramApiKey : undefined,
196
+ backend: rawVoice.backend === "local" ? "local" : undefined,
197
+ localModel: typeof rawVoice.localModel === "string" ? rawVoice.localModel : undefined,
198
+ localEndpoint: typeof rawVoice.localEndpoint === "string" ? rawVoice.localEndpoint : undefined,
199
+ toggleShortcut:
200
+ source !== "project" && typeof rawVoice.toggleShortcut === "string"
201
+ ? rawVoice.toggleShortcut
202
+ : DEFAULT_CONFIG.toggleShortcut,
203
+ // TTS fields — type-validated; mismatched persisted values fall
204
+ // back to safe defaults so a hand-edited config can't poison the
205
+ // engine. Notably: ttsLocalVoiceId rejects strings (would crash
206
+ // sherpa's numeric sid arg) and ttsDeepgramVoiceId rejects numbers
207
+ // (would 4xx at the Deepgram REST layer).
208
+ ttsEnabled: typeof rawVoice.ttsEnabled === "boolean" ? rawVoice.ttsEnabled : DEFAULT_CONFIG.ttsEnabled,
209
+ ttsBackend: rawVoice.ttsBackend === "deepgram" ? "deepgram" : DEFAULT_CONFIG.ttsBackend,
210
+ ttsLocalModel:
211
+ typeof rawVoice.ttsLocalModel === "string" && rawVoice.ttsLocalModel
212
+ ? rawVoice.ttsLocalModel
213
+ : DEFAULT_CONFIG.ttsLocalModel,
214
+ ttsLocalVoiceId:
215
+ typeof rawVoice.ttsLocalVoiceId === "number" && Number.isFinite(rawVoice.ttsLocalVoiceId)
216
+ ? rawVoice.ttsLocalVoiceId
217
+ : DEFAULT_CONFIG.ttsLocalVoiceId,
218
+ ttsDeepgramVoiceId:
219
+ typeof rawVoice.ttsDeepgramVoiceId === "string" && rawVoice.ttsDeepgramVoiceId
220
+ ? rawVoice.ttsDeepgramVoiceId
221
+ : DEFAULT_CONFIG.ttsDeepgramVoiceId,
222
+ ttsSpeed:
223
+ typeof rawVoice.ttsSpeed === "number" && Number.isFinite(rawVoice.ttsSpeed)
224
+ ? Math.max(0.5, Math.min(2.0, rawVoice.ttsSpeed))
225
+ : DEFAULT_CONFIG.ttsSpeed,
226
+ ttsAutoSpeak: typeof rawVoice.ttsAutoSpeak === "boolean" ? rawVoice.ttsAutoSpeak : DEFAULT_CONFIG.ttsAutoSpeak,
227
+ autoSubmitOnSpeak:
228
+ typeof rawVoice.autoSubmitOnSpeak === "boolean" ? rawVoice.autoSubmitOnSpeak : DEFAULT_CONFIG.autoSubmitOnSpeak,
229
+ holdThresholdMs:
230
+ typeof rawVoice.holdThresholdMs === "number" &&
231
+ Number.isFinite(rawVoice.holdThresholdMs) &&
232
+ rawVoice.holdThresholdMs >= 200 &&
233
+ rawVoice.holdThresholdMs <= 3000
234
+ ? rawVoice.holdThresholdMs
235
+ : DEFAULT_CONFIG.holdThresholdMs,
236
+ ttsLanguage: typeof rawVoice.ttsLanguage === "string" && rawVoice.ttsLanguage ? rawVoice.ttsLanguage : undefined,
237
+ ttsDeepgramStreaming:
238
+ typeof rawVoice.ttsDeepgramStreaming === "boolean"
239
+ ? rawVoice.ttsDeepgramStreaming
240
+ : DEFAULT_CONFIG.ttsDeepgramStreaming,
241
+ ttsOnboardingShown: typeof rawVoice.ttsOnboardingShown === "boolean" ? rawVoice.ttsOnboardingShown : false,
242
+ onboarding: normalizeOnboarding(rawVoice.onboarding, fallbackCompleted),
243
+ };
244
+ }
245
+
246
+ export function loadConfigWithSource(cwd: string, options: ConfigPathOptions = {}): LoadedVoiceConfig {
247
+ const globalSettingsPath = getGlobalSettingsPath(options);
248
+ const projectSettingsPath = getProjectSettingsPath(cwd);
249
+ const globalVoice = readJsonFile(globalSettingsPath)[SETTINGS_KEY];
250
+ const projectVoice = readJsonFile(projectSettingsPath)[SETTINGS_KEY];
251
+
252
+ if (projectVoice && typeof projectVoice === "object") {
253
+ return {
254
+ config: migrateConfig(projectVoice, "project"),
255
+ source: "project",
256
+ globalSettingsPath,
257
+ projectSettingsPath,
258
+ };
259
+ }
260
+
261
+ if (globalVoice && typeof globalVoice === "object") {
262
+ return {
263
+ config: migrateConfig(globalVoice, "global"),
264
+ source: "global",
265
+ globalSettingsPath,
266
+ projectSettingsPath,
267
+ };
268
+ }
269
+
270
+ return {
271
+ config: structuredClone(DEFAULT_CONFIG),
272
+ source: "default",
273
+ globalSettingsPath,
274
+ projectSettingsPath,
275
+ };
276
+ }
277
+
278
+ const VALID_MODIFIERS = new Set(["ctrl", "shift", "alt", "meta", "cmd", "super"]);
279
+ const SHORTCUT_PATTERN = /^[a-z0-9+]+$/;
280
+
281
+ /** Validate a shortcut string like "ctrl+shift+v". Returns true if structurally valid. */
282
+ export function isValidShortcut(shortcut: string): boolean {
283
+ if (typeof shortcut !== "string" || shortcut.length === 0 || !SHORTCUT_PATTERN.test(shortcut)) return false;
284
+ const parts = shortcut.split("+");
285
+ if (parts.length < 1 || parts.length > 4) return false;
286
+ const key = parts[parts.length - 1]!;
287
+ if (key.length === 0) return false;
288
+ const mods = parts.slice(0, -1);
289
+ return mods.every((m) => VALID_MODIFIERS.has(m));
290
+ }
291
+
292
+ /**
293
+ * Resolve the toggle shortcut from global config at startup.
294
+ * Returns the validated shortcut or the default if invalid/missing.
295
+ * Reads disk once — caller should cache the result.
296
+ */
297
+ export function loadGlobalToggleShortcut(options: ConfigPathOptions = {}): string {
298
+ const fallback = DEFAULT_CONFIG.toggleShortcut || "ctrl+shift+v";
299
+ try {
300
+ const globalSettingsPath = getGlobalSettingsPath(options);
301
+ const globalVoice = readJsonFile(globalSettingsPath)[SETTINGS_KEY];
302
+ if (globalVoice && typeof globalVoice === "object" && typeof (globalVoice as any).toggleShortcut === "string") {
303
+ const candidate = (globalVoice as any).toggleShortcut;
304
+ if (isValidShortcut(candidate)) return candidate;
305
+ process.stderr.write(
306
+ `[pi-voice] Warning: invalid toggleShortcut "${candidate}" in settings, using default "${fallback}"\n`
307
+ );
308
+ }
309
+ } catch {
310
+ // Fall through to default
311
+ }
312
+ return fallback;
313
+ }
314
+
315
+ /** Check if a URL points to a loopback address (localhost/127.0.0.1/::1). */
316
+ export function isLoopbackEndpoint(endpoint: string): boolean {
317
+ try {
318
+ const url = new URL(endpoint);
319
+ const proto = url.protocol;
320
+ if (proto !== "http:" && proto !== "https:") return false;
321
+ const host = url.hostname;
322
+ return host === "localhost" || host === "127.0.0.1" || host === "::1" || host === "[::1]";
323
+ } catch {
324
+ return false;
325
+ }
326
+ }
327
+
328
+ export function getSessionStartPersistedConfig({
329
+ config,
330
+ envDeepgramApiKey,
331
+ }: {
332
+ config: VoiceConfig;
333
+ envDeepgramApiKey?: string;
334
+ }): VoiceConfig {
335
+ if (!envDeepgramApiKey || config.deepgramApiKey) {
336
+ return config;
337
+ }
338
+
339
+ return {
340
+ ...config,
341
+ deepgramApiKey: undefined,
342
+ };
343
+ }
344
+
345
+ function serializeConfig(config: VoiceConfig, scope: VoiceSettingsScope): VoiceConfig {
346
+ return {
347
+ ...config,
348
+ scope,
349
+ // Never persist API keys into project-scoped config — prevents accidental repo commits
350
+ deepgramApiKey: scope === "project" ? undefined : config.deepgramApiKey,
351
+ // Only allow loopback endpoints in project config — prevents mic audio exfiltration
352
+ localEndpoint:
353
+ scope === "project" && config.localEndpoint && !isLoopbackEndpoint(config.localEndpoint)
354
+ ? undefined
355
+ : config.localEndpoint,
356
+ // Shortcut registration is static at extension load time — project-scoped overrides cannot apply
357
+ toggleShortcut: scope === "project" ? undefined : config.toggleShortcut,
358
+ onboarding: {
359
+ ...config.onboarding,
360
+ schemaVersion: VOICE_CONFIG_VERSION,
361
+ },
362
+ };
363
+ }
364
+
365
+ export function saveConfig(
366
+ config: VoiceConfig,
367
+ scope: VoiceSettingsScope,
368
+ cwd: string,
369
+ options: ConfigPathOptions = {}
370
+ ): string {
371
+ const settingsPath = scope === "project" ? getProjectSettingsPath(cwd) : getGlobalSettingsPath(options);
372
+ const settings = readJsonFile(settingsPath);
373
+ 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 {
381
+ try {
382
+ if (fs.existsSync(tmpPath)) fs.unlinkSync(tmpPath);
383
+ } catch {}
384
+ }
385
+ return settingsPath;
386
+ }
387
+
388
+ export function needsOnboarding(config: VoiceConfig, source: VoiceConfigSource): boolean {
389
+ const skippedAt = config.onboarding.skippedAt ? Date.parse(config.onboarding.skippedAt) : Number.NaN;
390
+ const deferWindowMs = 1000 * 60 * 60 * 24;
391
+ const recentlyDeferred = Number.isFinite(skippedAt) && Date.now() - skippedAt < deferWindowMs;
392
+ if (recentlyDeferred) return false;
393
+ if (source === "default") return true;
394
+ return !config.onboarding.completed;
395
+ }
@@ -0,0 +1,33 @@
1
+ /**
2
+ * Deepgram API helpers — URL building and constants.
3
+ * Extracted for testability.
4
+ */
5
+
6
+ import type { VoiceConfig } from "./config";
7
+ import { modelForLanguage } from "./onboarding";
8
+
9
+ export const DEEPGRAM_WS_URL = "wss://api.deepgram.com/v1/listen";
10
+ export const SAMPLE_RATE = 16000;
11
+ export const CHANNELS = 1;
12
+ export const ENCODING = "linear16";
13
+
14
+ export function buildDeepgramWsUrl(config: VoiceConfig): string {
15
+ const language = config.language || "en";
16
+ const model = modelForLanguage(language);
17
+ const params = new URLSearchParams({
18
+ encoding: ENCODING,
19
+ sample_rate: String(SAMPLE_RATE),
20
+ channels: String(CHANNELS),
21
+ endpointing: "200",
22
+ utterance_end_ms: "1000",
23
+ language,
24
+ model,
25
+ smart_format: "true",
26
+ interim_results: "true",
27
+ });
28
+ return `${DEEPGRAM_WS_URL}?${params.toString()}`;
29
+ }
30
+
31
+ export function resolveDeepgramApiKey(config: VoiceConfig): string | null {
32
+ return process.env.DEEPGRAM_API_KEY || config.deepgramApiKey || null;
33
+ }