klaudio 0.10.2 → 0.11.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
@@ -7,6 +7,17 @@ if (process.argv[2] === "play") {
7
7
  process.exit(0);
8
8
  }
9
9
 
10
+ // Subcommand: klaudio notify "title" "body"
11
+ if (process.argv[2] === "notify") {
12
+ const title = process.argv[3] || "klaudio";
13
+ const body = process.argv[4] || "";
14
+ if (body) {
15
+ const { sendNotification } = await import("../src/notify.js");
16
+ await sendNotification(title, body);
17
+ }
18
+ process.exit(0);
19
+ }
20
+
10
21
  // Subcommand: klaudio say "text" [--voice <voice>]
11
22
  if (process.argv[2] === "say") {
12
23
  const args = process.argv.slice(3);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "klaudio",
3
- "version": "0.10.2",
3
+ "version": "0.11.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,7 +32,6 @@
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",
36
35
  "react": "^19.2.4"
37
36
  },
38
37
  "engines": {
package/src/cli.js CHANGED
@@ -2,10 +2,10 @@ 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
+ import { KOKORO_PRESET_VOICES, KOKORO_VOICES, KOKORO_DEFAULT_VOICE, isKokoroAvailable } from "./tts.js";
6
6
  import { playSoundWithCancel, getWavDuration } from "./player.js";
7
7
  import { getAvailableGames, getSystemSounds } from "./scanner.js";
8
- import { install, uninstall, getExistingSounds } from "./installer.js";
8
+ import { install, uninstall, getExistingSounds, checkHooksOutdated } from "./installer.js";
9
9
  import { getVgmstreamPath, findPackedAudioFiles, extractToWav } from "./extractor.js";
10
10
  import { extractUnityResource } from "./unity.js";
11
11
  import { extractBunFile, isBunFile } from "./scumm.js";
@@ -17,6 +17,17 @@ import { join } from "node:path";
17
17
  const MAX_PLAY_SECONDS = 10;
18
18
  const ACCENT = "#76C41E"; // Needle green-yellow midpoint
19
19
 
20
+ /** Truncate a filename: keep first 10 chars + ext (max 4 chars) */
21
+ function shortName(filePath) {
22
+ const name = basename(filePath);
23
+ const dot = name.lastIndexOf(".");
24
+ if (dot <= 0 || name.length <= 18) return name;
25
+ const stem = name.slice(0, dot);
26
+ const ext = name.slice(dot);
27
+ if (stem.length <= 10) return name;
28
+ return stem.slice(0, 10) + "..." + ext;
29
+ }
30
+
20
31
  const h = React.createElement;
21
32
 
22
33
  // ── Custom SelectInput components (bright colors for CMD) ────
@@ -171,11 +182,14 @@ const ScopeScreen = ({ onNext, onMusic, tts, onToggleTts }) => {
171
182
  };
172
183
 
173
184
  // ── Screen: Preset ──────────────────────────────────────────────
174
- const PresetScreen = ({ existingSounds, onNext, onReapply, onBack }) => {
185
+ const PresetScreen = ({ existingSounds, outdatedReasons, onNext, onReapply, onBack }) => {
175
186
  const hasExisting = existingSounds && Object.values(existingSounds).some(Boolean);
187
+ const isOutdated = outdatedReasons && outdatedReasons.length > 0;
176
188
  const items = [
177
189
  ...(hasExisting ? [{
178
- label: "✓ Re-apply current sounds — update config with current selections",
190
+ label: isOutdated
191
+ ? "⬆ Update hooks — new features available for your current sounds"
192
+ : "✓ Re-apply current sounds — update config with current selections",
179
193
  value: "_reapply",
180
194
  }] : []),
181
195
  ...Object.entries(PRESETS).map(([id, p]) => ({
@@ -206,8 +220,16 @@ const PresetScreen = ({ existingSounds, onNext, onReapply, onBack }) => {
206
220
  ? h(Box, { flexDirection: "column", marginLeft: 4, marginBottom: 1 },
207
221
  h(Text, { dimColor: true }, "Current sounds:"),
208
222
  ...Object.entries(existingSounds).filter(([_, p]) => p).map(([eid, p]) =>
209
- h(Text, { key: eid, color: "green", dimColor: true }, ` ✓ ${EVENTS[eid].name}: ${basename(p)}`),
223
+ h(Text, { key: eid, color: "green", dimColor: true }, ` ✓ ${EVENTS[eid].name}: ${shortName(p)}`),
210
224
  ),
225
+ isOutdated
226
+ ? h(Box, { flexDirection: "column", marginTop: 1 },
227
+ h(Text, { color: "yellow" }, " Updates available:"),
228
+ ...outdatedReasons.map((r, i) =>
229
+ h(Text, { key: i, color: "yellow", dimColor: true }, ` + ${r}`),
230
+ ),
231
+ )
232
+ : null,
211
233
  )
212
234
  : null,
213
235
  h(Box, { flexDirection: "column", marginLeft: 2 },
@@ -1370,11 +1392,11 @@ const ExtractingScreen = ({ game, onDone, onBack }) => {
1370
1392
  };
1371
1393
 
1372
1394
  // ── Screen: Confirm ─────────────────────────────────────────────
1373
- const ConfirmScreen = ({ scope, sounds, tts, voice, onToggleTts, onCycleVoice, onConfirm, onBack }) => {
1395
+ const ConfirmScreen = ({ scope, sounds, tts, voice, hasKokoro, onToggleTts, onCycleVoice, onConfirm, onBack }) => {
1374
1396
  useInput((input, key) => {
1375
1397
  if (key.escape) onBack();
1376
1398
  else if (input === "t") onToggleTts();
1377
- else if (input === "v" && tts) onCycleVoice();
1399
+ else if (input === "v" && tts && hasKokoro) onCycleVoice();
1378
1400
  });
1379
1401
 
1380
1402
  const items = [
@@ -1383,7 +1405,7 @@ const ConfirmScreen = ({ scope, sounds, tts, voice, onToggleTts, onCycleVoice, o
1383
1405
  ];
1384
1406
 
1385
1407
  const soundEntries = Object.entries(sounds).filter(([_, path]) => path);
1386
- const voiceInfo = KOKORO_VOICES.find((v) => v.id === voice);
1408
+ const voiceInfo = hasKokoro ? KOKORO_VOICES.find((v) => v.id === voice) : null;
1387
1409
 
1388
1410
  return h(Box, { flexDirection: "column" },
1389
1411
  h(Text, { bold: true, marginLeft: 2 }, " Ready to install:"),
@@ -1391,7 +1413,7 @@ const ConfirmScreen = ({ scope, sounds, tts, voice, onToggleTts, onCycleVoice, o
1391
1413
  h(Text, { marginLeft: 4 }, `Scope: ${scope === "global" ? "Global (Claude Code + Copilot)" : "This project (Claude Code + Copilot)"}`),
1392
1414
  ...soundEntries.map(([eid, path]) =>
1393
1415
  h(Text, { key: eid, marginLeft: 4 },
1394
- `${EVENTS[eid].name} → ${basename(path)}`
1416
+ `${EVENTS[eid].name} → ${shortName(path)}`
1395
1417
  )
1396
1418
  ),
1397
1419
  h(Box, { marginLeft: 4, marginTop: 1 },
@@ -1530,10 +1552,16 @@ const InstallApp = () => {
1530
1552
  const [installResult, setInstallResult] = useState(null);
1531
1553
  const [tts, setTts] = useState(true);
1532
1554
  const [voice, setVoice] = useState(KOKORO_DEFAULT_VOICE);
1555
+ const [hasKokoro, setHasKokoro] = useState(false);
1556
+ const [outdatedReasons, setOutdatedReasons] = useState([]);
1533
1557
  const [musicFiles, setMusicFiles] = useState([]);
1534
1558
  const [musicGameName, setMusicGameName] = useState(null);
1535
1559
  const [musicShuffle, setMusicShuffle] = useState(false);
1536
1560
 
1561
+ useEffect(() => {
1562
+ isKokoroAvailable().then(setHasKokoro).catch(() => {});
1563
+ }, []);
1564
+
1537
1565
  const initSoundsFromPreset = useCallback((pid) => {
1538
1566
  const preset = PRESETS[pid];
1539
1567
  if (preset) setSounds({ ...preset.sounds });
@@ -1550,6 +1578,7 @@ const InstallApp = () => {
1550
1578
  getExistingSounds(s).then((existing) => {
1551
1579
  if (Object.keys(existing).length > 0) setSounds(existing);
1552
1580
  });
1581
+ checkHooksOutdated(s).then(setOutdatedReasons).catch(() => {});
1553
1582
  setScreen(SCREEN.PRESET);
1554
1583
  },
1555
1584
  onMusic: () => setScreen(SCREEN.MUSIC_MODE),
@@ -1558,6 +1587,7 @@ const InstallApp = () => {
1558
1587
  case SCREEN.PRESET:
1559
1588
  return h(PresetScreen, {
1560
1589
  existingSounds: sounds,
1590
+ outdatedReasons,
1561
1591
  onReapply: () => setScreen(SCREEN.CONFIRM),
1562
1592
  onNext: (id) => {
1563
1593
  if (id === "_music") {
@@ -1660,6 +1690,7 @@ const InstallApp = () => {
1660
1690
  sounds,
1661
1691
  tts,
1662
1692
  voice,
1693
+ hasKokoro,
1663
1694
  onToggleTts: () => setTts((v) => !v),
1664
1695
  onCycleVoice: () => setVoice((v) => {
1665
1696
  const idx = KOKORO_VOICES.findIndex((x) => x.id === v);
package/src/installer.js CHANGED
@@ -118,8 +118,8 @@ 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 + speaks a TTS message if a tool isn't approved within DELAY seconds.
122
- DELAY=15
121
+ # Plays a sound + sends a system notification if a tool isn't approved within DELAY seconds.
122
+ DELAY=60
123
123
  MARKER="/tmp/.claude-approval-pending"
124
124
  SOUND="${normalized}"
125
125
 
@@ -135,6 +135,7 @@ case "$1" in
135
135
  PROJECT=$(tail -1 "$MARKER" 2>/dev/null | sed 's|.*[/\\\\]||')
136
136
  rm -f "$MARKER"
137
137
  npx klaudio play "$SOUND" 2>/dev/null
138
+ npx klaudio notify "\${PROJECT:-project}" "Waiting for your approval" 2>/dev/null
138
139
  npx klaudio say "\${PROJECT:-project} needs your attention" 2>/dev/null
139
140
  fi
140
141
  ) &
@@ -268,6 +269,58 @@ export async function getExistingSounds(scope) {
268
269
  return sounds;
269
270
  }
270
271
 
272
+ /**
273
+ * Check if existing hooks are outdated (missing features from newer versions).
274
+ * Returns a list of reasons why hooks should be updated.
275
+ */
276
+ export async function checkHooksOutdated(scope) {
277
+ const claudeDir = getTargetDir(scope);
278
+ const settingsFile = join(claudeDir, "settings.json");
279
+ const reasons = [];
280
+
281
+ try {
282
+ const existing = await readFile(settingsFile, "utf-8");
283
+ const settings = JSON.parse(existing);
284
+ if (!settings.hooks) return reasons;
285
+
286
+ // Check Stop hook for --notify flag
287
+ const stopEntries = settings.hooks.Stop || [];
288
+ const stopHook = stopEntries.find((e) => e._klaudio || e.hooks?.[0]?.command?.includes("klaudio"));
289
+ if (stopHook?.hooks?.[0]?.command && !stopHook.hooks[0].command.includes("--notify")) {
290
+ reasons.push("System notifications on task complete");
291
+ }
292
+
293
+ // Check Notification hook for --notify flag
294
+ const notifEntries = settings.hooks.Notification || [];
295
+ const notifHook = notifEntries.find((e) => e._klaudio || e.hooks?.[0]?.command?.includes("klaudio"));
296
+ if (notifHook?.hooks?.[0]?.command && !notifHook.hooks[0].command.includes("--notify")) {
297
+ reasons.push("System notifications on background task");
298
+ }
299
+
300
+ // Check approval script for notify command and timer delay
301
+ const scriptPath = join(claudeDir, "approval-notify.sh");
302
+ try {
303
+ const script = await readFile(scriptPath, "utf-8");
304
+ if (!script.includes("klaudio notify")) {
305
+ reasons.push("System notifications on approval wait");
306
+ }
307
+ if (script.includes("DELAY=15")) {
308
+ reasons.push("Approval timer too short (15s -> 60s)");
309
+ }
310
+ } catch { /* no script */ }
311
+
312
+ // Check for duplicate hooks (old bug)
313
+ for (const [event, entries] of Object.entries(settings.hooks)) {
314
+ const klaudioEntries = entries.filter((e) => e._klaudio || e._klonk);
315
+ if (klaudioEntries.length > 1) {
316
+ reasons.push(`Duplicate ${event} hooks`);
317
+ }
318
+ }
319
+ } catch { /* no existing config */ }
320
+
321
+ return reasons;
322
+ }
323
+
271
324
  /**
272
325
  * Uninstall klaudio hooks from settings.
273
326
  */
package/src/notify.js ADDED
@@ -0,0 +1,135 @@
1
+ import { spawn } from "node:child_process";
2
+ import { platform } from "node:os";
3
+
4
+ /**
5
+ * Send a native OS notification (fire-and-forget).
6
+ * Click-to-focus: activates the terminal or editor that triggered it.
7
+ *
8
+ * Windows: WinRT toast (Win10+), focuses Windows Terminal or VS Code on click
9
+ * macOS: terminal-notifier (if installed) or osascript fallback
10
+ * Linux: notify-send
11
+ */
12
+ export function sendNotification(title, body) {
13
+ const os = platform();
14
+ try {
15
+ if (os === "win32") return notifyWindows(title, body);
16
+ if (os === "darwin") return notifyMac(title, body);
17
+ return notifyLinux(title, body);
18
+ } catch {
19
+ return Promise.resolve();
20
+ }
21
+ }
22
+
23
+ /**
24
+ * Detect the terminal/editor environment.
25
+ */
26
+ function detectTerminal() {
27
+ const tp = process.env.TERM_PROGRAM;
28
+ if (tp === "vscode") return "vscode";
29
+ if (tp === "cursor") return "cursor";
30
+ if (tp === "iTerm.app") return "iterm";
31
+ if (tp === "Apple_Terminal") return "terminal";
32
+ if (process.env.WT_SESSION) return "windows-terminal";
33
+ // Fallback: check PATH for clues (hooks inherit the terminal's env)
34
+ const path = process.env.PATH || "";
35
+ if (/cursor[/\\]/i.test(path) && /resources[/\\]app[/\\]bin/i.test(path)) return "cursor";
36
+ if (/VS Code[/\\]bin/i.test(path) || /Code[/\\]bin/i.test(path)) return "vscode";
37
+ return "unknown";
38
+ }
39
+
40
+ function escapeXml(s) {
41
+ return s.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;").replace(/'/g, "&apos;");
42
+ }
43
+
44
+ // ── Windows ──────────────────────────────────────────────────────
45
+
46
+ function notifyWindows(title, body) {
47
+ const safeTitle = escapeXml(title);
48
+ const safeBody = escapeXml(body);
49
+
50
+ // Determine activation strategy based on the terminal we're running in
51
+ let toastAttrs = "";
52
+ let appId;
53
+ const terminal = detectTerminal();
54
+
55
+ if (terminal === "vscode" || terminal === "cursor") {
56
+ // VS Code / Cursor: use protocol handler to focus the editor on click
57
+ const protocol = terminal === "cursor" ? "cursor://" : "vscode://";
58
+ toastAttrs = ` activationType="protocol" launch="${protocol}"`;
59
+ appId = "klaudio";
60
+ } else if (terminal === "windows-terminal") {
61
+ // Windows Terminal: use its AUMID so clicking focuses WT
62
+ appId = "Microsoft.WindowsTerminal_8wekyb3d8bbwe!App";
63
+ } else {
64
+ appId = "klaudio";
65
+ }
66
+
67
+ const toastXml = `<toast${toastAttrs}><visual><binding template="ToastGeneric"><text>${safeTitle}</text><text>${safeBody}</text></binding></visual></toast>`;
68
+
69
+ // PowerShell script: show WinRT toast notification
70
+ const ps = `\
71
+ [void][Windows.UI.Notifications.ToastNotificationManager, Windows.UI.Notifications, ContentType = WindowsRuntime]
72
+ [void][Windows.Data.Xml.Dom.XmlDocument, Windows.Data.Xml.Dom, ContentType = WindowsRuntime]
73
+ $x = [Windows.Data.Xml.Dom.XmlDocument]::new()
74
+ $x.LoadXml('${toastXml.replace(/'/g, "''")}')
75
+ $t = [Windows.UI.Notifications.ToastNotification]::new($x)
76
+ [Windows.UI.Notifications.ToastNotificationManager]::CreateToastNotifier('${appId}').Show($t)`;
77
+
78
+ // Run detached so the Node process can exit immediately
79
+ const child = spawn("powershell.exe", ["-NoProfile", "-NonInteractive", "-Command", ps], {
80
+ windowsHide: true,
81
+ detached: true,
82
+ stdio: "ignore",
83
+ });
84
+ child.unref();
85
+ return Promise.resolve();
86
+ }
87
+
88
+ // ── macOS ────────────────────────────────────────────────────────
89
+
90
+ function notifyMac(title, body) {
91
+ // Determine which app to activate when the notification is clicked
92
+ const terminal = detectTerminal();
93
+ const bundleIds = {
94
+ vscode: "com.microsoft.VSCode",
95
+ cursor: "com.todesktop.230313mzl4w4u92",
96
+ iterm: "com.googlecode.iterm2",
97
+ terminal: "com.apple.Terminal",
98
+ };
99
+ const bundleId = bundleIds[terminal] || "com.apple.Terminal";
100
+
101
+ // Try terminal-notifier first (best UX: click-to-focus), fall back to osascript
102
+ return new Promise((resolve) => {
103
+ const child = spawn("terminal-notifier", [
104
+ "-title", title, "-message", body,
105
+ "-activate", bundleId, "-sender", bundleId,
106
+ "-sound", "default",
107
+ ], { stdio: "ignore", timeout: 10000 });
108
+
109
+ child.on("error", () => {
110
+ // terminal-notifier not installed — fall back to osascript
111
+ const safeTitle = title.replace(/\\/g, "\\\\").replace(/"/g, '\\"');
112
+ const safeBody = body.replace(/\\/g, "\\\\").replace(/"/g, '\\"');
113
+ const script = `display notification "${safeBody}" with title "${safeTitle}"`;
114
+ const child2 = spawn("osascript", ["-e", script], {
115
+ stdio: "ignore",
116
+ detached: true,
117
+ });
118
+ child2.unref();
119
+ resolve();
120
+ });
121
+
122
+ child.on("close", () => resolve());
123
+ });
124
+ }
125
+
126
+ // ── Linux ────────────────────────────────────────────────────────
127
+
128
+ function notifyLinux(title, body) {
129
+ const child = spawn("notify-send", ["-a", "klaudio", title, body], {
130
+ stdio: "ignore",
131
+ detached: true,
132
+ });
133
+ child.unref();
134
+ return Promise.resolve();
135
+ }
package/src/player.js CHANGED
@@ -386,6 +386,30 @@ export async function handlePlayCommand(args) {
386
386
  if (stdinData.trim()) hookData = JSON.parse(stdinData);
387
387
  } catch { /* no stdin or invalid JSON */ }
388
388
 
389
+ const notify = args.includes("--notify");
390
+
391
+ // Send system notification (detached, never blocks)
392
+ if (notify) {
393
+ const project = hookData.cwd ? hookData.cwd.replace(/\\/g, "/").split("/").pop() : null;
394
+ const notifTitle = project || "klaudio";
395
+ let notifBody = "Task complete";
396
+ if (hookData.last_assistant_message) {
397
+ // Extract first sentence for the notification body
398
+ const plain = hookData.last_assistant_message
399
+ .replace(/```[\s\S]*?```/g, "").replace(/`([^`]+)`/g, "$1")
400
+ .replace(/\*\*([^*]+)\*\*/g, "$1").replace(/\*([^*]+)\*/g, "$1")
401
+ .replace(/#{1,6}\s+/g, "").replace(/\[([^\]]+)\]\([^)]+\)/g, "$1")
402
+ .replace(/\([^)]*\)/g, "").replace(/\n+/g, " ").trim();
403
+ const first = plain.match(/^[^.!?]*[.!?]/)?.[0] || plain.slice(0, 120);
404
+ notifBody = first.trim();
405
+ } else if (hookData.message) {
406
+ notifBody = hookData.message.slice(0, 120);
407
+ }
408
+ import("./notify.js").then(({ sendNotification }) =>
409
+ sendNotification(notifTitle, notifBody)
410
+ ).catch(() => {});
411
+ }
412
+
389
413
  // Play sound (fire and forget, don't wait)
390
414
  const soundPromise = soundFile
391
415
  ? playSoundWithCancel(soundFile).promise.catch(() => {})
@@ -455,9 +479,10 @@ export async function handlePlayCommand(args) {
455
479
  /**
456
480
  * Generate the shell command string for use in Claude Code hooks.
457
481
  */
458
- export function getHookPlayCommand(soundFilePath, { tts = false, voice } = {}) {
482
+ export function getHookPlayCommand(soundFilePath, { tts = false, voice, notify = true } = {}) {
459
483
  const normalized = soundFilePath.replace(/\\/g, "/");
460
484
  const ttsFlag = tts ? " --tts" : "";
461
485
  const voiceFlag = tts && voice ? ` --voice ${voice}` : "";
462
- return `npx klaudio play "${normalized}"${ttsFlag}${voiceFlag}`;
486
+ const notifyFlag = notify ? " --notify" : "";
487
+ return `npx klaudio play "${normalized}"${ttsFlag}${voiceFlag}${notifyFlag}`;
463
488
  }
package/src/tts.js CHANGED
@@ -40,17 +40,80 @@ const KOKORO_VOICES = [
40
40
  // Singleton: reuse the loaded model across calls
41
41
  let kokoroInstance = null;
42
42
  let kokoroLoadPromise = null;
43
+ const KOKORO_DIR = join(homedir(), ".klaudio", "kokoro");
43
44
 
44
45
  /**
45
- * Load the Kokoro TTS model (singleton, downloads ~86MB on first use).
46
- * Uses CPU backend (DirectML has ConvTranspose compatibility issues).
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.
47
101
  */
48
102
  async function getKokoro() {
49
103
  if (kokoroInstance) return kokoroInstance;
50
104
  if (kokoroLoadPromise) return kokoroLoadPromise;
51
105
 
52
106
  kokoroLoadPromise = (async () => {
53
- const { KokoroTTS } = await import("kokoro-js");
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;
54
117
  kokoroInstance = await KokoroTTS.from_pretrained(
55
118
  "onnx-community/Kokoro-82M-v1.0-ONNX",
56
119
  { dtype: "q4", device: "cpu" },