klaudio 0.11.2 → 0.11.3
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 +96 -96
- package/bin/cli.js +44 -44
- package/package.json +40 -44
- package/src/cache.js +306 -306
- package/src/cli.js +1821 -1821
- package/src/extractor.js +213 -213
- package/src/installer.js +369 -368
- package/src/notify.js +138 -135
- package/src/player.js +488 -488
- package/src/presets.js +87 -87
- package/src/scanner.js +445 -445
- package/src/scumm.js +560 -560
- package/src/tts.js +391 -391
package/src/scumm.js
CHANGED
|
@@ -1,560 +1,560 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* SCUMM engine .BUN audio extractor.
|
|
3
|
-
*
|
|
4
|
-
* Extracts audio resources from LucasArts SCUMM engine bundle files
|
|
5
|
-
* (Curse of Monkey Island, The Dig, Full Throttle, etc.).
|
|
6
|
-
*
|
|
7
|
-
* BUN files contain compressed iMUS audio resources. Each resource is
|
|
8
|
-
* decompressed block-by-block using LZ77 and/or IMA ADPCM codecs,
|
|
9
|
-
* producing raw PCM data that is written out as WAV files.
|
|
10
|
-
*
|
|
11
|
-
* Format details derived from the publicly documented ScummVM specifications.
|
|
12
|
-
*/
|
|
13
|
-
|
|
14
|
-
import { open, mkdir, writeFile } from "node:fs/promises";
|
|
15
|
-
import { join, basename, extname } from "node:path";
|
|
16
|
-
|
|
17
|
-
const CHUNK_SIZE = 0x2000; // 8192 bytes — standard decompressed block size
|
|
18
|
-
|
|
19
|
-
// ── IMA Step Table (public domain, 89 values) ──────────────────
|
|
20
|
-
const IMA_TABLE = [
|
|
21
|
-
7, 8, 9, 10, 11, 12, 13, 14,
|
|
22
|
-
16, 17, 19, 21, 23, 25, 28, 31,
|
|
23
|
-
34, 37, 41, 45, 50, 55, 60, 66,
|
|
24
|
-
73, 80, 88, 97, 107, 118, 130, 143,
|
|
25
|
-
157, 173, 190, 209, 230, 253, 279, 307,
|
|
26
|
-
337, 371, 408, 449, 494, 544, 598, 658,
|
|
27
|
-
724, 796, 876, 963, 1060, 1166, 1282, 1411,
|
|
28
|
-
1552, 1707, 1878, 2066, 2272, 2499, 2749, 3024,
|
|
29
|
-
3327, 3660, 4026, 4428, 4871, 5358, 5894, 6484,
|
|
30
|
-
7132, 7845, 8630, 9493, 10442, 11487, 12635, 13899,
|
|
31
|
-
15289, 16818, 18500, 20350, 22385, 24623, 27086, 29794,
|
|
32
|
-
32767,
|
|
33
|
-
];
|
|
34
|
-
|
|
35
|
-
// ── Step adjustment table indexed by [bitCount-2][data] ─────────
|
|
36
|
-
const IMX_OTHER_TABLE = [
|
|
37
|
-
// bitcount=2 (2 entries)
|
|
38
|
-
[-1, 4],
|
|
39
|
-
// bitcount=3 (4 entries)
|
|
40
|
-
[-1, -1, 2, 8],
|
|
41
|
-
// bitcount=4 (8 entries)
|
|
42
|
-
[-1, -1, -1, -1, 1, 2, 4, 6],
|
|
43
|
-
// bitcount=5 (16 entries)
|
|
44
|
-
[-1, -1, -1, -1, -1, -1, -1, -1, 1, 2, 4, 6, 8, 12, 16, 32],
|
|
45
|
-
// bitcount=6 (32 entries)
|
|
46
|
-
[
|
|
47
|
-
-1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1,
|
|
48
|
-
1, 2, 4, 6, 8, 10, 12, 14, 16, 18, 20, 22, 24, 26, 28, 32,
|
|
49
|
-
],
|
|
50
|
-
// bitcount=7 (64 entries)
|
|
51
|
-
[
|
|
52
|
-
-1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1,
|
|
53
|
-
-1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1,
|
|
54
|
-
1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16,
|
|
55
|
-
17, 18, 19, 20, 21, 22, 23, 24, 25, 26, 27, 28, 29, 30, 31, 32,
|
|
56
|
-
],
|
|
57
|
-
];
|
|
58
|
-
|
|
59
|
-
// ── Precomputed tables (built once at module load) ──────────────
|
|
60
|
-
|
|
61
|
-
// _destImcTable[pos]: how many bits to read per IMA position (2-7)
|
|
62
|
-
const DEST_IMC_TABLE = new Uint8Array(89);
|
|
63
|
-
|
|
64
|
-
// _destImcTable2[pos*64 + n]: precomputed delta contribution
|
|
65
|
-
const DEST_IMC_TABLE2 = new Int32Array(89 * 64);
|
|
66
|
-
|
|
67
|
-
(function initTables() {
|
|
68
|
-
// Build _destImcTable
|
|
69
|
-
for (let pos = 0; pos <= 88; pos++) {
|
|
70
|
-
let put = 1;
|
|
71
|
-
let val = Math.trunc(Math.trunc(IMA_TABLE[pos] * 4 / 7) / 2);
|
|
72
|
-
while (val !== 0) {
|
|
73
|
-
val = Math.trunc(val / 2);
|
|
74
|
-
put++;
|
|
75
|
-
}
|
|
76
|
-
if (put < 3) put = 3;
|
|
77
|
-
if (put > 8) put = 8;
|
|
78
|
-
DEST_IMC_TABLE[pos] = put - 1;
|
|
79
|
-
}
|
|
80
|
-
|
|
81
|
-
// Build _destImcTable2
|
|
82
|
-
for (let n = 0; n < 64; n++) {
|
|
83
|
-
for (let pos = 0; pos <= 88; pos++) {
|
|
84
|
-
let count = 32;
|
|
85
|
-
let put = 0;
|
|
86
|
-
let tableValue = IMA_TABLE[pos];
|
|
87
|
-
do {
|
|
88
|
-
if ((count & n) !== 0) {
|
|
89
|
-
put += tableValue;
|
|
90
|
-
}
|
|
91
|
-
count = Math.trunc(count / 2);
|
|
92
|
-
tableValue = Math.trunc(tableValue / 2);
|
|
93
|
-
} while (count !== 0);
|
|
94
|
-
DEST_IMC_TABLE2[n + pos * 64] = put;
|
|
95
|
-
}
|
|
96
|
-
}
|
|
97
|
-
})();
|
|
98
|
-
|
|
99
|
-
// ── LZ77 decompressor ──────────────────────────────────────────
|
|
100
|
-
|
|
101
|
-
function compDecode(src, dst) {
|
|
102
|
-
let sp = 0; // source pointer
|
|
103
|
-
let dp = 0; // dest pointer
|
|
104
|
-
let mask = src[sp] | (src[sp + 1] << 8); // LE uint16
|
|
105
|
-
sp += 2;
|
|
106
|
-
let bitsLeft = 16;
|
|
107
|
-
|
|
108
|
-
function nextBit() {
|
|
109
|
-
const bit = mask & 1;
|
|
110
|
-
mask >>>= 1;
|
|
111
|
-
bitsLeft--;
|
|
112
|
-
if (bitsLeft === 0) {
|
|
113
|
-
mask = src[sp] | (src[sp + 1] << 8);
|
|
114
|
-
sp += 2;
|
|
115
|
-
bitsLeft = 16;
|
|
116
|
-
}
|
|
117
|
-
return bit;
|
|
118
|
-
}
|
|
119
|
-
|
|
120
|
-
for (;;) {
|
|
121
|
-
if (nextBit()) {
|
|
122
|
-
// Literal byte
|
|
123
|
-
dst[dp++] = src[sp++];
|
|
124
|
-
} else {
|
|
125
|
-
let size, data;
|
|
126
|
-
if (!nextBit()) {
|
|
127
|
-
// Short back-reference
|
|
128
|
-
size = nextBit() << 1;
|
|
129
|
-
size = (size | nextBit()) + 3; // 3..6
|
|
130
|
-
data = src[sp++] | 0xffffff00; // sign-extend byte to negative offset
|
|
131
|
-
} else {
|
|
132
|
-
// Long back-reference
|
|
133
|
-
data = src[sp++];
|
|
134
|
-
size = src[sp++];
|
|
135
|
-
data |= 0xfffff000 + ((size & 0xf0) << 4); // 12-bit negative offset
|
|
136
|
-
size = (size & 0x0f) + 3; // 3..18
|
|
137
|
-
|
|
138
|
-
if (size === 3) {
|
|
139
|
-
// Size field was 0 — check terminator
|
|
140
|
-
if ((src[sp++] + 1) === 1) {
|
|
141
|
-
return dp; // done
|
|
142
|
-
}
|
|
143
|
-
}
|
|
144
|
-
}
|
|
145
|
-
// data is a negative offset (as signed 32-bit)
|
|
146
|
-
let refPos = dp + (data | 0); // ensure signed
|
|
147
|
-
for (let i = 0; i < size; i++) {
|
|
148
|
-
dst[dp++] = dst[refPos++];
|
|
149
|
-
}
|
|
150
|
-
}
|
|
151
|
-
}
|
|
152
|
-
}
|
|
153
|
-
|
|
154
|
-
// ── ADPCM decompressor ─────────────────────────────────────────
|
|
155
|
-
|
|
156
|
-
function decompressADPCM(src, dst, channels) {
|
|
157
|
-
let sp = 0;
|
|
158
|
-
const firstWord = (src[sp] << 8) | src[sp + 1]; // BE uint16
|
|
159
|
-
sp += 2;
|
|
160
|
-
|
|
161
|
-
let dp = 0;
|
|
162
|
-
let outputSamplesLeft = 0x1000; // 4096 samples = 8192 bytes
|
|
163
|
-
|
|
164
|
-
if (firstWord !== 0) {
|
|
165
|
-
// Copy raw bytes
|
|
166
|
-
for (let i = 0; i < firstWord; i++) {
|
|
167
|
-
dst[dp++] = src[sp++];
|
|
168
|
-
}
|
|
169
|
-
outputSamplesLeft -= Math.trunc(firstWord / 2);
|
|
170
|
-
}
|
|
171
|
-
|
|
172
|
-
// Read seed values per channel
|
|
173
|
-
const initialTablePos = [];
|
|
174
|
-
const initialOutputWord = [];
|
|
175
|
-
if (firstWord === 0) {
|
|
176
|
-
for (let ch = 0; ch < channels; ch++) {
|
|
177
|
-
initialTablePos.push(src[sp]);
|
|
178
|
-
sp += 1;
|
|
179
|
-
sp += 4; // skip 4 bytes
|
|
180
|
-
initialOutputWord.push(
|
|
181
|
-
((src[sp] << 24) | (src[sp + 1] << 16) | (src[sp + 2] << 8) | src[sp + 3]) | 0,
|
|
182
|
-
); // BE int32
|
|
183
|
-
sp += 4;
|
|
184
|
-
}
|
|
185
|
-
}
|
|
186
|
-
|
|
187
|
-
let totalBitOffset = 0;
|
|
188
|
-
const bitStreamStart = sp;
|
|
189
|
-
|
|
190
|
-
for (let ch = 0; ch < channels; ch++) {
|
|
191
|
-
let curTablePos = initialTablePos[ch] || 0;
|
|
192
|
-
let outputWord = initialOutputWord[ch] || 0;
|
|
193
|
-
let destPos = dp + ch * 2;
|
|
194
|
-
|
|
195
|
-
let bound;
|
|
196
|
-
if (channels === 1) {
|
|
197
|
-
bound = outputSamplesLeft;
|
|
198
|
-
} else if (ch === 0) {
|
|
199
|
-
bound = Math.trunc((outputSamplesLeft + 1) / 2);
|
|
200
|
-
} else {
|
|
201
|
-
bound = Math.trunc(outputSamplesLeft / 2);
|
|
202
|
-
}
|
|
203
|
-
|
|
204
|
-
for (let i = 0; i < bound; i++) {
|
|
205
|
-
const curTableEntryBitCount = DEST_IMC_TABLE[curTablePos];
|
|
206
|
-
|
|
207
|
-
// Read variable-width packet from bitstream (big-endian)
|
|
208
|
-
const bytePos = bitStreamStart + (totalBitOffset >>> 3);
|
|
209
|
-
const bitShift = totalBitOffset & 7;
|
|
210
|
-
const readWord = ((src[bytePos] << 8) | (src[bytePos + 1] || 0)) << bitShift;
|
|
211
|
-
const packet = (readWord >>> (16 - curTableEntryBitCount)) & ((1 << curTableEntryBitCount) - 1);
|
|
212
|
-
totalBitOffset += curTableEntryBitCount;
|
|
213
|
-
|
|
214
|
-
// Extract sign and data
|
|
215
|
-
const signBitMask = 1 << (curTableEntryBitCount - 1);
|
|
216
|
-
const data = packet & (signBitMask - 1);
|
|
217
|
-
|
|
218
|
-
// Compute delta
|
|
219
|
-
const tmpA = data << (7 - curTableEntryBitCount);
|
|
220
|
-
const imcTableEntry = IMA_TABLE[curTablePos] >>> (curTableEntryBitCount - 1);
|
|
221
|
-
let delta = imcTableEntry + DEST_IMC_TABLE2[tmpA + curTablePos * 64];
|
|
222
|
-
|
|
223
|
-
if (packet & signBitMask) delta = -delta;
|
|
224
|
-
|
|
225
|
-
outputWord += delta;
|
|
226
|
-
if (outputWord < -0x8000) outputWord = -0x8000;
|
|
227
|
-
if (outputWord > 0x7fff) outputWord = 0x7fff;
|
|
228
|
-
|
|
229
|
-
// Write 16-bit LE
|
|
230
|
-
const uval = outputWord & 0xffff;
|
|
231
|
-
dst[destPos] = uval & 0xff;
|
|
232
|
-
dst[destPos + 1] = (uval >>> 8) & 0xff;
|
|
233
|
-
destPos += channels * 2;
|
|
234
|
-
|
|
235
|
-
// Adjust table position
|
|
236
|
-
const adj = IMX_OTHER_TABLE[curTableEntryBitCount - 2]?.[data] ?? -1;
|
|
237
|
-
curTablePos += adj;
|
|
238
|
-
if (curTablePos < 0) curTablePos = 0;
|
|
239
|
-
if (curTablePos > 88) curTablePos = 88;
|
|
240
|
-
}
|
|
241
|
-
}
|
|
242
|
-
|
|
243
|
-
return CHUNK_SIZE;
|
|
244
|
-
}
|
|
245
|
-
|
|
246
|
-
// ── Codec dispatcher ────────────────────────────────────────────
|
|
247
|
-
|
|
248
|
-
function decompressCodec(codec, input, inputSize) {
|
|
249
|
-
const output = Buffer.alloc(CHUNK_SIZE);
|
|
250
|
-
|
|
251
|
-
if (codec === 0) {
|
|
252
|
-
// Raw copy
|
|
253
|
-
input.copy(output, 0, 0, Math.min(inputSize, CHUNK_SIZE));
|
|
254
|
-
return output;
|
|
255
|
-
}
|
|
256
|
-
|
|
257
|
-
if (codec === 1) {
|
|
258
|
-
compDecode(input, output);
|
|
259
|
-
return output;
|
|
260
|
-
}
|
|
261
|
-
|
|
262
|
-
if (codec === 2) {
|
|
263
|
-
// LZ77 + single delta
|
|
264
|
-
const size = compDecode(input, output);
|
|
265
|
-
for (let z = 1; z < size; z++) {
|
|
266
|
-
output[z] = (output[z] + output[z - 1]) & 0xff;
|
|
267
|
-
}
|
|
268
|
-
return output;
|
|
269
|
-
}
|
|
270
|
-
|
|
271
|
-
if (codec === 3) {
|
|
272
|
-
// LZ77 + double delta
|
|
273
|
-
const size = compDecode(input, output);
|
|
274
|
-
for (let z = 2; z < size; z++) {
|
|
275
|
-
output[z] = (output[z] + output[z - 1]) & 0xff;
|
|
276
|
-
}
|
|
277
|
-
for (let z = 1; z < size; z++) {
|
|
278
|
-
output[z] = (output[z] + output[z - 1]) & 0xff;
|
|
279
|
-
}
|
|
280
|
-
return output;
|
|
281
|
-
}
|
|
282
|
-
|
|
283
|
-
if (codec === 13) {
|
|
284
|
-
decompressADPCM(input, output, 1);
|
|
285
|
-
return output;
|
|
286
|
-
}
|
|
287
|
-
|
|
288
|
-
if (codec === 15) {
|
|
289
|
-
decompressADPCM(input, output, 2);
|
|
290
|
-
return output;
|
|
291
|
-
}
|
|
292
|
-
|
|
293
|
-
// Unsupported codec — return silence
|
|
294
|
-
return output;
|
|
295
|
-
}
|
|
296
|
-
|
|
297
|
-
// ── BUN directory parsing ───────────────────────────────────────
|
|
298
|
-
|
|
299
|
-
async function readBE32(fh, offset) {
|
|
300
|
-
const buf = Buffer.alloc(4);
|
|
301
|
-
await fh.read(buf, 0, 4, offset);
|
|
302
|
-
return buf.readUInt32BE(0);
|
|
303
|
-
}
|
|
304
|
-
|
|
305
|
-
/**
|
|
306
|
-
* Parse the BUN file header and directory.
|
|
307
|
-
*/
|
|
308
|
-
export async function parseBunDirectory(fh) {
|
|
309
|
-
const tagBuf = Buffer.alloc(4);
|
|
310
|
-
await fh.read(tagBuf, 0, 4, 0);
|
|
311
|
-
const tag = tagBuf.toString("ascii");
|
|
312
|
-
const isCompressed = tag === "LB23";
|
|
313
|
-
|
|
314
|
-
const dirOffset = await readBE32(fh, 4);
|
|
315
|
-
const numFiles = await readBE32(fh, 8);
|
|
316
|
-
|
|
317
|
-
const entries = [];
|
|
318
|
-
|
|
319
|
-
if (isCompressed) {
|
|
320
|
-
// LB23 format: 24-byte filename + 4-byte offset + 4-byte size = 32 bytes per entry
|
|
321
|
-
const dirBuf = Buffer.alloc(numFiles * 32);
|
|
322
|
-
await fh.read(dirBuf, 0, dirBuf.length, dirOffset);
|
|
323
|
-
|
|
324
|
-
for (let i = 0; i < numFiles; i++) {
|
|
325
|
-
const base = i * 32;
|
|
326
|
-
const nameBuf = dirBuf.subarray(base, base + 24);
|
|
327
|
-
const nullIdx = nameBuf.indexOf(0);
|
|
328
|
-
const filename = nameBuf.subarray(0, nullIdx >= 0 ? nullIdx : 24).toString("ascii");
|
|
329
|
-
const offset = dirBuf.readUInt32BE(base + 24);
|
|
330
|
-
const size = dirBuf.readUInt32BE(base + 28);
|
|
331
|
-
entries.push({ filename, offset, size });
|
|
332
|
-
}
|
|
333
|
-
} else {
|
|
334
|
-
// Legacy format: 8-byte name + 4-byte ext + 4-byte offset + 4-byte size = 20 bytes
|
|
335
|
-
const dirBuf = Buffer.alloc(numFiles * 20);
|
|
336
|
-
await fh.read(dirBuf, 0, dirBuf.length, dirOffset);
|
|
337
|
-
|
|
338
|
-
for (let i = 0; i < numFiles; i++) {
|
|
339
|
-
const base = i * 20;
|
|
340
|
-
let name = "";
|
|
341
|
-
for (let j = 0; j < 8; j++) {
|
|
342
|
-
const ch = dirBuf[base + j];
|
|
343
|
-
if (ch === 0) break;
|
|
344
|
-
name += String.fromCharCode(ch);
|
|
345
|
-
}
|
|
346
|
-
let ext = "";
|
|
347
|
-
for (let j = 0; j < 4; j++) {
|
|
348
|
-
const ch = dirBuf[base + 8 + j];
|
|
349
|
-
if (ch === 0) break;
|
|
350
|
-
ext += String.fromCharCode(ch);
|
|
351
|
-
}
|
|
352
|
-
const filename = ext ? `${name}.${ext}` : name;
|
|
353
|
-
const offset = dirBuf.readUInt32BE(base + 12);
|
|
354
|
-
const size = dirBuf.readUInt32BE(base + 16);
|
|
355
|
-
entries.push({ filename, offset, size });
|
|
356
|
-
}
|
|
357
|
-
}
|
|
358
|
-
|
|
359
|
-
return { isCompressed, entries };
|
|
360
|
-
}
|
|
361
|
-
|
|
362
|
-
// ── COMP table loading ──────────────────────────────────────────
|
|
363
|
-
|
|
364
|
-
/**
|
|
365
|
-
* Load the COMP block table for a resource at the given offset.
|
|
366
|
-
* Returns { isUncompressed, blocks[], lastBlockSize }
|
|
367
|
-
*/
|
|
368
|
-
async function loadCompTable(fh, offset) {
|
|
369
|
-
const tagBuf = Buffer.alloc(4);
|
|
370
|
-
await fh.read(tagBuf, 0, 4, offset);
|
|
371
|
-
const tag = tagBuf.toString("ascii");
|
|
372
|
-
|
|
373
|
-
if (tag !== "COMP") {
|
|
374
|
-
// Raw iMUS — not compressed
|
|
375
|
-
return { isUncompressed: true, blocks: [], lastBlockSize: 0 };
|
|
376
|
-
}
|
|
377
|
-
|
|
378
|
-
const numBlocks = await readBE32(fh, offset + 4);
|
|
379
|
-
// Skip 4 bytes at offset+8
|
|
380
|
-
const lastBlockSize = await readBE32(fh, offset + 12);
|
|
381
|
-
|
|
382
|
-
const blocks = [];
|
|
383
|
-
const tableBuf = Buffer.alloc(numBlocks * 16);
|
|
384
|
-
await fh.read(tableBuf, 0, tableBuf.length, offset + 16);
|
|
385
|
-
|
|
386
|
-
for (let i = 0; i < numBlocks; i++) {
|
|
387
|
-
const base = i * 16;
|
|
388
|
-
blocks.push({
|
|
389
|
-
offset: tableBuf.readUInt32BE(base),
|
|
390
|
-
size: tableBuf.readUInt32BE(base + 4),
|
|
391
|
-
codec: tableBuf.readUInt32BE(base + 8),
|
|
392
|
-
// skip 4 bytes at base+12
|
|
393
|
-
});
|
|
394
|
-
}
|
|
395
|
-
|
|
396
|
-
return { isUncompressed: false, blocks, lastBlockSize };
|
|
397
|
-
}
|
|
398
|
-
|
|
399
|
-
// ── iMUS resource parsing ───────────────────────────────────────
|
|
400
|
-
|
|
401
|
-
/**
|
|
402
|
-
* Scan a decompressed buffer for FRMT and DATA chunks.
|
|
403
|
-
* Returns { sampleRate, bitsPerSample, channels, pcmData }
|
|
404
|
-
*/
|
|
405
|
-
function parseImusResource(buf) {
|
|
406
|
-
let sampleRate = 22050;
|
|
407
|
-
let bitsPerSample = 16;
|
|
408
|
-
let channels = 1;
|
|
409
|
-
let pcmData = null;
|
|
410
|
-
|
|
411
|
-
let pos = 0;
|
|
412
|
-
while (pos + 8 <= buf.length) {
|
|
413
|
-
const tag = buf.toString("ascii", pos, pos + 4);
|
|
414
|
-
const size = buf.readUInt32BE(pos + 4);
|
|
415
|
-
const chunkStart = pos + 8;
|
|
416
|
-
|
|
417
|
-
if (tag === "FRMT" && chunkStart + 20 <= buf.length) {
|
|
418
|
-
// Skip 8 bytes, then read bits, rate, channels
|
|
419
|
-
bitsPerSample = buf.readUInt32BE(chunkStart + 8);
|
|
420
|
-
sampleRate = buf.readUInt32BE(chunkStart + 12);
|
|
421
|
-
channels = buf.readUInt32BE(chunkStart + 16);
|
|
422
|
-
} else if (tag === "DATA") {
|
|
423
|
-
pcmData = buf.subarray(chunkStart, chunkStart + size);
|
|
424
|
-
break; // DATA is the last meaningful chunk
|
|
425
|
-
}
|
|
426
|
-
|
|
427
|
-
// For container tags (iMUS, MAP), descend into them
|
|
428
|
-
if (tag === "iMUS" || tag === "MAP\u0020" || tag === "MAP ") {
|
|
429
|
-
pos += 8; // descend
|
|
430
|
-
} else {
|
|
431
|
-
pos += 8 + size; // skip payload
|
|
432
|
-
}
|
|
433
|
-
}
|
|
434
|
-
|
|
435
|
-
return { sampleRate, bitsPerSample, channels, pcmData };
|
|
436
|
-
}
|
|
437
|
-
|
|
438
|
-
// ── WAV writer ──────────────────────────────────────────────────
|
|
439
|
-
|
|
440
|
-
function createWav(pcmData, sampleRate, channels, bitsPerSample) {
|
|
441
|
-
const bytesPerSample = Math.trunc(bitsPerSample / 8);
|
|
442
|
-
const byteRate = sampleRate * channels * bytesPerSample;
|
|
443
|
-
const blockAlign = channels * bytesPerSample;
|
|
444
|
-
const dataSize = pcmData.length;
|
|
445
|
-
const headerSize = 44;
|
|
446
|
-
|
|
447
|
-
const wav = Buffer.alloc(headerSize + dataSize);
|
|
448
|
-
wav.write("RIFF", 0);
|
|
449
|
-
wav.writeUInt32LE(headerSize + dataSize - 8, 4);
|
|
450
|
-
wav.write("WAVE", 8);
|
|
451
|
-
wav.write("fmt ", 12);
|
|
452
|
-
wav.writeUInt32LE(16, 16); // fmt chunk size
|
|
453
|
-
wav.writeUInt16LE(1, 20); // PCM format
|
|
454
|
-
wav.writeUInt16LE(channels, 22);
|
|
455
|
-
wav.writeUInt32LE(sampleRate, 24);
|
|
456
|
-
wav.writeUInt32LE(byteRate, 28);
|
|
457
|
-
wav.writeUInt16LE(blockAlign, 32);
|
|
458
|
-
wav.writeUInt16LE(bitsPerSample, 34);
|
|
459
|
-
wav.write("data", 36);
|
|
460
|
-
wav.writeUInt32LE(dataSize, 40);
|
|
461
|
-
pcmData.copy(wav, 44);
|
|
462
|
-
|
|
463
|
-
return wav;
|
|
464
|
-
}
|
|
465
|
-
|
|
466
|
-
// ── Main extraction entry point ─────────────────────────────────
|
|
467
|
-
|
|
468
|
-
/**
|
|
469
|
-
* Extract all audio resources from a BUN file to WAV files.
|
|
470
|
-
*
|
|
471
|
-
* @param {string} bunPath - Path to the .BUN file
|
|
472
|
-
* @param {string} outputDir - Directory to write WAV files
|
|
473
|
-
* @param {(msg: string) => void} [onProgress] - Progress callback
|
|
474
|
-
* @returns {Promise<string[]>} Array of output WAV file paths
|
|
475
|
-
*/
|
|
476
|
-
export async function extractBunFile(bunPath, outputDir, onProgress) {
|
|
477
|
-
await mkdir(outputDir, { recursive: true });
|
|
478
|
-
|
|
479
|
-
const fh = await open(bunPath, "r");
|
|
480
|
-
const outputs = [];
|
|
481
|
-
|
|
482
|
-
try {
|
|
483
|
-
const { entries } = await parseBunDirectory(fh);
|
|
484
|
-
|
|
485
|
-
for (let ei = 0; ei < entries.length; ei++) {
|
|
486
|
-
const entry = entries[ei];
|
|
487
|
-
if (onProgress) onProgress(`Extracting ${ei + 1}/${entries.length}: ${entry.filename}`);
|
|
488
|
-
|
|
489
|
-
try {
|
|
490
|
-
const comp = await loadCompTable(fh, entry.offset);
|
|
491
|
-
|
|
492
|
-
let fullBuf;
|
|
493
|
-
if (comp.isUncompressed) {
|
|
494
|
-
// Read raw iMUS data
|
|
495
|
-
fullBuf = Buffer.alloc(entry.size);
|
|
496
|
-
await fh.read(fullBuf, 0, entry.size, entry.offset);
|
|
497
|
-
} else {
|
|
498
|
-
// Decompress all blocks
|
|
499
|
-
const totalSize = (comp.blocks.length - 1) * CHUNK_SIZE + comp.lastBlockSize;
|
|
500
|
-
fullBuf = Buffer.alloc(totalSize);
|
|
501
|
-
let outPos = 0;
|
|
502
|
-
|
|
503
|
-
for (let i = 0; i < comp.blocks.length; i++) {
|
|
504
|
-
const block = comp.blocks[i];
|
|
505
|
-
const inputBuf = Buffer.alloc(block.size + 1); // +1 CMI hack
|
|
506
|
-
await fh.read(inputBuf, 0, block.size, entry.offset + block.offset);
|
|
507
|
-
inputBuf[block.size] = 0; // zero padding
|
|
508
|
-
|
|
509
|
-
const decompressed = decompressCodec(block.codec, inputBuf, block.size);
|
|
510
|
-
const blockOutSize = i === comp.blocks.length - 1 ? comp.lastBlockSize : CHUNK_SIZE;
|
|
511
|
-
decompressed.copy(fullBuf, outPos, 0, blockOutSize);
|
|
512
|
-
outPos += blockOutSize;
|
|
513
|
-
}
|
|
514
|
-
}
|
|
515
|
-
|
|
516
|
-
// Parse the iMUS structure
|
|
517
|
-
const { sampleRate, bitsPerSample, channels, pcmData } = parseImusResource(fullBuf);
|
|
518
|
-
if (!pcmData || pcmData.length === 0) continue;
|
|
519
|
-
|
|
520
|
-
// Write WAV into a subdirectory named after the BUN file
|
|
521
|
-
const wav = createWav(pcmData, sampleRate, channels, bitsPerSample);
|
|
522
|
-
const bunName = basename(bunPath, extname(bunPath)).toLowerCase();
|
|
523
|
-
const subDir = bunName.includes("vox") ? "voice"
|
|
524
|
-
: bunName.includes("mus") ? "music"
|
|
525
|
-
: bunName.includes("sfx") ? "sfx"
|
|
526
|
-
: "other";
|
|
527
|
-
const bunOutputDir = join(outputDir, subDir);
|
|
528
|
-
await mkdir(bunOutputDir, { recursive: true });
|
|
529
|
-
const safeName = entry.filename.replace(/[^a-zA-Z0-9._-]/g, "_");
|
|
530
|
-
const outName = safeName.replace(/\.[^.]+$/, "") + ".wav";
|
|
531
|
-
const outPath = join(bunOutputDir, outName);
|
|
532
|
-
await writeFile(outPath, wav);
|
|
533
|
-
outputs.push(outPath);
|
|
534
|
-
} catch {
|
|
535
|
-
// Skip entries that fail to decompress
|
|
536
|
-
}
|
|
537
|
-
}
|
|
538
|
-
} finally {
|
|
539
|
-
await fh.close();
|
|
540
|
-
}
|
|
541
|
-
|
|
542
|
-
return outputs;
|
|
543
|
-
}
|
|
544
|
-
|
|
545
|
-
/**
|
|
546
|
-
* Quick magic-byte check for BUN files.
|
|
547
|
-
* Returns true if the file starts with 'LB23'.
|
|
548
|
-
*/
|
|
549
|
-
export async function isBunFile(filePath) {
|
|
550
|
-
try {
|
|
551
|
-
const fh = await open(filePath, "r");
|
|
552
|
-
const buf = Buffer.alloc(4);
|
|
553
|
-
await fh.read(buf, 0, 4, 0);
|
|
554
|
-
await fh.close();
|
|
555
|
-
const tag = buf.toString("ascii");
|
|
556
|
-
return tag === "LB23";
|
|
557
|
-
} catch {
|
|
558
|
-
return false;
|
|
559
|
-
}
|
|
560
|
-
}
|
|
1
|
+
/**
|
|
2
|
+
* SCUMM engine .BUN audio extractor.
|
|
3
|
+
*
|
|
4
|
+
* Extracts audio resources from LucasArts SCUMM engine bundle files
|
|
5
|
+
* (Curse of Monkey Island, The Dig, Full Throttle, etc.).
|
|
6
|
+
*
|
|
7
|
+
* BUN files contain compressed iMUS audio resources. Each resource is
|
|
8
|
+
* decompressed block-by-block using LZ77 and/or IMA ADPCM codecs,
|
|
9
|
+
* producing raw PCM data that is written out as WAV files.
|
|
10
|
+
*
|
|
11
|
+
* Format details derived from the publicly documented ScummVM specifications.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import { open, mkdir, writeFile } from "node:fs/promises";
|
|
15
|
+
import { join, basename, extname } from "node:path";
|
|
16
|
+
|
|
17
|
+
const CHUNK_SIZE = 0x2000; // 8192 bytes — standard decompressed block size
|
|
18
|
+
|
|
19
|
+
// ── IMA Step Table (public domain, 89 values) ──────────────────
|
|
20
|
+
const IMA_TABLE = [
|
|
21
|
+
7, 8, 9, 10, 11, 12, 13, 14,
|
|
22
|
+
16, 17, 19, 21, 23, 25, 28, 31,
|
|
23
|
+
34, 37, 41, 45, 50, 55, 60, 66,
|
|
24
|
+
73, 80, 88, 97, 107, 118, 130, 143,
|
|
25
|
+
157, 173, 190, 209, 230, 253, 279, 307,
|
|
26
|
+
337, 371, 408, 449, 494, 544, 598, 658,
|
|
27
|
+
724, 796, 876, 963, 1060, 1166, 1282, 1411,
|
|
28
|
+
1552, 1707, 1878, 2066, 2272, 2499, 2749, 3024,
|
|
29
|
+
3327, 3660, 4026, 4428, 4871, 5358, 5894, 6484,
|
|
30
|
+
7132, 7845, 8630, 9493, 10442, 11487, 12635, 13899,
|
|
31
|
+
15289, 16818, 18500, 20350, 22385, 24623, 27086, 29794,
|
|
32
|
+
32767,
|
|
33
|
+
];
|
|
34
|
+
|
|
35
|
+
// ── Step adjustment table indexed by [bitCount-2][data] ─────────
|
|
36
|
+
const IMX_OTHER_TABLE = [
|
|
37
|
+
// bitcount=2 (2 entries)
|
|
38
|
+
[-1, 4],
|
|
39
|
+
// bitcount=3 (4 entries)
|
|
40
|
+
[-1, -1, 2, 8],
|
|
41
|
+
// bitcount=4 (8 entries)
|
|
42
|
+
[-1, -1, -1, -1, 1, 2, 4, 6],
|
|
43
|
+
// bitcount=5 (16 entries)
|
|
44
|
+
[-1, -1, -1, -1, -1, -1, -1, -1, 1, 2, 4, 6, 8, 12, 16, 32],
|
|
45
|
+
// bitcount=6 (32 entries)
|
|
46
|
+
[
|
|
47
|
+
-1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1,
|
|
48
|
+
1, 2, 4, 6, 8, 10, 12, 14, 16, 18, 20, 22, 24, 26, 28, 32,
|
|
49
|
+
],
|
|
50
|
+
// bitcount=7 (64 entries)
|
|
51
|
+
[
|
|
52
|
+
-1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1,
|
|
53
|
+
-1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1,
|
|
54
|
+
1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16,
|
|
55
|
+
17, 18, 19, 20, 21, 22, 23, 24, 25, 26, 27, 28, 29, 30, 31, 32,
|
|
56
|
+
],
|
|
57
|
+
];
|
|
58
|
+
|
|
59
|
+
// ── Precomputed tables (built once at module load) ──────────────
|
|
60
|
+
|
|
61
|
+
// _destImcTable[pos]: how many bits to read per IMA position (2-7)
|
|
62
|
+
const DEST_IMC_TABLE = new Uint8Array(89);
|
|
63
|
+
|
|
64
|
+
// _destImcTable2[pos*64 + n]: precomputed delta contribution
|
|
65
|
+
const DEST_IMC_TABLE2 = new Int32Array(89 * 64);
|
|
66
|
+
|
|
67
|
+
(function initTables() {
|
|
68
|
+
// Build _destImcTable
|
|
69
|
+
for (let pos = 0; pos <= 88; pos++) {
|
|
70
|
+
let put = 1;
|
|
71
|
+
let val = Math.trunc(Math.trunc(IMA_TABLE[pos] * 4 / 7) / 2);
|
|
72
|
+
while (val !== 0) {
|
|
73
|
+
val = Math.trunc(val / 2);
|
|
74
|
+
put++;
|
|
75
|
+
}
|
|
76
|
+
if (put < 3) put = 3;
|
|
77
|
+
if (put > 8) put = 8;
|
|
78
|
+
DEST_IMC_TABLE[pos] = put - 1;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
// Build _destImcTable2
|
|
82
|
+
for (let n = 0; n < 64; n++) {
|
|
83
|
+
for (let pos = 0; pos <= 88; pos++) {
|
|
84
|
+
let count = 32;
|
|
85
|
+
let put = 0;
|
|
86
|
+
let tableValue = IMA_TABLE[pos];
|
|
87
|
+
do {
|
|
88
|
+
if ((count & n) !== 0) {
|
|
89
|
+
put += tableValue;
|
|
90
|
+
}
|
|
91
|
+
count = Math.trunc(count / 2);
|
|
92
|
+
tableValue = Math.trunc(tableValue / 2);
|
|
93
|
+
} while (count !== 0);
|
|
94
|
+
DEST_IMC_TABLE2[n + pos * 64] = put;
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
})();
|
|
98
|
+
|
|
99
|
+
// ── LZ77 decompressor ──────────────────────────────────────────
|
|
100
|
+
|
|
101
|
+
function compDecode(src, dst) {
|
|
102
|
+
let sp = 0; // source pointer
|
|
103
|
+
let dp = 0; // dest pointer
|
|
104
|
+
let mask = src[sp] | (src[sp + 1] << 8); // LE uint16
|
|
105
|
+
sp += 2;
|
|
106
|
+
let bitsLeft = 16;
|
|
107
|
+
|
|
108
|
+
function nextBit() {
|
|
109
|
+
const bit = mask & 1;
|
|
110
|
+
mask >>>= 1;
|
|
111
|
+
bitsLeft--;
|
|
112
|
+
if (bitsLeft === 0) {
|
|
113
|
+
mask = src[sp] | (src[sp + 1] << 8);
|
|
114
|
+
sp += 2;
|
|
115
|
+
bitsLeft = 16;
|
|
116
|
+
}
|
|
117
|
+
return bit;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
for (;;) {
|
|
121
|
+
if (nextBit()) {
|
|
122
|
+
// Literal byte
|
|
123
|
+
dst[dp++] = src[sp++];
|
|
124
|
+
} else {
|
|
125
|
+
let size, data;
|
|
126
|
+
if (!nextBit()) {
|
|
127
|
+
// Short back-reference
|
|
128
|
+
size = nextBit() << 1;
|
|
129
|
+
size = (size | nextBit()) + 3; // 3..6
|
|
130
|
+
data = src[sp++] | 0xffffff00; // sign-extend byte to negative offset
|
|
131
|
+
} else {
|
|
132
|
+
// Long back-reference
|
|
133
|
+
data = src[sp++];
|
|
134
|
+
size = src[sp++];
|
|
135
|
+
data |= 0xfffff000 + ((size & 0xf0) << 4); // 12-bit negative offset
|
|
136
|
+
size = (size & 0x0f) + 3; // 3..18
|
|
137
|
+
|
|
138
|
+
if (size === 3) {
|
|
139
|
+
// Size field was 0 — check terminator
|
|
140
|
+
if ((src[sp++] + 1) === 1) {
|
|
141
|
+
return dp; // done
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
// data is a negative offset (as signed 32-bit)
|
|
146
|
+
let refPos = dp + (data | 0); // ensure signed
|
|
147
|
+
for (let i = 0; i < size; i++) {
|
|
148
|
+
dst[dp++] = dst[refPos++];
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
// ── ADPCM decompressor ─────────────────────────────────────────
|
|
155
|
+
|
|
156
|
+
function decompressADPCM(src, dst, channels) {
|
|
157
|
+
let sp = 0;
|
|
158
|
+
const firstWord = (src[sp] << 8) | src[sp + 1]; // BE uint16
|
|
159
|
+
sp += 2;
|
|
160
|
+
|
|
161
|
+
let dp = 0;
|
|
162
|
+
let outputSamplesLeft = 0x1000; // 4096 samples = 8192 bytes
|
|
163
|
+
|
|
164
|
+
if (firstWord !== 0) {
|
|
165
|
+
// Copy raw bytes
|
|
166
|
+
for (let i = 0; i < firstWord; i++) {
|
|
167
|
+
dst[dp++] = src[sp++];
|
|
168
|
+
}
|
|
169
|
+
outputSamplesLeft -= Math.trunc(firstWord / 2);
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
// Read seed values per channel
|
|
173
|
+
const initialTablePos = [];
|
|
174
|
+
const initialOutputWord = [];
|
|
175
|
+
if (firstWord === 0) {
|
|
176
|
+
for (let ch = 0; ch < channels; ch++) {
|
|
177
|
+
initialTablePos.push(src[sp]);
|
|
178
|
+
sp += 1;
|
|
179
|
+
sp += 4; // skip 4 bytes
|
|
180
|
+
initialOutputWord.push(
|
|
181
|
+
((src[sp] << 24) | (src[sp + 1] << 16) | (src[sp + 2] << 8) | src[sp + 3]) | 0,
|
|
182
|
+
); // BE int32
|
|
183
|
+
sp += 4;
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
let totalBitOffset = 0;
|
|
188
|
+
const bitStreamStart = sp;
|
|
189
|
+
|
|
190
|
+
for (let ch = 0; ch < channels; ch++) {
|
|
191
|
+
let curTablePos = initialTablePos[ch] || 0;
|
|
192
|
+
let outputWord = initialOutputWord[ch] || 0;
|
|
193
|
+
let destPos = dp + ch * 2;
|
|
194
|
+
|
|
195
|
+
let bound;
|
|
196
|
+
if (channels === 1) {
|
|
197
|
+
bound = outputSamplesLeft;
|
|
198
|
+
} else if (ch === 0) {
|
|
199
|
+
bound = Math.trunc((outputSamplesLeft + 1) / 2);
|
|
200
|
+
} else {
|
|
201
|
+
bound = Math.trunc(outputSamplesLeft / 2);
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
for (let i = 0; i < bound; i++) {
|
|
205
|
+
const curTableEntryBitCount = DEST_IMC_TABLE[curTablePos];
|
|
206
|
+
|
|
207
|
+
// Read variable-width packet from bitstream (big-endian)
|
|
208
|
+
const bytePos = bitStreamStart + (totalBitOffset >>> 3);
|
|
209
|
+
const bitShift = totalBitOffset & 7;
|
|
210
|
+
const readWord = ((src[bytePos] << 8) | (src[bytePos + 1] || 0)) << bitShift;
|
|
211
|
+
const packet = (readWord >>> (16 - curTableEntryBitCount)) & ((1 << curTableEntryBitCount) - 1);
|
|
212
|
+
totalBitOffset += curTableEntryBitCount;
|
|
213
|
+
|
|
214
|
+
// Extract sign and data
|
|
215
|
+
const signBitMask = 1 << (curTableEntryBitCount - 1);
|
|
216
|
+
const data = packet & (signBitMask - 1);
|
|
217
|
+
|
|
218
|
+
// Compute delta
|
|
219
|
+
const tmpA = data << (7 - curTableEntryBitCount);
|
|
220
|
+
const imcTableEntry = IMA_TABLE[curTablePos] >>> (curTableEntryBitCount - 1);
|
|
221
|
+
let delta = imcTableEntry + DEST_IMC_TABLE2[tmpA + curTablePos * 64];
|
|
222
|
+
|
|
223
|
+
if (packet & signBitMask) delta = -delta;
|
|
224
|
+
|
|
225
|
+
outputWord += delta;
|
|
226
|
+
if (outputWord < -0x8000) outputWord = -0x8000;
|
|
227
|
+
if (outputWord > 0x7fff) outputWord = 0x7fff;
|
|
228
|
+
|
|
229
|
+
// Write 16-bit LE
|
|
230
|
+
const uval = outputWord & 0xffff;
|
|
231
|
+
dst[destPos] = uval & 0xff;
|
|
232
|
+
dst[destPos + 1] = (uval >>> 8) & 0xff;
|
|
233
|
+
destPos += channels * 2;
|
|
234
|
+
|
|
235
|
+
// Adjust table position
|
|
236
|
+
const adj = IMX_OTHER_TABLE[curTableEntryBitCount - 2]?.[data] ?? -1;
|
|
237
|
+
curTablePos += adj;
|
|
238
|
+
if (curTablePos < 0) curTablePos = 0;
|
|
239
|
+
if (curTablePos > 88) curTablePos = 88;
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
return CHUNK_SIZE;
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
// ── Codec dispatcher ────────────────────────────────────────────
|
|
247
|
+
|
|
248
|
+
function decompressCodec(codec, input, inputSize) {
|
|
249
|
+
const output = Buffer.alloc(CHUNK_SIZE);
|
|
250
|
+
|
|
251
|
+
if (codec === 0) {
|
|
252
|
+
// Raw copy
|
|
253
|
+
input.copy(output, 0, 0, Math.min(inputSize, CHUNK_SIZE));
|
|
254
|
+
return output;
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
if (codec === 1) {
|
|
258
|
+
compDecode(input, output);
|
|
259
|
+
return output;
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
if (codec === 2) {
|
|
263
|
+
// LZ77 + single delta
|
|
264
|
+
const size = compDecode(input, output);
|
|
265
|
+
for (let z = 1; z < size; z++) {
|
|
266
|
+
output[z] = (output[z] + output[z - 1]) & 0xff;
|
|
267
|
+
}
|
|
268
|
+
return output;
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
if (codec === 3) {
|
|
272
|
+
// LZ77 + double delta
|
|
273
|
+
const size = compDecode(input, output);
|
|
274
|
+
for (let z = 2; z < size; z++) {
|
|
275
|
+
output[z] = (output[z] + output[z - 1]) & 0xff;
|
|
276
|
+
}
|
|
277
|
+
for (let z = 1; z < size; z++) {
|
|
278
|
+
output[z] = (output[z] + output[z - 1]) & 0xff;
|
|
279
|
+
}
|
|
280
|
+
return output;
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
if (codec === 13) {
|
|
284
|
+
decompressADPCM(input, output, 1);
|
|
285
|
+
return output;
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
if (codec === 15) {
|
|
289
|
+
decompressADPCM(input, output, 2);
|
|
290
|
+
return output;
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
// Unsupported codec — return silence
|
|
294
|
+
return output;
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
// ── BUN directory parsing ───────────────────────────────────────
|
|
298
|
+
|
|
299
|
+
async function readBE32(fh, offset) {
|
|
300
|
+
const buf = Buffer.alloc(4);
|
|
301
|
+
await fh.read(buf, 0, 4, offset);
|
|
302
|
+
return buf.readUInt32BE(0);
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
/**
|
|
306
|
+
* Parse the BUN file header and directory.
|
|
307
|
+
*/
|
|
308
|
+
export async function parseBunDirectory(fh) {
|
|
309
|
+
const tagBuf = Buffer.alloc(4);
|
|
310
|
+
await fh.read(tagBuf, 0, 4, 0);
|
|
311
|
+
const tag = tagBuf.toString("ascii");
|
|
312
|
+
const isCompressed = tag === "LB23";
|
|
313
|
+
|
|
314
|
+
const dirOffset = await readBE32(fh, 4);
|
|
315
|
+
const numFiles = await readBE32(fh, 8);
|
|
316
|
+
|
|
317
|
+
const entries = [];
|
|
318
|
+
|
|
319
|
+
if (isCompressed) {
|
|
320
|
+
// LB23 format: 24-byte filename + 4-byte offset + 4-byte size = 32 bytes per entry
|
|
321
|
+
const dirBuf = Buffer.alloc(numFiles * 32);
|
|
322
|
+
await fh.read(dirBuf, 0, dirBuf.length, dirOffset);
|
|
323
|
+
|
|
324
|
+
for (let i = 0; i < numFiles; i++) {
|
|
325
|
+
const base = i * 32;
|
|
326
|
+
const nameBuf = dirBuf.subarray(base, base + 24);
|
|
327
|
+
const nullIdx = nameBuf.indexOf(0);
|
|
328
|
+
const filename = nameBuf.subarray(0, nullIdx >= 0 ? nullIdx : 24).toString("ascii");
|
|
329
|
+
const offset = dirBuf.readUInt32BE(base + 24);
|
|
330
|
+
const size = dirBuf.readUInt32BE(base + 28);
|
|
331
|
+
entries.push({ filename, offset, size });
|
|
332
|
+
}
|
|
333
|
+
} else {
|
|
334
|
+
// Legacy format: 8-byte name + 4-byte ext + 4-byte offset + 4-byte size = 20 bytes
|
|
335
|
+
const dirBuf = Buffer.alloc(numFiles * 20);
|
|
336
|
+
await fh.read(dirBuf, 0, dirBuf.length, dirOffset);
|
|
337
|
+
|
|
338
|
+
for (let i = 0; i < numFiles; i++) {
|
|
339
|
+
const base = i * 20;
|
|
340
|
+
let name = "";
|
|
341
|
+
for (let j = 0; j < 8; j++) {
|
|
342
|
+
const ch = dirBuf[base + j];
|
|
343
|
+
if (ch === 0) break;
|
|
344
|
+
name += String.fromCharCode(ch);
|
|
345
|
+
}
|
|
346
|
+
let ext = "";
|
|
347
|
+
for (let j = 0; j < 4; j++) {
|
|
348
|
+
const ch = dirBuf[base + 8 + j];
|
|
349
|
+
if (ch === 0) break;
|
|
350
|
+
ext += String.fromCharCode(ch);
|
|
351
|
+
}
|
|
352
|
+
const filename = ext ? `${name}.${ext}` : name;
|
|
353
|
+
const offset = dirBuf.readUInt32BE(base + 12);
|
|
354
|
+
const size = dirBuf.readUInt32BE(base + 16);
|
|
355
|
+
entries.push({ filename, offset, size });
|
|
356
|
+
}
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
return { isCompressed, entries };
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
// ── COMP table loading ──────────────────────────────────────────
|
|
363
|
+
|
|
364
|
+
/**
|
|
365
|
+
* Load the COMP block table for a resource at the given offset.
|
|
366
|
+
* Returns { isUncompressed, blocks[], lastBlockSize }
|
|
367
|
+
*/
|
|
368
|
+
async function loadCompTable(fh, offset) {
|
|
369
|
+
const tagBuf = Buffer.alloc(4);
|
|
370
|
+
await fh.read(tagBuf, 0, 4, offset);
|
|
371
|
+
const tag = tagBuf.toString("ascii");
|
|
372
|
+
|
|
373
|
+
if (tag !== "COMP") {
|
|
374
|
+
// Raw iMUS — not compressed
|
|
375
|
+
return { isUncompressed: true, blocks: [], lastBlockSize: 0 };
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
const numBlocks = await readBE32(fh, offset + 4);
|
|
379
|
+
// Skip 4 bytes at offset+8
|
|
380
|
+
const lastBlockSize = await readBE32(fh, offset + 12);
|
|
381
|
+
|
|
382
|
+
const blocks = [];
|
|
383
|
+
const tableBuf = Buffer.alloc(numBlocks * 16);
|
|
384
|
+
await fh.read(tableBuf, 0, tableBuf.length, offset + 16);
|
|
385
|
+
|
|
386
|
+
for (let i = 0; i < numBlocks; i++) {
|
|
387
|
+
const base = i * 16;
|
|
388
|
+
blocks.push({
|
|
389
|
+
offset: tableBuf.readUInt32BE(base),
|
|
390
|
+
size: tableBuf.readUInt32BE(base + 4),
|
|
391
|
+
codec: tableBuf.readUInt32BE(base + 8),
|
|
392
|
+
// skip 4 bytes at base+12
|
|
393
|
+
});
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
return { isUncompressed: false, blocks, lastBlockSize };
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
// ── iMUS resource parsing ───────────────────────────────────────
|
|
400
|
+
|
|
401
|
+
/**
|
|
402
|
+
* Scan a decompressed buffer for FRMT and DATA chunks.
|
|
403
|
+
* Returns { sampleRate, bitsPerSample, channels, pcmData }
|
|
404
|
+
*/
|
|
405
|
+
function parseImusResource(buf) {
|
|
406
|
+
let sampleRate = 22050;
|
|
407
|
+
let bitsPerSample = 16;
|
|
408
|
+
let channels = 1;
|
|
409
|
+
let pcmData = null;
|
|
410
|
+
|
|
411
|
+
let pos = 0;
|
|
412
|
+
while (pos + 8 <= buf.length) {
|
|
413
|
+
const tag = buf.toString("ascii", pos, pos + 4);
|
|
414
|
+
const size = buf.readUInt32BE(pos + 4);
|
|
415
|
+
const chunkStart = pos + 8;
|
|
416
|
+
|
|
417
|
+
if (tag === "FRMT" && chunkStart + 20 <= buf.length) {
|
|
418
|
+
// Skip 8 bytes, then read bits, rate, channels
|
|
419
|
+
bitsPerSample = buf.readUInt32BE(chunkStart + 8);
|
|
420
|
+
sampleRate = buf.readUInt32BE(chunkStart + 12);
|
|
421
|
+
channels = buf.readUInt32BE(chunkStart + 16);
|
|
422
|
+
} else if (tag === "DATA") {
|
|
423
|
+
pcmData = buf.subarray(chunkStart, chunkStart + size);
|
|
424
|
+
break; // DATA is the last meaningful chunk
|
|
425
|
+
}
|
|
426
|
+
|
|
427
|
+
// For container tags (iMUS, MAP), descend into them
|
|
428
|
+
if (tag === "iMUS" || tag === "MAP\u0020" || tag === "MAP ") {
|
|
429
|
+
pos += 8; // descend
|
|
430
|
+
} else {
|
|
431
|
+
pos += 8 + size; // skip payload
|
|
432
|
+
}
|
|
433
|
+
}
|
|
434
|
+
|
|
435
|
+
return { sampleRate, bitsPerSample, channels, pcmData };
|
|
436
|
+
}
|
|
437
|
+
|
|
438
|
+
// ── WAV writer ──────────────────────────────────────────────────
|
|
439
|
+
|
|
440
|
+
function createWav(pcmData, sampleRate, channels, bitsPerSample) {
|
|
441
|
+
const bytesPerSample = Math.trunc(bitsPerSample / 8);
|
|
442
|
+
const byteRate = sampleRate * channels * bytesPerSample;
|
|
443
|
+
const blockAlign = channels * bytesPerSample;
|
|
444
|
+
const dataSize = pcmData.length;
|
|
445
|
+
const headerSize = 44;
|
|
446
|
+
|
|
447
|
+
const wav = Buffer.alloc(headerSize + dataSize);
|
|
448
|
+
wav.write("RIFF", 0);
|
|
449
|
+
wav.writeUInt32LE(headerSize + dataSize - 8, 4);
|
|
450
|
+
wav.write("WAVE", 8);
|
|
451
|
+
wav.write("fmt ", 12);
|
|
452
|
+
wav.writeUInt32LE(16, 16); // fmt chunk size
|
|
453
|
+
wav.writeUInt16LE(1, 20); // PCM format
|
|
454
|
+
wav.writeUInt16LE(channels, 22);
|
|
455
|
+
wav.writeUInt32LE(sampleRate, 24);
|
|
456
|
+
wav.writeUInt32LE(byteRate, 28);
|
|
457
|
+
wav.writeUInt16LE(blockAlign, 32);
|
|
458
|
+
wav.writeUInt16LE(bitsPerSample, 34);
|
|
459
|
+
wav.write("data", 36);
|
|
460
|
+
wav.writeUInt32LE(dataSize, 40);
|
|
461
|
+
pcmData.copy(wav, 44);
|
|
462
|
+
|
|
463
|
+
return wav;
|
|
464
|
+
}
|
|
465
|
+
|
|
466
|
+
// ── Main extraction entry point ─────────────────────────────────
|
|
467
|
+
|
|
468
|
+
/**
|
|
469
|
+
* Extract all audio resources from a BUN file to WAV files.
|
|
470
|
+
*
|
|
471
|
+
* @param {string} bunPath - Path to the .BUN file
|
|
472
|
+
* @param {string} outputDir - Directory to write WAV files
|
|
473
|
+
* @param {(msg: string) => void} [onProgress] - Progress callback
|
|
474
|
+
* @returns {Promise<string[]>} Array of output WAV file paths
|
|
475
|
+
*/
|
|
476
|
+
export async function extractBunFile(bunPath, outputDir, onProgress) {
|
|
477
|
+
await mkdir(outputDir, { recursive: true });
|
|
478
|
+
|
|
479
|
+
const fh = await open(bunPath, "r");
|
|
480
|
+
const outputs = [];
|
|
481
|
+
|
|
482
|
+
try {
|
|
483
|
+
const { entries } = await parseBunDirectory(fh);
|
|
484
|
+
|
|
485
|
+
for (let ei = 0; ei < entries.length; ei++) {
|
|
486
|
+
const entry = entries[ei];
|
|
487
|
+
if (onProgress) onProgress(`Extracting ${ei + 1}/${entries.length}: ${entry.filename}`);
|
|
488
|
+
|
|
489
|
+
try {
|
|
490
|
+
const comp = await loadCompTable(fh, entry.offset);
|
|
491
|
+
|
|
492
|
+
let fullBuf;
|
|
493
|
+
if (comp.isUncompressed) {
|
|
494
|
+
// Read raw iMUS data
|
|
495
|
+
fullBuf = Buffer.alloc(entry.size);
|
|
496
|
+
await fh.read(fullBuf, 0, entry.size, entry.offset);
|
|
497
|
+
} else {
|
|
498
|
+
// Decompress all blocks
|
|
499
|
+
const totalSize = (comp.blocks.length - 1) * CHUNK_SIZE + comp.lastBlockSize;
|
|
500
|
+
fullBuf = Buffer.alloc(totalSize);
|
|
501
|
+
let outPos = 0;
|
|
502
|
+
|
|
503
|
+
for (let i = 0; i < comp.blocks.length; i++) {
|
|
504
|
+
const block = comp.blocks[i];
|
|
505
|
+
const inputBuf = Buffer.alloc(block.size + 1); // +1 CMI hack
|
|
506
|
+
await fh.read(inputBuf, 0, block.size, entry.offset + block.offset);
|
|
507
|
+
inputBuf[block.size] = 0; // zero padding
|
|
508
|
+
|
|
509
|
+
const decompressed = decompressCodec(block.codec, inputBuf, block.size);
|
|
510
|
+
const blockOutSize = i === comp.blocks.length - 1 ? comp.lastBlockSize : CHUNK_SIZE;
|
|
511
|
+
decompressed.copy(fullBuf, outPos, 0, blockOutSize);
|
|
512
|
+
outPos += blockOutSize;
|
|
513
|
+
}
|
|
514
|
+
}
|
|
515
|
+
|
|
516
|
+
// Parse the iMUS structure
|
|
517
|
+
const { sampleRate, bitsPerSample, channels, pcmData } = parseImusResource(fullBuf);
|
|
518
|
+
if (!pcmData || pcmData.length === 0) continue;
|
|
519
|
+
|
|
520
|
+
// Write WAV into a subdirectory named after the BUN file
|
|
521
|
+
const wav = createWav(pcmData, sampleRate, channels, bitsPerSample);
|
|
522
|
+
const bunName = basename(bunPath, extname(bunPath)).toLowerCase();
|
|
523
|
+
const subDir = bunName.includes("vox") ? "voice"
|
|
524
|
+
: bunName.includes("mus") ? "music"
|
|
525
|
+
: bunName.includes("sfx") ? "sfx"
|
|
526
|
+
: "other";
|
|
527
|
+
const bunOutputDir = join(outputDir, subDir);
|
|
528
|
+
await mkdir(bunOutputDir, { recursive: true });
|
|
529
|
+
const safeName = entry.filename.replace(/[^a-zA-Z0-9._-]/g, "_");
|
|
530
|
+
const outName = safeName.replace(/\.[^.]+$/, "") + ".wav";
|
|
531
|
+
const outPath = join(bunOutputDir, outName);
|
|
532
|
+
await writeFile(outPath, wav);
|
|
533
|
+
outputs.push(outPath);
|
|
534
|
+
} catch {
|
|
535
|
+
// Skip entries that fail to decompress
|
|
536
|
+
}
|
|
537
|
+
}
|
|
538
|
+
} finally {
|
|
539
|
+
await fh.close();
|
|
540
|
+
}
|
|
541
|
+
|
|
542
|
+
return outputs;
|
|
543
|
+
}
|
|
544
|
+
|
|
545
|
+
/**
|
|
546
|
+
* Quick magic-byte check for BUN files.
|
|
547
|
+
* Returns true if the file starts with 'LB23'.
|
|
548
|
+
*/
|
|
549
|
+
export async function isBunFile(filePath) {
|
|
550
|
+
try {
|
|
551
|
+
const fh = await open(filePath, "r");
|
|
552
|
+
const buf = Buffer.alloc(4);
|
|
553
|
+
await fh.read(buf, 0, 4, 0);
|
|
554
|
+
await fh.close();
|
|
555
|
+
const tag = buf.toString("ascii");
|
|
556
|
+
return tag === "LB23";
|
|
557
|
+
} catch {
|
|
558
|
+
return false;
|
|
559
|
+
}
|
|
560
|
+
}
|