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,430 @@
1
+ /**
2
+ * High-level TTS orchestrator.
3
+ *
4
+ * Public API:
5
+ * - speak(text, opts) — synthesize and play with backend dispatch
6
+ * - chunkText(text, lang) — split into playback-friendly sentences
7
+ *
8
+ * Pipeline:
9
+ * 1. Validate inputs (non-empty text, valid backend choice)
10
+ * 2. Resolve language from opts → config → fallback
11
+ * 3. Backend dispatch: local (sherpa) or cloud (Deepgram)
12
+ * 4. Sentence-aware chunking via Intl.Segmenter (with word-window
13
+ * fallback for locales/runtimes without segmenter support)
14
+ * 5. Sequentially synthesize + play each chunk
15
+ * 6. Cooperative abort via AbortSignal at every async boundary
16
+ *
17
+ * Sentence chunking uses Intl.Segmenter rather than naive `.`/`!`/`?`
18
+ * splitting. The naive split misfires on common patterns (`Dr. Smith`,
19
+ * `e.g.`, `v2.0`, `U.S.A.`, URLs, file paths, decimal numbers). Locale-
20
+ * aware Segmenter handles these correctly without an abbreviation table
21
+ * to maintain.
22
+ */
23
+
24
+ import type { VoiceConfig } from "./config";
25
+ import { synthesize, type TtsAudio } from "./tts-engine";
26
+ import { openPlaybackStream, float32ToInt16, type PlaybackStream } from "./tts-playback";
27
+ import { getTtsModel, type TtsLocalModelInfo } from "./tts-local-models";
28
+ import {
29
+ deepgramSpeak,
30
+ deepgramSpeakStreaming,
31
+ DEFAULT_DEEPGRAM_TTS_VOICE,
32
+ DEEPGRAM_TTS_SAMPLE_RATE,
33
+ assertLanguageForDeepgram,
34
+ } from "./tts-deepgram";
35
+ import { play } from "./tts-playback";
36
+
37
+ // ─── Public API ───────────────────────────────────────────────────────────────
38
+
39
+ export interface SpeakOpts {
40
+ /** The full text to speak. Will be sentence-chunked before synthesis. */
41
+ text: string;
42
+ /** Active VoiceConfig — read for backend choice, voice, language, speed. */
43
+ config: VoiceConfig;
44
+ /**
45
+ * Override config.ttsLanguage. BCP-47 tag (e.g. "en", "es-ES").
46
+ * Defaults to `config.ttsLanguage ?? config.language ?? "en"`.
47
+ */
48
+ language?: string;
49
+ /** Cooperative cancellation. Aborts mid-chunk if user hits Escape. */
50
+ signal?: AbortSignal;
51
+ /**
52
+ * Resolver for installed local model directories. Required for the
53
+ * local backend; the orchestrator doesn't know where models live —
54
+ * that's a property of the download/install layer in voice.ts /
55
+ * model-download.ts. Throws if the model id isn't installed.
56
+ */
57
+ resolveModelDir?: (modelId: string) => string;
58
+ /** Override the play() function — used by tests to capture audio. */
59
+ playAudio?: typeof play;
60
+ }
61
+
62
+ /**
63
+ * Synthesize `opts.text` and play it through the user's audio output.
64
+ * Resolves when playback completes; rejects on abort or error.
65
+ *
66
+ * Errors propagate as-thrown:
67
+ * - DOMException("AbortError") if signal fires
68
+ * - Error("...") with a user-facing message for everything else
69
+ */
70
+ export async function speak(opts: SpeakOpts): Promise<void> {
71
+ const { text, config, signal } = opts;
72
+
73
+ if (!text || typeof text !== "string" || !text.trim()) {
74
+ throw new Error("speak(): text is required and must be non-empty");
75
+ }
76
+ if (signal?.aborted) {
77
+ throw makeAbortError();
78
+ }
79
+
80
+ const language = resolveLanguage(opts);
81
+ const backend: "local" | "deepgram" = config.ttsBackend === "deepgram" ? "deepgram" : "local";
82
+
83
+ // Chunk the text once up front. Each chunk is small enough to fit a
84
+ // single synthesize call without the engine refusing it; sequential
85
+ // playback keeps the audio in order.
86
+ const chunks = chunkText(text, language);
87
+ if (chunks.length === 0) {
88
+ throw new Error("speak(): no synthesizable content after chunking");
89
+ }
90
+
91
+ const playAudio = opts.playAudio ?? play;
92
+
93
+ // v7.1.3 — pipelined synth + true streaming playback when sox/paplay
94
+ // is available. Two layers:
95
+ //
96
+ // (1) Pipeline: while chunk N plays, synthesize chunk N+1 in parallel
97
+ // so the gap between sentences disappears.
98
+ // (2) Stream: when a streaming-capable player is on PATH (sox / paplay
99
+ // / Linux), open a long-lived player process and pipe int16 PCM
100
+ // bytes directly. Drops file-write/open/start latency. Falls back
101
+ // to file-based per-chunk playback when no streaming player is
102
+ // available (Windows, or systems without sox).
103
+ //
104
+ // Cancellation: if the signal aborts mid-pipeline, the in-flight synth
105
+ // promise is left to settle naturally and the streaming sink is
106
+ // cancelled (kills sox + drops buffered audio). A throwing synth
107
+ // (broken model + sid) cancels the stream and propagates.
108
+ // godspeed runtime/gemini-3.1-pro VETO fix: when we kick off
109
+ // `nextSynth = synthOne(...)` and the loop later exits via abort
110
+ // or an unrelated throw, the prefetched promise can reject after
111
+ // no one is awaiting it → unhandledRejection crashes the Pi
112
+ // process. Attach a safety `.catch()` that lives until the next
113
+ // loop iteration's `await` consumes the (rejected) promise. The
114
+ // awaiter still observes the rejection because the catch is on a
115
+ // SHADOW promise — `synthOne()` itself returns the original.
116
+ const synthOne = (chunk: string) => {
117
+ const p = synthesizeChunk({
118
+ chunk,
119
+ backend,
120
+ config,
121
+ language,
122
+ signal,
123
+ resolveModelDir: opts.resolveModelDir,
124
+ });
125
+ // Shadow .catch — swallows so an orphaned promise never
126
+ // surfaces as UnhandledPromiseRejection. The original `p` is
127
+ // returned so the actual awaiter sees the rejection.
128
+ p.catch(() => {
129
+ /* see comment above */
130
+ });
131
+ return p;
132
+ };
133
+
134
+ // Discover the audio sample rate for the streaming player. Local
135
+ // engine returns it on the first synth result; for Deepgram we
136
+ // know it's the buildDeepgramSpeakUrl rate. We open the stream
137
+ // AFTER the first synth so we have a definitive rate to pass.
138
+ let stream: PlaybackStream | null = null;
139
+
140
+ const cleanupStream = () => {
141
+ if (stream) {
142
+ try {
143
+ stream.cancel();
144
+ } catch {
145
+ /* already cancelled */
146
+ }
147
+ stream = null;
148
+ }
149
+ };
150
+
151
+ // v7.1.3 — Deepgram WebSocket streaming TTS. When the user enables
152
+ // `ttsDeepgramStreaming` AND we have a local stream sink, bypass the
153
+ // REST/file-based chunk loop entirely: open the sink once, send each
154
+ // chunk's text directly to Deepgram's WS, and let the binary frames
155
+ // flow into the sink as they arrive. Sub-200ms TTFA in good network
156
+ // conditions vs ~1-2s for REST/file path.
157
+ if (backend === "deepgram" && config.ttsDeepgramStreaming === true) {
158
+ const dgSampleRate = 24000;
159
+ const sink = openPlaybackStream({ sampleRate: dgSampleRate, signal });
160
+ if (sink) {
161
+ try {
162
+ const voiceId = config.ttsDeepgramVoiceId || "aura-asteria-en";
163
+ for (const chunk of chunks) {
164
+ if (signal?.aborted) throw makeAbortError();
165
+ await deepgramSpeakStreaming({
166
+ text: chunk,
167
+ voiceId,
168
+ config,
169
+ sampleRate: dgSampleRate,
170
+ signal,
171
+ sink,
172
+ });
173
+ }
174
+ await sink.end();
175
+ await sink.done();
176
+ return;
177
+ } catch (err) {
178
+ try {
179
+ sink.cancel();
180
+ } catch {}
181
+ throw err;
182
+ }
183
+ }
184
+ // No streaming player available — fall through to REST/file path.
185
+ }
186
+
187
+ try {
188
+ let nextSynth: ReturnType<typeof synthOne> | null = null;
189
+ for (let i = 0; i < chunks.length; i++) {
190
+ if (signal?.aborted) throw makeAbortError();
191
+ // First iteration synthesizes inline; subsequent iterations use
192
+ // the prefetched audio from the previous iteration.
193
+ const audio = await (nextSynth ?? synthOne(chunks[i]!));
194
+ nextSynth = null;
195
+ if (signal?.aborted) throw makeAbortError();
196
+
197
+ // Streaming path: open the sink lazily on first chunk so we
198
+ // have the actual sample rate. PCM-yielding chunks (local
199
+ // `{ samples, sampleRate }`) are write-and-go; pre-encoded
200
+ // WAV chunks (Deepgram REST) cannot stream and fall through.
201
+ if ("samples" in audio && audio.sampleRate) {
202
+ if (stream === null && i === 0) {
203
+ stream = openPlaybackStream({ sampleRate: audio.sampleRate, signal });
204
+ }
205
+ if (stream) {
206
+ // Kick off NEXT chunk's synth BEFORE awaiting the
207
+ // write — synthesis runs in parallel with stdin
208
+ // write/drain. writePcm returns a promise that
209
+ // resolves once the byte queue is accepted (or
210
+ // drained on backpressure).
211
+ if (i + 1 < chunks.length) nextSynth = synthOne(chunks[i + 1]!);
212
+ await stream.writePcm(float32ToInt16(audio.samples));
213
+ continue;
214
+ }
215
+ }
216
+
217
+ // Non-streaming fallback (Windows, missing sox, or WAV chunks):
218
+ // file-per-chunk playback. Pipeline next synth while playing.
219
+ if (i + 1 < chunks.length) {
220
+ nextSynth = synthOne(chunks[i + 1]!);
221
+ }
222
+ await playAudio({ source: audio, signal });
223
+ if (signal?.aborted) throw makeAbortError();
224
+ }
225
+
226
+ // Streaming path: tell the player no more PCM is coming and wait
227
+ // for it to drain. end() awaits all queued writes + appends a
228
+ // silence tail before signaling EOF (compensates for sox closing
229
+ // the audio device on EOF and dropping ~1s of buffered audio).
230
+ if (stream) {
231
+ await stream.end();
232
+ await stream.done();
233
+ stream = null;
234
+ }
235
+ } catch (err) {
236
+ cleanupStream();
237
+ throw err;
238
+ }
239
+ }
240
+
241
+ // ─── Backend dispatch ─────────────────────────────────────────────────────────
242
+
243
+ interface SynthesizeChunkOpts {
244
+ chunk: string;
245
+ backend: "local" | "deepgram";
246
+ config: VoiceConfig;
247
+ language: string;
248
+ signal?: AbortSignal;
249
+ resolveModelDir?: (modelId: string) => string;
250
+ }
251
+
252
+ /**
253
+ * Single chunk → audio. Returns either a Float32 PCM frame (local) or a
254
+ * pre-encoded WAV blob (Deepgram). The playback layer accepts both.
255
+ */
256
+ async function synthesizeChunk(
257
+ opts: SynthesizeChunkOpts
258
+ ): Promise<{ samples: Float32Array; sampleRate: number } | { wav: Uint8Array }> {
259
+ const { chunk, backend, config, language, signal } = opts;
260
+ if (backend === "deepgram") {
261
+ const voiceId =
262
+ typeof config.ttsDeepgramVoiceId === "string" && config.ttsDeepgramVoiceId
263
+ ? config.ttsDeepgramVoiceId
264
+ : DEFAULT_DEEPGRAM_TTS_VOICE;
265
+ assertLanguageForDeepgram(voiceId, language);
266
+ const result = await deepgramSpeak({ text: chunk, voiceId, config, signal });
267
+ // The Deepgram REST endpoint we use returns a complete WAV blob;
268
+ // the playback layer reads the sample rate from the WAV header so
269
+ // `result.sampleRate` is informational only at this layer (v6.1
270
+ // streaming will wire it into the playback layer directly).
271
+ return { wav: result.wav };
272
+ }
273
+
274
+ // Local backend
275
+ const modelId = config.ttsLocalModel || "kitten-nano-en-v0_2";
276
+ const model: TtsLocalModelInfo = getTtsModel(modelId);
277
+ if (!opts.resolveModelDir) {
278
+ throw new Error(
279
+ "speak(): local TTS requires a resolveModelDir resolver. " +
280
+ "This is supplied by voice.ts when invoking speak() from the slash-command layer."
281
+ );
282
+ }
283
+ const modelDir = opts.resolveModelDir(modelId);
284
+
285
+ const sid =
286
+ typeof config.ttsLocalVoiceId === "number" && Number.isFinite(config.ttsLocalVoiceId)
287
+ ? config.ttsLocalVoiceId
288
+ : model.defaultSid;
289
+ const speed = typeof config.ttsSpeed === "number" && Number.isFinite(config.ttsSpeed) ? config.ttsSpeed : 1.0;
290
+
291
+ const audio: TtsAudio = await synthesize({
292
+ text: chunk,
293
+ model,
294
+ modelDir,
295
+ language,
296
+ sid,
297
+ speed,
298
+ signal,
299
+ });
300
+ return { samples: audio.samples, sampleRate: audio.sampleRate };
301
+ }
302
+
303
+ // ─── Language resolution ──────────────────────────────────────────────────────
304
+
305
+ function resolveLanguage(opts: SpeakOpts): string {
306
+ const candidate = opts.language ?? opts.config.ttsLanguage ?? opts.config.language ?? "en";
307
+ if (typeof candidate !== "string" || !candidate.trim()) return "en";
308
+ return candidate.trim();
309
+ }
310
+
311
+ // ─── Sentence chunking ────────────────────────────────────────────────────────
312
+
313
+ /**
314
+ * Hard cap on a single chunk in characters. sherpa-onnx's max_num_sentences
315
+ * default is 2; at conversational speech rate ~25 words / sentence, 2
316
+ * sentences ≈ 50 words ≈ 300-400 characters. Setting cap at 600 gives a
317
+ * safety margin while still keeping playback latency low.
318
+ */
319
+ const MAX_CHUNK_CHARS = 600;
320
+
321
+ /**
322
+ * Split `text` into playback-friendly chunks. Uses `Intl.Segmenter` for
323
+ * locale-aware sentence boundaries when available; falls back to a
324
+ * word-window splitter otherwise.
325
+ *
326
+ * Returned chunks are non-empty and each fits within MAX_CHUNK_CHARS.
327
+ * The caller can iterate them and play sequentially.
328
+ *
329
+ * Verified against problematic inputs (Dr./e.g./v2.0/U.S.A./URLs/decimals):
330
+ * Intl.Segmenter does NOT split on those abbreviations. See `tests/`
331
+ * for the regression cases.
332
+ */
333
+ export function chunkText(text: string, language: string): string[] {
334
+ const trimmed = text.trim();
335
+ if (!trimmed) return [];
336
+
337
+ const sentences = segmentSentences(trimmed, language);
338
+ const chunks: string[] = [];
339
+ let buf = "";
340
+
341
+ for (const sentence of sentences) {
342
+ const s = sentence.trim();
343
+ if (!s) continue;
344
+
345
+ if (s.length > MAX_CHUNK_CHARS) {
346
+ // Single sentence longer than cap — wrap-split on word boundaries.
347
+ if (buf) {
348
+ chunks.push(buf);
349
+ buf = "";
350
+ }
351
+ chunks.push(...wordWindowSplit(s));
352
+ continue;
353
+ }
354
+
355
+ const candidate = buf ? `${buf} ${s}` : s;
356
+ if (candidate.length > MAX_CHUNK_CHARS) {
357
+ chunks.push(buf);
358
+ buf = s;
359
+ } else {
360
+ buf = candidate;
361
+ }
362
+ }
363
+ if (buf) chunks.push(buf);
364
+ return chunks;
365
+ }
366
+
367
+ /**
368
+ * Locale-aware sentence segmentation. Falls back to a simple word-window
369
+ * splitter on environments where Intl.Segmenter is missing (e.g. older
370
+ * Node without ICU full-data, or Bun if locale-specific segmentation is
371
+ * unavailable for that lang).
372
+ */
373
+ function segmentSentences(text: string, language: string): string[] {
374
+ const SegmenterCtor: typeof Intl.Segmenter | undefined = (Intl as any).Segmenter;
375
+ if (typeof SegmenterCtor === "function") {
376
+ try {
377
+ const seg = new SegmenterCtor(language, { granularity: "sentence" });
378
+ const out: string[] = [];
379
+ for (const piece of seg.segment(text)) {
380
+ out.push(piece.segment);
381
+ }
382
+ return out;
383
+ } catch {
384
+ // Fall through to word-window fallback.
385
+ }
386
+ }
387
+ return wordWindowSplit(text);
388
+ }
389
+
390
+ /**
391
+ * Split on whitespace and re-pack into chunks of ~25 words each, never
392
+ * splitting mid-token. Used as the segmentation fallback.
393
+ */
394
+ function wordWindowSplit(text: string): string[] {
395
+ const words = text.split(/(\s+)/);
396
+ const chunks: string[] = [];
397
+ let buf = "";
398
+ let wordCount = 0;
399
+ const TARGET_WORDS = 25;
400
+ for (const w of words) {
401
+ const isWhitespace = /^\s+$/.test(w);
402
+ if (!isWhitespace && wordCount >= TARGET_WORDS && buf.length > 0) {
403
+ chunks.push(buf.trim());
404
+ buf = "";
405
+ wordCount = 0;
406
+ }
407
+ buf += w;
408
+ if (!isWhitespace) wordCount++;
409
+ if (buf.length >= MAX_CHUNK_CHARS) {
410
+ // Hard cap mid-text — flush.
411
+ chunks.push(buf.trim());
412
+ buf = "";
413
+ wordCount = 0;
414
+ }
415
+ }
416
+ const tail = buf.trim();
417
+ if (tail) chunks.push(tail);
418
+ return chunks;
419
+ }
420
+
421
+ // ─── Errors ───────────────────────────────────────────────────────────────────
422
+
423
+ function makeAbortError(): Error {
424
+ if (typeof DOMException === "function") {
425
+ return new DOMException("speak() aborted", "AbortError");
426
+ }
427
+ const e = new Error("speak() aborted");
428
+ (e as any).name = "AbortError";
429
+ return e;
430
+ }