@lewismoten/gamemaker-sandbox-engine 0.0.0-stage → 3.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +24 -0
- package/NOTICE.md +753 -0
- package/README.md +65 -2
- package/dist/assets/music.worklet-NqOeNo6T.js +464 -0
- package/dist/engine.js +5436 -0
- package/dist/types/audio/cmfLoad.d.ts +7 -0
- package/dist/types/audio/cmfPlayer.d.ts +69 -0
- package/dist/types/audio/context.d.ts +12 -0
- package/dist/types/audio/engine.d.ts +39 -0
- package/dist/types/audio/music.worklet.d.ts +14 -0
- package/dist/types/audio/opl.d.ts +37 -0
- package/dist/types/audio/output.d.ts +13 -0
- package/dist/types/dosPlayer/fileExtensions.d.ts +44 -0
- package/dist/types/dosPlayer/patchRules.d.ts +24 -0
- package/dist/types/dosPlayer/patchTable.d.ts +80 -0
- package/dist/types/engine/character.d.ts +30 -0
- package/dist/types/engine/funcKeys.d.ts +33 -0
- package/dist/types/engine/game.d.ts +52 -0
- package/dist/types/engine/keys.d.ts +17 -0
- package/dist/types/engine/machine.d.ts +99 -0
- package/dist/types/engine/menus.d.ts +46 -0
- package/dist/types/engine/monsters.d.ts +19 -0
- package/dist/types/engine/package.d.ts +4 -0
- package/dist/types/engine/playGain.d.ts +11 -0
- package/dist/types/engine/playerLook.d.ts +26 -0
- package/dist/types/engine/recorder.d.ts +60 -0
- package/dist/types/engine/render.d.ts +8 -0
- package/dist/types/engine/scene.d.ts +156 -0
- package/dist/types/engine/scroll.d.ts +16 -0
- package/dist/types/engine/showGraphic.d.ts +39 -0
- package/dist/types/engine/slowPc.d.ts +62 -0
- package/dist/types/engine/touch.d.ts +33 -0
- package/dist/types/engine/world.d.ts +215 -0
- package/dist/types/formats/binary.d.ts +44 -0
- package/dist/types/formats/blocks.d.ts +52 -0
- package/dist/types/formats/character.d.ts +65 -0
- package/dist/types/formats/cmf.d.ts +33 -0
- package/dist/types/formats/fli.d.ts +45 -0
- package/dist/types/formats/game.d.ts +72 -0
- package/dist/types/formats/gif.d.ts +15 -0
- package/dist/types/formats/graphicScripts.d.ts +18 -0
- package/dist/types/formats/highscore.d.ts +10 -0
- package/dist/types/formats/map.d.ts +24 -0
- package/dist/types/formats/monster.d.ts +48 -0
- package/dist/types/formats/palette.d.ts +15 -0
- package/dist/types/formats/recording.d.ts +7 -0
- package/dist/types/formats/sound.d.ts +13 -0
- package/dist/types/formats/voc.d.ts +13 -0
- package/dist/types/gfx/cp437Glyphs.d.ts +2 -0
- package/dist/types/gfx/font8x8.d.ts +2 -0
- package/dist/types/gfx/screen.d.ts +46 -0
- package/dist/types/input/chords.d.ts +22 -0
- package/dist/types/input/deskMouse.d.ts +14 -0
- package/dist/types/input/keyboard.d.ts +39 -0
- package/dist/types/input/scancodes.d.ts +3 -0
- package/dist/types/project/gameFiles.d.ts +49 -0
- package/package.json +26 -4
package/README.md
CHANGED
|
@@ -1,3 +1,66 @@
|
|
|
1
|
-
#
|
|
1
|
+
# Game-Maker: Sandbox Edition browser engine
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
`@lewismoten/gamemaker-sandbox-engine` runs RSD Game-Maker games from their original DOS files in a browser. It is the same TypeScript engine used by the Game-Maker: Sandbox Edition web player. The package includes its audio worklet and TypeScript declarations; it does not include games or the site's editor and catalog.
|
|
4
|
+
|
|
5
|
+
Build a tarball from this repository with `cd web/packages/engine && npm pack`. The package has not been published to the npm registry. Install the tarball with `npm install ./lewismoten-gamemaker-sandbox-engine-3.1.0.tgz`.
|
|
6
|
+
|
|
7
|
+
Pass the files from one game's DOS directory to `GameFiles`. Names are case insensitive and use DOS 8.3 names. `GameFiles.mainGame()` finds a `.GAM` file. Keep the original file names and bytes; the game refers to its other files by name.
|
|
8
|
+
|
|
9
|
+
```ts
|
|
10
|
+
import { GameFiles, Machine, runGame } from '@lewismoten/gamemaker-sandbox-engine';
|
|
11
|
+
|
|
12
|
+
const input = document.querySelector<HTMLInputElement>('#files')!;
|
|
13
|
+
const canvas = document.querySelector<HTMLCanvasElement>('#screen')!;
|
|
14
|
+
const play = document.querySelector<HTMLButtonElement>('#play')!;
|
|
15
|
+
let files: GameFiles | undefined;
|
|
16
|
+
|
|
17
|
+
input.addEventListener('change', async () => {
|
|
18
|
+
const entries = await Promise.all(
|
|
19
|
+
Array.from(input.files ?? [], async (file): Promise<[string, Uint8Array]> => [
|
|
20
|
+
file.name,
|
|
21
|
+
new Uint8Array(await file.arrayBuffer()),
|
|
22
|
+
]),
|
|
23
|
+
);
|
|
24
|
+
files = new GameFiles(entries);
|
|
25
|
+
play.disabled = !files.mainGame();
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
play.addEventListener('click', async () => {
|
|
29
|
+
if (!files) return;
|
|
30
|
+
const gam = files.mainGame();
|
|
31
|
+
if (!gam) throw new Error('Choose a game directory containing a .GAM file');
|
|
32
|
+
|
|
33
|
+
const machine = new Machine(canvas, files);
|
|
34
|
+
try {
|
|
35
|
+
await machine.audio.resume(); // the click is a user gesture
|
|
36
|
+
await runGame(machine, gam);
|
|
37
|
+
} finally {
|
|
38
|
+
machine.destroy();
|
|
39
|
+
}
|
|
40
|
+
});
|
|
41
|
+
```
|
|
42
|
+
|
|
43
|
+
```html
|
|
44
|
+
<input id="files" type="file" webkitdirectory multiple aria-label="Game directory" />
|
|
45
|
+
<button id="play" disabled>Play</button>
|
|
46
|
+
<canvas id="screen" tabindex="0" aria-label="Game screen"></canvas>
|
|
47
|
+
<style>
|
|
48
|
+
#screen { width: min(100%, 960px); aspect-ratio: 8 / 5; image-rendering: pixelated; }
|
|
49
|
+
</style>
|
|
50
|
+
```
|
|
51
|
+
|
|
52
|
+
`Machine` owns the screen, keyboard and audio. Call `machine.destroy()` when play finishes or the containing page closes. `runGame` accepts `RunOptions` for callbacks, joystick input, playback and the optional DOS player appearance. The game may write saves or high scores into `GameFiles`; use `files.onChange` to persist those bytes. The package targets modern browsers and ES modules.
|
|
53
|
+
|
|
54
|
+
The source is MIT licensed; see `LICENSE` and `NOTICE.md` for credits.
|
|
55
|
+
|
|
56
|
+
## Publishing
|
|
57
|
+
|
|
58
|
+
The [release workflow](../../../.github/workflows/publish-engine.yml) publishes this package to
|
|
59
|
+
GitHub Packages when an `engine-v<version>` tag is pushed. The tagged commit must be on
|
|
60
|
+
`master`, and `<version>` must exactly match this package's `package.json` (for example,
|
|
61
|
+
`engine-v3.1.0`). Merge the package changes to `master` before pushing the tag.
|
|
62
|
+
|
|
63
|
+
The workflow uses GitHub's `GITHUB_TOKEN` with package write permission. It checks the source,
|
|
64
|
+
builds the package, and publishes it to GitHub Packages. No package is published by ordinary
|
|
65
|
+
branch pushes. Publishing to npm is a separate, manual decision; this workflow does not use
|
|
66
|
+
an npm token.
|
|
@@ -0,0 +1,464 @@
|
|
|
1
|
+
const g = [0.5, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 10, 12, 12, 15, 15], M = [0, 9, 12, 13.875, 15, 16.125, 16.875, 17.625, 18, 18.75, 19.125, 19.5, 19.875, 20.25, 20.625, 21], v = [0, 0.5, 0.25, 1], O = 49716, l = 96, D = [0, 1, 2, 3, 4, 5, 8, 9, 10, 11, 12, 13, 16, 17, 18, 19, 20, 21];
|
|
2
|
+
class b {
|
|
3
|
+
am = !1;
|
|
4
|
+
vib = !1;
|
|
5
|
+
sustainHold = !1;
|
|
6
|
+
ksr = !1;
|
|
7
|
+
mult = 1;
|
|
8
|
+
ksl = 0;
|
|
9
|
+
tl = 0;
|
|
10
|
+
ar = 0;
|
|
11
|
+
dr = 0;
|
|
12
|
+
sl = 0;
|
|
13
|
+
rr = 0;
|
|
14
|
+
wave = 0;
|
|
15
|
+
// Runtime state. envDb is the envelope attenuation (0 = full, SILENT_DB = off);
|
|
16
|
+
// out/prevOut keep the last two modulator outputs for feedback.
|
|
17
|
+
phase = 0;
|
|
18
|
+
// cycles
|
|
19
|
+
stage = 0;
|
|
20
|
+
envDb = l;
|
|
21
|
+
out = 0;
|
|
22
|
+
prevOut = 0;
|
|
23
|
+
keyOn() {
|
|
24
|
+
this.stage = 1, this.phase = 0;
|
|
25
|
+
}
|
|
26
|
+
keyOff() {
|
|
27
|
+
this.stage !== 0 && (this.stage = 4);
|
|
28
|
+
}
|
|
29
|
+
/** Rate in "rate units" (0..63) including key scale rate offset. */
|
|
30
|
+
rate(t, s) {
|
|
31
|
+
return t === 0 ? 0 : Math.min(63, t * 4 + (this.ksr ? s : s >> 2));
|
|
32
|
+
}
|
|
33
|
+
stepEnvelope(t, s) {
|
|
34
|
+
switch (this.stage) {
|
|
35
|
+
case 1: {
|
|
36
|
+
const e = this.rate(this.ar, s);
|
|
37
|
+
if (e === 0) return;
|
|
38
|
+
if (e >= 60)
|
|
39
|
+
this.envDb = 0;
|
|
40
|
+
else {
|
|
41
|
+
const h = 2.826 / Math.pow(2, (e - 4) / 4);
|
|
42
|
+
this.envDb -= (this.envDb + 8) * (t / h) * 3.2;
|
|
43
|
+
}
|
|
44
|
+
this.envDb <= 0 && (this.envDb = 0, this.stage = 2);
|
|
45
|
+
return;
|
|
46
|
+
}
|
|
47
|
+
case 2: {
|
|
48
|
+
const e = this.sl === 15 ? 93 : this.sl * 3;
|
|
49
|
+
this.envDb += this.decayDbPerSecond(this.dr, s) * t, this.envDb >= e && (this.envDb = e, this.stage = 3);
|
|
50
|
+
return;
|
|
51
|
+
}
|
|
52
|
+
// EG-TYP set (sustainHold) holds the level until key off; otherwise the
|
|
53
|
+
// percussive envelope keeps falling at the release rate.
|
|
54
|
+
case 3:
|
|
55
|
+
this.sustainHold || (this.envDb += this.decayDbPerSecond(this.rr, s) * t);
|
|
56
|
+
break;
|
|
57
|
+
case 4:
|
|
58
|
+
this.envDb += this.decayDbPerSecond(this.rr, s) * t;
|
|
59
|
+
break;
|
|
60
|
+
default:
|
|
61
|
+
return;
|
|
62
|
+
}
|
|
63
|
+
this.envDb >= l && (this.envDb = l, this.stage = 0);
|
|
64
|
+
}
|
|
65
|
+
decayDbPerSecond(t, s) {
|
|
66
|
+
const e = this.rate(t, s);
|
|
67
|
+
return e === 0 ? 0 : l / (39.28 / Math.pow(2, (e - 4) / 4));
|
|
68
|
+
}
|
|
69
|
+
/** Waveform value at phase (cycles): 0 sine, 1 half sine, 2 absolute sine, 3 pulse (quarter) sine. */
|
|
70
|
+
wave01(t) {
|
|
71
|
+
const s = t - Math.floor(t), e = Math.sin(s * 2 * Math.PI);
|
|
72
|
+
switch (this.wave) {
|
|
73
|
+
case 1:
|
|
74
|
+
return s < 0.5 ? e : 0;
|
|
75
|
+
case 2:
|
|
76
|
+
return Math.abs(e);
|
|
77
|
+
case 3:
|
|
78
|
+
return s < 0.25 || s >= 0.5 && s < 0.75 ? Math.abs(e) : 0;
|
|
79
|
+
default:
|
|
80
|
+
return e;
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
class S {
|
|
85
|
+
fnum = 0;
|
|
86
|
+
block = 0;
|
|
87
|
+
key = !1;
|
|
88
|
+
feedback = 0;
|
|
89
|
+
additive = !1;
|
|
90
|
+
ops = [new b(), new b()];
|
|
91
|
+
/** Hz = F-number * clock / 2^(20 - block); block is the octave. */
|
|
92
|
+
get freq() {
|
|
93
|
+
return this.fnum * O / Math.pow(2, 20 - this.block);
|
|
94
|
+
}
|
|
95
|
+
/** Key scale rate offset: block * 2 + bit 9 (or 8) of the F-number. */
|
|
96
|
+
rof(t) {
|
|
97
|
+
return this.block * 2 + (this.fnum >> (t ? 8 : 9) & 1);
|
|
98
|
+
}
|
|
99
|
+
/** KSL attenuation: table value minus 6 dB per octave below block 7, scaled by the KSL setting. */
|
|
100
|
+
kslDb(t) {
|
|
101
|
+
return t.ksl ? Math.max(0, M[this.fnum >> 6] - 6 * (7 - this.block)) * v[t.ksl] : 0;
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
class P {
|
|
105
|
+
sampleRate;
|
|
106
|
+
channels = Array.from({ length: 9 }, () => new S());
|
|
107
|
+
regs = new Uint8Array(256);
|
|
108
|
+
waveSelect = !1;
|
|
109
|
+
noteSel = !1;
|
|
110
|
+
amDepth = !1;
|
|
111
|
+
vibDepth = !1;
|
|
112
|
+
rhythm = !1;
|
|
113
|
+
time = 0;
|
|
114
|
+
noise = 1;
|
|
115
|
+
constructor(t) {
|
|
116
|
+
this.sampleRate = t;
|
|
117
|
+
}
|
|
118
|
+
reset() {
|
|
119
|
+
for (let t = 0; t < 256; t++) this.write(t, 0);
|
|
120
|
+
for (const t of this.channels)
|
|
121
|
+
for (const s of t.ops)
|
|
122
|
+
s.stage = 0, s.envDb = l;
|
|
123
|
+
}
|
|
124
|
+
read(t) {
|
|
125
|
+
return this.regs[t & 255];
|
|
126
|
+
}
|
|
127
|
+
write(t, s) {
|
|
128
|
+
if (t &= 255, s &= 255, this.regs[t] = s, t === 1) {
|
|
129
|
+
this.waveSelect = (s & 32) !== 0;
|
|
130
|
+
return;
|
|
131
|
+
}
|
|
132
|
+
if (t === 8) {
|
|
133
|
+
this.noteSel = (s & 64) !== 0;
|
|
134
|
+
return;
|
|
135
|
+
}
|
|
136
|
+
if (t === 189) {
|
|
137
|
+
this.writeRhythm(s);
|
|
138
|
+
return;
|
|
139
|
+
}
|
|
140
|
+
const e = t & 224;
|
|
141
|
+
if (e >= 32 && e <= 128 || e === 224) {
|
|
142
|
+
const o = D.indexOf(t & 31);
|
|
143
|
+
if (o < 0) return;
|
|
144
|
+
const r = this.channels[Math.floor(o / 6) * 3 + o % 3].ops[Math.floor(o % 6 / 3)];
|
|
145
|
+
switch (e) {
|
|
146
|
+
case 32:
|
|
147
|
+
r.am = (s & 128) !== 0, r.vib = (s & 64) !== 0, r.sustainHold = (s & 32) !== 0, r.ksr = (s & 16) !== 0, r.mult = g[s & 15];
|
|
148
|
+
break;
|
|
149
|
+
case 64:
|
|
150
|
+
r.ksl = s >> 6, r.tl = s & 63;
|
|
151
|
+
break;
|
|
152
|
+
case 96:
|
|
153
|
+
r.ar = s >> 4, r.dr = s & 15;
|
|
154
|
+
break;
|
|
155
|
+
case 128:
|
|
156
|
+
r.sl = s >> 4, r.rr = s & 15;
|
|
157
|
+
break;
|
|
158
|
+
case 224:
|
|
159
|
+
r.wave = this.waveSelect ? s & 3 : 0;
|
|
160
|
+
break;
|
|
161
|
+
}
|
|
162
|
+
return;
|
|
163
|
+
}
|
|
164
|
+
const h = t & 15;
|
|
165
|
+
if (h > 8) return;
|
|
166
|
+
const i = this.channels[h];
|
|
167
|
+
if (e === 160 && t < 176)
|
|
168
|
+
i.fnum = i.fnum & 768 | s;
|
|
169
|
+
else if (t >= 176 && t <= 184) {
|
|
170
|
+
i.fnum = i.fnum & 255 | (s & 3) << 8, i.block = s >> 2 & 7;
|
|
171
|
+
const o = (s & 32) !== 0;
|
|
172
|
+
if (o && !i.key) for (const a of i.ops) a.keyOn();
|
|
173
|
+
if (!o && i.key) for (const a of i.ops) a.keyOff();
|
|
174
|
+
i.key = o;
|
|
175
|
+
} else t >= 192 && t <= 200 && (i.feedback = s >> 1 & 7, i.additive = (s & 1) !== 0);
|
|
176
|
+
}
|
|
177
|
+
rhythmKeys = 0;
|
|
178
|
+
/**
|
|
179
|
+
* Register 0xBD: bit 7 tremolo depth, bit 6 vibrato depth, bit 5 rhythm mode, bits 0-4 the
|
|
180
|
+
* drum keys. In rhythm mode channels 6-8 become the bass drum (both operators of channel 6),
|
|
181
|
+
* hi-hat and snare (channel 7) and tom-tom and cymbal (channel 8).
|
|
182
|
+
*/
|
|
183
|
+
writeRhythm(t) {
|
|
184
|
+
this.amDepth = (t & 128) !== 0, this.vibDepth = (t & 64) !== 0, this.rhythm = (t & 32) !== 0;
|
|
185
|
+
const s = this.rhythm ? t & 31 : 0, e = s ^ this.rhythmKeys, [h, i, o] = [this.channels[6], this.channels[7], this.channels[8]], a = (r, n) => {
|
|
186
|
+
if (e & r)
|
|
187
|
+
for (const f of n) s & r ? f.keyOn() : f.keyOff();
|
|
188
|
+
};
|
|
189
|
+
a(16, h.ops), a(8, [i.ops[1]]), a(4, [o.ops[0]]), a(2, [o.ops[1]]), a(1, [i.ops[0]]), this.rhythmKeys = s;
|
|
190
|
+
}
|
|
191
|
+
/** Renders mono samples into out (added to existing content). */
|
|
192
|
+
render(t, s = 0.12) {
|
|
193
|
+
const e = 1 / this.sampleRate;
|
|
194
|
+
for (let h = 0; h < t.length; h++) {
|
|
195
|
+
this.time += e;
|
|
196
|
+
const i = (1 - Math.cos(this.time * 3.7 * 2 * Math.PI)) / 2 * (this.amDepth ? 4.8 : 1), o = Math.sin(this.time * 6.1 * 2 * Math.PI) * (this.vibDepth ? 14 : 7), a = Math.pow(2, o / 1200);
|
|
197
|
+
this.noise = this.noise >> 1 | ((this.noise >> 0 ^ this.noise >> 14) & 1) << 22;
|
|
198
|
+
const r = this.noise & 1 ? 1 : -1;
|
|
199
|
+
let n = 0;
|
|
200
|
+
const f = this.rhythm ? 6 : 9;
|
|
201
|
+
for (let c = 0; c < f; c++) n += this.channelSample(this.channels[c], e, i, a);
|
|
202
|
+
this.rhythm && (n += this.rhythmSample(e, i, a, r)), t[h] += n * s;
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
/** Linear amplitude from the summed attenuation: envelope + TL (0.75 dB steps) + KSL + tremolo. */
|
|
206
|
+
opLevel(t, s, e) {
|
|
207
|
+
const h = s.envDb + s.tl * 0.75 + t.kslDb(s) + (s.am ? e : 0);
|
|
208
|
+
return h >= l ? 0 : Math.pow(10, -h / 20);
|
|
209
|
+
}
|
|
210
|
+
advance(t, s, e, h) {
|
|
211
|
+
s.phase += t.freq * s.mult * (s.vib ? h : 1) * e, s.phase > 1e6 && (s.phase -= Math.floor(s.phase)), s.stepEnvelope(e, t.rof(this.noteSel));
|
|
212
|
+
}
|
|
213
|
+
channelSample(t, s, e, h) {
|
|
214
|
+
const [i, o] = t.ops;
|
|
215
|
+
if (i.stage === 0 && o.stage === 0) return 0;
|
|
216
|
+
this.advance(t, i, s, h), this.advance(t, o, s, h);
|
|
217
|
+
const a = t.feedback ? (i.out + i.prevOut) * Math.pow(2, t.feedback - 7) : 0, r = i.wave01(i.phase + a) * this.opLevel(t, i, e);
|
|
218
|
+
i.prevOut = i.out, i.out = r;
|
|
219
|
+
const n = this.opLevel(t, o, e);
|
|
220
|
+
return t.additive ? r + o.wave01(o.phase) * n : o.wave01(o.phase + r * 4) * n;
|
|
221
|
+
}
|
|
222
|
+
/**
|
|
223
|
+
* Approximates the five drums. The bass drum is a normal FM channel (at double level); the
|
|
224
|
+
* hi-hat, snare and cymbal mix operator tone with LFSR noise, and the tom-tom is a plain tone.
|
|
225
|
+
* The mixing weights are this port's approximation, not the chip's exact phase logic.
|
|
226
|
+
*/
|
|
227
|
+
rhythmSample(t, s, e, h) {
|
|
228
|
+
const [i, o, a] = [this.channels[6], this.channels[7], this.channels[8]];
|
|
229
|
+
let r = this.channelSample(i, t, s, e) * 2;
|
|
230
|
+
const [n, f] = o.ops, [c, p] = a.ops;
|
|
231
|
+
for (const [w, k] of [
|
|
232
|
+
[o, n],
|
|
233
|
+
[o, f],
|
|
234
|
+
[a, c],
|
|
235
|
+
[a, p]
|
|
236
|
+
])
|
|
237
|
+
this.advance(w, k, t, e);
|
|
238
|
+
return n.stage !== 0 && (r += h * 0.5 * this.opLevel(o, n, s) * (n.wave01(n.phase) > 0 ? 1 : 0.6) * 2), f.stage !== 0 && (r += (f.wave01(f.phase) * 0.6 + h * 0.4) * this.opLevel(o, f, s) * 2), c.stage !== 0 && (r += c.wave01(c.phase) * this.opLevel(a, c, s) * 2), p.stage !== 0 && (r += (p.wave01(p.phase) > 0 ? 0.5 : -0.5) * (h > 0 ? 1 : 0.7) * this.opLevel(a, p, s) * 2), r;
|
|
239
|
+
}
|
|
240
|
+
}
|
|
241
|
+
const y = [0, 1, 2, 8, 9, 10, 16, 17, 18], T = 1193180 / Math.floor(65535 / 8), u = { 11: 16, 12: 8, 13: 4, 14: 2, 15: 1 }, d = {
|
|
242
|
+
11: { ch: 6, ops: ["mod", "car"] },
|
|
243
|
+
12: { ch: 7, ops: ["car"] },
|
|
244
|
+
13: { ch: 8, ops: ["mod"] },
|
|
245
|
+
14: { ch: 8, ops: ["car"] },
|
|
246
|
+
15: { ch: 7, ops: ["mod"] }
|
|
247
|
+
};
|
|
248
|
+
class K {
|
|
249
|
+
opl;
|
|
250
|
+
music = new Uint8Array(0);
|
|
251
|
+
instruments = [];
|
|
252
|
+
// PlayIt's statics: read position in the music block, ticks left before the next
|
|
253
|
+
// event, and the running status (channel and 8 = note off / 9 = note on).
|
|
254
|
+
offset = 0;
|
|
255
|
+
wait = 0;
|
|
256
|
+
channel = 0;
|
|
257
|
+
funct = 8;
|
|
258
|
+
rhythm = !1;
|
|
259
|
+
rhythmKeys = 0;
|
|
260
|
+
// Note sounding on each MIDI channel (-1 none), so a note off only releases its own note.
|
|
261
|
+
notes = new Int16Array(16).fill(-1);
|
|
262
|
+
programs = new Uint8Array(16);
|
|
263
|
+
playing = !1;
|
|
264
|
+
constructor(t) {
|
|
265
|
+
this.opl = t, this.init();
|
|
266
|
+
}
|
|
267
|
+
/** sbfd_init: enable waveform select, melody mode (0xBD = deep tremolo and vibrato, rhythm off). */
|
|
268
|
+
init() {
|
|
269
|
+
this.opl.reset(), this.opl.write(1, 32), this.opl.write(8, 0), this.opl.write(189, 192);
|
|
270
|
+
}
|
|
271
|
+
load(t, s) {
|
|
272
|
+
this.stop(), this.init(), this.music = t, this.instruments = s, this.rhythm = !1, this.rhythmKeys = 0;
|
|
273
|
+
for (let e = 0; e < 16; e++) this.programChange(e, e);
|
|
274
|
+
this.offset = 0, this.playing = t.length > 0;
|
|
275
|
+
}
|
|
276
|
+
/** sbfd_music_off + rewind. */
|
|
277
|
+
stop() {
|
|
278
|
+
this.allNotesOff(), this.offset = 0, this.playing = !1;
|
|
279
|
+
}
|
|
280
|
+
allNotesOff() {
|
|
281
|
+
for (let t = 0; t < 9; t++) this.opl.write(176 + t, this.opl.read(176 + t) & -33);
|
|
282
|
+
this.rhythmKeys = 0, this.opl.write(189, 192 | (this.rhythm ? 32 : 0)), this.notes.fill(-1);
|
|
283
|
+
}
|
|
284
|
+
/**
|
|
285
|
+
* *(APOS) in SBMUSIC.C. PlayIt read sbmusic+offset with no bounds check, so past the end of
|
|
286
|
+
* the farmalloc'd music block it read whatever heap memory followed. Every CMF ends with the
|
|
287
|
+
* FF 2F 00 end marker, which restarts the song before the read gets there; only a truncated
|
|
288
|
+
* file runs off the end, and for that the port reads 0xFF, which takes the same path as the
|
|
289
|
+
* end marker (sbfd_music_off, offset = 0).
|
|
290
|
+
*/
|
|
291
|
+
byte(t) {
|
|
292
|
+
return this.music[t] ?? 255;
|
|
293
|
+
}
|
|
294
|
+
/**
|
|
295
|
+
* getpaws() in SBMUSIC.C: reads a MIDI variable length number (7 bits per byte, high bit
|
|
296
|
+
* set on every byte but the last), with no limit on the number of bytes:
|
|
297
|
+
* do { offset++; value = (value << 7) | ((c=*(APOS)) & 0x07F); } while (c & 0x80);
|
|
298
|
+
* value is an unsigned long, so bits shifted past 32 are lost (>>> 0). The one difference:
|
|
299
|
+
* the loop stops at the end of the data, where DOS read on into unknown memory.
|
|
300
|
+
*/
|
|
301
|
+
getPaws() {
|
|
302
|
+
let t = this.byte(this.offset);
|
|
303
|
+
if (t & 128) {
|
|
304
|
+
t &= 127;
|
|
305
|
+
let s;
|
|
306
|
+
do
|
|
307
|
+
this.offset++, s = this.byte(this.offset), t = (t << 7 | s & 127) >>> 0;
|
|
308
|
+
while (s & 128 && this.offset < this.music.length);
|
|
309
|
+
}
|
|
310
|
+
return this.offset++, t;
|
|
311
|
+
}
|
|
312
|
+
/** One timer tick of PlayIt(). */
|
|
313
|
+
tick() {
|
|
314
|
+
if (!this.playing) return;
|
|
315
|
+
this.offset === 0 && (this.wait = 1, this.getPaws()), this.wait--;
|
|
316
|
+
let t = 0;
|
|
317
|
+
for (; this.wait <= 0 && t++ < 1e5; ) {
|
|
318
|
+
const s = this.byte(this.offset);
|
|
319
|
+
switch (s & 240) {
|
|
320
|
+
case 144:
|
|
321
|
+
this.channel = s & 15, this.offset++, this.funct = 9;
|
|
322
|
+
break;
|
|
323
|
+
case 128:
|
|
324
|
+
this.channel = s & 15, this.offset++, this.funct = 8;
|
|
325
|
+
break;
|
|
326
|
+
}
|
|
327
|
+
const e = this.byte(this.offset);
|
|
328
|
+
if (e < 128) {
|
|
329
|
+
const h = this.byte(this.offset + 1);
|
|
330
|
+
this.funct === 8 || h === 0 ? this.noteOff(this.channel, e) : this.noteOn(this.channel, e, h), this.offset += 2;
|
|
331
|
+
} else
|
|
332
|
+
switch (e & 240) {
|
|
333
|
+
case 160:
|
|
334
|
+
this.offset += 3;
|
|
335
|
+
break;
|
|
336
|
+
case 176:
|
|
337
|
+
this.byte(this.offset + 1) === 103 && this.setMode(this.byte(this.offset + 2)), this.offset += 3;
|
|
338
|
+
break;
|
|
339
|
+
case 192:
|
|
340
|
+
this.programChange(e & 15, this.byte(this.offset + 1)), this.offset += 2;
|
|
341
|
+
break;
|
|
342
|
+
case 208:
|
|
343
|
+
this.offset += 2;
|
|
344
|
+
break;
|
|
345
|
+
case 224:
|
|
346
|
+
this.offset += 3;
|
|
347
|
+
break;
|
|
348
|
+
case 240:
|
|
349
|
+
e === 247 || e === 240 ? (this.offset++, this.offset += this.getPaws(), this.offset++) : (this.allNotesOff(), this.offset = 0);
|
|
350
|
+
break;
|
|
351
|
+
default:
|
|
352
|
+
this.allNotesOff(), this.offset = 0;
|
|
353
|
+
}
|
|
354
|
+
this.wait = this.getPaws();
|
|
355
|
+
}
|
|
356
|
+
}
|
|
357
|
+
/** sbfd_setmode: switch melody/rhythm mode and reload the drum channels' instruments. */
|
|
358
|
+
setMode(t) {
|
|
359
|
+
if (this.allNotesOff(), this.rhythm = t !== 0, this.opl.write(189, 192 | (this.rhythm ? 32 : 0)), this.rhythm) for (const s of [11, 12, 13, 14, 15]) this.programChange(s, this.programs[s]);
|
|
360
|
+
}
|
|
361
|
+
programChange(t, s) {
|
|
362
|
+
this.programs[t] = s;
|
|
363
|
+
const e = this.instruments[s];
|
|
364
|
+
if (e) {
|
|
365
|
+
if (this.rhythm && d[t]) {
|
|
366
|
+
const h = d[t];
|
|
367
|
+
h.ops.length === 2 ? this.loadVoice(h.ch, e) : this.loadOperator(y[h.ch] + (h.ops[0] === "car" ? 3 : 0), e, 0);
|
|
368
|
+
return;
|
|
369
|
+
}
|
|
370
|
+
t < this.melodyVoices() && this.loadVoice(t, e);
|
|
371
|
+
}
|
|
372
|
+
}
|
|
373
|
+
melodyVoices() {
|
|
374
|
+
return this.rhythm ? 6 : 9;
|
|
375
|
+
}
|
|
376
|
+
/**
|
|
377
|
+
* Writes one operator from a 16 byte SBI instrument record, whose bytes alternate
|
|
378
|
+
* modulator/carrier: 0/1 AM-VIB-EG-KSR-MULT, 2/3 KSL-TL, 4/5 AR-DR, 6/7 SL-RR, 8/9 waveform.
|
|
379
|
+
*/
|
|
380
|
+
loadOperator(t, s, e) {
|
|
381
|
+
this.opl.write(32 + t, s[0 + e]), this.opl.write(64 + t, s[2 + e]), this.opl.write(96 + t, s[4 + e]), this.opl.write(128 + t, s[6 + e]), this.opl.write(224 + t, s[8 + e]);
|
|
382
|
+
}
|
|
383
|
+
loadVoice(t, s) {
|
|
384
|
+
const e = y[t];
|
|
385
|
+
this.loadOperator(e, s, 0), this.loadOperator(e + 3, s, 1), this.opl.write(192 + t, s[10]);
|
|
386
|
+
}
|
|
387
|
+
/**
|
|
388
|
+
* MIDI note -> OPL frequency: picks the lowest block (octave) whose F-number fits in 10 bits,
|
|
389
|
+
* which keeps the most pitch resolution (Hz = fnum * 49716 / 2^(20 - block)).
|
|
390
|
+
*/
|
|
391
|
+
setPitch(t, s, e) {
|
|
392
|
+
const h = 440 * Math.pow(2, (s - 69) / 12);
|
|
393
|
+
let i, o = 0;
|
|
394
|
+
for (i = 0; i < 8 && (o = Math.round(h * Math.pow(2, 20 - i) / 49716), !(o < 1024)); i++)
|
|
395
|
+
;
|
|
396
|
+
i = Math.min(i, 7), o = Math.min(o, 1023), this.opl.write(160 + t, o & 255), this.opl.write(176 + t, (e ? 32 : 0) | i << 2 | o >> 8);
|
|
397
|
+
}
|
|
398
|
+
/**
|
|
399
|
+
* SBFMDRV's loudness by velocity: it scales the instrument's carrier level (TL is an
|
|
400
|
+
* attenuation, 0 loudest .. 63 silent) and keeps the key-scaling bits:
|
|
401
|
+
* TL = 63 - floor((velocity + 128) * (63 - instrumentTL) / 256). So velocity 127 plays
|
|
402
|
+
* the instrument almost as loud as written and velocity 1 about half as loud in TL steps.
|
|
403
|
+
*/
|
|
404
|
+
applyVelocity(t, s) {
|
|
405
|
+
const e = this.instruments[this.programs[t]];
|
|
406
|
+
if (!e) return;
|
|
407
|
+
let h, i;
|
|
408
|
+
const o = this.rhythm ? d[t] : void 0;
|
|
409
|
+
if (o && o.ops.length === 1)
|
|
410
|
+
h = y[o.ch] + (o.ops[0] === "car" ? 3 : 0), i = e[2];
|
|
411
|
+
else {
|
|
412
|
+
const r = o ? o.ch : t;
|
|
413
|
+
if (!o && r >= this.melodyVoices()) return;
|
|
414
|
+
h = y[r] + 3, i = e[3];
|
|
415
|
+
}
|
|
416
|
+
const a = 63 - Math.floor((Math.min(127, s) + 128) * (63 - (i & 63)) / 256);
|
|
417
|
+
this.opl.write(64 + h, i & 192 | a);
|
|
418
|
+
}
|
|
419
|
+
noteOn(t, s, e = 127) {
|
|
420
|
+
if (this.applyVelocity(t, e), this.rhythm && u[t]) {
|
|
421
|
+
const h = u[t];
|
|
422
|
+
this.setPitch(d[t].ch, s, !1), this.rhythmKeys &= ~h, this.opl.write(189, 224 | this.rhythmKeys), this.rhythmKeys |= h, this.opl.write(189, 224 | this.rhythmKeys), this.notes[t] = s;
|
|
423
|
+
return;
|
|
424
|
+
}
|
|
425
|
+
t >= this.melodyVoices() || (this.notes[t] >= 0 && this.opl.write(176 + t, this.opl.read(176 + t) & -33), this.setPitch(t, s, !0), this.notes[t] = s);
|
|
426
|
+
}
|
|
427
|
+
noteOff(t, s) {
|
|
428
|
+
if (this.notes[t] === s) {
|
|
429
|
+
if (this.notes[t] = -1, this.rhythm && u[t]) {
|
|
430
|
+
this.rhythmKeys &= ~u[t], this.opl.write(189, 224 | this.rhythmKeys);
|
|
431
|
+
return;
|
|
432
|
+
}
|
|
433
|
+
t < this.melodyVoices() && this.opl.write(176 + t, this.opl.read(176 + t) & -33);
|
|
434
|
+
}
|
|
435
|
+
}
|
|
436
|
+
}
|
|
437
|
+
class L extends AudioWorkletProcessor {
|
|
438
|
+
opl = new P(sampleRate);
|
|
439
|
+
player = new K(this.opl);
|
|
440
|
+
untilTick = 0;
|
|
441
|
+
paused = !1;
|
|
442
|
+
ended = !1;
|
|
443
|
+
samplesPerTick = sampleRate / T;
|
|
444
|
+
constructor() {
|
|
445
|
+
super(), this.port.onmessage = (t) => {
|
|
446
|
+
const s = t.data;
|
|
447
|
+
s.type === "load" ? (this.player.load(s.music, s.instruments), this.untilTick = 0) : s.type === "stop" ? this.player.stop() : s.type === "pause" ? this.paused = s.paused : s.type === "end" && (this.ended = !0);
|
|
448
|
+
};
|
|
449
|
+
}
|
|
450
|
+
process(t, s) {
|
|
451
|
+
if (this.ended) return !1;
|
|
452
|
+
const e = s[0][0];
|
|
453
|
+
if (!e || this.paused) return !0;
|
|
454
|
+
let h = 0;
|
|
455
|
+
for (; h < e.length; ) {
|
|
456
|
+
this.untilTick <= 0 && (this.player.tick(), this.untilTick += this.samplesPerTick);
|
|
457
|
+
const i = Math.min(e.length - h, Math.ceil(this.untilTick));
|
|
458
|
+
this.opl.render(e.subarray(h, h + i)), this.untilTick -= i, h += i;
|
|
459
|
+
}
|
|
460
|
+
for (let i = 1; i < s[0].length; i++) s[0][i].set(e);
|
|
461
|
+
return !0;
|
|
462
|
+
}
|
|
463
|
+
}
|
|
464
|
+
registerProcessor("gm-music", L);
|