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