Files
Crystal/tests/audio.test.ts
T
kawaandClaude Opus 5.5 7f30c3f450 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>
2026-10-15 15:54:02 +02:00

153 lines
5.8 KiB
TypeScript

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;
}