klaudio 0.10.0 → 0.10.2
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 +14 -1
- package/package.json +1 -1
- package/src/cli.js +18 -3
- package/src/installer.js +6 -2
- package/src/tts.js +65 -17
package/bin/cli.js
CHANGED
|
@@ -1,12 +1,25 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
|
|
3
|
-
// Subcommand: klaudio play <file> [--tts]
|
|
3
|
+
// Subcommand: klaudio play <file> [--tts] [--voice <voice>]
|
|
4
4
|
if (process.argv[2] === "play") {
|
|
5
5
|
const { handlePlayCommand } = await import("../src/player.js");
|
|
6
6
|
await handlePlayCommand(process.argv.slice(3));
|
|
7
7
|
process.exit(0);
|
|
8
8
|
}
|
|
9
9
|
|
|
10
|
+
// Subcommand: klaudio say "text" [--voice <voice>]
|
|
11
|
+
if (process.argv[2] === "say") {
|
|
12
|
+
const args = process.argv.slice(3);
|
|
13
|
+
const text = args.find((a) => !a.startsWith("--"));
|
|
14
|
+
const voice = args.find((a) => a.startsWith("--voice="))?.slice(8)
|
|
15
|
+
|| args[args.indexOf("--voice") + 1];
|
|
16
|
+
if (text) {
|
|
17
|
+
const { speak } = await import("../src/tts.js");
|
|
18
|
+
await speak(text, { voice });
|
|
19
|
+
}
|
|
20
|
+
process.exit(0);
|
|
21
|
+
}
|
|
22
|
+
|
|
10
23
|
// Default: interactive installer UI
|
|
11
24
|
const { run } = await import("../src/cli.js");
|
|
12
25
|
|
package/package.json
CHANGED
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 } from "./tts.js";
|
|
5
|
+
import { KOKORO_PRESET_VOICES, KOKORO_VOICES, KOKORO_DEFAULT_VOICE } 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 } from "./installer.js";
|
|
@@ -1370,10 +1370,11 @@ const ExtractingScreen = ({ game, onDone, onBack }) => {
|
|
|
1370
1370
|
};
|
|
1371
1371
|
|
|
1372
1372
|
// ── Screen: Confirm ─────────────────────────────────────────────
|
|
1373
|
-
const ConfirmScreen = ({ scope, sounds, tts, onToggleTts, onConfirm, onBack }) => {
|
|
1373
|
+
const ConfirmScreen = ({ scope, sounds, tts, voice, onToggleTts, onCycleVoice, onConfirm, onBack }) => {
|
|
1374
1374
|
useInput((input, key) => {
|
|
1375
1375
|
if (key.escape) onBack();
|
|
1376
1376
|
else if (input === "t") onToggleTts();
|
|
1377
|
+
else if (input === "v" && tts) onCycleVoice();
|
|
1377
1378
|
});
|
|
1378
1379
|
|
|
1379
1380
|
const items = [
|
|
@@ -1382,6 +1383,7 @@ const ConfirmScreen = ({ scope, sounds, tts, onToggleTts, onConfirm, onBack }) =
|
|
|
1382
1383
|
];
|
|
1383
1384
|
|
|
1384
1385
|
const soundEntries = Object.entries(sounds).filter(([_, path]) => path);
|
|
1386
|
+
const voiceInfo = KOKORO_VOICES.find((v) => v.id === voice);
|
|
1385
1387
|
|
|
1386
1388
|
return h(Box, { flexDirection: "column" },
|
|
1387
1389
|
h(Text, { bold: true, marginLeft: 2 }, " Ready to install:"),
|
|
@@ -1398,6 +1400,12 @@ const ConfirmScreen = ({ scope, sounds, tts, onToggleTts, onConfirm, onBack }) =
|
|
|
1398
1400
|
),
|
|
1399
1401
|
h(Text, { dimColor: true }, " (t to toggle — reads a short summary when tasks complete)"),
|
|
1400
1402
|
),
|
|
1403
|
+
tts && voiceInfo ? h(Box, { marginLeft: 4 },
|
|
1404
|
+
h(Text, { color: ACCENT },
|
|
1405
|
+
`🎙 Voice: ${voiceInfo.name} (${voiceInfo.gender}, ${voiceInfo.accent})`,
|
|
1406
|
+
),
|
|
1407
|
+
h(Text, { dimColor: true }, " (v to change voice)"),
|
|
1408
|
+
) : null,
|
|
1401
1409
|
),
|
|
1402
1410
|
h(Box, { marginTop: 1, marginLeft: 2 },
|
|
1403
1411
|
h(SelectInput, { indicatorComponent: Indicator, itemComponent: Item, items, onSelect: (item) => {
|
|
@@ -1521,6 +1529,7 @@ const InstallApp = () => {
|
|
|
1521
1529
|
const [selectedGame, setSelectedGame] = useState(null);
|
|
1522
1530
|
const [installResult, setInstallResult] = useState(null);
|
|
1523
1531
|
const [tts, setTts] = useState(true);
|
|
1532
|
+
const [voice, setVoice] = useState(KOKORO_DEFAULT_VOICE);
|
|
1524
1533
|
const [musicFiles, setMusicFiles] = useState([]);
|
|
1525
1534
|
const [musicGameName, setMusicGameName] = useState(null);
|
|
1526
1535
|
const [musicShuffle, setMusicShuffle] = useState(false);
|
|
@@ -1569,6 +1578,7 @@ const InstallApp = () => {
|
|
|
1569
1578
|
} else {
|
|
1570
1579
|
setPresetId(id);
|
|
1571
1580
|
initSoundsFromPreset(id);
|
|
1581
|
+
if (KOKORO_PRESET_VOICES[id]) setVoice(KOKORO_PRESET_VOICES[id]);
|
|
1572
1582
|
setScreen(SCREEN.PREVIEW);
|
|
1573
1583
|
}
|
|
1574
1584
|
},
|
|
@@ -1649,7 +1659,12 @@ const InstallApp = () => {
|
|
|
1649
1659
|
scope,
|
|
1650
1660
|
sounds,
|
|
1651
1661
|
tts,
|
|
1662
|
+
voice,
|
|
1652
1663
|
onToggleTts: () => setTts((v) => !v),
|
|
1664
|
+
onCycleVoice: () => setVoice((v) => {
|
|
1665
|
+
const idx = KOKORO_VOICES.findIndex((x) => x.id === v);
|
|
1666
|
+
return KOKORO_VOICES[(idx + 1) % KOKORO_VOICES.length].id;
|
|
1667
|
+
}),
|
|
1653
1668
|
onConfirm: () => setScreen(SCREEN.INSTALLING),
|
|
1654
1669
|
onBack: () => {
|
|
1655
1670
|
if (selectedGame) setScreen(SCREEN.GAME_SOUNDS);
|
|
@@ -1662,7 +1677,7 @@ const InstallApp = () => {
|
|
|
1662
1677
|
scope,
|
|
1663
1678
|
sounds,
|
|
1664
1679
|
tts,
|
|
1665
|
-
voice
|
|
1680
|
+
voice,
|
|
1666
1681
|
onDone: (result) => {
|
|
1667
1682
|
setInstallResult(result);
|
|
1668
1683
|
setScreen(SCREEN.DONE);
|
package/src/installer.js
CHANGED
|
@@ -118,7 +118,7 @@ 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 if a tool isn't approved within DELAY seconds.
|
|
121
|
+
# Plays a sound + speaks a TTS message if a tool isn't approved within DELAY seconds.
|
|
122
122
|
DELAY=15
|
|
123
123
|
MARKER="/tmp/.claude-approval-pending"
|
|
124
124
|
SOUND="${normalized}"
|
|
@@ -126,12 +126,16 @@ SOUND="${normalized}"
|
|
|
126
126
|
case "$1" in
|
|
127
127
|
start)
|
|
128
128
|
TOKEN="$$-$(date +%s%N)"
|
|
129
|
+
# Store token and CWD so the delayed notification knows the project name
|
|
129
130
|
echo "$TOKEN" > "$MARKER"
|
|
131
|
+
echo "$PWD" >> "$MARKER"
|
|
130
132
|
(
|
|
131
133
|
sleep "$DELAY"
|
|
132
|
-
if [ -f "$MARKER" ] && [ "$(
|
|
134
|
+
if [ -f "$MARKER" ] && [ "$(head -1 "$MARKER" 2>/dev/null)" = "$TOKEN" ]; then
|
|
135
|
+
PROJECT=$(tail -1 "$MARKER" 2>/dev/null | sed 's|.*[/\\\\]||')
|
|
133
136
|
rm -f "$MARKER"
|
|
134
137
|
npx klaudio play "$SOUND" 2>/dev/null
|
|
138
|
+
npx klaudio say "\${PROJECT:-project} needs your attention" 2>/dev/null
|
|
135
139
|
fi
|
|
136
140
|
) &
|
|
137
141
|
;;
|
package/src/tts.js
CHANGED
|
@@ -21,6 +21,22 @@ const KOKORO_PRESET_VOICES = {
|
|
|
21
21
|
};
|
|
22
22
|
const KOKORO_DEFAULT_VOICE = "af_heart";
|
|
23
23
|
|
|
24
|
+
// Curated voice list for the picker (best quality voices)
|
|
25
|
+
const KOKORO_VOICES = [
|
|
26
|
+
{ id: "af_heart", name: "Heart", gender: "F", accent: "US", grade: "A" },
|
|
27
|
+
{ id: "af_bella", name: "Bella", gender: "F", accent: "US", grade: "A-" },
|
|
28
|
+
{ id: "af_nicole", name: "Nicole", gender: "F", accent: "US", grade: "B-" },
|
|
29
|
+
{ id: "af_nova", name: "Nova", gender: "F", accent: "US", grade: "C" },
|
|
30
|
+
{ id: "af_sky", name: "Sky", gender: "F", accent: "US", grade: "C-" },
|
|
31
|
+
{ id: "af_sarah", name: "Sarah", gender: "F", accent: "US", grade: "C+" },
|
|
32
|
+
{ id: "am_fenrir", name: "Fenrir", gender: "M", accent: "US", grade: "C+" },
|
|
33
|
+
{ id: "am_michael",name: "Michael",gender: "M", accent: "US", grade: "C+" },
|
|
34
|
+
{ id: "am_puck", name: "Puck", gender: "M", accent: "US", grade: "C+" },
|
|
35
|
+
{ id: "bf_emma", name: "Emma", gender: "F", accent: "UK", grade: "B-" },
|
|
36
|
+
{ id: "bm_george", name: "George", gender: "M", accent: "UK", grade: "C" },
|
|
37
|
+
{ id: "bm_fable", name: "Fable", gender: "M", accent: "UK", grade: "C" },
|
|
38
|
+
];
|
|
39
|
+
|
|
24
40
|
// Singleton: reuse the loaded model across calls
|
|
25
41
|
let kokoroInstance = null;
|
|
26
42
|
let kokoroLoadPromise = null;
|
|
@@ -244,9 +260,33 @@ function speakMacOS(text) {
|
|
|
244
260
|
|
|
245
261
|
// ── Public API ──────────────────────────────────────────────────
|
|
246
262
|
|
|
263
|
+
let speaking = false;
|
|
264
|
+
const TTS_LOCK = join(tmpdir(), ".klaudio-tts-lock");
|
|
265
|
+
|
|
266
|
+
/**
|
|
267
|
+
* Try to acquire a cross-process TTS lock.
|
|
268
|
+
* Returns true if acquired, false if another process is speaking.
|
|
269
|
+
* Stale locks (>30s) are automatically cleaned up.
|
|
270
|
+
*/
|
|
271
|
+
async function acquireTTSLock() {
|
|
272
|
+
try {
|
|
273
|
+
const lockStat = await stat(TTS_LOCK);
|
|
274
|
+
if (Date.now() - lockStat.mtimeMs < 30000) return false; // fresh lock, skip
|
|
275
|
+
} catch { /* no lock file, good */ }
|
|
276
|
+
try {
|
|
277
|
+
await fsWriteFile(TTS_LOCK, String(process.pid), "utf-8");
|
|
278
|
+
return true;
|
|
279
|
+
} catch { return false; }
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
async function releaseTTSLock() {
|
|
283
|
+
try { const { unlink } = await import("node:fs/promises"); await unlink(TTS_LOCK); } catch { /* ignore */ }
|
|
284
|
+
}
|
|
285
|
+
|
|
247
286
|
/**
|
|
248
287
|
* Speak text using the best available TTS engine.
|
|
249
288
|
* Priority: Kokoro (GPU/CPU) → Piper → macOS say
|
|
289
|
+
* Only one speak() call runs at a time — concurrent calls are skipped.
|
|
250
290
|
*
|
|
251
291
|
* @param {string} text - Text to speak
|
|
252
292
|
* @param {object} [options]
|
|
@@ -255,26 +295,34 @@ function speakMacOS(text) {
|
|
|
255
295
|
*/
|
|
256
296
|
export async function speak(text, options = {}) {
|
|
257
297
|
if (!text) return;
|
|
298
|
+
if (speaking) return; // in-process mutex
|
|
299
|
+
if (!await acquireTTSLock()) return; // cross-process mutex
|
|
300
|
+
speaking = true;
|
|
258
301
|
|
|
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
302
|
try {
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
303
|
+
const { voice, onProgress } = typeof options === "function"
|
|
304
|
+
? { voice: null, onProgress: options } // backwards compat: speak(text, onProgress)
|
|
305
|
+
: options;
|
|
306
|
+
|
|
307
|
+
// Try Kokoro first (works on all platforms, best quality)
|
|
308
|
+
try {
|
|
309
|
+
await speakKokoro(text, voice);
|
|
310
|
+
return;
|
|
311
|
+
} catch {
|
|
312
|
+
// Kokoro unavailable — fall through
|
|
313
|
+
}
|
|
270
314
|
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
315
|
+
// macOS: use built-in `say`
|
|
316
|
+
if (platform() === "darwin") {
|
|
317
|
+
return speakMacOS(text);
|
|
318
|
+
}
|
|
275
319
|
|
|
276
|
-
|
|
277
|
-
|
|
320
|
+
// Fallback: Piper
|
|
321
|
+
return speakPiper(text, onProgress);
|
|
322
|
+
} finally {
|
|
323
|
+
speaking = false;
|
|
324
|
+
await releaseTTSLock();
|
|
325
|
+
}
|
|
278
326
|
}
|
|
279
327
|
|
|
280
|
-
export { KOKORO_PRESET_VOICES };
|
|
328
|
+
export { KOKORO_PRESET_VOICES, KOKORO_VOICES, KOKORO_DEFAULT_VOICE };
|