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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "klaudio",
3
- "version": "0.9.2",
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
- await speak(spoken);
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
- return `npx klaudio play "${normalized}"${ttsFlag}`;
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, spawn } from "node:child_process";
2
- import { mkdir, stat, rename, chmod } from "node:fs/promises";
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
- * Get the piper release asset name for the current platform.
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 (dylib error, etc.) — skip silently
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 };