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/README.md +67 -65
- package/package.json +44 -40
- package/src/cache.js +262 -262
- package/src/cli.js +1205 -999
- package/src/extractor.js +203 -203
- package/src/installer.js +161 -128
- package/src/player.js +359 -359
- package/src/presets.js +5 -5
- package/src/scanner.js +41 -6
- package/src/unity.js +241 -0
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
|
-
|
|
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
|
+
}
|