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/presets.js CHANGED
@@ -12,16 +12,16 @@ const SOUNDS_DIR = isCompiledBinary
12
12
  * Sound events that can be configured.
13
13
  */
14
14
  export const EVENTS = {
15
- stop: {
16
- name: "Task Complete",
17
- description: "Plays when Claude finishes a response",
18
- hookEvent: "Stop",
19
- },
20
15
  notification: {
21
16
  name: "Notification",
22
17
  description: "Plays when Claude needs your attention",
23
18
  hookEvent: "Notification",
24
19
  },
20
+ stop: {
21
+ name: "Task Complete",
22
+ description: "Plays when Claude finishes a response",
23
+ hookEvent: "Stop",
24
+ },
25
25
  };
26
26
 
27
27
  /**
package/src/scanner.js CHANGED
@@ -1,9 +1,10 @@
1
- import { readdir, readFile, stat } from "node:fs/promises";
1
+ import { readdir, readFile, stat, open } from "node:fs/promises";
2
2
  import { join, extname } from "node:path";
3
3
  import { platform, homedir } from "node:os";
4
4
 
5
5
  const AUDIO_EXTENSIONS = new Set([".wav", ".mp3", ".ogg", ".flac", ".aac"]);
6
6
  const PACKED_EXTENSIONS = new Set([".wem", ".bnk", ".bank", ".fsb", ".pck"]);
7
+ const UNITY_RESOURCE_EXTENSIONS = new Set([".resource", ".ress"]);
7
8
  const MAX_DEPTH = 5;
8
9
  const MAX_FILES = 200;
9
10
 
@@ -236,7 +237,7 @@ async function getGameSources() {
236
237
  /**
237
238
  * Recursively find audio files in a directory.
238
239
  */
239
- async function findAudioFiles(dir, depth = 0, results = [], packedCount = { n: 0 }) {
240
+ async function findAudioFiles(dir, depth = 0, results = [], packedCount = { n: 0 }, unityResources = []) {
240
241
  if (depth > MAX_DEPTH || results.length >= MAX_FILES) return results;
241
242
 
242
243
  try {
@@ -252,13 +253,15 @@ async function findAudioFiles(dir, depth = 0, results = [], packedCount = { n: 0
252
253
  if (["__pycache__", "node_modules", ".git", "shader", "texture"].some(s => lower.includes(s))) {
253
254
  continue;
254
255
  }
255
- await findAudioFiles(fullPath, depth + 1, results, packedCount);
256
+ await findAudioFiles(fullPath, depth + 1, results, packedCount, unityResources);
256
257
  } else if (entry.isFile()) {
257
258
  const ext = extname(entry.name).toLowerCase();
258
259
  if (AUDIO_EXTENSIONS.has(ext)) {
259
260
  results.push({ path: fullPath, name: entry.name, dir });
260
261
  } else if (PACKED_EXTENSIONS.has(ext)) {
261
262
  packedCount.n++;
263
+ } else if (UNITY_RESOURCE_EXTENSIONS.has(ext)) {
264
+ unityResources.push(fullPath);
262
265
  }
263
266
  }
264
267
  }
@@ -276,12 +279,28 @@ const SKIP_DIRS = new Set([
276
279
  "steam controller configs", "epic online services",
277
280
  ]);
278
281
 
282
+ /**
283
+ * Quick check if a file starts with FSB5 magic (Unity .resource with audio).
284
+ */
285
+ async function hasFSB5Magic(filePath) {
286
+ try {
287
+ const fh = await open(filePath, "r");
288
+ const buf = Buffer.alloc(4);
289
+ await fh.read(buf, 0, 4, 0);
290
+ await fh.close();
291
+ return buf[0] === 0x46 && buf[1] === 0x53 && buf[2] === 0x42 && buf[3] === 0x35; // "FSB5"
292
+ } catch {
293
+ return false;
294
+ }
295
+ }
296
+
279
297
  async function scanGameDir(gamePath, gameName, games, onProgress) {
280
298
  if (onProgress) onProgress({ phase: "scanning", game: gameName });
281
299
  await tick();
282
300
 
283
301
  const packedCount = { n: 0 };
284
- let audioFiles = await findAudioFiles(gamePath, 0, [], packedCount);
302
+ const unityResources = [];
303
+ let audioFiles = await findAudioFiles(gamePath, 0, [], packedCount, unityResources);
285
304
 
286
305
  const seen = new Set();
287
306
  audioFiles = audioFiles.filter((f) => {
@@ -290,10 +309,22 @@ async function scanGameDir(gamePath, gameName, games, onProgress) {
290
309
  return true;
291
310
  });
292
311
 
312
+ // Check Unity .resource files for FSB5 audio
313
+ let unityAudioCount = 0;
314
+ const validUnityResources = [];
315
+ for (const resPath of unityResources) {
316
+ if (await hasFSB5Magic(resPath)) {
317
+ unityAudioCount++;
318
+ validUnityResources.push(resPath);
319
+ }
320
+ }
321
+
293
322
  games.set(gameName, {
294
323
  path: gamePath,
295
324
  files: audioFiles.slice(0, 50),
296
325
  packedAudioCount: packedCount.n,
326
+ unityAudioCount,
327
+ unityResources: validUnityResources,
297
328
  });
298
329
  }
299
330
 
@@ -313,7 +344,9 @@ export async function scanForGames(onProgress, onGameFound) {
313
344
  files: data.files,
314
345
  hasAudio: data.files.length > 0,
315
346
  packedAudioCount: data.packedAudioCount || 0,
316
- canExtract: data.packedAudioCount > 0,
347
+ canExtract: (data.packedAudioCount || 0) > 0 || (data.unityAudioCount || 0) > 0,
348
+ unityAudioCount: data.unityAudioCount || 0,
349
+ unityResources: data.unityResources || [],
317
350
  });
318
351
  }
319
352
  }
@@ -359,7 +392,9 @@ export async function getAvailableGames(onProgress, onGameFound) {
359
392
  files: data.files,
360
393
  hasAudio: data.files.length > 0,
361
394
  packedAudioCount: data.packedAudioCount || 0,
362
- canExtract: data.packedAudioCount > 0,
395
+ canExtract: (data.packedAudioCount || 0) > 0 || (data.unityAudioCount || 0) > 0,
396
+ unityAudioCount: data.unityAudioCount || 0,
397
+ unityResources: data.unityResources || [],
363
398
  }))
364
399
  // Games with audio first, then extractable, then others
365
400
  .sort((a, b) => {
package/src/unity.js ADDED
@@ -0,0 +1,241 @@
1
+ /**
2
+ * Unity .resource file audio extractor.
3
+ *
4
+ * Unity stores audio as concatenated FSB5 (FMOD Sound Bank) entries inside
5
+ * `.resource` / `.resS` files. This module splits those files into
6
+ * individual WAV files without any external tools.
7
+ *
8
+ * Supported FSB5 codecs:
9
+ * - PCM16 (codec 2) — decoded directly to WAV
10
+ * - Vorbis (codec 15) — written as raw .fsb for vgmstream
11
+ */
12
+
13
+ import { readFile, writeFile, mkdir, readdir, stat } from "node:fs/promises";
14
+ import { open } from "node:fs/promises";
15
+ import { join, basename, extname } from "node:path";
16
+
17
+ const FSB5_MAGIC = 0x35425346; // "FSB5" little-endian
18
+
19
+ /**
20
+ * Scan a buffer for all FSB5 bank offsets.
21
+ */
22
+ function findFSB5Offsets(buf) {
23
+ const offsets = [];
24
+ for (let i = 0; i <= buf.length - 4; i++) {
25
+ if (buf.readUInt32LE(i) === FSB5_MAGIC) {
26
+ offsets.push(i);
27
+ }
28
+ }
29
+ return offsets;
30
+ }
31
+
32
+ /**
33
+ * Parse an FSB5 header at a given offset in a buffer.
34
+ * Returns { version, numSamples, sampleHeaderSize, nameTableSize, dataSize, mode, headerSize, totalSize }
35
+ */
36
+ function parseFSB5Header(buf, offset) {
37
+ const version = buf.readUInt32LE(offset + 4);
38
+ const numSamples = buf.readUInt32LE(offset + 8);
39
+ const sampleHeaderSize = buf.readUInt32LE(offset + 12);
40
+ const nameTableSize = buf.readUInt32LE(offset + 16);
41
+ const dataSize = buf.readUInt32LE(offset + 20);
42
+ const mode = buf.readUInt32LE(offset + 24);
43
+ // FSB5 base header is 60 bytes (version 1) or 64 bytes (version 0)
44
+ const headerSize = version === 1 ? 60 : 64;
45
+ const totalSize = headerSize + sampleHeaderSize + nameTableSize + dataSize;
46
+ return { version, numSamples, sampleHeaderSize, nameTableSize, dataSize, mode, headerSize, totalSize };
47
+ }
48
+
49
+ /**
50
+ * Parse FSB5 sample metadata (frequency, channels, data offset, data length).
51
+ * Each sample header entry is an 8-byte packed value.
52
+ */
53
+ function parseFSB5Samples(buf, bankOffset, header) {
54
+ const samples = [];
55
+ let pos = bankOffset + header.headerSize;
56
+ const dataStart = bankOffset + header.headerSize + header.sampleHeaderSize + header.nameTableSize;
57
+
58
+ for (let i = 0; i < header.numSamples; i++) {
59
+ // Sample header: first 8 bytes contain packed metadata
60
+ // Bits 0: hasNextChunk
61
+ // Bits 1-3: frequency index
62
+ // Bits 5-6: channels - 1
63
+ // Bits 7-33: dataOffset (relative to data section start, in units of 32 bytes)
64
+ // Bits 34-63: numSamples (sample count / length)
65
+ const lo = buf.readUInt32LE(pos);
66
+ const hi = buf.readUInt32LE(pos + 4);
67
+
68
+ const freqIndex = (lo >> 1) & 0xf;
69
+ const freqTable = [8000, 11000, 11025, 16000, 22050, 24000, 32000, 44100, 48000, 96000];
70
+ const frequency = freqTable[freqIndex] || 44100;
71
+
72
+ const channels = ((lo >> 5) & 0x3) + 1;
73
+
74
+ // dataOffset in 32-byte granularity
75
+ const dataOffsetRaw = ((lo >>> 7) | ((hi & 0x3) << 25));
76
+ const dataOffset = dataOffsetRaw * 32;
77
+
78
+ const sampleCount = (hi >>> 2);
79
+
80
+ // Determine data length: distance to next sample's offset, or remaining data
81
+ let dataLength;
82
+ if (i + 1 < header.numSamples) {
83
+ // Peek next sample's offset
84
+ const nextLo = buf.readUInt32LE(pos + 8);
85
+ const nextHi = buf.readUInt32LE(pos + 12);
86
+ const nextDataOffset = (((nextLo >>> 7) | ((nextHi & 0x3) << 25))) * 32;
87
+ dataLength = nextDataOffset - dataOffset;
88
+ } else {
89
+ dataLength = header.dataSize - dataOffset;
90
+ }
91
+
92
+ // Skip extra metadata chunks if hasNextChunk bit is set
93
+ let samplePos = pos + 8;
94
+ let hasNext = lo & 1;
95
+ while (hasNext) {
96
+ const chunkInfo = buf.readUInt32LE(samplePos);
97
+ hasNext = chunkInfo & 1;
98
+ const chunkSize = (chunkInfo >>> 1) & 0xffffff;
99
+ samplePos += 4 + chunkSize;
100
+ }
101
+
102
+ samples.push({
103
+ frequency,
104
+ channels,
105
+ dataOffset: dataStart + dataOffset,
106
+ dataLength,
107
+ sampleCount,
108
+ });
109
+
110
+ pos = samplePos;
111
+ }
112
+
113
+ return samples;
114
+ }
115
+
116
+ /**
117
+ * Create a WAV file buffer from raw PCM16 data.
118
+ */
119
+ function createWav(pcmData, sampleRate, channels) {
120
+ const bitsPerSample = 16;
121
+ const byteRate = sampleRate * channels * (bitsPerSample / 8);
122
+ const blockAlign = channels * (bitsPerSample / 8);
123
+ const dataSize = pcmData.length;
124
+ const headerSize = 44;
125
+
126
+ const wav = Buffer.alloc(headerSize + dataSize);
127
+ wav.write("RIFF", 0);
128
+ wav.writeUInt32LE(headerSize + dataSize - 8, 4);
129
+ wav.write("WAVE", 8);
130
+ wav.write("fmt ", 12);
131
+ wav.writeUInt32LE(16, 16); // fmt chunk size
132
+ wav.writeUInt16LE(1, 20); // PCM format
133
+ wav.writeUInt16LE(channels, 22);
134
+ wav.writeUInt32LE(sampleRate, 24);
135
+ wav.writeUInt32LE(byteRate, 28);
136
+ wav.writeUInt16LE(blockAlign, 32);
137
+ wav.writeUInt16LE(bitsPerSample, 34);
138
+ wav.write("data", 36);
139
+ wav.writeUInt32LE(dataSize, 40);
140
+ pcmData.copy(wav, 44);
141
+
142
+ return wav;
143
+ }
144
+
145
+ /**
146
+ * Extract audio from a Unity .resource file.
147
+ *
148
+ * @param {string} resourcePath - Path to the .resource / .resS file
149
+ * @param {string} outputDir - Directory to write extracted files
150
+ * @returns {Promise<Array<{path: string, name: string, codec: string}>>}
151
+ */
152
+ export async function extractUnityResource(resourcePath, outputDir) {
153
+ const buf = await readFile(resourcePath);
154
+ const offsets = findFSB5Offsets(buf);
155
+ if (offsets.length === 0) return [];
156
+
157
+ await mkdir(outputDir, { recursive: true });
158
+
159
+ const baseName = basename(resourcePath, extname(resourcePath));
160
+ const results = [];
161
+
162
+ for (let i = 0; i < offsets.length; i++) {
163
+ const header = parseFSB5Header(buf, offsets[i]);
164
+ if (header.numSamples === 0) continue;
165
+
166
+ const samples = parseFSB5Samples(buf, offsets[i], header);
167
+
168
+ for (let s = 0; s < samples.length; s++) {
169
+ const sample = samples[s];
170
+ const sampleIdx = results.length;
171
+ const codecName = header.mode === 2 ? "pcm16" : header.mode === 15 ? "vorbis" : `codec${header.mode}`;
172
+
173
+ if (header.mode === 2) {
174
+ // PCM16 — extract directly to WAV
175
+ const pcmData = buf.slice(sample.dataOffset, sample.dataOffset + sample.dataLength);
176
+ const wav = createWav(pcmData, sample.frequency, sample.channels);
177
+ const outName = `${baseName}_${String(sampleIdx).padStart(3, "0")}.wav`;
178
+ const outPath = join(outputDir, outName);
179
+ await writeFile(outPath, wav);
180
+ results.push({ path: outPath, name: outName, codec: codecName });
181
+ } else {
182
+ // Non-PCM (Vorbis etc.) — write raw FSB5 bank for vgmstream
183
+ const fsbData = buf.slice(offsets[i], offsets[i] + header.totalSize);
184
+ const outName = `${baseName}_${String(sampleIdx).padStart(3, "0")}.fsb`;
185
+ const outPath = join(outputDir, outName);
186
+ await writeFile(outPath, fsbData);
187
+ results.push({ path: outPath, name: outName, codec: codecName });
188
+ }
189
+ }
190
+ }
191
+
192
+ return results;
193
+ }
194
+
195
+ /**
196
+ * Check if a file is a Unity resource with FSB5 audio.
197
+ * Reads only the first 4 bytes.
198
+ */
199
+ export async function isUnityAudioResource(filePath) {
200
+ try {
201
+ const fh = await open(filePath, "r");
202
+ const buf = Buffer.alloc(4);
203
+ await fh.read(buf, 0, 4, 0);
204
+ await fh.close();
205
+ return buf.readUInt32LE(0) === FSB5_MAGIC;
206
+ } catch {
207
+ return false;
208
+ }
209
+ }
210
+
211
+ /**
212
+ * Find Unity .resource files containing audio in a game directory.
213
+ */
214
+ export async function findUnityAudioResources(gameDir, maxDepth = 5) {
215
+ const results = [];
216
+
217
+ async function scan(dir, depth) {
218
+ if (depth > maxDepth) return;
219
+ try {
220
+ const entries = await readdir(dir, { withFileTypes: true });
221
+ for (const entry of entries) {
222
+ const fullPath = join(dir, entry.name);
223
+ if (entry.isDirectory()) {
224
+ const lower = entry.name.toLowerCase();
225
+ if (["__pycache__", "node_modules", ".git"].some((s) => lower.includes(s))) continue;
226
+ await scan(fullPath, depth + 1);
227
+ } else if (entry.isFile()) {
228
+ const ext = extname(entry.name).toLowerCase();
229
+ if (ext === ".resource" || ext === ".ress") {
230
+ if (await isUnityAudioResource(fullPath)) {
231
+ results.push(fullPath);
232
+ }
233
+ }
234
+ }
235
+ }
236
+ } catch { /* skip */ }
237
+ }
238
+
239
+ await scan(gameDir, 0);
240
+ return results;
241
+ }