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 +2 -2
- package/package.json +1 -1
- package/src/cli.js +120 -48
- package/src/player.js +31 -3
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 (
|
|
46
|
+
Play longer game tracks (30s–10min) 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
|
|
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
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 [
|
|
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
|
-
|
|
943
|
-
|
|
944
|
-
|
|
945
|
-
|
|
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 (
|
|
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,
|
|
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
|
|
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
|
-
|
|
970
|
-
value: g.
|
|
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.
|
|
979
|
-
|
|
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 [
|
|
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
|
-
|
|
1003
|
-
|
|
1004
|
-
|
|
1005
|
-
|
|
1006
|
-
|
|
1007
|
-
if (
|
|
1008
|
-
|
|
1009
|
-
|
|
1010
|
-
|
|
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
|
|
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 >=
|
|
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
|
-
|
|
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(
|
|
1068
|
+
setTrack(pickRandom(null));
|
|
1040
1069
|
setLoading(false);
|
|
1041
1070
|
}
|
|
1042
1071
|
}
|
|
1043
1072
|
if (!cancelled) {
|
|
1044
1073
|
setScanDone(true);
|
|
1045
|
-
|
|
1074
|
+
setPool([...poolRef.current]);
|
|
1046
1075
|
if (!startedRef.current) {
|
|
1047
1076
|
startedRef.current = true;
|
|
1048
1077
|
if (poolRef.current.length > 0) {
|
|
1049
|
-
setTrack(
|
|
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
|
-
|
|
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 =
|
|
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
|
|
1154
|
+
h(Text, { color: "yellow", marginLeft: 2 }, " No tracks between 30s–10min 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:
|
|
1189
|
+
h(Box, { marginTop: 1, marginLeft: 2 },
|
|
1151
1190
|
scanDone
|
|
1152
|
-
? h(Text, { dimColor: true },
|
|
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 }, ` ${
|
|
1194
|
+
h(Text, { dimColor: true }, ` ${pool.length} tracks (${scanProgress.done}/${scanProgress.total} scanned)`),
|
|
1156
1195
|
),
|
|
1157
1196
|
),
|
|
1158
|
-
h(Box, {
|
|
1159
|
-
h(
|
|
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.
|
|
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
|
-
|
|
410
|
-
|
|
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;
|