@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.
- package/CHANGELOG.md +41 -0
- package/dist/ai/index.d.ts +2 -2
- package/dist/ai/index.d.ts.map +1 -1
- package/dist/ai/index.js +1 -1
- package/dist/ai/index.js.map +1 -1
- package/dist/ai/search.d.ts +49 -0
- package/dist/ai/search.d.ts.map +1 -1
- package/dist/ai/search.js +48 -0
- package/dist/ai/search.js.map +1 -1
- package/dist/audio/MidiRender.d.ts +39 -2
- package/dist/audio/MidiRender.d.ts.map +1 -1
- package/dist/audio/MidiRender.js +286 -199
- package/dist/audio/MidiRender.js.map +1 -1
- package/dist/audio/SoundFont.d.ts +46 -12
- package/dist/audio/SoundFont.d.ts.map +1 -1
- package/dist/audio/SoundFont.js +433 -74
- package/dist/audio/SoundFont.js.map +1 -1
- package/dist/audio/index.d.ts +4 -4
- package/dist/audio/index.d.ts.map +1 -1
- package/dist/audio/index.js +2 -2
- package/dist/audio/index.js.map +1 -1
- package/dist/board/Engine.d.ts +33 -0
- package/dist/board/Engine.d.ts.map +1 -1
- package/dist/board/Engine.js +448 -1
- package/dist/board/Engine.js.map +1 -1
- package/dist/board/index.d.ts +2 -2
- package/dist/board/index.d.ts.map +1 -1
- package/dist/board/index.js +1 -1
- package/dist/board/index.js.map +1 -1
- package/dist/mw_games.global.js +54 -54
- package/dist/threads/index.d.ts +3 -0
- package/dist/threads/index.d.ts.map +1 -0
- package/dist/threads/index.js +2 -0
- package/dist/threads/index.js.map +1 -0
- package/dist/threads/spawn.d.ts +41 -0
- package/dist/threads/spawn.d.ts.map +1 -0
- package/dist/threads/spawn.js +143 -0
- package/dist/threads/spawn.js.map +1 -0
- package/dist/threads/worker.d.ts +51 -0
- package/dist/threads/worker.d.ts.map +1 -0
- package/dist/threads/worker.js +84 -0
- package/dist/threads/worker.js.map +1 -0
- package/dist/version.d.ts +1 -1
- package/dist/version.js +1 -1
- package/package.json +6 -1
- package/sbom.cdx.json +54 -28
- package/tools/compress-dist.d.mts +8 -0
- 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
|
|
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"}
|
package/dist/audio/SoundFont.js
CHANGED
|
@@ -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
|
-
|
|
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
|
|
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
|
|
95
|
-
|
|
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 =
|
|
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
|
|
143
|
-
for (let index = 0; index <
|
|
144
|
-
|
|
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 <
|
|
159
|
-
presets.set(`${
|
|
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
|
|
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
|
|
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(
|
|
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 (!
|
|
231
|
+
if (!tables.instruments[instrument + 1])
|
|
180
232
|
continue;
|
|
181
|
-
for (const zone of zonesOf(
|
|
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
|
|
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
|
-
|
|
194
|
-
|
|
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
|