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 +11 -0
- package/package.json +1 -2
- package/src/cli.js +40 -9
- package/src/installer.js +55 -2
- package/src/notify.js +135 -0
- package/src/player.js +27 -2
- package/src/tts.js +66 -3
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.
|
|
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:
|
|
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}: ${
|
|
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} → ${
|
|
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 +
|
|
122
|
-
DELAY=
|
|
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, "&").replace(/</g, "<").replace(/>/g, ">").replace(/"/g, """).replace(/'/g, "'");
|
|
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
|
-
|
|
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
|
-
*
|
|
46
|
-
*
|
|
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
|
-
|
|
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" },
|