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/README.md ADDED
@@ -0,0 +1,65 @@
1
+ # klaudio
2
+
3
+ Add sound effects to your [Claude Code](https://docs.anthropic.com/en/docs/claude-code) sessions. Plays sounds when Claude finishes a task, sends a notification, and more.
4
+
5
+ ## Quick Start
6
+
7
+ ```bash
8
+ npx klaudio
9
+ ```
10
+
11
+ The interactive installer walks you through:
12
+
13
+ 1. **Choose scope** — install globally (`~/.claude`) or per-project (`.claude/`)
14
+ 2. **Pick a source** — use a built-in preset, scan your local games for sounds, or provide custom files
15
+ 3. **Preview & assign** — listen to sounds and assign them to events
16
+ 4. **Install** — writes Claude Code hooks to your `settings.json`
17
+
18
+ ## Sound Sources
19
+
20
+ ### Built-in Presets
21
+
22
+ Ready-made sound packs (Retro 8-bit, Minimal Zen, Sci-Fi Terminal, Victory Fanfare) that work out of the box.
23
+
24
+ ### Game Sound Scanner
25
+
26
+ Scans your Steam and Epic Games libraries for audio files:
27
+
28
+ - Finds loose audio files (`.wav`, `.mp3`, `.ogg`, `.flac`, `.aac`)
29
+ - Extracts packed audio (Wwise `.wem`, FMOD `.bank`, `.fsb`) using [vgmstream](https://vgmstream.org/) (downloaded automatically)
30
+ - Parses Wwise metadata (`SoundbanksInfo.json`) for descriptive filenames
31
+ - Categorizes sounds (voice, ambient, music, SFX, UI, creature) for easy browsing
32
+ - Caches extracted sounds in `~/.klonk/cache/` for instant reuse
33
+
34
+ ### Custom Files
35
+
36
+ Point to your own `.wav`/`.mp3` files.
37
+
38
+ ## Features
39
+
40
+ - **Auto-preview** — sounds play automatically as you browse the list (toggle with `p`)
41
+ - **Category filtering** — drill into voice, ambient, SFX, etc. when a game has enough variety
42
+ - **Type-to-filter** — start typing to narrow down long lists
43
+ - **10-second clamp** — long sounds are processed with ffmpeg: silence stripped, fade out baked in
44
+ - **Background scanning** — game list updates live as directories are scanned
45
+ - **Cross-platform** — Windows (PowerShell/ffplay), macOS (afplay/ffplay), Linux (aplay/ffplay)
46
+
47
+ ## Events
48
+
49
+ | Event | Triggers when |
50
+ |---|---|
51
+ | Task Complete | Claude finishes a response |
52
+ | Notification | Claude needs your attention |
53
+
54
+ ## Uninstall
55
+
56
+ ```bash
57
+ npx klaudio --uninstall
58
+ ```
59
+
60
+ ## Requirements
61
+
62
+ - Node.js 18+ (Claude Code already requires this)
63
+ - [Claude Code](https://docs.anthropic.com/en/docs/claude-code) installed
64
+ - For packed audio extraction: internet connection (vgmstream-cli is downloaded automatically)
65
+ - For best playback with fade effects: [ffmpeg/ffplay](https://ffmpeg.org/) on PATH (falls back to native players)
package/bin/cli.js ADDED
@@ -0,0 +1,13 @@
1
+ #!/usr/bin/env node
2
+
3
+ import { run } from "../src/cli.js";
4
+
5
+ run().catch((err) => {
6
+ if (err.name === "ExitPromptError") {
7
+ // User pressed Ctrl+C
8
+ console.log("\n Cancelled.\n");
9
+ process.exit(0);
10
+ }
11
+ console.error(err);
12
+ process.exit(1);
13
+ });
package/package.json ADDED
@@ -0,0 +1,40 @@
1
+ {
2
+ "name": "klaudio",
3
+ "version": "0.1.0",
4
+ "description": "Add sound effects to your coding sessions — play sounds when tasks complete, notifications arrive, and more",
5
+ "type": "module",
6
+ "bin": {
7
+ "klaudio": "./bin/cli.js"
8
+ },
9
+ "files": [
10
+ "bin/",
11
+ "src/",
12
+ "sounds/"
13
+ ],
14
+ "scripts": {
15
+ "generate-sounds": "node scripts/generate-sounds.js",
16
+ "start": "node bin/cli.js",
17
+ "build": "bun run build.js",
18
+ "build:all": "bun run build.js --all"
19
+ },
20
+ "keywords": [
21
+ "klonk",
22
+ "sounds",
23
+ "sfx",
24
+ "hooks",
25
+ "notifications",
26
+ "audio",
27
+ "claude-code",
28
+ "coding-tools"
29
+ ],
30
+ "license": "MIT",
31
+ "dependencies": {
32
+ "ink": "^6.8.0",
33
+ "ink-select-input": "^6.2.0",
34
+ "ink-spinner": "^5.0.0",
35
+ "react": "^19.2.4"
36
+ },
37
+ "engines": {
38
+ "node": ">=18"
39
+ }
40
+ }
Binary file
Binary file
Binary file
Binary file
package/src/cache.js ADDED
@@ -0,0 +1,262 @@
1
+ import { readdir, mkdir, readFile, writeFile, stat, copyFile } from "node:fs/promises";
2
+ import { join, extname, basename, dirname } from "node:path";
3
+ import { homedir } from "node:os";
4
+
5
+ const CACHE_DIR = join(homedir(), ".klonk", "cache");
6
+
7
+ /**
8
+ * Category keywords to match against folder names, filenames, and Wwise event names.
9
+ */
10
+ const CATEGORY_PATTERNS = {
11
+ ambient: [
12
+ "ambient", "ambience", "ambi_", "amb_", "atmosphere", "atmos",
13
+ "environment", "nature", "wind", "rain", "river", "ocean",
14
+ "forest", "weather", "background_loop", "room_tone",
15
+ ],
16
+ music: [
17
+ "music", "mus_", "soundtrack", "bgm", "score", "theme",
18
+ "menu_music", "mainmenu", "background_music", "level_music",
19
+ "snippet",
20
+ ],
21
+ sfx: [
22
+ "sfx", "effect", "impact", "explosion", "hit", "slash",
23
+ "swing", "shoot", "weapon", "combat", "fight", "footstep",
24
+ "step", "walk", "jump", "land", "collect", "pickup", "drop",
25
+ "build", "craft", "deposit", "chop", "mine", "hammer",
26
+ "saw", "axe", "click", "whoosh", "swoosh",
27
+ ],
28
+ ui: [
29
+ "ui_", "/ui/", "gui_", "/gui/", "menu", "button", "hover",
30
+ "confirm", "cancel", "popup", "notification", "alert",
31
+ "interface", "hud", "tab", "scroll",
32
+ ],
33
+ voice: [
34
+ "voice", "vocal", "vox", "dialogue", "dialog", "speech",
35
+ "narrat", "speak", "talk", "grunt", "shout", "scream",
36
+ "laugh", "cry", "cheer",
37
+ ],
38
+ creature: [
39
+ "creature", "animal", "monster", "enemy", "npc",
40
+ "cow", "horse", "bird", "dog", "cat", "wolf", "bear",
41
+ "chicken", "sheep", "pig",
42
+ ],
43
+ };
44
+
45
+ /**
46
+ * Infer a category from a filename, its parent folder path, and optional metadata name.
47
+ */
48
+ export function inferCategory(filePath, metadataName) {
49
+ const name = (metadataName || basename(filePath)).toLowerCase();
50
+ const dirPath = dirname(filePath).toLowerCase().replace(/\\/g, "/");
51
+ const combined = `${dirPath}/${name}`;
52
+
53
+ // Check each category's patterns against the combined path+name
54
+ const scores = {};
55
+ for (const [cat, patterns] of Object.entries(CATEGORY_PATTERNS)) {
56
+ scores[cat] = 0;
57
+ for (const p of patterns) {
58
+ if (combined.includes(p)) scores[cat]++;
59
+ }
60
+ }
61
+
62
+ // Return the best match, or "other" if nothing matched
63
+ const best = Object.entries(scores).sort((a, b) => b[1] - a[1])[0];
64
+ return best[1] > 0 ? best[0] : "other";
65
+ }
66
+
67
+ /**
68
+ * Parse Wwise SoundbanksInfo.json to build a map of WEM ID -> { name, category }.
69
+ */
70
+ export async function parseWwiseSoundbanksInfo(gamePath) {
71
+ const map = new Map(); // wemId -> { name, category }
72
+
73
+ // Try to find SoundbanksInfo.json
74
+ const searchDirs = [
75
+ join(gamePath, "audio"),
76
+ join(gamePath, "Audio"),
77
+ join(gamePath, "sound"),
78
+ join(gamePath, "Sound"),
79
+ gamePath,
80
+ ];
81
+
82
+ let soundbanksData = null;
83
+ for (const dir of searchDirs) {
84
+ try {
85
+ const content = await readFile(join(dir, "SoundbanksInfo.json"), "utf-8");
86
+ soundbanksData = JSON.parse(content);
87
+ break;
88
+ } catch { /* try next */ }
89
+ }
90
+
91
+ if (soundbanksData?.SoundBanksInfo?.SoundBanks) {
92
+ for (const bank of soundbanksData.SoundBanksInfo.SoundBanks) {
93
+ if (!bank.Media) continue;
94
+ for (const media of bank.Media) {
95
+ if (media.Id && media.ShortName) {
96
+ const name = basename(media.ShortName, extname(media.ShortName));
97
+ map.set(String(media.Id), {
98
+ name,
99
+ category: inferCategory(media.ShortName, name),
100
+ });
101
+ }
102
+ }
103
+ }
104
+ }
105
+
106
+ // Also parse Wwise_IDs.h for event name categories
107
+ const eventCategories = new Map(); // maps lowered keyword -> category
108
+ for (const dir of searchDirs) {
109
+ try {
110
+ const content = await readFile(join(dir, "Wwise_IDs.h"), "utf-8");
111
+ const eventRegex = /PLAY_(\w+)\s*=/g;
112
+ let match;
113
+ while ((match = eventRegex.exec(content)) !== null) {
114
+ const eventName = match[1].toLowerCase();
115
+ const cat = inferCategory("", eventName);
116
+ if (cat !== "other") {
117
+ // Extract keywords from event name to help tag related files
118
+ const parts = eventName.split("_").filter(p => p.length > 2);
119
+ for (const part of parts) {
120
+ if (!["play", "sfx", "music", "pause", "stop", "switch"].includes(part)) {
121
+ eventCategories.set(part, cat);
122
+ }
123
+ }
124
+ }
125
+ }
126
+ } catch { /* skip */ }
127
+ }
128
+
129
+ return { mediaMap: map, eventCategories };
130
+ }
131
+
132
+ /**
133
+ * Get the cache directory for a game.
134
+ */
135
+ function gameCacheDir(gameName) {
136
+ return join(CACHE_DIR, gameName.replace(/[^a-zA-Z0-9_-]/g, "_"));
137
+ }
138
+
139
+ /**
140
+ * Check if we have a cached extraction for a game.
141
+ * Returns the manifest if cached, null otherwise.
142
+ */
143
+ export async function getCachedExtraction(gameName) {
144
+ const cacheDir = gameCacheDir(gameName);
145
+ try {
146
+ const manifest = JSON.parse(
147
+ await readFile(join(cacheDir, "manifest.json"), "utf-8")
148
+ );
149
+ // Verify at least some files still exist
150
+ if (manifest.files?.length > 0) {
151
+ try {
152
+ await stat(join(cacheDir, manifest.files[0].name));
153
+ return manifest;
154
+ } catch {
155
+ return null; // files were cleaned up
156
+ }
157
+ }
158
+ return null;
159
+ } catch {
160
+ return null;
161
+ }
162
+ }
163
+
164
+ /**
165
+ * Save extracted files to cache with metadata.
166
+ * @param {string} gameName
167
+ * @param {Array<{path: string, name: string}>} files - extracted WAV files
168
+ * @param {string} gamePath - original game directory (for metadata lookup)
169
+ * @returns {object} manifest with categorized files
170
+ */
171
+ export async function cacheExtraction(gameName, files, gamePath) {
172
+ const cacheDir = gameCacheDir(gameName);
173
+ await mkdir(cacheDir, { recursive: true });
174
+
175
+ // Parse Wwise metadata if available
176
+ const { mediaMap } = await parseWwiseSoundbanksInfo(gamePath);
177
+
178
+ const cachedFiles = [];
179
+ for (const file of files) {
180
+ const destPath = join(cacheDir, file.name);
181
+
182
+ // Copy to cache if not already there
183
+ try {
184
+ await stat(destPath);
185
+ } catch {
186
+ try {
187
+ await copyFile(file.path, destPath);
188
+ } catch { continue; }
189
+ }
190
+
191
+ // Try to find metadata name from WEM ID
192
+ const wemId = basename(file.name, ".wav").replace(/_\d{3}$/, "");
193
+ const meta = mediaMap.get(wemId);
194
+
195
+ const displayName = meta?.name || basename(file.name, ".wav");
196
+ const category = meta?.category || inferCategory(file.path, file.name);
197
+
198
+ cachedFiles.push({
199
+ name: file.name,
200
+ path: destPath,
201
+ displayName,
202
+ category,
203
+ });
204
+ }
205
+
206
+ const manifest = {
207
+ gameName,
208
+ gamePath,
209
+ extractedAt: new Date().toISOString(),
210
+ fileCount: cachedFiles.length,
211
+ files: cachedFiles,
212
+ categories: [...new Set(cachedFiles.map((f) => f.category))].sort(),
213
+ };
214
+
215
+ await writeFile(join(cacheDir, "manifest.json"), JSON.stringify(manifest, null, 2));
216
+ return manifest;
217
+ }
218
+
219
+ /**
220
+ * Enrich loose audio files (not extracted) with category info.
221
+ * Uses folder structure and filename heuristics.
222
+ */
223
+ export function categorizeLooseFiles(files) {
224
+ return files.map((f) => ({
225
+ ...f,
226
+ displayName: basename(f.name, extname(f.name)),
227
+ category: inferCategory(f.path, f.name),
228
+ }));
229
+ }
230
+
231
+ /** Priority order for sorting — voice first, then interactive, then ambient. */
232
+ const CATEGORY_PRIORITY = {
233
+ voice: 0, creature: 1, ui: 2, sfx: 3, ambient: 4, music: 5, other: 6,
234
+ };
235
+
236
+ /**
237
+ * Get all available categories from a list of categorized files,
238
+ * with counts, sorted by priority.
239
+ */
240
+ export function getCategories(files) {
241
+ const counts = {};
242
+ for (const f of files) {
243
+ const cat = f.category || "other";
244
+ counts[cat] = (counts[cat] || 0) + 1;
245
+ }
246
+ const sorted = Object.keys(counts).sort(
247
+ (a, b) => (CATEGORY_PRIORITY[a] ?? 99) - (CATEGORY_PRIORITY[b] ?? 99)
248
+ );
249
+ return { categories: ["all", ...sorted], counts };
250
+ }
251
+
252
+ /**
253
+ * Sort files with voice/creature first, then by category priority.
254
+ */
255
+ export function sortFilesByPriority(files) {
256
+ return [...files].sort((a, b) => {
257
+ const pa = CATEGORY_PRIORITY[a.category] ?? 99;
258
+ const pb = CATEGORY_PRIORITY[b.category] ?? 99;
259
+ if (pa !== pb) return pa - pb;
260
+ return (a.displayName || a.name).localeCompare(b.displayName || b.name);
261
+ });
262
+ }