klaudio 0.7.0 → 0.8.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/README.md +20 -4
- package/bin/cli.js +9 -1
- package/package.json +1 -1
- package/src/cli.js +87 -22
- package/src/installer.js +5 -2
- package/src/player.js +44 -8
- package/src/tts.js +220 -0
package/README.md
CHANGED
|
@@ -11,9 +11,10 @@ npx klaudio
|
|
|
11
11
|
The interactive installer walks you through:
|
|
12
12
|
|
|
13
13
|
1. **Choose scope** — install globally (`~/.claude`) or per-project (`.claude/`), or launch the **Music Player**
|
|
14
|
-
2. **Pick a source** — use a built-in preset, scan your Steam & Epic Games library
|
|
14
|
+
2. **Pick a source** — use a built-in preset, OS system sounds, scan your Steam & Epic Games library, or provide custom files
|
|
15
15
|
3. **Preview & assign** — listen to sounds and assign them to events (tab to switch between events)
|
|
16
|
-
4. **
|
|
16
|
+
4. **Toggle voice summary** — enable TTS to hear a spoken summary when tasks complete
|
|
17
|
+
5. **Install** — writes Claude Code hooks to your `settings.json`
|
|
17
18
|
|
|
18
19
|
## Sound Sources
|
|
19
20
|
|
|
@@ -21,6 +22,10 @@ The interactive installer walks you through:
|
|
|
21
22
|
|
|
22
23
|
Ready-made sound packs (Retro 8-bit, Minimal Zen, Sci-Fi Terminal, Victory Fanfare) that work out of the box.
|
|
23
24
|
|
|
25
|
+
### System Sounds
|
|
26
|
+
|
|
27
|
+
Use your OS built-in notification sounds (Windows Media, macOS system sounds, Linux sound themes).
|
|
28
|
+
|
|
24
29
|
### Game Sound Scanner
|
|
25
30
|
|
|
26
31
|
Scans your local Steam and Epic Games libraries for audio files:
|
|
@@ -47,15 +52,25 @@ Play longer game tracks (90s–4min) as background music while you code:
|
|
|
47
52
|
|
|
48
53
|
Requires previously extracted game audio (use "Scan local games" first).
|
|
49
54
|
|
|
55
|
+
## Voice Summary (TTS)
|
|
56
|
+
|
|
57
|
+
When enabled, klaudio speaks a short summary of what Claude did after playing the task-complete sound. Uses [Piper](https://github.com/rhasspy/piper) for fast, offline neural text-to-speech (auto-downloaded on first use, ~40MB total).
|
|
58
|
+
|
|
59
|
+
- Toggle with `t` on the scope or confirm screen
|
|
60
|
+
- Reads the first sentence of Claude's last message
|
|
61
|
+
- Uses the `en_GB-alan-medium` voice (British male)
|
|
62
|
+
- Hooks receive data via stdin from Claude Code — no extra setup needed
|
|
63
|
+
|
|
50
64
|
## Features
|
|
51
65
|
|
|
52
66
|
- **Auto-preview** — sounds play automatically as you browse the list (toggle with `p`)
|
|
53
67
|
- **Multi-game selection** — pick sounds from different games, tab between events
|
|
54
68
|
- **Category filtering** — drill into voice, ambient, SFX, etc. when a game has enough variety
|
|
55
69
|
- **Type-to-filter** — start typing to narrow down long lists
|
|
70
|
+
- **Duration filter** — type `<10s`, `>5s`, `<=3s` etc. to filter by audio length
|
|
56
71
|
- **10-second clamp** — long sounds are processed with ffmpeg: silence stripped, fade out baked in
|
|
57
72
|
- **Background scanning** — game list updates live as directories are scanned
|
|
58
|
-
- **
|
|
73
|
+
- **Re-apply current sounds** — re-running the installer shows your current selections with a quick re-apply option
|
|
59
74
|
|
|
60
75
|
## Events
|
|
61
76
|
|
|
@@ -74,7 +89,8 @@ npx klaudio --uninstall
|
|
|
74
89
|
|
|
75
90
|
- Node.js 18+ (Claude Code already requires this)
|
|
76
91
|
- [Claude Code](https://docs.anthropic.com/en/docs/claude-code) installed
|
|
77
|
-
- For packed audio extraction: internet connection (vgmstream-cli
|
|
92
|
+
- For packed audio extraction: internet connection (vgmstream-cli downloaded automatically)
|
|
93
|
+
- For voice summaries: internet connection on first use (Piper TTS downloaded automatically)
|
|
78
94
|
- For best playback with fade effects: [ffmpeg/ffplay](https://ffmpeg.org/) on PATH (falls back to native players)
|
|
79
95
|
|
|
80
96
|
> **Note:** Currently only tested on Windows. macOS and Linux support is planned but not yet verified.
|
package/bin/cli.js
CHANGED
|
@@ -1,6 +1,14 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
// Subcommand: klaudio play <file> [--tts]
|
|
4
|
+
if (process.argv[2] === "play") {
|
|
5
|
+
const { handlePlayCommand } = await import("../src/player.js");
|
|
6
|
+
await handlePlayCommand(process.argv.slice(3));
|
|
7
|
+
process.exit(0);
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
// Default: interactive installer UI
|
|
11
|
+
const { run } = await import("../src/cli.js");
|
|
4
12
|
|
|
5
13
|
run().catch((err) => {
|
|
6
14
|
if (err.name === "ExitPromptError") {
|
package/package.json
CHANGED
package/src/cli.js
CHANGED
|
@@ -39,8 +39,17 @@ const SelectInput = ({ items = [], isFocused = true, initialIndex = 0, indicator
|
|
|
39
39
|
const prevValues = previousItems.current.map((i) => i.value);
|
|
40
40
|
const curValues = items.map((i) => i.value);
|
|
41
41
|
if (prevValues.length !== curValues.length || prevValues.some((v, i) => v !== curValues[i])) {
|
|
42
|
-
|
|
43
|
-
|
|
42
|
+
// Try to keep the currently selected item highlighted
|
|
43
|
+
const prevSelected = previousItems.current[selectedIndex];
|
|
44
|
+
const newIdx = prevSelected ? items.findIndex((i) => i.value === prevSelected.value) : -1;
|
|
45
|
+
if (newIdx >= 0) {
|
|
46
|
+
setSelectedIndex(newIdx);
|
|
47
|
+
setScrollOffset(hasLimit ? Math.max(0, Math.min(newIdx, items.length - limit)) : 0);
|
|
48
|
+
} else {
|
|
49
|
+
// Selected item gone — reset to top
|
|
50
|
+
setScrollOffset(0);
|
|
51
|
+
setSelectedIndex(0);
|
|
52
|
+
}
|
|
44
53
|
}
|
|
45
54
|
previousItems.current = items;
|
|
46
55
|
}, [items]);
|
|
@@ -115,7 +124,7 @@ const NavHint = ({ back = true, extra = "" }) =>
|
|
|
115
124
|
);
|
|
116
125
|
|
|
117
126
|
// ── Screen: Scope ───────────────────────────────────────────────
|
|
118
|
-
const ScopeScreen = ({ onNext, onMusic }) => {
|
|
127
|
+
const ScopeScreen = ({ onNext, onMusic, tts, onToggleTts }) => {
|
|
119
128
|
const items = [
|
|
120
129
|
{ label: "Global — Claude Code + Copilot (all projects)", value: "global" },
|
|
121
130
|
{ label: "This project — Claude Code + Copilot (this project only)", value: "project" },
|
|
@@ -130,6 +139,8 @@ const ScopeScreen = ({ onNext, onMusic }) => {
|
|
|
130
139
|
setSel((i) => Math.max(0, i - 1));
|
|
131
140
|
} else if (input === "j" || key.downArrow) {
|
|
132
141
|
setSel((i) => Math.min(items.length - 1, i + 1));
|
|
142
|
+
} else if (input === "t") {
|
|
143
|
+
onToggleTts();
|
|
133
144
|
} else if (key.return) {
|
|
134
145
|
const v = items[sel].value;
|
|
135
146
|
if (v === "_music") onMusic();
|
|
@@ -148,27 +159,63 @@ const ScopeScreen = ({ onNext, onMusic }) => {
|
|
|
148
159
|
),
|
|
149
160
|
)),
|
|
150
161
|
),
|
|
162
|
+
h(Box, { marginTop: 1, marginLeft: 4 },
|
|
163
|
+
h(Text, { color: tts ? "green" : "gray" },
|
|
164
|
+
tts ? "🗣 Voice summary: ON" : "🗣 Voice summary: OFF",
|
|
165
|
+
),
|
|
166
|
+
h(Text, { dimColor: true }, " (t to toggle)"),
|
|
167
|
+
),
|
|
151
168
|
);
|
|
152
169
|
};
|
|
153
170
|
|
|
154
171
|
// ── Screen: Preset ──────────────────────────────────────────────
|
|
155
|
-
const PresetScreen = ({ onNext, onBack }) => {
|
|
156
|
-
|
|
157
|
-
|
|
172
|
+
const PresetScreen = ({ existingSounds, onNext, onReapply, onBack }) => {
|
|
173
|
+
const hasExisting = existingSounds && Object.values(existingSounds).some(Boolean);
|
|
158
174
|
const items = [
|
|
175
|
+
...(hasExisting ? [{
|
|
176
|
+
label: "✓ Re-apply current sounds — update config with current selections",
|
|
177
|
+
value: "_reapply",
|
|
178
|
+
}] : []),
|
|
159
179
|
...Object.entries(PRESETS).map(([id, p]) => ({
|
|
160
180
|
label: `${p.icon} ${p.name} — ${p.description}`,
|
|
161
181
|
value: id,
|
|
162
182
|
})),
|
|
183
|
+
// separator before these
|
|
163
184
|
{ label: "🔔 System sounds — use built-in OS notification sounds", value: "_system" },
|
|
164
|
-
{ label: "🕹️ Scan
|
|
185
|
+
{ label: "🕹️ Scan your games library — find sounds from Steam & Epic Games", value: "_scan" },
|
|
165
186
|
{ label: "📁 Custom files — provide your own sound files", value: "_custom" },
|
|
166
187
|
];
|
|
188
|
+
const GAP_AT = (hasExisting ? 1 : 0) + Object.keys(PRESETS).length; // separator before non-preset options
|
|
189
|
+
const [sel, setSel] = useState(0);
|
|
190
|
+
|
|
191
|
+
useInput((input, key) => {
|
|
192
|
+
if (key.escape) onBack();
|
|
193
|
+
else if (input === "k" || key.upArrow) setSel((i) => Math.max(0, i - 1));
|
|
194
|
+
else if (input === "j" || key.downArrow) setSel((i) => Math.min(items.length - 1, i + 1));
|
|
195
|
+
else if (key.return) {
|
|
196
|
+
if (items[sel].value === "_reapply") onReapply();
|
|
197
|
+
else onNext(items[sel].value);
|
|
198
|
+
}
|
|
199
|
+
});
|
|
167
200
|
|
|
168
201
|
return h(Box, { flexDirection: "column" },
|
|
169
202
|
h(Text, { bold: true }, " Choose a sound preset:"),
|
|
170
|
-
|
|
171
|
-
h(
|
|
203
|
+
hasExisting
|
|
204
|
+
? h(Box, { flexDirection: "column", marginLeft: 4, marginBottom: 1 },
|
|
205
|
+
h(Text, { dimColor: true }, "Current sounds:"),
|
|
206
|
+
...Object.entries(existingSounds).filter(([_, p]) => p).map(([eid, p]) =>
|
|
207
|
+
h(Text, { key: eid, color: "green", dimColor: true }, ` ✓ ${EVENTS[eid].name}: ${basename(p)}`),
|
|
208
|
+
),
|
|
209
|
+
)
|
|
210
|
+
: null,
|
|
211
|
+
h(Box, { flexDirection: "column", marginLeft: 2 },
|
|
212
|
+
...items.map((item, i) => h(React.Fragment, { key: item.value },
|
|
213
|
+
i === GAP_AT ? h(Text, { dimColor: true }, "\n ...or pick your own") : null,
|
|
214
|
+
h(Box, null,
|
|
215
|
+
h(Indicator, { isSelected: i === sel }),
|
|
216
|
+
h(Item, { isSelected: i === sel, label: item.label }),
|
|
217
|
+
),
|
|
218
|
+
)),
|
|
172
219
|
),
|
|
173
220
|
h(NavHint, { back: true }),
|
|
174
221
|
);
|
|
@@ -601,6 +648,16 @@ const GameSoundsScreen = ({ game, sounds, onSelectSound, onDone, onBack }) => {
|
|
|
601
648
|
return () => clearInterval(interval);
|
|
602
649
|
}, [playing]);
|
|
603
650
|
|
|
651
|
+
// Parse duration filter: "10s", "<10s", "< 10s", ">5s", "> 5s", "<=10s", ">=5s"
|
|
652
|
+
// (must be before early returns to satisfy React hook rules)
|
|
653
|
+
const durationFilter = useMemo(() => {
|
|
654
|
+
const m = filter.match(/^\s*(<|>|<=|>=)?\s*(\d+(?:\.\d+)?)\s*s\s*$/);
|
|
655
|
+
if (!m) return null;
|
|
656
|
+
const op = m[1] || "<=";
|
|
657
|
+
const val = parseFloat(m[2]);
|
|
658
|
+
return { op, val };
|
|
659
|
+
}, [filter]);
|
|
660
|
+
|
|
604
661
|
const eventId = eventIds[currentEvent];
|
|
605
662
|
const eventInfo = EVENTS[eventId];
|
|
606
663
|
const stepLabel = `(${currentEvent + 1}/${eventIds.length})`;
|
|
@@ -759,15 +816,6 @@ const GameSoundsScreen = ({ game, sounds, onSelectSound, onDone, onBack }) => {
|
|
|
759
816
|
// Phase 1: Browse and pick files (auto-preview plays on highlight)
|
|
760
817
|
const filterLower = filter.toLowerCase();
|
|
761
818
|
|
|
762
|
-
// Parse duration filter: "10s", "<10s", "< 10s", ">5s", "> 5s", "<=10s", ">=5s"
|
|
763
|
-
const durationFilter = useMemo(() => {
|
|
764
|
-
const m = filter.match(/^\s*(<|>|<=|>=)?\s*(\d+(?:\.\d+)?)\s*s\s*$/);
|
|
765
|
-
if (!m) return null;
|
|
766
|
-
const op = m[1] || "<=";
|
|
767
|
-
const val = parseFloat(m[2]);
|
|
768
|
-
return { op, val };
|
|
769
|
-
}, [filter]);
|
|
770
|
-
|
|
771
819
|
const allFileItems = categoryFiles.map((f) => {
|
|
772
820
|
const dur = fileDurations[f.path];
|
|
773
821
|
const durStr = dur != null ? ` (${dur}s${dur > MAX_PLAY_SECONDS ? `, preview ${MAX_PLAY_SECONDS}s` : ""})` : "";
|
|
@@ -1270,8 +1318,11 @@ const ExtractingScreen = ({ game, onDone, onBack }) => {
|
|
|
1270
1318
|
};
|
|
1271
1319
|
|
|
1272
1320
|
// ── Screen: Confirm ─────────────────────────────────────────────
|
|
1273
|
-
const ConfirmScreen = ({ scope, sounds, onConfirm, onBack }) => {
|
|
1274
|
-
useInput((
|
|
1321
|
+
const ConfirmScreen = ({ scope, sounds, tts, onToggleTts, onConfirm, onBack }) => {
|
|
1322
|
+
useInput((input, key) => {
|
|
1323
|
+
if (key.escape) onBack();
|
|
1324
|
+
else if (input === "t") onToggleTts();
|
|
1325
|
+
});
|
|
1275
1326
|
|
|
1276
1327
|
const items = [
|
|
1277
1328
|
{ label: "✓ Yes, install!", value: "yes" },
|
|
@@ -1289,6 +1340,12 @@ const ConfirmScreen = ({ scope, sounds, onConfirm, onBack }) => {
|
|
|
1289
1340
|
`${EVENTS[eid].name} → ${basename(path)}`
|
|
1290
1341
|
)
|
|
1291
1342
|
),
|
|
1343
|
+
h(Box, { marginLeft: 4, marginTop: 1 },
|
|
1344
|
+
h(Text, { color: tts ? "green" : "gray" },
|
|
1345
|
+
tts ? "🗣 Voice summary: ON" : "🗣 Voice summary: OFF",
|
|
1346
|
+
),
|
|
1347
|
+
h(Text, { dimColor: true }, " (t to toggle — reads a short summary when tasks complete)"),
|
|
1348
|
+
),
|
|
1292
1349
|
),
|
|
1293
1350
|
h(Box, { marginTop: 1, marginLeft: 2 },
|
|
1294
1351
|
h(SelectInput, { indicatorComponent: Indicator, itemComponent: Item, items, onSelect: (item) => {
|
|
@@ -1301,13 +1358,13 @@ const ConfirmScreen = ({ scope, sounds, onConfirm, onBack }) => {
|
|
|
1301
1358
|
};
|
|
1302
1359
|
|
|
1303
1360
|
// ── Screen: Installing ──────────────────────────────────────────
|
|
1304
|
-
const InstallingScreen = ({ scope, sounds, onDone }) => {
|
|
1361
|
+
const InstallingScreen = ({ scope, sounds, tts, onDone }) => {
|
|
1305
1362
|
useEffect(() => {
|
|
1306
1363
|
const validSounds = {};
|
|
1307
1364
|
for (const [eventId, path] of Object.entries(sounds)) {
|
|
1308
1365
|
if (path) validSounds[eventId] = path;
|
|
1309
1366
|
}
|
|
1310
|
-
install({ scope, sounds: validSounds }).then(onDone).catch((err) => {
|
|
1367
|
+
install({ scope, sounds: validSounds, tts }).then(onDone).catch((err) => {
|
|
1311
1368
|
onDone({ error: err.message });
|
|
1312
1369
|
});
|
|
1313
1370
|
}, []);
|
|
@@ -1411,6 +1468,7 @@ const InstallApp = () => {
|
|
|
1411
1468
|
const [sounds, setSounds] = useState({});
|
|
1412
1469
|
const [selectedGame, setSelectedGame] = useState(null);
|
|
1413
1470
|
const [installResult, setInstallResult] = useState(null);
|
|
1471
|
+
const [tts, setTts] = useState(true);
|
|
1414
1472
|
const [musicFiles, setMusicFiles] = useState([]);
|
|
1415
1473
|
const [musicGameName, setMusicGameName] = useState(null);
|
|
1416
1474
|
const [musicShuffle, setMusicShuffle] = useState(false);
|
|
@@ -1424,6 +1482,8 @@ const InstallApp = () => {
|
|
|
1424
1482
|
switch (screen) {
|
|
1425
1483
|
case SCREEN.SCOPE:
|
|
1426
1484
|
return h(ScopeScreen, {
|
|
1485
|
+
tts,
|
|
1486
|
+
onToggleTts: () => setTts((v) => !v),
|
|
1427
1487
|
onNext: (s) => {
|
|
1428
1488
|
setScope(s);
|
|
1429
1489
|
getExistingSounds(s).then((existing) => {
|
|
@@ -1436,6 +1496,8 @@ const InstallApp = () => {
|
|
|
1436
1496
|
|
|
1437
1497
|
case SCREEN.PRESET:
|
|
1438
1498
|
return h(PresetScreen, {
|
|
1499
|
+
existingSounds: sounds,
|
|
1500
|
+
onReapply: () => setScreen(SCREEN.CONFIRM),
|
|
1439
1501
|
onNext: (id) => {
|
|
1440
1502
|
if (id === "_music") {
|
|
1441
1503
|
setScreen(SCREEN.MUSIC_MODE);
|
|
@@ -1534,6 +1596,8 @@ const InstallApp = () => {
|
|
|
1534
1596
|
return h(ConfirmScreen, {
|
|
1535
1597
|
scope,
|
|
1536
1598
|
sounds,
|
|
1599
|
+
tts,
|
|
1600
|
+
onToggleTts: () => setTts((v) => !v),
|
|
1537
1601
|
onConfirm: () => setScreen(SCREEN.INSTALLING),
|
|
1538
1602
|
onBack: () => {
|
|
1539
1603
|
if (selectedGame) setScreen(SCREEN.GAME_SOUNDS);
|
|
@@ -1545,6 +1609,7 @@ const InstallApp = () => {
|
|
|
1545
1609
|
return h(InstallingScreen, {
|
|
1546
1610
|
scope,
|
|
1547
1611
|
sounds,
|
|
1612
|
+
tts,
|
|
1548
1613
|
onDone: (result) => {
|
|
1549
1614
|
setInstallResult(result);
|
|
1550
1615
|
setScreen(SCREEN.DONE);
|
package/src/installer.js
CHANGED
|
@@ -20,8 +20,9 @@ function getTargetDir(scope) {
|
|
|
20
20
|
* @param {object} options
|
|
21
21
|
* @param {string} options.scope - "global" or "project"
|
|
22
22
|
* @param {Record<string, string>} options.sounds - Map of event ID -> source sound file path
|
|
23
|
+
* @param {boolean} [options.tts] - Enable TTS voice summary on task complete
|
|
23
24
|
*/
|
|
24
|
-
export async function install({ scope, sounds }) {
|
|
25
|
+
export async function install({ scope, sounds, tts = false }) {
|
|
25
26
|
const claudeDir = getTargetDir(scope);
|
|
26
27
|
const soundsDir = join(claudeDir, "sounds");
|
|
27
28
|
const settingsFile = join(claudeDir, "settings.json");
|
|
@@ -60,7 +61,9 @@ export async function install({ scope, sounds }) {
|
|
|
60
61
|
if (!event) continue;
|
|
61
62
|
|
|
62
63
|
const hookEvent = event.hookEvent;
|
|
63
|
-
|
|
64
|
+
// Enable TTS only for the "stop" event (task complete)
|
|
65
|
+
const useTts = tts && eventId === "stop";
|
|
66
|
+
const playCommand = getHookPlayCommand(soundPath, { tts: useTts });
|
|
64
67
|
|
|
65
68
|
// Check if there's already a klaudio hook for this event
|
|
66
69
|
if (!settings.hooks[hookEvent]) {
|
package/src/player.js
CHANGED
|
@@ -363,16 +363,52 @@ export async function processSound(filePath) {
|
|
|
363
363
|
}
|
|
364
364
|
|
|
365
365
|
/**
|
|
366
|
-
*
|
|
366
|
+
* Handle the "play" subcommand: play a sound file and optionally speak a TTS summary.
|
|
367
|
+
* Reads hook JSON from stdin to get last_assistant_message for TTS.
|
|
367
368
|
*/
|
|
368
|
-
export function
|
|
369
|
-
const
|
|
370
|
-
const
|
|
371
|
-
const needsFfplay = !MEDIA_PLAYER_FORMATS.has(ext);
|
|
369
|
+
export async function handlePlayCommand(args) {
|
|
370
|
+
const soundFile = args.find((a) => !a.startsWith("-"));
|
|
371
|
+
const tts = args.includes("--tts");
|
|
372
372
|
|
|
373
|
-
|
|
374
|
-
|
|
373
|
+
// Read stdin (hook JSON) non-blocking
|
|
374
|
+
let hookData = {};
|
|
375
|
+
try {
|
|
376
|
+
const chunks = [];
|
|
377
|
+
process.stdin.setEncoding("utf-8");
|
|
378
|
+
// Read whatever is available with a short timeout
|
|
379
|
+
const stdinData = await new Promise((res) => {
|
|
380
|
+
const timer = setTimeout(() => { process.stdin.pause(); res(chunks.join("")); }, 500);
|
|
381
|
+
process.stdin.on("data", (chunk) => chunks.push(chunk));
|
|
382
|
+
process.stdin.on("end", () => { clearTimeout(timer); res(chunks.join("")); });
|
|
383
|
+
process.stdin.resume();
|
|
384
|
+
});
|
|
385
|
+
if (stdinData.trim()) hookData = JSON.parse(stdinData);
|
|
386
|
+
} catch { /* no stdin or invalid JSON */ }
|
|
387
|
+
|
|
388
|
+
// Play sound (fire and forget, don't wait)
|
|
389
|
+
const soundPromise = soundFile
|
|
390
|
+
? playSoundWithCancel(soundFile).promise.catch(() => {})
|
|
391
|
+
: Promise.resolve();
|
|
392
|
+
|
|
393
|
+
// TTS: speak first 1-2 sentences of last_assistant_message
|
|
394
|
+
if (tts && hookData.last_assistant_message) {
|
|
395
|
+
const msg = hookData.last_assistant_message;
|
|
396
|
+
// Extract first sentence only
|
|
397
|
+
const sentences = msg.match(/[^.!?]*[.!?]/g);
|
|
398
|
+
const summary = sentences ? sentences[0].trim() : msg.slice(0, 100);
|
|
399
|
+
await soundPromise;
|
|
400
|
+
const { speak } = await import("./tts.js");
|
|
401
|
+
await speak(summary);
|
|
402
|
+
} else {
|
|
403
|
+
await soundPromise;
|
|
375
404
|
}
|
|
405
|
+
}
|
|
376
406
|
|
|
377
|
-
|
|
407
|
+
/**
|
|
408
|
+
* Generate the shell command string for use in Claude Code hooks.
|
|
409
|
+
*/
|
|
410
|
+
export function getHookPlayCommand(soundFilePath, { tts = false } = {}) {
|
|
411
|
+
const normalized = soundFilePath.replace(/\\/g, "/");
|
|
412
|
+
const ttsFlag = tts ? " --tts" : "";
|
|
413
|
+
return `npx klaudio play "${normalized}"${ttsFlag}`;
|
|
378
414
|
}
|
package/src/tts.js
ADDED
|
@@ -0,0 +1,220 @@
|
|
|
1
|
+
import { execFile, spawn } from "node:child_process";
|
|
2
|
+
import { mkdir, stat, rename, chmod } from "node:fs/promises";
|
|
3
|
+
import { createWriteStream } from "node:fs";
|
|
4
|
+
import { join, basename } from "node:path";
|
|
5
|
+
import { homedir, platform, arch, tmpdir } from "node:os";
|
|
6
|
+
import { pipeline } from "node:stream/promises";
|
|
7
|
+
import { createHash } from "node:crypto";
|
|
8
|
+
|
|
9
|
+
const PIPER_VERSION = "2023.11.14-2";
|
|
10
|
+
const VOICE_NAME = "en_GB-alan-medium";
|
|
11
|
+
const VOICE_SAMPLE_RATE = 22050;
|
|
12
|
+
|
|
13
|
+
const PIPER_DIR = join(homedir(), ".klaudio", "piper");
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Get the piper release asset name for the current platform.
|
|
17
|
+
*/
|
|
18
|
+
function getPiperAssetName() {
|
|
19
|
+
const os = platform();
|
|
20
|
+
const a = arch();
|
|
21
|
+
|
|
22
|
+
if (os === "win32") return "piper_windows_amd64.zip";
|
|
23
|
+
if (os === "darwin") return a === "arm64" ? "piper_macos_aarch64.tar.gz" : "piper_macos_x64.tar.gz";
|
|
24
|
+
// Linux
|
|
25
|
+
if (a === "arm64" || a === "aarch64") return "piper_linux_aarch64.tar.gz";
|
|
26
|
+
return "piper_linux_x86_64.tar.gz";
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Get the piper binary path.
|
|
31
|
+
*/
|
|
32
|
+
function getPiperBinPath() {
|
|
33
|
+
const bin = platform() === "win32" ? "piper.exe" : "piper";
|
|
34
|
+
return join(PIPER_DIR, "piper", bin);
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Get the voice model path.
|
|
39
|
+
*/
|
|
40
|
+
function getVoiceModelPath() {
|
|
41
|
+
return join(PIPER_DIR, `${VOICE_NAME}.onnx`);
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Download a file from a URL to a local path.
|
|
46
|
+
*/
|
|
47
|
+
async function downloadFile(url, destPath, onProgress) {
|
|
48
|
+
const res = await fetch(url, { redirect: "follow" });
|
|
49
|
+
if (!res.ok) throw new Error(`Download failed: ${res.status} ${res.statusText}`);
|
|
50
|
+
const total = parseInt(res.headers.get("content-length") || "0", 10);
|
|
51
|
+
let downloaded = 0;
|
|
52
|
+
|
|
53
|
+
const fileStream = createWriteStream(destPath);
|
|
54
|
+
const reader = res.body.getReader();
|
|
55
|
+
|
|
56
|
+
// Manual stream piping with progress
|
|
57
|
+
while (true) {
|
|
58
|
+
const { done, value } = await reader.read();
|
|
59
|
+
if (done) break;
|
|
60
|
+
fileStream.write(value);
|
|
61
|
+
downloaded += value.length;
|
|
62
|
+
if (onProgress && total > 0) {
|
|
63
|
+
onProgress(Math.round((downloaded / total) * 100));
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
fileStream.end();
|
|
68
|
+
await new Promise((resolve, reject) => {
|
|
69
|
+
fileStream.on("finish", resolve);
|
|
70
|
+
fileStream.on("error", reject);
|
|
71
|
+
});
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* Extract a .tar.gz or .zip archive.
|
|
76
|
+
*/
|
|
77
|
+
async function extractArchive(archivePath, destDir) {
|
|
78
|
+
const os = platform();
|
|
79
|
+
|
|
80
|
+
if (archivePath.endsWith(".zip")) {
|
|
81
|
+
if (os === "win32") {
|
|
82
|
+
// Use PowerShell to extract on Windows
|
|
83
|
+
await new Promise((resolve, reject) => {
|
|
84
|
+
execFile("powershell.exe", [
|
|
85
|
+
"-NoProfile", "-Command",
|
|
86
|
+
`Expand-Archive -Path '${archivePath}' -DestinationPath '${destDir}' -Force`,
|
|
87
|
+
], { windowsHide: true, timeout: 60000 }, (err) => err ? reject(err) : resolve());
|
|
88
|
+
});
|
|
89
|
+
} else {
|
|
90
|
+
await new Promise((resolve, reject) => {
|
|
91
|
+
execFile("unzip", ["-o", archivePath, "-d", destDir], { timeout: 60000 }, (err) => err ? reject(err) : resolve());
|
|
92
|
+
});
|
|
93
|
+
}
|
|
94
|
+
} else {
|
|
95
|
+
// tar.gz
|
|
96
|
+
await new Promise((resolve, reject) => {
|
|
97
|
+
execFile("tar", ["xzf", archivePath, "-C", destDir], { timeout: 60000 }, (err) => err ? reject(err) : resolve());
|
|
98
|
+
});
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* Ensure piper binary is available, downloading if needed.
|
|
104
|
+
* Returns the path to the piper executable.
|
|
105
|
+
*/
|
|
106
|
+
export async function ensurePiper(onProgress) {
|
|
107
|
+
const binPath = getPiperBinPath();
|
|
108
|
+
|
|
109
|
+
// Check if already downloaded
|
|
110
|
+
try {
|
|
111
|
+
await stat(binPath);
|
|
112
|
+
return binPath;
|
|
113
|
+
} catch { /* needs download */ }
|
|
114
|
+
|
|
115
|
+
try {
|
|
116
|
+
await mkdir(PIPER_DIR, { recursive: true });
|
|
117
|
+
|
|
118
|
+
const asset = getPiperAssetName();
|
|
119
|
+
const url = `https://github.com/rhasspy/piper/releases/download/${PIPER_VERSION}/${asset}`;
|
|
120
|
+
const archivePath = join(PIPER_DIR, asset);
|
|
121
|
+
|
|
122
|
+
if (onProgress) onProgress(`Downloading piper TTS...`);
|
|
123
|
+
await downloadFile(url, archivePath, (pct) => {
|
|
124
|
+
if (onProgress) onProgress(`Downloading piper TTS... ${pct}%`);
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
if (onProgress) onProgress("Extracting piper...");
|
|
128
|
+
await extractArchive(archivePath, PIPER_DIR);
|
|
129
|
+
|
|
130
|
+
// Make executable on Unix
|
|
131
|
+
if (platform() !== "win32") {
|
|
132
|
+
try { await chmod(binPath, 0o755); } catch { /* ignore */ }
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
return binPath;
|
|
136
|
+
} catch (err) {
|
|
137
|
+
// Clean up partial downloads
|
|
138
|
+
try { const { unlink } = await import("node:fs/promises"); await unlink(join(PIPER_DIR, getPiperAssetName())); } catch { /* ignore */ }
|
|
139
|
+
throw new Error(`Failed to download piper: ${err.message}`);
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
/**
|
|
144
|
+
* Ensure voice model is available, downloading if needed.
|
|
145
|
+
* Returns the path to the .onnx model file.
|
|
146
|
+
*/
|
|
147
|
+
export async function ensureVoiceModel(onProgress) {
|
|
148
|
+
const modelPath = getVoiceModelPath();
|
|
149
|
+
const configPath = modelPath + ".json";
|
|
150
|
+
|
|
151
|
+
// Check if already downloaded
|
|
152
|
+
try {
|
|
153
|
+
await stat(modelPath);
|
|
154
|
+
await stat(configPath);
|
|
155
|
+
return modelPath;
|
|
156
|
+
} catch { /* needs download */ }
|
|
157
|
+
|
|
158
|
+
try {
|
|
159
|
+
await mkdir(PIPER_DIR, { recursive: true });
|
|
160
|
+
|
|
161
|
+
const baseUrl = `https://huggingface.co/rhasspy/piper-voices/resolve/v1.0.0/en/en_GB/alan/medium`;
|
|
162
|
+
|
|
163
|
+
if (onProgress) onProgress("Downloading voice model...");
|
|
164
|
+
await downloadFile(`${baseUrl}/${VOICE_NAME}.onnx`, modelPath, (pct) => {
|
|
165
|
+
if (onProgress) onProgress(`Downloading voice model... ${pct}%`);
|
|
166
|
+
});
|
|
167
|
+
|
|
168
|
+
if (onProgress) onProgress("Downloading voice config...");
|
|
169
|
+
await downloadFile(`${baseUrl}/${VOICE_NAME}.onnx.json`, configPath);
|
|
170
|
+
|
|
171
|
+
return modelPath;
|
|
172
|
+
} catch (err) {
|
|
173
|
+
// Clean up partial downloads
|
|
174
|
+
const { unlink } = await import("node:fs/promises");
|
|
175
|
+
try { await unlink(modelPath); } catch { /* ignore */ }
|
|
176
|
+
try { await unlink(configPath); } catch { /* ignore */ }
|
|
177
|
+
throw new Error(`Failed to download voice model: ${err.message}`);
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
/**
|
|
182
|
+
* Speak text using Piper TTS.
|
|
183
|
+
* Auto-downloads piper and voice model on first use.
|
|
184
|
+
* Returns a promise that resolves when speech is done.
|
|
185
|
+
*/
|
|
186
|
+
export async function speak(text, onProgress) {
|
|
187
|
+
if (!text) return;
|
|
188
|
+
|
|
189
|
+
let piperBin, modelPath;
|
|
190
|
+
try {
|
|
191
|
+
[piperBin, modelPath] = await Promise.all([
|
|
192
|
+
ensurePiper(onProgress),
|
|
193
|
+
ensureVoiceModel(onProgress),
|
|
194
|
+
]);
|
|
195
|
+
} catch {
|
|
196
|
+
// TTS unavailable (download failed, offline, etc.) — skip silently
|
|
197
|
+
return;
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
// Generate to temp wav file
|
|
201
|
+
const hash = createHash("md5").update(text).digest("hex").slice(0, 8);
|
|
202
|
+
const outPath = join(tmpdir(), `klaudio-tts-${hash}.wav`);
|
|
203
|
+
|
|
204
|
+
await new Promise((resolve, reject) => {
|
|
205
|
+
const child = execFile(piperBin, [
|
|
206
|
+
"--model", modelPath,
|
|
207
|
+
"--output_file", outPath,
|
|
208
|
+
], { windowsHide: true, timeout: 15000 }, (err) => {
|
|
209
|
+
if (err) reject(err);
|
|
210
|
+
else resolve();
|
|
211
|
+
});
|
|
212
|
+
// Feed text via stdin
|
|
213
|
+
child.stdin.write(text);
|
|
214
|
+
child.stdin.end();
|
|
215
|
+
});
|
|
216
|
+
|
|
217
|
+
// Play the generated wav
|
|
218
|
+
const { playSoundWithCancel } = await import("./player.js");
|
|
219
|
+
await playSoundWithCancel(outPath, { maxSeconds: 0 }).promise.catch(() => {});
|
|
220
|
+
}
|