klaudio 0.1.0 → 0.3.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/cache.js CHANGED
@@ -1,262 +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
- }
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
+ }