klaudio 0.11.2 → 0.11.3

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/src/tts.js CHANGED
@@ -1,391 +1,391 @@
1
- import { execFile } from "node:child_process";
2
- import { mkdir, stat, chmod, writeFile as fsWriteFile } 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 { createHash } from "node:crypto";
7
-
8
- const PIPER_VERSION = "2023.11.14-2";
9
- const VOICE_NAME = "en_GB-alan-medium";
10
-
11
- const PIPER_DIR = join(homedir(), ".klaudio", "piper");
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
- const KOKORO_DIR = join(homedir(), ".klaudio", "kokoro");
44
-
45
- /**
46
- * Ensure kokoro-js is installed in ~/.klaudio/kokoro.
47
- * Installs on first use via npm.
48
- */
49
- async function ensureKokoroInstalled() {
50
- const kokoroMod = join(KOKORO_DIR, "node_modules", "kokoro-js");
51
- try {
52
- await stat(join(kokoroMod, "package.json"));
53
- return; // already installed
54
- } catch { /* needs install */ }
55
-
56
- await mkdir(KOKORO_DIR, { recursive: true });
57
- await fsWriteFile(join(KOKORO_DIR, "package.json"), '{"private":true}', "utf-8");
58
-
59
- const npmCmd = platform() === "win32" ? "npm.cmd" : "npm";
60
- await new Promise((resolve, reject) => {
61
- execFile(npmCmd, ["install", "kokoro-js"], {
62
- cwd: KOKORO_DIR,
63
- windowsHide: true,
64
- timeout: 180000,
65
- }, (err) => err ? reject(err) : resolve());
66
- });
67
- }
68
-
69
- /**
70
- * Check if kokoro-js is available without loading the model.
71
- */
72
- export async function isKokoroAvailable() {
73
- try {
74
- await importKokoro();
75
- return true;
76
- } catch { return false; }
77
- }
78
-
79
- /**
80
- * Try to import kokoro-js from various locations.
81
- */
82
- async function importKokoro() {
83
- // 1. Try local ~/.klaudio/kokoro install
84
- try {
85
- const { createRequire } = await import("node:module");
86
- const req = createRequire(join(KOKORO_DIR, "node_modules", "kokoro-js", "package.json"));
87
- return req("kokoro-js");
88
- } catch { /* not there */ }
89
-
90
- // 2. Try global/project import (dev environment or globally installed)
91
- try {
92
- return await import("kokoro-js");
93
- } catch { /* not available */ }
94
-
95
- throw new Error("kokoro-js not available");
96
- }
97
-
98
- /**
99
- * Load the Kokoro TTS model (singleton).
100
- * Auto-installs kokoro-js on first use, then downloads ~25MB model on first generate.
101
- */
102
- async function getKokoro() {
103
- if (kokoroInstance) return kokoroInstance;
104
- if (kokoroLoadPromise) return kokoroLoadPromise;
105
-
106
- kokoroLoadPromise = (async () => {
107
- // Try import first (already installed?), otherwise install then import
108
- let mod;
109
- try {
110
- mod = await importKokoro();
111
- } catch {
112
- await ensureKokoroInstalled();
113
- mod = await importKokoro();
114
- }
115
-
116
- const { KokoroTTS } = mod;
117
- kokoroInstance = await KokoroTTS.from_pretrained(
118
- "onnx-community/Kokoro-82M-v1.0-ONNX",
119
- { dtype: "q4", device: "cpu" },
120
- );
121
- return kokoroInstance;
122
- })();
123
-
124
- try {
125
- return await kokoroLoadPromise;
126
- } catch (err) {
127
- kokoroLoadPromise = null;
128
- throw err;
129
- }
130
- }
131
-
132
- /**
133
- * Speak text using Kokoro TTS.
134
- * Returns true if successful, false if Kokoro is unavailable.
135
- */
136
- async function speakKokoro(text, voice) {
137
- const tts = await getKokoro();
138
- const voiceId = voice || KOKORO_DEFAULT_VOICE;
139
-
140
- const audio = await tts.generate(text, { voice: voiceId, speed: 1.0 });
141
-
142
- // Save to temp wav and play
143
- const hash = createHash("md5").update(text + voiceId).digest("hex").slice(0, 8);
144
- const outPath = join(tmpdir(), `klaudio-kokoro-${hash}.wav`);
145
- audio.save(outPath);
146
-
147
- const { playSoundWithCancel } = await import("./player.js");
148
- await playSoundWithCancel(outPath, { maxSeconds: 0 }).promise.catch(() => {});
149
- }
150
-
151
- // ── Piper TTS (fallback engine) ─────────────────────────────────
152
-
153
- function getPiperAssetName() {
154
- const os = platform();
155
- const a = arch();
156
- if (os === "win32") return "piper_windows_amd64.zip";
157
- if (os === "darwin") return a === "arm64" ? "piper_macos_aarch64.tar.gz" : "piper_macos_x64.tar.gz";
158
- if (a === "arm64" || a === "aarch64") return "piper_linux_aarch64.tar.gz";
159
- return "piper_linux_x86_64.tar.gz";
160
- }
161
-
162
- function getPiperBinPath() {
163
- const bin = platform() === "win32" ? "piper.exe" : "piper";
164
- return join(PIPER_DIR, "piper", bin);
165
- }
166
-
167
- function getVoiceModelPath() {
168
- return join(PIPER_DIR, `${VOICE_NAME}.onnx`);
169
- }
170
-
171
- async function downloadFile(url, destPath, onProgress) {
172
- const res = await fetch(url, { redirect: "follow" });
173
- if (!res.ok) throw new Error(`Download failed: ${res.status} ${res.statusText}`);
174
- const total = parseInt(res.headers.get("content-length") || "0", 10);
175
- let downloaded = 0;
176
-
177
- const fileStream = createWriteStream(destPath);
178
- const reader = res.body.getReader();
179
-
180
- while (true) {
181
- const { done, value } = await reader.read();
182
- if (done) break;
183
- fileStream.write(value);
184
- downloaded += value.length;
185
- if (onProgress && total > 0) {
186
- onProgress(Math.round((downloaded / total) * 100));
187
- }
188
- }
189
-
190
- fileStream.end();
191
- await new Promise((resolve, reject) => {
192
- fileStream.on("finish", resolve);
193
- fileStream.on("error", reject);
194
- });
195
- }
196
-
197
- async function extractArchive(archivePath, destDir) {
198
- const os = platform();
199
- if (archivePath.endsWith(".zip")) {
200
- if (os === "win32") {
201
- await new Promise((resolve, reject) => {
202
- execFile("powershell.exe", [
203
- "-NoProfile", "-Command",
204
- `Expand-Archive -Path '${archivePath}' -DestinationPath '${destDir}' -Force`,
205
- ], { windowsHide: true, timeout: 60000 }, (err) => err ? reject(err) : resolve());
206
- });
207
- } else {
208
- await new Promise((resolve, reject) => {
209
- execFile("unzip", ["-o", archivePath, "-d", destDir], { timeout: 60000 }, (err) => err ? reject(err) : resolve());
210
- });
211
- }
212
- } else {
213
- await new Promise((resolve, reject) => {
214
- execFile("tar", ["xzf", archivePath, "-C", destDir], { timeout: 60000 }, (err) => err ? reject(err) : resolve());
215
- });
216
- }
217
- }
218
-
219
- export async function ensurePiper(onProgress) {
220
- const binPath = getPiperBinPath();
221
- try {
222
- await stat(binPath);
223
- return binPath;
224
- } catch { /* needs download */ }
225
-
226
- try {
227
- await mkdir(PIPER_DIR, { recursive: true });
228
- const asset = getPiperAssetName();
229
- const url = `https://github.com/rhasspy/piper/releases/download/${PIPER_VERSION}/${asset}`;
230
- const archivePath = join(PIPER_DIR, asset);
231
-
232
- if (onProgress) onProgress(`Downloading piper TTS...`);
233
- await downloadFile(url, archivePath, (pct) => {
234
- if (onProgress) onProgress(`Downloading piper TTS... ${pct}%`);
235
- });
236
-
237
- if (onProgress) onProgress("Extracting piper...");
238
- await extractArchive(archivePath, PIPER_DIR);
239
-
240
- if (platform() !== "win32") {
241
- try { await chmod(binPath, 0o755); } catch { /* ignore */ }
242
- }
243
-
244
- return binPath;
245
- } catch (err) {
246
- try { const { unlink } = await import("node:fs/promises"); await unlink(join(PIPER_DIR, getPiperAssetName())); } catch { /* ignore */ }
247
- throw new Error(`Failed to download piper: ${err.message}`);
248
- }
249
- }
250
-
251
- export async function ensureVoiceModel(onProgress) {
252
- const modelPath = getVoiceModelPath();
253
- const configPath = modelPath + ".json";
254
- try {
255
- await stat(modelPath);
256
- await stat(configPath);
257
- return modelPath;
258
- } catch { /* needs download */ }
259
-
260
- try {
261
- await mkdir(PIPER_DIR, { recursive: true });
262
- const baseUrl = `https://huggingface.co/rhasspy/piper-voices/resolve/v1.0.0/en/en_GB/alan/medium`;
263
-
264
- if (onProgress) onProgress("Downloading voice model...");
265
- await downloadFile(`${baseUrl}/${VOICE_NAME}.onnx`, modelPath, (pct) => {
266
- if (onProgress) onProgress(`Downloading voice model... ${pct}%`);
267
- });
268
-
269
- if (onProgress) onProgress("Downloading voice config...");
270
- await downloadFile(`${baseUrl}/${VOICE_NAME}.onnx.json`, configPath);
271
-
272
- return modelPath;
273
- } catch (err) {
274
- const { unlink } = await import("node:fs/promises");
275
- try { await unlink(modelPath); } catch { /* ignore */ }
276
- try { await unlink(configPath); } catch { /* ignore */ }
277
- throw new Error(`Failed to download voice model: ${err.message}`);
278
- }
279
- }
280
-
281
- async function speakPiper(text, onProgress) {
282
- let piperBin, modelPath;
283
- try {
284
- [piperBin, modelPath] = await Promise.all([
285
- ensurePiper(onProgress),
286
- ensureVoiceModel(onProgress),
287
- ]);
288
- } catch {
289
- return;
290
- }
291
-
292
- const hash = createHash("md5").update(text).digest("hex").slice(0, 8);
293
- const outPath = join(tmpdir(), `klaudio-tts-${hash}.wav`);
294
-
295
- try {
296
- await new Promise((resolve, reject) => {
297
- const child = execFile(piperBin, [
298
- "--model", modelPath,
299
- "--output_file", outPath,
300
- "--sentence_silence", "0.5",
301
- ], { windowsHide: true, timeout: 15000 }, (err) => {
302
- if (err) reject(err);
303
- else resolve();
304
- });
305
- child.stdin.write(text);
306
- child.stdin.end();
307
- });
308
-
309
- const { playSoundWithCancel } = await import("./player.js");
310
- await playSoundWithCancel(outPath, { maxSeconds: 0 }).promise.catch(() => {});
311
- } catch {
312
- // Piper failed — skip silently
313
- }
314
- }
315
-
316
- // ── macOS fallback ──────────────────────────────────────────────
317
-
318
- function speakMacOS(text) {
319
- return new Promise((resolve) => {
320
- execFile("say", ["-v", "Daniel", text], { timeout: 15000 }, () => resolve());
321
- });
322
- }
323
-
324
- // ── Public API ──────────────────────────────────────────────────
325
-
326
- let speaking = false;
327
- const TTS_LOCK = join(tmpdir(), ".klaudio-tts-lock");
328
-
329
- /**
330
- * Try to acquire a cross-process TTS lock.
331
- * Returns true if acquired, false if another process is speaking.
332
- * Stale locks (>30s) are automatically cleaned up.
333
- */
334
- async function acquireTTSLock() {
335
- try {
336
- const lockStat = await stat(TTS_LOCK);
337
- if (Date.now() - lockStat.mtimeMs < 30000) return false; // fresh lock, skip
338
- } catch { /* no lock file, good */ }
339
- try {
340
- await fsWriteFile(TTS_LOCK, String(process.pid), "utf-8");
341
- return true;
342
- } catch { return false; }
343
- }
344
-
345
- async function releaseTTSLock() {
346
- try { const { unlink } = await import("node:fs/promises"); await unlink(TTS_LOCK); } catch { /* ignore */ }
347
- }
348
-
349
- /**
350
- * Speak text using the best available TTS engine.
351
- * Priority: Kokoro (GPU/CPU) → Piper → macOS say
352
- * Only one speak() call runs at a time — concurrent calls are skipped.
353
- *
354
- * @param {string} text - Text to speak
355
- * @param {object} [options]
356
- * @param {string} [options.voice] - Kokoro voice ID (e.g. "af_heart")
357
- * @param {Function} [options.onProgress] - Progress callback for downloads
358
- */
359
- export async function speak(text, options = {}) {
360
- if (!text) return;
361
- if (speaking) return; // in-process mutex
362
- if (!await acquireTTSLock()) return; // cross-process mutex
363
- speaking = true;
364
-
365
- try {
366
- const { voice, onProgress } = typeof options === "function"
367
- ? { voice: null, onProgress: options } // backwards compat: speak(text, onProgress)
368
- : options;
369
-
370
- // Try Kokoro first (works on all platforms, best quality)
371
- try {
372
- await speakKokoro(text, voice);
373
- return;
374
- } catch {
375
- // Kokoro unavailable fall through
376
- }
377
-
378
- // macOS: use built-in `say`
379
- if (platform() === "darwin") {
380
- return speakMacOS(text);
381
- }
382
-
383
- // Fallback: Piper
384
- return speakPiper(text, onProgress);
385
- } finally {
386
- speaking = false;
387
- await releaseTTSLock();
388
- }
389
- }
390
-
391
- export { KOKORO_PRESET_VOICES, KOKORO_VOICES, KOKORO_DEFAULT_VOICE };
1
+ import { execFile } from "node:child_process";
2
+ import { mkdir, stat, chmod, writeFile as fsWriteFile } 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 { createHash } from "node:crypto";
7
+
8
+ const PIPER_VERSION = "2023.11.14-2";
9
+ const VOICE_NAME = "en_GB-alan-medium";
10
+
11
+ const PIPER_DIR = join(homedir(), ".klaudio", "piper");
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
+ const KOKORO_DIR = join(homedir(), ".klaudio", "kokoro");
44
+
45
+ /**
46
+ * Ensure kokoro-js is installed in ~/.klaudio/kokoro.
47
+ * Installs on first use via npm.
48
+ */
49
+ async function ensureKokoroInstalled() {
50
+ const kokoroMod = join(KOKORO_DIR, "node_modules", "kokoro-js");
51
+ try {
52
+ await stat(join(kokoroMod, "package.json"));
53
+ return; // already installed
54
+ } catch { /* needs install */ }
55
+
56
+ await mkdir(KOKORO_DIR, { recursive: true });
57
+ await fsWriteFile(join(KOKORO_DIR, "package.json"), '{"private":true}', "utf-8");
58
+
59
+ const npmCmd = platform() === "win32" ? "npm.cmd" : "npm";
60
+ await new Promise((resolve, reject) => {
61
+ execFile(npmCmd, ["install", "kokoro-js"], {
62
+ cwd: KOKORO_DIR,
63
+ windowsHide: true,
64
+ timeout: 180000,
65
+ }, (err) => err ? reject(err) : resolve());
66
+ });
67
+ }
68
+
69
+ /**
70
+ * Check if kokoro-js is available without loading the model.
71
+ */
72
+ export async function isKokoroAvailable() {
73
+ try {
74
+ await importKokoro();
75
+ return true;
76
+ } catch { return false; }
77
+ }
78
+
79
+ /**
80
+ * Try to import kokoro-js from various locations.
81
+ */
82
+ async function importKokoro() {
83
+ // 1. Try local ~/.klaudio/kokoro install
84
+ try {
85
+ const { createRequire } = await import("node:module");
86
+ const req = createRequire(join(KOKORO_DIR, "node_modules", "kokoro-js", "package.json"));
87
+ return req("kokoro-js");
88
+ } catch { /* not there */ }
89
+
90
+ // 2. Try global/project import (dev environment or globally installed)
91
+ try {
92
+ return await import("kokoro-js");
93
+ } catch { /* not available */ }
94
+
95
+ throw new Error("kokoro-js not available");
96
+ }
97
+
98
+ /**
99
+ * Load the Kokoro TTS model (singleton).
100
+ * Auto-installs kokoro-js on first use, then downloads ~25MB model on first generate.
101
+ */
102
+ async function getKokoro() {
103
+ if (kokoroInstance) return kokoroInstance;
104
+ if (kokoroLoadPromise) return kokoroLoadPromise;
105
+
106
+ kokoroLoadPromise = (async () => {
107
+ // Try import first (already installed?), otherwise install then import
108
+ let mod;
109
+ try {
110
+ mod = await importKokoro();
111
+ } catch {
112
+ await ensureKokoroInstalled();
113
+ mod = await importKokoro();
114
+ }
115
+
116
+ const { KokoroTTS } = mod;
117
+ kokoroInstance = await KokoroTTS.from_pretrained(
118
+ "onnx-community/Kokoro-82M-v1.0-ONNX",
119
+ { dtype: "q4", device: "cpu" },
120
+ );
121
+ return kokoroInstance;
122
+ })();
123
+
124
+ try {
125
+ return await kokoroLoadPromise;
126
+ } catch (err) {
127
+ kokoroLoadPromise = null;
128
+ throw err;
129
+ }
130
+ }
131
+
132
+ /**
133
+ * Speak text using Kokoro TTS.
134
+ * Returns true if successful, false if Kokoro is unavailable.
135
+ */
136
+ async function speakKokoro(text, voice) {
137
+ const tts = await getKokoro();
138
+ const voiceId = voice || KOKORO_DEFAULT_VOICE;
139
+
140
+ const audio = await tts.generate(text, { voice: voiceId, speed: 1.0 });
141
+
142
+ // Save to temp wav and play
143
+ const hash = createHash("md5").update(text + voiceId).digest("hex").slice(0, 8);
144
+ const outPath = join(tmpdir(), `klaudio-kokoro-${hash}.wav`);
145
+ audio.save(outPath);
146
+
147
+ const { playSoundWithCancel } = await import("./player.js");
148
+ await playSoundWithCancel(outPath, { maxSeconds: 0 }).promise.catch(() => {});
149
+ }
150
+
151
+ // ── Piper TTS (fallback engine) ─────────────────────────────────
152
+
153
+ function getPiperAssetName() {
154
+ const os = platform();
155
+ const a = arch();
156
+ if (os === "win32") return "piper_windows_amd64.zip";
157
+ if (os === "darwin") return a === "arm64" ? "piper_macos_aarch64.tar.gz" : "piper_macos_x64.tar.gz";
158
+ if (a === "arm64" || a === "aarch64") return "piper_linux_aarch64.tar.gz";
159
+ return "piper_linux_x86_64.tar.gz";
160
+ }
161
+
162
+ function getPiperBinPath() {
163
+ const bin = platform() === "win32" ? "piper.exe" : "piper";
164
+ return join(PIPER_DIR, "piper", bin);
165
+ }
166
+
167
+ function getVoiceModelPath() {
168
+ return join(PIPER_DIR, `${VOICE_NAME}.onnx`);
169
+ }
170
+
171
+ async function downloadFile(url, destPath, onProgress) {
172
+ const res = await fetch(url, { redirect: "follow" });
173
+ if (!res.ok) throw new Error(`Download failed: ${res.status} ${res.statusText}`);
174
+ const total = parseInt(res.headers.get("content-length") || "0", 10);
175
+ let downloaded = 0;
176
+
177
+ const fileStream = createWriteStream(destPath);
178
+ const reader = res.body.getReader();
179
+
180
+ while (true) {
181
+ const { done, value } = await reader.read();
182
+ if (done) break;
183
+ fileStream.write(value);
184
+ downloaded += value.length;
185
+ if (onProgress && total > 0) {
186
+ onProgress(Math.round((downloaded / total) * 100));
187
+ }
188
+ }
189
+
190
+ fileStream.end();
191
+ await new Promise((resolve, reject) => {
192
+ fileStream.on("finish", resolve);
193
+ fileStream.on("error", reject);
194
+ });
195
+ }
196
+
197
+ async function extractArchive(archivePath, destDir) {
198
+ const os = platform();
199
+ if (archivePath.endsWith(".zip")) {
200
+ if (os === "win32") {
201
+ await new Promise((resolve, reject) => {
202
+ execFile("powershell.exe", [
203
+ "-NoProfile", "-Command",
204
+ `Expand-Archive -Path '${archivePath}' -DestinationPath '${destDir}' -Force`,
205
+ ], { windowsHide: true, timeout: 60000 }, (err) => err ? reject(err) : resolve());
206
+ });
207
+ } else {
208
+ await new Promise((resolve, reject) => {
209
+ execFile("unzip", ["-o", archivePath, "-d", destDir], { timeout: 60000 }, (err) => err ? reject(err) : resolve());
210
+ });
211
+ }
212
+ } else {
213
+ await new Promise((resolve, reject) => {
214
+ execFile("tar", ["xzf", archivePath, "-C", destDir], { timeout: 60000 }, (err) => err ? reject(err) : resolve());
215
+ });
216
+ }
217
+ }
218
+
219
+ export async function ensurePiper(onProgress) {
220
+ const binPath = getPiperBinPath();
221
+ try {
222
+ await stat(binPath);
223
+ return binPath;
224
+ } catch { /* needs download */ }
225
+
226
+ try {
227
+ await mkdir(PIPER_DIR, { recursive: true });
228
+ const asset = getPiperAssetName();
229
+ const url = `https://github.com/rhasspy/piper/releases/download/${PIPER_VERSION}/${asset}`;
230
+ const archivePath = join(PIPER_DIR, asset);
231
+
232
+ if (onProgress) onProgress(`Downloading piper TTS...`);
233
+ await downloadFile(url, archivePath, (pct) => {
234
+ if (onProgress) onProgress(`Downloading piper TTS... ${pct}%`);
235
+ });
236
+
237
+ if (onProgress) onProgress("Extracting piper...");
238
+ await extractArchive(archivePath, PIPER_DIR);
239
+
240
+ if (platform() !== "win32") {
241
+ try { await chmod(binPath, 0o755); } catch { /* ignore */ }
242
+ }
243
+
244
+ return binPath;
245
+ } catch (err) {
246
+ try { const { unlink } = await import("node:fs/promises"); await unlink(join(PIPER_DIR, getPiperAssetName())); } catch { /* ignore */ }
247
+ throw new Error(`Failed to download piper: ${err.message}`);
248
+ }
249
+ }
250
+
251
+ export async function ensureVoiceModel(onProgress) {
252
+ const modelPath = getVoiceModelPath();
253
+ const configPath = modelPath + ".json";
254
+ try {
255
+ await stat(modelPath);
256
+ await stat(configPath);
257
+ return modelPath;
258
+ } catch { /* needs download */ }
259
+
260
+ try {
261
+ await mkdir(PIPER_DIR, { recursive: true });
262
+ const baseUrl = `https://huggingface.co/rhasspy/piper-voices/resolve/v1.0.0/en/en_GB/alan/medium`;
263
+
264
+ if (onProgress) onProgress("Downloading voice model...");
265
+ await downloadFile(`${baseUrl}/${VOICE_NAME}.onnx`, modelPath, (pct) => {
266
+ if (onProgress) onProgress(`Downloading voice model... ${pct}%`);
267
+ });
268
+
269
+ if (onProgress) onProgress("Downloading voice config...");
270
+ await downloadFile(`${baseUrl}/${VOICE_NAME}.onnx.json`, configPath);
271
+
272
+ return modelPath;
273
+ } catch (err) {
274
+ const { unlink } = await import("node:fs/promises");
275
+ try { await unlink(modelPath); } catch { /* ignore */ }
276
+ try { await unlink(configPath); } catch { /* ignore */ }
277
+ throw new Error(`Failed to download voice model: ${err.message}`);
278
+ }
279
+ }
280
+
281
+ async function speakPiper(text, onProgress) {
282
+ let piperBin, modelPath;
283
+ try {
284
+ [piperBin, modelPath] = await Promise.all([
285
+ ensurePiper(onProgress),
286
+ ensureVoiceModel(onProgress),
287
+ ]);
288
+ } catch {
289
+ return;
290
+ }
291
+
292
+ const hash = createHash("md5").update(text).digest("hex").slice(0, 8);
293
+ const outPath = join(tmpdir(), `klaudio-tts-${hash}.wav`);
294
+
295
+ try {
296
+ await new Promise((resolve, reject) => {
297
+ const child = execFile(piperBin, [
298
+ "--model", modelPath,
299
+ "--output_file", outPath,
300
+ "--sentence_silence", "0.5",
301
+ ], { windowsHide: true, timeout: 15000 }, (err) => {
302
+ if (err) reject(err);
303
+ else resolve();
304
+ });
305
+ child.stdin.write(text);
306
+ child.stdin.end();
307
+ });
308
+
309
+ const { playSoundWithCancel } = await import("./player.js");
310
+ await playSoundWithCancel(outPath, { maxSeconds: 0 }).promise.catch(() => {});
311
+ } catch {
312
+ // Piper failed — skip silently
313
+ }
314
+ }
315
+
316
+ // ── macOS fallback ──────────────────────────────────────────────
317
+
318
+ function speakMacOS(text) {
319
+ return new Promise((resolve) => {
320
+ execFile("say", ["-v", "Daniel", text], { timeout: 15000 }, () => resolve());
321
+ });
322
+ }
323
+
324
+ // ── Public API ──────────────────────────────────────────────────
325
+
326
+ let speaking = false;
327
+ const TTS_LOCK = join(tmpdir(), ".klaudio-tts-lock");
328
+
329
+ /**
330
+ * Try to acquire a cross-process TTS lock.
331
+ * Returns true if acquired, false if another process is speaking.
332
+ * Stale locks (>30s) are automatically cleaned up.
333
+ */
334
+ async function acquireTTSLock() {
335
+ try {
336
+ const lockStat = await stat(TTS_LOCK);
337
+ if (Date.now() - lockStat.mtimeMs < 30000) return false; // fresh lock, skip
338
+ } catch { /* no lock file, good */ }
339
+ try {
340
+ await fsWriteFile(TTS_LOCK, String(process.pid), "utf-8");
341
+ return true;
342
+ } catch { return false; }
343
+ }
344
+
345
+ async function releaseTTSLock() {
346
+ try { const { unlink } = await import("node:fs/promises"); await unlink(TTS_LOCK); } catch { /* ignore */ }
347
+ }
348
+
349
+ /**
350
+ * Speak text using the best available TTS engine.
351
+ * Priority: Kokoro (GPU/CPU) → Piper → macOS say
352
+ * Only one speak() call runs at a time — concurrent calls are skipped.
353
+ *
354
+ * @param {string} text - Text to speak
355
+ * @param {object} [options]
356
+ * @param {string} [options.voice] - Kokoro voice ID (e.g. "af_heart")
357
+ * @param {Function} [options.onProgress] - Progress callback for downloads
358
+ */
359
+ export async function speak(text, options = {}) {
360
+ if (!text) return;
361
+ if (speaking) return; // in-process mutex
362
+ if (!await acquireTTSLock()) return; // cross-process mutex
363
+ speaking = true;
364
+
365
+ try {
366
+ const { voice, onProgress } = typeof options === "function"
367
+ ? { voice: null, onProgress: options } // backwards compat: speak(text, onProgress)
368
+ : options;
369
+
370
+ // macOS: prefer built-in `say` (Kokoro ONNX has threading issues on macOS)
371
+ if (platform() === "darwin") {
372
+ return speakMacOS(text);
373
+ }
374
+
375
+ // Try Kokoro first (works on all platforms, best quality)
376
+ try {
377
+ await speakKokoro(text, voice);
378
+ return;
379
+ } catch {
380
+ // Kokoro unavailable — fall through
381
+ }
382
+
383
+ // Fallback: Piper
384
+ return speakPiper(text, onProgress);
385
+ } finally {
386
+ speaking = false;
387
+ await releaseTTSLock();
388
+ }
389
+ }
390
+
391
+ export { KOKORO_PRESET_VOICES, KOKORO_VOICES, KOKORO_DEFAULT_VOICE };