@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.
- package/CHANGELOG.md +115 -0
- package/REFERENCE.md +2128 -0
- package/api-surface.json +72 -0
- package/data/microgm-opt.PROVENANCE.md +3 -0
- package/data/soundfont-catalog.json +183 -0
- package/dist/ai/index.d.ts +10 -0
- package/dist/ai/index.d.ts.map +1 -1
- package/dist/ai/index.js.map +1 -1
- package/dist/ai/lua.d.ts +6 -0
- package/dist/ai/lua.d.ts.map +1 -1
- package/dist/ai/lua.js.map +1 -1
- package/dist/audio/MidiRender.d.ts +3 -1
- package/dist/audio/MidiRender.d.ts.map +1 -1
- package/dist/audio/MidiRender.js +3 -1
- package/dist/audio/MidiRender.js.map +1 -1
- package/dist/audio/SampleDecoder.d.ts +41 -0
- package/dist/audio/SampleDecoder.d.ts.map +1 -0
- package/dist/audio/SampleDecoder.js +70 -0
- package/dist/audio/SampleDecoder.js.map +1 -0
- package/dist/audio/SoundFont.d.ts +93 -1
- package/dist/audio/SoundFont.d.ts.map +1 -1
- package/dist/audio/SoundFont.js +290 -8
- package/dist/audio/SoundFont.js.map +1 -1
- package/dist/audio/index.d.ts +4 -2
- package/dist/audio/index.d.ts.map +1 -1
- package/dist/audio/index.js +2 -1
- package/dist/audio/index.js.map +1 -1
- package/dist/core/DialogueText.d.ts +25 -0
- package/dist/core/DialogueText.d.ts.map +1 -1
- package/dist/core/DialogueText.js +25 -0
- package/dist/core/DialogueText.js.map +1 -1
- package/dist/core/Motion.d.ts +20 -0
- package/dist/core/Motion.d.ts.map +1 -1
- package/dist/core/Motion.js +20 -0
- package/dist/core/Motion.js.map +1 -1
- package/dist/core/Save.d.ts +31 -0
- package/dist/core/Save.d.ts.map +1 -1
- package/dist/core/Save.js +31 -0
- package/dist/core/Save.js.map +1 -1
- package/dist/core/Twee.d.ts +34 -0
- package/dist/core/Twee.d.ts.map +1 -1
- package/dist/core/Twee.js +34 -0
- package/dist/core/Twee.js.map +1 -1
- package/dist/mw_games.global.js +11 -11
- package/dist/mwl/presentation.d.ts +16 -0
- package/dist/mwl/presentation.d.ts.map +1 -1
- package/dist/mwl/presentation.js.map +1 -1
- package/dist/mwl/pyroeis-runtime.d.ts +7 -0
- package/dist/mwl/pyroeis-runtime.d.ts.map +1 -1
- package/dist/mwl/pyroeis-runtime.js.map +1 -1
- package/dist/mwl/pyroeis.d.ts +3 -0
- package/dist/mwl/pyroeis.d.ts.map +1 -1
- package/dist/mwl/pyroeis.js.map +1 -1
- package/dist/physics/PhysicsWorld.d.ts +3 -0
- package/dist/physics/PhysicsWorld.d.ts.map +1 -1
- package/dist/physics/PhysicsWorld.js.map +1 -1
- package/dist/physics/XpbdWorld.d.ts +56 -0
- package/dist/physics/XpbdWorld.d.ts.map +1 -1
- package/dist/physics/XpbdWorld.js +45 -0
- package/dist/physics/XpbdWorld.js.map +1 -1
- package/dist/physics/cloth/index.d.ts +20 -19
- package/dist/physics/cloth/index.d.ts.map +1 -1
- package/dist/physics/cloth/index.js +19 -0
- package/dist/physics/cloth/index.js.map +1 -1
- package/dist/physics/spring/index.d.ts +19 -18
- package/dist/physics/spring/index.d.ts.map +1 -1
- package/dist/physics/spring/index.js +23 -0
- package/dist/physics/spring/index.js.map +1 -1
- package/dist/three-d/Character3D.d.ts +8 -7
- package/dist/three-d/Character3D.d.ts.map +1 -1
- package/dist/three-d/Character3D.js +7 -0
- package/dist/three-d/Character3D.js.map +1 -1
- package/dist/three-d/Collision3D.d.ts +4 -0
- package/dist/three-d/Collision3D.d.ts.map +1 -1
- package/dist/three-d/Collision3D.js.map +1 -1
- package/dist/three-d/Engine3D.d.ts +2 -0
- package/dist/three-d/Engine3D.d.ts.map +1 -1
- package/dist/three-d/Engine3D.js.map +1 -1
- package/dist/three-d/Grid.d.ts +5 -0
- package/dist/three-d/Grid.d.ts.map +1 -1
- package/dist/three-d/Grid.js.map +1 -1
- package/dist/three-d/Heightmap.d.ts +3 -0
- package/dist/three-d/Heightmap.d.ts.map +1 -1
- package/dist/three-d/Heightmap.js.map +1 -1
- package/dist/three-d/TileGrid3D.d.ts +6 -0
- package/dist/three-d/TileGrid3D.d.ts.map +1 -1
- package/dist/three-d/TileGrid3D.js.map +1 -1
- package/dist/three-d/Vox.d.ts +7 -0
- package/dist/three-d/Vox.d.ts.map +1 -1
- package/dist/three-d/Vox.js.map +1 -1
- package/dist/two-d/presentation/MwlPresentation.d.ts +7 -0
- package/dist/two-d/presentation/MwlPresentation.d.ts.map +1 -1
- package/dist/two-d/presentation/MwlPresentation.js.map +1 -1
- package/dist/two-d/ui/theme.d.ts +18 -0
- package/dist/two-d/ui/theme.d.ts.map +1 -1
- package/dist/two-d/ui/theme.js.map +1 -1
- package/dist/version.d.ts +1 -1
- package/dist/version.js +1 -1
- package/package.json +23 -1
- package/sbom.cdx.json +599 -139
- package/tools/benchmark-browser.mjs +6 -0
- package/tools/bundle-size.mjs +4 -0
- package/tools/compile-resources.d.mts +6 -0
- package/tools/compile-resources.mjs +53 -16
- package/tools/emit-page.d.mts +3 -0
- package/tools/emit-page.mjs +15 -2
- package/tools/init.mjs +3 -1
- package/tools/make-example-assets.mjs +4 -0
- package/tools/multiplayer-server.mjs +4 -0
- package/tools/mwl.mjs +7 -0
- package/tools/play-uci.mjs +4 -0
- package/tools/sbom.mjs +4 -0
- package/tools/soundfonts.d.mts +79 -0
- package/tools/soundfonts.mjs +301 -0
- package/tools/svg-optimise.d.mts +8 -0
- package/tools/svg-optimise.mjs +45 -0
- package/tools/verify-scenarios.mjs +6 -2
- package/tools/vite.mjs +2 -1
- 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;
|
|
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"}
|
package/dist/audio/SoundFont.js
CHANGED
|
@@ -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
|
|
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
|
-
(
|
|
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,
|