@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 +1 -1
- package/esm/src/split.js +198 -54
- package/package.json +1 -1
- package/script/src/split.js +197 -53
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.
|
|
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
|
-
|
|
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]
|
|
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
|
-
|
|
169
|
-
|
|
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
|
-
|
|
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
|
-
//
|
|
200
|
-
//
|
|
201
|
-
//
|
|
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
|
-
|
|
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({
|
|
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
|
-
//
|
|
221
|
-
//
|
|
222
|
-
|
|
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
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
await
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
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
|
-
|
|
258
|
-
|
|
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
|
-
|
|
263
|
-
decode?.dispose?.();
|
|
407
|
+
disposeCodecs();
|
|
264
408
|
}
|
|
265
409
|
}
|
package/package.json
CHANGED
package/script/src/split.js
CHANGED
|
@@ -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
|
-
|
|
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]
|
|
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
|
-
|
|
206
|
-
|
|
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
|
-
|
|
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
|
-
//
|
|
237
|
-
//
|
|
238
|
-
//
|
|
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
|
-
|
|
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({
|
|
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
|
-
//
|
|
258
|
-
//
|
|
259
|
-
|
|
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
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
await
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
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
|
-
|
|
295
|
-
|
|
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
|
-
|
|
300
|
-
decode?.dispose?.();
|
|
444
|
+
disposeCodecs();
|
|
301
445
|
}
|
|
302
446
|
}
|