klaudio 0.5.2 → 0.6.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
@@ -10,7 +10,7 @@ npx klaudio
10
10
 
11
11
  The interactive installer walks you through:
12
12
 
13
- 1. **Choose scope** — install globally (`~/.claude`) or per-project (`.claude/`)
13
+ 1. **Choose scope** — install globally (`~/.claude`) or per-project (`.claude/`), or launch the **Music Player**
14
14
  2. **Pick a source** — use a built-in preset, scan your Steam & Epic Games library for sounds, or provide custom files
15
15
  3. **Preview & assign** — listen to sounds and assign them to events (tab to switch between events)
16
16
  4. **Install** — writes Claude Code hooks to your `settings.json`
@@ -36,6 +36,17 @@ Scans your local Steam and Epic Games libraries for audio files:
36
36
 
37
37
  Point to your own `.wav`/`.mp3` files.
38
38
 
39
+ ## Music Player
40
+
41
+ Play longer game tracks (90s–4min) as background music while you code:
42
+
43
+ - **Shuffle all** — scans all cached game audio, filters by duration, picks random tracks continuously
44
+ - **Play songs from game** — pick a specific cached game and play its music
45
+ - Controls: `n` next, `space` pause/resume, `esc` back
46
+ - Background scanning — starts playing as soon as the first track is found, keeps indexing
47
+
48
+ Requires previously extracted game audio (use "Scan local games" first).
49
+
39
50
  ## Features
40
51
 
41
52
  - **Auto-preview** — sounds play automatically as you browse the list (toggle with `p`)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "klaudio",
3
- "version": "0.5.2",
3
+ "version": "0.6.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/cache.js CHANGED
@@ -14,9 +14,9 @@ const CATEGORY_PATTERNS = {
14
14
  "forest", "weather", "background_loop", "room_tone",
15
15
  ],
16
16
  music: [
17
- "music", "mus_", "soundtrack", "bgm", "score", "theme",
17
+ "music", "mus_", "/music/", "soundtrack", "bgm", "score", "theme",
18
18
  "menu_music", "mainmenu", "background_music", "level_music",
19
- "snippet",
19
+ "snippet", "musdisk",
20
20
  ],
21
21
  sfx: [
22
22
  "sfx", "effect", "impact", "explosion", "hit", "slash",
@@ -31,9 +31,9 @@ const CATEGORY_PATTERNS = {
31
31
  "interface", "hud", "tab", "scroll",
32
32
  ],
33
33
  voice: [
34
- "voice", "vocal", "vox", "dialogue", "dialog", "speech",
34
+ "voice", "vocal", "vox", "/voice/", "dialogue", "dialog", "speech",
35
35
  "narrat", "speak", "talk", "grunt", "shout", "scream",
36
- "laugh", "cry", "cheer",
36
+ "laugh", "cry", "cheer", "voxdisk",
37
37
  ],
38
38
  creature: [
39
39
  "creature", "animal", "monster", "enemy", "npc",
@@ -42,6 +42,15 @@ const CATEGORY_PATTERNS = {
42
42
  ],
43
43
  };
44
44
 
45
+ /**
46
+ * SCUMM BUN filename prefix patterns.
47
+ * These are checked before generic patterns for files from BUN extraction.
48
+ */
49
+ const SCUMM_PREFIX_PATTERNS = {
50
+ voice: [/^ad[a-z]/i, /^adgt/i, /^adso/i],
51
+ music: [/^\d{4}-[a-z]_/i],
52
+ };
53
+
45
54
  /**
46
55
  * Infer a category from a filename, its parent folder path, and optional metadata name.
47
56
  */
@@ -50,6 +59,14 @@ export function inferCategory(filePath, metadataName) {
50
59
  const dirPath = dirname(filePath).toLowerCase().replace(/\\/g, "/");
51
60
  const combined = `${dirPath}/${name}`;
52
61
 
62
+ // Check SCUMM prefix patterns first (high-confidence filename-based)
63
+ const baseName = basename(name, extname(name));
64
+ for (const [cat, patterns] of Object.entries(SCUMM_PREFIX_PATTERNS)) {
65
+ for (const re of patterns) {
66
+ if (re.test(baseName)) return cat;
67
+ }
68
+ }
69
+
53
70
  // Check each category's patterns against the combined path+name
54
71
  const scores = {};
55
72
  for (const [cat, patterns] of Object.entries(CATEGORY_PATTERNS)) {
@@ -136,6 +153,33 @@ function gameCacheDir(gameName) {
136
153
  return join(CACHE_DIR, gameName.replace(/[^a-zA-Z0-9_-]/g, "_"));
137
154
  }
138
155
 
156
+ /**
157
+ * List all cached games with their files.
158
+ * Returns array of { gameName, files[] }.
159
+ */
160
+ export async function listCachedGames() {
161
+ const games = [];
162
+ try {
163
+ const entries = await readdir(CACHE_DIR, { withFileTypes: true });
164
+ for (const entry of entries) {
165
+ if (!entry.isDirectory()) continue;
166
+ try {
167
+ const manifest = JSON.parse(
168
+ await readFile(join(CACHE_DIR, entry.name, "manifest.json"), "utf-8")
169
+ );
170
+ if (manifest.files?.length > 0) {
171
+ // Verify at least one file still exists
172
+ try {
173
+ await stat(join(CACHE_DIR, entry.name, manifest.files[0].name));
174
+ games.push({ gameName: manifest.gameName, files: manifest.files });
175
+ } catch { /* files cleaned up, skip */ }
176
+ }
177
+ } catch { /* no manifest, skip */ }
178
+ }
179
+ } catch { /* cache dir doesn't exist */ }
180
+ return games;
181
+ }
182
+
139
183
  /**
140
184
  * Check if we have a cached extraction for a game.
141
185
  * Returns the manifest if cached, null otherwise.
package/src/cli.js CHANGED
@@ -1,4 +1,4 @@
1
- import React, { useState, useEffect, useCallback, useRef } from "react";
1
+ 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";
@@ -8,7 +8,7 @@ import { install, uninstall, getExistingSounds } from "./installer.js";
8
8
  import { getVgmstreamPath, findPackedAudioFiles, extractToWav } from "./extractor.js";
9
9
  import { extractUnityResource } from "./unity.js";
10
10
  import { extractBunFile, isBunFile } from "./scumm.js";
11
- import { getCachedExtraction, cacheExtraction, categorizeLooseFiles, getCategories, sortFilesByPriority } from "./cache.js";
11
+ import { getCachedExtraction, cacheExtraction, categorizeLooseFiles, getCategories, sortFilesByPriority, listCachedGames } from "./cache.js";
12
12
  import { basename, dirname } from "node:path";
13
13
  import { tmpdir } from "node:os";
14
14
  import { join } from "node:path";
@@ -90,6 +90,9 @@ const SCREEN = {
90
90
  CONFIRM: 7,
91
91
  INSTALLING: 8,
92
92
  DONE: 9,
93
+ MUSIC_MODE: 10,
94
+ MUSIC_GAME_PICK: 11,
95
+ MUSIC_PLAYING: 12,
93
96
  };
94
97
 
95
98
  const isUninstallMode = process.argv.includes("--uninstall") || process.argv.includes("--remove");
@@ -112,15 +115,38 @@ const NavHint = ({ back = true, extra = "" }) =>
112
115
  );
113
116
 
114
117
  // ── Screen: Scope ───────────────────────────────────────────────
115
- const ScopeScreen = ({ onNext }) => {
118
+ const ScopeScreen = ({ onNext, onMusic }) => {
116
119
  const items = [
117
- { label: "Global (~/.claude) sounds in all projects", value: "global" },
118
- { label: "This project (.claude/) project-specific", value: "project" },
120
+ { label: "Global — Claude Code + Copilot (all projects)", value: "global" },
121
+ { label: "This project — Claude Code + Copilot (this project only)", value: "project" },
122
+ // index 2 = music
123
+ { label: "🎵 Play game music while you code", value: "_music" },
119
124
  ];
125
+ const [sel, setSel] = useState(0);
126
+ const GAP_AT = 2; // visual gap before this index
127
+
128
+ useInput((input, key) => {
129
+ if (input === "k" || key.upArrow) {
130
+ setSel((i) => Math.max(0, i - 1));
131
+ } else if (input === "j" || key.downArrow) {
132
+ setSel((i) => Math.min(items.length - 1, i + 1));
133
+ } else if (key.return) {
134
+ const v = items[sel].value;
135
+ if (v === "_music") onMusic();
136
+ else onNext(v);
137
+ }
138
+ });
139
+
120
140
  return h(Box, { flexDirection: "column" },
121
141
  h(Text, { bold: true }, " Where should sounds be installed?"),
122
- h(Box, { marginLeft: 2 },
123
- h(SelectInput, { indicatorComponent: Indicator, itemComponent: Item, items, onSelect: (item) => onNext(item.value) }),
142
+ h(Box, { flexDirection: "column", marginLeft: 2 },
143
+ ...items.map((item, i) => h(React.Fragment, { key: item.value },
144
+ i === GAP_AT ? h(Text, { dimColor: true }, "\n ...or") : null,
145
+ h(Box, null,
146
+ h(Indicator, { isSelected: i === sel }),
147
+ h(Item, { isSelected: i === sel, label: item.label }),
148
+ ),
149
+ )),
124
150
  ),
125
151
  );
126
152
  };
@@ -450,13 +476,13 @@ const GameSoundsScreen = ({ game, sounds, onSelectSound, onDone, onBack }) => {
450
476
  const meaningfulCats = categories.filter((c) => c !== "all" && (counts[c] || 0) >= 2);
451
477
  const showCategoryPicker = meaningfulCats.length >= 2;
452
478
 
453
- // Sort files: voice first, then by priority
454
- const sortedFiles = hasCategories ? sortFilesByPriority(game.files) : game.files;
479
+ // Sort files: voice first, then by priority (memoized for stable references)
480
+ const sortedFiles = useMemo(() => hasCategories ? sortFilesByPriority(game.files) : game.files, [game.files, hasCategories]);
455
481
 
456
- // Filter files by category (no hard cap SelectInput handles visible window)
457
- const categoryFiles = activeCategory && activeCategory !== "all"
482
+ // Filter files by category (memoized to prevent infinite re-render loops)
483
+ const categoryFiles = useMemo(() => activeCategory && activeCategory !== "all"
458
484
  ? sortedFiles.filter((f) => f.category === activeCategory)
459
- : sortedFiles;
485
+ : sortedFiles, [sortedFiles, activeCategory]);
460
486
 
461
487
  // Stop current playback helper
462
488
  const stopPlayback = useCallback(() => {
@@ -803,6 +829,268 @@ const GameSoundsScreen = ({ game, sounds, onSelectSound, onDone, onBack }) => {
803
829
  );
804
830
  };
805
831
 
832
+ // ── Helpers for Music Player ─────────────────────────────────────
833
+ const formatTime = (secs) => {
834
+ const m = Math.floor(secs / 60);
835
+ const s = Math.floor(secs % 60);
836
+ return `${m}:${s.toString().padStart(2, "0")}`;
837
+ };
838
+
839
+ // ── Screen: Music Mode ──────────────────────────────────────────
840
+ const MusicModeScreen = ({ onRandom, onPickGame, onBack }) => {
841
+ useInput((_, key) => { if (key.escape) onBack(); });
842
+
843
+ const items = [
844
+ { label: "🎲 Shuffle all — play random songs from all cached games", value: "random" },
845
+ { label: "🎮 Play songs from game — choose a game", value: "game" },
846
+ ];
847
+
848
+ return h(Box, { flexDirection: "column" },
849
+ h(Text, { bold: true, marginLeft: 2 }, " 🎵 Music Player"),
850
+ h(Text, { dimColor: true, marginLeft: 2 }, " Play longer game tracks as background music"),
851
+ h(Box, { marginTop: 1, marginLeft: 2 },
852
+ h(SelectInput, { indicatorComponent: Indicator, itemComponent: Item, items,
853
+ onSelect: (item) => {
854
+ if (item.value === "random") onRandom();
855
+ else onPickGame();
856
+ },
857
+ }),
858
+ ),
859
+ h(NavHint, { back: true }),
860
+ );
861
+ };
862
+
863
+ // ── Screen: Music Game Pick ─────────────────────────────────────
864
+ const MusicGamePickScreen = ({ onNext, onBack }) => {
865
+ const [games, setGames] = useState([]);
866
+ const [loading, setLoading] = useState(true);
867
+
868
+ useInput((_, key) => { if (key.escape) onBack(); });
869
+
870
+ useEffect(() => {
871
+ let cancelled = false;
872
+ listCachedGames().then((cached) => {
873
+ if (!cancelled) {
874
+ setGames(cached);
875
+ setLoading(false);
876
+ }
877
+ });
878
+ return () => { cancelled = true; };
879
+ }, []);
880
+
881
+ if (loading) {
882
+ return h(Box, { flexDirection: "column" },
883
+ h(Box, { marginLeft: 2 },
884
+ h(Text, { color: ACCENT }, h(Spinner, { type: "dots" })),
885
+ h(Text, null, " Scanning cached games..."),
886
+ ),
887
+ );
888
+ }
889
+
890
+ if (games.length === 0) {
891
+ return h(Box, { flexDirection: "column" },
892
+ h(Text, { color: "yellow", marginLeft: 2 }, " No cached games found."),
893
+ h(Text, { dimColor: true, marginLeft: 2 }, " Use \"Scan local games\" first to extract game audio."),
894
+ h(NavHint, { back: true }),
895
+ );
896
+ }
897
+
898
+ const items = games.map((g) => ({
899
+ label: `${g.gameName} (${g.files.length} files)`,
900
+ value: g.gameName,
901
+ }));
902
+
903
+ return h(Box, { flexDirection: "column" },
904
+ h(Text, { bold: true, marginLeft: 2 }, " Pick a game:"),
905
+ h(Box, { marginLeft: 2 },
906
+ h(SelectInput, { indicatorComponent: Indicator, itemComponent: Item, items, limit: 15,
907
+ onSelect: (item) => {
908
+ const game = games.find((g) => g.gameName === item.value);
909
+ onNext(game);
910
+ },
911
+ }),
912
+ ),
913
+ h(NavHint, { back: true }),
914
+ );
915
+ };
916
+
917
+ // ── Screen: Music Playing ───────────────────────────────────────
918
+ const MusicPlayingScreen = ({ files, gameName, shuffle: initialShuffle, onBack }) => {
919
+ const [track, setTrack] = useState(null); // current track { path, name, displayName, duration }
920
+ const [loading, setLoading] = useState(true);
921
+ const [scanProgress, setScanProgress] = useState({ done: 0, total: files.length, found: 0 });
922
+ const [scanDone, setScanDone] = useState(false);
923
+ const [playing, setPlaying] = useState(false);
924
+ const [paused, setPaused] = useState(false);
925
+ const [elapsed, setElapsed] = useState(0);
926
+ const [poolSize, setPoolSize] = useState(0);
927
+ const cancelRef = useRef(null);
928
+ const pauseRef = useRef(null);
929
+ const resumeRef = useRef(null);
930
+ const versionRef = useRef(0);
931
+ const poolRef = useRef([]); // ever-growing pool of qualifying tracks
932
+ const lastPlayedRef = useRef(-1); // index of last played track in pool
933
+
934
+ // Pick a random track from the pool (different from last played)
935
+ const pickFromPool = useCallback(() => {
936
+ const pool = poolRef.current;
937
+ if (pool.length === 0) return null;
938
+ if (pool.length === 1) return pool[0];
939
+ let idx;
940
+ do { idx = Math.floor(Math.random() * pool.length); } while (idx === lastPlayedRef.current && pool.length > 1);
941
+ lastPlayedRef.current = idx;
942
+ return pool[idx];
943
+ }, []);
944
+
945
+ // Scan files for duration (90s-4min), pick first random once found, keep scanning in background
946
+ const startedRef = useRef(false);
947
+ useEffect(() => {
948
+ let cancelled = false;
949
+ (async () => {
950
+ const BATCH = 20;
951
+ for (let i = 0; i < files.length; i += BATCH) {
952
+ if (cancelled) return;
953
+ const batch = files.slice(i, i + BATCH);
954
+ const results = await Promise.all(batch.map(async (f) => {
955
+ const dur = await getWavDuration(f.path);
956
+ return { ...f, duration: dur };
957
+ }));
958
+ for (const r of results) {
959
+ if (r.duration != null && r.duration >= 90 && r.duration <= 240) {
960
+ poolRef.current.push(r);
961
+ }
962
+ }
963
+ const found = poolRef.current.length;
964
+ setScanProgress({ done: Math.min(i + BATCH, files.length), total: files.length, found });
965
+ setPoolSize(found);
966
+ // Start playing the first time we find a qualifying track
967
+ if (found >= 1 && !startedRef.current && !cancelled) {
968
+ startedRef.current = true;
969
+ setTrack(pickFromPool());
970
+ setLoading(false);
971
+ }
972
+ }
973
+ if (!cancelled) {
974
+ setScanDone(true);
975
+ setPoolSize(poolRef.current.length);
976
+ if (!startedRef.current) {
977
+ startedRef.current = true;
978
+ if (poolRef.current.length > 0) {
979
+ setTrack(pickFromPool());
980
+ }
981
+ setLoading(false);
982
+ }
983
+ }
984
+ })();
985
+ return () => { cancelled = true; };
986
+ }, []);
987
+
988
+ // Play current track
989
+ useEffect(() => {
990
+ if (!track) return;
991
+
992
+ const myVersion = ++versionRef.current;
993
+ const { promise, cancel, pause, resume } = playSoundWithCancel(track.path, { maxSeconds: 0 });
994
+ cancelRef.current = cancel;
995
+ pauseRef.current = pause;
996
+ resumeRef.current = resume;
997
+ setPlaying(true);
998
+ setPaused(false);
999
+ setElapsed(0);
1000
+
1001
+ promise.then(() => {
1002
+ if (versionRef.current === myVersion) {
1003
+ // Track ended naturally — pick next random from pool
1004
+ const next = pickFromPool();
1005
+ if (next) setTrack(next);
1006
+ }
1007
+ }).catch(() => {});
1008
+
1009
+ return () => cancel();
1010
+ }, [track]);
1011
+
1012
+ // Elapsed timer
1013
+ useEffect(() => {
1014
+ if (!playing || paused) return;
1015
+ const interval = setInterval(() => setElapsed((e) => e + 1), 1000);
1016
+ return () => clearInterval(interval);
1017
+ }, [playing, paused]);
1018
+
1019
+ // Controls
1020
+ useInput((input, key) => {
1021
+ if (key.escape) {
1022
+ if (cancelRef.current) cancelRef.current();
1023
+ onBack();
1024
+ } else if (input === "n") {
1025
+ versionRef.current++;
1026
+ if (cancelRef.current) cancelRef.current();
1027
+ const next = pickFromPool();
1028
+ if (next) setTrack(next);
1029
+ } else if (input === " ") {
1030
+ if (paused) {
1031
+ if (resumeRef.current) resumeRef.current();
1032
+ setPaused(false);
1033
+ } else {
1034
+ if (pauseRef.current) pauseRef.current();
1035
+ setPaused(true);
1036
+ }
1037
+ }
1038
+ });
1039
+
1040
+ // Loading state
1041
+ if (loading) {
1042
+ return h(Box, { flexDirection: "column" },
1043
+ h(Box, { marginLeft: 2, flexDirection: "column", borderStyle: "round", borderColor: ACCENT, paddingX: 2 },
1044
+ h(Text, { bold: true, color: ACCENT }, `🎵 ${gameName || "Music Player"}`),
1045
+ h(Box, { marginTop: 1 },
1046
+ h(Text, { color: ACCENT }, h(Spinner, { type: "dots" })),
1047
+ h(Text, null, ` Scanning for music tracks... ${scanProgress.found} found (${scanProgress.done}/${scanProgress.total})`),
1048
+ ),
1049
+ ),
1050
+ h(NavHint, { back: true }),
1051
+ );
1052
+ }
1053
+
1054
+ if (!track) {
1055
+ return h(Box, { flexDirection: "column" },
1056
+ h(Text, { color: "yellow", marginLeft: 2 }, " No tracks between 90s–4min found."),
1057
+ h(Text, { dimColor: true, marginLeft: 2 }, " Try a different game or source."),
1058
+ h(NavHint, { back: true }),
1059
+ );
1060
+ }
1061
+
1062
+ const trackName = track.displayName || track.name || basename(track.path);
1063
+
1064
+ return h(Box, { flexDirection: "column" },
1065
+ h(Box, { marginLeft: 2, flexDirection: "column", borderStyle: "round", borderColor: paused ? "yellow" : "green", paddingX: 2 },
1066
+ h(Text, { bold: true, color: paused ? "yellow" : "green" }, `🎵 ${gameName || "Music Player"}`),
1067
+ h(Box, { marginTop: 1 },
1068
+ h(Text, { color: paused ? "yellow" : "green", bold: true },
1069
+ paused ? "⏸ " : "▶ ",
1070
+ ),
1071
+ h(Text, { bold: true }, trackName),
1072
+ ),
1073
+ track.gameName
1074
+ ? h(Text, { dimColor: true }, ` ${track.gameName}`)
1075
+ : null,
1076
+ h(Text, { dimColor: true },
1077
+ ` ${formatTime(elapsed)} / ${formatTime(track.duration || 0)}`,
1078
+ ),
1079
+ ),
1080
+ h(Box, { marginTop: 1, marginLeft: 4 },
1081
+ scanDone
1082
+ ? h(Text, { dimColor: true }, `${poolSize} music tracks indexed`)
1083
+ : h(Box, null,
1084
+ h(Text, { color: ACCENT }, h(Spinner, { type: "dots" })),
1085
+ h(Text, { dimColor: true }, ` ${poolSize} music tracks indexed (${scanProgress.done}/${scanProgress.total} scanned)`),
1086
+ ),
1087
+ ),
1088
+ h(Box, { marginTop: 1, marginLeft: 4 },
1089
+ h(Text, { dimColor: true }, "n next space pause esc back"),
1090
+ ),
1091
+ );
1092
+ };
1093
+
806
1094
  // ── Screen: Extracting ──────────────────────────────────────────
807
1095
  const ExtractingScreen = ({ game, onDone, onBack }) => {
808
1096
  const [status, setStatus] = useState("Checking cache...");
@@ -973,7 +1261,7 @@ const ConfirmScreen = ({ scope, sounds, onConfirm, onBack }) => {
973
1261
  return h(Box, { flexDirection: "column" },
974
1262
  h(Text, { bold: true, marginLeft: 2 }, " Ready to install:"),
975
1263
  h(Box, { marginTop: 1, flexDirection: "column" },
976
- h(Text, { marginLeft: 4 }, `Scope: ${scope === "global" ? "Global (~/.claude)" : "This project (.claude/)"}`),
1264
+ h(Text, { marginLeft: 4 }, `Scope: ${scope === "global" ? "Global (Claude Code + Copilot)" : "This project (Claude Code + Copilot)"}`),
977
1265
  ...soundEntries.map(([eid, path]) =>
978
1266
  h(Text, { key: eid, marginLeft: 4 },
979
1267
  `${EVENTS[eid].name} → ${basename(path)}`
@@ -1101,6 +1389,9 @@ const InstallApp = () => {
1101
1389
  const [sounds, setSounds] = useState({});
1102
1390
  const [selectedGame, setSelectedGame] = useState(null);
1103
1391
  const [installResult, setInstallResult] = useState(null);
1392
+ const [musicFiles, setMusicFiles] = useState([]);
1393
+ const [musicGameName, setMusicGameName] = useState(null);
1394
+ const [musicShuffle, setMusicShuffle] = useState(false);
1104
1395
 
1105
1396
  const initSoundsFromPreset = useCallback((pid) => {
1106
1397
  const preset = PRESETS[pid];
@@ -1118,12 +1409,15 @@ const InstallApp = () => {
1118
1409
  });
1119
1410
  setScreen(SCREEN.PRESET);
1120
1411
  },
1412
+ onMusic: () => setScreen(SCREEN.MUSIC_MODE),
1121
1413
  });
1122
1414
 
1123
1415
  case SCREEN.PRESET:
1124
1416
  return h(PresetScreen, {
1125
1417
  onNext: (id) => {
1126
- if (id === "_scan") {
1418
+ if (id === "_music") {
1419
+ setScreen(SCREEN.MUSIC_MODE);
1420
+ } else if (id === "_scan") {
1127
1421
  setScreen(SCREEN.GAME_PICK);
1128
1422
  } else if (id === "_custom") {
1129
1423
  const firstPreset = Object.keys(PRESETS)[0];
@@ -1232,6 +1526,40 @@ const InstallApp = () => {
1232
1526
  case SCREEN.DONE:
1233
1527
  return h(DoneScreen, { result: installResult });
1234
1528
 
1529
+ case SCREEN.MUSIC_MODE:
1530
+ return h(MusicModeScreen, {
1531
+ onRandom: () => {
1532
+ listCachedGames().then((games) => {
1533
+ const allFiles = games.flatMap((g) => g.files.map((f) => ({ ...f, gameName: g.gameName })));
1534
+ setMusicFiles(allFiles);
1535
+ setMusicGameName("All Games");
1536
+ setMusicShuffle(true);
1537
+ setScreen(SCREEN.MUSIC_PLAYING);
1538
+ });
1539
+ },
1540
+ onPickGame: () => setScreen(SCREEN.MUSIC_GAME_PICK),
1541
+ onBack: () => setScreen(SCREEN.SCOPE),
1542
+ });
1543
+
1544
+ case SCREEN.MUSIC_GAME_PICK:
1545
+ return h(MusicGamePickScreen, {
1546
+ onNext: (game) => {
1547
+ setMusicFiles(game.files);
1548
+ setMusicGameName(game.gameName);
1549
+ setMusicShuffle(false);
1550
+ setScreen(SCREEN.MUSIC_PLAYING);
1551
+ },
1552
+ onBack: () => setScreen(SCREEN.MUSIC_MODE),
1553
+ });
1554
+
1555
+ case SCREEN.MUSIC_PLAYING:
1556
+ return h(MusicPlayingScreen, {
1557
+ files: musicFiles,
1558
+ gameName: musicGameName,
1559
+ shuffle: musicShuffle,
1560
+ onBack: () => setScreen(SCREEN.MUSIC_MODE),
1561
+ });
1562
+
1235
1563
  default:
1236
1564
  return h(Text, { color: "red" }, "Unknown screen");
1237
1565
  }
package/src/installer.js CHANGED
@@ -88,6 +88,9 @@ export async function install({ scope, sounds }) {
88
88
  // Write settings
89
89
  await writeFile(settingsFile, JSON.stringify(settings, null, 2) + "\n", "utf-8");
90
90
 
91
+ // Also install Copilot coding agent hooks (.github/hooks/klaudio.json)
92
+ await installCopilotHooks(installedSounds, scope);
93
+
91
94
  return {
92
95
  soundsDir,
93
96
  settingsFile,
@@ -95,6 +98,58 @@ export async function install({ scope, sounds }) {
95
98
  };
96
99
  }
97
100
 
101
+ /**
102
+ * Install hooks for GitHub Copilot coding agent.
103
+ * Writes .github/hooks/klaudio.json in the Copilot format.
104
+ */
105
+ async function installCopilotHooks(installedSounds, scope) {
106
+ // Find the repo root (.github lives at repo root)
107
+ const repoRoot = scope === "global" ? null : process.cwd();
108
+ if (!repoRoot) return; // Copilot hooks are project-scoped only
109
+
110
+ const hooksDir = join(repoRoot, ".github", "hooks");
111
+ const hooksFile = join(hooksDir, "klaudio.json");
112
+
113
+ await mkdir(hooksDir, { recursive: true });
114
+
115
+ // Read existing file if present
116
+ let config = { version: 1, hooks: {} };
117
+ try {
118
+ const existing = await readFile(hooksFile, "utf-8");
119
+ config = JSON.parse(existing);
120
+ if (!config.hooks) config.hooks = {};
121
+ } catch { /* start fresh */ }
122
+
123
+ for (const [eventId, soundPath] of Object.entries(installedSounds)) {
124
+ const event = EVENTS[eventId];
125
+ if (!event?.copilotHookEvent) continue;
126
+
127
+ const normalized = soundPath.replace(/\\/g, "/");
128
+ const bashCmd = `afplay "${normalized}" 2>/dev/null & aplay "${normalized}" 2>/dev/null &`;
129
+ const psCmd = `Add-Type -AssemblyName PresentationCore; $p = New-Object System.Windows.Media.MediaPlayer; $p.Open([System.Uri]::new('${normalized.replace(/\//g, "\\")}')); Start-Sleep -Milliseconds 200; $p.Play(); Start-Sleep -Seconds 2`;
130
+
131
+ if (!config.hooks[event.copilotHookEvent]) {
132
+ config.hooks[event.copilotHookEvent] = [];
133
+ }
134
+
135
+ // Remove existing klaudio entries
136
+ config.hooks[event.copilotHookEvent] = config.hooks[event.copilotHookEvent].filter(
137
+ (entry) => !entry._klaudio
138
+ );
139
+
140
+ config.hooks[event.copilotHookEvent].push({
141
+ _klaudio: true,
142
+ type: "command",
143
+ bash: bashCmd,
144
+ powershell: psCmd,
145
+ timeoutSec: 10,
146
+ comment: `klaudio: ${event.name}`,
147
+ });
148
+ }
149
+
150
+ await writeFile(hooksFile, JSON.stringify(config, null, 2) + "\n", "utf-8");
151
+ }
152
+
98
153
  /**
99
154
  * Read existing klaudio sound selections from settings.
100
155
  * Returns a map of eventId -> soundFilePath (from the sounds/ dir).
@@ -154,8 +209,22 @@ export async function uninstall(scope) {
154
209
  }
155
210
 
156
211
  await writeFile(settingsFile, JSON.stringify(settings, null, 2) + "\n", "utf-8");
157
- return true;
158
- } catch {
159
- return false;
160
- }
212
+ } catch { /* no existing config */ }
213
+
214
+ // Also clean up Copilot hooks
215
+ await uninstallCopilotHooks(scope);
216
+
217
+ return true;
218
+ }
219
+
220
+ /**
221
+ * Remove klaudio entries from .github/hooks/klaudio.json.
222
+ */
223
+ async function uninstallCopilotHooks(scope) {
224
+ if (scope === "global") return;
225
+ const hooksFile = join(process.cwd(), ".github", "hooks", "klaudio.json");
226
+ try {
227
+ const { unlink } = await import("node:fs/promises");
228
+ await unlink(hooksFile);
229
+ } catch { /* file doesn't exist */ }
161
230
  }
package/src/player.js CHANGED
@@ -14,7 +14,7 @@ const MEDIA_PLAYER_FORMATS = new Set([".wav", ".mp3", ".wma", ".aac"]);
14
14
  /**
15
15
  * Determine the best playback strategy for a file on the current OS.
16
16
  */
17
- function getPlaybackCommand(absPath, { withFade = false } = {}) {
17
+ function getPlaybackCommand(absPath, { withFade = false, maxSeconds = MAX_PLAY_SECONDS } = {}) {
18
18
  const os = platform();
19
19
  const ext = extname(absPath).toLowerCase();
20
20
 
@@ -22,14 +22,14 @@ function getPlaybackCommand(absPath, { withFade = false } = {}) {
22
22
  const ffplayArgs = ["-nodisp", "-autoexit", "-loglevel", "quiet"];
23
23
  if (withFade) {
24
24
  // silenceremove strips leading silence (below -50dB threshold)
25
- // afade fades out over last FADE_SECONDS before the MAX_PLAY_SECONDS cut
26
- const fadeStart = MAX_PLAY_SECONDS - FADE_SECONDS;
25
+ // afade fades out over last FADE_SECONDS before the maxSeconds cut
26
+ const fadeStart = maxSeconds - FADE_SECONDS;
27
27
  const filters = [
28
28
  "silenceremove=start_periods=1:start_silence=0.1:start_threshold=-50dB",
29
29
  `afade=t=out:st=${fadeStart}:d=${FADE_SECONDS}`,
30
30
  ];
31
31
  ffplayArgs.push("-af", filters.join(","));
32
- ffplayArgs.push("-t", String(MAX_PLAY_SECONDS));
32
+ ffplayArgs.push("-t", String(maxSeconds));
33
33
  }
34
34
  ffplayArgs.push(absPath);
35
35
 
@@ -197,9 +197,10 @@ export function playSound(filePath) {
197
197
  * Returns { promise, cancel } — call cancel() to stop playback immediately.
198
198
  * Playback is clamped to MAX_PLAY_SECONDS.
199
199
  */
200
- export function playSoundWithCancel(filePath) {
200
+ export function playSoundWithCancel(filePath, { maxSeconds = MAX_PLAY_SECONDS } = {}) {
201
+ const uncapped = !maxSeconds;
201
202
  const absPath = resolve(filePath);
202
- const strategy = getPlaybackCommand(absPath, { withFade: true });
203
+ const strategy = getPlaybackCommand(absPath, { withFade: !uncapped, maxSeconds });
203
204
  let childProcess = null;
204
205
  let timer = null;
205
206
  let cancelled = false;
@@ -234,48 +235,66 @@ export function playSoundWithCancel(filePath) {
234
235
  }
235
236
 
236
237
  function startExec(cmd, args) {
237
- childProcess = execFile(cmd, args, { windowsHide: true, timeout: (MAX_PLAY_SECONDS + 2) * 1000 }, (err) => {
238
+ const execTimeout = uncapped ? 0 : (maxSeconds + 2) * 1000;
239
+ childProcess = execFile(cmd, args, { windowsHide: true, timeout: execTimeout }, (err) => {
238
240
  if (err && strategy.fallback && !cancelled) {
239
241
  if (strategy.fallback === "powershell") {
240
242
  childProcess = execFile(
241
243
  "powershell.exe",
242
- ["-NoProfile", "-Command", buildPsCommand(absPath, MAX_PLAY_SECONDS)],
243
- { windowsHide: true, timeout: (MAX_PLAY_SECONDS + 2) * 1000 },
244
+ ["-NoProfile", "-Command", buildPsCommand(absPath, uncapped ? 0 : maxSeconds)],
245
+ { windowsHide: true, timeout: execTimeout },
244
246
  (psErr) => onDone(psErr)
245
247
  );
246
248
  } else if (strategy.fallback === "afplay") {
247
249
  // macOS: ffplay not available, fall back to afplay (no fade)
248
- childProcess = execFile("afplay", [absPath], { timeout: (MAX_PLAY_SECONDS + 2) * 1000 }, (afErr) => onDone(afErr));
250
+ childProcess = execFile("afplay", [absPath], { timeout: execTimeout }, (afErr) => onDone(afErr));
249
251
  }
250
252
  } else {
251
253
  onDone(err);
252
254
  }
253
255
  });
254
256
 
255
- // Set a hard timeout to kill after MAX_PLAY_SECONDS
256
- timer = setTimeout(() => {
257
- killChild();
258
- resolvePromise();
259
- }, MAX_PLAY_SECONDS * 1000);
257
+ // Set a hard timeout to kill after maxSeconds (skip if uncapped)
258
+ if (!uncapped) {
259
+ timer = setTimeout(() => {
260
+ killChild();
261
+ resolvePromise();
262
+ }, maxSeconds * 1000);
263
+ }
260
264
  }
261
265
 
262
266
  if (strategy.type === "exec") {
263
267
  startExec(strategy.cmd, strategy.args);
264
268
  } else if (strategy.type === "powershell") {
269
+ const execTimeout = uncapped ? 0 : (maxSeconds + 2) * 1000;
265
270
  childProcess = execFile(
266
271
  "powershell.exe",
267
- ["-NoProfile", "-Command", buildPsCommand(absPath, MAX_PLAY_SECONDS)],
268
- { windowsHide: true, timeout: (MAX_PLAY_SECONDS + 2) * 1000 },
272
+ ["-NoProfile", "-Command", buildPsCommand(absPath, uncapped ? 0 : maxSeconds)],
273
+ { windowsHide: true, timeout: execTimeout },
269
274
  (err) => onDone(err)
270
275
  );
271
- timer = setTimeout(() => {
272
- killChild();
273
- resolvePromise();
274
- }, MAX_PLAY_SECONDS * 1000);
276
+ if (!uncapped) {
277
+ timer = setTimeout(() => {
278
+ killChild();
279
+ resolvePromise();
280
+ }, maxSeconds * 1000);
281
+ }
275
282
  }
276
283
  });
277
284
 
278
- return { promise, cancel };
285
+ const pause = () => {
286
+ if (childProcess && !childProcess.killed && platform() !== "win32") {
287
+ try { process.kill(childProcess.pid, "SIGSTOP"); } catch { /* ignore */ }
288
+ }
289
+ };
290
+
291
+ const resume = () => {
292
+ if (childProcess && !childProcess.killed && platform() !== "win32") {
293
+ try { process.kill(childProcess.pid, "SIGCONT"); } catch { /* ignore */ }
294
+ }
295
+ };
296
+
297
+ return { promise, cancel, pause, resume };
279
298
  }
280
299
 
281
300
  /**
package/src/presets.js CHANGED
@@ -16,11 +16,13 @@ export const EVENTS = {
16
16
  name: "Notification",
17
17
  description: "Plays when Claude needs your attention",
18
18
  hookEvent: "Notification",
19
+ copilotHookEvent: null, // Copilot doesn't have this yet
19
20
  },
20
21
  stop: {
21
22
  name: "Task Complete",
22
23
  description: "Plays when Claude finishes a response",
23
24
  hookEvent: "Stop",
25
+ copilotHookEvent: "sessionEnd",
24
26
  },
25
27
  };
26
28
 
package/src/scumm.js CHANGED
@@ -517,11 +517,18 @@ export async function extractBunFile(bunPath, outputDir, onProgress) {
517
517
  const { sampleRate, bitsPerSample, channels, pcmData } = parseImusResource(fullBuf);
518
518
  if (!pcmData || pcmData.length === 0) continue;
519
519
 
520
- // Write WAV
520
+ // Write WAV into a subdirectory named after the BUN file
521
521
  const wav = createWav(pcmData, sampleRate, channels, bitsPerSample);
522
+ const bunName = basename(bunPath, extname(bunPath)).toLowerCase();
523
+ const subDir = bunName.includes("vox") ? "voice"
524
+ : bunName.includes("mus") ? "music"
525
+ : bunName.includes("sfx") ? "sfx"
526
+ : "other";
527
+ const bunOutputDir = join(outputDir, subDir);
528
+ await mkdir(bunOutputDir, { recursive: true });
522
529
  const safeName = entry.filename.replace(/[^a-zA-Z0-9._-]/g, "_");
523
530
  const outName = safeName.replace(/\.[^.]+$/, "") + ".wav";
524
- const outPath = join(outputDir, outName);
531
+ const outPath = join(bunOutputDir, outName);
525
532
  await writeFile(outPath, wav);
526
533
  outputs.push(outPath);
527
534
  } catch {