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,454 @@
1
+ /**
2
+ * Deepgram cloud TTS — REST `/v1/speak` client.
3
+ *
4
+ * Uses the same `DEEPGRAM_API_KEY` users configure for STT (resolved via
5
+ * `resolveDeepgramApiKey` from `./deepgram.ts`). One API key drives both
6
+ * sides of the voice loop.
7
+ *
8
+ * v6.0 ships REST only. WebSocket streaming (`wss://api.deepgram.com/v1/speak`)
9
+ * for sub-200ms TTFB is gated behind the `tts.deepgramStreaming` config flag
10
+ * and lives in a future v6.1.
11
+ *
12
+ * Endpoint contract (verified against
13
+ * https://developers.deepgram.com/docs/text-to-speech-rest):
14
+ * POST https://api.deepgram.com/v1/speak
15
+ * ?model=<voice>
16
+ * &encoding=linear16
17
+ * &sample_rate=<hz>
18
+ * &container=wav
19
+ * Headers: Authorization: Token <api-key>
20
+ * Content-Type: application/json
21
+ * Body: { "text": "..." }
22
+ * Returns: audio/wav bytes (or audio/mpeg if container=none for some voices)
23
+ *
24
+ * Concurrency contract: this module is stateless. Each call opens a fresh
25
+ * fetch — there is no client cache. The caller (speak.ts) holds the abort
26
+ * signal and any response-level cancellation.
27
+ */
28
+
29
+ import type { VoiceConfig } from "./config";
30
+ import { resolveDeepgramApiKey } from "./deepgram";
31
+ import type { PlaybackStream } from "./tts-playback";
32
+
33
+ // ─── Catalog ──────────────────────────────────────────────────────────────────
34
+
35
+ /**
36
+ * Subset of Deepgram Aura voices surfaced in the picker. The full Aura
37
+ * catalog is large and changes; we list the stable, well-known voices a
38
+ * coding agent would actually use. Users can override with any voice id
39
+ * supported by Deepgram by setting `ttsDeepgramVoiceId` directly.
40
+ *
41
+ * Naming convention: `aura-<name>-<lang>` for Aura-1, `aura-2-<name>-<lang>`
42
+ * for Aura-2. The trailing language code makes language ↔ voice matching
43
+ * a substring check.
44
+ *
45
+ * Sample rate is the recommended default per Deepgram docs — 24 kHz gives
46
+ * good fidelity and matches the Kitten/Kokoro local pipeline so playback
47
+ * code doesn't need backend-specific buffer handling.
48
+ */
49
+ export const DEEPGRAM_TTS_VOICES = [
50
+ { id: "aura-asteria-en", name: "Asteria (en, female, conversational)", language: "en", gender: "female" },
51
+ { id: "aura-luna-en", name: "Luna (en, female, polished)", language: "en", gender: "female" },
52
+ { id: "aura-stella-en", name: "Stella (en, female, friendly)", language: "en", gender: "female" },
53
+ { id: "aura-athena-en", name: "Athena (en, female, mature)", language: "en", gender: "female" },
54
+ { id: "aura-hera-en", name: "Hera (en, female, business)", language: "en", gender: "female" },
55
+ { id: "aura-orion-en", name: "Orion (en, male, approachable)", language: "en", gender: "male" },
56
+ { id: "aura-arcas-en", name: "Arcas (en, male, neutral)", language: "en", gender: "male" },
57
+ { id: "aura-perseus-en", name: "Perseus (en, male, confident)", language: "en", gender: "male" },
58
+ { id: "aura-angus-en", name: "Angus (en, male, Irish)", language: "en", gender: "male" },
59
+ { id: "aura-orpheus-en", name: "Orpheus (en, male, warm)", language: "en", gender: "male" },
60
+ { id: "aura-helios-en", name: "Helios (en, male, energetic)", language: "en", gender: "male" },
61
+ { id: "aura-zeus-en", name: "Zeus (en, male, deep)", language: "en", gender: "male" },
62
+ ] as const;
63
+
64
+ /** Default Deepgram voice id — used if the user hasn't picked one. */
65
+ export const DEFAULT_DEEPGRAM_TTS_VOICE = "aura-asteria-en";
66
+
67
+ /** Sample rate negotiated with the REST endpoint (Hz). */
68
+ export const DEEPGRAM_TTS_SAMPLE_RATE = 24000;
69
+
70
+ /**
71
+ * Maximum response size (bytes). At 24 kHz mono 16-bit PCM that is
72
+ * ~26 minutes of audio — far more than any conceivable agent-response
73
+ * synthesis. Defends against a misconfigured account or a Deepgram outage
74
+ * returning a huge HTML error page; without this a pathological response
75
+ * could OOM the agent process.
76
+ */
77
+ const DEEPGRAM_TTS_MAX_BYTES = 75_000_000; // ~75 MB
78
+
79
+ // ─── REST client ──────────────────────────────────────────────────────────────
80
+
81
+ export interface DeepgramSpeakOpts {
82
+ text: string;
83
+ voiceId: string;
84
+ config: VoiceConfig;
85
+ signal?: AbortSignal;
86
+ /**
87
+ * Override the sample rate (Hz). Defaults to DEEPGRAM_TTS_SAMPLE_RATE.
88
+ * 8000 / 16000 / 22050 / 24000 / 32000 / 48000 are all supported by Aura.
89
+ */
90
+ sampleRate?: number;
91
+ }
92
+
93
+ export interface DeepgramSpeakResult {
94
+ /** WAV bytes from the REST response — write to a temp file then play. */
95
+ wav: Uint8Array;
96
+ /** Effective sample rate for playback configuration. */
97
+ sampleRate: number;
98
+ }
99
+
100
+ /**
101
+ * Synthesize via Deepgram REST. Returns a complete WAV blob suitable for
102
+ * `tts-playback.ts` to write to a temp file and spawn the player against.
103
+ *
104
+ * Errors are normalized to a small, user-facing set:
105
+ * - "DEEPGRAM_API_KEY not set" (resolveDeepgramApiKey returned null)
106
+ * - "Deepgram TTS HTTP <status>: <body>" (4xx/5xx response)
107
+ * - AbortError (signal fired)
108
+ * - "Deepgram TTS network error: <msg>" (fetch failure)
109
+ *
110
+ * Aborts are wired through `fetch(url, { signal })`. When the user hits
111
+ * Escape mid-synthesis, fetch tears down the connection and rejects with
112
+ * a DOMException whose name === "AbortError".
113
+ */
114
+ export async function deepgramSpeak(opts: DeepgramSpeakOpts): Promise<DeepgramSpeakResult> {
115
+ const { text, voiceId, config, signal } = opts;
116
+ const sampleRate = opts.sampleRate ?? DEEPGRAM_TTS_SAMPLE_RATE;
117
+
118
+ if (!text || typeof text !== "string") {
119
+ throw new Error(`Deepgram TTS text is required (got: ${text})`);
120
+ }
121
+ if (!voiceId || typeof voiceId !== "string") {
122
+ throw new Error(`Deepgram TTS voiceId is required (got: ${voiceId})`);
123
+ }
124
+
125
+ const apiKey = resolveDeepgramApiKey(config);
126
+ if (!apiKey) {
127
+ throw new Error(
128
+ "DEEPGRAM_API_KEY not set. Run /voice-settings to configure it, or export DEEPGRAM_API_KEY in your shell."
129
+ );
130
+ }
131
+
132
+ const url = buildDeepgramSpeakUrl(voiceId, sampleRate);
133
+
134
+ let response: Response;
135
+ try {
136
+ response = await fetch(url, {
137
+ method: "POST",
138
+ headers: {
139
+ Authorization: `Token ${apiKey}`,
140
+ "Content-Type": "application/json",
141
+ // Deepgram's TTS REST returns audio bytes regardless of
142
+ // Accept, but setting it explicitly documents intent and
143
+ // matches the official docs example.
144
+ Accept: "audio/wav",
145
+ },
146
+ body: JSON.stringify({ text }),
147
+ signal,
148
+ });
149
+ } catch (err: any) {
150
+ // fetch() aborts surface as DOMException with name "AbortError".
151
+ // Re-throw as-is so callers can pattern-match on err.name.
152
+ if (err?.name === "AbortError") throw err;
153
+ throw new Error(`Deepgram TTS network error: ${err?.message ?? String(err)}`);
154
+ }
155
+
156
+ if (!response.ok) {
157
+ // Try to capture the body for a useful error. Deepgram returns
158
+ // JSON like {"err_code":"INVALID_AUTH",...} on auth failures and
159
+ // plain text on others.
160
+ let body = "";
161
+ try {
162
+ body = (await response.text()).slice(0, 300);
163
+ } catch {}
164
+ throw new Error(`Deepgram TTS HTTP ${response.status}${body ? `: ${body}` : ""}`);
165
+ }
166
+
167
+ // Size-bound the response. Trust Content-Length when present; fall back
168
+ // to streaming with a running byte count when it's absent or chunked.
169
+ // Either way we cap at DEEPGRAM_TTS_MAX_BYTES to defend against a
170
+ // runaway error page or misconfigured account.
171
+ const declared = parseInt(response.headers.get("content-length") ?? "", 10);
172
+ if (Number.isFinite(declared) && declared > DEEPGRAM_TTS_MAX_BYTES) {
173
+ throw new Error(
174
+ `Deepgram TTS response too large (${declared} bytes, max ${DEEPGRAM_TTS_MAX_BYTES}). ` +
175
+ `Reduce text length or check your Deepgram account.`
176
+ );
177
+ }
178
+
179
+ const wav = await readBoundedBody(response, DEEPGRAM_TTS_MAX_BYTES);
180
+ return { wav, sampleRate };
181
+ }
182
+
183
+ /**
184
+ * Read a fetch response body into a Uint8Array, aborting if cumulative
185
+ * bytes exceed `maxBytes`. Falls back to `arrayBuffer()` when the body
186
+ * isn't a stream (older runtimes / mocks).
187
+ */
188
+ async function readBoundedBody(response: Response, maxBytes: number): Promise<Uint8Array> {
189
+ if (!response.body) {
190
+ // No stream available — buffer in one shot but verify size after.
191
+ const buf = new Uint8Array(await response.arrayBuffer());
192
+ if (buf.byteLength > maxBytes) {
193
+ throw new Error(`Deepgram TTS response too large (${buf.byteLength} bytes, max ${maxBytes}).`);
194
+ }
195
+ return buf;
196
+ }
197
+
198
+ const reader = response.body.getReader();
199
+ const chunks: Uint8Array[] = [];
200
+ let total = 0;
201
+ try {
202
+ while (true) {
203
+ const { value, done } = await reader.read();
204
+ if (done) break;
205
+ if (!value) continue;
206
+ total += value.byteLength;
207
+ if (total > maxBytes) {
208
+ // Cancel the underlying fetch so we don't keep streaming.
209
+ try {
210
+ await reader.cancel(`Deepgram TTS response exceeded ${maxBytes} bytes`);
211
+ } catch {}
212
+ throw new Error(
213
+ `Deepgram TTS response too large (>${maxBytes} bytes). ` +
214
+ `Reduce text length or check your Deepgram account.`
215
+ );
216
+ }
217
+ chunks.push(value);
218
+ }
219
+ } finally {
220
+ try {
221
+ reader.releaseLock();
222
+ } catch {}
223
+ }
224
+
225
+ const out = new Uint8Array(total);
226
+ let offset = 0;
227
+ for (const chunk of chunks) {
228
+ out.set(chunk, offset);
229
+ offset += chunk.byteLength;
230
+ }
231
+ return out;
232
+ }
233
+
234
+ /**
235
+ * Build the `/v1/speak` request URL. Exported so unit tests can verify the
236
+ * exact query-string shape without making a network call.
237
+ */
238
+ export function buildDeepgramSpeakUrl(voiceId: string, sampleRate: number): string {
239
+ const params = new URLSearchParams({
240
+ model: voiceId,
241
+ encoding: "linear16",
242
+ sample_rate: String(sampleRate),
243
+ container: "wav",
244
+ });
245
+ return `https://api.deepgram.com/v1/speak?${params.toString()}`;
246
+ }
247
+
248
+ // ─── Voice catalog helpers ────────────────────────────────────────────────────
249
+
250
+ /** Look up a voice entry by id; returns undefined if not in the surfaced list. */
251
+ export function getDeepgramVoice(id: string): (typeof DEEPGRAM_TTS_VOICES)[number] | undefined {
252
+ return DEEPGRAM_TTS_VOICES.find((v) => v.id === id);
253
+ }
254
+
255
+ /**
256
+ * Filter the surfaced voice list by language tag. Used by the settings
257
+ * panel voice picker — when the user has `ttsLanguage = "en"` we only
258
+ * show English Aura voices.
259
+ *
260
+ * Region matching is intentionally loose (base tag only) for Deepgram
261
+ * because Aura voice ids carry language without region (e.g. `aura-asteria-en`
262
+ * is American, `aura-angus-en` is Irish — both list `language: "en"`).
263
+ */
264
+ export function filterDeepgramVoicesByLanguage(lang: string): readonly (typeof DEEPGRAM_TTS_VOICES)[number][] {
265
+ const base = (lang.split("-")[0] ?? "").toLowerCase();
266
+ if (!base) return DEEPGRAM_TTS_VOICES;
267
+ return DEEPGRAM_TTS_VOICES.filter((v) => v.language === base);
268
+ }
269
+
270
+ /**
271
+ * Validate that a Deepgram voice id matches a requested language. Used by
272
+ * the speak orchestrator before any network call so language ↔ voice
273
+ * mismatches surface immediately rather than after a wasted round trip.
274
+ *
275
+ * Voices not in the surfaced catalog (custom Aura-2 ids the user pasted
276
+ * directly into config) are accepted on faith — Deepgram's server will
277
+ * reject if invalid.
278
+ */
279
+ export function assertLanguageForDeepgram(voiceId: string, language: string): void {
280
+ if (!language || typeof language !== "string") {
281
+ throw new Error(`TTS language is required (got: ${language})`);
282
+ }
283
+ if (!voiceId || typeof voiceId !== "string") {
284
+ throw new Error(`TTS voice id is required (got: ${voiceId})`);
285
+ }
286
+ const voice = getDeepgramVoice(voiceId);
287
+ if (!voice) {
288
+ // Unknown id — let Deepgram validate it server-side.
289
+ return;
290
+ }
291
+ const requestedBase = (language.split("-")[0] ?? "").toLowerCase();
292
+ if (voice.language !== requestedBase) {
293
+ throw new Error(
294
+ `Deepgram voice ${voice.id} speaks ${voice.language} but ttsLanguage is ${language}. ` +
295
+ `Pick a voice for ${language} via /voice-settings, or change ttsLanguage to ${voice.language}.`
296
+ );
297
+ }
298
+ }
299
+
300
+ // ─── v7.1.3: Deepgram WebSocket streaming TTS ─────────────────────────────────
301
+
302
+ /**
303
+ * Open a WebSocket connection to Deepgram's streaming TTS endpoint and
304
+ * pipe binary audio frames into a `PlaybackStream` sink as they arrive.
305
+ *
306
+ * Endpoint: `wss://api.deepgram.com/v1/speak?model=<voice>&encoding=linear16&sample_rate=<rate>`
307
+ * Auth: `Authorization: Token <api-key>` request header
308
+ * Send: JSON `{"type":"Speak","text":"..."}` then `{"type":"Flush"}`
309
+ * Receive: Binary frames = raw 16-bit signed LE PCM (mono).
310
+ * Text frames = control / metadata / errors.
311
+ *
312
+ * Resolves when the server signals end-of-stream (after Flush). Rejects
313
+ * on auth errors, network errors, or signal abort. The sink itself is
314
+ * NOT ended/cancelled by this function — the caller controls the sink
315
+ * lifecycle so multiple calls can stream into the same player.
316
+ *
317
+ * For sub-200ms time-to-first-audio (TTFB) the network path matters
318
+ * most. We send Flush immediately after the text so Deepgram starts
319
+ * emitting audio frames without waiting for more input.
320
+ */
321
+ export interface DeepgramStreamingOpts {
322
+ readonly text: string;
323
+ readonly voiceId: string;
324
+ readonly config: VoiceConfig;
325
+ readonly sampleRate?: number;
326
+ readonly signal?: AbortSignal;
327
+ /** Sink to receive PCM frames as they arrive. Caller manages lifecycle. */
328
+ readonly sink: PlaybackStream;
329
+ }
330
+
331
+ export async function deepgramSpeakStreaming(opts: DeepgramStreamingOpts): Promise<void> {
332
+ const { text, voiceId, config, sink, signal } = opts;
333
+ const sampleRate = opts.sampleRate ?? DEEPGRAM_TTS_SAMPLE_RATE;
334
+
335
+ if (!text || typeof text !== "string") throw new Error(`Deepgram TTS text is required (got: ${text})`);
336
+ if (!voiceId || typeof voiceId !== "string") throw new Error(`Deepgram TTS voiceId is required (got: ${voiceId})`);
337
+ const apiKey = resolveDeepgramApiKey(config);
338
+ if (!apiKey) throw new Error("DEEPGRAM_API_KEY not set.");
339
+ if (signal?.aborted) throw makeAbortError();
340
+
341
+ const wsUrl =
342
+ `wss://api.deepgram.com/v1/speak?model=${encodeURIComponent(voiceId)}` +
343
+ `&encoding=linear16&sample_rate=${sampleRate}`;
344
+
345
+ // Node 22 ships a built-in WebSocket. The 3rd-arg `headers` form is
346
+ // the ws-package extension; the global Node WebSocket accepts headers
347
+ // via `WebSocket.HEADERS_INIT` constructor 2nd arg. To stay
348
+ // compatible across Node versions we use the 3rd-arg shape that the
349
+ // `ws` package supports — that's already pulled in transitively.
350
+ let ws: any;
351
+ try {
352
+ // Try built-in (Node 22+ undici).
353
+ ws = new (globalThis as any).WebSocket(wsUrl, {
354
+ headers: { Authorization: `Token ${apiKey}` },
355
+ });
356
+ } catch {
357
+ // Fall back to ws package if the built-in rejects the headers form.
358
+ const { WebSocket } = await import("ws");
359
+ ws = new WebSocket(wsUrl, { headers: { Authorization: `Token ${apiKey}` } });
360
+ }
361
+ ws.binaryType = "arraybuffer";
362
+
363
+ return new Promise<void>((resolve, reject) => {
364
+ let settled = false;
365
+ const settle = (action: () => void) => {
366
+ if (settled) return;
367
+ settled = true;
368
+ action();
369
+ };
370
+ const onAbort = () =>
371
+ settle(() => {
372
+ try {
373
+ ws.close(1000, "abort");
374
+ } catch {}
375
+ reject(makeAbortError());
376
+ });
377
+ signal?.addEventListener("abort", onAbort);
378
+
379
+ ws.addEventListener("open", () => {
380
+ try {
381
+ ws.send(JSON.stringify({ type: "Speak", text }));
382
+ // Flush tells Deepgram "no more text — emit audio + close".
383
+ ws.send(JSON.stringify({ type: "Flush" }));
384
+ } catch (err: any) {
385
+ settle(() => reject(new Error(`Deepgram WS send failed: ${err?.message ?? err}`)));
386
+ }
387
+ });
388
+ ws.addEventListener("message", (ev: any) => {
389
+ const data = ev?.data;
390
+ if (data instanceof ArrayBuffer) {
391
+ // Binary frame = raw int16 LE PCM. Pipe straight to sink.
392
+ // `sink.writePcm` returns a Promise (writeTail chain
393
+ // serializes internally; even fire-and-forget here
394
+ // preserves order). Catch on the returned promise so a
395
+ // late rejection (sink torn down mid-frame) doesn't
396
+ // crash with UnhandledPromiseRejection.
397
+ const i16 = new Int16Array(data);
398
+ try {
399
+ const p = sink.writePcm(i16);
400
+ if (p && typeof (p as Promise<void>).catch === "function") {
401
+ (p as Promise<void>).catch(() => {
402
+ /* sink already errored, caller observes */
403
+ });
404
+ }
405
+ } catch {
406
+ /* sync errors swallowed — sink handles state */
407
+ }
408
+ } else if (typeof data === "string") {
409
+ // Control frames: { "type": "Metadata" } / { "type": "Flushed" } / errors
410
+ try {
411
+ const msg = JSON.parse(data);
412
+ if (msg?.type === "Flushed" || msg?.type === "Final") {
413
+ // Server signals end of synthesis. Close and resolve.
414
+ try {
415
+ ws.close(1000, "done");
416
+ } catch {}
417
+ settle(() => resolve());
418
+ } else if (msg?.type === "Error" || msg?.error) {
419
+ // godspeed glm finding: must close socket on error
420
+ // path or sustained errors leak FDs + abort listeners.
421
+ try {
422
+ ws.close(1011, "error");
423
+ } catch {}
424
+ settle(() => reject(new Error(`Deepgram WS error: ${msg.error ?? data}`)));
425
+ }
426
+ } catch {
427
+ /* ignore unparsable text frames */
428
+ }
429
+ }
430
+ });
431
+ ws.addEventListener("error", (ev: any) => {
432
+ // godspeed glm finding: close on error path to release the
433
+ // FD + abort listener. Without this, sustained errors leak.
434
+ try {
435
+ ws.close(1011, "error");
436
+ } catch {}
437
+ settle(() => reject(new Error(`Deepgram WS error: ${ev?.message ?? "unknown"}`)));
438
+ });
439
+ ws.addEventListener("close", (ev: any) => {
440
+ signal?.removeEventListener("abort", onAbort);
441
+ // Server-initiated close after a successful stream resolves
442
+ // the promise; abort/error paths already settled above.
443
+ if (ev?.code === 1000) settle(() => resolve());
444
+ else settle(() => reject(new Error(`Deepgram WS closed: code=${ev?.code} reason=${ev?.reason ?? ""}`)));
445
+ });
446
+ });
447
+ }
448
+
449
+ function makeAbortError(): Error {
450
+ if (typeof DOMException === "function") return new DOMException("Deepgram TTS aborted", "AbortError");
451
+ const e = new Error("Deepgram TTS aborted");
452
+ (e as any).name = "AbortError";
453
+ return e;
454
+ }