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 +14 -1
- package/package.json +2 -1
- package/src/cli.js +20 -3
- package/src/installer.js +8 -4
- package/src/player.js +6 -3
- package/src/tts.js +124 -67
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.
|
|
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" ] && [ "$(
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
2
|
-
import { mkdir, stat,
|
|
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
|
-
*
|
|
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
|
|
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 };
|