@datamoc/mw_games 0.18.0 → 0.20.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.
Files changed (48) hide show
  1. package/CHANGELOG.md +41 -0
  2. package/dist/ai/index.d.ts +2 -2
  3. package/dist/ai/index.d.ts.map +1 -1
  4. package/dist/ai/index.js +1 -1
  5. package/dist/ai/index.js.map +1 -1
  6. package/dist/ai/search.d.ts +49 -0
  7. package/dist/ai/search.d.ts.map +1 -1
  8. package/dist/ai/search.js +48 -0
  9. package/dist/ai/search.js.map +1 -1
  10. package/dist/audio/MidiRender.d.ts +39 -2
  11. package/dist/audio/MidiRender.d.ts.map +1 -1
  12. package/dist/audio/MidiRender.js +286 -199
  13. package/dist/audio/MidiRender.js.map +1 -1
  14. package/dist/audio/SoundFont.d.ts +46 -12
  15. package/dist/audio/SoundFont.d.ts.map +1 -1
  16. package/dist/audio/SoundFont.js +433 -74
  17. package/dist/audio/SoundFont.js.map +1 -1
  18. package/dist/audio/index.d.ts +4 -4
  19. package/dist/audio/index.d.ts.map +1 -1
  20. package/dist/audio/index.js +2 -2
  21. package/dist/audio/index.js.map +1 -1
  22. package/dist/board/Engine.d.ts +33 -0
  23. package/dist/board/Engine.d.ts.map +1 -1
  24. package/dist/board/Engine.js +448 -1
  25. package/dist/board/Engine.js.map +1 -1
  26. package/dist/board/index.d.ts +2 -2
  27. package/dist/board/index.d.ts.map +1 -1
  28. package/dist/board/index.js +1 -1
  29. package/dist/board/index.js.map +1 -1
  30. package/dist/mw_games.global.js +54 -54
  31. package/dist/threads/index.d.ts +3 -0
  32. package/dist/threads/index.d.ts.map +1 -0
  33. package/dist/threads/index.js +2 -0
  34. package/dist/threads/index.js.map +1 -0
  35. package/dist/threads/spawn.d.ts +41 -0
  36. package/dist/threads/spawn.d.ts.map +1 -0
  37. package/dist/threads/spawn.js +143 -0
  38. package/dist/threads/spawn.js.map +1 -0
  39. package/dist/threads/worker.d.ts +51 -0
  40. package/dist/threads/worker.d.ts.map +1 -0
  41. package/dist/threads/worker.js +84 -0
  42. package/dist/threads/worker.js.map +1 -0
  43. package/dist/version.d.ts +1 -1
  44. package/dist/version.js +1 -1
  45. package/package.json +6 -1
  46. package/sbom.cdx.json +54 -28
  47. package/tools/compress-dist.d.mts +8 -0
  48. package/tools/compress-dist.mjs +76 -30
@@ -1,15 +1,4 @@
1
- /**
2
- * A SoundFont 2 reader: presets to instruments to sample zones, enough to play
3
- * General MIDI music with real samples. Supported: key and velocity ranges, root key
4
- * and tuning, sample loop modes, the volume envelope (attack, hold, decay, sustain,
5
- * release), pan and attenuation, with the global-zone and preset-plus-instrument
6
- * layering rules. Not supported: modulators, filters and LFOs, 24-bit samples, or
7
- * stereo-linked sample pairs beyond playing each side as its own mono voice.
8
- *
9
- * A SoundFont file is only data (recorded samples plus preset tables), so reading one
10
- * is ordinary format engineering; the licence that matters is the font's own, checked
11
- * before it ships, never here.
12
- */
1
+ import { type MidiFile } from './Midi.ts';
13
2
  /** one mono sample: raw frames plus its loop region in frames and native rate */
14
3
  export interface SoundFontSample {
15
4
  data: Float32Array;
@@ -45,6 +34,11 @@ export interface SoundFont {
45
34
  hasPreset(bank: number, program: number): boolean;
46
35
  readonly presetCount: number;
47
36
  }
37
+ /** one used program: the voice a game's MIDI files actually request */
38
+ export interface SoundFontProgram {
39
+ bank: number;
40
+ program: number;
41
+ }
48
42
  /**
49
43
  * Reads SoundFont 2 bytes into layered preset voices.
50
44
  *
@@ -61,4 +55,44 @@ export interface SoundFont {
61
55
  * ```
62
56
  */
63
57
  export declare function parseSoundFont(data: ArrayBuffer | ArrayBufferView): SoundFont;
58
+ /**
59
+ * Every sampled voice the given MIDI files can request, as (bank, program) pairs:
60
+ * the channel voice in effect at each scheduled note, with drums mapped to bank 128
61
+ * the same way the renderer maps them. A game's compile step feeds this straight
62
+ * into `subsetSoundFont` so the shipped font carries only these programs.
63
+ *
64
+ * @example
65
+ * ```ts
66
+ * import { parseMidi, collectSoundFontUsage } from '@datamoc/mw_games/audio';
67
+ *
68
+ * declare const midiBytes: ArrayBuffer;
69
+ *
70
+ * const usage = collectSoundFontUsage([parseMidi(midiBytes)]);
71
+ * console.log(usage.length);
72
+ * ```
73
+ */
74
+ export declare function collectSoundFontUsage(files: readonly MidiFile[]): SoundFontProgram[];
75
+ /**
76
+ * Writes a smaller, valid SoundFont 2 file carrying only the used programs: their
77
+ * presets (names kept), instruments and samples, with zones, ranges, tuning,
78
+ * envelopes and sample loops copied verbatim and every index remapped. Zones that
79
+ * reference samples missing from the file (ROM samples) are dropped, matching what
80
+ * the reader voices for the full font; the library, genre and morphology metadata
81
+ * is zeroed and the INFO list, when present, rides along untouched. Modulator lists
82
+ * are emptied and 24-bit upper bits are dropped: the reader voices neither, so what
83
+ * it plays is unchanged, while a third-party player would miss those.
84
+ *
85
+ * @example
86
+ * ```ts
87
+ * import { parseMidi, collectSoundFontUsage, subsetSoundFont } from '@datamoc/mw_games/audio';
88
+ *
89
+ * declare const midiBytes: ArrayBuffer;
90
+ * declare const soundfontBytes: ArrayBuffer;
91
+ *
92
+ * const usage = collectSoundFontUsage([parseMidi(midiBytes)]);
93
+ * const pruned = subsetSoundFont(soundfontBytes, usage);
94
+ * console.log(usage.length, pruned.byteLength);
95
+ * ```
96
+ */
97
+ export declare function subsetSoundFont(data: ArrayBuffer | ArrayBufferView, usage: readonly SoundFontProgram[]): ArrayBuffer;
64
98
  //# sourceMappingURL=SoundFont.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"SoundFont.d.ts","sourceRoot":"","sources":["../../src/audio/SoundFont.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;GAWG;AAoBH,iFAAiF;AACjF,MAAM,WAAW,eAAe;IAC/B,IAAI,EAAE,YAAY,CAAC;IACnB,IAAI,EAAE,MAAM,CAAC;IACb,SAAS,EAAE,MAAM,CAAC;IAClB,OAAO,EAAE,MAAM,CAAC;CAChB;AAQD,wDAAwD;AACxD,MAAM,WAAW,cAAc;IAC9B,MAAM,EAAE,eAAe,CAAC;IACxB,OAAO,EAAE,MAAM,CAAC;IAChB,6DAA6D;IAC7D,KAAK,EAAE,MAAM,CAAC;IACd,IAAI,EAAE,OAAO,CAAC;IACd,wDAAwD;IACxD,IAAI,EAAE,MAAM,CAAC;IACb,8CAA8C;IAC9C,GAAG,EAAE,MAAM,CAAC;IACZ,6EAA6E;IAC7E,MAAM,EAAE,MAAM,CAAC;IACf,IAAI,EAAE,MAAM,CAAC;IACb,KAAK,EAAE,MAAM,CAAC;IACd,OAAO,EAAE,MAAM,CAAC;IAChB,OAAO,EAAE,MAAM,CAAC;CAChB;AAED;;;;GAIG;AACH,MAAM,WAAW,SAAS;IACzB,MAAM,CAAC,IAAI,EAAE,MAAM,EAAE,OAAO,EAAE,MAAM,EAAE,GAAG,EAAE,MAAM,EAAE,QAAQ,EAAE,MAAM,GAAG,cAAc,EAAE,CAAC;IACvF,SAAS,CAAC,IAAI,EAAE,MAAM,EAAE,OAAO,EAAE,MAAM,GAAG,OAAO,CAAC;IAClD,QAAQ,CAAC,WAAW,EAAE,MAAM,CAAC;CAC7B;AAmBD;;;;;;;;;;;;;;GAcG;AACH,wBAAgB,cAAc,CAAC,IAAI,EAAE,WAAW,GAAG,eAAe,GAAG,SAAS,CA6L7E"}
1
+ {"version":3,"file":"SoundFont.d.ts","sourceRoot":"","sources":["../../src/audio/SoundFont.ts"],"names":[],"mappings":"AAAA,OAAO,EAAgB,KAAK,QAAQ,EAAE,MAAM,WAAW,CAAC;AAwCxD,iFAAiF;AACjF,MAAM,WAAW,eAAe;IAC/B,IAAI,EAAE,YAAY,CAAC;IACnB,IAAI,EAAE,MAAM,CAAC;IACb,SAAS,EAAE,MAAM,CAAC;IAClB,OAAO,EAAE,MAAM,CAAC;CAChB;AAED,wDAAwD;AACxD,MAAM,WAAW,cAAc;IAC9B,MAAM,EAAE,eAAe,CAAC;IACxB,OAAO,EAAE,MAAM,CAAC;IAChB,6DAA6D;IAC7D,KAAK,EAAE,MAAM,CAAC;IACd,IAAI,EAAE,OAAO,CAAC;IACd,wDAAwD;IACxD,IAAI,EAAE,MAAM,CAAC;IACb,8CAA8C;IAC9C,GAAG,EAAE,MAAM,CAAC;IACZ,6EAA6E;IAC7E,MAAM,EAAE,MAAM,CAAC;IACf,IAAI,EAAE,MAAM,CAAC;IACb,KAAK,EAAE,MAAM,CAAC;IACd,OAAO,EAAE,MAAM,CAAC;IAChB,OAAO,EAAE,MAAM,CAAC;CAChB;AAED;;;;GAIG;AACH,MAAM,WAAW,SAAS;IACzB,MAAM,CAAC,IAAI,EAAE,MAAM,EAAE,OAAO,EAAE,MAAM,EAAE,GAAG,EAAE,MAAM,EAAE,QAAQ,EAAE,MAAM,GAAG,cAAc,EAAE,CAAC;IACvF,SAAS,CAAC,IAAI,EAAE,MAAM,EAAE,OAAO,EAAE,MAAM,GAAG,OAAO,CAAC;IAClD,QAAQ,CAAC,WAAW,EAAE,MAAM,CAAC;CAC7B;AAED,uEAAuE;AACvE,MAAM,WAAW,gBAAgB;IAChC,IAAI,EAAE,MAAM,CAAC;IACb,OAAO,EAAE,MAAM,CAAC;CAChB;AAyMD;;;;;;;;;;;;;;GAcG;AACH,wBAAgB,cAAc,CAAC,IAAI,EAAE,WAAW,GAAG,eAAe,GAAG,SAAS,CAkG7E;AAED;;;;;;;;;;;;;;;GAeG;AACH,wBAAgB,qBAAqB,CAAC,KAAK,EAAE,SAAS,QAAQ,EAAE,GAAG,gBAAgB,EAAE,CAcpF;AA6CD;;;;;;;;;;;;;;;;;;;;;GAqBG;AACH,wBAAgB,eAAe,CAAC,IAAI,EAAE,WAAW,GAAG,eAAe,EAAE,KAAK,EAAE,SAAS,gBAAgB,EAAE,GAAG,WAAW,CAiOpH"}
@@ -1,3 +1,4 @@
1
+ import { scheduleMidi } from './Midi.js';
1
2
  /**
2
3
  * A SoundFont 2 reader: presets to instruments to sample zones, enough to play
3
4
  * General MIDI music with real samples. Supported: key and velocity ranges, root key
@@ -9,6 +10,13 @@
9
10
  * A SoundFont file is only data (recorded samples plus preset tables), so reading one
10
11
  * is ordinary format engineering; the licence that matters is the font's own, checked
11
12
  * before it ships, never here.
13
+ *
14
+ * `subsetSoundFont` answers the shipping side: a game that plays five instruments
15
+ * should not carry all 128. It takes the programs the game's MIDI files actually use
16
+ * (see `collectSoundFontUsage`) and writes a smaller, valid SoundFont 2 file with the
17
+ * same voices for those programs: zones, ranges, tuning, envelopes and sample loops
18
+ * survive byte-identical, while unused presets, instruments and samples are gone and
19
+ * the library, genre and morphology metadata is zeroed.
12
20
  */
13
21
  const GENERATORS = {
14
22
  keyRange: 43,
@@ -31,37 +39,37 @@ const GENERATORS = {
31
39
  function timecents(value) {
32
40
  return 2 ** (value / 1200);
33
41
  }
34
- /**
35
- * Reads SoundFont 2 bytes into layered preset voices.
36
- *
37
- * @example
38
- * ```ts
39
- * import { parseSoundFont, parseMidi, renderMidiToBuffer } from '@datamoc/mw_games/audio';
40
- *
41
- * declare const soundfontBytes: ArrayBuffer;
42
- * declare const midiBytes: ArrayBuffer;
43
- *
44
- * const font = parseSoundFont(soundfontBytes);
45
- * const rendered = renderMidiToBuffer(parseMidi(midiBytes), { soundfont: font });
46
- * console.log(font.presetCount, rendered.duration);
47
- * ```
48
- */
49
- export function parseSoundFont(data) {
50
- const bytes = data instanceof ArrayBuffer
42
+ function toBytes(data) {
43
+ return data instanceof ArrayBuffer
51
44
  ? new Uint8Array(data)
52
45
  : new Uint8Array(data.buffer, data.byteOffset, data.byteLength);
46
+ }
47
+ function decodeName(bytes, at) {
48
+ let name = '';
49
+ for (let i = 0; i < 20; i++) {
50
+ const code = bytes[at + i];
51
+ if (code === 0)
52
+ break;
53
+ name += String.fromCharCode(code);
54
+ }
55
+ return name;
56
+ }
57
+ function readTables(bytes) {
53
58
  const view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength);
54
59
  const fourcc = (at) => String.fromCharCode(bytes[at], bytes[at + 1], bytes[at + 2], bytes[at + 3]);
55
60
  if (fourcc(0) !== 'RIFF' || fourcc(8) !== 'sfbk')
56
61
  throw new Error('not a SoundFont 2 file');
57
62
  const chunks = new Map();
63
+ const lists = new Map();
58
64
  const walk = (start, end) => {
59
65
  let at = start;
60
66
  while (at + 8 <= end) {
61
67
  const id = fourcc(at);
62
68
  const size = view.getUint32(at + 4, true);
63
- if (id === 'LIST')
69
+ if (id === 'LIST') {
70
+ lists.set(fourcc(at + 8), { at, size });
64
71
  walk(at + 12, at + 8 + size);
72
+ }
65
73
  else
66
74
  chunks.set(id, { at: at + 8, size });
67
75
  at += 8 + size + (size & 1);
@@ -86,13 +94,18 @@ export function parseSoundFont(data) {
86
94
  amount: view.getInt16(at + 2, true),
87
95
  }));
88
96
  const bags = (name) => records(name, 4, (at) => view.getUint16(at, true));
89
- const presetHeaders = records('phdr', 38, (at) => ({
97
+ const presets = records('phdr', 38, (at) => ({
98
+ name: decodeName(bytes, at),
90
99
  preset: view.getUint16(at + 20, true),
91
100
  bank: view.getUint16(at + 22, true),
92
101
  bag: view.getUint16(at + 24, true),
93
102
  }));
94
- const instrumentHeaders = records('inst', 22, (at) => ({ bag: view.getUint16(at + 20, true) }));
95
- const sampleHeaders = records('shdr', 46, (at) => ({
103
+ const instruments = records('inst', 22, (at) => ({
104
+ name: decodeName(bytes, at),
105
+ bag: view.getUint16(at + 20, true),
106
+ }));
107
+ const samples = records('shdr', 46, (at) => ({
108
+ name: decodeName(bytes, at),
96
109
  start: view.getUint32(at + 20, true),
97
110
  end: view.getUint32(at + 24, true),
98
111
  loopStart: view.getUint32(at + 28, true),
@@ -100,70 +113,109 @@ export function parseSoundFont(data) {
100
113
  rate: view.getUint32(at + 36, true),
101
114
  rootKey: bytes[at + 40],
102
115
  correction: view.getInt8(at + 41),
116
+ link: view.getUint16(at + 42, true),
103
117
  type: view.getUint16(at + 44, true),
104
118
  }));
105
- const presetBags = bags('pbag');
106
- const instrumentBags = bags('ibag');
107
- const presetGens = generators('pgen');
108
- const instrumentGens = generators('igen');
109
- //Zones of one header entry; a first zone lacking the terminal generator is global.
110
- const zonesOf = (headers, bagList, gens, index, terminal) => {
111
- const first = headers[index].bag;
112
- const last = headers[index + 1]?.bag ?? bagList.length - 1;
113
- const zones = [];
114
- let global = null;
115
- for (let bag = first; bag < last; bag++) {
116
- const map = new Map();
117
- for (let g = bagList[bag]; g < (bagList[bag + 1] ?? gens.length); g++) {
118
- map.set(gens[g].oper, gens[g]);
119
- }
120
- if (!map.has(terminal)) {
121
- if (bag === first)
122
- global = map;
123
- continue;
124
- }
125
- zones.push({ gens: map, global });
126
- }
127
- return zones;
128
- };
129
- const generatorOf = (zone, oper) => zone.gens.get(oper) ?? zone.global?.get(oper) ?? null;
130
119
  const smplChunk = chunks.get('smpl');
131
120
  const smpl = new Int16Array(bytes.buffer.slice(bytes.byteOffset + smplChunk.at, bytes.byteOffset + smplChunk.at + smplChunk.size - (smplChunk.size & 1)));
121
+ const info = lists.get('INFO') ?? null;
122
+ return {
123
+ bytes,
124
+ presets,
125
+ presetBags: bags('pbag'),
126
+ presetGens: generators('pgen'),
127
+ instruments,
128
+ instBags: bags('ibag'),
129
+ instGens: generators('igen'),
130
+ samples,
131
+ smpl,
132
+ info: info ? bytes.slice(info.at, info.at + 8 + info.size + (info.size & 1)) : null,
133
+ };
134
+ }
135
+ /** the generators of one bag, in file order */
136
+ function bagGens(bagList, gens, bag) {
137
+ return gens.slice(bagList[bag], bagList[bag + 1] ?? gens.length);
138
+ }
139
+ //Zones of one header entry: [{ gens: Map(oper -> generator) }...]; a first zone lacking the terminal generator is global.
140
+ function zonesOf(headers, bagList, gens, index, terminal) {
141
+ const first = headers[index].bag;
142
+ const last = headers[index + 1]?.bag ?? bagList.length - 1;
143
+ const zones = [];
144
+ let global = null;
145
+ for (let bag = first; bag < last; bag++) {
146
+ const slice = bagGens(bagList, gens, bag);
147
+ const map = new Map();
148
+ for (const generator of slice)
149
+ map.set(generator.oper, generator);
150
+ if (!map.has(terminal)) {
151
+ if (bag === first)
152
+ global = map;
153
+ continue;
154
+ }
155
+ zones.push({ gens: map, global });
156
+ }
157
+ return zones;
158
+ }
159
+ function generatorOf(zone, oper) {
160
+ return zone.gens.get(oper) ?? zone.global?.get(oper) ?? null;
161
+ }
162
+ function presetIndexOf(presets, bank, program) {
163
+ return (presets.get(`${bank}:${program}`) ??
164
+ presets.get(`0:${program}`) ??
165
+ (bank === 128 ? presets.get('128:0') : undefined) ??
166
+ presets.get('0:0'));
167
+ }
168
+ /**
169
+ * Reads SoundFont 2 bytes into layered preset voices.
170
+ *
171
+ * @example
172
+ * ```ts
173
+ * import { parseSoundFont, parseMidi, renderMidiToBuffer } from '@datamoc/mw_games/audio';
174
+ *
175
+ * declare const soundfontBytes: ArrayBuffer;
176
+ * declare const midiBytes: ArrayBuffer;
177
+ *
178
+ * const font = parseSoundFont(soundfontBytes);
179
+ * const rendered = renderMidiToBuffer(parseMidi(midiBytes), { soundfont: font });
180
+ * console.log(font.presetCount, rendered.duration);
181
+ * ```
182
+ */
183
+ export function parseSoundFont(data) {
184
+ const tables = readTables(toBytes(data));
132
185
  const sampleCache = new Map();
186
+ const tuning = new Map();
133
187
  const sampleOf = (id) => {
134
188
  if (sampleCache.has(id))
135
189
  return sampleCache.get(id);
136
- const header = sampleHeaders[id];
137
- //ROM samples live outside the file and cannot play
190
+ const header = tables.samples[id];
138
191
  if (!header || header.end <= header.start || header.type & 0x8000) {
139
- sampleCache.set(id, null);
192
+ sampleCache.set(id, null); //ROM samples live outside the file and cannot play
140
193
  return null;
141
194
  }
142
- const data = new Float32Array(header.end - header.start);
143
- for (let index = 0; index < data.length; index++) {
144
- data[index] = smpl[header.start + index] / 32768;
195
+ const frames = new Float32Array(header.end - header.start);
196
+ for (let index = 0; index < frames.length; index++) {
197
+ frames[index] = tables.smpl[header.start + index] / 32768;
145
198
  }
146
199
  const sample = {
147
- data,
200
+ data: frames,
148
201
  rate: header.rate || 22050,
149
202
  loopStart: header.loopStart - header.start,
150
203
  loopEnd: header.loopEnd - header.start,
151
- rootKey: header.rootKey === 255 ? 60 : header.rootKey,
152
- correction: header.correction,
153
204
  };
154
205
  sampleCache.set(id, sample);
206
+ tuning.set(id, {
207
+ rootKey: header.rootKey === 255 ? 60 : header.rootKey,
208
+ correction: header.correction,
209
+ });
155
210
  return sample;
156
211
  };
157
212
  const presets = new Map();
158
- for (let index = 0; index < presetHeaders.length - 1; index++) {
159
- presets.set(`${presetHeaders[index].bank}:${presetHeaders[index].preset}`, index);
213
+ for (let index = 0; index < tables.presets.length - 1; index++) {
214
+ presets.set(`${tables.presets[index].bank}:${tables.presets[index].preset}`, index);
160
215
  }
161
- //Zones that sound for (bank, program, key, velocity), each with its playback recipe.
216
+ //Zones that sound for (bank, program, key, velocity), each with the resolved playback recipe.
162
217
  function voices(bank, program, key, velocity) {
163
- const presetIndex = presets.get(`${bank}:${program}`) ??
164
- presets.get(`0:${program}`) ??
165
- (bank === 128 ? presets.get('128:0') : undefined) ??
166
- presets.get('0:0');
218
+ const presetIndex = presetIndexOf(presets, bank, program);
167
219
  if (presetIndex === undefined)
168
220
  return [];
169
221
  const out = [];
@@ -171,32 +223,29 @@ export function parseSoundFont(data) {
171
223
  const generator = generatorOf(zone, oper);
172
224
  return !generator || (value >= generator.lo && value <= generator.hi);
173
225
  };
174
- for (const presetZone of zonesOf(presetHeaders, presetBags, presetGens, presetIndex, GENERATORS.instrument)) {
226
+ for (const presetZone of zonesOf(tables.presets, tables.presetBags, tables.presetGens, presetIndex, GENERATORS.instrument)) {
175
227
  if (!within(presetZone, GENERATORS.keyRange, key) || !within(presetZone, GENERATORS.velRange, velocity)) {
176
228
  continue;
177
229
  }
178
230
  const instrument = generatorOf(presetZone, GENERATORS.instrument).amount;
179
- if (!instrumentHeaders[instrument + 1])
231
+ if (!tables.instruments[instrument + 1])
180
232
  continue;
181
- for (const zone of zonesOf(instrumentHeaders, instrumentBags, instrumentGens, instrument, GENERATORS.sampleId)) {
233
+ for (const zone of zonesOf(tables.instruments, tables.instBags, tables.instGens, instrument, GENERATORS.sampleId)) {
182
234
  if (!within(zone, GENERATORS.keyRange, key) || !within(zone, GENERATORS.velRange, velocity)) {
183
235
  continue;
184
236
  }
185
- const sample = sampleOf(generatorOf(zone, GENERATORS.sampleId).amount);
237
+ const sampleId = generatorOf(zone, GENERATORS.sampleId).amount;
238
+ const sample = sampleOf(sampleId);
186
239
  if (!sample)
187
240
  continue;
188
241
  const both = (oper, fallback = 0) => (generatorOf(zone, oper)?.amount ?? fallback) + (generatorOf(presetZone, oper)?.amount ?? 0);
189
242
  const root = generatorOf(zone, GENERATORS.rootKey)?.amount;
190
243
  const modes = generatorOf(zone, GENERATORS.sampleModes)?.amount ?? 0;
244
+ const native = tuning.get(sampleId);
191
245
  out.push({
192
- sample: {
193
- data: sample.data,
194
- rate: sample.rate,
195
- loopStart: sample.loopStart,
196
- loopEnd: sample.loopEnd,
197
- },
198
- rootKey: root !== undefined && root >= 0 ? root : sample.rootKey,
199
- cents: both(GENERATORS.coarseTune) * 100 + both(GENERATORS.fineTune) + sample.correction,
246
+ sample,
247
+ rootKey: root !== undefined && root >= 0 ? root : native.rootKey,
248
+ cents: both(GENERATORS.coarseTune) * 100 + both(GENERATORS.fineTune) + native.correction,
200
249
  loop: (modes & 3) === 1 || (modes & 3) === 3,
201
250
  gain: 10 ** (-Math.max(0, both(GENERATORS.attenuation)) / 200),
202
251
  pan: Math.max(-1, Math.min(1, both(GENERATORS.pan) / 500)),
@@ -216,4 +265,314 @@ export function parseSoundFont(data) {
216
265
  presetCount: presets.size,
217
266
  };
218
267
  }
268
+ /**
269
+ * Every sampled voice the given MIDI files can request, as (bank, program) pairs:
270
+ * the channel voice in effect at each scheduled note, with drums mapped to bank 128
271
+ * the same way the renderer maps them. A game's compile step feeds this straight
272
+ * into `subsetSoundFont` so the shipped font carries only these programs.
273
+ *
274
+ * @example
275
+ * ```ts
276
+ * import { parseMidi, collectSoundFontUsage } from '@datamoc/mw_games/audio';
277
+ *
278
+ * declare const midiBytes: ArrayBuffer;
279
+ *
280
+ * const usage = collectSoundFontUsage([parseMidi(midiBytes)]);
281
+ * console.log(usage.length);
282
+ * ```
283
+ */
284
+ export function collectSoundFontUsage(files) {
285
+ const seen = new Set();
286
+ const out = [];
287
+ for (const file of files) {
288
+ for (const note of scheduleMidi(file)) {
289
+ const bank = note.channel === 9 ? 128 : note.bank;
290
+ const key = `${bank}:${note.program}`;
291
+ if (!seen.has(key)) {
292
+ seen.add(key);
293
+ out.push({ bank, program: note.program });
294
+ }
295
+ }
296
+ }
297
+ return out;
298
+ }
299
+ function u16le(value) {
300
+ return [value & 0xff, (value >> 8) & 0xff];
301
+ }
302
+ function u32le(value) {
303
+ return [value & 0xff, (value >> 8) & 0xff, (value >> 16) & 0xff, (value >> 24) & 0xff];
304
+ }
305
+ function encodeName(name) {
306
+ const codes = [...name].map((char) => char.charCodeAt(0) & 0xff).slice(0, 20);
307
+ return [...codes, ...new Array(20 - codes.length).fill(0)];
308
+ }
309
+ function asciiBytes(text) {
310
+ return [...text].map((char) => char.charCodeAt(0));
311
+ }
312
+ /** one chunk as bytes: kept samples run to megabytes, so this allocates instead of spreading */
313
+ function chunkBytes(id, payload) {
314
+ const out = new Uint8Array(8 + payload.length + (payload.length & 1));
315
+ out.set(asciiBytes(id), 0);
316
+ new DataView(out.buffer).setUint32(4, payload.length, true);
317
+ out.set(payload, 8);
318
+ return out;
319
+ }
320
+ function concat(parts) {
321
+ let length = 0;
322
+ for (const part of parts)
323
+ length += part.length;
324
+ const out = new Uint8Array(length);
325
+ let at = 0;
326
+ for (const part of parts) {
327
+ out.set(part, at);
328
+ at += part.length;
329
+ }
330
+ return out;
331
+ }
332
+ function listBytes(type, subs) {
333
+ const body = concat([new Uint8Array(asciiBytes(type)), ...subs]);
334
+ return concat([new Uint8Array(asciiBytes('LIST')), new Uint8Array(u32le(body.length)), body]);
335
+ }
336
+ /**
337
+ * Writes a smaller, valid SoundFont 2 file carrying only the used programs: their
338
+ * presets (names kept), instruments and samples, with zones, ranges, tuning,
339
+ * envelopes and sample loops copied verbatim and every index remapped. Zones that
340
+ * reference samples missing from the file (ROM samples) are dropped, matching what
341
+ * the reader voices for the full font; the library, genre and morphology metadata
342
+ * is zeroed and the INFO list, when present, rides along untouched. Modulator lists
343
+ * are emptied and 24-bit upper bits are dropped: the reader voices neither, so what
344
+ * it plays is unchanged, while a third-party player would miss those.
345
+ *
346
+ * @example
347
+ * ```ts
348
+ * import { parseMidi, collectSoundFontUsage, subsetSoundFont } from '@datamoc/mw_games/audio';
349
+ *
350
+ * declare const midiBytes: ArrayBuffer;
351
+ * declare const soundfontBytes: ArrayBuffer;
352
+ *
353
+ * const usage = collectSoundFontUsage([parseMidi(midiBytes)]);
354
+ * const pruned = subsetSoundFont(soundfontBytes, usage);
355
+ * console.log(usage.length, pruned.byteLength);
356
+ * ```
357
+ */
358
+ export function subsetSoundFont(data, usage) {
359
+ const tables = readTables(toBytes(data));
360
+ const presetIndex = new Map();
361
+ for (let index = 0; index < tables.presets.length - 1; index++) {
362
+ presetIndex.set(`${tables.presets[index].bank}:${tables.presets[index].preset}`, index);
363
+ }
364
+ //Used presets in file order, resolved through the same fallback the reader voices.
365
+ const seenPresets = new Set();
366
+ const usedPresets = [];
367
+ for (const { bank, program } of usage) {
368
+ const index = presetIndexOf(presetIndex, bank, program);
369
+ if (index !== undefined && !seenPresets.has(index)) {
370
+ seenPresets.add(index);
371
+ usedPresets.push(index);
372
+ }
373
+ }
374
+ usedPresets.sort((a, b) => a - b);
375
+ const usableSample = (id) => {
376
+ const header = tables.samples[id];
377
+ return !!header && header.end > header.start && !(header.type & 0x8000);
378
+ };
379
+ //A bag's generators verbatim, except instrument and sample ids are remapped.
380
+ const remapIds = (gens, oper, ids) => gens.map((generator) => generator.oper === oper
381
+ ? {
382
+ oper,
383
+ lo: ids.get(generator.amount) & 0xff,
384
+ hi: (ids.get(generator.amount) >> 8) & 0xff,
385
+ amount: ids.get(generator.amount),
386
+ }
387
+ : generator);
388
+ //Instruments in first-use order, with zones that lost their sample dropped.
389
+ const instrumentOrder = [];
390
+ const instrumentZones = new Map();
391
+ {
392
+ const seen = new Set();
393
+ for (const preset of usedPresets) {
394
+ const first = tables.presets[preset].bag;
395
+ const last = tables.presets[preset + 1]?.bag ?? tables.presetBags.length - 1;
396
+ for (let bag = first; bag < last; bag++) {
397
+ const gens = bagGens(tables.presetBags, tables.presetGens, bag);
398
+ if (!gens.some((generator) => generator.oper === GENERATORS.instrument))
399
+ continue;
400
+ const instrument = gens.find((generator) => generator.oper === GENERATORS.instrument).amount;
401
+ if (!tables.instruments[instrument + 1])
402
+ continue; // same guard the reader voices with
403
+ if (!seen.has(instrument)) {
404
+ seen.add(instrument);
405
+ instrumentOrder.push(instrument);
406
+ }
407
+ }
408
+ }
409
+ }
410
+ const instrumentNew = new Map(instrumentOrder.map((old, index) => [old, index]));
411
+ for (const instrument of instrumentOrder) {
412
+ const first = tables.instruments[instrument].bag;
413
+ const last = tables.instruments[instrument + 1]?.bag ?? tables.instBags.length - 1;
414
+ const zones = [];
415
+ for (let bag = first; bag < last; bag++) {
416
+ const gens = bagGens(tables.instBags, tables.instGens, bag);
417
+ if (!gens.some((generator) => generator.oper === GENERATORS.sampleId))
418
+ continue;
419
+ const sample = gens.find((generator) => generator.oper === GENERATORS.sampleId).amount;
420
+ if (!usableSample(sample))
421
+ continue;
422
+ zones.push(gens);
423
+ }
424
+ instrumentZones.set(instrument, zones);
425
+ }
426
+ //Samples in first-use order, chasing stereo links one hop so none dangles.
427
+ const sampleOrder = [];
428
+ {
429
+ const seen = new Set();
430
+ const keep = (id) => {
431
+ if (seen.has(id) || !usableSample(id))
432
+ return;
433
+ seen.add(id);
434
+ sampleOrder.push(id);
435
+ const link = tables.samples[id].link;
436
+ if (link !== id)
437
+ keep(link);
438
+ };
439
+ for (const instrument of instrumentOrder) {
440
+ for (const zone of instrumentZones.get(instrument)) {
441
+ keep(zone.find((generator) => generator.oper === GENERATORS.sampleId).amount);
442
+ }
443
+ }
444
+ }
445
+ const sampleNew = new Map(sampleOrder.map((old, index) => [old, index]));
446
+ const genRecord = (generator) => [...u16le(generator.oper), generator.lo, generator.hi];
447
+ const bagRecord = (genIndex) => [...u16le(genIndex), ...u16le(0)];
448
+ //Presets: global zone first when the source has one, then its instrument zones.
449
+ const newPresetBags = [];
450
+ const newPresetGens = [];
451
+ const newPresets = [];
452
+ for (const preset of usedPresets) {
453
+ const source = tables.presets[preset];
454
+ const bagStart = newPresetBags.length;
455
+ const first = source.bag;
456
+ const last = tables.presets[preset + 1]?.bag ?? tables.presetBags.length - 1;
457
+ for (let bag = first; bag < last; bag++) {
458
+ const gens = bagGens(tables.presetBags, tables.presetGens, bag);
459
+ const isGlobal = bag === first && !gens.some((generator) => generator.oper === GENERATORS.instrument);
460
+ if (!isGlobal && !gens.some((generator) => generator.oper === GENERATORS.instrument))
461
+ continue;
462
+ newPresetBags.push(newPresetGens.length);
463
+ newPresetGens.push(...remapIds(gens, GENERATORS.instrument, instrumentNew));
464
+ }
465
+ newPresets.push({ name: source.name, preset: source.preset, bank: source.bank, bag: bagStart });
466
+ }
467
+ newPresetBags.push(newPresetGens.length);
468
+ //Instruments: same shape, sample ids remapped, sample zones already pruned above.
469
+ const newInstBags = [];
470
+ const newInstGens = [];
471
+ const newInstruments = [];
472
+ for (const instrument of instrumentOrder) {
473
+ const bagStart = newInstBags.length;
474
+ const zones = instrumentZones.get(instrument);
475
+ //An instrument whose zones all lost their samples keeps its header with no
476
+ //bags: it voices nothing, exactly like the full font did.
477
+ //A global zone (first bag without a sample id) rides along when present.
478
+ const first = tables.instruments[instrument].bag;
479
+ const last = tables.instruments[instrument + 1]?.bag ?? tables.instBags.length - 1;
480
+ for (let bag = first; bag < last; bag++) {
481
+ const gens = bagGens(tables.instBags, tables.instGens, bag);
482
+ if (bag === first && !gens.some((generator) => generator.oper === GENERATORS.sampleId)) {
483
+ newInstBags.push(newInstGens.length);
484
+ newInstGens.push(...gens);
485
+ }
486
+ }
487
+ for (const zone of zones) {
488
+ newInstBags.push(newInstGens.length);
489
+ newInstGens.push(...remapIds(zone, GENERATORS.sampleId, sampleNew));
490
+ }
491
+ newInstruments.push({ name: tables.instruments[instrument].name, bag: bagStart });
492
+ }
493
+ newInstBags.push(newInstGens.length);
494
+ //Samples: data concatenated, offsets and links remapped, headers otherwise intact.
495
+ const newSamples = [];
496
+ const sampleData = [];
497
+ for (const old of sampleOrder) {
498
+ const header = tables.samples[old];
499
+ const start = sampleData.length / 2;
500
+ for (let index = header.start; index < header.end; index++) {
501
+ const word = tables.smpl[index];
502
+ sampleData.push(word & 0xff, (word >> 8) & 0xff);
503
+ }
504
+ const self = sampleNew.get(old);
505
+ newSamples.push({
506
+ ...header,
507
+ start,
508
+ end: start + (header.end - header.start),
509
+ loopStart: start + (header.loopStart - header.start),
510
+ loopEnd: start + (header.loopEnd - header.start),
511
+ link: sampleNew.get(header.link) ?? self,
512
+ });
513
+ }
514
+ const presetRecords = (entry) => [
515
+ ...encodeName(entry.name),
516
+ ...u16le(entry.preset),
517
+ ...u16le(entry.bank),
518
+ ...u16le(entry.bag),
519
+ ...new Array(12).fill(0),
520
+ ];
521
+ const terminalPreset = tables.presets[tables.presets.length - 1];
522
+ const phdr = chunkBytes('phdr', [
523
+ ...newPresets.flatMap(presetRecords),
524
+ ...presetRecords({
525
+ name: terminalPreset?.name ?? 'EOS',
526
+ preset: 0,
527
+ bank: 0,
528
+ bag: newPresetBags.length - 1,
529
+ }),
530
+ ]);
531
+ const pbag = chunkBytes('pbag', newPresetBags.flatMap(bagRecord));
532
+ const pgen = chunkBytes('pgen', newPresetGens.flatMap(genRecord));
533
+ const pmod = chunkBytes('pmod', new Array(10).fill(0));
534
+ const instRecords = (entry) => [
535
+ ...encodeName(entry.name),
536
+ ...u16le(entry.bag),
537
+ ];
538
+ const terminalInst = tables.instruments[tables.instruments.length - 1];
539
+ const inst = chunkBytes('inst', [
540
+ ...newInstruments.flatMap(instRecords),
541
+ ...instRecords({ name: terminalInst?.name ?? 'EOS', bag: newInstBags.length - 1 }),
542
+ ]);
543
+ const ibag = chunkBytes('ibag', newInstBags.flatMap(bagRecord));
544
+ const igen = chunkBytes('igen', newInstGens.flatMap(genRecord));
545
+ const imod = chunkBytes('imod', new Array(10).fill(0));
546
+ const sampleRecords = (header) => [
547
+ ...encodeName(header.name),
548
+ ...u32le(header.start),
549
+ ...u32le(header.end),
550
+ ...u32le(header.loopStart),
551
+ ...u32le(header.loopEnd),
552
+ ...u32le(header.rate),
553
+ header.rootKey,
554
+ header.correction & 0xff,
555
+ ...u16le(header.link),
556
+ ...u16le(header.type),
557
+ ];
558
+ const terminalSample = {
559
+ name: '',
560
+ start: 0,
561
+ end: 0,
562
+ loopStart: 0,
563
+ loopEnd: 0,
564
+ rate: 0,
565
+ rootKey: 0,
566
+ correction: 0,
567
+ link: 0,
568
+ type: 0,
569
+ };
570
+ const shdr = chunkBytes('shdr', [...newSamples.flatMap(sampleRecords), ...sampleRecords(terminalSample)]);
571
+ const smpl = chunkBytes('smpl', sampleData);
572
+ const sdta = listBytes('sdta', [smpl]);
573
+ const pdta = listBytes('pdta', [phdr, pbag, pmod, pgen, inst, ibag, imod, igen, shdr]);
574
+ const body = concat([new Uint8Array(asciiBytes('sfbk')), ...(tables.info ? [tables.info] : []), sdta, pdta]);
575
+ const bytes = concat([new Uint8Array(asciiBytes('RIFF')), new Uint8Array(u32le(body.length)), body]);
576
+ return bytes.buffer;
577
+ }
219
578
  //# sourceMappingURL=SoundFont.js.map