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,675 @@
1
+ /**
2
+ * TTS audio playback — write WAV to a temp file, spawn a platform player,
3
+ * abort cleanly on signal. v6.0 ships file-based playback for simplicity;
4
+ * stdin-streaming for sub-200ms TTFB is a v6.1 optimization.
5
+ *
6
+ * Both backends produce a complete WAV blob:
7
+ * - Local engine returns Float32Array PCM → encoded to WAV here
8
+ * - Deepgram REST returns WAV bytes directly (container=wav)
9
+ *
10
+ * Concurrency contract: each play() call owns its own temp file. Two
11
+ * concurrent calls write to distinct UUID-named files and spawn distinct
12
+ * player processes. The caller is responsible for serializing if it
13
+ * doesn't want overlapping audio (the speak orchestrator does this).
14
+ *
15
+ * Security model:
16
+ * - Player invoked via `child_process.spawn(cmd, [args])` — argument
17
+ * array, no shell, no string interpolation. Cannot be hijacked by a
18
+ * malicious TMPDIR with shell metacharacters.
19
+ * - Windows uses an env-var indirection ($env:PI_SPEAK_PATH) so paths
20
+ * containing single quotes (e.g. C:\Users\O'Neil\...) cannot inject
21
+ * into the PowerShell command string.
22
+ * - Temp filenames are randomUUID — no user input in the name.
23
+ * - Files are written 0600 and asserted to live under os.tmpdir().
24
+ * - Cleanup uses a single-ownership token: the playback Promise's
25
+ * `finally` block is the ONLY code path that unlinks. Abort kills
26
+ * the player but leaves cleanup to that finally.
27
+ */
28
+
29
+ import { spawn, spawnSync, type ChildProcess } from "node:child_process";
30
+ import * as fs from "node:fs";
31
+ import * as os from "node:os";
32
+ import * as path from "node:path";
33
+ import { randomUUID } from "node:crypto";
34
+
35
+ // ─── Types ────────────────────────────────────────────────────────────────────
36
+
37
+ /**
38
+ * Audio source for playback. Either:
39
+ * - { wav: Uint8Array } — pre-encoded WAV bytes
40
+ * - { samples: Float32Array; sampleRate } — raw float PCM, encoded here
41
+ */
42
+ export type PlaybackSource = { wav: Uint8Array } | { samples: Float32Array; sampleRate: number };
43
+
44
+ export interface PlayOpts {
45
+ source: PlaybackSource;
46
+ signal?: AbortSignal;
47
+ /**
48
+ * Override the player command for testing. Production callers leave
49
+ * this unset so we pick by `process.platform`.
50
+ */
51
+ playerOverride?: PlayerSpec;
52
+ }
53
+
54
+ // ─── Public API ───────────────────────────────────────────────────────────────
55
+
56
+ /**
57
+ * Play `source` to the user's default audio output and resolve when
58
+ * playback finishes. Aborts cleanly via `opts.signal`.
59
+ *
60
+ * Resolves with `void` on successful completion. Rejects with:
61
+ * - DOMException("AbortError") if signal fires
62
+ * - Error("No audio player found...") if platform player can't be spawned
63
+ * - Error("Audio player exited with code N") on non-zero exit
64
+ */
65
+ export async function play(opts: PlayOpts): Promise<void> {
66
+ const { source, signal } = opts;
67
+
68
+ if (signal?.aborted) {
69
+ throw makeAbortError();
70
+ }
71
+
72
+ const wav = "wav" in source ? source.wav : encodeWav(source.samples, source.sampleRate);
73
+
74
+ const tmpFile = createTempWavPath();
75
+ let cleanupDone = false;
76
+ const cleanup = () => {
77
+ if (cleanupDone) return;
78
+ cleanupDone = true;
79
+ try {
80
+ fs.unlinkSync(tmpFile);
81
+ } catch {
82
+ /* may already be gone */
83
+ }
84
+ };
85
+
86
+ // Single-ownership unlink: the `finally` below is the ONLY code path
87
+ // that removes the temp file. Abort kills the player via Node's
88
+ // native `signal` option on spawn(); the player exit triggers the
89
+ // same finally. No double-delete possible.
90
+
91
+ try {
92
+ // Write the WAV with 0600 perms so other users on a multi-user box
93
+ // cannot read TTS output (transcripts can be sensitive even though
94
+ // they're agent-generated).
95
+ fs.writeFileSync(tmpFile, wav, { mode: 0o600 });
96
+
97
+ // Re-check abort after the sync write — if user hit Escape during
98
+ // the write, no point spawning the player.
99
+ if (signal?.aborted) throw makeAbortError();
100
+
101
+ const player = opts.playerOverride ?? choosePlayer();
102
+ const env = player.env ? { ...process.env, ...player.env(tmpFile) } : process.env;
103
+
104
+ const proc: ChildProcess = spawn(player.cmd, player.args(tmpFile), {
105
+ stdio: ["ignore", "ignore", "pipe"], // capture stderr for error messages
106
+ env,
107
+ // Node's native abort plumbing — when `signal` aborts, Node
108
+ // kills the child process atomically. Single source of kills,
109
+ // no race window between "abort fires" and "we look up proc to
110
+ // kill" (which a hand-rolled addEventListener would have).
111
+ ...(signal ? { signal } : {}),
112
+ });
113
+
114
+ await new Promise<void>((resolve, reject) => {
115
+ // Node can emit BOTH "error" (with AbortError) and "close" for
116
+ // the same termination — the order is racy. `settled` ensures
117
+ // exactly one settlement reaches the await.
118
+ let settled = false;
119
+ const settle = (action: () => void) => {
120
+ if (settled) return;
121
+ settled = true;
122
+ action();
123
+ };
124
+
125
+ let stderr = "";
126
+ const STDERR_CAP = 2048;
127
+ proc.stderr?.on("data", (d: Buffer) => {
128
+ // Cap BEFORE appending so a single multi-MB chunk can't
129
+ // blow past the budget. Truncate the chunk to the
130
+ // remaining headroom; once full, drop further chunks.
131
+ if (stderr.length >= STDERR_CAP) return;
132
+ const headroom = STDERR_CAP - stderr.length;
133
+ const text = d.toString();
134
+ stderr += text.length > headroom ? text.slice(0, headroom) : text;
135
+ });
136
+ proc.on("error", (err: NodeJS.ErrnoException) => {
137
+ settle(() => {
138
+ // Node fires "error" with AbortError when the native
139
+ // signal aborts; also when spawn() itself fails (ENOENT
140
+ // etc.). Distinguish by err.name.
141
+ if (err.name === "AbortError" || signal?.aborted) {
142
+ reject(makeAbortError());
143
+ } else {
144
+ reject(new Error(`Audio player ${player.cmd} failed to start: ${err.message}`));
145
+ }
146
+ });
147
+ });
148
+ proc.on("close", (code, sig) => {
149
+ settle(() => {
150
+ // Order matters: a clean exit (code === 0) ALWAYS wins,
151
+ // even if the abort signal fired in the microtask gap
152
+ // between the player finishing and the close handler
153
+ // running. The reverse — surfacing AbortError on a
154
+ // successfully-played audio — would be wrong UX.
155
+ if (code === 0) {
156
+ resolve();
157
+ } else if (signal?.aborted) {
158
+ reject(makeAbortError());
159
+ } else if (sig) {
160
+ reject(new Error(`Audio player ${player.cmd} terminated by ${sig}`));
161
+ } else {
162
+ const tail = stderr.trim().slice(-200);
163
+ reject(new Error(`Audio player ${player.cmd} exited with code ${code}` + (tail ? ` (${tail})` : "")));
164
+ }
165
+ });
166
+ });
167
+ });
168
+ } finally {
169
+ cleanup();
170
+ }
171
+ }
172
+
173
+ // ─── v7.1.3 streaming playback ─────────────────────────────────────────────────
174
+
175
+ /**
176
+ * Streaming playback sink. Synthesis writes PCM as it produces samples;
177
+ * the sink pipes them to a long-lived audio process (`sox` / `paplay`)
178
+ * so audio starts playing the moment the first chunk arrives. Drops
179
+ * file-write/open/start latency vs the file-based `play()` path.
180
+ *
181
+ * Supported writes: `Int16Array` mono samples at the configured
182
+ * `sampleRate`. Float32 inputs must be int16-converted by the caller —
183
+ * keeps this layer narrow and avoids per-write float→int conversions
184
+ * if the source already has int16 (e.g. Deepgram WS TTS).
185
+ *
186
+ * Lifecycle: `end()` flushes any buffered writes, then sends EOF to the
187
+ * player and resolves `done()` when playback finishes. `cancel()`
188
+ * kills the player immediately. Both are idempotent.
189
+ *
190
+ * Backpressure: `writePcm` returns a Promise that resolves once the
191
+ * data is accepted by Node's writable stream (immediately if the
192
+ * write returns true, or on `'drain'` if it returns false). Callers
193
+ * MUST await it to avoid unbounded memory growth and the "many
194
+ * once('drain')" race where multiple listeners fire on a single
195
+ * drain event before all writes have actually been buffered.
196
+ */
197
+ export interface PlaybackStream {
198
+ writePcm(int16: Int16Array): Promise<void>;
199
+ end(): Promise<void>;
200
+ cancel(): void;
201
+ done(): Promise<void>;
202
+ }
203
+
204
+ export interface OpenPlaybackStreamOpts {
205
+ sampleRate: number;
206
+ signal?: AbortSignal;
207
+ }
208
+
209
+ /**
210
+ * Open a streaming playback sink. Returns `null` when no streaming-capable
211
+ * player is found on PATH — the caller should fall back to the file-based
212
+ * `play()` path. Player priority: `sox` (preferred — works on macOS via
213
+ * homebrew, Linux via apt/yum, ships with most pi-listen STT installs) →
214
+ * `paplay` (Linux PulseAudio) → null.
215
+ *
216
+ * Windows is intentionally unsupported here — PowerShell SoundPlayer
217
+ * can't accept piped PCM. Windows users get the file-based fallback.
218
+ */
219
+ export function openPlaybackStream(opts: OpenPlaybackStreamOpts): PlaybackStream | null {
220
+ const { sampleRate, signal } = opts;
221
+ if (signal?.aborted) return null;
222
+ if (process.platform === "win32") return null;
223
+
224
+ const player = pickStreamingPlayer(sampleRate);
225
+ if (!player) return null;
226
+
227
+ let cancelled = false;
228
+ let ended = false;
229
+
230
+ const proc: ChildProcess = spawn(player.cmd, player.args, {
231
+ stdio: ["pipe", "ignore", "pipe"],
232
+ ...(signal ? { signal } : {}),
233
+ });
234
+
235
+ // v7.1.3 diagnostic: append every byte-count + lifecycle event to a
236
+ // stable log path so production truncation issues can be diagnosed
237
+ // without re-running with PI_VOICE_DEBUG. Best-effort — silently
238
+ // drops if /tmp isn't writable.
239
+ const diagLog = (s: string) => {
240
+ try {
241
+ const fs2 = require("node:fs") as typeof import("node:fs");
242
+ fs2.appendFileSync("/tmp/pi-listen-stream.log", `[${new Date().toISOString()}] ${s}\n`);
243
+ } catch {
244
+ /* best-effort */
245
+ }
246
+ };
247
+ diagLog(`opened ${player.cmd} sampleRate=${sampleRate} pid=${proc.pid}`);
248
+ let totalBytesAccepted = 0;
249
+ let totalWrites = 0;
250
+
251
+ let stderr = "";
252
+ const STDERR_CAP = 2048;
253
+ proc.stderr?.on("data", (d: Buffer) => {
254
+ if (stderr.length >= STDERR_CAP) return;
255
+ const headroom = STDERR_CAP - stderr.length;
256
+ const text = d.toString();
257
+ stderr += text.length > headroom ? text.slice(0, headroom) : text;
258
+ });
259
+
260
+ // Defensive: attach an internal handler to the donePromise so a
261
+ // cancel-then-not-await flow (caller throws before reaching
262
+ // `await stream.done()`) doesn't surface as an UnhandledPromiseRejection.
263
+ // The caller-facing `done()` returns the same promise; awaiters still
264
+ // see the rejection. (godspeed runtime/gemini-3.1-pro finding.)
265
+ const donePromise = attachSafetyCatch(
266
+ new Promise<void>((resolve, reject) => {
267
+ let settled = false;
268
+ const settle = (action: () => void) => {
269
+ if (settled) return;
270
+ settled = true;
271
+ action();
272
+ };
273
+ proc.on("error", (err: NodeJS.ErrnoException) => {
274
+ settle(() => {
275
+ if (err.name === "AbortError" || signal?.aborted || cancelled) {
276
+ reject(makeAbortError());
277
+ } else {
278
+ reject(new Error(`Streaming player ${player.cmd} failed: ${err.message}`));
279
+ }
280
+ });
281
+ });
282
+ proc.on("close", (code, sig) => {
283
+ diagLog(`proc.close code=${code} sig=${sig} cancelled=${cancelled} aborted=${signal?.aborted}`);
284
+ settle(() => {
285
+ if (cancelled || signal?.aborted) {
286
+ reject(makeAbortError());
287
+ } else if (code === 0) {
288
+ resolve();
289
+ } else if (sig) {
290
+ reject(new Error(`Streaming player ${player.cmd} terminated by ${sig}`));
291
+ } else {
292
+ const tail = stderr.trim().slice(-200);
293
+ reject(new Error(`Streaming player ${player.cmd} exited with code ${code}${tail ? ` (${tail})` : ""}`));
294
+ }
295
+ });
296
+ });
297
+ // EPIPE if player exits before we finish writing — common when the
298
+ // user aborts; settled by 'close' above.
299
+ proc.stdin?.on("error", () => {});
300
+ })
301
+ );
302
+
303
+ // Serialize writes via a chained promise. Each writePcm awaits the
304
+ // previous write's drain (if backpressured) before issuing the next
305
+ // stdin.write(). This is the only correct backpressure pattern for
306
+ // `once('drain')` — multiple listeners on the same event all fire
307
+ // simultaneously on the first drain, so Promise.all on independent
308
+ // drain promises resolves prematurely.
309
+ let writeTail: Promise<void> = Promise.resolve();
310
+
311
+ // v7.1.3 — chunked write to avoid sox-with-large-stdin-burst bug.
312
+ // When we pour a multi-MB Float32→Int16 PCM blob into sox stdin in
313
+ // one go, sox/CoreAudio appears to underrun mid-playback (consuming
314
+ // only the first ~64KB OS pipe buffer, exiting cleanly with code 0
315
+ // at ~1.5s in regardless of how much we queued). Splitting into
316
+ // CHUNK_BYTES-sized writes keeps sox fed in real time without
317
+ // overflowing — one chunk of audio per ~250ms of playback at
318
+ // 24kHz mono int16.
319
+ const CHUNK_BYTES = 12_000; // ~250ms @ 24kHz, ~270ms @ 22kHz
320
+
321
+ const writeOne = async (view: Uint8Array): Promise<void> => {
322
+ if (!proc.stdin || proc.stdin.destroyed) {
323
+ diagLog(`writeOne: stdin destroyed, dropping ${view.byteLength} bytes`);
324
+ return;
325
+ }
326
+ totalWrites++;
327
+ totalBytesAccepted += view.byteLength;
328
+ diagLog(`writeOne[${totalWrites}]: ${view.byteLength} bytes (total ${totalBytesAccepted})`);
329
+
330
+ // Slice-and-write loop with backpressure awareness.
331
+ for (let off = 0; off < view.byteLength; off += CHUNK_BYTES) {
332
+ if (!proc.stdin || proc.stdin.destroyed) {
333
+ diagLog(`writeOne: stdin destroyed mid-chunk at offset ${off}`);
334
+ return;
335
+ }
336
+ const slice = view.subarray(off, Math.min(off + CHUNK_BYTES, view.byteLength));
337
+ const ok = proc.stdin.write(slice);
338
+ if (!ok) {
339
+ await new Promise<void>((res) => {
340
+ const stdin = proc.stdin!;
341
+ const cleanup = () => {
342
+ stdin.off("drain", onDrain);
343
+ stdin.off("close", onClose);
344
+ stdin.off("error", onError);
345
+ };
346
+ const onDrain = () => {
347
+ cleanup();
348
+ res();
349
+ };
350
+ const onClose = () => {
351
+ cleanup();
352
+ res();
353
+ };
354
+ const onError = () => {
355
+ cleanup();
356
+ res();
357
+ };
358
+ stdin.once("drain", onDrain);
359
+ stdin.once("close", onClose);
360
+ stdin.once("error", onError);
361
+ });
362
+ }
363
+ }
364
+ };
365
+
366
+ return {
367
+ writePcm(int16: Int16Array): Promise<void> {
368
+ if (cancelled || ended) return Promise.resolve();
369
+ const view = new Uint8Array(int16.buffer, int16.byteOffset, int16.byteLength);
370
+ // Chain: caller can either await this OR fire-and-forget; if
371
+ // they fire-and-forget, end() awaits the same tail.
372
+ writeTail = writeTail
373
+ .then(() => writeOne(view))
374
+ .catch(() => {
375
+ /* EPIPE ok */
376
+ });
377
+ return writeTail;
378
+ },
379
+ async end(): Promise<void> {
380
+ if (ended) return;
381
+ ended = true;
382
+ diagLog(`end() called — awaiting ${totalWrites} writes (${totalBytesAccepted} bytes)`);
383
+ // Drain all pending writes before signaling EOF.
384
+ try {
385
+ await writeTail;
386
+ } catch {
387
+ /* swallowed */
388
+ }
389
+ // Sox closes the audio device immediately on EOF, dropping
390
+ // any audio still in the OS hardware buffer (~1-2s on macOS
391
+ // CoreAudio). Append a tail of silence so the trailing real
392
+ // audio is fully flushed before sox tears down the device.
393
+ // 1.0 sec at sampleRate samples = sampleRate * 2 bytes.
394
+ const SILENCE_TAIL_SECS = 1;
395
+ const silence = new Int16Array(sampleRate * SILENCE_TAIL_SECS);
396
+ try {
397
+ await writeOne(new Uint8Array(silence.buffer, silence.byteOffset, silence.byteLength));
398
+ } catch {
399
+ /* EPIPE ok */
400
+ }
401
+ diagLog(`end() — silence tail written, calling stdin.end()`);
402
+ try {
403
+ proc.stdin?.end();
404
+ } catch {
405
+ /* already closed */
406
+ }
407
+ },
408
+ cancel(): void {
409
+ if (cancelled) return;
410
+ cancelled = true;
411
+ diagLog(`cancel() called after ${totalWrites} writes (${totalBytesAccepted} bytes)`);
412
+ try {
413
+ proc.stdin?.destroy();
414
+ } catch {}
415
+ try {
416
+ proc.kill("SIGTERM");
417
+ } catch {}
418
+ },
419
+ done(): Promise<void> {
420
+ return donePromise;
421
+ },
422
+ };
423
+ }
424
+
425
+ /** Internal: prevent unhandled rejection when the caller never awaits done(). */
426
+ function attachSafetyCatch<T>(p: Promise<T>): Promise<T> {
427
+ p.catch(() => {
428
+ /* swallow — real awaiters see the rejection */
429
+ });
430
+ return p;
431
+ }
432
+
433
+ interface StreamingPlayerSpec {
434
+ cmd: string;
435
+ args: string[];
436
+ }
437
+
438
+ function pickStreamingPlayer(sampleRate: number): StreamingPlayerSpec | null {
439
+ // v7.1.3 — ffplay is the most-reliable streaming PCM consumer on
440
+ // macOS: it's designed for real-time piped audio and doesn't suffer
441
+ // the sox-with-CoreAudio underrun where sox exits cleanly after
442
+ // playing only the first ~1.5s of a multi-MB stdin write. Prefer
443
+ // ffplay when present; fall back to paplay (Linux) or sox.
444
+ if (binaryAvailable("ffplay")) {
445
+ return {
446
+ cmd: "ffplay",
447
+ args: [
448
+ "-nodisp", // no video window
449
+ "-autoexit", // exit when input EOFs
450
+ "-loglevel",
451
+ "quiet",
452
+ "-f",
453
+ "s16le",
454
+ "-ar",
455
+ String(sampleRate),
456
+ "-ch_layout",
457
+ "mono", // ffmpeg 8+ uses ch_layout instead of -ac
458
+ "-i",
459
+ "pipe:0",
460
+ ],
461
+ };
462
+ }
463
+ // paplay (Linux PulseAudio / PipeWire-pulse): pipe PCM via stdin.
464
+ if (process.platform === "linux" && binaryAvailable("paplay")) {
465
+ return {
466
+ cmd: "paplay",
467
+ args: ["--raw", `--rate=${sampleRate}`, "--format=s16le", "--channels=1", "--client-name=pi-listen"],
468
+ };
469
+ }
470
+ // sox last-resort: cross-platform but has the macOS CoreAudio
471
+ // underrun issue noted above. Used when ffplay/paplay missing.
472
+ if (binaryAvailable("sox")) {
473
+ return {
474
+ cmd: "sox",
475
+ args: ["-t", "raw", "-r", String(sampleRate), "-e", "signed-integer", "-b", "16", "-c", "1", "-q", "-", "-d"],
476
+ };
477
+ }
478
+ return null;
479
+ }
480
+
481
+ const _binaryCache = new Map<string, boolean>();
482
+ function binaryAvailable(cmd: string): boolean {
483
+ const cached = _binaryCache.get(cmd);
484
+ if (cached !== undefined) return cached;
485
+ try {
486
+ const r = spawnSync(cmd, ["--version"], { stdio: "ignore" });
487
+ const ok = r.status === 0 || r.status === 1; // some tools return 1 for --version
488
+ _binaryCache.set(cmd, ok);
489
+ return ok;
490
+ } catch {
491
+ _binaryCache.set(cmd, false);
492
+ return false;
493
+ }
494
+ }
495
+
496
+ /** Helper — convert Float32 [-1, 1] PCM to Int16 with NaN guard + clamp. */
497
+ export function float32ToInt16(samples: Float32Array): Int16Array {
498
+ const out = new Int16Array(samples.length);
499
+ for (let i = 0; i < samples.length; i++) {
500
+ const raw = samples[i]!;
501
+ const finite = Number.isFinite(raw) ? raw : 0;
502
+ const s = Math.max(-1, Math.min(1, finite));
503
+ out[i] = s < 0 ? Math.round(s * 0x8000) : Math.round(s * 0x7fff);
504
+ }
505
+ return out;
506
+ }
507
+
508
+ // ─── Player selection ─────────────────────────────────────────────────────────
509
+
510
+ interface PlayerSpec {
511
+ cmd: string;
512
+ args: (path: string) => string[];
513
+ /**
514
+ * Optional environment variables. The Windows player uses this to pass
515
+ * the path via $env:PI_SPEAK_PATH instead of substituting it into the
516
+ * PowerShell command string — defeats injection via paths containing `'`.
517
+ */
518
+ env?: (path: string) => NodeJS.ProcessEnv;
519
+ }
520
+
521
+ /**
522
+ * Choose a platform-appropriate player. Throws with an actionable message
523
+ * if no player is recognized — the message guides the user to install
524
+ * something compatible.
525
+ *
526
+ * Linux prefers paplay (PulseAudio / PipeWire compat) but falls back to
527
+ * aplay (raw ALSA). The fallback is decided at spawn time, not here, so
528
+ * we pick paplay first and let the caller observe spawn failure to retry
529
+ * with aplay. See the inline comment on linuxPlayer below.
530
+ */
531
+ function choosePlayer(): PlayerSpec {
532
+ switch (process.platform) {
533
+ case "darwin":
534
+ return {
535
+ cmd: "afplay",
536
+ args: (p) => [p],
537
+ };
538
+ case "linux":
539
+ return linuxPlayer();
540
+ case "win32":
541
+ // PowerShell SoundPlayer reads from $env:PI_SPEAK_PATH so the
542
+ // path is never interpolated into the command string. Defends
543
+ // against any path that contains single quotes or other
544
+ // PowerShell metacharacters.
545
+ return {
546
+ cmd: "powershell",
547
+ args: () => ["-NoProfile", "-Command", "$p = $env:PI_SPEAK_PATH; (New-Object Media.SoundPlayer $p).PlaySync()"],
548
+ env: (p) => ({ PI_SPEAK_PATH: p }),
549
+ };
550
+ default:
551
+ throw new Error(
552
+ `No audio player configured for platform: ${process.platform}. ` + `Supported: darwin, linux, win32.`
553
+ );
554
+ }
555
+ }
556
+
557
+ /**
558
+ * Linux player selection. We default to paplay (PulseAudio / PipeWire
559
+ * compat shim) because almost all modern desktop distros run pulse or
560
+ * pipewire-pulse. If paplay isn't installed, callers will get
561
+ * `spawn paplay ENOENT`; the caller (speak orchestrator) can detect that
562
+ * and surface "install paplay or aplay" — we don't probe here because
563
+ * `which paplay` would add an extra spawn per playback.
564
+ *
565
+ * Users who only have aplay can override via the (future) settings-panel
566
+ * "audio player" option. v6.0 does not surface that knob; v6.1 adds it
567
+ * if field reports show it's needed.
568
+ */
569
+ function linuxPlayer(): PlayerSpec {
570
+ return {
571
+ cmd: "paplay",
572
+ args: (p) => [p],
573
+ };
574
+ }
575
+
576
+ // ─── Temp file ────────────────────────────────────────────────────────────────
577
+
578
+ function createTempWavPath(): string {
579
+ const tmpdir = os.tmpdir();
580
+ const file = path.join(tmpdir, `pi-speak-${randomUUID()}.wav`);
581
+ // Defense in depth: assert the file lives under tmpdir, in case
582
+ // path.join somehow ate a `..` (it shouldn't, but the assertion is
583
+ // nearly free and pins down the invariant).
584
+ const rel = path.relative(tmpdir, file);
585
+ if (rel.startsWith("..") || path.isAbsolute(rel)) {
586
+ throw new Error(`Refusing to write outside tmpdir: ${file}`);
587
+ }
588
+ return file;
589
+ }
590
+
591
+ // ─── WAV encoding ─────────────────────────────────────────────────────────────
592
+
593
+ /**
594
+ * Encode Float32 PCM samples in [-1, 1] as a mono 16-bit signed-LE WAV.
595
+ * Standard 44-byte RIFF header followed by sample data.
596
+ *
597
+ * No external deps so this works in the smoke test sandbox where
598
+ * sherpa.writeWave isn't loaded. Float-to-int16 clamps to [-32768, 32767]
599
+ * to handle out-of-range values from the engine without wrap-around
600
+ * artifacts.
601
+ */
602
+ export function encodeWav(samples: Float32Array, sampleRate: number): Uint8Array {
603
+ if (!Number.isFinite(sampleRate) || sampleRate <= 0) {
604
+ throw new Error(`Invalid sample rate: ${sampleRate}`);
605
+ }
606
+ const numSamples = samples.length;
607
+ // WAV header chunk-size fields are uint32 — `36 + dataLen` must fit.
608
+ // 4 GiB total is the spec maximum; we cap at ~2 GiB of PCM data
609
+ // (1,073,741,800 bytes) which is roughly 6 hours at 24 kHz mono.
610
+ // Anything longer is almost certainly a programmer error in chunking
611
+ // upstream — surface it loudly rather than emitting a corrupt header.
612
+ const MAX_DATA_BYTES = 0xffffffff - 36;
613
+ if (numSamples > MAX_DATA_BYTES / 2) {
614
+ throw new Error(
615
+ `encodeWav: ${numSamples} samples exceeds WAV uint32 limit. ` +
616
+ `Chunk the input upstream (e.g. via Intl.Segmenter sentence chunking).`
617
+ );
618
+ }
619
+ const byteRate = sampleRate * 2; // mono * 16-bit (channels * bytesPerSample)
620
+ const dataLen = numSamples * 2;
621
+ const buf = new ArrayBuffer(44 + dataLen);
622
+ const view = new DataView(buf);
623
+
624
+ // "RIFF" chunk descriptor
625
+ writeAscii(view, 0, "RIFF");
626
+ view.setUint32(4, 36 + dataLen, true); // chunk size = file size - 8
627
+ writeAscii(view, 8, "WAVE");
628
+
629
+ // "fmt " sub-chunk
630
+ writeAscii(view, 12, "fmt ");
631
+ view.setUint32(16, 16, true); // PCM fmt chunk size
632
+ view.setUint16(20, 1, true); // format = 1 (PCM)
633
+ view.setUint16(22, 1, true); // channels = 1 (mono)
634
+ view.setUint32(24, sampleRate, true);
635
+ view.setUint32(28, byteRate, true);
636
+ view.setUint16(32, 2, true); // block align = channels * bytes-per-sample
637
+ view.setUint16(34, 16, true); // bits per sample
638
+
639
+ // "data" sub-chunk
640
+ writeAscii(view, 36, "data");
641
+ view.setUint32(40, dataLen, true);
642
+
643
+ // PCM samples — replace non-finite values with 0 (silence) instead of
644
+ // letting Math.max/min coerce NaN to -1. A NaN sample slipping through
645
+ // would otherwise produce a single-sample DC offset spike on output.
646
+ // 0 is the correct silent-sample value for signed PCM.
647
+ let offset = 44;
648
+ for (let i = 0; i < numSamples; i++) {
649
+ const raw = samples[i]!;
650
+ const finite = Number.isFinite(raw) ? raw : 0;
651
+ const s = Math.max(-1, Math.min(1, finite));
652
+ const i16 = s < 0 ? Math.round(s * 0x8000) : Math.round(s * 0x7fff);
653
+ view.setInt16(offset, i16, true);
654
+ offset += 2;
655
+ }
656
+
657
+ return new Uint8Array(buf);
658
+ }
659
+
660
+ function writeAscii(view: DataView, offset: number, str: string): void {
661
+ for (let i = 0; i < str.length; i++) {
662
+ view.setUint8(offset + i, str.charCodeAt(i));
663
+ }
664
+ }
665
+
666
+ // ─── Errors ───────────────────────────────────────────────────────────────────
667
+
668
+ function makeAbortError(): Error {
669
+ if (typeof DOMException === "function") {
670
+ return new DOMException("Audio playback aborted", "AbortError");
671
+ }
672
+ const e = new Error("Audio playback aborted");
673
+ (e as any).name = "AbortError";
674
+ return e;
675
+ }