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:
1 parent
aff8391e55
commit
7f30c3f450
34 files changed
+3961
-74
No files matched your search
@@ -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);
|
||||
});
|
||||
Reference in new issue
Block a user