sf2-json 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Maxime Larrivée-Roy
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,71 @@
1
+ # sf2-json
2
+
3
+ `sf2-json` is a utility designed to parse and convert SoundFont (SF2) files into JSON format. It serves as both a library for Node.js applications and a command-line interface (CLI) tool for automated workflows.
4
+
5
+ ## Installation
6
+
7
+ To integrate the package into your Node.js project:
8
+
9
+ ```bash
10
+ npm install sf2-json
11
+
12
+ ```
13
+
14
+ ## Usage
15
+
16
+ ### Node.js Library
17
+
18
+ The package exposes the `sf2tojson` function for programmatic use.
19
+
20
+ ```javascript
21
+ import { sf2tojson } from 'sf2-json';
22
+
23
+ async function processSoundFont() {
24
+ try {
25
+ const inputPath = 'path/to/instrument.sf2';
26
+ const outputDir = 'path/to/output/directory';
27
+
28
+ await sf2tojson(inputPath, outputDir);
29
+ console.log('Conversion successfully completed.');
30
+ } catch (error) {
31
+ console.error('An error occurred during conversion:', error);
32
+ }
33
+ }
34
+
35
+ processSoundFont();
36
+
37
+ ```
38
+
39
+ ### Command-Line Interface (CLI)
40
+
41
+ When installed globally, the package provides the `sf2tojson` command to facilitate terminal-based conversions.
42
+
43
+ ```bash
44
+ npm install -g sf2-json
45
+ sf2tojson <input_file.sf2> <output_directory>
46
+
47
+ ```
48
+
49
+ **Example:**
50
+
51
+ ```bash
52
+ sf2tojson ./assets/piano.sf2 ./data/json/
53
+
54
+ ```
55
+
56
+ ## Features
57
+
58
+ * **Dual-Purpose Architecture**: Fully functional as both a Node.js library and a standalone CLI utility.
59
+ * **Seamless Integration**: Provides clear access to SF2 data structures in a JSON format compatible with WebAudio and MIDI synthesizers.
60
+ * **Type Safety**: Includes TypeScript declaration files (`index.d.ts`) to ensure robust development environments.
61
+ * **Extensibility**: Built upon established parsing standards to ensure reliability.
62
+
63
+ ## License
64
+
65
+ This project is licensed under the MIT License. See the `LICENSE` file for full details.
66
+
67
+ ## Author
68
+
69
+ Maxime Larrivée-Roy
70
+
71
+ ---
package/bin/cli.js ADDED
@@ -0,0 +1,18 @@
1
+ #!/usr/bin/env node
2
+
3
+ import { sf2tojson } from "../index.js";
4
+
5
+ const [,, inputPath, outputDir] = process.argv;
6
+
7
+ if (!inputPath || !outputDir) {
8
+ console.log('Usage: sf2tojson <input.sf2|.gz> <output_directory>');
9
+ process.exit(1);
10
+ }
11
+
12
+ try {
13
+ await sf2tojson(inputPath, outputDir, { verbose: true });
14
+ console.log('Success!.');
15
+ } catch (error) {
16
+ console.error('Conversion error :', error);
17
+ process.exit(1);
18
+ }
package/index.d.ts ADDED
@@ -0,0 +1,16 @@
1
+ // index.d.ts
2
+
3
+ declare module 'sf2-json' {
4
+ /**
5
+ * Converts an SF2 SoundFont file into JSON format.
6
+ * @param inputPath - The file path to the source .sf2 file.
7
+ * @param outputDir - The directory path where the resulting JSON files will be saved.
8
+ * @returns A promise that resolves when the conversion process is complete.
9
+ */
10
+ export function sf2tojson(
11
+ inputPath: string,
12
+ outputDir: string
13
+ ): Promise<void>;
14
+
15
+ export default sf2tojson;
16
+ }
package/index.js ADDED
@@ -0,0 +1,4 @@
1
+ import sf2tojson from "./src/sf2-json.js";
2
+
3
+ export { sf2tojson };
4
+ export default sf2tojson;
package/package.json ADDED
@@ -0,0 +1,53 @@
1
+ {
2
+ "name": "sf2-json",
3
+ "version": "1.0.0",
4
+ "description": "SF2 to JSON Converter for WebAudioFonts",
5
+ "keywords": [
6
+ "sf2",
7
+ "json",
8
+ "webaudiofont",
9
+ "music",
10
+ "sound",
11
+ "soundfont",
12
+ "midi",
13
+ "midiplayer",
14
+ "midi-player",
15
+ "instruments",
16
+ "sound",
17
+ "soundbank"
18
+ ],
19
+ "homepage": "https://github.com/ZmotriN/sf2-json#readme",
20
+ "bugs": {
21
+ "url": "https://github.com/ZmotriN/sf2-json/issues"
22
+ },
23
+ "repository": {
24
+ "type": "git",
25
+ "url": "git+https://github.com/ZmotriN/sf2-json.git"
26
+ },
27
+ "license": "MIT",
28
+ "author": "Maxime Larrivée-Roy",
29
+ "type": "module",
30
+ "files": [
31
+ "src/",
32
+ "index.js",
33
+ "index.d.ts"
34
+ ],
35
+ "main": "index.js",
36
+ "types": "index.d.ts",
37
+ "exports": {
38
+ ".": {
39
+ "types": "./index.d.ts",
40
+ "import": "./index.js"
41
+ }
42
+ },
43
+ "scripts": {
44
+ "test": "echo \"Error: no test specified\" && exit 1"
45
+ },
46
+ "bin": {
47
+ "sf2tojson": "./bin/cli.js"
48
+ },
49
+ "dependencies": {
50
+ "@marmooo/soundfont-parser": "^0.1.8",
51
+ "ffmpeg-static": "^5.3.0"
52
+ }
53
+ }
@@ -0,0 +1,359 @@
1
+ import { readFileSync, writeFileSync, mkdirSync, existsSync } from 'fs';
2
+ import { spawnSync } from 'child_process';
3
+ import { createRequire } from 'module';
4
+ import { gunzipSync } from 'zlib';
5
+ import path from 'path';
6
+ import {
7
+ parse,
8
+ SoundFont,
9
+ createInstrumentGeneratorObject,
10
+ createPresetGeneratorObject,
11
+ convertToInstrumentGeneratorParams,
12
+ DefaultInstrumentZone,
13
+ } from '@marmooo/soundfont-parser';
14
+
15
+
16
+ const require = createRequire(import.meta.url);
17
+ const ffmpegPath = require('ffmpeg-static');
18
+
19
+
20
+ const RESAMPLE_RATE = 48000;
21
+
22
+ const GM_CATEGORIES = [
23
+ 'Piano', 'Chromatic Perc', 'Organ', 'Guitar',
24
+ 'Bass', 'Strings', 'Ensemble', 'Brass',
25
+ 'Reed', 'Pipe', 'Synth Lead', 'Synth Pad',
26
+ 'Synth Effects', 'Ethnic', 'Percussive', 'Sound Effects',
27
+ ];
28
+
29
+ const GM_INSTRUMENTS = [
30
+ 'Acoustic Grand Piano', 'Bright Acoustic Piano', 'Electric Grand Piano', 'Honky-tonk Piano',
31
+ 'Electric Piano 1', 'Electric Piano 2', 'Harpsichord', 'Clavinet',
32
+ 'Celesta', 'Glockenspiel', 'Music Box', 'Vibraphone',
33
+ 'Marimba', 'Xylophone', 'Tubular Bells', 'Dulcimer',
34
+ 'Drawbar Organ', 'Percussive Organ', 'Rock Organ', 'Church Organ',
35
+ 'Reed Organ', 'Accordion', 'Harmonica', 'Tango Accordion',
36
+ 'Acoustic Guitar (nylon)', 'Acoustic Guitar (steel)', 'Electric Guitar (jazz)', 'Electric Guitar (clean)',
37
+ 'Electric Guitar (muted)', 'Overdriven Guitar', 'Distortion Guitar', 'Guitar Harmonics',
38
+ 'Acoustic Bass', 'Electric Bass (finger)', 'Electric Bass (pick)', 'Fretless Bass',
39
+ 'Slap Bass 1', 'Slap Bass 2', 'Synth Bass 1', 'Synth Bass 2',
40
+ 'Violin', 'Viola', 'Cello', 'Contrabass',
41
+ 'Tremolo Strings', 'Pizzicato Strings', 'Orchestral Harp', 'Timpani',
42
+ 'String Ensemble 1', 'String Ensemble 2', 'Synth Strings 1', 'Synth Strings 2',
43
+ 'Choir Aahs', 'Voice Oohs', 'Synth Voice', 'Orchestra Hit',
44
+ 'Trumpet', 'Trombone', 'Tuba', 'Muted Trumpet',
45
+ 'French Horn', 'Brass Section', 'Synth Brass 1', 'Synth Brass 2',
46
+ 'Soprano Sax', 'Alto Sax', 'Tenor Sax', 'Baritone Sax',
47
+ 'Oboe', 'English Horn', 'Bassoon', 'Clarinet',
48
+ 'Piccolo', 'Flute', 'Recorder', 'Pan Flute',
49
+ 'Blown Bottle', 'Shakuhachi', 'Whistle', 'Ocarina',
50
+ 'Lead 1 (square)', 'Lead 2 (sawtooth)', 'Lead 3 (calliope)', 'Lead 4 (chiff)',
51
+ 'Lead 5 (charang)', 'Lead 6 (voice)', 'Lead 7 (fifths)', 'Lead 8 (bass+lead)',
52
+ 'Pad 1 (new age)', 'Pad 2 (warm)', 'Pad 3 (polysynth)', 'Pad 4 (choir)',
53
+ 'Pad 5 (bowed)', 'Pad 6 (metallic)', 'Pad 7 (halo)', 'Pad 8 (sweep)',
54
+ 'FX 1 (rain)', 'FX 2 (soundtrack)', 'FX 3 (crystal)', 'FX 4 (atmosphere)',
55
+ 'FX 5 (brightness)', 'FX 6 (goblins)', 'FX 7 (echoes)', 'FX 8 (sci-fi)',
56
+ 'Sitar', 'Banjo', 'Shamisen', 'Koto',
57
+ 'Kalimba', 'Bagpipe', 'Fiddle', 'Shanai',
58
+ 'Tinkle Bell', 'Agogo', 'Steel Drums', 'Woodblock',
59
+ 'Taiko Drum', 'Melodic Tom', 'Synth Drum', 'Reverse Cymbal',
60
+ 'Guitar Fret Noise', 'Breath Noise', 'Seashore', 'Bird Tweet',
61
+ 'Telephone Ring', 'Helicopter', 'Applause', 'Gunshot',
62
+ ];
63
+
64
+
65
+ function encodeOpus(wavBuffer, bitrate = 128, sampleRate = 32000) {
66
+ const result = spawnSync(ffmpegPath, [
67
+ '-hide_banner',
68
+ '-loglevel', 'error',
69
+ '-i', 'pipe:0',
70
+ '-ar', String(sampleRate),
71
+ '-ac', '1',
72
+ '-af', "highpass=f=100,lowpass=f=8000,firequalizer=gain='if(lt(f,100),-2,if(lt(f,400),-3,if(lt(f,3000),2,if(lt(f,8000),1,-5))))':gain_entry='entry(0,-10);entry(100,0);entry(400,-2);entry(3000,2);entry(8000,1);entry(20000,-15)'",
73
+ '-b:a', `${bitrate}k`,
74
+ '-c:a', 'libopus',
75
+ '-id3v2_version', '0',
76
+ '-f', 'ogg',
77
+ 'pipe:1',
78
+ ], { input: wavBuffer, maxBuffer: 64 * 1024 * 1024 });
79
+
80
+ if (result.error) throw result.error;
81
+ if (result.status !== 0) {
82
+ throw new Error(`ffmpeg error: ${result.stderr?.toString()}`);
83
+ }
84
+
85
+ return result.stdout;
86
+ }
87
+
88
+
89
+ function getGMCategory(bank, program) {
90
+ if (bank === 128) return 'Percussion';
91
+ return GM_CATEGORIES[Math.floor(program / 8)] ?? 'Unknown';
92
+ }
93
+
94
+
95
+ function getGMInstrumentName(bank, program, fallback) {
96
+ return fallback ?? GM_INSTRUMENTS[program];
97
+ }
98
+
99
+
100
+ function readSf2(sf2Path) {
101
+ const raw = readFileSync(sf2Path);
102
+ if (raw[0] === 0x1F && raw[1] === 0x8B) {
103
+ return new Uint8Array(gunzipSync(raw));
104
+ }
105
+ return new Uint8Array(raw);
106
+ }
107
+
108
+
109
+ function pcm24ToPcm16(src) {
110
+ const frameCount = Math.floor(src.byteLength / 3);
111
+ const out = Buffer.allocUnsafe(frameCount * 2);
112
+ for (let i = 0; i < frameCount; i++) {
113
+ const off = i * 3;
114
+ let val = src[off] | (src[off + 1] << 8) | (src[off + 2] << 16);
115
+ if (val & 0x800000) val |= 0xFF000000;
116
+ out.writeInt16LE(val >> 8, i * 2);
117
+ }
118
+ return out;
119
+ }
120
+
121
+
122
+ function normalizeBuffer(buffer, targetPeak = 0.9) {
123
+ const samples = new Int16Array(buffer.buffer, buffer.byteOffset, buffer.byteLength / 2);
124
+ let peak = 0;
125
+ for (let i = 0; i < samples.length; i++) {
126
+ const abs = Math.abs(samples[i]);
127
+ if (abs > peak) peak = abs;
128
+ }
129
+ if (peak === 0) return buffer;
130
+ const targetPeakInt = targetPeak * 32767;
131
+ const factor = Math.min(targetPeakInt / peak, 3.0);
132
+ if (factor <= 1.0) return buffer;
133
+ for (let i = 0; i < samples.length; i++) {
134
+ samples[i] = Math.round(samples[i] * factor);
135
+ }
136
+ return buffer;
137
+ }
138
+
139
+
140
+ function buildWavBuffer(audioData) {
141
+ const { type, data, sampleHeader } = audioData;
142
+ const { sampleRate, loopStart, loopEnd } = sampleHeader;
143
+
144
+ let pcm16;
145
+ if (type === 'pcm16') pcm16 = Buffer.from(data.buffer, data.byteOffset, data.byteLength);
146
+ else if (type === 'pcm24') pcm16 = pcm24ToPcm16(data);
147
+ else throw new Error(`buildWavBuffer: type '${type}' non supporté (SF3 compressé).`);
148
+ pcm16 = normalizeBuffer(pcm16);
149
+
150
+ const loopLen = loopEnd - loopStart;
151
+ const MIN_LOOP_SAMPLES = Math.ceil(2 * 1152 * sampleRate / RESAMPLE_RATE);
152
+ if (loopLen > 0 && loopLen < MIN_LOOP_SAMPLES) {
153
+ const preLoop = pcm16.slice(0, loopStart * 2);
154
+ const loopData = pcm16.slice(loopStart * 2, loopEnd * 2);
155
+ const postLoop = pcm16.slice(loopEnd * 2);
156
+ const repeats = Math.ceil(MIN_LOOP_SAMPLES / loopLen);
157
+ const loopRepeated = Buffer.concat(Array(repeats).fill(loopData));
158
+ pcm16 = Buffer.concat([preLoop, loopRepeated, postLoop]);
159
+ }
160
+
161
+ const minSamples = Math.ceil(4 * 1152 * sampleRate / RESAMPLE_RATE);
162
+ const minBytes = minSamples * 2;
163
+ if (pcm16.byteLength < minBytes) {
164
+ const pad = Buffer.alloc(minBytes - pcm16.byteLength);
165
+ pcm16 = Buffer.concat([pcm16, pad]);
166
+ }
167
+
168
+ const numChannels = 1;
169
+ const bitsPerSample = 16;
170
+ const byteRate = sampleRate * numChannels * (bitsPerSample / 8);
171
+ const blockAlign = numChannels * (bitsPerSample / 8);
172
+ const dataSize = pcm16.byteLength;
173
+ const header = Buffer.allocUnsafe(44);
174
+
175
+ header.write('RIFF', 0);
176
+ header.writeUInt32LE(36 + dataSize, 4);
177
+ header.write('WAVE', 8);
178
+ header.write('fmt ', 12);
179
+ header.writeUInt32LE(16, 16);
180
+ header.writeUInt16LE(1, 20);
181
+ header.writeUInt16LE(numChannels, 22);
182
+ header.writeUInt32LE(sampleRate, 24);
183
+ header.writeUInt32LE(byteRate, 28);
184
+ header.writeUInt16LE(blockAlign, 32);
185
+ header.writeUInt16LE(bitsPerSample, 34);
186
+ header.write('data', 36);
187
+ header.writeUInt32LE(dataSize, 40);
188
+
189
+ return Buffer.concat([header, pcm16]);
190
+ }
191
+
192
+
193
+ function extractZones(soundFont, parsed, presetHeaderIndex) {
194
+ const presetGeneratorsList = soundFont.getPresetGenerators(presetHeaderIndex);
195
+ const zones = [];
196
+ const seenSampleIds = new Set();
197
+ let globalPresetGen = null;
198
+
199
+ for (const rawGenList of presetGeneratorsList) {
200
+ const presetGen = createPresetGeneratorObject(rawGenList);
201
+ if (presetGen.instrument === undefined) {
202
+ globalPresetGen = presetGen;
203
+ continue;
204
+ }
205
+
206
+ const instrId = presetGen.instrument;
207
+ const instrGeneratorsList = soundFont.getInstrumentGenerators(instrId);
208
+ const defaults = convertToInstrumentGeneratorParams(DefaultInstrumentZone);
209
+ let globalInstrGen = null;
210
+
211
+ for (const rawInstrGenList of instrGeneratorsList) {
212
+ const instrGen = createInstrumentGeneratorObject(rawInstrGenList);
213
+ if (instrGen.sampleID === undefined) { globalInstrGen = instrGen; continue; }
214
+
215
+ const merged = { ...defaults };
216
+ if (globalInstrGen) Object.assign(merged, globalInstrGen);
217
+ Object.assign(merged, instrGen);
218
+ const applyPresetOffsets = (gen) => {
219
+ if (!gen) return;
220
+ for (const [key, val] of Object.entries(gen)) {
221
+ if (key === 'keyRange' || key === 'velRange' || key === 'instrument') continue;
222
+ if (key in merged && typeof val === 'number') merged[key] += val;
223
+ }
224
+ };
225
+ applyPresetOffsets(globalPresetGen);
226
+ applyPresetOffsets(presetGen);
227
+
228
+ const sampleId = merged.sampleID;
229
+ if (seenSampleIds.has(sampleId)) continue;
230
+ const sampleHeader = parsed.sampleHeaders[sampleId];
231
+ if (!sampleHeader || sampleHeader.isEnd) continue;
232
+ seenSampleIds.add(sampleId);
233
+ zones.push({ generators: merged, sampleHeader, sample: parsed.samples[sampleId] });
234
+ }
235
+ }
236
+
237
+ const byKeyRange = new Map();
238
+ for (const zone of zones) {
239
+ const lo = zone.generators.keyRange?.lo ?? 0;
240
+ const hi = zone.generators.keyRange?.hi ?? 127;
241
+ const key = `${lo}-${hi}`;
242
+ if (!byKeyRange.has(key)) {
243
+ byKeyRange.set(key, zone);
244
+ } else {
245
+ const center = (lo + hi) / 2;
246
+ const existing = byKeyRange.get(key);
247
+ const existingDist = Math.abs(existing.sampleHeader.originalPitch - center);
248
+ const newDist = Math.abs(zone.sampleHeader.originalPitch - center);
249
+ if (newDist < existingDist) byKeyRange.set(key, zone);
250
+ }
251
+ }
252
+
253
+ return Array.from(byKeyRange.values());
254
+ }
255
+
256
+
257
+ async function buildZone(generators, sampleHeader, sample) {
258
+ const { sampleRate, originalPitch, pitchCorrection, loopStart, loopEnd, start } = sampleHeader;
259
+ const fineTune = (generators.fineTune ?? 0) + (pitchCorrection ?? 0);
260
+
261
+ const SF2_DEFAULT_ATTACK = -12000;
262
+ const SF2_DEFAULT_HOLD = -12000;
263
+ const SF2_DEFAULT_DECAY = -12000;
264
+ const SF2_DEFAULT_SUSTAIN = 0;
265
+ const SF2_DEFAULT_RELEASE = -12000;
266
+
267
+ const ahdsr =
268
+ (generators.attackVolEnv ?? SF2_DEFAULT_ATTACK) !== SF2_DEFAULT_ATTACK ||
269
+ (generators.holdVolEnv ?? SF2_DEFAULT_HOLD) !== SF2_DEFAULT_HOLD ||
270
+ (generators.decayVolEnv ?? SF2_DEFAULT_DECAY) !== SF2_DEFAULT_DECAY ||
271
+ (generators.sustainVolEnv ?? SF2_DEFAULT_SUSTAIN) !== SF2_DEFAULT_SUSTAIN ||
272
+ (generators.releaseVolEnv ?? SF2_DEFAULT_RELEASE) !== SF2_DEFAULT_RELEASE;
273
+
274
+ const midi = (generators.overridingRootKey !== undefined &&
275
+ generators.overridingRootKey !== 255 &&
276
+ generators.overridingRootKey >= 0)
277
+ ? generators.overridingRootKey
278
+ : originalPitch;
279
+
280
+ const wavBuffer = buildWavBuffer(sample);
281
+ const mp3Buffer = encodeOpus(wavBuffer, 96, RESAMPLE_RATE, true);
282
+ const audioBase64 = mp3Buffer.toString('base64');
283
+
284
+ return {
285
+ originalPitch: midi * 100,
286
+ keyRangeLow: generators.keyRange?.lo ?? 0,
287
+ keyRangeHigh: generators.keyRange?.hi ?? 127,
288
+ loopStart,
289
+ loopEnd,
290
+ coarseTune: generators.coarseTune ?? 0,
291
+ fineTune,
292
+ sampleRate,
293
+ ahdsr: ahdsr,
294
+ file: audioBase64,
295
+ };
296
+ }
297
+
298
+
299
+ export default async function sf2tojson(sf2Path, outputDir, options = {}) {
300
+ const { verbose = true, compress = true } = options;
301
+ if (!existsSync(outputDir)) mkdirSync(outputDir, { recursive: true });
302
+
303
+ const bankName = path.basename(sf2Path).replace(/\.sf2(\.gz)?$/, '');
304
+ const fileData = readSf2(sf2Path);
305
+ const parsed = parse(fileData);
306
+ const soundFont = new SoundFont(parsed);
307
+
308
+ let written = 0;
309
+ let skipped = 0;
310
+ const presetHeaders = parsed.presetHeaders.filter((h) => !h.isEnd);
311
+ const series = new Array(130).fill(0);
312
+
313
+ for (let i = 0; i < presetHeaders.length; i++) {
314
+ const header = presetHeaders[i];
315
+
316
+ if (verbose) process.stdout.write(`[${i + 1}/${presetHeaders.length}] bank=${header.bank} program=${header.preset} "${header.presetName.trim()}"…\n`);
317
+
318
+ const rawZones = extractZones(soundFont, parsed, i);
319
+ if (rawZones.length === 0) {
320
+ if (verbose) process.stdout.write(' ⚠ aucune zone, preset ignoré\n');
321
+ skipped++;
322
+ continue;
323
+ }
324
+
325
+ const zones = await Promise.all(
326
+ rawZones.map(({ generators, sampleHeader, sample }) =>
327
+ buildZone(generators, sampleHeader, sample)
328
+ )
329
+ );
330
+
331
+ const bank = header.bank;
332
+ const isDrum = bank === 128;
333
+ const isSFX = bank >= 120 && bank < 128;
334
+ let program;
335
+ if (isDrum) program = 128;
336
+ else if (isSFX) program = bank;
337
+ else program = header.preset;
338
+ const presetId = String(program).padStart(3, '0') + String(series[program]);
339
+ const id = `${presetId}_${bankName}`;
340
+
341
+ const output = {
342
+ id,
343
+ presetId,
344
+ bank: bankName,
345
+ category: getGMCategory(bank, program),
346
+ instrument: getGMInstrumentName(bank, program, header.presetName.trim()),
347
+ serie: series[program]++,
348
+ program: isDrum ? -1 : program+1,
349
+ zones,
350
+ };
351
+
352
+ const filename = `${id}.json`;
353
+ writeFileSync(path.join(outputDir, filename), JSON.stringify(output));
354
+ if (verbose) process.stdout.write(` ✓ ${filename} (${zones.length} zone(s))\n`);
355
+ written++;
356
+ }
357
+
358
+ return { written, skipped };
359
+ }