klaudio 0.9.2 → 0.10.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/package.json +2 -1
- package/src/cli.js +4 -2
- package/src/installer.js +2 -2
- package/src/player.js +6 -3
- package/src/tts.js +108 -67
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "klaudio",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.10.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": {
|
|
@@ -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 } 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";
|
|
@@ -1409,13 +1410,13 @@ const ConfirmScreen = ({ scope, sounds, tts, onToggleTts, onConfirm, onBack }) =
|
|
|
1409
1410
|
};
|
|
1410
1411
|
|
|
1411
1412
|
// ── Screen: Installing ──────────────────────────────────────────
|
|
1412
|
-
const InstallingScreen = ({ scope, sounds, tts, onDone }) => {
|
|
1413
|
+
const InstallingScreen = ({ scope, sounds, tts, voice, onDone }) => {
|
|
1413
1414
|
useEffect(() => {
|
|
1414
1415
|
const validSounds = {};
|
|
1415
1416
|
for (const [eventId, path] of Object.entries(sounds)) {
|
|
1416
1417
|
if (path) validSounds[eventId] = path;
|
|
1417
1418
|
}
|
|
1418
|
-
install({ scope, sounds: validSounds, tts }).then(onDone).catch((err) => {
|
|
1419
|
+
install({ scope, sounds: validSounds, tts, voice }).then(onDone).catch((err) => {
|
|
1419
1420
|
onDone({ error: err.message });
|
|
1420
1421
|
});
|
|
1421
1422
|
}, []);
|
|
@@ -1661,6 +1662,7 @@ const InstallApp = () => {
|
|
|
1661
1662
|
scope,
|
|
1662
1663
|
sounds,
|
|
1663
1664
|
tts,
|
|
1665
|
+
voice: KOKORO_PRESET_VOICES[presetId],
|
|
1664
1666
|
onDone: (result) => {
|
|
1665
1667
|
setInstallResult(result);
|
|
1666
1668
|
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]) {
|
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,94 @@
|
|
|
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
|
+
// Singleton: reuse the loaded model across calls
|
|
25
|
+
let kokoroInstance = null;
|
|
26
|
+
let kokoroLoadPromise = null;
|
|
27
|
+
|
|
15
28
|
/**
|
|
16
|
-
*
|
|
29
|
+
* Load the Kokoro TTS model (singleton, downloads ~86MB on first use).
|
|
30
|
+
* Uses CPU backend (DirectML has ConvTranspose compatibility issues).
|
|
17
31
|
*/
|
|
32
|
+
async function getKokoro() {
|
|
33
|
+
if (kokoroInstance) return kokoroInstance;
|
|
34
|
+
if (kokoroLoadPromise) return kokoroLoadPromise;
|
|
35
|
+
|
|
36
|
+
kokoroLoadPromise = (async () => {
|
|
37
|
+
const { KokoroTTS } = await import("kokoro-js");
|
|
38
|
+
kokoroInstance = await KokoroTTS.from_pretrained(
|
|
39
|
+
"onnx-community/Kokoro-82M-v1.0-ONNX",
|
|
40
|
+
{ dtype: "q4", device: "cpu" },
|
|
41
|
+
);
|
|
42
|
+
return kokoroInstance;
|
|
43
|
+
})();
|
|
44
|
+
|
|
45
|
+
try {
|
|
46
|
+
return await kokoroLoadPromise;
|
|
47
|
+
} catch (err) {
|
|
48
|
+
kokoroLoadPromise = null;
|
|
49
|
+
throw err;
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* Speak text using Kokoro TTS.
|
|
55
|
+
* Returns true if successful, false if Kokoro is unavailable.
|
|
56
|
+
*/
|
|
57
|
+
async function speakKokoro(text, voice) {
|
|
58
|
+
const tts = await getKokoro();
|
|
59
|
+
const voiceId = voice || KOKORO_DEFAULT_VOICE;
|
|
60
|
+
|
|
61
|
+
const audio = await tts.generate(text, { voice: voiceId, speed: 1.0 });
|
|
62
|
+
|
|
63
|
+
// Save to temp wav and play
|
|
64
|
+
const hash = createHash("md5").update(text + voiceId).digest("hex").slice(0, 8);
|
|
65
|
+
const outPath = join(tmpdir(), `klaudio-kokoro-${hash}.wav`);
|
|
66
|
+
audio.save(outPath);
|
|
67
|
+
|
|
68
|
+
const { playSoundWithCancel } = await import("./player.js");
|
|
69
|
+
await playSoundWithCancel(outPath, { maxSeconds: 0 }).promise.catch(() => {});
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
// ── Piper TTS (fallback engine) ─────────────────────────────────
|
|
73
|
+
|
|
18
74
|
function getPiperAssetName() {
|
|
19
75
|
const os = platform();
|
|
20
76
|
const a = arch();
|
|
21
|
-
|
|
22
77
|
if (os === "win32") return "piper_windows_amd64.zip";
|
|
23
78
|
if (os === "darwin") return a === "arm64" ? "piper_macos_aarch64.tar.gz" : "piper_macos_x64.tar.gz";
|
|
24
|
-
// Linux
|
|
25
79
|
if (a === "arm64" || a === "aarch64") return "piper_linux_aarch64.tar.gz";
|
|
26
80
|
return "piper_linux_x86_64.tar.gz";
|
|
27
81
|
}
|
|
28
82
|
|
|
29
|
-
/**
|
|
30
|
-
* Get the piper binary path.
|
|
31
|
-
*/
|
|
32
83
|
function getPiperBinPath() {
|
|
33
84
|
const bin = platform() === "win32" ? "piper.exe" : "piper";
|
|
34
85
|
return join(PIPER_DIR, "piper", bin);
|
|
35
86
|
}
|
|
36
87
|
|
|
37
|
-
/**
|
|
38
|
-
* Get the voice model path.
|
|
39
|
-
*/
|
|
40
88
|
function getVoiceModelPath() {
|
|
41
89
|
return join(PIPER_DIR, `${VOICE_NAME}.onnx`);
|
|
42
90
|
}
|
|
43
91
|
|
|
44
|
-
/**
|
|
45
|
-
* Download a file from a URL to a local path.
|
|
46
|
-
*/
|
|
47
92
|
async function downloadFile(url, destPath, onProgress) {
|
|
48
93
|
const res = await fetch(url, { redirect: "follow" });
|
|
49
94
|
if (!res.ok) throw new Error(`Download failed: ${res.status} ${res.statusText}`);
|
|
@@ -53,7 +98,6 @@ async function downloadFile(url, destPath, onProgress) {
|
|
|
53
98
|
const fileStream = createWriteStream(destPath);
|
|
54
99
|
const reader = res.body.getReader();
|
|
55
100
|
|
|
56
|
-
// Manual stream piping with progress
|
|
57
101
|
while (true) {
|
|
58
102
|
const { done, value } = await reader.read();
|
|
59
103
|
if (done) break;
|
|
@@ -71,15 +115,10 @@ async function downloadFile(url, destPath, onProgress) {
|
|
|
71
115
|
});
|
|
72
116
|
}
|
|
73
117
|
|
|
74
|
-
/**
|
|
75
|
-
* Extract a .tar.gz or .zip archive.
|
|
76
|
-
*/
|
|
77
118
|
async function extractArchive(archivePath, destDir) {
|
|
78
119
|
const os = platform();
|
|
79
|
-
|
|
80
120
|
if (archivePath.endsWith(".zip")) {
|
|
81
121
|
if (os === "win32") {
|
|
82
|
-
// Use PowerShell to extract on Windows
|
|
83
122
|
await new Promise((resolve, reject) => {
|
|
84
123
|
execFile("powershell.exe", [
|
|
85
124
|
"-NoProfile", "-Command",
|
|
@@ -92,21 +131,14 @@ async function extractArchive(archivePath, destDir) {
|
|
|
92
131
|
});
|
|
93
132
|
}
|
|
94
133
|
} else {
|
|
95
|
-
// tar.gz
|
|
96
134
|
await new Promise((resolve, reject) => {
|
|
97
135
|
execFile("tar", ["xzf", archivePath, "-C", destDir], { timeout: 60000 }, (err) => err ? reject(err) : resolve());
|
|
98
136
|
});
|
|
99
137
|
}
|
|
100
138
|
}
|
|
101
139
|
|
|
102
|
-
/**
|
|
103
|
-
* Ensure piper binary is available, downloading if needed.
|
|
104
|
-
* Returns the path to the piper executable.
|
|
105
|
-
*/
|
|
106
140
|
export async function ensurePiper(onProgress) {
|
|
107
141
|
const binPath = getPiperBinPath();
|
|
108
|
-
|
|
109
|
-
// Check if already downloaded
|
|
110
142
|
try {
|
|
111
143
|
await stat(binPath);
|
|
112
144
|
return binPath;
|
|
@@ -114,7 +146,6 @@ export async function ensurePiper(onProgress) {
|
|
|
114
146
|
|
|
115
147
|
try {
|
|
116
148
|
await mkdir(PIPER_DIR, { recursive: true });
|
|
117
|
-
|
|
118
149
|
const asset = getPiperAssetName();
|
|
119
150
|
const url = `https://github.com/rhasspy/piper/releases/download/${PIPER_VERSION}/${asset}`;
|
|
120
151
|
const archivePath = join(PIPER_DIR, asset);
|
|
@@ -127,28 +158,20 @@ export async function ensurePiper(onProgress) {
|
|
|
127
158
|
if (onProgress) onProgress("Extracting piper...");
|
|
128
159
|
await extractArchive(archivePath, PIPER_DIR);
|
|
129
160
|
|
|
130
|
-
// Make executable on Unix
|
|
131
161
|
if (platform() !== "win32") {
|
|
132
162
|
try { await chmod(binPath, 0o755); } catch { /* ignore */ }
|
|
133
163
|
}
|
|
134
164
|
|
|
135
165
|
return binPath;
|
|
136
166
|
} catch (err) {
|
|
137
|
-
// Clean up partial downloads
|
|
138
167
|
try { const { unlink } = await import("node:fs/promises"); await unlink(join(PIPER_DIR, getPiperAssetName())); } catch { /* ignore */ }
|
|
139
168
|
throw new Error(`Failed to download piper: ${err.message}`);
|
|
140
169
|
}
|
|
141
170
|
}
|
|
142
171
|
|
|
143
|
-
/**
|
|
144
|
-
* Ensure voice model is available, downloading if needed.
|
|
145
|
-
* Returns the path to the .onnx model file.
|
|
146
|
-
*/
|
|
147
172
|
export async function ensureVoiceModel(onProgress) {
|
|
148
173
|
const modelPath = getVoiceModelPath();
|
|
149
174
|
const configPath = modelPath + ".json";
|
|
150
|
-
|
|
151
|
-
// Check if already downloaded
|
|
152
175
|
try {
|
|
153
176
|
await stat(modelPath);
|
|
154
177
|
await stat(configPath);
|
|
@@ -157,7 +180,6 @@ export async function ensureVoiceModel(onProgress) {
|
|
|
157
180
|
|
|
158
181
|
try {
|
|
159
182
|
await mkdir(PIPER_DIR, { recursive: true });
|
|
160
|
-
|
|
161
183
|
const baseUrl = `https://huggingface.co/rhasspy/piper-voices/resolve/v1.0.0/en/en_GB/alan/medium`;
|
|
162
184
|
|
|
163
185
|
if (onProgress) onProgress("Downloading voice model...");
|
|
@@ -170,7 +192,6 @@ export async function ensureVoiceModel(onProgress) {
|
|
|
170
192
|
|
|
171
193
|
return modelPath;
|
|
172
194
|
} catch (err) {
|
|
173
|
-
// Clean up partial downloads
|
|
174
195
|
const { unlink } = await import("node:fs/promises");
|
|
175
196
|
try { await unlink(modelPath); } catch { /* ignore */ }
|
|
176
197
|
try { await unlink(configPath); } catch { /* ignore */ }
|
|
@@ -178,28 +199,7 @@ export async function ensureVoiceModel(onProgress) {
|
|
|
178
199
|
}
|
|
179
200
|
}
|
|
180
201
|
|
|
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
|
-
|
|
202
|
+
async function speakPiper(text, onProgress) {
|
|
203
203
|
let piperBin, modelPath;
|
|
204
204
|
try {
|
|
205
205
|
[piperBin, modelPath] = await Promise.all([
|
|
@@ -207,11 +207,9 @@ export async function speak(text, onProgress) {
|
|
|
207
207
|
ensureVoiceModel(onProgress),
|
|
208
208
|
]);
|
|
209
209
|
} catch {
|
|
210
|
-
// TTS unavailable (download failed, offline, etc.) — skip silently
|
|
211
210
|
return;
|
|
212
211
|
}
|
|
213
212
|
|
|
214
|
-
// Generate to temp wav file
|
|
215
213
|
const hash = createHash("md5").update(text).digest("hex").slice(0, 8);
|
|
216
214
|
const outPath = join(tmpdir(), `klaudio-tts-${hash}.wav`);
|
|
217
215
|
|
|
@@ -225,15 +223,58 @@ export async function speak(text, onProgress) {
|
|
|
225
223
|
if (err) reject(err);
|
|
226
224
|
else resolve();
|
|
227
225
|
});
|
|
228
|
-
// Feed text via stdin
|
|
229
226
|
child.stdin.write(text);
|
|
230
227
|
child.stdin.end();
|
|
231
228
|
});
|
|
232
229
|
|
|
233
|
-
// Play the generated wav
|
|
234
230
|
const { playSoundWithCancel } = await import("./player.js");
|
|
235
231
|
await playSoundWithCancel(outPath, { maxSeconds: 0 }).promise.catch(() => {});
|
|
236
232
|
} catch {
|
|
237
|
-
// Piper failed
|
|
233
|
+
// Piper failed — skip silently
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
// ── macOS fallback ──────────────────────────────────────────────
|
|
238
|
+
|
|
239
|
+
function speakMacOS(text) {
|
|
240
|
+
return new Promise((resolve) => {
|
|
241
|
+
execFile("say", ["-v", "Daniel", text], { timeout: 15000 }, () => resolve());
|
|
242
|
+
});
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
// ── Public API ──────────────────────────────────────────────────
|
|
246
|
+
|
|
247
|
+
/**
|
|
248
|
+
* Speak text using the best available TTS engine.
|
|
249
|
+
* Priority: Kokoro (GPU/CPU) → Piper → macOS say
|
|
250
|
+
*
|
|
251
|
+
* @param {string} text - Text to speak
|
|
252
|
+
* @param {object} [options]
|
|
253
|
+
* @param {string} [options.voice] - Kokoro voice ID (e.g. "af_heart")
|
|
254
|
+
* @param {Function} [options.onProgress] - Progress callback for downloads
|
|
255
|
+
*/
|
|
256
|
+
export async function speak(text, options = {}) {
|
|
257
|
+
if (!text) return;
|
|
258
|
+
|
|
259
|
+
const { voice, onProgress } = typeof options === "function"
|
|
260
|
+
? { voice: null, onProgress: options } // backwards compat: speak(text, onProgress)
|
|
261
|
+
: options;
|
|
262
|
+
|
|
263
|
+
// Try Kokoro first (works on all platforms, best quality)
|
|
264
|
+
try {
|
|
265
|
+
await speakKokoro(text, voice);
|
|
266
|
+
return;
|
|
267
|
+
} catch {
|
|
268
|
+
// Kokoro unavailable — fall through
|
|
238
269
|
}
|
|
270
|
+
|
|
271
|
+
// macOS: use built-in `say`
|
|
272
|
+
if (platform() === "darwin") {
|
|
273
|
+
return speakMacOS(text);
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
// Fallback: Piper
|
|
277
|
+
return speakPiper(text, onProgress);
|
|
239
278
|
}
|
|
279
|
+
|
|
280
|
+
export { KOKORO_PRESET_VOICES };
|