klaudio 0.8.0 → 0.8.3
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/package.json +1 -1
- package/src/cli.js +21 -3
- package/src/player.js +18 -3
- package/src/tts.js +33 -15
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/package.json
CHANGED
package/src/cli.js
CHANGED
|
@@ -169,8 +169,13 @@ const ScopeScreen = ({ onNext, onMusic, tts, onToggleTts }) => {
|
|
|
169
169
|
};
|
|
170
170
|
|
|
171
171
|
// ── Screen: Preset ──────────────────────────────────────────────
|
|
172
|
-
const PresetScreen = ({ onNext, onBack }) => {
|
|
172
|
+
const PresetScreen = ({ existingSounds, onNext, onReapply, onBack }) => {
|
|
173
|
+
const hasExisting = existingSounds && Object.values(existingSounds).some(Boolean);
|
|
173
174
|
const items = [
|
|
175
|
+
...(hasExisting ? [{
|
|
176
|
+
label: "✓ Re-apply current sounds — update config with current selections",
|
|
177
|
+
value: "_reapply",
|
|
178
|
+
}] : []),
|
|
174
179
|
...Object.entries(PRESETS).map(([id, p]) => ({
|
|
175
180
|
label: `${p.icon} ${p.name} — ${p.description}`,
|
|
176
181
|
value: id,
|
|
@@ -180,18 +185,29 @@ const PresetScreen = ({ onNext, onBack }) => {
|
|
|
180
185
|
{ label: "🕹️ Scan your games library — find sounds from Steam & Epic Games", value: "_scan" },
|
|
181
186
|
{ label: "📁 Custom files — provide your own sound files", value: "_custom" },
|
|
182
187
|
];
|
|
183
|
-
const GAP_AT = Object.keys(PRESETS).length; // separator before non-preset options
|
|
188
|
+
const GAP_AT = (hasExisting ? 1 : 0) + Object.keys(PRESETS).length; // separator before non-preset options
|
|
184
189
|
const [sel, setSel] = useState(0);
|
|
185
190
|
|
|
186
191
|
useInput((input, key) => {
|
|
187
192
|
if (key.escape) onBack();
|
|
188
193
|
else if (input === "k" || key.upArrow) setSel((i) => Math.max(0, i - 1));
|
|
189
194
|
else if (input === "j" || key.downArrow) setSel((i) => Math.min(items.length - 1, i + 1));
|
|
190
|
-
else if (key.return)
|
|
195
|
+
else if (key.return) {
|
|
196
|
+
if (items[sel].value === "_reapply") onReapply();
|
|
197
|
+
else onNext(items[sel].value);
|
|
198
|
+
}
|
|
191
199
|
});
|
|
192
200
|
|
|
193
201
|
return h(Box, { flexDirection: "column" },
|
|
194
202
|
h(Text, { bold: true }, " Choose a sound preset:"),
|
|
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,
|
|
195
211
|
h(Box, { flexDirection: "column", marginLeft: 2 },
|
|
196
212
|
...items.map((item, i) => h(React.Fragment, { key: item.value },
|
|
197
213
|
i === GAP_AT ? h(Text, { dimColor: true }, "\n ...or pick your own") : null,
|
|
@@ -1480,6 +1496,8 @@ const InstallApp = () => {
|
|
|
1480
1496
|
|
|
1481
1497
|
case SCREEN.PRESET:
|
|
1482
1498
|
return h(PresetScreen, {
|
|
1499
|
+
existingSounds: sounds,
|
|
1500
|
+
onReapply: () => setScreen(SCREEN.CONFIRM),
|
|
1483
1501
|
onNext: (id) => {
|
|
1484
1502
|
if (id === "_music") {
|
|
1485
1503
|
setScreen(SCREEN.MUSIC_MODE);
|
package/src/player.js
CHANGED
|
@@ -392,13 +392,28 @@ export async function handlePlayCommand(args) {
|
|
|
392
392
|
|
|
393
393
|
// TTS: speak first 1-2 sentences of last_assistant_message
|
|
394
394
|
if (tts && hookData.last_assistant_message) {
|
|
395
|
-
|
|
396
|
-
|
|
395
|
+
// Strip markdown syntax and extract first sentence
|
|
396
|
+
const msg = hookData.last_assistant_message
|
|
397
|
+
.replace(/```[\s\S]*?```/g, "") // remove code blocks
|
|
398
|
+
.replace(/`([^`]+)`/g, "$1") // inline code -> text
|
|
399
|
+
.replace(/\*\*([^*]+)\*\*/g, "$1") // **bold** -> text
|
|
400
|
+
.replace(/\*([^*]+)\*/g, "$1") // *italic* -> text
|
|
401
|
+
.replace(/__([^_]+)__/g, "$1") // __bold__ -> text
|
|
402
|
+
.replace(/_([^_]+)_/g, "$1") // _italic_ -> text
|
|
403
|
+
.replace(/#{1,6}\s+/g, "") // headings
|
|
404
|
+
.replace(/\[([^\]]+)\]\([^)]+\)/g, "$1") // [links](url) -> text
|
|
405
|
+
.replace(/^\s*[-*+]\s+/gm, "") // list bullets
|
|
406
|
+
.replace(/^\s*\d+\.\s+/gm, "") // numbered lists
|
|
407
|
+
.replace(/\n+/g, " ") // newlines -> spaces
|
|
408
|
+
.trim();
|
|
397
409
|
const sentences = msg.match(/[^.!?]*[.!?]/g);
|
|
398
410
|
const summary = sentences ? sentences[0].trim() : msg.slice(0, 100);
|
|
411
|
+
// Prefix with project folder name if available
|
|
412
|
+
const project = hookData.cwd ? hookData.cwd.replace(/\\/g, "/").split("/").pop() : null;
|
|
413
|
+
const spoken = project ? `${project}: ${summary}` : summary;
|
|
399
414
|
await soundPromise;
|
|
400
415
|
const { speak } = await import("./tts.js");
|
|
401
|
-
await speak(
|
|
416
|
+
await speak(spoken);
|
|
402
417
|
} else {
|
|
403
418
|
await soundPromise;
|
|
404
419
|
}
|
package/src/tts.js
CHANGED
|
@@ -179,13 +179,27 @@ export async function ensureVoiceModel(onProgress) {
|
|
|
179
179
|
}
|
|
180
180
|
|
|
181
181
|
/**
|
|
182
|
-
* Speak text using
|
|
182
|
+
* Speak text using macOS `say` command (built-in, good quality).
|
|
183
|
+
*/
|
|
184
|
+
function speakMacOS(text) {
|
|
185
|
+
return new Promise((resolve) => {
|
|
186
|
+
execFile("say", ["-v", "Daniel", text], { timeout: 15000 }, () => resolve());
|
|
187
|
+
});
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
/**
|
|
191
|
+
* Speak text using Piper TTS, with macOS `say` fallback.
|
|
183
192
|
* Auto-downloads piper and voice model on first use.
|
|
184
193
|
* Returns a promise that resolves when speech is done.
|
|
185
194
|
*/
|
|
186
195
|
export async function speak(text, onProgress) {
|
|
187
196
|
if (!text) return;
|
|
188
197
|
|
|
198
|
+
// macOS: use built-in `say` — better compatibility, no dylib issues
|
|
199
|
+
if (platform() === "darwin") {
|
|
200
|
+
return speakMacOS(text);
|
|
201
|
+
}
|
|
202
|
+
|
|
189
203
|
let piperBin, modelPath;
|
|
190
204
|
try {
|
|
191
205
|
[piperBin, modelPath] = await Promise.all([
|
|
@@ -201,20 +215,24 @@ export async function speak(text, onProgress) {
|
|
|
201
215
|
const hash = createHash("md5").update(text).digest("hex").slice(0, 8);
|
|
202
216
|
const outPath = join(tmpdir(), `klaudio-tts-${hash}.wav`);
|
|
203
217
|
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
218
|
+
try {
|
|
219
|
+
await new Promise((resolve, reject) => {
|
|
220
|
+
const child = execFile(piperBin, [
|
|
221
|
+
"--model", modelPath,
|
|
222
|
+
"--output_file", outPath,
|
|
223
|
+
], { windowsHide: true, timeout: 15000 }, (err) => {
|
|
224
|
+
if (err) reject(err);
|
|
225
|
+
else resolve();
|
|
226
|
+
});
|
|
227
|
+
// Feed text via stdin
|
|
228
|
+
child.stdin.write(text);
|
|
229
|
+
child.stdin.end();
|
|
211
230
|
});
|
|
212
|
-
// Feed text via stdin
|
|
213
|
-
child.stdin.write(text);
|
|
214
|
-
child.stdin.end();
|
|
215
|
-
});
|
|
216
231
|
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
232
|
+
// Play the generated wav
|
|
233
|
+
const { playSoundWithCancel } = await import("./player.js");
|
|
234
|
+
await playSoundWithCancel(outPath, { maxSeconds: 0 }).promise.catch(() => {});
|
|
235
|
+
} catch {
|
|
236
|
+
// Piper failed (dylib error, etc.) — skip silently
|
|
237
|
+
}
|
|
220
238
|
}
|