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/README.md +96 -96
- package/bin/cli.js +44 -44
- package/package.json +40 -44
- package/src/cache.js +306 -306
- package/src/cli.js +1821 -1821
- package/src/extractor.js +213 -213
- package/src/installer.js +369 -368
- package/src/notify.js +138 -135
- package/src/player.js +488 -488
- package/src/presets.js +87 -87
- package/src/scanner.js +445 -445
- package/src/scumm.js +560 -560
- package/src/tts.js +391 -391
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
|
-
//
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
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 };
|