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 +65 -0
- package/bin/cli.js +13 -0
- package/package.json +40 -0
- package/sounds/minimal-zen/notification.wav +0 -0
- package/sounds/minimal-zen/stop.wav +0 -0
- package/sounds/retro-8bit/notification.wav +0 -0
- package/sounds/retro-8bit/stop.wav +0 -0
- package/sounds/sci-fi-terminal/notification.wav +0 -0
- package/sounds/sci-fi-terminal/stop.wav +0 -0
- package/sounds/victory-fanfare/notification.wav +0 -0
- package/sounds/victory-fanfare/stop.wav +0 -0
- package/src/cache.js +262 -0
- package/src/cli.js +999 -0
- package/src/extractor.js +203 -0
- package/src/installer.js +128 -0
- package/src/player.js +359 -0
- package/src/presets.js +75 -0
- package/src/scanner.js +370 -0
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
|
|
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
|
+
}
|