Play the music and sound effects from the ROM's sound engine (Closes #34)

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>
This commit is contained in:
kawaandClaude Opus 5.5 committed 2026-10-15 15:54:02 +02:00
1 parent aff8391e55
commit 7f30c3f450
34 files changed
+3961 -74

No files matched your search

+89
View File
@@ -0,0 +1,89 @@
import { test, expect, type Page } from "@playwright/test";
import { existsSync } from "node:fs";
import { resolve } from "node:path";
import { completeIntro } from "./intro-driver";
const romPath = resolve(
process.env.CRYSTAL_ROM_PATH ??
"Pokemon - Crystal Version (UE) (V1.1) [C][!].gbc",
);
/** Tap everything the page sends to the speakers with an analyser, so the
* test can measure what is actually being played. */
async function listen(page: Page) {
await page.addInitScript(() => {
const connect = AudioNode.prototype.connect as (...args: unknown[]) => unknown;
const tapped = window as unknown as { __analysers: AnalyserNode[] };
tapped.__analysers = [];
AudioNode.prototype.connect = function (this: AudioNode, ...args: unknown[]) {
if (args[0] instanceof AudioDestinationNode) {
const analyser = this.context.createAnalyser();
analyser.fftSize = 2048;
connect.call(this, analyser);
tapped.__analysers.push(analyser);
}
return connect.apply(this, args);
} as typeof AudioNode.prototype.connect;
});
}
/** The loudest RMS level heard over `ms`. */
async function level(page: Page, ms: number) {
return page.evaluate(async (ms) => {
const analysers = (window as unknown as { __analysers: AnalyserNode[] }).__analysers;
let loudest = 0;
const end = performance.now() + ms;
while (performance.now() < end) {
for (const analyser of analysers) {
// A suspended context sends nothing; its analyser keeps old data.
if (analyser.context.state !== "running") continue;
const data = new Float32Array(analyser.fftSize);
analyser.getFloatTimeDomainData(data);
let sum = 0;
for (const value of data) sum += value * value;
loudest = Math.max(loudest, Math.sqrt(sum / data.length));
}
await new Promise((done) => setTimeout(done, 50));
}
return loudest;
}, ms);
}
test("music plays after the first press and stops while paused", async ({ page }) => {
test.skip(!existsSync(romPath), "Requires a local user-supplied ROM.");
test.setTimeout(120_000);
await listen(page);
await page.goto("/");
await page.locator("#rom-file").setInputFiles(romPath);
await expect(page.locator("#status")).toContainText("verified");
await page.locator("#play").click();
await completeIntro(page);
// The bedroom's music plays once the introduction hands over.
await expect.poll(() => level(page, 500), { timeout: 15_000 }).toBeGreaterThan(0.01);
// Leaving the game tab pauses play, and the sound with it.
await page.locator("#tab-saves").click();
await expect.poll(() => level(page, 500), { timeout: 10_000 }).toBeLessThan(0.001);
// Returning and playing again resumes the music.
await page.locator("#tab-game").click();
await page.locator("#play").click();
await expect.poll(() => level(page, 500), { timeout: 15_000 }).toBeGreaterThan(0.01);
});
test("Settings turns the sound off and on", async ({ page }) => {
test.skip(!existsSync(romPath), "Requires a local user-supplied ROM.");
test.setTimeout(120_000);
await listen(page);
await page.goto("/");
await page.locator("#rom-file").setInputFiles(romPath);
await expect(page.locator("#status")).toContainText("verified");
await page.locator("#settings").click();
await page.locator('input[name="volume"][value="0"]').check();
await page.locator("#close-settings").click();
await page.locator("#play").click();
await completeIntro(page);
expect(await level(page, 1500)).toBeLessThan(0.001);
await page.locator("#settings").click();
await page.locator('input[name="volume"][value="1"]').check();
await page.locator("#close-settings").click();
await page.locator("#play").click();
await expect.poll(() => level(page, 500), { timeout: 15_000 }).toBeGreaterThan(0.01);
});
+155
View File
@@ -0,0 +1,155 @@
import { describe, expect, it } from "vitest";
import { existsSync, readFileSync } from "node:fs";
import { CrystalAssets } from "../src/game/rom/assets";
import { GameWorld } from "../src/game/world/world";
import { generatePartyMon } from "../src/game/party/pokemon";
import { SOUND } from "../src/game/audio/constants";
import { battleMusic, hpBarPixels, victoryMusic, MUSIC_TRAINERS as T } from "../src/game/audio/battle-music";
import { battleFixtureWorld, ENCOUNTER_WALK } from "./battle-fixture";
import { settle } from "./world-driver";
const romPath =
process.env.CRYSTAL_ROM_PATH ??
"Pokemon - Crystal Version (UE) (V1.1) [C][!].gbc";
describe("battle music choices (PlayBattleMusic, PlayVictoryMusic)", () => {
it("picks the wild, trainer, leader, rival and Rocket songs", () => {
expect(battleMusic({ night: false, kanto: false })).toBe(SOUND.MUSIC_JOHTO_WILD_BATTLE);
expect(battleMusic({ night: true, kanto: false })).toBe(SOUND.MUSIC_JOHTO_WILD_BATTLE_NIGHT);
expect(battleMusic({ night: true, kanto: true })).toBe(SOUND.MUSIC_KANTO_WILD_BATTLE);
expect(battleMusic({ battleType: T.BATTLETYPE_ROAMING, night: false, kanto: false })).toBe(SOUND.MUSIC_SUICUNE_BATTLE);
expect(battleMusic({ trainerClass: T.FALKNER, night: false, kanto: false })).toBe(SOUND.MUSIC_JOHTO_GYM_LEADER_BATTLE);
expect(battleMusic({ trainerClass: T.BROCK, night: false, kanto: true })).toBe(SOUND.MUSIC_KANTO_GYM_LEADER_BATTLE);
expect(battleMusic({ trainerClass: T.GRUNTM, night: false, kanto: false })).toBe(SOUND.MUSIC_ROCKET_BATTLE);
expect(battleMusic({ trainerClass: T.RIVAL1, night: false, kanto: false })).toBe(SOUND.MUSIC_RIVAL_BATTLE);
expect(battleMusic({ trainerClass: T.RIVAL2, trainerId: 4, night: false, kanto: false })).toBe(SOUND.MUSIC_CHAMPION_BATTLE);
expect(battleMusic({ trainerClass: T.RED, night: false, kanto: true })).toBe(SOUND.MUSIC_CHAMPION_BATTLE);
// Youngsters and the like: by region.
expect(battleMusic({ trainerClass: 30, night: false, kanto: false })).toBe(SOUND.MUSIC_JOHTO_TRAINER_BATTLE);
expect(battleMusic({ trainerClass: 30, night: false, kanto: true })).toBe(SOUND.MUSIC_KANTO_TRAINER_BATTLE);
expect(victoryMusic(undefined)).toBe(SOUND.MUSIC_WILD_VICTORY);
expect(victoryMusic(T.MORTY)).toBe(SOUND.MUSIC_GYM_VICTORY);
// IsGymLeader's list runs on into the Kanto leaders.
expect(victoryMusic(T.BLUE)).toBe(SOUND.MUSIC_GYM_VICTORY);
expect(victoryMusic(30)).toBe(SOUND.MUSIC_TRAINER_VICTORY);
});
it("measures the HP bar as ComputeHPBarPixels does", () => {
expect(hpBarPixels(0, 20)).toBe(0);
expect(hpBarPixels(1, 200)).toBe(1);
expect(hpBarPixels(20, 20)).toBe(48);
expect(hpBarPixels(4, 20)).toBe(9);
expect(hpBarPixels(5, 20)).toBe(12);
// Above 255 HP both sides are divided by four first.
expect(hpBarPixels(100, 400)).toBe(12);
});
});
describe.skipIf(!existsSync(romPath))("sound in the world with the local ROM", () => {
const load = () => new CrystalAssets(new Uint8Array(readFileSync(romPath)));
/** Every song and sound effect the engine is asked to start. */
const record = (world: GameWorld) => {
const music: number[] = [],
sfx: number[] = [],
cries: number[] = [];
const engine = world.sound.engine;
const playMusic = engine.playMusic.bind(engine),
playSFX = engine.playSFX.bind(engine),
playCry = engine.playCryWith.bind(engine);
engine.playMusic = (id) => {
if (id) music.push(id);
playMusic(id);
};
engine.playSFX = (id) => {
sfx.push(id);
playSFX(id);
};
engine.playCryWith = (id, pitch, length) => {
cries.push(id);
playCry(id, pitch, length);
};
return { music, sfx, cries };
};
it("plays a wild battle's music, cries and victory, then the map's music again", () => {
const world = battleFixtureWorld(load(), "win");
world.restore(world.save());
const heard = record(world);
const mapMusic = world.sound.engine.mapMusic;
expect(mapMusic).toBe(world.mapMusicId());
for (const direction of ENCOUNTER_WALK) world.move(direction);
expect(world.battle).toBeDefined();
// PlayBattleMusic (noon: day music), then the wild Pokémon's cry.
expect(heard.music).toContain(SOUND.MUSIC_JOHTO_WILD_BATTLE);
const pidgey = world.sound.engine.cryEntry(16).id;
expect(heard.cries[0]).toBe(pidgey);
for (let tick = 0; tick < 10000 && (world.battle || world.busy); tick++) {
const view = world.battle;
if (view?.prompt?.kind === "action") world.battleAction({ action: "fight", move: 0 });
else if (view?.prompt?.kind === "yesno") world.battleChoose(1);
else if (view?.prompt?.kind === "forget") world.battleChoose(3);
else if (view && (view.prompt?.kind === "stats" || view.message?.prompt)) world.interact();
else if (world.dialogue) world.advanceDialogue();
else world.tick();
}
expect(world.battle).toBeUndefined();
// The enemy fainted (SFX_KINESIS, SFX_FAINT), the wild victory played,
// and reloadmapafterbattle restarted the map's music.
expect(heard.music).toContain(SOUND.MUSIC_WILD_VICTORY);
for (let tick = 0; tick < 200; tick++) world.tick();
expect(heard.sfx).toContain(SOUND.SFX_KINESIS);
expect(heard.sfx).toContain(SOUND.SFX_FAINT);
expect(heard.music.at(-1)).toBe(mapMusic);
expect(world.sound.engine.channelMusicId(0)).toBe(mapMusic);
expect(world.sound.engine.lowHealthAlarm).toBe(false);
});
it("sounds the low HP tone while the player's HP bar is red", () => {
const world = battleFixtureWorld(load(), "loss");
for (const direction of ENCOUNTER_WALK) world.move(direction);
for (let tick = 0; tick < 400 && world.battle && world.battle.prompt?.kind !== "action"; tick++)
if (world.battle.message?.prompt) world.interact();
else world.tick();
expect(world.battle?.prompt?.kind).toBe("action");
expect(world.sound.engine.lowHealthAlarm).toBe(true);
});
it("heals with the Pokémon Center's jingle, then the map's music returns", () => {
const assets = load();
const world = new GameWorld(assets, () => new Date("2026-09-22T13:00:00"));
const mon = generatePartyMon(assets.pokemon, {
species: 155, level: 10, item: 0, otId: world.playerId, ot: "CHRIS", dvs: 0x823b, caughtData: 0,
});
mon.hp = 1;
world.state.party.push(mon, structuredClone(mon));
world.restore({ ...world.save(), map: "26:5", x: 3, y: 3, direction: "up" });
const heard = record(world);
world.interact();
// PlayTalkObject
expect(heard.sfx).toEqual([SOUND.SFX_READ_TEXT_2]);
const ticksBefore = world.ticks;
const pages = settle(world, [1]);
expect(pages).toContain("Your POKéMON are\nfully healed.");
// A ball sound for each Pokémon, the heal jingle, and the map's music.
expect(heard.sfx.filter((id) => id === SOUND.SFX_SECOND_PART_OF_ITEMFINDER)).toHaveLength(2);
expect(heard.music).toContain(SOUND.MUSIC_HEAL);
expect(heard.music.at(-1)).toBe(SOUND.MUSIC_POKEMON_CENTER);
// HealMachineAnim's 2 × 30 + 80 frames, then pause 30.
expect(world.ticks - ticksBefore).toBeGreaterThan(70 + 15);
});
it("plays the warp's sound and fades to the next map's music", () => {
const world = new GameWorld(load(), () => new Date("2026-09-22T13:00:00"));
// New Bark Town, below Elm's lab door.
world.restore({ ...world.save(), map: "24:4", x: 6, y: 4, direction: "up" });
const heard = record(world);
expect(world.sound.engine.mapMusic).toBe(SOUND.MUSIC_NEW_BARK_TOWN);
world.move("up");
for (let tick = 0; tick < 40; tick++) world.tick();
expect(world.mapId).toBe("24:5");
expect(heard.sfx[0]).toBe(SOUND.SFX_ENTER_DOOR);
// FadeToMapMusic: Elm's lab plays MUSIC_PROF_ELM once the fade ends.
for (let tick = 0; tick < 200; tick++) world.tick();
expect(world.sound.engine.mapMusic).toBe(SOUND.MUSIC_PROF_ELM);
expect(world.sound.engine.channelMusicId(0)).toBe(SOUND.MUSIC_PROF_ELM);
});
});
+152
View File
@@ -0,0 +1,152 @@
import { describe, expect, it } from "vitest";
import { existsSync, readFileSync, writeFileSync } from "node:fs";
import { Apu, CPU_HZ, FRAME_CYCLES } from "../src/game/audio/apu";
import { AudioEngine } from "../src/game/audio/engine";
import { SOUND } from "../src/game/audio/constants";
import { RomReader } from "../src/game/rom/reader";
import { CrystalAssets } from "../src/game/rom/assets";
import { GameWorld } from "../src/game/world/world";
const RATE = 48000;
const romPath =
process.env.CRYSTAL_ROM_PATH ??
"Pokemon - Crystal Version (UE) (V1.1) [C][!].gbc";
function render(apu: Apu, seconds: number) {
const count = Math.round(RATE * seconds);
const left = new Float32Array(count),
right = new Float32Array(count);
apu.render(left, right, 0, count);
return { left, right };
}
const rms = (samples: Float32Array, from = 0, to = samples.length) => {
let sum = 0;
for (let i = from; i < to; i++) sum += samples[i] * samples[i];
return Math.sqrt(sum / Math.max(1, to - from));
};
/** Upward zero crossings per second. */
const pitch = (samples: Float32Array) => {
let crossings = 0;
for (let i = 1; i < samples.length; i++) if (samples[i - 1] < 0 && samples[i] >= 0) crossings++;
return crossings / (samples.length / RATE);
};
const on = (apu: Apu) => {
apu.write(0x16, 0x80); // NR52: power
apu.write(0x14, 0x77); // NR50: full volume
apu.write(0x15, 0xff); // NR51: every channel to both sides
};
describe("sound channels (apu.ts)", () => {
it("plays a pulse note at 131072 / (2048 - frequency) Hz", () => {
const apu = new Apu(RATE);
on(apu);
apu.write(0x01, 0x80); // 50% duty
apu.write(0x02, 0xf0); // volume 15, no envelope
apu.write(0x03, 1750 & 0xff);
apu.write(0x04, 0x80 | (1750 >> 8));
const { left } = render(apu, 1);
expect(pitch(left)).toBeGreaterThan(436);
expect(pitch(left)).toBeLessThan(444);
expect(rms(left)).toBeGreaterThan(0.05);
});
it("fades a decreasing envelope out in 15 steps of 1/64 s", () => {
const apu = new Apu(RATE);
on(apu);
apu.write(0x06, 0x80);
apu.write(0x07, 0xf1); // volume 15, decrease every 1/64 s
apu.write(0x08, 0x00);
apu.write(0x09, 0x87);
const { left } = render(apu, 0.5);
expect(rms(left, 0, 2400)).toBeGreaterThan(0.05);
// After 15/64 s the channel is silent but for the filter's settling.
expect(rms(left, Math.round(RATE * 0.3))).toBeLessThan(0.01);
});
it("routes channels by NR51 and mutes the wave channel at level 0", () => {
const apu = new Apu(RATE);
on(apu);
apu.write(0x15, 0x04); // wave channel to the right side only
for (let i = 0; i < 16; i++) apu.write(0x20 + i, i < 8 ? 0xff : 0x00);
apu.write(0x0a, 0x80);
apu.write(0x0c, 0x20); // full level
apu.write(0x0d, 0x00);
apu.write(0x0e, 0x87);
const { left, right } = render(apu, 0.25);
expect(rms(left)).toBe(0);
expect(rms(right)).toBeGreaterThan(0.05);
apu.write(0x0c, 0x00);
const quiet = render(apu, 0.25);
expect(rms(quiet.right, 2400)).toBeLessThan(0.01);
});
it("plays noise without a steady pitch", () => {
const apu = new Apu(RATE);
on(apu);
apu.write(0x11, 0xf0);
apu.write(0x12, 0x32);
apu.write(0x13, 0x80);
const { left } = render(apu, 0.25);
expect(rms(left)).toBeGreaterThan(0.05);
});
it("has 70224 cycles in a video frame of the 4 MiHz clock", () => {
expect(CPU_HZ / FRAME_CYCLES).toBeCloseTo(59.7275, 3);
});
});
describe.skipIf(!existsSync(romPath))("music from the local ROM", () => {
const rom = () => new Uint8Array(readFileSync(romPath));
it("renders Route 29's music through the engine and the channels", () => {
const engine = new AudioEngine(new RomReader(rom()));
const apu = new Apu(RATE);
engine.initSound();
engine.playMusic(SOUND.MUSIC_ROUTE_29);
const seconds = 4;
const samplesPerFrame = (RATE * FRAME_CYCLES) / CPU_HZ;
const total = Math.round(RATE * seconds);
const left = new Float32Array(total),
right = new Float32Array(total);
let done = 0,
due = 0;
while (done < total) {
engine.updateSound();
for (const write of engine.drain()) apu.write(write >> 8, write & 0xff);
due += samplesPerFrame;
const count = Math.min(total - done, Math.floor(due) - done);
if (count > 0) apu.render(left, right, done, count);
done += Math.max(0, count);
}
expect(rms(left)).toBeGreaterThan(0.03);
expect(rms(right)).toBeGreaterThan(0.03);
// A listening copy, for comparing with the original by ear.
if (process.env.AUDIO_WAV) writeFileSync(process.env.AUDIO_WAV, wav(left, right));
});
it("plays each map's music on entry and fades between maps", () => {
const world = new GameWorld(new CrystalAssets(rom()));
world.restore({ ...world.save(), map: "24:3", x: 50, y: 10 });
for (let tick = 0; tick < 4; tick++) world.tick();
expect(world.sound.engine.mapMusic).toBe(SOUND.MUSIC_ROUTE_29);
expect(world.sound.engine.channelMusicId(0)).toBe(SOUND.MUSIC_ROUTE_29);
expect(world.mapMusicId()).toBe(SOUND.MUSIC_ROUTE_29);
});
});
/** 16-bit stereo WAV. */
function wav(left: Float32Array, right: Float32Array) {
const frames = left.length;
const buffer = Buffer.alloc(44 + frames * 4);
buffer.write("RIFF", 0);
buffer.writeUInt32LE(36 + frames * 4, 4);
buffer.write("WAVEfmt ", 8);
buffer.writeUInt32LE(16, 16);
buffer.writeUInt16LE(1, 20);
buffer.writeUInt16LE(2, 22);
buffer.writeUInt32LE(RATE, 24);
buffer.writeUInt32LE(RATE * 4, 28);
buffer.writeUInt16LE(4, 32);
buffer.writeUInt16LE(16, 34);
buffer.write("data", 36);
buffer.writeUInt32LE(frames * 4, 40);
for (let i = 0; i < frames; i++) {
buffer.writeInt16LE(Math.max(-1, Math.min(1, left[i])) * 32767, 44 + i * 4);
buffer.writeInt16LE(Math.max(-1, Math.min(1, right[i])) * 32767, 46 + i * 4);
}
return buffer;
}
+6 -1
View File
@@ -747,8 +747,13 @@ describe.skipIf(!existsSync(romPath))(
walkTo(world, 6, 4);
expect(world.move("up")).toBe(false);
world.interact();
// pokepic shows the ROM's front picture and waits for a button.
// pokepic shows the ROM's front picture; the cry plays (PlayMonCry
// waits for it) and waitbutton then waits for a button.
expect(world.picture).toBe(155);
expect(world.dialogue).toBeUndefined();
expect(world.sound.sfxPlaying).toBe(true);
for (let tick = 0; tick < 300 && !world.dialogue; tick++) world.tick();
expect(world.sound.sfxPlaying).toBe(false);
expect(world.dialogue?.pages).toEqual([""]);
world.advanceDialogue();
expect(world.picture).toBeUndefined();
+206
View File
@@ -0,0 +1,206 @@
import { test, expect } from "@playwright/test";
import { existsSync } from "node:fs";
import { romPath, symbolsPath, loadSymbols, openReference } from "./reference-fixture";
test.skip(!existsSync(romPath) || !existsSync(symbolsPath), "Needs private ROM and reference symbols.");
/** The synthesizer against the reference emulator's own sound output: the
* cartridge's UpdateSound drives binjgb's APU once per frame, the port's
* engine drives apu.ts with the same songs, and the two recordings are
* compared window by window by their spectra (pitches and loudness), which
* the consoles' different output filters leave alone. */
test("apu.ts sounds like the reference emulator's APU for songs, effects and cries", async ({ page }) => {
test.setTimeout(300_000);
const { at } = loadSymbols();
const labels = {
options: at("wOptions"),
initSound: at("InitSound"),
updateSound: at("UpdateSound"),
playMusic: at("PlayMusic"),
playSFX: at("PlaySFX"),
playCry: at("PlayCry"),
};
await openReference(page);
const results = await page.evaluate(async (labels) => {
const { BinjgbEmulator } = await import("/src/emulator/binjgb.ts");
const { AudioEngine } = await import("/src/game/audio/engine.ts");
const { Apu, FRAME_CYCLES } = await import("/src/game/audio/apu.ts");
const { RomReader } = await import("/src/game/rom/reader.ts");
const rom = new Uint8Array(
await (document.querySelector("#reference-fixture") as HTMLInputElement).files![0].arrayBuffer(),
);
const reference = new BinjgbEmulator();
await reference.load(rom, document.createElement("canvas"));
const inner = reference as unknown as {
module: { HEAPU8: Uint8Array } & Record<string, (...args: number[]) => number>;
handle: number;
audio?: AudioContext;
};
const m = inner.module,
h = inner.handle;
const rate = inner.audio?.sampleRate ?? 44100;
const write = (p: number, v: number) => reference.writeMemory(p, v & 255);
const read = (p: number) => reference.readMemory(p);
// Recorded reference sound, mixed to mono.
let recorded: number[] = [];
const run = (cycles: number) => {
const target = m._emulator_get_ticks_f64(h) + cycles;
for (let n = 0; n < 1000; n++) {
const event = m._emulator_run_until_f64(h, target);
if (event & 2) {
const pointer = m._get_audio_buffer_ptr(h);
const bytes = m.HEAPU8.subarray(pointer, pointer + 2048 * 2);
for (let i = 0; i < 2048; i++) recorded.push((bytes[i * 2] + bytes[i * 2 + 1]) / 510 - 0.5);
}
if (event & 4) return;
}
throw Error("Reference cycle budget exceeded.");
};
// DI; SP; ld de, nn; call; sentinel; then spin. Returns the cycles the
// call took, in 256-cycle steps.
const call = (address: number, de = 0) => {
const program = [0xf3, 0x31, 0xfe, 0xc7, 0x11, de & 255, de >> 8, 0xcd, address & 255, address >> 8, 0x3e, 0x42, 0xea, 0xf0, 0xc0, 0x18, 0xfe];
write(0xc0f0, 0);
program.forEach((v, i) => write(0xc000 + i, v));
reference.setProgramCounter(0xc000);
let cycles = 0;
for (; cycles < 65536 && read(0xc0f0) !== 0x42; cycles += 256) run(256);
if (read(0xc0f0) !== 0x42) throw Error(`Routine $${address.toString(16)} did not return`);
return cycles;
};
const fft = (re: Float64Array) => {
const n = re.length,
im = new Float64Array(n);
for (let i = 1, j = 0; i < n; i++) {
let bit = n >> 1;
for (; j & bit; bit >>= 1) j ^= bit;
j ^= bit;
if (i < j) [re[i], re[j]] = [re[j], re[i]];
}
for (let len = 2; len <= n; len <<= 1) {
const angle = (-2 * Math.PI) / len;
for (let i = 0; i < n; i += len)
for (let k = 0; k < len / 2; k++) {
const wr = Math.cos(angle * k),
wi = Math.sin(angle * k);
const ur = re[i + k],
ui = im[i + k];
const vr = re[i + k + len / 2] * wr - im[i + k + len / 2] * wi;
const vi = re[i + k + len / 2] * wi + im[i + k + len / 2] * wr;
re[i + k] = ur + vr;
im[i + k] = ui + vi;
re[i + k + len / 2] = ur - vr;
im[i + k + len / 2] = ui - vi;
}
}
// Magnitudes up to 4 kHz, where the music's fundamentals and most
// harmonics are.
const bins = Math.floor((4000 / rate) * n);
const out = new Float64Array(bins);
for (let i = 1; i < bins; i++) out[i] = Math.hypot(re[i], im[i]);
return out;
};
const SIZE = 4096;
const spectra = (samples: ArrayLike<number>, start: number, count: number) => {
const result: Float64Array[] = [];
for (let w = 0; w < count; w++) {
const frame = new Float64Array(SIZE);
let mean = 0;
for (let i = 0; i < SIZE; i++) mean += samples[start + w * SIZE + i] ?? 0;
mean /= SIZE;
for (let i = 0; i < SIZE; i++)
frame[i] = ((samples[start + w * SIZE + i] ?? 0) - mean) * (0.5 - 0.5 * Math.cos((2 * Math.PI * i) / SIZE));
result.push(fft(frame));
}
return result;
};
const cosine = (a: Float64Array, b: Float64Array) => {
let dot = 0,
na = 0,
nb = 0;
for (let i = 0; i < a.length; i++) {
dot += a[i] * b[i];
na += a[i] * a[i];
nb += b[i] * b[i];
}
return na && nb ? dot / Math.sqrt(na * nb) : na === nb ? 1 : 0;
};
const cases = [
{ name: "Route 29", routine: labels.playMusic, de: 0x34, seconds: 8 },
{ name: "Johto wild battle", routine: labels.playMusic, de: 0x29, seconds: 8 },
{ name: "Pokémon Center", routine: labels.playMusic, de: 0x09, seconds: 6 },
{ name: "Goldenrod City", routine: labels.playMusic, de: 0x3d, seconds: 6 },
{ name: "Dragon's Den (noise, vibrato)", routine: labels.playMusic, de: 0x49, seconds: 6 },
{ name: "SFX_SAVE", routine: labels.playSFX, de: 0x25, seconds: 2 },
{ name: "Pikachu's cry", routine: labels.playCry, de: 24, seconds: 1.5 },
{ name: "Lugia's cry", routine: labels.playCry, de: 248, seconds: 2 },
];
const results = [];
for (const testCase of cases) {
const engine = new AudioEngine(new RomReader(rom));
const apu = new Apu(rate);
write(labels.options, 0);
call(labels.initSound);
engine.initSound();
call(testCase.routine, testCase.de);
if (testCase.routine === labels.playMusic) engine.playMusic(testCase.de);
else if (testCase.routine === labels.playSFX) engine.playSFX(testCase.de);
else engine.playCry(testCase.de + 1);
// Start both recordings at the first UpdateSound.
recorded = [];
const frames = Math.round(testCase.seconds * 59.7275);
const native = new Float32Array(Math.ceil(((frames + 1) * FRAME_CYCLES * rate) / 4194304) + 4096);
const left = new Float32Array(native.length),
right = new Float32Array(native.length);
let rendered = 0,
due = 0;
for (let frame = 0; frame < frames; frame++) {
// One UpdateSound per video frame, as VBlank calls it.
run(FRAME_CYCLES - call(labels.updateSound));
engine.updateSound();
for (const value of engine.drain()) apu.write(value >> 8, value & 0xff);
due += (FRAME_CYCLES * rate) / 4194304;
const count = Math.floor(due) - rendered;
apu.render(left, right, rendered, count);
rendered += count;
}
for (let i = 0; i < rendered; i++) native[i] = (left[i] + right[i]) / 2;
// binjgb's buffer runs ahead by up to one 2048-sample chunk; align by
// the best match of the loudness envelopes within that range.
const windows = Math.floor(Math.min(rendered, recorded.length - 2048) / SIZE) - 1;
let best = { score: -1, shift: 0 };
for (let shift = -2048; shift <= 2048; shift += 64) {
const a = spectra(native, Math.max(0, shift), Math.min(windows, 3)),
b = spectra(recorded, Math.max(0, -shift), Math.min(windows, 3));
const score = a.reduce((sum, spectrum, i) => sum + cosine(spectrum, b[i]), 0);
if (score > best.score) best = { score, shift };
}
const a = spectra(native, Math.max(0, best.shift), windows),
b = spectra(recorded, Math.max(0, -best.shift), windows);
const scores = a.map((spectrum, i) => cosine(spectrum, b[i])).sort((x, y) => x - y);
const rms = (x: ArrayLike<number>, n: number) => {
let sum = 0;
for (let i = 0; i < n; i++) sum += x[i] * x[i];
return Math.sqrt(sum / Math.max(1, n));
};
results.push({
name: testCase.name,
nativeRms: rms(native, rendered),
referenceRms: rms(recorded, recorded.length),
recorded: recorded.length,
rendered,
windows,
shift: best.shift,
median: scores[Math.floor(scores.length / 2)],
worst: scores[0],
});
}
reference.dispose();
return results;
}, labels);
console.log(JSON.stringify(results, null, 1));
for (const result of results) {
expect(result.windows, result.name).toBeGreaterThan(0);
expect(result.median, result.name).toBeGreaterThan(0.85);
}
});
+250
View File
@@ -0,0 +1,250 @@
import { test, expect } from "@playwright/test";
import { existsSync } from "node:fs";
import { romPath, symbolsPath, loadSymbols, openReference } from "./reference-fixture";
test.skip(!existsSync(romPath) || !existsSync(symbolsPath), "Needs private ROM and reference symbols.");
/** Each case starts both engines from InitSound, runs the same calls into
* the sound engine and compares the audio WRAM ($c100-$c2c1) and the
* readable sound registers after every UpdateSound. */
type Step =
| { call: "PlayMusic" | "PlaySFX" | "PlayCry" | "PlayStereoSFX"; de: number }
| { poke: string; value: number }
| { frames: number };
type Case = { name: string; stereo?: boolean; steps: Step[] };
async function compare(page: import("@playwright/test").Page, cases: Case[]) {
const { at, routine } = loadSymbols();
const labels = {
romBank: at("hROMBank"),
options: at("wOptions"),
playerState: at("wPlayerState"),
initSound: at("InitSound"),
updateSound: at("UpdateSound"),
PlayMusic: at("PlayMusic"),
PlaySFX: at("PlaySFX"),
PlayCry: at("PlayCry"),
PlayStereoSFX: routine("PlayStereoSFX"),
wMusicFade: at("wMusicFade"),
wMusicFadeID: at("wMusicFadeID"),
wLowHealthAlarm: at("wLowHealthAlarm"),
wStereoPanningMask: at("wStereoPanningMask"),
wCryTracks: at("wCryTracks"),
};
await openReference(page);
return page.evaluate(
async ({ labels, cases }) => {
const { BinjgbEmulator } = await import("/src/emulator/binjgb.ts");
const { AudioEngine, AUDIO_RAM } = await import("/src/game/audio/engine.ts");
const { RomReader } = await import("/src/game/rom/reader.ts");
const rom = new Uint8Array(
await (document.querySelector("#reference-fixture") as HTMLInputElement).files![0].arrayBuffer(),
);
const reference = new BinjgbEmulator();
await reference.load(rom, document.createElement("canvas"));
const read = (p: number) => reference.readMemory(p);
const write = (p: number, v: number) => reference.writeMemory(p, v & 255);
// DI; SP=$c7fe; [ld de, nn]; [ld a, bank / ldh [hROMBank] / ld [$2000]];
// call routine; sentinel at $c0f0.
const run = (address: number, de = 0, bank?: number) => {
const program = [0xf3, 0x31, 0xfe, 0xc7, 0x11, de & 255, de >> 8];
if (bank !== undefined) program.push(0x3e, bank, 0xe0, labels.romBank & 255, 0xea, 0, 0x20);
program.push(0xcd, address & 255, address >> 8, 0x3e, 0x42, 0xea, 0xf0, 0xc0, 0x18, 0xfe);
write(0xc0f0, 0);
program.forEach((v, i) => write(0xc000 + i, v));
reference.setProgramCounter(0xc000);
for (let n = 0; n < 64 && read(0xc0f0) !== 0x42; n++) reference.advanceCycles(2048);
if (read(0xc0f0) !== 0x42) throw Error(`Routine $${address.toString(16)} did not return`);
};
const size = 0x1c2;
const results: unknown[] = [];
for (const testCase of cases) {
const engine = new AudioEngine(new RomReader(rom));
write(labels.options, testCase.stereo ? 1 << 5 : 0);
write(labels.playerState, 0);
engine.stereo = !!testCase.stereo;
run(labels.initSound);
engine.initSound();
for (let i = 0; i < size; i++) engine.ram[i] = read(AUDIO_RAM + i);
let frame = 0;
let failure: unknown;
// Frames where the cartridge had a channel on, and register writes
// the port made: proof the case exercised the engine.
let active = 0;
let writes = 0;
const check = (where: string) => {
for (let i = 0; i < size; i++) {
const original = read(AUDIO_RAM + i);
if (original !== engine.ram[i]) {
failure = {
where,
frame,
address: (AUDIO_RAM + i).toString(16),
original,
native: engine.ram[i],
};
return false;
}
}
// Readable sound registers, except NR52's channel status bits.
for (let r = 0; r < 0x16; r++) {
const original = read(0xff10 + r);
const masks = [
0x80, 0x3f, 0x00, 0xff, 0xbf, 0xff, 0x3f, 0x00, 0xff, 0xbf, 0x7f, 0xff,
0x9f, 0xff, 0xbf, 0xff, 0xff, 0x00, 0x00, 0xbf, 0x00, 0x00,
];
const native = engine.registers[r] | masks[r];
if (original !== native) {
failure = { where, frame, register: (0xff10 + r).toString(16), original, native };
return false;
}
}
return true;
};
for (const step of testCase.steps) {
if (failure) break;
if ("call" in step) {
if (step.call === "PlayMusic") {
run(labels.PlayMusic, step.de);
engine.playMusic(step.de);
} else if (step.call === "PlaySFX") {
run(labels.PlaySFX, step.de);
engine.playSFX(step.de);
} else if (step.call === "PlayCry") {
run(labels.PlayCry, step.de);
engine.playCry(step.de + 1);
} else {
run(labels.PlayStereoSFX.address, step.de, labels.PlayStereoSFX.bank);
engine.playStereoSFX(step.de);
}
check(step.call);
} else if ("poke" in step) {
const address = labels[step.poke as keyof typeof labels] as number;
write(address, step.value);
engine.w(address, step.value);
} else {
for (let n = 0; n < step.frames && !failure; n++) {
run(labels.updateSound);
engine.updateSound();
frame++;
if ([0, 1, 2, 3, 4, 5, 6, 7].some((n) => read(AUDIO_RAM + 4 + n * 0x32) & 1)) active++;
writes += engine.drain().length;
check("UpdateSound");
}
}
}
results.push({ name: testCase.name, frames: frame, active, writes, failure });
}
reference.dispose();
return results as { name: string; frames: number; active: number; writes: number; failure?: unknown }[];
},
{ labels, cases },
);
}
const failures = (results: { name: string; failure?: unknown }[]) =>
results.filter((result) => result.failure);
test("every song matches _UpdateSound for 20 seconds", async ({ page }) => {
test.setTimeout(600_000);
const cases: Case[] = [];
for (let id = 1; id < 0x67; id++)
cases.push({ name: `music ${id}`, stereo: id % 2 === 0, steps: [{ call: "PlayMusic", de: id }, { frames: 1200 }] });
const results = await compare(page, cases);
expect(results.length).toBe(0x66);
// Every song plays for at least two seconds (the jingles end) and
// writes registers; nearly all loop for the whole 20 seconds.
expect(results.filter((result) => result.active < 120 || result.writes < 100)).toEqual([]);
expect(results.filter((result) => result.active === 1200).length).toBeGreaterThan(90);
expect(failures(results), JSON.stringify(failures(results).slice(0, 3))).toEqual([]);
});
test("every sound effect matches PlaySFX and PlayStereoSFX", async ({ page }) => {
test.setTimeout(600_000);
const cases: Case[] = [];
for (let id = 0; id < 0xcf; id++) {
cases.push({ name: `sfx ${id}`, steps: [{ call: "PlaySFX", de: id }, { frames: 300 }] });
cases.push({
name: `stereo sfx ${id}`,
stereo: true,
steps: [
{ poke: "wStereoPanningMask", value: id & 1 ? 0xf0 : 0x0f },
{ poke: "wCryTracks", value: id % 3 },
{ call: "PlayStereoSFX", de: id },
{ frames: 300 },
],
});
}
const results = await compare(page, cases);
expect(failures(results), JSON.stringify(failures(results).slice(0, 3))).toEqual([]);
});
test("every cry matches PlayCry, over music and from a battle side", async ({ page }) => {
test.setTimeout(600_000);
const cases: Case[] = [];
for (let species = 1; species <= 251; species++)
cases.push({
name: `cry ${species}`,
stereo: species % 2 === 0,
steps: [
{ call: "PlayMusic", de: 1 + (species % 0x66) },
{ frames: 30 },
{ poke: "wStereoPanningMask", value: species % 3 ? 1 : 0 },
{ poke: "wCryTracks", value: species & 1 ? 0xf0 : 0x0f },
{ call: "PlayCry", de: species - 1 },
{ frames: 240 },
],
});
const results = await compare(page, cases);
expect(failures(results), JSON.stringify(failures(results).slice(0, 3))).toEqual([]);
});
test("fades, the danger tone and sound effects over music match", async ({ page }) => {
test.setTimeout(300_000);
const cases: Case[] = [];
for (let id = 1; id < 0x67; id += 5) {
cases.push({
name: `fade out ${id} to ${id + 1}`,
steps: [
{ call: "PlayMusic", de: id },
{ frames: 60 },
{ poke: "wMusicFadeID", value: (id % 0x66) + 1 },
{ poke: "wMusicFade", value: 8 },
{ frames: 600 },
],
});
cases.push({
name: `fade in ${id}`,
steps: [
{ call: "PlayMusic", de: id },
{ poke: "wMusicFade", value: 4 | 0x80 },
{ frames: 120 },
],
});
cases.push({
name: `danger over ${id}`,
steps: [
{ call: "PlayMusic", de: id },
{ poke: "wLowHealthAlarm", value: 0x80 },
{ frames: 90 },
{ call: "PlaySFX", de: 0x10 },
{ frames: 90 },
{ poke: "wLowHealthAlarm", value: 0 },
{ frames: 30 },
],
});
cases.push({
name: `sfx priority over ${id}`,
steps: [
{ call: "PlayMusic", de: id },
{ frames: 45 },
{ call: "PlaySFX", de: (id * 7) % 0xcf },
{ frames: 5 },
{ call: "PlaySFX", de: (id * 11) % 0xcf },
{ frames: 200 },
],
});
}
const results = await compare(page, cases);
expect(failures(results), JSON.stringify(failures(results).slice(0, 3))).toEqual([]);
});