diff --git a/examples/audio_demo.ne b/examples/audio_demo.ne index 0eebfe2..3744290 100644 --- a/examples/audio_demo.ne +++ b/examples/audio_demo.ne @@ -23,6 +23,8 @@ game "Audio Demo" { var px: u8 = 128 var py: u8 = 120 +var timer: u8 = 0 +var music_on: bool = false on frame { // Move the smiley so you can see the game is running. @@ -42,6 +44,26 @@ on frame { if button.start { start_music theme } if button.select { stop_music } + // Even without any button presses, the demo auto-plays a + // short coin SFX every 60 frames so the e2e harness (which + // runs headless without simulated input) can capture a + // non-silent audio hash. This exercises the full play-path + // through the APU driver under CI. + timer += 1 + if timer == 30 { + play coin + } + if timer == 60 { + timer = 0 + if music_on { + stop_music + music_on = false + } else { + start_music theme + music_on = true + } + } + draw Smiley at: (px, py) } diff --git a/tests/emulator/goldens/arrays_and_functions.audio.hash b/tests/emulator/goldens/arrays_and_functions.audio.hash new file mode 100644 index 0000000..5f988a9 --- /dev/null +++ b/tests/emulator/goldens/arrays_and_functions.audio.hash @@ -0,0 +1 @@ +a82b6ff5 132084 diff --git a/tests/emulator/goldens/audio_demo.audio.hash b/tests/emulator/goldens/audio_demo.audio.hash new file mode 100644 index 0000000..c214de5 --- /dev/null +++ b/tests/emulator/goldens/audio_demo.audio.hash @@ -0,0 +1 @@ +ace0df78 132084 diff --git a/tests/emulator/goldens/audio_demo.png b/tests/emulator/goldens/audio_demo.png new file mode 100644 index 0000000..c4ce20a Binary files /dev/null and b/tests/emulator/goldens/audio_demo.png differ diff --git a/tests/emulator/goldens/bitwise_ops.audio.hash b/tests/emulator/goldens/bitwise_ops.audio.hash new file mode 100644 index 0000000..5f988a9 --- /dev/null +++ b/tests/emulator/goldens/bitwise_ops.audio.hash @@ -0,0 +1 @@ +a82b6ff5 132084 diff --git a/tests/emulator/goldens/bouncing_ball.audio.hash b/tests/emulator/goldens/bouncing_ball.audio.hash new file mode 100644 index 0000000..5f988a9 --- /dev/null +++ b/tests/emulator/goldens/bouncing_ball.audio.hash @@ -0,0 +1 @@ +a82b6ff5 132084 diff --git a/tests/emulator/goldens/coin_cavern.audio.hash b/tests/emulator/goldens/coin_cavern.audio.hash new file mode 100644 index 0000000..5f988a9 --- /dev/null +++ b/tests/emulator/goldens/coin_cavern.audio.hash @@ -0,0 +1 @@ +a82b6ff5 132084 diff --git a/tests/emulator/goldens/comparisons.audio.hash b/tests/emulator/goldens/comparisons.audio.hash new file mode 100644 index 0000000..5f988a9 --- /dev/null +++ b/tests/emulator/goldens/comparisons.audio.hash @@ -0,0 +1 @@ +a82b6ff5 132084 diff --git a/tests/emulator/goldens/function_chain.audio.hash b/tests/emulator/goldens/function_chain.audio.hash new file mode 100644 index 0000000..5f988a9 --- /dev/null +++ b/tests/emulator/goldens/function_chain.audio.hash @@ -0,0 +1 @@ +a82b6ff5 132084 diff --git a/tests/emulator/goldens/function_chain.png b/tests/emulator/goldens/function_chain.png index b9470cf..7b90ee6 100644 Binary files a/tests/emulator/goldens/function_chain.png and b/tests/emulator/goldens/function_chain.png differ diff --git a/tests/emulator/goldens/hello_sprite.audio.hash b/tests/emulator/goldens/hello_sprite.audio.hash new file mode 100644 index 0000000..5f988a9 --- /dev/null +++ b/tests/emulator/goldens/hello_sprite.audio.hash @@ -0,0 +1 @@ +a82b6ff5 132084 diff --git a/tests/emulator/goldens/inline_asm_demo.audio.hash b/tests/emulator/goldens/inline_asm_demo.audio.hash new file mode 100644 index 0000000..5f988a9 --- /dev/null +++ b/tests/emulator/goldens/inline_asm_demo.audio.hash @@ -0,0 +1 @@ +a82b6ff5 132084 diff --git a/tests/emulator/goldens/logic_ops.audio.hash b/tests/emulator/goldens/logic_ops.audio.hash new file mode 100644 index 0000000..5f988a9 --- /dev/null +++ b/tests/emulator/goldens/logic_ops.audio.hash @@ -0,0 +1 @@ +a82b6ff5 132084 diff --git a/tests/emulator/goldens/logic_ops.png b/tests/emulator/goldens/logic_ops.png index d8ce710..ab3d666 100644 Binary files a/tests/emulator/goldens/logic_ops.png and b/tests/emulator/goldens/logic_ops.png differ diff --git a/tests/emulator/goldens/loop_break_continue.audio.hash b/tests/emulator/goldens/loop_break_continue.audio.hash new file mode 100644 index 0000000..5f988a9 --- /dev/null +++ b/tests/emulator/goldens/loop_break_continue.audio.hash @@ -0,0 +1 @@ +a82b6ff5 132084 diff --git a/tests/emulator/goldens/match_demo.audio.hash b/tests/emulator/goldens/match_demo.audio.hash new file mode 100644 index 0000000..5f988a9 --- /dev/null +++ b/tests/emulator/goldens/match_demo.audio.hash @@ -0,0 +1 @@ +a82b6ff5 132084 diff --git a/tests/emulator/goldens/mmc1_banked.audio.hash b/tests/emulator/goldens/mmc1_banked.audio.hash new file mode 100644 index 0000000..5f988a9 --- /dev/null +++ b/tests/emulator/goldens/mmc1_banked.audio.hash @@ -0,0 +1 @@ +a82b6ff5 132084 diff --git a/tests/emulator/goldens/mmc3_per_state_split.audio.hash b/tests/emulator/goldens/mmc3_per_state_split.audio.hash new file mode 100644 index 0000000..5f988a9 --- /dev/null +++ b/tests/emulator/goldens/mmc3_per_state_split.audio.hash @@ -0,0 +1 @@ +a82b6ff5 132084 diff --git a/tests/emulator/goldens/scanline_split.audio.hash b/tests/emulator/goldens/scanline_split.audio.hash new file mode 100644 index 0000000..5f988a9 --- /dev/null +++ b/tests/emulator/goldens/scanline_split.audio.hash @@ -0,0 +1 @@ +a82b6ff5 132084 diff --git a/tests/emulator/goldens/sprites_and_palettes.audio.hash b/tests/emulator/goldens/sprites_and_palettes.audio.hash new file mode 100644 index 0000000..5f988a9 --- /dev/null +++ b/tests/emulator/goldens/sprites_and_palettes.audio.hash @@ -0,0 +1 @@ +a82b6ff5 132084 diff --git a/tests/emulator/goldens/state_machine.audio.hash b/tests/emulator/goldens/state_machine.audio.hash new file mode 100644 index 0000000..5f988a9 --- /dev/null +++ b/tests/emulator/goldens/state_machine.audio.hash @@ -0,0 +1 @@ +a82b6ff5 132084 diff --git a/tests/emulator/goldens/structs_enums_for.audio.hash b/tests/emulator/goldens/structs_enums_for.audio.hash new file mode 100644 index 0000000..5f988a9 --- /dev/null +++ b/tests/emulator/goldens/structs_enums_for.audio.hash @@ -0,0 +1 @@ +a82b6ff5 132084 diff --git a/tests/emulator/goldens/two_player.audio.hash b/tests/emulator/goldens/two_player.audio.hash new file mode 100644 index 0000000..5f988a9 --- /dev/null +++ b/tests/emulator/goldens/two_player.audio.hash @@ -0,0 +1 @@ +a82b6ff5 132084 diff --git a/tests/emulator/harness.html b/tests/emulator/harness.html index 14756b0..fce0855 100644 --- a/tests/emulator/harness.html +++ b/tests/emulator/harness.html @@ -25,6 +25,47 @@ const buf32 = new Uint32Array(buf); for (let i = 0; i < buf32.length; i++) buf32[i] = 0xff000000; + // Audio recording buffers. `onAudioSample(l, r)` pushes one + // sample per channel; we collect both into growable int16 arrays + // so the runner can hash them for fast diffing or dump them as a + // WAV file when a diff fails. Samples are quantized to int16 at + // capture time — minor fp drift in jsnes shouldn't change the + // hash, and WAV output expects int16 anyway. The capacity grows + // exponentially to amortize reallocation; 3 seconds at 44.1 kHz + // is ~132k samples per channel which comfortably fits in the + // initial 64k allocation after one doubling. + const SAMPLE_RATE = 44100; + let audioLeft = new Int16Array(65536); + let audioRight = new Int16Array(65536); + let audioLen = 0; + + function pushAudio(l, r) { + if (audioLen >= audioLeft.length) { + const next = new Int16Array(audioLeft.length * 2); + next.set(audioLeft); + audioLeft = next; + const next2 = new Int16Array(audioRight.length * 2); + next2.set(audioRight); + audioRight = next2; + } + // Clamp to [-1, 1] then scale to int16 range. jsnes nominally + // produces samples in [-1, 1] but we clamp defensively in case + // of numerical edge cases at high volumes. + let ll = l; + if (ll < -1) ll = -1; + else if (ll > 1) ll = 1; + let rr = r; + if (rr < -1) rr = -1; + else if (rr > 1) rr = 1; + audioLeft[audioLen] = (ll * 32767) | 0; + audioRight[audioLen] = (rr * 32767) | 0; + audioLen++; + } + + function resetAudio() { + audioLen = 0; + } + const nes = new NES({ onFrame(frameBuffer) { for (let i = 0; i < 256 * 240; i++) { @@ -34,9 +75,10 @@ image.data.set(buf8); ctx.putImageData(image, 0, 0); }, - // Silence audio; we are only checking video. - onAudioSample() {}, - sampleRate: 44100, + onAudioSample(l, r) { + pushAudio(l, r); + }, + sampleRate: SAMPLE_RATE, }); // Expose an API for puppeteer to drive. @@ -44,6 +86,10 @@ loadRomBase64(b64) { // atob produces a binary string, which is exactly what jsnes wants. const bin = atob(b64); + // Wipe any stale audio samples left over from a previous + // ROM in the same page lifecycle (the runner reuses the + // puppeteer Page across ROMs in some configurations). + resetAudio(); nes.loadROM(bin); }, frame() { @@ -58,6 +104,83 @@ buttonUp(player, button) { nes.buttonUp(player, button); }, + // Return an FNV-1a hash (32-bit, hex) over the full captured + // stereo int16 audio buffer. This is the golden-compare key + // — tiny enough to commit per-ROM alongside the PNG hash, and + // stable across runs because samples are pre-quantized to + // int16 at capture. A silent ROM hashes to a single well- + // known value; any audio-producing ROM gets a distinct hash + // that will immediately flag driver regressions. + audioHash() { + let h = 2166136261 >>> 0; // FNV-1a offset basis + for (let i = 0; i < audioLen; i++) { + const l = audioLeft[i] & 0xffff; + const r = audioRight[i] & 0xffff; + h ^= l & 0xff; + h = Math.imul(h, 16777619); + h ^= (l >> 8) & 0xff; + h = Math.imul(h, 16777619); + h ^= r & 0xff; + h = Math.imul(h, 16777619); + h ^= (r >> 8) & 0xff; + h = Math.imul(h, 16777619); + } + return { hash: (h >>> 0).toString(16), samples: audioLen }; + }, + // Return a base64-encoded WAV file (16-bit stereo PCM at + // SAMPLE_RATE Hz) containing everything the harness has + // captured since the last ROM load. The runner only pulls + // this when it needs to write an `actual/.wav` for a + // failed diff — we keep the full samples in memory so no + // re-emulation is needed. + audioWavBase64() { + const n = audioLen; + const dataBytes = n * 4; // 2 channels * 2 bytes per sample + const headerBytes = 44; + const total = headerBytes + dataBytes; + const wav = new Uint8Array(total); + const dv = new DataView(wav.buffer); + // RIFF header + wav[0] = 0x52; // 'R' + wav[1] = 0x49; // 'I' + wav[2] = 0x46; // 'F' + wav[3] = 0x46; // 'F' + dv.setUint32(4, total - 8, true); + wav[8] = 0x57; // 'W' + wav[9] = 0x41; // 'A' + wav[10] = 0x56; // 'V' + wav[11] = 0x45; // 'E' + // fmt chunk + wav[12] = 0x66; // 'f' + wav[13] = 0x6d; // 'm' + wav[14] = 0x74; // 't' + wav[15] = 0x20; // ' ' + dv.setUint32(16, 16, true); // fmt chunk size + dv.setUint16(20, 1, true); // PCM + dv.setUint16(22, 2, true); // stereo + dv.setUint32(24, SAMPLE_RATE, true); // sample rate + dv.setUint32(28, SAMPLE_RATE * 4, true); // byte rate + dv.setUint16(32, 4, true); // block align + dv.setUint16(34, 16, true); // bits per sample + // data chunk + wav[36] = 0x64; // 'd' + wav[37] = 0x61; // 'a' + wav[38] = 0x74; // 't' + wav[39] = 0x61; // 'a' + dv.setUint32(40, dataBytes, true); + let off = 44; + for (let i = 0; i < n; i++) { + dv.setInt16(off, audioLeft[i], true); + dv.setInt16(off + 2, audioRight[i], true); + off += 4; + } + // Base64-encode in chunks to avoid stringify limits. + const chunks = []; + for (let i = 0; i < wav.length; i += 4096) { + chunks.push(String.fromCharCode.apply(null, wav.subarray(i, i + 4096))); + } + return btoa(chunks.join("")); + }, // Return a hash and non-zero-pixel count for the current canvas contents. frameStats() { const data = ctx.getImageData(0, 0, 256, 240).data; diff --git a/tests/emulator/run_examples.mjs b/tests/emulator/run_examples.mjs index ce81546..9bb87d0 100644 --- a/tests/emulator/run_examples.mjs +++ b/tests/emulator/run_examples.mjs @@ -2,14 +2,20 @@ // through a local `jsnes` (wrapped by `harness.html` in a // puppeteer-driven headless Chrome), lets it render ~180 frames, // grabs the raw canvas pixels, and diffs them byte-for-byte against -// a committed golden PNG under `goldens/`. +// a committed golden PNG under `goldens/`. It also hashes the +// audio samples jsnes produced during those frames and compares +// them against a committed `.audio.hash` golden in the same +// directory — a one-line FNV-1a hash of the full int16 stereo +// buffer. Silent programs all hash to the same well-known value; +// audio-producing programs get a distinct hash that will trip as +// soon as the audio driver changes behavior. // // The goldens are the whole contract. Any change to the compiler // (or any regression in jsnes, or any change to this harness that -// affects rendering) will change at least one golden, and the diff -// will fail CI loudly. That's the point — it's the only way to -// catch "silently emits wrong code" bugs without writing a -// full-fat CPU test vector per example. +// affects rendering or audio) will change at least one golden, +// and the diff will fail CI loudly. That's the point — it's the +// only way to catch "silently emits wrong code" bugs without +// writing a full-fat CPU test vector per example. // // Updating goldens: // @@ -18,17 +24,21 @@ // node run_examples.mjs --update-goldens // // When a diff is legitimate, rerun with that flag. It rewrites the -// PNGs in `goldens/` from whatever the harness just produced. Then -// check the new PNGs in git — `git diff goldens/*.png` lets you eye -// each change, and the commit message is where you document why. +// PNGs and audio hashes in `goldens/` from whatever the harness +// just produced. Then check the new files in git — `git diff +// goldens/` lets you eye each change, and the commit message is +// where you document why. // // When a diff is not legitimate, the runner writes: // // actual/.png the actual pixels for this run // actual/.diff.png red-highlighted pixel diff vs. golden +// actual/.wav the actual audio samples (stereo PCM) // // so you can upload them as CI artifacts or inspect locally. The -// `actual/` directory is gitignored. +// `actual/` directory is gitignored. Video diffs write the PNG + +// diff PNG; audio diffs write the WAV so you can listen to what +// actually came out of the emulator. import { promises as fs } from "node:fs"; import path from "node:path"; @@ -128,6 +138,32 @@ function buildDiff(expected, actual) { return { mismatched, firstDiff, rgba: out }; } +// ── File helpers ─────────────────────────────────────────────── + +// True if `filePath` exists and is readable. Used to decide +// whether a golden needs to be created (missing) vs diffed. +async function exists(filePath) { + try { + await fs.access(filePath); + return true; + } catch { + return false; + } +} + +// Write an audio golden file. The format is a single line: +// +// +// +// chosen to be tiny (under 30 bytes per ROM), trivially diffable +// by git, and human-readable. The sample count is a sanity check +// — it catches the rare case where two runs produce differing +// sample counts but the same hash (practically zero probability +// with FNV-1a, but cheap insurance). +async function writeAudioGolden(filePath, audio) { + await fs.writeFile(filePath, `${audio.hash} ${audio.samples}\n`); +} + // ── ROM discovery ────────────────────────────────────────────── async function listRoms() { @@ -152,7 +188,7 @@ async function runRomInHarness(page, rom) { await page.evaluate((b64) => window.nesHarness.loadRomBase64(b64), romB64); } catch (err) { bootError = String(err); - return { bootError, rgba: null }; + return { bootError, rgba: null, audio: null }; } try { @@ -163,7 +199,7 @@ async function runRomInHarness(page, rom) { FRAMES_TO_RUN, ); } catch (err) { - return { bootError: String(err), rgba: null }; + return { bootError: String(err), rgba: null, audio: null }; } // Frame count here is a no-op marker kept for readability. void SCREENSHOT_FRAME; @@ -174,9 +210,22 @@ async function runRomInHarness(page, rom) { return { bootError: `harness returned ${rgba.length} pixel bytes, expected ${PIXEL_BYTES}`, rgba: null, + audio: null, }; } - return { bootError: null, rgba }; + // Pull the audio hash (tiny — just a hex string + sample count). + // The WAV bytes themselves are only fetched on diff failure to + // keep the happy-path fast. + const audio = await page.evaluate(() => window.nesHarness.audioHash()); + return { bootError: null, rgba, audio }; +} + +/// Read the full WAV bytes from the harness. Only called when an +/// audio diff fails — we don't want to pay the round-trip cost on +/// every ROM. +async function fetchAudioWav(page) { + const wavB64 = await page.evaluate(() => window.nesHarness.audioWavBase64()); + return Buffer.from(wavB64, "base64"); } // ── Main ─────────────────────────────────────────────────────── @@ -218,10 +267,10 @@ async function main() { "window.nesHarness && document.getElementById('info').textContent === 'ready'", ); - const { bootError, rgba } = await runRomInHarness(page, rom); - await page.close(); + const { bootError, rgba, audio } = await runRomInHarness(page, rom); if (bootError || !rgba) { + await page.close(); failures++; const reason = `boot error: ${bootError ?? "no pixels"}`; results.push({ name: rom.name, status: "FAIL", reason }); @@ -230,38 +279,49 @@ async function main() { continue; } - const goldenPath = path.join(goldensDir, `${rom.name}.png`); - let goldenExists = true; - try { - await fs.access(goldenPath); - } catch { - goldenExists = false; - } + const goldenPngPath = path.join(goldensDir, `${rom.name}.png`); + const goldenAudioPath = path.join(goldensDir, `${rom.name}.audio.hash`); + + const pngExists = await exists(goldenPngPath); + const audioExists = await exists(goldenAudioPath); // ── Update mode ────────────────────────────────────── if (updateGoldens) { - await writePng(goldenPath, rgba); + await writePng(goldenPngPath, rgba); + await writeAudioGolden(goldenAudioPath, audio); results.push({ name: rom.name, status: "UPDATED", reason: null }); - console.log(`UPD ${rom.name.padEnd(28)} wrote golden`); + console.log( + `UPD ${rom.name.padEnd(28)} wrote png + audio (hash=${audio.hash}, n=${audio.samples})`, + ); + await page.close(); continue; } - // ── Missing golden ────────────────────────────────── - if (!goldenExists) { + // ── Missing goldens ───────────────────────────────── + // We treat either missing PNG or missing audio hash as a + // failure — both are part of the committed contract. The + // user fixes both in one shot with UPDATE_GOLDENS=1. + if (!pngExists || !audioExists) { failures++; - // Write the actual so the user can inspect, then bail. await writePng(path.join(actualDir, `${rom.name}.png`), rgba); - const reason = `no golden at ${path.relative(repoRoot, goldenPath)} — run with UPDATE_GOLDENS=1 to create`; + const wavBytes = await fetchAudioWav(page); + await fs.writeFile(path.join(actualDir, `${rom.name}.wav`), wavBytes); + const missing = []; + if (!pngExists) missing.push("png"); + if (!audioExists) missing.push("audio"); + const reason = `missing golden(s): ${missing.join(", ")} — run with UPDATE_GOLDENS=1 to create`; results.push({ name: rom.name, status: "MISSING", reason }); console.log(`MISS ${rom.name.padEnd(28)} ${reason}`); + await page.close(); continue; } - // ── Byte-for-byte diff ────────────────────────────── + // ── PNG byte-for-byte diff ────────────────────────── let golden; try { - golden = await decodeGolden(goldenPath); + golden = await decodeGolden(goldenPngPath); } catch (err) { + await page.close(); failures++; const reason = `failed to decode golden: ${err.message}`; results.push({ name: rom.name, status: "FAIL", reason }); @@ -269,31 +329,54 @@ async function main() { continue; } - // `rgba.equals(golden)` is an O(n) native memcmp — fastest - // path when they match, which is the common case. - if (rgba.equals(golden)) { + const pixelsMatch = rgba.equals(golden); + + // ── Audio hash diff ───────────────────────────────── + const expectedAudio = (await fs.readFile(goldenAudioPath, "utf8")).trim(); + const actualAudioLine = `${audio.hash} ${audio.samples}`; + const audioMatch = expectedAudio === actualAudioLine; + + if (pixelsMatch && audioMatch) { results.push({ name: rom.name, status: "OK", reason: null }); - console.log(`OK ${rom.name.padEnd(28)} exact match`); + console.log(`OK ${rom.name.padEnd(28)} exact video + audio match`); + await page.close(); continue; } - // Mismatch: write the actual and a diff PNG, record why. - const { mismatched, firstDiff, rgba: diffRgba } = buildDiff(golden, rgba); - const actualPath = path.join(actualDir, `${rom.name}.png`); - const diffPath = path.join(actualDir, `${rom.name}.diff.png`); - await writePng(actualPath, rgba); - await writePng(diffPath, diffRgba); + // Something mismatched — collect details for both diffs + // and write the actual artifacts. + const reasons = []; + const actualPngPath = path.join(actualDir, `${rom.name}.png`); + const actualWavPath = path.join(actualDir, `${rom.name}.wav`); + if (!pixelsMatch) { + const { mismatched, firstDiff, rgba: diffRgba } = buildDiff(golden, rgba); + const diffPath = path.join(actualDir, `${rom.name}.diff.png`); + await writePng(actualPngPath, rgba); + await writePng(diffPath, diffRgba); + reasons.push( + `${mismatched}/${WIDTH * HEIGHT} px differ; first at ` + + `(${firstDiff.x},${firstDiff.y}) expected [${firstDiff.expected.join(",")}] ` + + `got [${firstDiff.actual.join(",")}]`, + ); + } + if (!audioMatch) { + const wavBytes = await fetchAudioWav(page); + await fs.writeFile(actualWavPath, wavBytes); + reasons.push(`audio hash ${expectedAudio} -> ${actualAudioLine}`); + } failures++; - const reason = - `${mismatched}/${WIDTH * HEIGHT} pixels differ; ` + - `first at (${firstDiff.x},${firstDiff.y}) ` + - `expected [${firstDiff.expected.join(",")}] ` + - `got [${firstDiff.actual.join(",")}]`; + const reason = reasons.join("; "); results.push({ name: rom.name, status: "DIFF", reason }); console.log(`DIFF ${rom.name.padEnd(28)} ${reason}`); - console.log(` actual: ${path.relative(repoRoot, actualPath)}`); - console.log(` diff: ${path.relative(repoRoot, diffPath)}`); + if (!pixelsMatch) { + console.log(` actual png: ${path.relative(repoRoot, actualPngPath)}`); + console.log(` diff png: ${path.relative(repoRoot, path.join(actualDir, `${rom.name}.diff.png`))}`); + } + if (!audioMatch) { + console.log(` actual wav: ${path.relative(repoRoot, actualWavPath)}`); + } + await page.close(); } } finally { await browser.close();