The sound engine (audio/engine.asm, home/audio.asm) is ported on a byte copy of its audio WRAM and a sound register model. A reference test runs the cartridge's routines beside it and matches the WRAM and registers after every UpdateSound for all songs, effects and cries, fades, the danger tone and effect priority. apu.ts synthesizes the four channels in an AudioWorklet; its output matches the reference emulator's APU by spectrum. The world runs the engine twice per tick; Play or the first press unlocks sound, pausing suspends it, Settings sets the volume. Wired in: map music by map setup and special map music, script audio commands, text fanfares, WaitSFX and cries that hold the script frozen (the last page stays shown), battle music, cries, fainting, victory, capture and the low HP tone, Pokémon Center healing, evolution, the intro, the Pokédex's cries, PCs, marts, healing items, talk and menu clicks, bumps, ledges, warps, boulders, the Bicycle and Surf. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
78 lines
2.5 KiB
TypeScript
78 lines
2.5 KiB
TypeScript
import workletUrl from "./worklet.ts?worker&url";
|
|
import type { AudioMessage } from "./worklet";
|
|
|
|
/** The browser side of the sound: an AudioContext running the synthesizer
|
|
* worklet, fed the engine's register writes frame by frame. Browsers start
|
|
* audio only from a user gesture, so `unlock` runs from the first press. */
|
|
export class AudioOutput {
|
|
private context?: AudioContext;
|
|
private node?: AudioWorkletNode;
|
|
private gain = 1;
|
|
private failed = false;
|
|
|
|
/** Create or resume the context; call from a user gesture. */
|
|
unlock() {
|
|
if (this.failed) return;
|
|
try {
|
|
if (!this.context) {
|
|
const Context =
|
|
window.AudioContext ??
|
|
(window as unknown as { webkitAudioContext?: typeof AudioContext }).webkitAudioContext;
|
|
if (!Context || !("audioWorklet" in Context.prototype)) {
|
|
this.failed = true;
|
|
return;
|
|
}
|
|
const context = new Context({ latencyHint: "interactive" });
|
|
this.context = context;
|
|
void context.audioWorklet
|
|
.addModule(workletUrl)
|
|
.then(() => {
|
|
const node = new AudioWorkletNode(context, "crystal-audio", {
|
|
numberOfInputs: 0,
|
|
outputChannelCount: [2],
|
|
});
|
|
node.connect(context.destination);
|
|
this.node = node;
|
|
this.post({ kind: "gain", value: this.gain });
|
|
})
|
|
.catch(() => {
|
|
this.failed = true;
|
|
});
|
|
}
|
|
if (this.context.state !== "running") void this.context.resume().catch(() => {});
|
|
} catch {
|
|
this.failed = true;
|
|
}
|
|
}
|
|
/** Whether sound is playing (unlocked, loaded and running). */
|
|
get running() {
|
|
return !!this.node && this.context?.state === "running";
|
|
}
|
|
/** Frames of register writes, in order. */
|
|
send(frames: number[][]) {
|
|
if (!frames.length || !this.running) return;
|
|
this.post({ kind: "frames", frames });
|
|
}
|
|
setGain(value: number) {
|
|
this.gain = value;
|
|
this.post({ kind: "gain", value });
|
|
}
|
|
/** Pause: silence at once and drop the queued frames. */
|
|
suspend() {
|
|
this.post({ kind: "reset" });
|
|
if (this.context?.state === "running") void this.context.suspend().catch(() => {});
|
|
}
|
|
resume() {
|
|
if (this.context && this.context.state !== "running") void this.context.resume().catch(() => {});
|
|
}
|
|
dispose() {
|
|
this.node?.disconnect();
|
|
void this.context?.close().catch(() => {});
|
|
this.node = undefined;
|
|
this.context = undefined;
|
|
}
|
|
private post(message: AudioMessage) {
|
|
this.node?.port.postMessage(message);
|
|
}
|
|
}
|