klaudio 0.10.3 → 0.11.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 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.3",
3
+ "version": "0.11.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": {
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) ────
@@ -126,7 +137,7 @@ const NavHint = ({ back = true, extra = "" }) =>
126
137
  );
127
138
 
128
139
  // ── Screen: Scope ───────────────────────────────────────────────
129
- const ScopeScreen = ({ onNext, onMusic, tts, onToggleTts }) => {
140
+ const ScopeScreen = ({ onNext, onMusic, tts, onToggleTts, outdatedReasons }) => {
130
141
  const items = [
131
142
  { label: "Global — Claude Code + Copilot (all projects)", value: "global" },
132
143
  { label: "This project — Claude Code + Copilot (this project only)", value: "project" },
@@ -150,7 +161,17 @@ const ScopeScreen = ({ onNext, onMusic, tts, onToggleTts }) => {
150
161
  }
151
162
  });
152
163
 
164
+ const isOutdated = outdatedReasons && outdatedReasons.length > 0;
165
+
153
166
  return h(Box, { flexDirection: "column" },
167
+ isOutdated
168
+ ? h(Box, { flexDirection: "column", marginLeft: 2, marginBottom: 1 },
169
+ h(Text, { color: "yellow", bold: true }, " Updates available — re-apply to enable:"),
170
+ ...outdatedReasons.map((r, i) =>
171
+ h(Text, { key: i, color: "yellow", dimColor: true, marginLeft: 4 }, `+ ${r}`),
172
+ ),
173
+ )
174
+ : null,
154
175
  h(Text, { bold: true }, " Where should sounds be installed?"),
155
176
  h(Box, { flexDirection: "column", marginLeft: 2 },
156
177
  ...items.map((item, i) => h(React.Fragment, { key: item.value },
@@ -171,11 +192,14 @@ const ScopeScreen = ({ onNext, onMusic, tts, onToggleTts }) => {
171
192
  };
172
193
 
173
194
  // ── Screen: Preset ──────────────────────────────────────────────
174
- const PresetScreen = ({ existingSounds, onNext, onReapply, onBack }) => {
195
+ const PresetScreen = ({ existingSounds, outdatedReasons, onNext, onReapply, onBack }) => {
175
196
  const hasExisting = existingSounds && Object.values(existingSounds).some(Boolean);
197
+ const isOutdated = outdatedReasons && outdatedReasons.length > 0;
176
198
  const items = [
177
199
  ...(hasExisting ? [{
178
- label: "✓ Re-apply current sounds — update config with current selections",
200
+ label: isOutdated
201
+ ? "⬆ Update hooks — new features available for your current sounds"
202
+ : "✓ Re-apply current sounds — update config with current selections",
179
203
  value: "_reapply",
180
204
  }] : []),
181
205
  ...Object.entries(PRESETS).map(([id, p]) => ({
@@ -206,8 +230,16 @@ const PresetScreen = ({ existingSounds, onNext, onReapply, onBack }) => {
206
230
  ? h(Box, { flexDirection: "column", marginLeft: 4, marginBottom: 1 },
207
231
  h(Text, { dimColor: true }, "Current sounds:"),
208
232
  ...Object.entries(existingSounds).filter(([_, p]) => p).map(([eid, p]) =>
209
- h(Text, { key: eid, color: "green", dimColor: true }, ` ✓ ${EVENTS[eid].name}: ${basename(p)}`),
233
+ h(Text, { key: eid, color: "green", dimColor: true }, ` ✓ ${EVENTS[eid].name}: ${shortName(p)}`),
210
234
  ),
235
+ isOutdated
236
+ ? h(Box, { flexDirection: "column", marginTop: 1 },
237
+ h(Text, { color: "yellow" }, " Updates available:"),
238
+ ...outdatedReasons.map((r, i) =>
239
+ h(Text, { key: i, color: "yellow", dimColor: true }, ` + ${r}`),
240
+ ),
241
+ )
242
+ : null,
211
243
  )
212
244
  : null,
213
245
  h(Box, { flexDirection: "column", marginLeft: 2 },
@@ -1370,11 +1402,11 @@ const ExtractingScreen = ({ game, onDone, onBack }) => {
1370
1402
  };
1371
1403
 
1372
1404
  // ── Screen: Confirm ─────────────────────────────────────────────
1373
- const ConfirmScreen = ({ scope, sounds, tts, voice, onToggleTts, onCycleVoice, onConfirm, onBack }) => {
1405
+ const ConfirmScreen = ({ scope, sounds, tts, voice, hasKokoro, onToggleTts, onCycleVoice, onConfirm, onBack }) => {
1374
1406
  useInput((input, key) => {
1375
1407
  if (key.escape) onBack();
1376
1408
  else if (input === "t") onToggleTts();
1377
- else if (input === "v" && tts) onCycleVoice();
1409
+ else if (input === "v" && tts && hasKokoro) onCycleVoice();
1378
1410
  });
1379
1411
 
1380
1412
  const items = [
@@ -1383,7 +1415,7 @@ const ConfirmScreen = ({ scope, sounds, tts, voice, onToggleTts, onCycleVoice, o
1383
1415
  ];
1384
1416
 
1385
1417
  const soundEntries = Object.entries(sounds).filter(([_, path]) => path);
1386
- const voiceInfo = KOKORO_VOICES.find((v) => v.id === voice);
1418
+ const voiceInfo = hasKokoro ? KOKORO_VOICES.find((v) => v.id === voice) : null;
1387
1419
 
1388
1420
  return h(Box, { flexDirection: "column" },
1389
1421
  h(Text, { bold: true, marginLeft: 2 }, " Ready to install:"),
@@ -1391,7 +1423,7 @@ const ConfirmScreen = ({ scope, sounds, tts, voice, onToggleTts, onCycleVoice, o
1391
1423
  h(Text, { marginLeft: 4 }, `Scope: ${scope === "global" ? "Global (Claude Code + Copilot)" : "This project (Claude Code + Copilot)"}`),
1392
1424
  ...soundEntries.map(([eid, path]) =>
1393
1425
  h(Text, { key: eid, marginLeft: 4 },
1394
- `${EVENTS[eid].name} → ${basename(path)}`
1426
+ `${EVENTS[eid].name} → ${shortName(path)}`
1395
1427
  )
1396
1428
  ),
1397
1429
  h(Box, { marginLeft: 4, marginTop: 1 },
@@ -1530,10 +1562,28 @@ const InstallApp = () => {
1530
1562
  const [installResult, setInstallResult] = useState(null);
1531
1563
  const [tts, setTts] = useState(true);
1532
1564
  const [voice, setVoice] = useState(KOKORO_DEFAULT_VOICE);
1565
+ const [hasKokoro, setHasKokoro] = useState(false);
1566
+ const [outdatedReasons, setOutdatedReasons] = useState([]);
1533
1567
  const [musicFiles, setMusicFiles] = useState([]);
1534
1568
  const [musicGameName, setMusicGameName] = useState(null);
1535
1569
  const [musicShuffle, setMusicShuffle] = useState(false);
1536
1570
 
1571
+ useEffect(() => {
1572
+ isKokoroAvailable().then(setHasKokoro).catch(() => {});
1573
+ // Check both scopes for outdated hooks on startup
1574
+ Promise.all([
1575
+ checkHooksOutdated("global"),
1576
+ checkHooksOutdated("project"),
1577
+ ]).then(([g, p]) => {
1578
+ const combined = [...new Set([...g, ...p])];
1579
+ setOutdatedReasons(combined);
1580
+ }).catch(() => {});
1581
+ // Also pre-load existing sounds from global (most common)
1582
+ getExistingSounds("global").then((existing) => {
1583
+ if (Object.keys(existing).length > 0) setSounds(existing);
1584
+ }).catch(() => {});
1585
+ }, []);
1586
+
1537
1587
  const initSoundsFromPreset = useCallback((pid) => {
1538
1588
  const preset = PRESETS[pid];
1539
1589
  if (preset) setSounds({ ...preset.sounds });
@@ -1544,12 +1594,15 @@ const InstallApp = () => {
1544
1594
  case SCREEN.SCOPE:
1545
1595
  return h(ScopeScreen, {
1546
1596
  tts,
1597
+ outdatedReasons,
1547
1598
  onToggleTts: () => setTts((v) => !v),
1548
1599
  onNext: (s) => {
1549
1600
  setScope(s);
1601
+ // Refresh sounds/outdated for the selected scope
1550
1602
  getExistingSounds(s).then((existing) => {
1551
1603
  if (Object.keys(existing).length > 0) setSounds(existing);
1552
1604
  });
1605
+ checkHooksOutdated(s).then(setOutdatedReasons).catch(() => {});
1553
1606
  setScreen(SCREEN.PRESET);
1554
1607
  },
1555
1608
  onMusic: () => setScreen(SCREEN.MUSIC_MODE),
@@ -1558,6 +1611,7 @@ const InstallApp = () => {
1558
1611
  case SCREEN.PRESET:
1559
1612
  return h(PresetScreen, {
1560
1613
  existingSounds: sounds,
1614
+ outdatedReasons,
1561
1615
  onReapply: () => setScreen(SCREEN.CONFIRM),
1562
1616
  onNext: (id) => {
1563
1617
  if (id === "_music") {
@@ -1660,6 +1714,7 @@ const InstallApp = () => {
1660
1714
  sounds,
1661
1715
  tts,
1662
1716
  voice,
1717
+ hasKokoro,
1663
1718
  onToggleTts: () => setTts((v) => !v),
1664
1719
  onCycleVoice: () => setVoice((v) => {
1665
1720
  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
@@ -66,6 +66,16 @@ async function ensureKokoroInstalled() {
66
66
  });
67
67
  }
68
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
+
69
79
  /**
70
80
  * Try to import kokoro-js from various locations.
71
81
  */