klaudio 0.4.1 → 0.5.1

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/bin/cli.js CHANGED
File without changes
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "klaudio",
3
- "version": "0.4.1",
3
+ "version": "0.5.1",
4
4
  "description": "Add sound effects to your coding sessions — play sounds when tasks complete, notifications arrive, and more",
5
5
  "type": "module",
6
6
  "bin": {
package/src/cli.js CHANGED
@@ -7,6 +7,7 @@ import { getAvailableGames } from "./scanner.js";
7
7
  import { install, uninstall, getExistingSounds } from "./installer.js";
8
8
  import { getVgmstreamPath, findPackedAudioFiles, extractToWav } from "./extractor.js";
9
9
  import { extractUnityResource } from "./unity.js";
10
+ import { extractBunFile, isBunFile } from "./scumm.js";
10
11
  import { getCachedExtraction, cacheExtraction, categorizeLooseFiles, getCategories, sortFilesByPriority } from "./cache.js";
11
12
  import { basename, dirname } from "node:path";
12
13
  import { tmpdir } from "node:os";
@@ -422,6 +423,12 @@ const CATEGORY_ICONS = {
422
423
  ambient: "🌿", music: "🎵", other: "📦", all: "📂",
423
424
  };
424
425
 
426
+ const FileItem = ({ isSelected, label, usedTag }) =>
427
+ h(Box, null,
428
+ h(Text, { color: isSelected ? ACCENT : undefined, bold: isSelected }, label),
429
+ usedTag ? h(Text, { dimColor: true }, usedTag) : null,
430
+ );
431
+
425
432
  const GameSoundsScreen = ({ game, sounds, onSelectSound, onDone, onBack }) => {
426
433
  const eventIds = Object.keys(EVENTS);
427
434
  const [currentEvent, setCurrentEvent] = useState(0);
@@ -446,21 +453,10 @@ const GameSoundsScreen = ({ game, sounds, onSelectSound, onDone, onBack }) => {
446
453
  // Sort files: voice first, then by priority
447
454
  const sortedFiles = hasCategories ? sortFilesByPriority(game.files) : game.files;
448
455
 
449
- // Fetch durations for visible files
450
- useEffect(() => {
451
- for (const f of sortedFiles.slice(0, 50)) {
452
- if (!fileDurations[f.path]) {
453
- getWavDuration(f.path).then((dur) => {
454
- if (dur != null) setFileDurations((d) => ({ ...d, [f.path]: dur }));
455
- });
456
- }
457
- }
458
- }, [game.files]);
459
-
460
- // Filter files by category
456
+ // Filter files by category (no hard cap — SelectInput handles visible window)
461
457
  const categoryFiles = activeCategory && activeCategory !== "all"
462
- ? sortedFiles.filter((f) => f.category === activeCategory).slice(0, 50)
463
- : sortedFiles.slice(0, 50);
458
+ ? sortedFiles.filter((f) => f.category === activeCategory)
459
+ : sortedFiles;
464
460
 
465
461
  // Stop current playback helper
466
462
  const stopPlayback = useCallback(() => {
@@ -472,6 +468,35 @@ const GameSoundsScreen = ({ game, sounds, onSelectSound, onDone, onBack }) => {
472
468
  setElapsed(0);
473
469
  }, []);
474
470
 
471
+ // Pre-fetch durations: first 15 on category enter, ±15 around highlighted file
472
+ useEffect(() => {
473
+ const end = Math.min(categoryFiles.length, 15);
474
+ for (let i = 0; i < end; i++) {
475
+ const f = categoryFiles[i];
476
+ if (!fileDurations[f.path]) {
477
+ getWavDuration(f.path).then((dur) => {
478
+ if (dur != null) setFileDurations((d) => ({ ...d, [f.path]: dur }));
479
+ });
480
+ }
481
+ }
482
+ }, [activeCategory]);
483
+
484
+ useEffect(() => {
485
+ if (!highlightedFile || highlightedFile === "_skip") return;
486
+ const idx = categoryFiles.findIndex((f) => f.path === highlightedFile);
487
+ if (idx < 0) return;
488
+ const start = Math.max(0, idx - 15);
489
+ const end = Math.min(categoryFiles.length, idx + 16);
490
+ for (let i = start; i < end; i++) {
491
+ const f = categoryFiles[i];
492
+ if (!fileDurations[f.path]) {
493
+ getWavDuration(f.path).then((dur) => {
494
+ if (dur != null) setFileDurations((d) => ({ ...d, [f.path]: dur }));
495
+ });
496
+ }
497
+ }
498
+ }, [highlightedFile, categoryFiles]);
499
+
475
500
  // Auto-preview: play sound when highlighted file changes (with debounce)
476
501
  useEffect(() => {
477
502
  if (!autoPreview || !highlightedFile || highlightedFile === "_skip") {
@@ -512,9 +537,6 @@ const GameSoundsScreen = ({ game, sounds, onSelectSound, onDone, onBack }) => {
512
537
  const hasSounds = Object.values(sounds).some(Boolean);
513
538
  const tabCount = hasSounds ? eventIds.length + 1 : eventIds.length;
514
539
  setCurrentEvent((i) => (i + 1) % tabCount);
515
- setHighlightedFile(null);
516
- setActiveCategory(null);
517
- setFilter("");
518
540
  } else if (key.escape) {
519
541
  if (playing) {
520
542
  stopPlayback();
@@ -595,7 +617,7 @@ const GameSoundsScreen = ({ game, sounds, onSelectSound, onDone, onBack }) => {
595
617
  h(Text, { bold: true, color: nowPlayingFile ? "green" : ACCENT },
596
618
  `${game.name}`,
597
619
  ),
598
- h(Box, { marginTop: 0, gap: 2 },
620
+ h(Box, { marginTop: 0, gap: 2, overflowX: "hidden" },
599
621
  ...eventIds.map((eid, i) => {
600
622
  const assigned = sounds[eid];
601
623
  const isCurrent = i === currentEvent;
@@ -699,16 +721,26 @@ const GameSoundsScreen = ({ game, sounds, onSelectSound, onDone, onBack }) => {
699
721
  );
700
722
  }
701
723
 
724
+ // Build a reverse map: filePath -> event name(s) it's assigned to
725
+ const assignedToMap = {};
726
+ for (const eid of eventIds) {
727
+ if (sounds[eid]) {
728
+ (assignedToMap[sounds[eid]] ||= []).push(EVENTS[eid].name);
729
+ }
730
+ }
731
+
702
732
  // Phase 1: Browse and pick files (auto-preview plays on highlight)
703
733
  const filterLower = filter.toLowerCase();
704
734
  const allFileItems = categoryFiles.map((f) => {
705
735
  const dur = fileDurations[f.path];
706
- const durStr = dur != null ? ` (${dur > MAX_PLAY_SECONDS ? MAX_PLAY_SECONDS + "s max" : dur + "s"})` : "";
736
+ const durStr = dur != null ? ` (${dur}s${dur > MAX_PLAY_SECONDS ? `, preview ${MAX_PLAY_SECONDS}s` : ""})` : "";
707
737
  const catTag = (!activeCategory || activeCategory === "all") && f.category && f.category !== "other"
708
738
  ? `[${(CATEGORY_LABELS[f.category] || f.category).toUpperCase()}] ` : "";
709
739
  const name = f.displayName || f.name;
740
+ const usedFor = assignedToMap[f.path];
710
741
  return {
711
742
  label: `${catTag}${name}${durStr}`,
743
+ usedTag: usedFor ? ` ← ${usedFor.join(", ")}` : null,
712
744
  value: f.path,
713
745
  };
714
746
  });
@@ -748,7 +780,7 @@ const GameSoundsScreen = ({ game, sounds, onSelectSound, onDone, onBack }) => {
748
780
  : null,
749
781
  fileItems.length > 0
750
782
  ? h(Box, { marginLeft: 2 },
751
- h(SelectInput, { indicatorComponent: Indicator, itemComponent: Item,
783
+ h(SelectInput, { indicatorComponent: Indicator, itemComponent: FileItem,
752
784
  items: fileItems,
753
785
  limit: 15,
754
786
  onHighlight: (item) => {
@@ -827,8 +859,31 @@ const ExtractingScreen = ({ game, onDone, onBack }) => {
827
859
  }
828
860
  }
829
861
 
830
- // Convert extracted .fsb Vorbis files via vgmstream, or handle traditional packed audio
831
- const needsVgmstream = fsbFiles.length > 0 || (allOutputs.length === 0 && !game.unityResources?.length);
862
+ // Scan for packed audio files (Wwise/FMOD/BUN)
863
+ let packedFiles = [];
864
+ if (allOutputs.length === 0 || !game.unityResources?.length) {
865
+ setStatus(`Scanning ${game.name} for packed audio...`);
866
+ packedFiles = await findPackedAudioFiles(game.path, 30);
867
+ }
868
+
869
+ // Extract BUN files natively (SCUMM engine audio)
870
+ const bunFiles = packedFiles.filter((f) => f.name.toLowerCase().endsWith(".bun"));
871
+ const nonBunFiles = packedFiles.filter((f) => !f.name.toLowerCase().endsWith(".bun"));
872
+
873
+ for (const file of bunFiles) {
874
+ if (cancelled) return;
875
+ setStatus(`Extracting SCUMM audio: ${file.name}`);
876
+ try {
877
+ const bunOutputs = await extractBunFile(file.path, outputDir, (msg) => {
878
+ if (!cancelled) setStatus(msg);
879
+ });
880
+ allOutputs.push(...bunOutputs);
881
+ setExtracted(allOutputs.length);
882
+ } catch { /* skip */ }
883
+ }
884
+
885
+ // Convert extracted .fsb Vorbis files via vgmstream, or handle non-BUN packed audio
886
+ const needsVgmstream = fsbFiles.length > 0 || nonBunFiles.length > 0;
832
887
  if (needsVgmstream) {
833
888
  // Get vgmstream-cli (downloads if needed)
834
889
  setStatus("Getting vgmstream-cli...");
@@ -847,32 +902,23 @@ const ExtractingScreen = ({ game, onDone, onBack }) => {
847
902
  } catch { /* skip */ }
848
903
  }
849
904
 
850
- // Also scan for traditional packed audio (Wwise/FMOD)
851
- if (allOutputs.length === 0 || !game.unityResources?.length) {
852
- setStatus(`Scanning ${game.name} for packed audio...`);
853
- const packedFiles = await findPackedAudioFiles(game.path, 30);
854
-
855
- if (packedFiles.length === 0 && allOutputs.length === 0) {
856
- if (!cancelled) onDone({ files: [], error: "No extractable audio files found" });
857
- return;
858
- }
859
-
860
- setStatus(`Found ${packedFiles.length} files. Extracting...`);
861
-
862
- for (const file of packedFiles) {
863
- if (cancelled) return;
864
- setStatus(`Extracting: ${file.name}`);
865
- try {
866
- const outputs = await extractToWav(file.path, outputDir, vgmstream);
867
- allOutputs.push(...outputs);
868
- setExtracted(allOutputs.length);
869
- } catch {
870
- // Skip files that fail
871
- }
872
- }
905
+ // Convert non-BUN packed audio via vgmstream
906
+ for (const file of nonBunFiles) {
907
+ if (cancelled) return;
908
+ setStatus(`Extracting: ${file.name}`);
909
+ try {
910
+ const outputs = await extractToWav(file.path, outputDir, vgmstream);
911
+ allOutputs.push(...outputs);
912
+ setExtracted(allOutputs.length);
913
+ } catch { /* skip */ }
873
914
  }
874
915
  }
875
916
 
917
+ if (allOutputs.length === 0 && fsbFiles.length === 0 && packedFiles.length === 0) {
918
+ if (!cancelled) onDone({ files: [], error: "No extractable audio files found" });
919
+ return;
920
+ }
921
+
876
922
  if (!cancelled) {
877
923
  // Cache the results with category metadata
878
924
  const rawFiles = allOutputs.map((p) => ({ path: p, name: basename(p) }));
package/src/extractor.js CHANGED
@@ -8,7 +8,7 @@ import { pipeline } from "node:stream/promises";
8
8
  const TOOLS_DIR = join(homedir(), ".klaudio", "tools");
9
9
 
10
10
  // Packed audio formats that vgmstream-cli can convert to WAV
11
- const PACKED_EXTENSIONS = new Set([".wem", ".bnk", ".bank", ".fsb", ".pck"]);
11
+ const PACKED_EXTENSIONS = new Set([".wem", ".bnk", ".bank", ".fsb", ".pck", ".bun"]);
12
12
 
13
13
  /**
14
14
  * Check if a file is a packed audio format we can extract.
@@ -140,7 +140,7 @@ export async function findPackedAudioFiles(gamePath, maxFiles = 50) {
140
140
  const ext = extname(entry.name).toLowerCase();
141
141
  // Formats vgmstream-cli can convert directly
142
142
  // (.bnk needs bnkextr preprocessing — skip for now)
143
- if (ext === ".wem" || ext === ".fsb" || ext === ".bank") {
143
+ if (ext === ".wem" || ext === ".fsb" || ext === ".bank" || ext === ".bun") {
144
144
  results.push({ path: fullPath, name: entry.name, dir });
145
145
  }
146
146
  }
package/src/scanner.js CHANGED
@@ -3,7 +3,7 @@ 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
- const PACKED_EXTENSIONS = new Set([".wem", ".bnk", ".bank", ".fsb", ".pck"]);
6
+ const PACKED_EXTENSIONS = new Set([".wem", ".bnk", ".bank", ".fsb", ".pck", ".bun"]);
7
7
  const UNITY_RESOURCE_EXTENSIONS = new Set([".resource", ".ress"]);
8
8
  const MAX_DEPTH = 5;
9
9
  const MAX_FILES = 200;
package/src/scumm.js ADDED
@@ -0,0 +1,553 @@
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
521
+ const wav = createWav(pcmData, sampleRate, channels, bitsPerSample);
522
+ const safeName = entry.filename.replace(/[^a-zA-Z0-9._-]/g, "_");
523
+ const outName = safeName.replace(/\.[^.]+$/, "") + ".wav";
524
+ const outPath = join(outputDir, outName);
525
+ await writeFile(outPath, wav);
526
+ outputs.push(outPath);
527
+ } catch {
528
+ // Skip entries that fail to decompress
529
+ }
530
+ }
531
+ } finally {
532
+ await fh.close();
533
+ }
534
+
535
+ return outputs;
536
+ }
537
+
538
+ /**
539
+ * Quick magic-byte check for BUN files.
540
+ * Returns true if the file starts with 'LB23'.
541
+ */
542
+ export async function isBunFile(filePath) {
543
+ try {
544
+ const fh = await open(filePath, "r");
545
+ const buf = Buffer.alloc(4);
546
+ await fh.read(buf, 0, 4, 0);
547
+ await fh.close();
548
+ const tag = buf.toString("ascii");
549
+ return tag === "LB23";
550
+ } catch {
551
+ return false;
552
+ }
553
+ }