@marmooo/soundfont-split 0.0.2 → 0.0.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/esm/cli.js CHANGED
@@ -3,7 +3,7 @@ import "./_dnt.polyfills.js";
3
3
  import * as dntShim from "./_dnt.shims.js";
4
4
  import { parseArgs } from "./deps/jsr.io/@std/cli/1.0.32/mod.js";
5
5
  import { splitSoundFont } from "./src/mod.js";
6
- const VERSION = "0.0.2";
6
+ const VERSION = "0.0.3";
7
7
  const args = parseArgs(dntShim.Deno.args, {
8
8
  string: ["quality", "concurrency"],
9
9
  boolean: ["version", "help", "recompress"],
package/esm/src/split.js CHANGED
@@ -1,5 +1,5 @@
1
1
  import * as dntShim from "../_dnt.shims.js";
2
- import { Bag, GeneratorList, Instrument, ModulatorList, parse, PresetHeader, RangeValue, SampleHeader, SoundFont, write, } from "@marmooo/soundfont";
2
+ import { AudioData, Bag, GeneratorList, Instrument, ModulatorList, parse, PresetHeader, RangeValue, SampleHeader, SoundFont, write, } from "@marmooo/soundfont";
3
3
  import { createDefaultEncoder } from "@marmooo/sf3-codec/encoder";
4
4
  import { createDefaultDecoder } from "@marmooo/sf3-codec/decoder";
5
5
  function cloneGenerator(g) {
@@ -22,6 +22,68 @@ function defaultConcurrency() {
22
22
  const nav = dntShim.dntGlobalThis.navigator;
23
23
  return nav?.hardwareConcurrency ?? 4;
24
24
  }
25
+ // Collect sample IDs referenced by a preset (including stereo-linked
26
+ // partners). Uses only headers / zones / generators — does NOT touch the
27
+ // lazy `samples` array, so it never materializes AudioData.
28
+ function collectSampleIds(soundFont, presetHeaderIndex) {
29
+ const headers = soundFont.presetHeaders;
30
+ if (presetHeaderIndex < 0 ||
31
+ presetHeaderIndex >= headers.length ||
32
+ headers[presetHeaderIndex].isEnd) {
33
+ throw new Error(`invalid presetHeaderIndex: ${presetHeaderIndex}`);
34
+ }
35
+ const ph = headers[presetHeaderIndex];
36
+ const nextPh = headers[presetHeaderIndex + 1];
37
+ const bagFrom = ph.presetBagIndex;
38
+ const bagTo = nextPh
39
+ ? nextPh.presetBagIndex
40
+ : soundFont.presetZone.length - 1;
41
+ const instrumentIdSet = new Set();
42
+ for (let zi = bagFrom; zi < bagTo; zi++) {
43
+ const bag = soundFont.presetZone[zi];
44
+ const nextBag = soundFont.presetZone[zi + 1];
45
+ if (!nextBag)
46
+ break;
47
+ const gens = soundFont.presetGenerators.slice(bag.generatorIndex, nextBag.generatorIndex);
48
+ for (const g of gens) {
49
+ if (g.type === "instrument" && typeof g.value === "number") {
50
+ instrumentIdSet.add(g.value);
51
+ }
52
+ }
53
+ }
54
+ const sampleIdSet = new Set();
55
+ for (const oldInstId of instrumentIdSet) {
56
+ const inst = soundFont.instruments[oldInstId];
57
+ const nextInst = soundFont.instruments[oldInstId + 1];
58
+ const iBagFrom = inst.instrumentBagIndex;
59
+ const iBagTo = nextInst
60
+ ? nextInst.instrumentBagIndex
61
+ : soundFont.instrumentZone.length - 1;
62
+ for (let zi = iBagFrom; zi < iBagTo; zi++) {
63
+ const bag = soundFont.instrumentZone[zi];
64
+ const nextBag = soundFont.instrumentZone[zi + 1];
65
+ if (!nextBag)
66
+ break;
67
+ const gens = soundFont.instrumentGenerators.slice(bag.generatorIndex, nextBag.generatorIndex);
68
+ for (const g of gens) {
69
+ if (g.type === "sampleID" && typeof g.value === "number") {
70
+ const sh = soundFont.sampleHeaders[g.value];
71
+ if (!sh || sh.isEnd)
72
+ continue;
73
+ sampleIdSet.add(g.value);
74
+ const linked = soundFont.sampleHeaders[sh.sampleLink];
75
+ if (sh.sampleLink !== 0 && linked && !linked.isEnd) {
76
+ sampleIdSet.add(sh.sampleLink);
77
+ }
78
+ }
79
+ }
80
+ }
81
+ }
82
+ return [...sampleIdSet].filter((id) => {
83
+ const sh = soundFont.sampleHeaders[id];
84
+ return sh !== undefined && !sh.isEnd;
85
+ });
86
+ }
25
87
  // Extract a single preset (by index into soundFont.presetHeaders) into a
26
88
  // minimal SoundFont that contains only the instruments and samples that
27
89
  // preset depends on. Indices are remapped so the result is a valid
@@ -122,9 +184,16 @@ export function extractPreset(soundFont, presetHeaderIndex) {
122
184
  .map(cloneModulator);
123
185
  for (const g of gens) {
124
186
  if (g.type === "sampleID" && typeof g.value === "number") {
125
- sampleIdSet.add(g.value);
126
187
  const sh = soundFont.sampleHeaders[g.value];
127
- if (sh && sh.sampleLink !== 0 && !sh.isEnd) {
188
+ // Skip terminal / out-of-range sample IDs (can appear after
189
+ // empty-name terminal records are stripped by the parser).
190
+ if (!sh || sh.isEnd)
191
+ continue;
192
+ sampleIdSet.add(g.value);
193
+ const linked = soundFont.sampleHeaders[sh.sampleLink];
194
+ if (sh.sampleLink !== 0 &&
195
+ linked &&
196
+ !linked.isEnd) {
128
197
  sampleIdSet.add(sh.sampleLink);
129
198
  }
130
199
  }
@@ -145,14 +214,19 @@ export function extractPreset(soundFont, presetHeaderIndex) {
145
214
  !instrumentGenerators[instrumentGenerators.length - 1].isEnd) {
146
215
  instrumentGenerators.push(GeneratorList.end());
147
216
  }
148
- const sampleIds = [...sampleIdSet].sort((a, b) => a - b);
217
+ const sampleIds = [...sampleIdSet]
218
+ .filter((id) => {
219
+ const sh = soundFont.sampleHeaders[id];
220
+ return sh !== undefined && !sh.isEnd;
221
+ })
222
+ .sort((a, b) => a - b);
149
223
  const sampleMap = new Map();
150
224
  sampleIds.forEach((id, i) => sampleMap.set(id, i));
151
225
  for (const g of instrumentGenerators) {
152
226
  if (g.type === "sampleID" && typeof g.value === "number") {
153
227
  const mapped = sampleMap.get(g.value);
154
228
  if (mapped === undefined) {
155
- throw new Error(`sample ${g.value} not collected`);
229
+ throw new Error(`sample ${g.value} not collected (missing or terminal header)`);
156
230
  }
157
231
  g.value = mapped;
158
232
  }
@@ -162,11 +236,17 @@ export function extractPreset(soundFont, presetHeaderIndex) {
162
236
  for (const oldId of sampleIds) {
163
237
  const sh = soundFont.sampleHeaders[oldId];
164
238
  const sample = soundFont.samples[oldId];
239
+ if (!sh || !sample || sh.isEnd)
240
+ continue;
165
241
  const newLink = sh.sampleLink !== 0 && sampleMap.has(sh.sampleLink)
166
242
  ? sampleMap.get(sh.sampleLink)
167
243
  : 0;
168
- sampleHeaders.push(new SampleHeader(sh.sampleName, sh.start, sh.end, sh.loopStart, sh.loopEnd, sh.sampleRate, sh.originalPitch, sh.pitchCorrection, newLink, sh.sampleType));
169
- samples.push(sample);
244
+ const newHeader = new SampleHeader(sh.sampleName, sh.start, sh.end, sh.loopStart, sh.loopEnd, sh.sampleRate, sh.originalPitch, sh.pitchCorrection, newLink, sh.sampleType);
245
+ sampleHeaders.push(newHeader);
246
+ // Copy sample bytes into a standalone buffer so the extracted SoundFont
247
+ // does not keep a view into the source file. After write() we can drop
248
+ // these copies without waiting for the source soundFont to release.
249
+ samples.push(new AudioData(sample.type, newHeader, sample.data.slice()));
170
250
  }
171
251
  const newPh = new PresetHeader(ph.presetName, ph.preset, ph.bank, 0, ph.library, ph.genre, ph.morphology);
172
252
  return new SoundFont({
@@ -191,17 +271,23 @@ export function extractPreset(soundFont, presetHeaderIndex) {
191
271
  // pool sized by `options.concurrency` (so the budget is global, not
192
272
  // per-preset).
193
273
  export async function splitSoundFont(input, outDir, options = {}) {
194
- const bytes = typeof input === "string" ? dntShim.Deno.readFileSync(input) : input;
274
+ // Keep a mutable reference so we can drop the original file buffer after
275
+ // all presets have been extracted (samples are lazy views into this buffer).
276
+ let bytes = typeof input === "string"
277
+ ? dntShim.Deno.readFileSync(input)
278
+ : input;
195
279
  const soundFont = parse(bytes);
196
280
  const toSf3 = options.toSf3 !== false;
197
281
  const concurrency = Math.max(1, options.concurrency ?? defaultConcurrency());
198
282
  await dntShim.Deno.mkdir(outDir, { recursive: true });
199
- // Shared encoder pool for the whole job. Callers that supply their own
200
- // encode via sf2ToSf3 are not used here - we own the pool so we can dispose
201
- // it and let the process exit (workers otherwise keep the event loop alive).
283
+ // Encoder/decoder pools. WASM heaps inside workers tend to grow and not
284
+ // return memory to the OS, so we periodically dispose + recreate them
285
+ // (see recycleCodecs below).
202
286
  let encode;
203
287
  let decode;
204
- if (toSf3) {
288
+ const makeCodecs = () => {
289
+ if (!toSf3)
290
+ return;
205
291
  encode = createDefaultEncoder({
206
292
  quality: options.quality,
207
293
  poolSize: concurrency,
@@ -209,57 +295,115 @@ export async function splitSoundFont(input, outDir, options = {}) {
209
295
  if (options.recompress) {
210
296
  decode = createDefaultDecoder({ poolSize: concurrency });
211
297
  }
212
- }
298
+ };
299
+ const disposeCodecs = () => {
300
+ encode?.dispose?.();
301
+ decode?.dispose?.();
302
+ encode = undefined;
303
+ decode = undefined;
304
+ };
305
+ makeCodecs();
306
+ // Recycle WASM worker pools every N finished presets to bound native/WASM
307
+ // heap growth. 16 is a balance between recycle cost and memory creep.
308
+ const RECYCLE_EVERY = 16;
309
+ let finishedSinceRecycle = 0;
213
310
  try {
214
311
  const headers = soundFont.presetHeaders;
215
312
  const jobs = [];
216
313
  for (let i = 0; i < headers.length; i++) {
217
- if (!headers[i].isEnd)
218
- jobs.push({ index: i, ph: headers[i] });
314
+ if (!headers[i].isEnd) {
315
+ jobs.push({
316
+ index: i,
317
+ ph: headers[i],
318
+ // Structure-only scan: does not materialize AudioData.
319
+ sampleIds: collectSampleIds(soundFont, i),
320
+ });
321
+ }
322
+ }
323
+ // How many remaining presets still need each sample. When the count
324
+ // hits 0 after a preset is written, we drop that slot from the source
325
+ // soundFont so the lazy AudioData (and its view into the file buffer)
326
+ // can be collected.
327
+ const sampleRefCount = new Map();
328
+ for (const job of jobs) {
329
+ for (const id of job.sampleIds) {
330
+ sampleRefCount.set(id, (sampleRefCount.get(id) ?? 0) + 1);
331
+ }
219
332
  }
220
- // Cap how many presets we assemble at once (encode pool is the real
221
- // limiter; this just bounds peak memory from parallel write()s).
222
- const presetParallel = concurrency;
333
+ // Only one preset is assembled/written at a time. Sample encodes inside
334
+ // write() still run up to `concurrency` in parallel. Parallel presets
335
+ // multiplied peak memory (several full encoded SF3 buffers in flight)
336
+ // and made WASM heap growth much worse; sequential presets keep the
337
+ // high-water mark close to "source file + one preset's temps".
223
338
  const results = new Array(jobs.length);
224
- let nextJob = 0;
225
- const runWorker = async () => {
226
- while (true) {
227
- const jobIndex = nextJob++;
228
- if (jobIndex >= jobs.length)
229
- return;
230
- const { index, ph } = jobs[jobIndex];
231
- const extracted = extractPreset(soundFont, index);
232
- const bankDir = `${outDir}/${String(ph.bank).padStart(3, "0")}`;
233
- await dntShim.Deno.mkdir(bankDir, { recursive: true });
234
- const ext = toSf3 ? "sf3" : "sf2";
235
- const fileName = `${String(ph.preset).padStart(3, "0")}.${ext}`;
236
- const path = `${bankDir}/${fileName}`;
237
- const outBytes = toSf3
238
- ? await write(extracted, {
239
- // write() may schedule many encodes; the pool caps actual work.
240
- encode: encode,
241
- decode,
242
- concurrency,
243
- })
244
- : await write(extracted);
245
- dntShim.Deno.writeFileSync(path, outBytes);
246
- const presetName = ph.presetName.replace(/\0+$/, "").trim();
247
- results[jobIndex] = {
248
- bank: ph.bank,
249
- preset: ph.preset,
250
- presetName,
251
- path,
252
- byteLength: outBytes.byteLength,
253
- };
254
- console.log(`wrote ${path} (${outBytes.byteLength} bytes) - ${presetName}`);
339
+ for (let jobIndex = 0; jobIndex < jobs.length; jobIndex++) {
340
+ const { index, ph, sampleIds } = jobs[jobIndex];
341
+ const extracted = extractPreset(soundFont, index);
342
+ const bankDir = `${outDir}/${String(ph.bank).padStart(3, "0")}`;
343
+ await dntShim.Deno.mkdir(bankDir, { recursive: true });
344
+ const ext = toSf3 ? "sf3" : "sf2";
345
+ const fileName = `${String(ph.preset).padStart(3, "0")}.${ext}`;
346
+ const path = `${bankDir}/${fileName}`;
347
+ const outBytes = toSf3
348
+ ? await write(extracted, {
349
+ encode: encode,
350
+ decode,
351
+ concurrency,
352
+ })
353
+ : await write(extracted);
354
+ dntShim.Deno.writeFileSync(path, outBytes);
355
+ const presetName = ph.presetName.replace(/\0+$/, "").trim();
356
+ results[jobIndex] = {
357
+ bank: ph.bank,
358
+ preset: ph.preset,
359
+ presetName,
360
+ path,
361
+ byteLength: outBytes.byteLength,
362
+ };
363
+ console.log(`wrote ${path} (${outBytes.byteLength} bytes) - ${presetName}`);
364
+ // Drop extracted copies (independent ArrayBuffers from extractPreset).
365
+ for (const s of extracted.samples) {
366
+ // Break any remaining internal refs early.
367
+ s.data = new Uint8Array(0);
368
+ }
369
+ extracted.samples.length = 0;
370
+ extracted.sampleHeaders.length = 0;
371
+ // Release source samples that no remaining preset needs.
372
+ for (const id of sampleIds) {
373
+ const left = (sampleRefCount.get(id) ?? 1) - 1;
374
+ if (left <= 0) {
375
+ sampleRefCount.delete(id);
376
+ const slot = soundFont.samples[id];
377
+ if (slot) {
378
+ // Detach the view into the source file buffer before dropping
379
+ // the AudioData object.
380
+ slot.data = new Uint8Array(0);
381
+ }
382
+ delete soundFont.samples[id];
383
+ }
384
+ else {
385
+ sampleRefCount.set(id, left);
386
+ }
387
+ }
388
+ finishedSinceRecycle++;
389
+ if (toSf3 && finishedSinceRecycle >= RECYCLE_EVERY) {
390
+ disposeCodecs();
391
+ makeCodecs();
392
+ finishedSinceRecycle = 0;
255
393
  }
256
- };
257
- const workers = Array.from({ length: Math.min(presetParallel, jobs.length) }, () => runWorker());
258
- await Promise.all(workers);
394
+ }
395
+ // Safety net: clear anything left and drop the original file buffer.
396
+ for (let i = 0; i < soundFont.samples.length; i++) {
397
+ const s = soundFont.samples[i];
398
+ if (s)
399
+ s.data = new Uint8Array(0);
400
+ }
401
+ soundFont.samples.length = 0;
402
+ soundFont.sampleHeaders.length = 0;
403
+ bytes = null;
259
404
  return results;
260
405
  }
261
406
  finally {
262
- encode?.dispose?.();
263
- decode?.dispose?.();
407
+ disposeCodecs();
264
408
  }
265
409
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@marmooo/soundfont-split",
3
- "version": "0.0.2",
3
+ "version": "0.0.3",
4
4
  "description": "Split a SoundFont (SF2 / SF3) into per-preset SF3 files.",
5
5
  "repository": {
6
6
  "type": "git",
@@ -59,6 +59,68 @@ function defaultConcurrency() {
59
59
  const nav = dntShim.dntGlobalThis.navigator;
60
60
  return nav?.hardwareConcurrency ?? 4;
61
61
  }
62
+ // Collect sample IDs referenced by a preset (including stereo-linked
63
+ // partners). Uses only headers / zones / generators — does NOT touch the
64
+ // lazy `samples` array, so it never materializes AudioData.
65
+ function collectSampleIds(soundFont, presetHeaderIndex) {
66
+ const headers = soundFont.presetHeaders;
67
+ if (presetHeaderIndex < 0 ||
68
+ presetHeaderIndex >= headers.length ||
69
+ headers[presetHeaderIndex].isEnd) {
70
+ throw new Error(`invalid presetHeaderIndex: ${presetHeaderIndex}`);
71
+ }
72
+ const ph = headers[presetHeaderIndex];
73
+ const nextPh = headers[presetHeaderIndex + 1];
74
+ const bagFrom = ph.presetBagIndex;
75
+ const bagTo = nextPh
76
+ ? nextPh.presetBagIndex
77
+ : soundFont.presetZone.length - 1;
78
+ const instrumentIdSet = new Set();
79
+ for (let zi = bagFrom; zi < bagTo; zi++) {
80
+ const bag = soundFont.presetZone[zi];
81
+ const nextBag = soundFont.presetZone[zi + 1];
82
+ if (!nextBag)
83
+ break;
84
+ const gens = soundFont.presetGenerators.slice(bag.generatorIndex, nextBag.generatorIndex);
85
+ for (const g of gens) {
86
+ if (g.type === "instrument" && typeof g.value === "number") {
87
+ instrumentIdSet.add(g.value);
88
+ }
89
+ }
90
+ }
91
+ const sampleIdSet = new Set();
92
+ for (const oldInstId of instrumentIdSet) {
93
+ const inst = soundFont.instruments[oldInstId];
94
+ const nextInst = soundFont.instruments[oldInstId + 1];
95
+ const iBagFrom = inst.instrumentBagIndex;
96
+ const iBagTo = nextInst
97
+ ? nextInst.instrumentBagIndex
98
+ : soundFont.instrumentZone.length - 1;
99
+ for (let zi = iBagFrom; zi < iBagTo; zi++) {
100
+ const bag = soundFont.instrumentZone[zi];
101
+ const nextBag = soundFont.instrumentZone[zi + 1];
102
+ if (!nextBag)
103
+ break;
104
+ const gens = soundFont.instrumentGenerators.slice(bag.generatorIndex, nextBag.generatorIndex);
105
+ for (const g of gens) {
106
+ if (g.type === "sampleID" && typeof g.value === "number") {
107
+ const sh = soundFont.sampleHeaders[g.value];
108
+ if (!sh || sh.isEnd)
109
+ continue;
110
+ sampleIdSet.add(g.value);
111
+ const linked = soundFont.sampleHeaders[sh.sampleLink];
112
+ if (sh.sampleLink !== 0 && linked && !linked.isEnd) {
113
+ sampleIdSet.add(sh.sampleLink);
114
+ }
115
+ }
116
+ }
117
+ }
118
+ }
119
+ return [...sampleIdSet].filter((id) => {
120
+ const sh = soundFont.sampleHeaders[id];
121
+ return sh !== undefined && !sh.isEnd;
122
+ });
123
+ }
62
124
  // Extract a single preset (by index into soundFont.presetHeaders) into a
63
125
  // minimal SoundFont that contains only the instruments and samples that
64
126
  // preset depends on. Indices are remapped so the result is a valid
@@ -159,9 +221,16 @@ function extractPreset(soundFont, presetHeaderIndex) {
159
221
  .map(cloneModulator);
160
222
  for (const g of gens) {
161
223
  if (g.type === "sampleID" && typeof g.value === "number") {
162
- sampleIdSet.add(g.value);
163
224
  const sh = soundFont.sampleHeaders[g.value];
164
- if (sh && sh.sampleLink !== 0 && !sh.isEnd) {
225
+ // Skip terminal / out-of-range sample IDs (can appear after
226
+ // empty-name terminal records are stripped by the parser).
227
+ if (!sh || sh.isEnd)
228
+ continue;
229
+ sampleIdSet.add(g.value);
230
+ const linked = soundFont.sampleHeaders[sh.sampleLink];
231
+ if (sh.sampleLink !== 0 &&
232
+ linked &&
233
+ !linked.isEnd) {
165
234
  sampleIdSet.add(sh.sampleLink);
166
235
  }
167
236
  }
@@ -182,14 +251,19 @@ function extractPreset(soundFont, presetHeaderIndex) {
182
251
  !instrumentGenerators[instrumentGenerators.length - 1].isEnd) {
183
252
  instrumentGenerators.push(soundfont_1.GeneratorList.end());
184
253
  }
185
- const sampleIds = [...sampleIdSet].sort((a, b) => a - b);
254
+ const sampleIds = [...sampleIdSet]
255
+ .filter((id) => {
256
+ const sh = soundFont.sampleHeaders[id];
257
+ return sh !== undefined && !sh.isEnd;
258
+ })
259
+ .sort((a, b) => a - b);
186
260
  const sampleMap = new Map();
187
261
  sampleIds.forEach((id, i) => sampleMap.set(id, i));
188
262
  for (const g of instrumentGenerators) {
189
263
  if (g.type === "sampleID" && typeof g.value === "number") {
190
264
  const mapped = sampleMap.get(g.value);
191
265
  if (mapped === undefined) {
192
- throw new Error(`sample ${g.value} not collected`);
266
+ throw new Error(`sample ${g.value} not collected (missing or terminal header)`);
193
267
  }
194
268
  g.value = mapped;
195
269
  }
@@ -199,11 +273,17 @@ function extractPreset(soundFont, presetHeaderIndex) {
199
273
  for (const oldId of sampleIds) {
200
274
  const sh = soundFont.sampleHeaders[oldId];
201
275
  const sample = soundFont.samples[oldId];
276
+ if (!sh || !sample || sh.isEnd)
277
+ continue;
202
278
  const newLink = sh.sampleLink !== 0 && sampleMap.has(sh.sampleLink)
203
279
  ? sampleMap.get(sh.sampleLink)
204
280
  : 0;
205
- sampleHeaders.push(new soundfont_1.SampleHeader(sh.sampleName, sh.start, sh.end, sh.loopStart, sh.loopEnd, sh.sampleRate, sh.originalPitch, sh.pitchCorrection, newLink, sh.sampleType));
206
- samples.push(sample);
281
+ const newHeader = new soundfont_1.SampleHeader(sh.sampleName, sh.start, sh.end, sh.loopStart, sh.loopEnd, sh.sampleRate, sh.originalPitch, sh.pitchCorrection, newLink, sh.sampleType);
282
+ sampleHeaders.push(newHeader);
283
+ // Copy sample bytes into a standalone buffer so the extracted SoundFont
284
+ // does not keep a view into the source file. After write() we can drop
285
+ // these copies without waiting for the source soundFont to release.
286
+ samples.push(new soundfont_1.AudioData(sample.type, newHeader, sample.data.slice()));
207
287
  }
208
288
  const newPh = new soundfont_1.PresetHeader(ph.presetName, ph.preset, ph.bank, 0, ph.library, ph.genre, ph.morphology);
209
289
  return new soundfont_1.SoundFont({
@@ -228,17 +308,23 @@ function extractPreset(soundFont, presetHeaderIndex) {
228
308
  // pool sized by `options.concurrency` (so the budget is global, not
229
309
  // per-preset).
230
310
  async function splitSoundFont(input, outDir, options = {}) {
231
- const bytes = typeof input === "string" ? dntShim.Deno.readFileSync(input) : input;
311
+ // Keep a mutable reference so we can drop the original file buffer after
312
+ // all presets have been extracted (samples are lazy views into this buffer).
313
+ let bytes = typeof input === "string"
314
+ ? dntShim.Deno.readFileSync(input)
315
+ : input;
232
316
  const soundFont = (0, soundfont_1.parse)(bytes);
233
317
  const toSf3 = options.toSf3 !== false;
234
318
  const concurrency = Math.max(1, options.concurrency ?? defaultConcurrency());
235
319
  await dntShim.Deno.mkdir(outDir, { recursive: true });
236
- // Shared encoder pool for the whole job. Callers that supply their own
237
- // encode via sf2ToSf3 are not used here - we own the pool so we can dispose
238
- // it and let the process exit (workers otherwise keep the event loop alive).
320
+ // Encoder/decoder pools. WASM heaps inside workers tend to grow and not
321
+ // return memory to the OS, so we periodically dispose + recreate them
322
+ // (see recycleCodecs below).
239
323
  let encode;
240
324
  let decode;
241
- if (toSf3) {
325
+ const makeCodecs = () => {
326
+ if (!toSf3)
327
+ return;
242
328
  encode = (0, encoder_1.createDefaultEncoder)({
243
329
  quality: options.quality,
244
330
  poolSize: concurrency,
@@ -246,57 +332,115 @@ async function splitSoundFont(input, outDir, options = {}) {
246
332
  if (options.recompress) {
247
333
  decode = (0, decoder_1.createDefaultDecoder)({ poolSize: concurrency });
248
334
  }
249
- }
335
+ };
336
+ const disposeCodecs = () => {
337
+ encode?.dispose?.();
338
+ decode?.dispose?.();
339
+ encode = undefined;
340
+ decode = undefined;
341
+ };
342
+ makeCodecs();
343
+ // Recycle WASM worker pools every N finished presets to bound native/WASM
344
+ // heap growth. 16 is a balance between recycle cost and memory creep.
345
+ const RECYCLE_EVERY = 16;
346
+ let finishedSinceRecycle = 0;
250
347
  try {
251
348
  const headers = soundFont.presetHeaders;
252
349
  const jobs = [];
253
350
  for (let i = 0; i < headers.length; i++) {
254
- if (!headers[i].isEnd)
255
- jobs.push({ index: i, ph: headers[i] });
351
+ if (!headers[i].isEnd) {
352
+ jobs.push({
353
+ index: i,
354
+ ph: headers[i],
355
+ // Structure-only scan: does not materialize AudioData.
356
+ sampleIds: collectSampleIds(soundFont, i),
357
+ });
358
+ }
256
359
  }
257
- // Cap how many presets we assemble at once (encode pool is the real
258
- // limiter; this just bounds peak memory from parallel write()s).
259
- const presetParallel = concurrency;
360
+ // How many remaining presets still need each sample. When the count
361
+ // hits 0 after a preset is written, we drop that slot from the source
362
+ // soundFont so the lazy AudioData (and its view into the file buffer)
363
+ // can be collected.
364
+ const sampleRefCount = new Map();
365
+ for (const job of jobs) {
366
+ for (const id of job.sampleIds) {
367
+ sampleRefCount.set(id, (sampleRefCount.get(id) ?? 0) + 1);
368
+ }
369
+ }
370
+ // Only one preset is assembled/written at a time. Sample encodes inside
371
+ // write() still run up to `concurrency` in parallel. Parallel presets
372
+ // multiplied peak memory (several full encoded SF3 buffers in flight)
373
+ // and made WASM heap growth much worse; sequential presets keep the
374
+ // high-water mark close to "source file + one preset's temps".
260
375
  const results = new Array(jobs.length);
261
- let nextJob = 0;
262
- const runWorker = async () => {
263
- while (true) {
264
- const jobIndex = nextJob++;
265
- if (jobIndex >= jobs.length)
266
- return;
267
- const { index, ph } = jobs[jobIndex];
268
- const extracted = extractPreset(soundFont, index);
269
- const bankDir = `${outDir}/${String(ph.bank).padStart(3, "0")}`;
270
- await dntShim.Deno.mkdir(bankDir, { recursive: true });
271
- const ext = toSf3 ? "sf3" : "sf2";
272
- const fileName = `${String(ph.preset).padStart(3, "0")}.${ext}`;
273
- const path = `${bankDir}/${fileName}`;
274
- const outBytes = toSf3
275
- ? await (0, soundfont_1.write)(extracted, {
276
- // write() may schedule many encodes; the pool caps actual work.
277
- encode: encode,
278
- decode,
279
- concurrency,
280
- })
281
- : await (0, soundfont_1.write)(extracted);
282
- dntShim.Deno.writeFileSync(path, outBytes);
283
- const presetName = ph.presetName.replace(/\0+$/, "").trim();
284
- results[jobIndex] = {
285
- bank: ph.bank,
286
- preset: ph.preset,
287
- presetName,
288
- path,
289
- byteLength: outBytes.byteLength,
290
- };
291
- console.log(`wrote ${path} (${outBytes.byteLength} bytes) - ${presetName}`);
376
+ for (let jobIndex = 0; jobIndex < jobs.length; jobIndex++) {
377
+ const { index, ph, sampleIds } = jobs[jobIndex];
378
+ const extracted = extractPreset(soundFont, index);
379
+ const bankDir = `${outDir}/${String(ph.bank).padStart(3, "0")}`;
380
+ await dntShim.Deno.mkdir(bankDir, { recursive: true });
381
+ const ext = toSf3 ? "sf3" : "sf2";
382
+ const fileName = `${String(ph.preset).padStart(3, "0")}.${ext}`;
383
+ const path = `${bankDir}/${fileName}`;
384
+ const outBytes = toSf3
385
+ ? await (0, soundfont_1.write)(extracted, {
386
+ encode: encode,
387
+ decode,
388
+ concurrency,
389
+ })
390
+ : await (0, soundfont_1.write)(extracted);
391
+ dntShim.Deno.writeFileSync(path, outBytes);
392
+ const presetName = ph.presetName.replace(/\0+$/, "").trim();
393
+ results[jobIndex] = {
394
+ bank: ph.bank,
395
+ preset: ph.preset,
396
+ presetName,
397
+ path,
398
+ byteLength: outBytes.byteLength,
399
+ };
400
+ console.log(`wrote ${path} (${outBytes.byteLength} bytes) - ${presetName}`);
401
+ // Drop extracted copies (independent ArrayBuffers from extractPreset).
402
+ for (const s of extracted.samples) {
403
+ // Break any remaining internal refs early.
404
+ s.data = new Uint8Array(0);
292
405
  }
293
- };
294
- const workers = Array.from({ length: Math.min(presetParallel, jobs.length) }, () => runWorker());
295
- await Promise.all(workers);
406
+ extracted.samples.length = 0;
407
+ extracted.sampleHeaders.length = 0;
408
+ // Release source samples that no remaining preset needs.
409
+ for (const id of sampleIds) {
410
+ const left = (sampleRefCount.get(id) ?? 1) - 1;
411
+ if (left <= 0) {
412
+ sampleRefCount.delete(id);
413
+ const slot = soundFont.samples[id];
414
+ if (slot) {
415
+ // Detach the view into the source file buffer before dropping
416
+ // the AudioData object.
417
+ slot.data = new Uint8Array(0);
418
+ }
419
+ delete soundFont.samples[id];
420
+ }
421
+ else {
422
+ sampleRefCount.set(id, left);
423
+ }
424
+ }
425
+ finishedSinceRecycle++;
426
+ if (toSf3 && finishedSinceRecycle >= RECYCLE_EVERY) {
427
+ disposeCodecs();
428
+ makeCodecs();
429
+ finishedSinceRecycle = 0;
430
+ }
431
+ }
432
+ // Safety net: clear anything left and drop the original file buffer.
433
+ for (let i = 0; i < soundFont.samples.length; i++) {
434
+ const s = soundFont.samples[i];
435
+ if (s)
436
+ s.data = new Uint8Array(0);
437
+ }
438
+ soundFont.samples.length = 0;
439
+ soundFont.sampleHeaders.length = 0;
440
+ bytes = null;
296
441
  return results;
297
442
  }
298
443
  finally {
299
- encode?.dispose?.();
300
- decode?.dispose?.();
444
+ disposeCodecs();
301
445
  }
302
446
  }