klaudio 0.1.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/src/presets.js ADDED
@@ -0,0 +1,75 @@
1
+ import { join, dirname } from "node:path";
2
+ import { fileURLToPath } from "node:url";
3
+
4
+ const __dirname = dirname(fileURLToPath(import.meta.url));
5
+ // Detect compiled bun binary: import.meta.url starts with "bun:" or "B:/"
6
+ const isCompiledBinary = import.meta.url.startsWith("bun:") || import.meta.url.startsWith("B:/~BUN/");
7
+ const SOUNDS_DIR = isCompiledBinary
8
+ ? join(dirname(process.execPath), "sounds")
9
+ : join(__dirname, "..", "sounds");
10
+
11
+ /**
12
+ * Sound events that can be configured.
13
+ */
14
+ export const EVENTS = {
15
+ stop: {
16
+ name: "Task Complete",
17
+ description: "Plays when Claude finishes a response",
18
+ hookEvent: "Stop",
19
+ },
20
+ notification: {
21
+ name: "Notification",
22
+ description: "Plays when Claude needs your attention",
23
+ hookEvent: "Notification",
24
+ },
25
+ };
26
+
27
+ /**
28
+ * Built-in presets with their sound file mappings.
29
+ */
30
+ export const PRESETS = {
31
+ "retro-8bit": {
32
+ name: "Retro 8-bit",
33
+ icon: "🎮",
34
+ description: "Chiptune bleeps, bloops, and victory jingles",
35
+ sounds: {
36
+ stop: join(SOUNDS_DIR, "retro-8bit", "stop.wav"),
37
+ notification: join(SOUNDS_DIR, "retro-8bit", "notification.wav"),
38
+ },
39
+ },
40
+ "minimal-zen": {
41
+ name: "Minimal Zen",
42
+ icon: "🔔",
43
+ description: "Soft chimes and gentle tones",
44
+ sounds: {
45
+ stop: join(SOUNDS_DIR, "minimal-zen", "stop.wav"),
46
+ notification: join(SOUNDS_DIR, "minimal-zen", "notification.wav"),
47
+ },
48
+ },
49
+ "sci-fi-terminal": {
50
+ name: "Sci-Fi Terminal",
51
+ icon: "🚀",
52
+ description: "Futuristic UI bleeps and digital notifications",
53
+ sounds: {
54
+ stop: join(SOUNDS_DIR, "sci-fi-terminal", "stop.wav"),
55
+ notification: join(SOUNDS_DIR, "sci-fi-terminal", "notification.wav"),
56
+ },
57
+ },
58
+ "victory-fanfare": {
59
+ name: "Victory Fanfare",
60
+ icon: "🏆",
61
+ description: "Celebratory jingles for task completion",
62
+ sounds: {
63
+ stop: join(SOUNDS_DIR, "victory-fanfare", "stop.wav"),
64
+ notification: join(SOUNDS_DIR, "victory-fanfare", "notification.wav"),
65
+ },
66
+ },
67
+ };
68
+
69
+ export function getPresetSoundPath(presetId, eventId) {
70
+ return PRESETS[presetId]?.sounds[eventId];
71
+ }
72
+
73
+ export function getSoundsDir() {
74
+ return SOUNDS_DIR;
75
+ }
package/src/scanner.js ADDED
@@ -0,0 +1,370 @@
1
+ import { readdir, readFile, stat } from "node:fs/promises";
2
+ import { join, extname } from "node:path";
3
+ import { platform, homedir } from "node:os";
4
+
5
+ const AUDIO_EXTENSIONS = new Set([".wav", ".mp3", ".ogg", ".flac", ".aac"]);
6
+ const PACKED_EXTENSIONS = new Set([".wem", ".bnk", ".bank", ".fsb", ".pck"]);
7
+ const MAX_DEPTH = 5;
8
+ const MAX_FILES = 200;
9
+
10
+ /** Yield to the event loop so the UI stays responsive. */
11
+ const tick = () => new Promise((r) => setTimeout(r, 0));
12
+
13
+ /**
14
+ * Check if a directory exists and is accessible.
15
+ */
16
+ async function dirExists(dirPath) {
17
+ try {
18
+ const s = await stat(dirPath);
19
+ return s.isDirectory();
20
+ } catch {
21
+ return false;
22
+ }
23
+ }
24
+
25
+ /**
26
+ * Discover available drive letters on Windows (C-Z).
27
+ */
28
+ async function getWindowsDrives() {
29
+ const drives = [];
30
+ for (const letter of "CDEFGHIJKLMNOPQRSTUVWXYZ") {
31
+ if (await dirExists(`${letter}:/`)) {
32
+ drives.push(`${letter}:`);
33
+ }
34
+ }
35
+ return drives;
36
+ }
37
+
38
+ /**
39
+ * Parse Steam's libraryfolders.vdf to find all Steam library paths.
40
+ */
41
+ async function getSteamLibraryPaths() {
42
+ const os = platform();
43
+ const home = homedir();
44
+ const paths = [];
45
+
46
+ // Known default Steam install locations to find the vdf
47
+ const steamRoots = [];
48
+ if (os === "win32") {
49
+ steamRoots.push(
50
+ "C:/Program Files (x86)/Steam",
51
+ "C:/Program Files/Steam",
52
+ );
53
+ } else if (os === "darwin") {
54
+ steamRoots.push(join(home, "Library/Application Support/Steam"));
55
+ } else {
56
+ steamRoots.push(
57
+ join(home, ".steam/steam"),
58
+ join(home, ".local/share/Steam"),
59
+ );
60
+ }
61
+
62
+ for (const root of steamRoots) {
63
+ const vdfPath = join(root, "steamapps", "libraryfolders.vdf");
64
+ try {
65
+ const content = await readFile(vdfPath, "utf-8");
66
+ // Parse "path" values from the VDF file (simple regex — VDF is not JSON)
67
+ const pathRegex = /"path"\s+"([^"]+)"/g;
68
+ let match;
69
+ while ((match = pathRegex.exec(content)) !== null) {
70
+ const libPath = match[1].replace(/\\\\/g, "/");
71
+ const commonPath = join(libPath, "steamapps", "common");
72
+ if (await dirExists(commonPath)) {
73
+ paths.push(commonPath);
74
+ }
75
+ }
76
+ if (paths.length > 0) break; // Found a valid vdf, stop looking
77
+ } catch {
78
+ // vdf not found here, try next
79
+ }
80
+ }
81
+
82
+ return paths;
83
+ }
84
+
85
+ /**
86
+ * Parse Epic Games launcher manifests to find actual install locations.
87
+ * These .item files are JSON with an InstallLocation field.
88
+ */
89
+ async function getEpicInstallPaths() {
90
+ const os = platform();
91
+ const paths = [];
92
+
93
+ const manifestDirs = [];
94
+ if (os === "win32") {
95
+ manifestDirs.push("C:/ProgramData/Epic/EpicGamesLauncher/Data/Manifests");
96
+ } else if (os === "darwin") {
97
+ const home = homedir();
98
+ manifestDirs.push(join(home, "Library/Application Support/Epic/EpicGamesLauncher/Data/Manifests"));
99
+ }
100
+
101
+ for (const manifestDir of manifestDirs) {
102
+ if (!(await dirExists(manifestDir))) continue;
103
+ try {
104
+ const entries = await readdir(manifestDir);
105
+ for (const entry of entries) {
106
+ if (!entry.endsWith(".item")) continue;
107
+ try {
108
+ const content = await readFile(join(manifestDir, entry), "utf-8");
109
+ const data = JSON.parse(content);
110
+ if (data.InstallLocation && data.bIsApplication !== false) {
111
+ const installPath = data.InstallLocation.replace(/\\\\/g, "/").replace(/\\/g, "/");
112
+ if (await dirExists(installPath)) {
113
+ paths.push({ path: installPath, name: data.DisplayName || null });
114
+ }
115
+ }
116
+ } catch { /* skip malformed manifests */ }
117
+ }
118
+ } catch { /* skip inaccessible dirs */ }
119
+ }
120
+
121
+ return paths;
122
+ }
123
+
124
+ /**
125
+ * Find Epic Games install directories by scanning drives (fallback)
126
+ * and parsing launcher manifests (primary).
127
+ */
128
+ async function getEpicGamesPaths() {
129
+ const os = platform();
130
+ const home = homedir();
131
+ const paths = [];
132
+
133
+ // Primary: parse manifests for exact install locations
134
+ const epicInstalls = await getEpicInstallPaths();
135
+ // These are individual game paths, not parent dirs — we'll handle them separately
136
+ // For now return parent dirs found by scanning
137
+
138
+ if (os === "win32") {
139
+ const drives = await getWindowsDrives();
140
+ const epicDirNames = [
141
+ "Epic Games",
142
+ "Program Files/Epic Games",
143
+ "Program Files (x86)/Epic Games",
144
+ "Games/EpicGames",
145
+ "Games/Epic Games",
146
+ ];
147
+
148
+ for (const drive of drives) {
149
+ for (const dirName of epicDirNames) {
150
+ const fullPath = `${drive}/${dirName}`;
151
+ if (await dirExists(fullPath)) {
152
+ paths.push(fullPath);
153
+ }
154
+ }
155
+ }
156
+ } else if (os === "darwin") {
157
+ const macPaths = [
158
+ "/Applications/Epic Games",
159
+ join(home, "Library/Application Support/Epic"),
160
+ ];
161
+ for (const p of macPaths) {
162
+ if (await dirExists(p)) paths.push(p);
163
+ }
164
+ }
165
+
166
+ return { parentDirs: paths, individualGames: epicInstalls };
167
+ }
168
+
169
+ /**
170
+ * Get all game sources.
171
+ * Returns { parentDirs: string[], individualGames: {path, name}[] }
172
+ */
173
+ async function getGameSources() {
174
+ const os = platform();
175
+ const home = homedir();
176
+ const parentDirs = [];
177
+ const individualGames = [];
178
+
179
+ // Steam libraries (from config file)
180
+ const steamPaths = await getSteamLibraryPaths();
181
+ parentDirs.push(...steamPaths);
182
+
183
+ // Epic Games (manifests + scanned dirs)
184
+ const epic = await getEpicGamesPaths();
185
+ parentDirs.push(...epic.parentDirs);
186
+ individualGames.push(...epic.individualGames);
187
+
188
+ // Additional common locations
189
+ if (os === "win32") {
190
+ const drives = await getWindowsDrives();
191
+ for (const drive of drives) {
192
+ for (const dir of ["Games", "SteamLibrary/steamapps/common"]) {
193
+ const fullPath = `${drive}/${dir}`;
194
+ if (await dirExists(fullPath)) {
195
+ parentDirs.push(fullPath);
196
+ }
197
+ }
198
+ }
199
+ const homeGames = join(home, "Games");
200
+ if (await dirExists(homeGames)) parentDirs.push(homeGames);
201
+ } else if (os === "darwin") {
202
+ for (const d of ["/Applications/Games", join(home, "Games")]) {
203
+ if (await dirExists(d)) parentDirs.push(d);
204
+ }
205
+ } else {
206
+ for (const d of [join(home, "Games"), join(home, ".local/share/lutris/games")]) {
207
+ if (await dirExists(d)) parentDirs.push(d);
208
+ }
209
+ }
210
+
211
+ // Deduplicate parent dirs
212
+ const seen = new Set();
213
+ const dedupedDirs = parentDirs.filter((d) => {
214
+ const n = d.replace(/\\/g, "/").toLowerCase();
215
+ if (seen.has(n)) return false;
216
+ seen.add(n);
217
+ return true;
218
+ });
219
+
220
+ // Deduplicate individual games (by path, avoid overlap with parent dirs)
221
+ const dedupedGames = individualGames.filter((g) => {
222
+ const n = g.path.replace(/\\/g, "/").toLowerCase();
223
+ // Skip if this game's parent is already in parentDirs
224
+ for (const pd of dedupedDirs) {
225
+ const pdn = pd.replace(/\\/g, "/").toLowerCase();
226
+ if (n.startsWith(pdn + "/") || n.startsWith(pdn + "\\")) return false;
227
+ }
228
+ if (seen.has(n)) return false;
229
+ seen.add(n);
230
+ return true;
231
+ });
232
+
233
+ return { parentDirs: dedupedDirs, individualGames: dedupedGames };
234
+ }
235
+
236
+ /**
237
+ * Recursively find audio files in a directory.
238
+ */
239
+ async function findAudioFiles(dir, depth = 0, results = [], packedCount = { n: 0 }) {
240
+ if (depth > MAX_DEPTH || results.length >= MAX_FILES) return results;
241
+
242
+ try {
243
+ const entries = await readdir(dir, { withFileTypes: true });
244
+
245
+ for (const entry of entries) {
246
+ if (results.length >= MAX_FILES) break;
247
+
248
+ const fullPath = join(dir, entry.name);
249
+
250
+ if (entry.isDirectory()) {
251
+ const lower = entry.name.toLowerCase();
252
+ if (["__pycache__", "node_modules", ".git", "shader", "texture"].some(s => lower.includes(s))) {
253
+ continue;
254
+ }
255
+ await findAudioFiles(fullPath, depth + 1, results, packedCount);
256
+ } else if (entry.isFile()) {
257
+ const ext = extname(entry.name).toLowerCase();
258
+ if (AUDIO_EXTENSIONS.has(ext)) {
259
+ results.push({ path: fullPath, name: entry.name, dir });
260
+ } else if (PACKED_EXTENSIONS.has(ext)) {
261
+ packedCount.n++;
262
+ }
263
+ }
264
+ }
265
+ } catch { /* skip */ }
266
+
267
+ return results;
268
+ }
269
+
270
+ /**
271
+ * Scan for installed games and their sound files.
272
+ * Returns a map of game name -> audio files.
273
+ */
274
+ const SKIP_DIRS = new Set([
275
+ "launcher", "directxredist", "steamworks shared",
276
+ "steam controller configs", "epic online services",
277
+ ]);
278
+
279
+ async function scanGameDir(gamePath, gameName, games, onProgress) {
280
+ if (onProgress) onProgress({ phase: "scanning", game: gameName });
281
+ await tick();
282
+
283
+ const packedCount = { n: 0 };
284
+ let audioFiles = await findAudioFiles(gamePath, 0, [], packedCount);
285
+
286
+ const seen = new Set();
287
+ audioFiles = audioFiles.filter((f) => {
288
+ if (seen.has(f.path)) return false;
289
+ seen.add(f.path);
290
+ return true;
291
+ });
292
+
293
+ games.set(gameName, {
294
+ path: gamePath,
295
+ files: audioFiles.slice(0, 50),
296
+ packedAudioCount: packedCount.n,
297
+ });
298
+ }
299
+
300
+ export async function scanForGames(onProgress, onGameFound) {
301
+ const { parentDirs, individualGames } = await getGameSources();
302
+ const games = new Map();
303
+
304
+ const allDirs = [...parentDirs, ...individualGames.map((g) => `${g.path} (Epic)`)];
305
+ if (onProgress) onProgress({ phase: "dirs", dirs: allDirs });
306
+
307
+ function emitGame(name, data) {
308
+ if (onGameFound) {
309
+ onGameFound({
310
+ name,
311
+ path: data.path,
312
+ fileCount: data.files.length,
313
+ files: data.files,
314
+ hasAudio: data.files.length > 0,
315
+ packedAudioCount: data.packedAudioCount || 0,
316
+ canExtract: data.packedAudioCount > 0,
317
+ });
318
+ }
319
+ }
320
+
321
+ // Scan parent directories (Steam libraries, Epic Games folders, etc.)
322
+ for (const baseDir of parentDirs) {
323
+ try {
324
+ const entries = await readdir(baseDir, { withFileTypes: true });
325
+
326
+ for (const entry of entries) {
327
+ if (!entry.isDirectory()) continue;
328
+ if (SKIP_DIRS.has(entry.name.toLowerCase())) continue;
329
+
330
+ await scanGameDir(join(baseDir, entry.name), entry.name, games, onProgress);
331
+ emitGame(entry.name, games.get(entry.name));
332
+ }
333
+ } catch {
334
+ // Skip inaccessible dirs
335
+ }
336
+ }
337
+
338
+ // Scan individual game install paths (from Epic manifests, etc.)
339
+ for (const { path: gamePath, name: displayName } of individualGames) {
340
+ const gameName = displayName || gamePath.split(/[\\/]/).pop();
341
+ if (games.has(gameName)) continue; // Already found via parent dir scan
342
+ await scanGameDir(gamePath, gameName, games, onProgress);
343
+ emitGame(gameName, games.get(gameName));
344
+ }
345
+
346
+ return games;
347
+ }
348
+
349
+ /**
350
+ * Get a flat list of all found game directories.
351
+ */
352
+ export async function getAvailableGames(onProgress, onGameFound) {
353
+ const games = await scanForGames(onProgress, onGameFound);
354
+ return Array.from(games.entries())
355
+ .map(([name, data]) => ({
356
+ name,
357
+ path: data.path,
358
+ fileCount: data.files.length,
359
+ files: data.files,
360
+ hasAudio: data.files.length > 0,
361
+ packedAudioCount: data.packedAudioCount || 0,
362
+ canExtract: data.packedAudioCount > 0,
363
+ }))
364
+ // Games with audio first, then extractable, then others
365
+ .sort((a, b) => {
366
+ if (a.hasAudio !== b.hasAudio) return a.hasAudio ? -1 : 1;
367
+ if (a.canExtract !== b.canExtract) return a.canExtract ? -1 : 1;
368
+ return a.name.localeCompare(b.name);
369
+ });
370
+ }