klaudio 0.9.2 → 0.10.1

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/bin/cli.js CHANGED
@@ -1,12 +1,25 @@
1
1
  #!/usr/bin/env node
2
2
 
3
- // Subcommand: klaudio play <file> [--tts]
3
+ // Subcommand: klaudio play <file> [--tts] [--voice <voice>]
4
4
  if (process.argv[2] === "play") {
5
5
  const { handlePlayCommand } = await import("../src/player.js");
6
6
  await handlePlayCommand(process.argv.slice(3));
7
7
  process.exit(0);
8
8
  }
9
9
 
10
+ // Subcommand: klaudio say "text" [--voice <voice>]
11
+ if (process.argv[2] === "say") {
12
+ const args = process.argv.slice(3);
13
+ const text = args.find((a) => !a.startsWith("--"));
14
+ const voice = args.find((a) => a.startsWith("--voice="))?.slice(8)
15
+ || args[args.indexOf("--voice") + 1];
16
+ if (text) {
17
+ const { speak } = await import("../src/tts.js");
18
+ await speak(text, { voice });
19
+ }
20
+ process.exit(0);
21
+ }
22
+
10
23
  // Default: interactive installer UI
11
24
  const { run } = await import("../src/cli.js");
12
25
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "klaudio",
3
- "version": "0.9.2",
3
+ "version": "0.10.1",
4
4
  "description": "Add sound effects to your coding sessions — play sounds when tasks complete, notifications arrive, and more",
5
5
  "type": "module",
6
6
  "bin": {
@@ -32,6 +32,7 @@
32
32
  "ink": "^6.8.0",
33
33
  "ink-select-input": "^6.2.0",
34
34
  "ink-spinner": "^5.0.0",
35
+ "kokoro-js": "^1.2.1",
35
36
  "react": "^19.2.4"
36
37
  },
37
38
  "engines": {
package/src/cli.js CHANGED
@@ -2,6 +2,7 @@ import React, { useState, useEffect, useCallback, useRef, useMemo } from "react"
2
2
  import { render, Box, Text, useInput, useApp } from "ink";
3
3
  import Spinner from "ink-spinner";
4
4
  import { PRESETS, EVENTS } from "./presets.js";
5
+ import { KOKORO_PRESET_VOICES, KOKORO_VOICES, KOKORO_DEFAULT_VOICE } from "./tts.js";
5
6
  import { playSoundWithCancel, getWavDuration } from "./player.js";
6
7
  import { getAvailableGames, getSystemSounds } from "./scanner.js";
7
8
  import { install, uninstall, getExistingSounds } from "./installer.js";
@@ -1369,10 +1370,11 @@ const ExtractingScreen = ({ game, onDone, onBack }) => {
1369
1370
  };
1370
1371
 
1371
1372
  // ── Screen: Confirm ─────────────────────────────────────────────
1372
- const ConfirmScreen = ({ scope, sounds, tts, onToggleTts, onConfirm, onBack }) => {
1373
+ const ConfirmScreen = ({ scope, sounds, tts, voice, onToggleTts, onCycleVoice, onConfirm, onBack }) => {
1373
1374
  useInput((input, key) => {
1374
1375
  if (key.escape) onBack();
1375
1376
  else if (input === "t") onToggleTts();
1377
+ else if (input === "v" && tts) onCycleVoice();
1376
1378
  });
1377
1379
 
1378
1380
  const items = [
@@ -1381,6 +1383,7 @@ const ConfirmScreen = ({ scope, sounds, tts, onToggleTts, onConfirm, onBack }) =
1381
1383
  ];
1382
1384
 
1383
1385
  const soundEntries = Object.entries(sounds).filter(([_, path]) => path);
1386
+ const voiceInfo = KOKORO_VOICES.find((v) => v.id === voice);
1384
1387
 
1385
1388
  return h(Box, { flexDirection: "column" },
1386
1389
  h(Text, { bold: true, marginLeft: 2 }, " Ready to install:"),
@@ -1397,6 +1400,12 @@ const ConfirmScreen = ({ scope, sounds, tts, onToggleTts, onConfirm, onBack }) =
1397
1400
  ),
1398
1401
  h(Text, { dimColor: true }, " (t to toggle — reads a short summary when tasks complete)"),
1399
1402
  ),
1403
+ tts && voiceInfo ? h(Box, { marginLeft: 4 },
1404
+ h(Text, { color: ACCENT },
1405
+ `🎙 Voice: ${voiceInfo.name} (${voiceInfo.gender}, ${voiceInfo.accent})`,
1406
+ ),
1407
+ h(Text, { dimColor: true }, " (v to change voice)"),
1408
+ ) : null,
1400
1409
  ),
1401
1410
  h(Box, { marginTop: 1, marginLeft: 2 },
1402
1411
  h(SelectInput, { indicatorComponent: Indicator, itemComponent: Item, items, onSelect: (item) => {
@@ -1409,13 +1418,13 @@ const ConfirmScreen = ({ scope, sounds, tts, onToggleTts, onConfirm, onBack }) =
1409
1418
  };
1410
1419
 
1411
1420
  // ── Screen: Installing ──────────────────────────────────────────
1412
- const InstallingScreen = ({ scope, sounds, tts, onDone }) => {
1421
+ const InstallingScreen = ({ scope, sounds, tts, voice, onDone }) => {
1413
1422
  useEffect(() => {
1414
1423
  const validSounds = {};
1415
1424
  for (const [eventId, path] of Object.entries(sounds)) {
1416
1425
  if (path) validSounds[eventId] = path;
1417
1426
  }
1418
- install({ scope, sounds: validSounds, tts }).then(onDone).catch((err) => {
1427
+ install({ scope, sounds: validSounds, tts, voice }).then(onDone).catch((err) => {
1419
1428
  onDone({ error: err.message });
1420
1429
  });
1421
1430
  }, []);
@@ -1520,6 +1529,7 @@ const InstallApp = () => {
1520
1529
  const [selectedGame, setSelectedGame] = useState(null);
1521
1530
  const [installResult, setInstallResult] = useState(null);
1522
1531
  const [tts, setTts] = useState(true);
1532
+ const [voice, setVoice] = useState(KOKORO_DEFAULT_VOICE);
1523
1533
  const [musicFiles, setMusicFiles] = useState([]);
1524
1534
  const [musicGameName, setMusicGameName] = useState(null);
1525
1535
  const [musicShuffle, setMusicShuffle] = useState(false);
@@ -1568,6 +1578,7 @@ const InstallApp = () => {
1568
1578
  } else {
1569
1579
  setPresetId(id);
1570
1580
  initSoundsFromPreset(id);
1581
+ if (KOKORO_PRESET_VOICES[id]) setVoice(KOKORO_PRESET_VOICES[id]);
1571
1582
  setScreen(SCREEN.PREVIEW);
1572
1583
  }
1573
1584
  },
@@ -1648,7 +1659,12 @@ const InstallApp = () => {
1648
1659
  scope,
1649
1660
  sounds,
1650
1661
  tts,
1662
+ voice,
1651
1663
  onToggleTts: () => setTts((v) => !v),
1664
+ onCycleVoice: () => setVoice((v) => {
1665
+ const idx = KOKORO_VOICES.findIndex((x) => x.id === v);
1666
+ return KOKORO_VOICES[(idx + 1) % KOKORO_VOICES.length].id;
1667
+ }),
1652
1668
  onConfirm: () => setScreen(SCREEN.INSTALLING),
1653
1669
  onBack: () => {
1654
1670
  if (selectedGame) setScreen(SCREEN.GAME_SOUNDS);
@@ -1661,6 +1677,7 @@ const InstallApp = () => {
1661
1677
  scope,
1662
1678
  sounds,
1663
1679
  tts,
1680
+ voice,
1664
1681
  onDone: (result) => {
1665
1682
  setInstallResult(result);
1666
1683
  setScreen(SCREEN.DONE);
package/src/installer.js CHANGED
@@ -22,7 +22,7 @@ function getTargetDir(scope) {
22
22
  * @param {Record<string, string>} options.sounds - Map of event ID -> source sound file path
23
23
  * @param {boolean} [options.tts] - Enable TTS voice summary on task complete
24
24
  */
25
- export async function install({ scope, sounds, tts = false }) {
25
+ export async function install({ scope, sounds, tts = false, voice } = {}) {
26
26
  const claudeDir = getTargetDir(scope);
27
27
  const soundsDir = join(claudeDir, "sounds");
28
28
  const settingsFile = join(claudeDir, "settings.json");
@@ -69,7 +69,7 @@ export async function install({ scope, sounds, tts = false }) {
69
69
  const hookEvent = event.hookEvent;
70
70
  // Enable TTS only for the "stop" event (task complete)
71
71
  const useTts = tts && eventId === "stop";
72
- const playCommand = getHookPlayCommand(soundPath, { tts: useTts });
72
+ const playCommand = getHookPlayCommand(soundPath, { tts: useTts, voice });
73
73
 
74
74
  // Check if there's already a klaudio hook for this event
75
75
  if (!settings.hooks[hookEvent]) {
@@ -118,7 +118,7 @@ async function installApprovalHooks(settings, soundPath, claudeDir) {
118
118
  // Write the timer script
119
119
  const script = `#!/usr/bin/env bash
120
120
  # klaudio: approval notification timer
121
- # Plays a sound if a tool isn't approved within DELAY seconds.
121
+ # Plays a sound + speaks a TTS message if a tool isn't approved within DELAY seconds.
122
122
  DELAY=15
123
123
  MARKER="/tmp/.claude-approval-pending"
124
124
  SOUND="${normalized}"
@@ -126,12 +126,16 @@ SOUND="${normalized}"
126
126
  case "$1" in
127
127
  start)
128
128
  TOKEN="$$-$(date +%s%N)"
129
+ # Store token and CWD so the delayed notification knows the project name
129
130
  echo "$TOKEN" > "$MARKER"
131
+ echo "$PWD" >> "$MARKER"
130
132
  (
131
133
  sleep "$DELAY"
132
- if [ -f "$MARKER" ] && [ "$(cat "$MARKER" 2>/dev/null)" = "$TOKEN" ]; then
134
+ if [ -f "$MARKER" ] && [ "$(head -1 "$MARKER" 2>/dev/null)" = "$TOKEN" ]; then
135
+ PROJECT=$(tail -1 "$MARKER" 2>/dev/null | sed 's|.*[/\\\\]||')
133
136
  rm -f "$MARKER"
134
137
  npx klaudio play "$SOUND" 2>/dev/null
138
+ npx klaudio say "\${PROJECT:-project} needs your attention" 2>/dev/null
135
139
  fi
136
140
  ) &
137
141
  ;;
package/src/player.js CHANGED
@@ -444,7 +444,9 @@ export async function handlePlayCommand(args) {
444
444
  const spoken = project ? `${project}: ${summary}` : summary;
445
445
  await soundPromise;
446
446
  const { speak } = await import("./tts.js");
447
- await speak(spoken);
447
+ const voice = args.find((a) => a.startsWith("--voice="))?.slice(8)
448
+ || args[args.indexOf("--voice") + 1];
449
+ await speak(spoken, { voice });
448
450
  } else {
449
451
  await soundPromise;
450
452
  }
@@ -453,8 +455,9 @@ export async function handlePlayCommand(args) {
453
455
  /**
454
456
  * Generate the shell command string for use in Claude Code hooks.
455
457
  */
456
- export function getHookPlayCommand(soundFilePath, { tts = false } = {}) {
458
+ export function getHookPlayCommand(soundFilePath, { tts = false, voice } = {}) {
457
459
  const normalized = soundFilePath.replace(/\\/g, "/");
458
460
  const ttsFlag = tts ? " --tts" : "";
459
- return `npx klaudio play "${normalized}"${ttsFlag}`;
461
+ const voiceFlag = tts && voice ? ` --voice ${voice}` : "";
462
+ return `npx klaudio play "${normalized}"${ttsFlag}${voiceFlag}`;
460
463
  }
package/src/tts.js CHANGED
@@ -1,49 +1,110 @@
1
- import { execFile, spawn } from "node:child_process";
2
- import { mkdir, stat, rename, chmod } from "node:fs/promises";
1
+ import { execFile } from "node:child_process";
2
+ import { mkdir, stat, chmod, writeFile as fsWriteFile } from "node:fs/promises";
3
3
  import { createWriteStream } from "node:fs";
4
4
  import { join, basename } from "node:path";
5
5
  import { homedir, platform, arch, tmpdir } from "node:os";
6
- import { pipeline } from "node:stream/promises";
7
6
  import { createHash } from "node:crypto";
8
7
 
9
8
  const PIPER_VERSION = "2023.11.14-2";
10
9
  const VOICE_NAME = "en_GB-alan-medium";
11
- const VOICE_SAMPLE_RATE = 22050;
12
10
 
13
11
  const PIPER_DIR = join(homedir(), ".klaudio", "piper");
14
12
 
13
+ // ── Kokoro TTS (primary engine) ─────────────────────────────────
14
+
15
+ // Default voice per preset vibe
16
+ const KOKORO_PRESET_VOICES = {
17
+ "retro-8bit": "af_bella",
18
+ "minimal-zen": "af_heart",
19
+ "sci-fi-terminal": "af_nova",
20
+ "victory-fanfare": "af_sky",
21
+ };
22
+ const KOKORO_DEFAULT_VOICE = "af_heart";
23
+
24
+ // Curated voice list for the picker (best quality voices)
25
+ const KOKORO_VOICES = [
26
+ { id: "af_heart", name: "Heart", gender: "F", accent: "US", grade: "A" },
27
+ { id: "af_bella", name: "Bella", gender: "F", accent: "US", grade: "A-" },
28
+ { id: "af_nicole", name: "Nicole", gender: "F", accent: "US", grade: "B-" },
29
+ { id: "af_nova", name: "Nova", gender: "F", accent: "US", grade: "C" },
30
+ { id: "af_sky", name: "Sky", gender: "F", accent: "US", grade: "C-" },
31
+ { id: "af_sarah", name: "Sarah", gender: "F", accent: "US", grade: "C+" },
32
+ { id: "am_fenrir", name: "Fenrir", gender: "M", accent: "US", grade: "C+" },
33
+ { id: "am_michael",name: "Michael",gender: "M", accent: "US", grade: "C+" },
34
+ { id: "am_puck", name: "Puck", gender: "M", accent: "US", grade: "C+" },
35
+ { id: "bf_emma", name: "Emma", gender: "F", accent: "UK", grade: "B-" },
36
+ { id: "bm_george", name: "George", gender: "M", accent: "UK", grade: "C" },
37
+ { id: "bm_fable", name: "Fable", gender: "M", accent: "UK", grade: "C" },
38
+ ];
39
+
40
+ // Singleton: reuse the loaded model across calls
41
+ let kokoroInstance = null;
42
+ let kokoroLoadPromise = null;
43
+
44
+ /**
45
+ * Load the Kokoro TTS model (singleton, downloads ~86MB on first use).
46
+ * Uses CPU backend (DirectML has ConvTranspose compatibility issues).
47
+ */
48
+ async function getKokoro() {
49
+ if (kokoroInstance) return kokoroInstance;
50
+ if (kokoroLoadPromise) return kokoroLoadPromise;
51
+
52
+ kokoroLoadPromise = (async () => {
53
+ const { KokoroTTS } = await import("kokoro-js");
54
+ kokoroInstance = await KokoroTTS.from_pretrained(
55
+ "onnx-community/Kokoro-82M-v1.0-ONNX",
56
+ { dtype: "q4", device: "cpu" },
57
+ );
58
+ return kokoroInstance;
59
+ })();
60
+
61
+ try {
62
+ return await kokoroLoadPromise;
63
+ } catch (err) {
64
+ kokoroLoadPromise = null;
65
+ throw err;
66
+ }
67
+ }
68
+
15
69
  /**
16
- * Get the piper release asset name for the current platform.
70
+ * Speak text using Kokoro TTS.
71
+ * Returns true if successful, false if Kokoro is unavailable.
17
72
  */
73
+ async function speakKokoro(text, voice) {
74
+ const tts = await getKokoro();
75
+ const voiceId = voice || KOKORO_DEFAULT_VOICE;
76
+
77
+ const audio = await tts.generate(text, { voice: voiceId, speed: 1.0 });
78
+
79
+ // Save to temp wav and play
80
+ const hash = createHash("md5").update(text + voiceId).digest("hex").slice(0, 8);
81
+ const outPath = join(tmpdir(), `klaudio-kokoro-${hash}.wav`);
82
+ audio.save(outPath);
83
+
84
+ const { playSoundWithCancel } = await import("./player.js");
85
+ await playSoundWithCancel(outPath, { maxSeconds: 0 }).promise.catch(() => {});
86
+ }
87
+
88
+ // ── Piper TTS (fallback engine) ─────────────────────────────────
89
+
18
90
  function getPiperAssetName() {
19
91
  const os = platform();
20
92
  const a = arch();
21
-
22
93
  if (os === "win32") return "piper_windows_amd64.zip";
23
94
  if (os === "darwin") return a === "arm64" ? "piper_macos_aarch64.tar.gz" : "piper_macos_x64.tar.gz";
24
- // Linux
25
95
  if (a === "arm64" || a === "aarch64") return "piper_linux_aarch64.tar.gz";
26
96
  return "piper_linux_x86_64.tar.gz";
27
97
  }
28
98
 
29
- /**
30
- * Get the piper binary path.
31
- */
32
99
  function getPiperBinPath() {
33
100
  const bin = platform() === "win32" ? "piper.exe" : "piper";
34
101
  return join(PIPER_DIR, "piper", bin);
35
102
  }
36
103
 
37
- /**
38
- * Get the voice model path.
39
- */
40
104
  function getVoiceModelPath() {
41
105
  return join(PIPER_DIR, `${VOICE_NAME}.onnx`);
42
106
  }
43
107
 
44
- /**
45
- * Download a file from a URL to a local path.
46
- */
47
108
  async function downloadFile(url, destPath, onProgress) {
48
109
  const res = await fetch(url, { redirect: "follow" });
49
110
  if (!res.ok) throw new Error(`Download failed: ${res.status} ${res.statusText}`);
@@ -53,7 +114,6 @@ async function downloadFile(url, destPath, onProgress) {
53
114
  const fileStream = createWriteStream(destPath);
54
115
  const reader = res.body.getReader();
55
116
 
56
- // Manual stream piping with progress
57
117
  while (true) {
58
118
  const { done, value } = await reader.read();
59
119
  if (done) break;
@@ -71,15 +131,10 @@ async function downloadFile(url, destPath, onProgress) {
71
131
  });
72
132
  }
73
133
 
74
- /**
75
- * Extract a .tar.gz or .zip archive.
76
- */
77
134
  async function extractArchive(archivePath, destDir) {
78
135
  const os = platform();
79
-
80
136
  if (archivePath.endsWith(".zip")) {
81
137
  if (os === "win32") {
82
- // Use PowerShell to extract on Windows
83
138
  await new Promise((resolve, reject) => {
84
139
  execFile("powershell.exe", [
85
140
  "-NoProfile", "-Command",
@@ -92,21 +147,14 @@ async function extractArchive(archivePath, destDir) {
92
147
  });
93
148
  }
94
149
  } else {
95
- // tar.gz
96
150
  await new Promise((resolve, reject) => {
97
151
  execFile("tar", ["xzf", archivePath, "-C", destDir], { timeout: 60000 }, (err) => err ? reject(err) : resolve());
98
152
  });
99
153
  }
100
154
  }
101
155
 
102
- /**
103
- * Ensure piper binary is available, downloading if needed.
104
- * Returns the path to the piper executable.
105
- */
106
156
  export async function ensurePiper(onProgress) {
107
157
  const binPath = getPiperBinPath();
108
-
109
- // Check if already downloaded
110
158
  try {
111
159
  await stat(binPath);
112
160
  return binPath;
@@ -114,7 +162,6 @@ export async function ensurePiper(onProgress) {
114
162
 
115
163
  try {
116
164
  await mkdir(PIPER_DIR, { recursive: true });
117
-
118
165
  const asset = getPiperAssetName();
119
166
  const url = `https://github.com/rhasspy/piper/releases/download/${PIPER_VERSION}/${asset}`;
120
167
  const archivePath = join(PIPER_DIR, asset);
@@ -127,28 +174,20 @@ export async function ensurePiper(onProgress) {
127
174
  if (onProgress) onProgress("Extracting piper...");
128
175
  await extractArchive(archivePath, PIPER_DIR);
129
176
 
130
- // Make executable on Unix
131
177
  if (platform() !== "win32") {
132
178
  try { await chmod(binPath, 0o755); } catch { /* ignore */ }
133
179
  }
134
180
 
135
181
  return binPath;
136
182
  } catch (err) {
137
- // Clean up partial downloads
138
183
  try { const { unlink } = await import("node:fs/promises"); await unlink(join(PIPER_DIR, getPiperAssetName())); } catch { /* ignore */ }
139
184
  throw new Error(`Failed to download piper: ${err.message}`);
140
185
  }
141
186
  }
142
187
 
143
- /**
144
- * Ensure voice model is available, downloading if needed.
145
- * Returns the path to the .onnx model file.
146
- */
147
188
  export async function ensureVoiceModel(onProgress) {
148
189
  const modelPath = getVoiceModelPath();
149
190
  const configPath = modelPath + ".json";
150
-
151
- // Check if already downloaded
152
191
  try {
153
192
  await stat(modelPath);
154
193
  await stat(configPath);
@@ -157,7 +196,6 @@ export async function ensureVoiceModel(onProgress) {
157
196
 
158
197
  try {
159
198
  await mkdir(PIPER_DIR, { recursive: true });
160
-
161
199
  const baseUrl = `https://huggingface.co/rhasspy/piper-voices/resolve/v1.0.0/en/en_GB/alan/medium`;
162
200
 
163
201
  if (onProgress) onProgress("Downloading voice model...");
@@ -170,7 +208,6 @@ export async function ensureVoiceModel(onProgress) {
170
208
 
171
209
  return modelPath;
172
210
  } catch (err) {
173
- // Clean up partial downloads
174
211
  const { unlink } = await import("node:fs/promises");
175
212
  try { await unlink(modelPath); } catch { /* ignore */ }
176
213
  try { await unlink(configPath); } catch { /* ignore */ }
@@ -178,28 +215,7 @@ export async function ensureVoiceModel(onProgress) {
178
215
  }
179
216
  }
180
217
 
181
- /**
182
- * Speak text using macOS `say` command (built-in, good quality).
183
- */
184
- function speakMacOS(text) {
185
- return new Promise((resolve) => {
186
- execFile("say", ["-v", "Daniel", text], { timeout: 15000 }, () => resolve());
187
- });
188
- }
189
-
190
- /**
191
- * Speak text using Piper TTS, with macOS `say` fallback.
192
- * Auto-downloads piper and voice model on first use.
193
- * Returns a promise that resolves when speech is done.
194
- */
195
- export async function speak(text, onProgress) {
196
- if (!text) return;
197
-
198
- // macOS: use built-in `say` — better compatibility, no dylib issues
199
- if (platform() === "darwin") {
200
- return speakMacOS(text);
201
- }
202
-
218
+ async function speakPiper(text, onProgress) {
203
219
  let piperBin, modelPath;
204
220
  try {
205
221
  [piperBin, modelPath] = await Promise.all([
@@ -207,11 +223,9 @@ export async function speak(text, onProgress) {
207
223
  ensureVoiceModel(onProgress),
208
224
  ]);
209
225
  } catch {
210
- // TTS unavailable (download failed, offline, etc.) — skip silently
211
226
  return;
212
227
  }
213
228
 
214
- // Generate to temp wav file
215
229
  const hash = createHash("md5").update(text).digest("hex").slice(0, 8);
216
230
  const outPath = join(tmpdir(), `klaudio-tts-${hash}.wav`);
217
231
 
@@ -225,15 +239,58 @@ export async function speak(text, onProgress) {
225
239
  if (err) reject(err);
226
240
  else resolve();
227
241
  });
228
- // Feed text via stdin
229
242
  child.stdin.write(text);
230
243
  child.stdin.end();
231
244
  });
232
245
 
233
- // Play the generated wav
234
246
  const { playSoundWithCancel } = await import("./player.js");
235
247
  await playSoundWithCancel(outPath, { maxSeconds: 0 }).promise.catch(() => {});
236
248
  } catch {
237
- // Piper failed (dylib error, etc.) — skip silently
249
+ // Piper failed — skip silently
238
250
  }
239
251
  }
252
+
253
+ // ── macOS fallback ──────────────────────────────────────────────
254
+
255
+ function speakMacOS(text) {
256
+ return new Promise((resolve) => {
257
+ execFile("say", ["-v", "Daniel", text], { timeout: 15000 }, () => resolve());
258
+ });
259
+ }
260
+
261
+ // ── Public API ──────────────────────────────────────────────────
262
+
263
+ /**
264
+ * Speak text using the best available TTS engine.
265
+ * Priority: Kokoro (GPU/CPU) → Piper → macOS say
266
+ *
267
+ * @param {string} text - Text to speak
268
+ * @param {object} [options]
269
+ * @param {string} [options.voice] - Kokoro voice ID (e.g. "af_heart")
270
+ * @param {Function} [options.onProgress] - Progress callback for downloads
271
+ */
272
+ export async function speak(text, options = {}) {
273
+ if (!text) return;
274
+
275
+ const { voice, onProgress } = typeof options === "function"
276
+ ? { voice: null, onProgress: options } // backwards compat: speak(text, onProgress)
277
+ : options;
278
+
279
+ // Try Kokoro first (works on all platforms, best quality)
280
+ try {
281
+ await speakKokoro(text, voice);
282
+ return;
283
+ } catch {
284
+ // Kokoro unavailable — fall through
285
+ }
286
+
287
+ // macOS: use built-in `say`
288
+ if (platform() === "darwin") {
289
+ return speakMacOS(text);
290
+ }
291
+
292
+ // Fallback: Piper
293
+ return speakPiper(text, onProgress);
294
+ }
295
+
296
+ export { KOKORO_PRESET_VOICES, KOKORO_VOICES, KOKORO_DEFAULT_VOICE };