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 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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "klaudio",
3
- "version": "0.10.0",
3
+ "version": "0.10.2",
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
@@ -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: KOKORO_PRESET_VOICES[presetId],
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" ] && [ "$(cat "$MARKER" 2>/dev/null)" = "$TOKEN" ]; then
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
- await speakKokoro(text, voice);
266
- return;
267
- } catch {
268
- // Kokoro unavailable — fall through
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
- // macOS: use built-in `say`
272
- if (platform() === "darwin") {
273
- return speakMacOS(text);
274
- }
315
+ // macOS: use built-in `say`
316
+ if (platform() === "darwin") {
317
+ return speakMacOS(text);
318
+ }
275
319
 
276
- // Fallback: Piper
277
- return speakPiper(text, onProgress);
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 };