klaudio 0.12.7 → 0.13.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/bin/cli.js CHANGED
@@ -19,15 +19,18 @@ if (process.argv[2] === "notify") {
19
19
  process.exit(0);
20
20
  }
21
21
 
22
- // Subcommand: klaudio say "text" [--voice <voice>]
22
+ // Subcommand: klaudio say "text" [--voice <voice>] [--speed <speed>]
23
23
  if (process.argv[2] === "say") {
24
24
  const args = process.argv.slice(3);
25
25
  const text = args.find((a) => !a.startsWith("--"));
26
26
  const voice = args.find((a) => a.startsWith("--voice="))?.slice(8)
27
27
  || args[args.indexOf("--voice") + 1];
28
+ const speedArg = args.find((a) => a.startsWith("--speed="))?.slice(8)
29
+ || args[args.indexOf("--speed") + 1];
30
+ const speed = speedArg ? parseFloat(speedArg) : undefined;
28
31
  if (text) {
29
32
  const { speak } = await import("../src/tts.js");
30
- await speak(text, { voice });
33
+ await speak(text, { voice, speed });
31
34
  }
32
35
  // Hard exit: skip native module destructors (onnxruntime crashes during cleanup)
33
36
  process.exit(0);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "klaudio",
3
- "version": "0.12.7",
3
+ "version": "0.13.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": {
package/src/cli.js CHANGED
@@ -2,7 +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, isKokoroAvailable } from "./tts.js";
5
+ import { KOKORO_PRESET_VOICES, KOKORO_VOICES, KOKORO_DEFAULT_VOICE, isKokoroAvailable, ensureKokoroInstalled } from "./tts.js";
6
6
  import { playSoundWithCancel, getWavDuration } from "./player.js";
7
7
  import { getAvailableGames, getSystemSounds } from "./scanner.js";
8
8
  import { install, uninstall, getExistingSounds, checkHooksOutdated } from "./installer.js";
@@ -137,7 +137,9 @@ const NavHint = ({ back = true, extra = "" }) =>
137
137
  );
138
138
 
139
139
  // ── Screen: Scope ───────────────────────────────────────────────
140
- const ScopeScreen = ({ onNext, onMusic, onUpdate, tts, onToggleTts, voice, hasKokoro, onCycleVoice, outdatedReasons }) => {
140
+ const SPEED_OPTIONS = [0.8, 0.9, 1.0, 1.1, 1.2, 1.3, 1.5];
141
+
142
+ const ScopeScreen = ({ onNext, onMusic, onUpdate, tts, onToggleTts, voice, speed, hasKokoro, onCycleVoice, onCycleSpeed, onInstallKokoro, installingKokoro, outdatedReasons }) => {
141
143
  const isOutdated = outdatedReasons && outdatedReasons.length > 0;
142
144
  const items = [
143
145
  ...(isOutdated ? [{ label: "⬆ Apply updates", value: "_update" }] : []),
@@ -166,6 +168,10 @@ const ScopeScreen = ({ onNext, onMusic, onUpdate, tts, onToggleTts, voice, hasKo
166
168
  import("../src/tts.js").then(({ speak }) =>
167
169
  speak(`Hi, I'm ${nextVoice.name}`, { voice: nextVoice.id })
168
170
  ).finally(() => setPreviewing(false));
171
+ } else if (input === "v" && tts && !hasKokoro && !installingKokoro) {
172
+ onInstallKokoro();
173
+ } else if (input === "s" && tts && hasKokoro) {
174
+ onCycleSpeed();
169
175
  } else if (key.return) {
170
176
  const v = items[sel].value;
171
177
  if (v === "_update") onUpdate();
@@ -207,6 +213,18 @@ const ScopeScreen = ({ onNext, onMusic, onUpdate, tts, onToggleTts, voice, hasKo
207
213
  ),
208
214
  h(Text, { dimColor: true }, " (v to change & preview)"),
209
215
  ) : null,
216
+ tts && hasKokoro ? h(Box, { marginLeft: 4 },
217
+ h(Text, { color: ACCENT }, `⚡ Speed: ${speed}x`),
218
+ h(Text, { dimColor: true }, " (s to change)"),
219
+ ) : null,
220
+ tts && !hasKokoro ? h(Box, { marginLeft: 4 },
221
+ installingKokoro
222
+ ? h(Text, { color: "yellow" }, h(Spinner, { type: "dots" }), " Installing Kokoro HD voices...")
223
+ : h(React.Fragment, null,
224
+ h(Text, { color: "yellow" }, "🎙 HD voices available"),
225
+ h(Text, { dimColor: true }, " (v to install Kokoro — 12 voices, ~25MB)"),
226
+ ),
227
+ ) : null,
210
228
  );
211
229
  };
212
230
 
@@ -1421,11 +1439,13 @@ const ExtractingScreen = ({ game, onDone, onBack }) => {
1421
1439
  };
1422
1440
 
1423
1441
  // ── Screen: Confirm ─────────────────────────────────────────────
1424
- const ConfirmScreen = ({ scope, sounds, tts, voice, hasKokoro, onToggleTts, onCycleVoice, onConfirm, onBack }) => {
1442
+ const ConfirmScreen = ({ scope, sounds, tts, voice, speed, hasKokoro, onToggleTts, onCycleVoice, onCycleSpeed, onInstallKokoro, installingKokoro, onConfirm, onBack }) => {
1425
1443
  useInput((input, key) => {
1426
1444
  if (key.escape) onBack();
1427
1445
  else if (input === "t") onToggleTts();
1428
1446
  else if (input === "v" && tts && hasKokoro) onCycleVoice();
1447
+ else if (input === "s" && tts && hasKokoro) onCycleSpeed();
1448
+ else if (input === "v" && tts && !hasKokoro && !installingKokoro) onInstallKokoro();
1429
1449
  });
1430
1450
 
1431
1451
  const items = [
@@ -1457,6 +1477,18 @@ const ConfirmScreen = ({ scope, sounds, tts, voice, hasKokoro, onToggleTts, onCy
1457
1477
  ),
1458
1478
  h(Text, { dimColor: true }, " (v to change voice)"),
1459
1479
  ) : null,
1480
+ tts && hasKokoro ? h(Box, { marginLeft: 4 },
1481
+ h(Text, { color: ACCENT }, `⚡ Speed: ${speed}x`),
1482
+ h(Text, { dimColor: true }, " (s to change)"),
1483
+ ) : null,
1484
+ tts && !hasKokoro ? h(Box, { marginLeft: 4 },
1485
+ installingKokoro
1486
+ ? h(Text, { color: "yellow" }, h(Spinner, { type: "dots" }), " Installing Kokoro HD voices...")
1487
+ : h(React.Fragment, null,
1488
+ h(Text, { color: "yellow" }, "🎙 HD voices available"),
1489
+ h(Text, { dimColor: true }, " (v to install Kokoro — 12 voices, ~25MB)"),
1490
+ ),
1491
+ ) : null,
1460
1492
  ),
1461
1493
  h(Box, { marginTop: 1, marginLeft: 2 },
1462
1494
  h(SelectInput, { indicatorComponent: Indicator, itemComponent: Item, items, onSelect: (item) => {
@@ -1469,13 +1501,13 @@ const ConfirmScreen = ({ scope, sounds, tts, voice, hasKokoro, onToggleTts, onCy
1469
1501
  };
1470
1502
 
1471
1503
  // ── Screen: Installing ──────────────────────────────────────────
1472
- const InstallingScreen = ({ scope, sounds, tts, voice, onDone }) => {
1504
+ const InstallingScreen = ({ scope, sounds, tts, voice, speed, onDone }) => {
1473
1505
  useEffect(() => {
1474
1506
  const validSounds = {};
1475
1507
  for (const [eventId, path] of Object.entries(sounds)) {
1476
1508
  if (path) validSounds[eventId] = path;
1477
1509
  }
1478
- install({ scope, sounds: validSounds, tts, voice }).then(onDone).catch((err) => {
1510
+ install({ scope, sounds: validSounds, tts, voice, speed }).then(onDone).catch((err) => {
1479
1511
  onDone({ error: err.message });
1480
1512
  });
1481
1513
  }, []);
@@ -1581,7 +1613,9 @@ const InstallApp = () => {
1581
1613
  const [installResult, setInstallResult] = useState(null);
1582
1614
  const [tts, setTts] = useState(true);
1583
1615
  const [voice, setVoice] = useState(KOKORO_DEFAULT_VOICE);
1616
+ const [speed, setSpeed] = useState(1.0);
1584
1617
  const [hasKokoro, setHasKokoro] = useState(false);
1618
+ const [installingKokoro, setInstallingKokoro] = useState(false);
1585
1619
  const [outdatedReasons, setOutdatedReasons] = useState([]);
1586
1620
  const [musicFiles, setMusicFiles] = useState([]);
1587
1621
  const [musicGameName, setMusicGameName] = useState(null);
@@ -1608,19 +1642,33 @@ const InstallApp = () => {
1608
1642
  if (preset) setSounds({ ...preset.sounds });
1609
1643
  }, []);
1610
1644
 
1645
+ const handleInstallKokoro = useCallback(() => {
1646
+ setInstallingKokoro(true);
1647
+ ensureKokoroInstalled()
1648
+ .then(() => { setHasKokoro(true); setInstallingKokoro(false); })
1649
+ .catch(() => { setInstallingKokoro(false); });
1650
+ }, []);
1651
+
1611
1652
  const content = (() => {
1612
1653
  switch (screen) {
1613
1654
  case SCREEN.SCOPE:
1614
1655
  return h(ScopeScreen, {
1615
1656
  tts,
1616
1657
  voice,
1658
+ speed,
1617
1659
  hasKokoro,
1660
+ installingKokoro,
1618
1661
  outdatedReasons,
1619
1662
  onToggleTts: () => setTts((v) => !v),
1620
1663
  onCycleVoice: () => setVoice((v) => {
1621
1664
  const idx = KOKORO_VOICES.findIndex((x) => x.id === v);
1622
1665
  return KOKORO_VOICES[(idx + 1) % KOKORO_VOICES.length].id;
1623
1666
  }),
1667
+ onCycleSpeed: () => setSpeed((s) => {
1668
+ const idx = SPEED_OPTIONS.indexOf(s);
1669
+ return SPEED_OPTIONS[(idx + 1) % SPEED_OPTIONS.length];
1670
+ }),
1671
+ onInstallKokoro: handleInstallKokoro,
1624
1672
  onNext: (s) => {
1625
1673
  setScope(s);
1626
1674
  // Refresh sounds/outdated for the selected scope
@@ -1744,12 +1792,19 @@ const InstallApp = () => {
1744
1792
  sounds,
1745
1793
  tts,
1746
1794
  voice,
1795
+ speed,
1747
1796
  hasKokoro,
1797
+ installingKokoro,
1748
1798
  onToggleTts: () => setTts((v) => !v),
1749
1799
  onCycleVoice: () => setVoice((v) => {
1750
1800
  const idx = KOKORO_VOICES.findIndex((x) => x.id === v);
1751
1801
  return KOKORO_VOICES[(idx + 1) % KOKORO_VOICES.length].id;
1752
1802
  }),
1803
+ onCycleSpeed: () => setSpeed((s) => {
1804
+ const idx = SPEED_OPTIONS.indexOf(s);
1805
+ return SPEED_OPTIONS[(idx + 1) % SPEED_OPTIONS.length];
1806
+ }),
1807
+ onInstallKokoro: handleInstallKokoro,
1753
1808
  onConfirm: () => setScreen(SCREEN.INSTALLING),
1754
1809
  onBack: () => {
1755
1810
  if (selectedGame) setScreen(SCREEN.GAME_SOUNDS);
@@ -1763,6 +1818,7 @@ const InstallApp = () => {
1763
1818
  sounds,
1764
1819
  tts,
1765
1820
  voice,
1821
+ speed,
1766
1822
  onDone: (result) => {
1767
1823
  setInstallResult(result);
1768
1824
  setScreen(SCREEN.DONE);
package/src/extractor.js CHANGED
@@ -139,8 +139,7 @@ export async function findPackedAudioFiles(gamePath, maxFiles = 50) {
139
139
  } else if (entry.isFile()) {
140
140
  const ext = extname(entry.name).toLowerCase();
141
141
  // Formats vgmstream-cli can convert directly
142
- // (.bnk needs bnkextr preprocessing skip for now)
143
- if (ext === ".wem" || ext === ".fsb" || ext === ".bank" || ext === ".bun" || ext === ".pck") {
142
+ if (ext === ".wem" || ext === ".fsb" || ext === ".bank" || ext === ".bun" || ext === ".pck" || ext === ".bnk") {
144
143
  results.push({ path: fullPath, name: entry.name, dir });
145
144
  }
146
145
  }
package/src/installer.js CHANGED
@@ -25,8 +25,9 @@ function getTargetDir(scope) {
25
25
  * @param {string} options.scope - "global" or "project"
26
26
  * @param {Record<string, string>} options.sounds - Map of event ID -> source sound file path
27
27
  * @param {boolean} [options.tts] - Enable TTS voice summary on task complete
28
+ * @param {number} [options.speed] - Voice speed multiplier (default 1.0)
28
29
  */
29
- export async function install({ scope, sounds, tts = false, voice } = {}) {
30
+ export async function install({ scope, sounds, tts = false, voice, speed } = {}) {
30
31
  const claudeDir = getTargetDir(scope);
31
32
  const soundsDir = join(claudeDir, "sounds");
32
33
  const settingsFile = join(claudeDir, "settings.json");
@@ -73,7 +74,7 @@ export async function install({ scope, sounds, tts = false, voice } = {}) {
73
74
  const hookEvent = event.hookEvent;
74
75
  // Enable TTS only for the "stop" event (task complete)
75
76
  const useTts = tts && eventId === "stop";
76
- const playCommand = getHookPlayCommand(soundPath, { tts: useTts, voice });
77
+ const playCommand = getHookPlayCommand(soundPath, { tts: useTts, voice, speed });
77
78
 
78
79
  // Check if there's already a klaudio hook for this event
79
80
  if (!settings.hooks[hookEvent]) {
package/src/player.js CHANGED
@@ -469,12 +469,15 @@ export async function handlePlayCommand(args) {
469
469
  await soundPromise;
470
470
  const voice = args.find((a) => a.startsWith("--voice="))?.slice(8)
471
471
  || args[args.indexOf("--voice") + 1];
472
+ const speedArg = args.find((a) => a.startsWith("--speed="))?.slice(8)
473
+ || args[args.indexOf("--speed") + 1];
474
+ const speed = speedArg ? parseFloat(speedArg) : undefined;
472
475
  // Spawn a detached child process for TTS so the hook can exit immediately
473
476
  const { spawn } = await import("node:child_process");
474
477
  const child = spawn(process.execPath, [
475
478
  "--input-type=module",
476
479
  "-e",
477
- `import{speak}from"${import.meta.resolve("./tts.js")}";await speak(${JSON.stringify(spoken)},{voice:${JSON.stringify(voice || undefined)}});`,
480
+ `import{speak}from"${import.meta.resolve("./tts.js")}";await speak(${JSON.stringify(spoken)},{voice:${JSON.stringify(voice || undefined)},speed:${JSON.stringify(speed || undefined)}});`,
478
481
  ], {
479
482
  detached: true,
480
483
  stdio: "ignore",
@@ -488,10 +491,11 @@ export async function handlePlayCommand(args) {
488
491
  /**
489
492
  * Generate the shell command string for use in Claude Code hooks.
490
493
  */
491
- export function getHookPlayCommand(soundFilePath, { tts = false, voice, notify = true } = {}) {
494
+ export function getHookPlayCommand(soundFilePath, { tts = false, voice, speed, notify = true } = {}) {
492
495
  const normalized = soundFilePath.replace(/\\/g, "/");
493
496
  const ttsFlag = tts ? " --tts" : "";
494
497
  const voiceFlag = tts && voice ? ` --voice ${voice}` : "";
498
+ const speedFlag = tts && speed && speed !== 1.0 ? ` --speed ${speed}` : "";
495
499
  const notifyFlag = notify ? " --notify" : "";
496
- return `npx klaudio play "${normalized}"${ttsFlag}${voiceFlag}${notifyFlag}`;
500
+ return `npx klaudio play "${normalized}"${ttsFlag}${voiceFlag}${speedFlag}${notifyFlag}`;
497
501
  }
package/src/tts.js CHANGED
@@ -133,11 +133,12 @@ async function getKokoro() {
133
133
  * Speak text using Kokoro TTS.
134
134
  * Returns true if successful, false if Kokoro is unavailable.
135
135
  */
136
- async function speakKokoro(text, voice) {
136
+ async function speakKokoro(text, voice, speed) {
137
137
  const tts = await getKokoro();
138
138
  const voiceId = voice || KOKORO_DEFAULT_VOICE;
139
+ const voiceSpeed = speed || 1.0;
139
140
 
140
- const audio = await tts.generate(text, { voice: voiceId, speed: 1.0 });
141
+ const audio = await tts.generate(text, { voice: voiceId, speed: voiceSpeed });
141
142
 
142
143
  // Save to temp wav and play
143
144
  const hash = createHash("md5").update(text + voiceId).digest("hex").slice(0, 8);
@@ -360,6 +361,7 @@ async function releaseTTSLock() {
360
361
  * @param {string} text - Text to speak
361
362
  * @param {object} [options]
362
363
  * @param {string} [options.voice] - Kokoro voice ID (e.g. "af_heart")
364
+ * @param {number} [options.speed] - Voice speed multiplier (default 1.0)
363
365
  * @param {Function} [options.onProgress] - Progress callback for downloads
364
366
  */
365
367
  export async function speak(text, options = {}) {
@@ -369,14 +371,14 @@ export async function speak(text, options = {}) {
369
371
  speaking = true;
370
372
 
371
373
  try {
372
- const { voice, onProgress } = typeof options === "function"
373
- ? { voice: null, onProgress: options } // backwards compat: speak(text, onProgress)
374
+ const { voice, speed, onProgress } = typeof options === "function"
375
+ ? { voice: null, speed: null, onProgress: options } // backwards compat: speak(text, onProgress)
374
376
  : options;
375
377
 
376
378
  // Try Kokoro (best quality)
377
379
  if (await isKokoroAvailable()) {
378
380
  try {
379
- await speakKokoro(text, voice);
381
+ await speakKokoro(text, voice, speed);
380
382
  return;
381
383
  } catch {
382
384
  // Kokoro failed at runtime — fall through
@@ -394,4 +396,4 @@ export async function speak(text, options = {}) {
394
396
  }
395
397
  }
396
398
 
397
- export { KOKORO_PRESET_VOICES, KOKORO_VOICES, KOKORO_DEFAULT_VOICE };
399
+ export { KOKORO_PRESET_VOICES, KOKORO_VOICES, KOKORO_DEFAULT_VOICE, ensureKokoroInstalled };