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 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 for sounds, or provide custom files
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. **Install** — writes Claude Code hooks to your `settings.json`
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
- - **Pre-loads existing config** — re-running the installer shows your current sound selections
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 is downloaded automatically)
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "klaudio",
3
- "version": "0.8.0",
3
+ "version": "0.8.3",
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
@@ -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) onNext(items[sel].value);
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
- const msg = hookData.last_assistant_message;
396
- // Extract first sentence only
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(summary);
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 Piper TTS.
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
- 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();
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
- // Play the generated wav
218
- const { playSoundWithCancel } = await import("./player.js");
219
- await playSoundWithCancel(outPath, { maxSeconds: 0 }).promise.catch(() => {});
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
  }