@datamoc/mw_games 0.40.0 → 0.42.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 (119) hide show
  1. package/CHANGELOG.md +115 -0
  2. package/REFERENCE.md +2128 -0
  3. package/api-surface.json +72 -0
  4. package/data/microgm-opt.PROVENANCE.md +3 -0
  5. package/data/soundfont-catalog.json +183 -0
  6. package/dist/ai/index.d.ts +10 -0
  7. package/dist/ai/index.d.ts.map +1 -1
  8. package/dist/ai/index.js.map +1 -1
  9. package/dist/ai/lua.d.ts +6 -0
  10. package/dist/ai/lua.d.ts.map +1 -1
  11. package/dist/ai/lua.js.map +1 -1
  12. package/dist/audio/MidiRender.d.ts +3 -1
  13. package/dist/audio/MidiRender.d.ts.map +1 -1
  14. package/dist/audio/MidiRender.js +3 -1
  15. package/dist/audio/MidiRender.js.map +1 -1
  16. package/dist/audio/SampleDecoder.d.ts +41 -0
  17. package/dist/audio/SampleDecoder.d.ts.map +1 -0
  18. package/dist/audio/SampleDecoder.js +70 -0
  19. package/dist/audio/SampleDecoder.js.map +1 -0
  20. package/dist/audio/SoundFont.d.ts +93 -1
  21. package/dist/audio/SoundFont.d.ts.map +1 -1
  22. package/dist/audio/SoundFont.js +290 -8
  23. package/dist/audio/SoundFont.js.map +1 -1
  24. package/dist/audio/index.d.ts +4 -2
  25. package/dist/audio/index.d.ts.map +1 -1
  26. package/dist/audio/index.js +2 -1
  27. package/dist/audio/index.js.map +1 -1
  28. package/dist/core/DialogueText.d.ts +25 -0
  29. package/dist/core/DialogueText.d.ts.map +1 -1
  30. package/dist/core/DialogueText.js +25 -0
  31. package/dist/core/DialogueText.js.map +1 -1
  32. package/dist/core/Motion.d.ts +20 -0
  33. package/dist/core/Motion.d.ts.map +1 -1
  34. package/dist/core/Motion.js +20 -0
  35. package/dist/core/Motion.js.map +1 -1
  36. package/dist/core/Save.d.ts +31 -0
  37. package/dist/core/Save.d.ts.map +1 -1
  38. package/dist/core/Save.js +31 -0
  39. package/dist/core/Save.js.map +1 -1
  40. package/dist/core/Twee.d.ts +34 -0
  41. package/dist/core/Twee.d.ts.map +1 -1
  42. package/dist/core/Twee.js +34 -0
  43. package/dist/core/Twee.js.map +1 -1
  44. package/dist/mw_games.global.js +11 -11
  45. package/dist/mwl/presentation.d.ts +16 -0
  46. package/dist/mwl/presentation.d.ts.map +1 -1
  47. package/dist/mwl/presentation.js.map +1 -1
  48. package/dist/mwl/pyroeis-runtime.d.ts +7 -0
  49. package/dist/mwl/pyroeis-runtime.d.ts.map +1 -1
  50. package/dist/mwl/pyroeis-runtime.js.map +1 -1
  51. package/dist/mwl/pyroeis.d.ts +3 -0
  52. package/dist/mwl/pyroeis.d.ts.map +1 -1
  53. package/dist/mwl/pyroeis.js.map +1 -1
  54. package/dist/physics/PhysicsWorld.d.ts +3 -0
  55. package/dist/physics/PhysicsWorld.d.ts.map +1 -1
  56. package/dist/physics/PhysicsWorld.js.map +1 -1
  57. package/dist/physics/XpbdWorld.d.ts +56 -0
  58. package/dist/physics/XpbdWorld.d.ts.map +1 -1
  59. package/dist/physics/XpbdWorld.js +45 -0
  60. package/dist/physics/XpbdWorld.js.map +1 -1
  61. package/dist/physics/cloth/index.d.ts +20 -19
  62. package/dist/physics/cloth/index.d.ts.map +1 -1
  63. package/dist/physics/cloth/index.js +19 -0
  64. package/dist/physics/cloth/index.js.map +1 -1
  65. package/dist/physics/spring/index.d.ts +19 -18
  66. package/dist/physics/spring/index.d.ts.map +1 -1
  67. package/dist/physics/spring/index.js +23 -0
  68. package/dist/physics/spring/index.js.map +1 -1
  69. package/dist/three-d/Character3D.d.ts +8 -7
  70. package/dist/three-d/Character3D.d.ts.map +1 -1
  71. package/dist/three-d/Character3D.js +7 -0
  72. package/dist/three-d/Character3D.js.map +1 -1
  73. package/dist/three-d/Collision3D.d.ts +4 -0
  74. package/dist/three-d/Collision3D.d.ts.map +1 -1
  75. package/dist/three-d/Collision3D.js.map +1 -1
  76. package/dist/three-d/Engine3D.d.ts +2 -0
  77. package/dist/three-d/Engine3D.d.ts.map +1 -1
  78. package/dist/three-d/Engine3D.js.map +1 -1
  79. package/dist/three-d/Grid.d.ts +5 -0
  80. package/dist/three-d/Grid.d.ts.map +1 -1
  81. package/dist/three-d/Grid.js.map +1 -1
  82. package/dist/three-d/Heightmap.d.ts +3 -0
  83. package/dist/three-d/Heightmap.d.ts.map +1 -1
  84. package/dist/three-d/Heightmap.js.map +1 -1
  85. package/dist/three-d/TileGrid3D.d.ts +6 -0
  86. package/dist/three-d/TileGrid3D.d.ts.map +1 -1
  87. package/dist/three-d/TileGrid3D.js.map +1 -1
  88. package/dist/three-d/Vox.d.ts +7 -0
  89. package/dist/three-d/Vox.d.ts.map +1 -1
  90. package/dist/three-d/Vox.js.map +1 -1
  91. package/dist/two-d/presentation/MwlPresentation.d.ts +7 -0
  92. package/dist/two-d/presentation/MwlPresentation.d.ts.map +1 -1
  93. package/dist/two-d/presentation/MwlPresentation.js.map +1 -1
  94. package/dist/two-d/ui/theme.d.ts +18 -0
  95. package/dist/two-d/ui/theme.d.ts.map +1 -1
  96. package/dist/two-d/ui/theme.js.map +1 -1
  97. package/dist/version.d.ts +1 -1
  98. package/dist/version.js +1 -1
  99. package/package.json +23 -1
  100. package/sbom.cdx.json +599 -139
  101. package/tools/benchmark-browser.mjs +6 -0
  102. package/tools/bundle-size.mjs +4 -0
  103. package/tools/compile-resources.d.mts +6 -0
  104. package/tools/compile-resources.mjs +53 -16
  105. package/tools/emit-page.d.mts +3 -0
  106. package/tools/emit-page.mjs +15 -2
  107. package/tools/init.mjs +3 -1
  108. package/tools/make-example-assets.mjs +4 -0
  109. package/tools/multiplayer-server.mjs +4 -0
  110. package/tools/mwl.mjs +7 -0
  111. package/tools/play-uci.mjs +4 -0
  112. package/tools/sbom.mjs +4 -0
  113. package/tools/soundfonts.d.mts +79 -0
  114. package/tools/soundfonts.mjs +301 -0
  115. package/tools/svg-optimise.d.mts +8 -0
  116. package/tools/svg-optimise.mjs +45 -0
  117. package/tools/verify-scenarios.mjs +6 -2
  118. package/tools/vite.mjs +2 -1
  119. package/tools.md +218 -0
@@ -0,0 +1,41 @@
1
+ /**
2
+ * Turns the Ogg Vorbis bytes of one SoundFont 3 sample into mono 16-bit frames at the
3
+ * sample's own rate, which is the rate its header states and the frame count its loop
4
+ * points were written against. Synchronous or asynchronous: a browser decodes through
5
+ * WebAudio (`webAudioSampleDecoder`), a Node tool through whatever it has installed.
6
+ */
7
+ export type SoundFontSampleDecoder = (ogg: Uint8Array, rate: number) => Int16Array | Promise<Int16Array>;
8
+ /** the slice of `OfflineAudioContext` the decoder uses, so a test can stand in for it */
9
+ export interface SampleDecodingContext {
10
+ decodeAudioData(data: ArrayBuffer): Promise<{
11
+ sampleRate: number;
12
+ length: number;
13
+ getChannelData(channel: number): Float32Array;
14
+ }>;
15
+ }
16
+ /**
17
+ * What a sample header may claim. The rate and the Ogg bytes come from a font file that may have been
18
+ * downloaded, so what they ask the decoder to allocate is bounded: the SoundFont 2 format itself
19
+ * stops at 50 kHz, and sixteen million frames is six minutes at 44.1 kHz for a single instrument sample.
20
+ */
21
+ export declare const MIN_SAMPLE_RATE = 400;
22
+ export declare const MAX_SAMPLE_RATE = 200000;
23
+ export declare const MAX_SAMPLE_FRAMES: number;
24
+ /**
25
+ * A decoder over WebAudio's `decodeAudioData`, for a browser: each sample decodes in a context
26
+ * created at the sample's own rate, so the browser does not resample it and the loop points keep
27
+ * meaning what the font wrote. A rate outside what a context accepts (8 to 96 kHz) decodes at
28
+ * the nearest accepted rate and is resampled back. `create` replaces the context factory.
29
+ *
30
+ * @example
31
+ * ```ts
32
+ * import { loadSoundFont, webAudioSampleDecoder } from '@datamoc/mw_games/audio';
33
+ *
34
+ * declare const sf3Bytes: ArrayBuffer;
35
+ *
36
+ * const font = await loadSoundFont(sf3Bytes, { decode: webAudioSampleDecoder() });
37
+ * console.log(font.presetCount);
38
+ * ```
39
+ */
40
+ export declare function webAudioSampleDecoder(create?: (rate: number) => SampleDecodingContext): SoundFontSampleDecoder;
41
+ //# sourceMappingURL=SampleDecoder.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"SampleDecoder.d.ts","sourceRoot":"","sources":["../../src/audio/SampleDecoder.ts"],"names":[],"mappings":"AAAA;;;;;GAKG;AACH,MAAM,MAAM,sBAAsB,GAAG,CAAC,GAAG,EAAE,UAAU,EAAE,IAAI,EAAE,MAAM,KAAK,UAAU,GAAG,OAAO,CAAC,UAAU,CAAC,CAAC;AAEzG,yFAAyF;AACzF,MAAM,WAAW,qBAAqB;IACrC,eAAe,CAAC,IAAI,EAAE,WAAW,GAAG,OAAO,CAAC;QAC3C,UAAU,EAAE,MAAM,CAAC;QACnB,MAAM,EAAE,MAAM,CAAC;QACf,cAAc,CAAC,OAAO,EAAE,MAAM,GAAG,YAAY,CAAC;KAC9C,CAAC,CAAC;CACH;AAMD;;;;GAIG;AACH,eAAO,MAAM,eAAe,MAAM,CAAC;AACnC,eAAO,MAAM,eAAe,SAAS,CAAC;AACtC,eAAO,MAAM,iBAAiB,QAAU,CAAC;AA+BzC;;;;;;;;;;;;;;;GAeG;AACH,wBAAgB,qBAAqB,CACpC,MAAM,GAAE,CAAC,IAAI,EAAE,MAAM,KAAK,qBAAsC,GAC9D,sBAAsB,CAcxB"}
@@ -0,0 +1,70 @@
1
+ /** the narrowest rate range every engine's `OfflineAudioContext` accepts */
2
+ const MIN_RATE = 8000;
3
+ const MAX_RATE = 96000;
4
+ /**
5
+ * What a sample header may claim. The rate and the Ogg bytes come from a font file that may have been
6
+ * downloaded, so what they ask the decoder to allocate is bounded: the SoundFont 2 format itself
7
+ * stops at 50 kHz, and sixteen million frames is six minutes at 44.1 kHz for a single instrument sample.
8
+ */
9
+ export const MIN_SAMPLE_RATE = 400;
10
+ export const MAX_SAMPLE_RATE = 200000;
11
+ export const MAX_SAMPLE_FRAMES = 1 << 24;
12
+ function toInt16(frames) {
13
+ const out = new Int16Array(frames.length);
14
+ for (let index = 0; index < frames.length; index++) {
15
+ out[index] = Math.round(Math.max(-1, Math.min(1, frames[index])) * 32767);
16
+ }
17
+ return out;
18
+ }
19
+ /** linear resampling, only for the rare sample whose rate a context cannot be created at */
20
+ function resample(frames, from, to) {
21
+ const length = Math.max(1, Math.round((frames.length * to) / from));
22
+ if (length > MAX_SAMPLE_FRAMES)
23
+ throw new Error('a decoded sample is longer than a SoundFont sample may be');
24
+ const out = new Float32Array(length);
25
+ for (let index = 0; index < out.length; index++) {
26
+ const at = (index * from) / to;
27
+ const base = Math.floor(at);
28
+ const next = Math.min(base + 1, frames.length - 1);
29
+ out[index] = frames[base] + (frames[next] - frames[base]) * (at - base);
30
+ }
31
+ return out;
32
+ }
33
+ function offlineContext(rate) {
34
+ if (typeof OfflineAudioContext === 'undefined') {
35
+ throw new Error('no OfflineAudioContext here: pass a decoder of your own to decode a SoundFont 3');
36
+ }
37
+ return new OfflineAudioContext(1, 1, rate);
38
+ }
39
+ /**
40
+ * A decoder over WebAudio's `decodeAudioData`, for a browser: each sample decodes in a context
41
+ * created at the sample's own rate, so the browser does not resample it and the loop points keep
42
+ * meaning what the font wrote. A rate outside what a context accepts (8 to 96 kHz) decodes at
43
+ * the nearest accepted rate and is resampled back. `create` replaces the context factory.
44
+ *
45
+ * @example
46
+ * ```ts
47
+ * import { loadSoundFont, webAudioSampleDecoder } from '@datamoc/mw_games/audio';
48
+ *
49
+ * declare const sf3Bytes: ArrayBuffer;
50
+ *
51
+ * const font = await loadSoundFont(sf3Bytes, { decode: webAudioSampleDecoder() });
52
+ * console.log(font.presetCount);
53
+ * ```
54
+ */
55
+ export function webAudioSampleDecoder(create = offlineContext) {
56
+ return async (ogg, rate) => {
57
+ if (!(rate >= MIN_SAMPLE_RATE && rate <= MAX_SAMPLE_RATE)) {
58
+ throw new Error(`a sample rate of ${rate} Hz is not one a SoundFont can state`);
59
+ }
60
+ const contextRate = Math.max(MIN_RATE, Math.min(MAX_RATE, rate));
61
+ //decodeAudioData detaches what it is given, and `ogg` is a view into the caller's font
62
+ const copy = ogg.slice().buffer;
63
+ const decoded = await create(contextRate).decodeAudioData(copy);
64
+ if (decoded.length > MAX_SAMPLE_FRAMES)
65
+ throw new Error('a decoded sample is longer than a SoundFont sample may be');
66
+ const channel = decoded.getChannelData(0);
67
+ return toInt16(decoded.sampleRate === rate ? channel : resample(channel, decoded.sampleRate, rate));
68
+ };
69
+ }
70
+ //# sourceMappingURL=SampleDecoder.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"SampleDecoder.js","sourceRoot":"","sources":["../../src/audio/SampleDecoder.ts"],"names":[],"mappings":"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"}
@@ -1,4 +1,5 @@
1
1
  import { type MidiFile } from './Midi.ts';
2
+ import type { SoundFontSampleDecoder } from './SampleDecoder.ts';
2
3
  /** one mono sample: raw frames plus its loop region in frames and native rate */
3
4
  export interface SoundFontSample {
4
5
  data: Float32Array;
@@ -39,6 +40,18 @@ export interface SoundFontProgram {
39
40
  bank: number;
40
41
  program: number;
41
42
  }
43
+ /**
44
+ * What plays for a (bank, program) the font does not carry. A bank missing the program falls back to
45
+ * bank 0 and a missing drum kit to the standard kit (never to a melodic preset) whatever the policy; past that, `'piano'` (the
46
+ * default, how most players behave) voices the font's own preset 0:0, and `'synth'` voices nothing, so
47
+ * the renderer's built-in synthesizer plays the instrument instead. A game with a trimmed bank picks
48
+ * `'synth'` to hear the synthesizer's timbre for an instrument it did not ship rather than a piano line.
49
+ */
50
+ export type MissingProgram = 'piano' | 'synth';
51
+ export interface SoundFontOptions {
52
+ /** what plays for a program the font lacks; `'piano'` when omitted */
53
+ missingProgram?: MissingProgram;
54
+ }
42
55
  /**
43
56
  * Reads SoundFont 2 bytes into layered preset voices.
44
57
  *
@@ -54,7 +67,7 @@ export interface SoundFontProgram {
54
67
  * console.log(font.presetCount, rendered.duration);
55
68
  * ```
56
69
  */
57
- export declare function parseSoundFont(data: ArrayBuffer | ArrayBufferView): SoundFont;
70
+ export declare function parseSoundFont(data: ArrayBuffer | ArrayBufferView, options?: SoundFontOptions): SoundFont;
58
71
  /**
59
72
  * Every sampled voice the given MIDI files can request, as (bank, program) pairs:
60
73
  * the channel voice in effect at each scheduled note, with drums mapped to bank 128
@@ -72,6 +85,85 @@ export declare function parseSoundFont(data: ArrayBuffer | ArrayBufferView): Sou
72
85
  * ```
73
86
  */
74
87
  export declare function collectSoundFontUsage(files: readonly MidiFile[]): SoundFontProgram[];
88
+ /**
89
+ * The programs of `usage` the font does not carry, so a game can tell at build time which of the
90
+ * instruments its MIDI files ask for would be missing. A bank without the program counts as carried
91
+ * when bank 0 has it (the fallback every player makes), a drum kit only when the font has that exact
92
+ * kit. What plays for a missing one is the `missingProgram` policy of `parseSoundFont`. Works on a
93
+ * SoundFont 2 or 3.
94
+ *
95
+ * @example
96
+ * ```ts
97
+ * import { missingSoundFontPrograms, parseMidi, collectSoundFontUsage } from '@datamoc/mw_games/audio';
98
+ *
99
+ * declare const soundfontBytes: ArrayBuffer;
100
+ * declare const midiBytes: ArrayBuffer;
101
+ *
102
+ * const usage = collectSoundFontUsage([parseMidi(midiBytes)]);
103
+ * for (const { bank, program } of missingSoundFontPrograms(soundfontBytes, usage)) {
104
+ * console.log(`bank ${bank} program ${program} is not in the font`);
105
+ * }
106
+ * ```
107
+ */
108
+ export declare function missingSoundFontPrograms(data: ArrayBuffer | ArrayBufferView, usage: readonly SoundFontProgram[]): SoundFontProgram[];
109
+ /**
110
+ * Joins several SoundFont 2 files into one, presets in the order given. When two files
111
+ * carry the same (bank, program) the first wins and the other's preset, instruments
112
+ * and samples are dropped, so the result never holds a voice nothing can reach. This is
113
+ * how single-instrument files (a per-program font collection) become the one file a game
114
+ * ships; `subsetSoundFont` then trims it like any other.
115
+ *
116
+ * @example
117
+ * ```ts
118
+ * import { mergeSoundFonts, parseSoundFont } from '@datamoc/mw_games/audio';
119
+ *
120
+ * declare const pianoBytes: ArrayBuffer;
121
+ * declare const drumsBytes: ArrayBuffer;
122
+ *
123
+ * const merged = mergeSoundFonts([pianoBytes, drumsBytes]);
124
+ * console.log(parseSoundFont(merged).presetCount);
125
+ * ```
126
+ */
127
+ export declare function mergeSoundFonts(fonts: readonly (ArrayBuffer | ArrayBufferView)[]): ArrayBuffer;
128
+ /**
129
+ * Decodes a SoundFont 3 into a plain SoundFont 2: every Ogg Vorbis sample a preset reaches goes
130
+ * through `decode` (one call per sample, mono 16-bit frames at the rate the header states) into
131
+ * the sample chunk, and the loop points a SoundFont 3 states relative to its sample become the
132
+ * absolute ones SoundFont 2 uses. A SoundFont 2 comes back unchanged and `decode` is never called.
133
+ * The result feeds everything that takes SoundFont 2 bytes, `renderMidiToBufferAsync` included.
134
+ * A loop that ends past the decoded frames is cut to them.
135
+ *
136
+ * @example
137
+ * ```ts
138
+ * import { decodeSoundFont, webAudioSampleDecoder } from '@datamoc/mw_games/audio';
139
+ *
140
+ * declare const sf3Bytes: ArrayBuffer;
141
+ *
142
+ * const sf2 = await decodeSoundFont(sf3Bytes, webAudioSampleDecoder());
143
+ * console.log(sf2.byteLength);
144
+ * ```
145
+ */
146
+ export declare function decodeSoundFont(data: ArrayBuffer | ArrayBufferView, decode: SoundFontSampleDecoder): Promise<ArrayBuffer>;
147
+ /**
148
+ * Reads a SoundFont 2 or 3 into layered preset voices: `parseSoundFont` for a plain font, and a
149
+ * SoundFont 3 decoded first through `decode`, `webAudioSampleDecoder()` (the browser) unless one
150
+ * is given. This is what a game calls on the bytes it ships, whichever kind they are.
151
+ *
152
+ * @example
153
+ * ```ts
154
+ * import { loadSoundFont, parseMidi, renderMidiToBuffer } from '@datamoc/mw_games/audio';
155
+ *
156
+ * declare const fontBytes: ArrayBuffer;
157
+ * declare const midiBytes: ArrayBuffer;
158
+ *
159
+ * const font = await loadSoundFont(fontBytes);
160
+ * const rendered = renderMidiToBuffer(parseMidi(midiBytes), { soundfont: font });
161
+ * console.log(rendered.duration);
162
+ * ```
163
+ */
164
+ export declare function loadSoundFont(data: ArrayBuffer | ArrayBufferView, options?: SoundFontOptions & {
165
+ decode?: SoundFontSampleDecoder;
166
+ }): Promise<SoundFont>;
75
167
  /**
76
168
  * Writes a smaller, valid SoundFont 2 file carrying only the used programs: their
77
169
  * presets (names kept), instruments and samples, with zones, ranges, tuning,
@@ -1 +1 @@
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
+ {"version":3,"file":"SoundFont.d.ts","sourceRoot":"","sources":["../../src/audio/SoundFont.ts"],"names":[],"mappings":"AAAA,OAAO,EAAgB,KAAK,QAAQ,EAAE,MAAM,WAAW,CAAC;AAExD,OAAO,KAAK,EAAE,sBAAsB,EAAE,MAAM,oBAAoB,CAAC;AAwCjE,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;AA8MD;;;;;;GAMG;AACH,MAAM,MAAM,cAAc,GAAG,OAAO,GAAG,OAAO,CAAC;AAE/C,MAAM,WAAW,gBAAgB;IAChC,sEAAsE;IACtE,cAAc,CAAC,EAAE,cAAc,CAAC;CAChC;AAeD;;;;;;;;;;;;;;GAcG;AACH,wBAAgB,cAAc,CAAC,IAAI,EAAE,WAAW,GAAG,eAAe,EAAE,OAAO,GAAE,gBAAqB,GAAG,SAAS,CAuG7G;AAED;;;;;;;;;;;;;;;GAeG;AACH,wBAAgB,qBAAqB,CAAC,KAAK,EAAE,SAAS,QAAQ,EAAE,GAAG,gBAAgB,EAAE,CAcpF;AAED;;;;;;;;;;;;;;;;;;;GAmBG;AACH,wBAAgB,wBAAwB,CACvC,IAAI,EAAE,WAAW,GAAG,eAAe,EACnC,KAAK,EAAE,SAAS,gBAAgB,EAAE,GAChC,gBAAgB,EAAE,CAgBpB;AA6CD;;;;;;;;;;;;;;;;;GAiBG;AACH,wBAAgB,eAAe,CAAC,KAAK,EAAE,SAAS,CAAC,WAAW,GAAG,eAAe,CAAC,EAAE,GAAG,WAAW,CA+F9F;AAkBD;;;;;;;;;;;;;;;;;GAiBG;AACH,wBAAsB,eAAe,CACpC,IAAI,EAAE,WAAW,GAAG,eAAe,EACnC,MAAM,EAAE,sBAAsB,GAC5B,OAAO,CAAC,WAAW,CAAC,CAyDtB;AAED;;;;;;;;;;;;;;;;GAgBG;AACH,wBAAsB,aAAa,CAClC,IAAI,EAAE,WAAW,GAAG,eAAe,EACnC,OAAO,GAAE,gBAAgB,GAAG;IAAE,MAAM,CAAC,EAAE,sBAAsB,CAAA;CAAO,GAClE,OAAO,CAAC,SAAS,CAAC,CAEpB;AAED;;;;;;;;;;;;;;;;;;;;;GAqBG;AACH,wBAAgB,eAAe,CAAC,IAAI,EAAE,WAAW,GAAG,eAAe,EAAE,KAAK,EAAE,SAAS,gBAAgB,EAAE,GAAG,WAAW,CAmBpH"}
@@ -1,4 +1,5 @@
1
1
  import { scheduleMidi } from './Midi.js';
2
+ import { MAX_SAMPLE_FRAMES, MAX_SAMPLE_RATE, MIN_SAMPLE_RATE, webAudioSampleDecoder } from './SampleDecoder.js';
2
3
  /**
3
4
  * A SoundFont 2 reader: presets to instruments to sample zones, enough to play
4
5
  * General MIDI music with real samples. Supported: key and velocity ranges, root key
@@ -117,7 +118,19 @@ function readTables(bytes) {
117
118
  type: view.getUint16(at + 44, true),
118
119
  }));
119
120
  const smplChunk = chunks.get('smpl');
120
- const smpl = new Int16Array(bytes.buffer.slice(bytes.byteOffset + smplChunk.at, bytes.byteOffset + smplChunk.at + smplChunk.size - (smplChunk.size & 1)));
121
+ const smplBytes = bytes.slice(smplChunk.at, smplChunk.at + smplChunk.size);
122
+ //ROM and empty samples have no data here; the rest must agree on what their offsets count
123
+ const sampled = samples.filter((sample) => sample.end > sample.start && !(sample.type & 0x8000));
124
+ const compressed = sampled.some((sample) => sample.type & 0x10);
125
+ if (compressed && !sampled.every((sample) => sample.type & 0x10)) {
126
+ throw new Error('SoundFont mixes compressed and plain samples, which it cannot address consistently');
127
+ }
128
+ //the offsets come from the file, so a lying header must not reach beyond the sample data
129
+ const limit = compressed ? smplBytes.length : smplBytes.length >> 1;
130
+ if (sampled.some((sample) => sample.end > limit)) {
131
+ throw new Error('a sample points outside the sample data: the SoundFont is damaged');
132
+ }
133
+ const smpl = compressed ? new Int16Array(0) : new Int16Array(smplBytes.buffer, 0, smplBytes.length >> 1);
121
134
  const info = lists.get('INFO') ?? null;
122
135
  return {
123
136
  bytes,
@@ -128,7 +141,9 @@ function readTables(bytes) {
128
141
  instBags: bags('ibag'),
129
142
  instGens: generators('igen'),
130
143
  samples,
144
+ smplBytes,
131
145
  smpl,
146
+ compressed,
132
147
  info: info ? bytes.slice(info.at, info.at + 8 + info.size + (info.size & 1)) : null,
133
148
  };
134
149
  }
@@ -159,11 +174,10 @@ function zonesOf(headers, bagList, gens, index, terminal) {
159
174
  function generatorOf(zone, oper) {
160
175
  return zone.gens.get(oper) ?? zone.global?.get(oper) ?? null;
161
176
  }
162
- function presetIndexOf(presets, bank, program) {
177
+ function presetIndexOf(presets, bank, program, missing = 'piano') {
163
178
  return (presets.get(`${bank}:${program}`) ??
164
- presets.get(`0:${program}`) ??
165
- (bank === 128 ? presets.get('128:0') : undefined) ??
166
- presets.get('0:0'));
179
+ (bank === 128 ? presets.get('128:0') : presets.get(`0:${program}`)) ??
180
+ (missing === 'piano' ? presets.get('0:0') : undefined));
167
181
  }
168
182
  /**
169
183
  * Reads SoundFont 2 bytes into layered preset voices.
@@ -180,8 +194,11 @@ function presetIndexOf(presets, bank, program) {
180
194
  * console.log(font.presetCount, rendered.duration);
181
195
  * ```
182
196
  */
183
- export function parseSoundFont(data) {
197
+ export function parseSoundFont(data, options = {}) {
184
198
  const tables = readTables(toBytes(data));
199
+ if (tables.compressed) {
200
+ throw new Error('SoundFont 3 (Ogg Vorbis samples) needs decoding first: use loadSoundFont() or decodeSoundFont(), or convert it with "mwg-soundfonts convert <in.sf3> <out.sf2>"');
201
+ }
185
202
  const sampleCache = new Map();
186
203
  const tuning = new Map();
187
204
  const sampleOf = (id) => {
@@ -215,7 +232,7 @@ export function parseSoundFont(data) {
215
232
  }
216
233
  //Zones that sound for (bank, program, key, velocity), each with the resolved playback recipe.
217
234
  function voices(bank, program, key, velocity) {
218
- const presetIndex = presetIndexOf(presets, bank, program);
235
+ const presetIndex = presetIndexOf(presets, bank, program, options.missingProgram);
219
236
  if (presetIndex === undefined)
220
237
  return [];
221
238
  const out = [];
@@ -296,6 +313,43 @@ export function collectSoundFontUsage(files) {
296
313
  }
297
314
  return out;
298
315
  }
316
+ /**
317
+ * The programs of `usage` the font does not carry, so a game can tell at build time which of the
318
+ * instruments its MIDI files ask for would be missing. A bank without the program counts as carried
319
+ * when bank 0 has it (the fallback every player makes), a drum kit only when the font has that exact
320
+ * kit. What plays for a missing one is the `missingProgram` policy of `parseSoundFont`. Works on a
321
+ * SoundFont 2 or 3.
322
+ *
323
+ * @example
324
+ * ```ts
325
+ * import { missingSoundFontPrograms, parseMidi, collectSoundFontUsage } from '@datamoc/mw_games/audio';
326
+ *
327
+ * declare const soundfontBytes: ArrayBuffer;
328
+ * declare const midiBytes: ArrayBuffer;
329
+ *
330
+ * const usage = collectSoundFontUsage([parseMidi(midiBytes)]);
331
+ * for (const { bank, program } of missingSoundFontPrograms(soundfontBytes, usage)) {
332
+ * console.log(`bank ${bank} program ${program} is not in the font`);
333
+ * }
334
+ * ```
335
+ */
336
+ export function missingSoundFontPrograms(data, usage) {
337
+ const carried = new Set(readTables(toBytes(data))
338
+ .presets.slice(0, -1)
339
+ .map((preset) => `${preset.bank}:${preset.preset}`));
340
+ const seen = new Set();
341
+ const missing = [];
342
+ for (const { bank, program } of usage) {
343
+ const key = `${bank}:${program}`;
344
+ if (seen.has(key))
345
+ continue;
346
+ seen.add(key);
347
+ if (carried.has(key) || (bank !== 128 && carried.has(`0:${program}`)))
348
+ continue;
349
+ missing.push({ bank, program });
350
+ }
351
+ return missing;
352
+ }
299
353
  function u16le(value) {
300
354
  return [value & 0xff, (value >> 8) & 0xff];
301
355
  }
@@ -333,6 +387,217 @@ function listBytes(type, subs) {
333
387
  const body = concat([new Uint8Array(asciiBytes(type)), ...subs]);
334
388
  return concat([new Uint8Array(asciiBytes('LIST')), new Uint8Array(u32le(body.length)), body]);
335
389
  }
390
+ /**
391
+ * Joins several SoundFont 2 files into one, presets in the order given. When two files
392
+ * carry the same (bank, program) the first wins and the other's preset, instruments
393
+ * and samples are dropped, so the result never holds a voice nothing can reach. This is
394
+ * how single-instrument files (a per-program font collection) become the one file a game
395
+ * ships; `subsetSoundFont` then trims it like any other.
396
+ *
397
+ * @example
398
+ * ```ts
399
+ * import { mergeSoundFonts, parseSoundFont } from '@datamoc/mw_games/audio';
400
+ *
401
+ * declare const pianoBytes: ArrayBuffer;
402
+ * declare const drumsBytes: ArrayBuffer;
403
+ *
404
+ * const merged = mergeSoundFonts([pianoBytes, drumsBytes]);
405
+ * console.log(parseSoundFont(merged).presetCount);
406
+ * ```
407
+ */
408
+ export function mergeSoundFonts(fonts) {
409
+ if (fonts.length === 0)
410
+ throw new Error('mergeSoundFonts needs at least one SoundFont');
411
+ const sources = fonts.map((font) => readTables(toBytes(font)));
412
+ const compressed = sources[0].compressed;
413
+ if (sources.some((source) => source.compressed !== compressed)) {
414
+ throw new Error('cannot merge a SoundFont 3 with a SoundFont 2: decode it first with decodeSoundFont()');
415
+ }
416
+ const merged = {
417
+ bytes: new Uint8Array(0),
418
+ presets: [],
419
+ presetBags: [],
420
+ presetGens: [],
421
+ instruments: [],
422
+ instBags: [],
423
+ instGens: [],
424
+ samples: [],
425
+ smplBytes: new Uint8Array(0),
426
+ smpl: new Int16Array(0),
427
+ compressed,
428
+ info: sources[0].info,
429
+ };
430
+ const parts = [];
431
+ let smplLength = 0; //bytes so far
432
+ for (const source of sources) {
433
+ const instrumentBase = merged.instruments.length;
434
+ const sampleBase = merged.samples.length;
435
+ const presetBagBase = merged.presetBags.length;
436
+ const presetGenBase = merged.presetGens.length;
437
+ const instBagBase = merged.instBags.length;
438
+ const instGenBase = merged.instGens.length;
439
+ //a plain font's sample offsets count frames, a SoundFont 3's count bytes (and its loops stay relative)
440
+ const offsetBase = compressed ? smplLength : smplLength >> 1;
441
+ //each table ends in a terminal record that only the joined result keeps
442
+ merged.presets.push(...source.presets.slice(0, -1).map((preset) => ({ ...preset, bag: preset.bag + presetBagBase })));
443
+ merged.presetBags.push(...source.presetBags.slice(0, -1).map((gen) => gen + presetGenBase));
444
+ merged.presetGens.push(...source.presetGens
445
+ .slice(0, source.presetBags[source.presetBags.length - 1])
446
+ .map((generator) => generator.oper === GENERATORS.instrument
447
+ ? { ...generator, amount: generator.amount + instrumentBase }
448
+ : generator));
449
+ merged.instruments.push(...source.instruments
450
+ .slice(0, -1)
451
+ .map((instrument) => ({ ...instrument, bag: instrument.bag + instBagBase })));
452
+ merged.instBags.push(...source.instBags.slice(0, -1).map((gen) => gen + instGenBase));
453
+ merged.instGens.push(...source.instGens
454
+ .slice(0, source.instBags[source.instBags.length - 1])
455
+ .map((generator) => generator.oper === GENERATORS.sampleId
456
+ ? { ...generator, amount: generator.amount + sampleBase }
457
+ : generator));
458
+ merged.samples.push(...source.samples.slice(0, -1).map((sample) => ({
459
+ ...sample,
460
+ start: sample.start + offsetBase,
461
+ end: sample.end + offsetBase,
462
+ loopStart: compressed ? sample.loopStart : sample.loopStart + offsetBase,
463
+ loopEnd: compressed ? sample.loopEnd : sample.loopEnd + offsetBase,
464
+ link: sample.link + sampleBase,
465
+ })));
466
+ //a plain font's chunk can end on an odd byte, which would shift every frame after it
467
+ const part = compressed ? source.smplBytes : source.smplBytes.subarray(0, source.smplBytes.length & ~1);
468
+ parts.push(part);
469
+ smplLength += part.length;
470
+ }
471
+ merged.presetBags.push(merged.presetGens.length);
472
+ merged.instBags.push(merged.instGens.length);
473
+ merged.presets.push({ name: 'EOS', preset: 0, bank: 0, bag: merged.presetBags.length - 1 });
474
+ merged.instruments.push({ name: 'EOS', bag: merged.instBags.length - 1 });
475
+ merged.samples.push({
476
+ name: 'EOS',
477
+ start: 0,
478
+ end: 0,
479
+ loopStart: 0,
480
+ loopEnd: 0,
481
+ rate: 0,
482
+ rootKey: 0,
483
+ correction: 0,
484
+ link: 0,
485
+ type: 0,
486
+ });
487
+ merged.smplBytes = concat(parts);
488
+ if (!compressed)
489
+ merged.smpl = new Int16Array(merged.smplBytes.slice().buffer);
490
+ return writeSoundFont(merged, distinctPresets(merged));
491
+ }
492
+ /** the presets of `tables` (indices) with each (bank, program) once, the first occurrence winning */
493
+ function distinctPresets(tables) {
494
+ const seen = new Set();
495
+ const out = [];
496
+ for (let index = 0; index < tables.presets.length - 1; index++) {
497
+ const key = `${tables.presets[index].bank}:${tables.presets[index].preset}`;
498
+ if (seen.has(key))
499
+ continue;
500
+ seen.add(key);
501
+ out.push(index);
502
+ }
503
+ return out;
504
+ }
505
+ /** decoded frames one font may hold in all: 256 MB of 16-bit audio */
506
+ const MAX_DECODED_FRAMES = 1 << 27;
507
+ /**
508
+ * Decodes a SoundFont 3 into a plain SoundFont 2: every Ogg Vorbis sample a preset reaches goes
509
+ * through `decode` (one call per sample, mono 16-bit frames at the rate the header states) into
510
+ * the sample chunk, and the loop points a SoundFont 3 states relative to its sample become the
511
+ * absolute ones SoundFont 2 uses. A SoundFont 2 comes back unchanged and `decode` is never called.
512
+ * The result feeds everything that takes SoundFont 2 bytes, `renderMidiToBufferAsync` included.
513
+ * A loop that ends past the decoded frames is cut to them.
514
+ *
515
+ * @example
516
+ * ```ts
517
+ * import { decodeSoundFont, webAudioSampleDecoder } from '@datamoc/mw_games/audio';
518
+ *
519
+ * declare const sf3Bytes: ArrayBuffer;
520
+ *
521
+ * const sf2 = await decodeSoundFont(sf3Bytes, webAudioSampleDecoder());
522
+ * console.log(sf2.byteLength);
523
+ * ```
524
+ */
525
+ export async function decodeSoundFont(data, decode) {
526
+ const bytes = toBytes(data);
527
+ const tables = readTables(bytes);
528
+ if (!tables.compressed)
529
+ return bytes.slice().buffer;
530
+ //prune first, so a sample no preset reaches is never decoded
531
+ const reachable = readTables(new Uint8Array(writeSoundFont(tables, distinctPresets(tables))));
532
+ const frames = [];
533
+ const placed = new Map();
534
+ let total = 0;
535
+ const samples = [];
536
+ for (const sample of reachable.samples) {
537
+ if (!(sample.end > sample.start) || sample.type & 0x8000) {
538
+ samples.push(sample);
539
+ continue;
540
+ }
541
+ const key = `${sample.start}:${sample.end}`;
542
+ let at = placed.get(key);
543
+ if (!at) {
544
+ if (!(sample.rate >= MIN_SAMPLE_RATE && sample.rate <= MAX_SAMPLE_RATE)) {
545
+ throw new Error(`a sample states ${sample.rate} Hz, which no SoundFont does: the font is damaged`);
546
+ }
547
+ const pcm = await decode(reachable.smplBytes.subarray(sample.start, sample.end), sample.rate);
548
+ //what a decoder returns is bounded too: a few bytes of Ogg can name a very long sound
549
+ if (pcm.length > MAX_SAMPLE_FRAMES || total + pcm.length > MAX_DECODED_FRAMES) {
550
+ throw new Error('the decoded sample data is larger than a SoundFont may hold: subset the font before shipping it');
551
+ }
552
+ at = { start: total, length: pcm.length };
553
+ placed.set(key, at);
554
+ frames.push(pcm);
555
+ total += pcm.length;
556
+ }
557
+ samples.push({
558
+ ...sample,
559
+ start: at.start,
560
+ end: at.start + at.length,
561
+ loopStart: at.start + Math.min(sample.loopStart, at.length),
562
+ loopEnd: at.start + Math.min(sample.loopEnd, at.length),
563
+ type: sample.type & ~0x10,
564
+ });
565
+ }
566
+ const smpl = new Int16Array(total);
567
+ let offset = 0;
568
+ for (const pcm of frames) {
569
+ smpl.set(pcm, offset);
570
+ offset += pcm.length;
571
+ }
572
+ const plain = {
573
+ ...reachable,
574
+ samples,
575
+ smpl,
576
+ smplBytes: new Uint8Array(smpl.buffer),
577
+ compressed: false,
578
+ };
579
+ return writeSoundFont(plain, distinctPresets(plain));
580
+ }
581
+ /**
582
+ * Reads a SoundFont 2 or 3 into layered preset voices: `parseSoundFont` for a plain font, and a
583
+ * SoundFont 3 decoded first through `decode`, `webAudioSampleDecoder()` (the browser) unless one
584
+ * is given. This is what a game calls on the bytes it ships, whichever kind they are.
585
+ *
586
+ * @example
587
+ * ```ts
588
+ * import { loadSoundFont, parseMidi, renderMidiToBuffer } from '@datamoc/mw_games/audio';
589
+ *
590
+ * declare const fontBytes: ArrayBuffer;
591
+ * declare const midiBytes: ArrayBuffer;
592
+ *
593
+ * const font = await loadSoundFont(fontBytes);
594
+ * const rendered = renderMidiToBuffer(parseMidi(midiBytes), { soundfont: font });
595
+ * console.log(rendered.duration);
596
+ * ```
597
+ */
598
+ export async function loadSoundFont(data, options = {}) {
599
+ return parseSoundFont(await decodeSoundFont(data, options.decode ?? webAudioSampleDecoder()), options);
600
+ }
336
601
  /**
337
602
  * Writes a smaller, valid SoundFont 2 file carrying only the used programs: their
338
603
  * presets (names kept), instruments and samples, with zones, ranges, tuning,
@@ -372,6 +637,10 @@ export function subsetSoundFont(data, usage) {
372
637
  }
373
638
  }
374
639
  usedPresets.sort((a, b) => a - b);
640
+ return writeSoundFont(tables, usedPresets);
641
+ }
642
+ /** writes the presets of `tables` listed in `usedPresets` (indices) with everything they reach, remapped */
643
+ function writeSoundFont(tables, usedPresets) {
375
644
  const usableSample = (id) => {
376
645
  const header = tables.samples[id];
377
646
  return !!header && header.end > header.start && !(header.type & 0x8000);
@@ -496,12 +765,25 @@ export function subsetSoundFont(data, usage) {
496
765
  const sampleData = [];
497
766
  for (const old of sampleOrder) {
498
767
  const header = tables.samples[old];
768
+ const self = sampleNew.get(old);
769
+ if (tables.compressed) {
770
+ //Ogg bytes copied as they are: offsets count bytes and the loop points stay relative to the sample
771
+ const start = sampleData.length;
772
+ for (let index = header.start; index < header.end; index++)
773
+ sampleData.push(tables.smplBytes[index]);
774
+ newSamples.push({
775
+ ...header,
776
+ start,
777
+ end: start + (header.end - header.start),
778
+ link: sampleNew.get(header.link) ?? self,
779
+ });
780
+ continue;
781
+ }
499
782
  const start = sampleData.length / 2;
500
783
  for (let index = header.start; index < header.end; index++) {
501
784
  const word = tables.smpl[index];
502
785
  sampleData.push(word & 0xff, (word >> 8) & 0xff);
503
786
  }
504
- const self = sampleNew.get(old);
505
787
  newSamples.push({
506
788
  ...header,
507
789
  start,