klaudio 0.8.4 → 0.9.0

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
@@ -43,7 +43,7 @@ Point to your own `.wav`/`.mp3` files.
43
43
 
44
44
  ## Music Player
45
45
 
46
- Play longer game tracks (90s4min) as background music while you code:
46
+ Play longer game tracks (30s10min) as background music while you code:
47
47
 
48
48
  - **Shuffle all** — scans all cached game audio, filters by duration, picks random tracks continuously
49
49
  - **Play songs from game** — pick a specific cached game and play its music
@@ -57,7 +57,7 @@ Requires previously extracted game audio (use "Scan local games" first).
57
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
58
 
59
59
  - Toggle with `t` on the scope or confirm screen
60
- - Reads the first sentence of Claude's last message
60
+ - Reads the first 1–2 sentences of Claude's last message (up to ~25 words), preserving version numbers and filenames
61
61
  - Uses the `en_GB-alan-medium` voice (British male)
62
62
  - Hooks receive data via stdin from Claude Code — no extra setup needed
63
63
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "klaudio",
3
- "version": "0.8.4",
3
+ "version": "0.9.0",
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
@@ -102,6 +102,7 @@ const SCREEN = {
102
102
  MUSIC_MODE: 10,
103
103
  MUSIC_GAME_PICK: 11,
104
104
  MUSIC_PLAYING: 12,
105
+ MUSIC_EXTRACTING: 13,
105
106
  };
106
107
 
107
108
  const isUninstallMode = process.argv.includes("--uninstall") || process.argv.includes("--remove");
@@ -930,53 +931,83 @@ const MusicModeScreen = ({ onRandom, onPickGame, onBack }) => {
930
931
  );
931
932
  };
932
933
 
933
- // ── Screen: Music Game Pick ─────────────────────────────────────
934
- const MusicGamePickScreen = ({ onNext, onBack }) => {
934
+ // ── Screen: Music Game Pick (scans all installed games) ─────────
935
+ const MusicGamePickScreen = ({ onNext, onExtract, onBack }) => {
935
936
  const [games, setGames] = useState([]);
936
- const [loading, setLoading] = useState(true);
937
+ const [scanning, setScanning] = useState(true);
938
+ const [scanStatus, setScanStatus] = useState("Discovering game directories...");
937
939
 
938
940
  useInput((_, key) => { if (key.escape) onBack(); });
939
941
 
940
942
  useEffect(() => {
941
943
  let cancelled = false;
942
- listCachedGames().then((cached) => {
943
- if (!cancelled) {
944
- setGames(cached);
945
- setLoading(false);
946
- }
944
+ getAvailableGames(
945
+ (progress) => {
946
+ if (cancelled) return;
947
+ if (progress.phase === "dirs") {
948
+ setScanStatus(`Scanning ${progress.dirs.length} directories...`);
949
+ } else if (progress.phase === "scanning") {
950
+ setScanStatus(`Scanning: ${progress.game}`);
951
+ }
952
+ },
953
+ (game) => {
954
+ if (cancelled) return;
955
+ if (!game.hasAudio && !game.canExtract) return; // skip games with no audio
956
+ setGames((prev) => {
957
+ if (prev.some((g) => g.name === game.name)) return prev;
958
+ const next = [...prev, game];
959
+ next.sort((a, b) => {
960
+ if (a.hasAudio !== b.hasAudio) return a.hasAudio ? -1 : 1;
961
+ if (a.canExtract !== b.canExtract) return a.canExtract ? -1 : 1;
962
+ return a.name.localeCompare(b.name);
963
+ });
964
+ return next;
965
+ });
966
+ },
967
+ ).then(() => {
968
+ if (!cancelled) setScanning(false);
969
+ }).catch(() => {
970
+ if (!cancelled) setScanning(false);
947
971
  });
948
972
  return () => { cancelled = true; };
949
973
  }, []);
950
974
 
951
- if (loading) {
975
+ if (scanning && games.length === 0) {
952
976
  return h(Box, { flexDirection: "column" },
953
977
  h(Box, { marginLeft: 2 },
954
978
  h(Text, { color: ACCENT }, h(Spinner, { type: "dots" })),
955
- h(Text, null, " Scanning cached games..."),
979
+ h(Text, null, ` ${scanStatus}`),
956
980
  ),
957
981
  );
958
982
  }
959
983
 
960
- if (games.length === 0) {
984
+ if (!scanning && games.length === 0) {
961
985
  return h(Box, { flexDirection: "column" },
962
- h(Text, { color: "yellow", marginLeft: 2 }, " No cached games found."),
963
- h(Text, { dimColor: true, marginLeft: 2 }, " Use \"Scan local games\" first to extract game audio."),
986
+ h(Text, { color: "yellow", marginLeft: 2 }, " No games with audio found."),
964
987
  h(NavHint, { back: true }),
965
988
  );
966
989
  }
967
990
 
968
- const items = games.map((g) => ({
969
- label: `${g.gameName} (${g.files.length} files)`,
970
- value: g.gameName,
971
- }));
991
+ const items = games.map((g) => {
992
+ const info = g.hasAudio ? `${g.fileCount} audio` : g.canExtract ? `${g.packedAudioCount + (g.unityAudioCount || 0)} packed` : "";
993
+ return { label: `${g.name}${info ? ` (${info})` : ""}`, value: g.name };
994
+ });
972
995
 
973
996
  return h(Box, { flexDirection: "column" },
974
997
  h(Text, { bold: true, marginLeft: 2 }, " Pick a game:"),
998
+ scanning ? h(Box, { marginLeft: 2 },
999
+ h(Text, { color: ACCENT }, h(Spinner, { type: "dots" })),
1000
+ h(Text, { dimColor: true }, ` ${scanStatus} (${games.length} games found)`),
1001
+ ) : null,
975
1002
  h(Box, { marginLeft: 2 },
976
1003
  h(SelectInput, { indicatorComponent: Indicator, itemComponent: Item, items, limit: 15,
977
1004
  onSelect: (item) => {
978
- const game = games.find((g) => g.gameName === item.value);
979
- onNext(game);
1005
+ const game = games.find((g) => g.name === item.value);
1006
+ if (game?.hasAudio) {
1007
+ onNext(game);
1008
+ } else {
1009
+ onExtract(game);
1010
+ }
980
1011
  },
981
1012
  }),
982
1013
  ),
@@ -993,26 +1024,24 @@ const MusicPlayingScreen = ({ files, gameName, shuffle: initialShuffle, onBack }
993
1024
  const [playing, setPlaying] = useState(false);
994
1025
  const [paused, setPaused] = useState(false);
995
1026
  const [elapsed, setElapsed] = useState(0);
996
- const [poolSize, setPoolSize] = useState(0);
1027
+ const [pool, setPool] = useState([]);
997
1028
  const cancelRef = useRef(null);
998
1029
  const pauseRef = useRef(null);
999
1030
  const resumeRef = useRef(null);
1000
1031
  const versionRef = useRef(0);
1001
1032
  const poolRef = useRef([]); // ever-growing pool of qualifying tracks
1002
- const lastPlayedRef = useRef(-1); // index of last played track in pool
1003
-
1004
- // Pick a random track from the pool (different from last played)
1005
- const pickFromPool = useCallback(() => {
1006
- const pool = poolRef.current;
1007
- if (pool.length === 0) return null;
1008
- if (pool.length === 1) return pool[0];
1009
- let idx;
1010
- do { idx = Math.floor(Math.random() * pool.length); } while (idx === lastPlayedRef.current && pool.length > 1);
1011
- lastPlayedRef.current = idx;
1012
- return pool[idx];
1033
+
1034
+ // Pick a random track from the pool (different from current)
1035
+ const pickRandom = useCallback((currentTrack) => {
1036
+ const p = poolRef.current;
1037
+ if (p.length === 0) return null;
1038
+ if (p.length === 1) return p[0];
1039
+ let pick;
1040
+ do { pick = p[Math.floor(Math.random() * p.length)]; } while (pick === currentTrack && p.length > 1);
1041
+ return pick;
1013
1042
  }, []);
1014
1043
 
1015
- // Scan files for duration (90s-4min), pick first random once found, keep scanning in background
1044
+ // Scan files for duration, pick first random once found, keep scanning in background
1016
1045
  const startedRef = useRef(false);
1017
1046
  useEffect(() => {
1018
1047
  let cancelled = false;
@@ -1026,27 +1055,27 @@ const MusicPlayingScreen = ({ files, gameName, shuffle: initialShuffle, onBack }
1026
1055
  return { ...f, duration: dur };
1027
1056
  }));
1028
1057
  for (const r of results) {
1029
- if (r.duration != null && r.duration >= 90 && r.duration <= 240) {
1058
+ if (r.duration != null && r.duration >= 30 && r.duration <= 600) {
1030
1059
  poolRef.current.push(r);
1031
1060
  }
1032
1061
  }
1033
1062
  const found = poolRef.current.length;
1034
1063
  setScanProgress({ done: Math.min(i + BATCH, files.length), total: files.length, found });
1035
- setPoolSize(found);
1064
+ setPool([...poolRef.current]);
1036
1065
  // Start playing the first time we find a qualifying track
1037
1066
  if (found >= 1 && !startedRef.current && !cancelled) {
1038
1067
  startedRef.current = true;
1039
- setTrack(pickFromPool());
1068
+ setTrack(pickRandom(null));
1040
1069
  setLoading(false);
1041
1070
  }
1042
1071
  }
1043
1072
  if (!cancelled) {
1044
1073
  setScanDone(true);
1045
- setPoolSize(poolRef.current.length);
1074
+ setPool([...poolRef.current]);
1046
1075
  if (!startedRef.current) {
1047
1076
  startedRef.current = true;
1048
1077
  if (poolRef.current.length > 0) {
1049
- setTrack(pickFromPool());
1078
+ setTrack(pickRandom(null));
1050
1079
  }
1051
1080
  setLoading(false);
1052
1081
  }
@@ -1070,8 +1099,7 @@ const MusicPlayingScreen = ({ files, gameName, shuffle: initialShuffle, onBack }
1070
1099
 
1071
1100
  promise.then(() => {
1072
1101
  if (versionRef.current === myVersion) {
1073
- // Track ended naturally — pick next random from pool
1074
- const next = pickFromPool();
1102
+ const next = pickRandom(track);
1075
1103
  if (next) setTrack(next);
1076
1104
  }
1077
1105
  }).catch(() => {});
@@ -1094,7 +1122,7 @@ const MusicPlayingScreen = ({ files, gameName, shuffle: initialShuffle, onBack }
1094
1122
  } else if (input === "n") {
1095
1123
  versionRef.current++;
1096
1124
  if (cancelRef.current) cancelRef.current();
1097
- const next = pickFromPool();
1125
+ const next = pickRandom(track);
1098
1126
  if (next) setTrack(next);
1099
1127
  } else if (input === " ") {
1100
1128
  if (paused) {
@@ -1123,7 +1151,7 @@ const MusicPlayingScreen = ({ files, gameName, shuffle: initialShuffle, onBack }
1123
1151
 
1124
1152
  if (!track) {
1125
1153
  return h(Box, { flexDirection: "column" },
1126
- h(Text, { color: "yellow", marginLeft: 2 }, " No tracks between 90s4min found."),
1154
+ h(Text, { color: "yellow", marginLeft: 2 }, " No tracks between 30s10min found."),
1127
1155
  h(Text, { dimColor: true, marginLeft: 2 }, " Try a different game or source."),
1128
1156
  h(NavHint, { back: true }),
1129
1157
  );
@@ -1131,6 +1159,17 @@ const MusicPlayingScreen = ({ files, gameName, shuffle: initialShuffle, onBack }
1131
1159
 
1132
1160
  const trackName = track.displayName || track.name || basename(track.path);
1133
1161
 
1162
+ // Build playlist items — highlight currently playing track
1163
+ const playlistItems = pool.map((t) => {
1164
+ const name = t.displayName || t.name || basename(t.path);
1165
+ const durStr = t.duration ? ` (${formatTime(t.duration)})` : "";
1166
+ const isPlaying = t.path === track.path;
1167
+ return {
1168
+ label: `${isPlaying ? "▶ " : " "}${name}${durStr}`,
1169
+ value: t.path,
1170
+ };
1171
+ });
1172
+
1134
1173
  return h(Box, { flexDirection: "column" },
1135
1174
  h(Box, { marginLeft: 2, flexDirection: "column", borderStyle: "round", borderColor: paused ? "yellow" : "green", paddingX: 2 },
1136
1175
  h(Text, { bold: true, color: paused ? "yellow" : "green" }, `🎵 ${gameName || "Music Player"}`),
@@ -1147,16 +1186,28 @@ const MusicPlayingScreen = ({ files, gameName, shuffle: initialShuffle, onBack }
1147
1186
  ` ${formatTime(elapsed)} / ${formatTime(track.duration || 0)}`,
1148
1187
  ),
1149
1188
  ),
1150
- h(Box, { marginTop: 1, marginLeft: 4 },
1189
+ h(Box, { marginTop: 1, marginLeft: 2 },
1151
1190
  scanDone
1152
- ? h(Text, { dimColor: true }, `${poolSize} music tracks indexed`)
1191
+ ? h(Text, { dimColor: true }, ` ${pool.length} tracks`)
1153
1192
  : h(Box, null,
1154
1193
  h(Text, { color: ACCENT }, h(Spinner, { type: "dots" })),
1155
- h(Text, { dimColor: true }, ` ${poolSize} music tracks indexed (${scanProgress.done}/${scanProgress.total} scanned)`),
1194
+ h(Text, { dimColor: true }, ` ${pool.length} tracks (${scanProgress.done}/${scanProgress.total} scanned)`),
1156
1195
  ),
1157
1196
  ),
1158
- h(Box, { marginTop: 1, marginLeft: 4 },
1159
- h(Text, { dimColor: true }, "n next space pause esc back"),
1197
+ h(Box, { marginLeft: 2 },
1198
+ h(SelectInput, { indicatorComponent: Indicator, itemComponent: Item, items: playlistItems, limit: 12,
1199
+ onSelect: (item) => {
1200
+ const picked = poolRef.current.find((t) => t.path === item.value);
1201
+ if (picked) {
1202
+ versionRef.current++;
1203
+ if (cancelRef.current) cancelRef.current();
1204
+ setTrack(picked);
1205
+ }
1206
+ },
1207
+ }),
1208
+ ),
1209
+ h(Box, { marginLeft: 4 },
1210
+ h(Text, { dimColor: true }, "n random space pause esc back"),
1160
1211
  ),
1161
1212
  );
1162
1213
  };
@@ -1637,11 +1688,15 @@ const InstallApp = () => {
1637
1688
  case SCREEN.MUSIC_GAME_PICK:
1638
1689
  return h(MusicGamePickScreen, {
1639
1690
  onNext: (game) => {
1640
- setMusicFiles(game.files);
1641
- setMusicGameName(game.gameName);
1691
+ setMusicFiles(game.files.map((f) => ({ ...f, gameName: game.name })));
1692
+ setMusicGameName(game.name);
1642
1693
  setMusicShuffle(false);
1643
1694
  setScreen(SCREEN.MUSIC_PLAYING);
1644
1695
  },
1696
+ onExtract: (game) => {
1697
+ setSelectedGame(game);
1698
+ setScreen(SCREEN.MUSIC_EXTRACTING);
1699
+ },
1645
1700
  onBack: () => setScreen(SCREEN.MUSIC_MODE),
1646
1701
  });
1647
1702
 
@@ -1653,6 +1708,23 @@ const InstallApp = () => {
1653
1708
  onBack: () => setScreen(SCREEN.MUSIC_MODE),
1654
1709
  });
1655
1710
 
1711
+ case SCREEN.MUSIC_EXTRACTING:
1712
+ return h(ExtractingScreen, {
1713
+ game: selectedGame,
1714
+ onDone: (result) => {
1715
+ if (result.error || result.files.length === 0) {
1716
+ setScreen(SCREEN.MUSIC_GAME_PICK);
1717
+ } else {
1718
+ // Go straight to playing the extracted files
1719
+ setMusicFiles(result.files.map((f) => ({ ...f, gameName: selectedGame.name })));
1720
+ setMusicGameName(selectedGame.name);
1721
+ setMusicShuffle(true);
1722
+ setScreen(SCREEN.MUSIC_PLAYING);
1723
+ }
1724
+ },
1725
+ onBack: () => setScreen(SCREEN.MUSIC_GAME_PICK),
1726
+ });
1727
+
1656
1728
  default:
1657
1729
  return h(Text, { color: "red" }, "Unknown screen");
1658
1730
  }
package/src/player.js CHANGED
@@ -208,8 +208,9 @@ export function playSoundWithCancel(filePath, { maxSeconds = MAX_PLAY_SECONDS }
208
208
  function killChild() {
209
209
  if (childProcess && !childProcess.killed) {
210
210
  try {
211
- // On Windows, spawned processes need taskkill for the process tree
212
211
  if (platform() === "win32") {
212
+ // Kill via Node handle first (immediate), then taskkill for child processes
213
+ try { childProcess.kill(); } catch { /* ignore */ }
213
214
  spawn("taskkill", ["/pid", String(childProcess.pid), "/f", "/t"], {
214
215
  stdio: "ignore", windowsHide: true,
215
216
  });
@@ -406,8 +407,35 @@ export async function handlePlayCommand(args) {
406
407
  .replace(/^\s*\d+\.\s+/gm, "") // numbered lists
407
408
  .replace(/\n+/g, " ") // newlines -> spaces
408
409
  .trim();
409
- const sentences = msg.match(/[^.!?]*[.!?]/g);
410
- const summary = sentences ? sentences[0].trim() : msg.slice(0, 100);
410
+ // Build summary: include sentences up to ~25 words max.
411
+ // Short next sentences (<4 chars, e.g. version numbers) are always included.
412
+ const MAX_WORDS = 25;
413
+ // Split on sentence-ending punctuation, but not periods between digits (0.8.4)
414
+ // or inside filenames (auth.js). A period is "sentence-ending" only if followed
415
+ // by a space+letter, end-of-string, or another sentence-end mark.
416
+ const sentences = msg.match(/(?:[^.!?]|\.(?=\d|\w{1,5}\b))*[.!?]+/g);
417
+ let summary;
418
+ if (!sentences) {
419
+ summary = msg.split(/\s+/).slice(0, MAX_WORDS).join(" ");
420
+ } else {
421
+ summary = sentences[0].trim();
422
+ for (let i = 1; i < sentences.length; i++) {
423
+ const next = sentences[i].trim();
424
+ const wordsSoFar = summary.split(/\s+/).length;
425
+ const nextWords = next.split(/\s+/).length;
426
+ // Always include tiny fragments (version numbers, short confirmations)
427
+ if (next.length < 4) {
428
+ summary += " " + next;
429
+ continue;
430
+ }
431
+ // Include next sentence if we're still under the word limit
432
+ if (wordsSoFar + nextWords <= MAX_WORDS) {
433
+ summary += " " + next;
434
+ } else {
435
+ break;
436
+ }
437
+ }
438
+ }
411
439
  // Prefix with project folder name if available
412
440
  const project = hookData.cwd ? hookData.cwd.replace(/\\/g, "/").split("/").pop() : null;
413
441
  const spoken = project ? `${project}: ${summary}` : summary;