klaudio 0.7.0 → 0.8.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.
package/bin/cli.js CHANGED
@@ -1,6 +1,14 @@
1
1
  #!/usr/bin/env node
2
2
 
3
- import { run } from "../src/cli.js";
3
+ // Subcommand: klaudio play <file> [--tts]
4
+ if (process.argv[2] === "play") {
5
+ const { handlePlayCommand } = await import("../src/player.js");
6
+ await handlePlayCommand(process.argv.slice(3));
7
+ process.exit(0);
8
+ }
9
+
10
+ // Default: interactive installer UI
11
+ const { run } = await import("../src/cli.js");
4
12
 
5
13
  run().catch((err) => {
6
14
  if (err.name === "ExitPromptError") {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "klaudio",
3
- "version": "0.7.0",
3
+ "version": "0.8.0",
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": {
package/src/cli.js CHANGED
@@ -39,8 +39,17 @@ const SelectInput = ({ items = [], isFocused = true, initialIndex = 0, indicator
39
39
  const prevValues = previousItems.current.map((i) => i.value);
40
40
  const curValues = items.map((i) => i.value);
41
41
  if (prevValues.length !== curValues.length || prevValues.some((v, i) => v !== curValues[i])) {
42
- setScrollOffset(0);
43
- setSelectedIndex(0);
42
+ // Try to keep the currently selected item highlighted
43
+ const prevSelected = previousItems.current[selectedIndex];
44
+ const newIdx = prevSelected ? items.findIndex((i) => i.value === prevSelected.value) : -1;
45
+ if (newIdx >= 0) {
46
+ setSelectedIndex(newIdx);
47
+ setScrollOffset(hasLimit ? Math.max(0, Math.min(newIdx, items.length - limit)) : 0);
48
+ } else {
49
+ // Selected item gone — reset to top
50
+ setScrollOffset(0);
51
+ setSelectedIndex(0);
52
+ }
44
53
  }
45
54
  previousItems.current = items;
46
55
  }, [items]);
@@ -115,7 +124,7 @@ const NavHint = ({ back = true, extra = "" }) =>
115
124
  );
116
125
 
117
126
  // ── Screen: Scope ───────────────────────────────────────────────
118
- const ScopeScreen = ({ onNext, onMusic }) => {
127
+ const ScopeScreen = ({ onNext, onMusic, tts, onToggleTts }) => {
119
128
  const items = [
120
129
  { label: "Global — Claude Code + Copilot (all projects)", value: "global" },
121
130
  { label: "This project — Claude Code + Copilot (this project only)", value: "project" },
@@ -130,6 +139,8 @@ const ScopeScreen = ({ onNext, onMusic }) => {
130
139
  setSel((i) => Math.max(0, i - 1));
131
140
  } else if (input === "j" || key.downArrow) {
132
141
  setSel((i) => Math.min(items.length - 1, i + 1));
142
+ } else if (input === "t") {
143
+ onToggleTts();
133
144
  } else if (key.return) {
134
145
  const v = items[sel].value;
135
146
  if (v === "_music") onMusic();
@@ -148,27 +159,47 @@ const ScopeScreen = ({ onNext, onMusic }) => {
148
159
  ),
149
160
  )),
150
161
  ),
162
+ h(Box, { marginTop: 1, marginLeft: 4 },
163
+ h(Text, { color: tts ? "green" : "gray" },
164
+ tts ? "🗣 Voice summary: ON" : "🗣 Voice summary: OFF",
165
+ ),
166
+ h(Text, { dimColor: true }, " (t to toggle)"),
167
+ ),
151
168
  );
152
169
  };
153
170
 
154
171
  // ── Screen: Preset ──────────────────────────────────────────────
155
172
  const PresetScreen = ({ onNext, onBack }) => {
156
- useInput((_, key) => { if (key.escape) onBack(); });
157
-
158
173
  const items = [
159
174
  ...Object.entries(PRESETS).map(([id, p]) => ({
160
175
  label: `${p.icon} ${p.name} — ${p.description}`,
161
176
  value: id,
162
177
  })),
178
+ // separator before these
163
179
  { label: "🔔 System sounds — use built-in OS notification sounds", value: "_system" },
164
- { label: "🕹️ Scan local games — find sounds from your Steam & Epic Games library", value: "_scan" },
180
+ { label: "🕹️ Scan your games library — find sounds from Steam & Epic Games", value: "_scan" },
165
181
  { label: "📁 Custom files — provide your own sound files", value: "_custom" },
166
182
  ];
183
+ const GAP_AT = Object.keys(PRESETS).length; // separator before non-preset options
184
+ const [sel, setSel] = useState(0);
185
+
186
+ useInput((input, key) => {
187
+ if (key.escape) onBack();
188
+ else if (input === "k" || key.upArrow) setSel((i) => Math.max(0, i - 1));
189
+ else if (input === "j" || key.downArrow) setSel((i) => Math.min(items.length - 1, i + 1));
190
+ else if (key.return) onNext(items[sel].value);
191
+ });
167
192
 
168
193
  return h(Box, { flexDirection: "column" },
169
194
  h(Text, { bold: true }, " Choose a sound preset:"),
170
- h(Box, { marginLeft: 2 },
171
- h(SelectInput, { indicatorComponent: Indicator, itemComponent: Item, items, onSelect: (item) => onNext(item.value) }),
195
+ h(Box, { flexDirection: "column", marginLeft: 2 },
196
+ ...items.map((item, i) => h(React.Fragment, { key: item.value },
197
+ i === GAP_AT ? h(Text, { dimColor: true }, "\n ...or pick your own") : null,
198
+ h(Box, null,
199
+ h(Indicator, { isSelected: i === sel }),
200
+ h(Item, { isSelected: i === sel, label: item.label }),
201
+ ),
202
+ )),
172
203
  ),
173
204
  h(NavHint, { back: true }),
174
205
  );
@@ -601,6 +632,16 @@ const GameSoundsScreen = ({ game, sounds, onSelectSound, onDone, onBack }) => {
601
632
  return () => clearInterval(interval);
602
633
  }, [playing]);
603
634
 
635
+ // Parse duration filter: "10s", "<10s", "< 10s", ">5s", "> 5s", "<=10s", ">=5s"
636
+ // (must be before early returns to satisfy React hook rules)
637
+ const durationFilter = useMemo(() => {
638
+ const m = filter.match(/^\s*(<|>|<=|>=)?\s*(\d+(?:\.\d+)?)\s*s\s*$/);
639
+ if (!m) return null;
640
+ const op = m[1] || "<=";
641
+ const val = parseFloat(m[2]);
642
+ return { op, val };
643
+ }, [filter]);
644
+
604
645
  const eventId = eventIds[currentEvent];
605
646
  const eventInfo = EVENTS[eventId];
606
647
  const stepLabel = `(${currentEvent + 1}/${eventIds.length})`;
@@ -759,15 +800,6 @@ const GameSoundsScreen = ({ game, sounds, onSelectSound, onDone, onBack }) => {
759
800
  // Phase 1: Browse and pick files (auto-preview plays on highlight)
760
801
  const filterLower = filter.toLowerCase();
761
802
 
762
- // Parse duration filter: "10s", "<10s", "< 10s", ">5s", "> 5s", "<=10s", ">=5s"
763
- const durationFilter = useMemo(() => {
764
- const m = filter.match(/^\s*(<|>|<=|>=)?\s*(\d+(?:\.\d+)?)\s*s\s*$/);
765
- if (!m) return null;
766
- const op = m[1] || "<=";
767
- const val = parseFloat(m[2]);
768
- return { op, val };
769
- }, [filter]);
770
-
771
803
  const allFileItems = categoryFiles.map((f) => {
772
804
  const dur = fileDurations[f.path];
773
805
  const durStr = dur != null ? ` (${dur}s${dur > MAX_PLAY_SECONDS ? `, preview ${MAX_PLAY_SECONDS}s` : ""})` : "";
@@ -1270,8 +1302,11 @@ const ExtractingScreen = ({ game, onDone, onBack }) => {
1270
1302
  };
1271
1303
 
1272
1304
  // ── Screen: Confirm ─────────────────────────────────────────────
1273
- const ConfirmScreen = ({ scope, sounds, onConfirm, onBack }) => {
1274
- useInput((_, key) => { if (key.escape) onBack(); });
1305
+ const ConfirmScreen = ({ scope, sounds, tts, onToggleTts, onConfirm, onBack }) => {
1306
+ useInput((input, key) => {
1307
+ if (key.escape) onBack();
1308
+ else if (input === "t") onToggleTts();
1309
+ });
1275
1310
 
1276
1311
  const items = [
1277
1312
  { label: "✓ Yes, install!", value: "yes" },
@@ -1289,6 +1324,12 @@ const ConfirmScreen = ({ scope, sounds, onConfirm, onBack }) => {
1289
1324
  `${EVENTS[eid].name} → ${basename(path)}`
1290
1325
  )
1291
1326
  ),
1327
+ h(Box, { marginLeft: 4, marginTop: 1 },
1328
+ h(Text, { color: tts ? "green" : "gray" },
1329
+ tts ? "🗣 Voice summary: ON" : "🗣 Voice summary: OFF",
1330
+ ),
1331
+ h(Text, { dimColor: true }, " (t to toggle — reads a short summary when tasks complete)"),
1332
+ ),
1292
1333
  ),
1293
1334
  h(Box, { marginTop: 1, marginLeft: 2 },
1294
1335
  h(SelectInput, { indicatorComponent: Indicator, itemComponent: Item, items, onSelect: (item) => {
@@ -1301,13 +1342,13 @@ const ConfirmScreen = ({ scope, sounds, onConfirm, onBack }) => {
1301
1342
  };
1302
1343
 
1303
1344
  // ── Screen: Installing ──────────────────────────────────────────
1304
- const InstallingScreen = ({ scope, sounds, onDone }) => {
1345
+ const InstallingScreen = ({ scope, sounds, tts, onDone }) => {
1305
1346
  useEffect(() => {
1306
1347
  const validSounds = {};
1307
1348
  for (const [eventId, path] of Object.entries(sounds)) {
1308
1349
  if (path) validSounds[eventId] = path;
1309
1350
  }
1310
- install({ scope, sounds: validSounds }).then(onDone).catch((err) => {
1351
+ install({ scope, sounds: validSounds, tts }).then(onDone).catch((err) => {
1311
1352
  onDone({ error: err.message });
1312
1353
  });
1313
1354
  }, []);
@@ -1411,6 +1452,7 @@ const InstallApp = () => {
1411
1452
  const [sounds, setSounds] = useState({});
1412
1453
  const [selectedGame, setSelectedGame] = useState(null);
1413
1454
  const [installResult, setInstallResult] = useState(null);
1455
+ const [tts, setTts] = useState(true);
1414
1456
  const [musicFiles, setMusicFiles] = useState([]);
1415
1457
  const [musicGameName, setMusicGameName] = useState(null);
1416
1458
  const [musicShuffle, setMusicShuffle] = useState(false);
@@ -1424,6 +1466,8 @@ const InstallApp = () => {
1424
1466
  switch (screen) {
1425
1467
  case SCREEN.SCOPE:
1426
1468
  return h(ScopeScreen, {
1469
+ tts,
1470
+ onToggleTts: () => setTts((v) => !v),
1427
1471
  onNext: (s) => {
1428
1472
  setScope(s);
1429
1473
  getExistingSounds(s).then((existing) => {
@@ -1534,6 +1578,8 @@ const InstallApp = () => {
1534
1578
  return h(ConfirmScreen, {
1535
1579
  scope,
1536
1580
  sounds,
1581
+ tts,
1582
+ onToggleTts: () => setTts((v) => !v),
1537
1583
  onConfirm: () => setScreen(SCREEN.INSTALLING),
1538
1584
  onBack: () => {
1539
1585
  if (selectedGame) setScreen(SCREEN.GAME_SOUNDS);
@@ -1545,6 +1591,7 @@ const InstallApp = () => {
1545
1591
  return h(InstallingScreen, {
1546
1592
  scope,
1547
1593
  sounds,
1594
+ tts,
1548
1595
  onDone: (result) => {
1549
1596
  setInstallResult(result);
1550
1597
  setScreen(SCREEN.DONE);
package/src/installer.js CHANGED
@@ -20,8 +20,9 @@ function getTargetDir(scope) {
20
20
  * @param {object} options
21
21
  * @param {string} options.scope - "global" or "project"
22
22
  * @param {Record<string, string>} options.sounds - Map of event ID -> source sound file path
23
+ * @param {boolean} [options.tts] - Enable TTS voice summary on task complete
23
24
  */
24
- export async function install({ scope, sounds }) {
25
+ export async function install({ scope, sounds, tts = false }) {
25
26
  const claudeDir = getTargetDir(scope);
26
27
  const soundsDir = join(claudeDir, "sounds");
27
28
  const settingsFile = join(claudeDir, "settings.json");
@@ -60,7 +61,9 @@ export async function install({ scope, sounds }) {
60
61
  if (!event) continue;
61
62
 
62
63
  const hookEvent = event.hookEvent;
63
- const playCommand = getHookPlayCommand(soundPath);
64
+ // Enable TTS only for the "stop" event (task complete)
65
+ const useTts = tts && eventId === "stop";
66
+ const playCommand = getHookPlayCommand(soundPath, { tts: useTts });
64
67
 
65
68
  // Check if there's already a klaudio hook for this event
66
69
  if (!settings.hooks[hookEvent]) {
package/src/player.js CHANGED
@@ -363,16 +363,52 @@ export async function processSound(filePath) {
363
363
  }
364
364
 
365
365
  /**
366
- * Generate the shell command string for use in Claude Code hooks.
366
+ * Handle the "play" subcommand: play a sound file and optionally speak a TTS summary.
367
+ * Reads hook JSON from stdin to get last_assistant_message for TTS.
367
368
  */
368
- export function getHookPlayCommand(soundFilePath) {
369
- const normalized = soundFilePath.replace(/\\/g, "/");
370
- const ext = extname(normalized).toLowerCase();
371
- const needsFfplay = !MEDIA_PLAYER_FORMATS.has(ext);
369
+ export async function handlePlayCommand(args) {
370
+ const soundFile = args.find((a) => !a.startsWith("-"));
371
+ const tts = args.includes("--tts");
372
372
 
373
- if (needsFfplay) {
374
- return `if command -v ffplay &>/dev/null; then ffplay -nodisp -autoexit -loglevel quiet "${normalized}" & elif [[ "$OSTYPE" == "darwin"* ]]; then afplay "${normalized}" & elif [[ "$OSTYPE" == "msys"* ]] || [[ "$OSTYPE" == "cygwin"* ]]; then powershell.exe -NoProfile -Command "Add-Type -AssemblyName PresentationCore; \\$p = New-Object System.Windows.Media.MediaPlayer; \\$p.Open([System.Uri]::new('$(cygpath -w "${normalized}")')); Start-Sleep -Milliseconds 200; \\$p.Play(); Start-Sleep -Seconds 2" & else aplay "${normalized}" & fi`;
373
+ // Read stdin (hook JSON) non-blocking
374
+ let hookData = {};
375
+ try {
376
+ const chunks = [];
377
+ process.stdin.setEncoding("utf-8");
378
+ // Read whatever is available with a short timeout
379
+ const stdinData = await new Promise((res) => {
380
+ const timer = setTimeout(() => { process.stdin.pause(); res(chunks.join("")); }, 500);
381
+ process.stdin.on("data", (chunk) => chunks.push(chunk));
382
+ process.stdin.on("end", () => { clearTimeout(timer); res(chunks.join("")); });
383
+ process.stdin.resume();
384
+ });
385
+ if (stdinData.trim()) hookData = JSON.parse(stdinData);
386
+ } catch { /* no stdin or invalid JSON */ }
387
+
388
+ // Play sound (fire and forget, don't wait)
389
+ const soundPromise = soundFile
390
+ ? playSoundWithCancel(soundFile).promise.catch(() => {})
391
+ : Promise.resolve();
392
+
393
+ // TTS: speak first 1-2 sentences of last_assistant_message
394
+ if (tts && hookData.last_assistant_message) {
395
+ const msg = hookData.last_assistant_message;
396
+ // Extract first sentence only
397
+ const sentences = msg.match(/[^.!?]*[.!?]/g);
398
+ const summary = sentences ? sentences[0].trim() : msg.slice(0, 100);
399
+ await soundPromise;
400
+ const { speak } = await import("./tts.js");
401
+ await speak(summary);
402
+ } else {
403
+ await soundPromise;
375
404
  }
405
+ }
376
406
 
377
- return `if [[ "$OSTYPE" == "darwin"* ]]; then afplay "${normalized}" & elif [[ "$OSTYPE" == "msys"* ]] || [[ "$OSTYPE" == "cygwin"* ]]; then powershell.exe -NoProfile -Command "Add-Type -AssemblyName PresentationCore; \\$p = New-Object System.Windows.Media.MediaPlayer; \\$p.Open([System.Uri]::new('$(cygpath -w "${normalized}")')); Start-Sleep -Milliseconds 200; \\$p.Play(); Start-Sleep -Seconds 2" & else aplay "${normalized}" & fi`;
407
+ /**
408
+ * Generate the shell command string for use in Claude Code hooks.
409
+ */
410
+ export function getHookPlayCommand(soundFilePath, { tts = false } = {}) {
411
+ const normalized = soundFilePath.replace(/\\/g, "/");
412
+ const ttsFlag = tts ? " --tts" : "";
413
+ return `npx klaudio play "${normalized}"${ttsFlag}`;
378
414
  }
package/src/tts.js ADDED
@@ -0,0 +1,220 @@
1
+ import { execFile, spawn } from "node:child_process";
2
+ import { mkdir, stat, rename, chmod } from "node:fs/promises";
3
+ import { createWriteStream } from "node:fs";
4
+ import { join, basename } from "node:path";
5
+ import { homedir, platform, arch, tmpdir } from "node:os";
6
+ import { pipeline } from "node:stream/promises";
7
+ import { createHash } from "node:crypto";
8
+
9
+ const PIPER_VERSION = "2023.11.14-2";
10
+ const VOICE_NAME = "en_GB-alan-medium";
11
+ const VOICE_SAMPLE_RATE = 22050;
12
+
13
+ const PIPER_DIR = join(homedir(), ".klaudio", "piper");
14
+
15
+ /**
16
+ * Get the piper release asset name for the current platform.
17
+ */
18
+ function getPiperAssetName() {
19
+ const os = platform();
20
+ const a = arch();
21
+
22
+ if (os === "win32") return "piper_windows_amd64.zip";
23
+ if (os === "darwin") return a === "arm64" ? "piper_macos_aarch64.tar.gz" : "piper_macos_x64.tar.gz";
24
+ // Linux
25
+ if (a === "arm64" || a === "aarch64") return "piper_linux_aarch64.tar.gz";
26
+ return "piper_linux_x86_64.tar.gz";
27
+ }
28
+
29
+ /**
30
+ * Get the piper binary path.
31
+ */
32
+ function getPiperBinPath() {
33
+ const bin = platform() === "win32" ? "piper.exe" : "piper";
34
+ return join(PIPER_DIR, "piper", bin);
35
+ }
36
+
37
+ /**
38
+ * Get the voice model path.
39
+ */
40
+ function getVoiceModelPath() {
41
+ return join(PIPER_DIR, `${VOICE_NAME}.onnx`);
42
+ }
43
+
44
+ /**
45
+ * Download a file from a URL to a local path.
46
+ */
47
+ async function downloadFile(url, destPath, onProgress) {
48
+ const res = await fetch(url, { redirect: "follow" });
49
+ if (!res.ok) throw new Error(`Download failed: ${res.status} ${res.statusText}`);
50
+ const total = parseInt(res.headers.get("content-length") || "0", 10);
51
+ let downloaded = 0;
52
+
53
+ const fileStream = createWriteStream(destPath);
54
+ const reader = res.body.getReader();
55
+
56
+ // Manual stream piping with progress
57
+ while (true) {
58
+ const { done, value } = await reader.read();
59
+ if (done) break;
60
+ fileStream.write(value);
61
+ downloaded += value.length;
62
+ if (onProgress && total > 0) {
63
+ onProgress(Math.round((downloaded / total) * 100));
64
+ }
65
+ }
66
+
67
+ fileStream.end();
68
+ await new Promise((resolve, reject) => {
69
+ fileStream.on("finish", resolve);
70
+ fileStream.on("error", reject);
71
+ });
72
+ }
73
+
74
+ /**
75
+ * Extract a .tar.gz or .zip archive.
76
+ */
77
+ async function extractArchive(archivePath, destDir) {
78
+ const os = platform();
79
+
80
+ if (archivePath.endsWith(".zip")) {
81
+ if (os === "win32") {
82
+ // Use PowerShell to extract on Windows
83
+ await new Promise((resolve, reject) => {
84
+ execFile("powershell.exe", [
85
+ "-NoProfile", "-Command",
86
+ `Expand-Archive -Path '${archivePath}' -DestinationPath '${destDir}' -Force`,
87
+ ], { windowsHide: true, timeout: 60000 }, (err) => err ? reject(err) : resolve());
88
+ });
89
+ } else {
90
+ await new Promise((resolve, reject) => {
91
+ execFile("unzip", ["-o", archivePath, "-d", destDir], { timeout: 60000 }, (err) => err ? reject(err) : resolve());
92
+ });
93
+ }
94
+ } else {
95
+ // tar.gz
96
+ await new Promise((resolve, reject) => {
97
+ execFile("tar", ["xzf", archivePath, "-C", destDir], { timeout: 60000 }, (err) => err ? reject(err) : resolve());
98
+ });
99
+ }
100
+ }
101
+
102
+ /**
103
+ * Ensure piper binary is available, downloading if needed.
104
+ * Returns the path to the piper executable.
105
+ */
106
+ export async function ensurePiper(onProgress) {
107
+ const binPath = getPiperBinPath();
108
+
109
+ // Check if already downloaded
110
+ try {
111
+ await stat(binPath);
112
+ return binPath;
113
+ } catch { /* needs download */ }
114
+
115
+ try {
116
+ await mkdir(PIPER_DIR, { recursive: true });
117
+
118
+ const asset = getPiperAssetName();
119
+ const url = `https://github.com/rhasspy/piper/releases/download/${PIPER_VERSION}/${asset}`;
120
+ const archivePath = join(PIPER_DIR, asset);
121
+
122
+ if (onProgress) onProgress(`Downloading piper TTS...`);
123
+ await downloadFile(url, archivePath, (pct) => {
124
+ if (onProgress) onProgress(`Downloading piper TTS... ${pct}%`);
125
+ });
126
+
127
+ if (onProgress) onProgress("Extracting piper...");
128
+ await extractArchive(archivePath, PIPER_DIR);
129
+
130
+ // Make executable on Unix
131
+ if (platform() !== "win32") {
132
+ try { await chmod(binPath, 0o755); } catch { /* ignore */ }
133
+ }
134
+
135
+ return binPath;
136
+ } catch (err) {
137
+ // Clean up partial downloads
138
+ try { const { unlink } = await import("node:fs/promises"); await unlink(join(PIPER_DIR, getPiperAssetName())); } catch { /* ignore */ }
139
+ throw new Error(`Failed to download piper: ${err.message}`);
140
+ }
141
+ }
142
+
143
+ /**
144
+ * Ensure voice model is available, downloading if needed.
145
+ * Returns the path to the .onnx model file.
146
+ */
147
+ export async function ensureVoiceModel(onProgress) {
148
+ const modelPath = getVoiceModelPath();
149
+ const configPath = modelPath + ".json";
150
+
151
+ // Check if already downloaded
152
+ try {
153
+ await stat(modelPath);
154
+ await stat(configPath);
155
+ return modelPath;
156
+ } catch { /* needs download */ }
157
+
158
+ try {
159
+ await mkdir(PIPER_DIR, { recursive: true });
160
+
161
+ const baseUrl = `https://huggingface.co/rhasspy/piper-voices/resolve/v1.0.0/en/en_GB/alan/medium`;
162
+
163
+ if (onProgress) onProgress("Downloading voice model...");
164
+ await downloadFile(`${baseUrl}/${VOICE_NAME}.onnx`, modelPath, (pct) => {
165
+ if (onProgress) onProgress(`Downloading voice model... ${pct}%`);
166
+ });
167
+
168
+ if (onProgress) onProgress("Downloading voice config...");
169
+ await downloadFile(`${baseUrl}/${VOICE_NAME}.onnx.json`, configPath);
170
+
171
+ return modelPath;
172
+ } catch (err) {
173
+ // Clean up partial downloads
174
+ const { unlink } = await import("node:fs/promises");
175
+ try { await unlink(modelPath); } catch { /* ignore */ }
176
+ try { await unlink(configPath); } catch { /* ignore */ }
177
+ throw new Error(`Failed to download voice model: ${err.message}`);
178
+ }
179
+ }
180
+
181
+ /**
182
+ * Speak text using Piper TTS.
183
+ * Auto-downloads piper and voice model on first use.
184
+ * Returns a promise that resolves when speech is done.
185
+ */
186
+ export async function speak(text, onProgress) {
187
+ if (!text) return;
188
+
189
+ let piperBin, modelPath;
190
+ try {
191
+ [piperBin, modelPath] = await Promise.all([
192
+ ensurePiper(onProgress),
193
+ ensureVoiceModel(onProgress),
194
+ ]);
195
+ } catch {
196
+ // TTS unavailable (download failed, offline, etc.) — skip silently
197
+ return;
198
+ }
199
+
200
+ // Generate to temp wav file
201
+ const hash = createHash("md5").update(text).digest("hex").slice(0, 8);
202
+ const outPath = join(tmpdir(), `klaudio-tts-${hash}.wav`);
203
+
204
+ await new Promise((resolve, reject) => {
205
+ const child = execFile(piperBin, [
206
+ "--model", modelPath,
207
+ "--output_file", outPath,
208
+ ], { windowsHide: true, timeout: 15000 }, (err) => {
209
+ if (err) reject(err);
210
+ else resolve();
211
+ });
212
+ // Feed text via stdin
213
+ child.stdin.write(text);
214
+ child.stdin.end();
215
+ });
216
+
217
+ // Play the generated wav
218
+ const { playSoundWithCancel } = await import("./player.js");
219
+ await playSoundWithCancel(outPath, { maxSeconds: 0 }).promise.catch(() => {});
220
+ }