From 32d96cadcc7c2e6f163dbac7154483d39bed64e7 Mon Sep 17 00:00:00 2001 From: Lucas Winther Date: Fri, 30 Oct 2026 02:52:50 +0100 Subject: [PATCH] fix: dataset layout data//, mix tests, cli hardening Co-Authored-By: Claude Sonnet 5.5 --- src/bench/cli.ts | 7 +++++-- src/bench/dataset.ts | 6 +++--- tests/audio/mix.test.ts | 25 +++++++++++++++++++++++++ tests/bench/dataset.test.ts | 20 ++++++++++++++++++++ 4 files changed, 53 insertions(+), 5 deletions(-) create mode 100644 tests/audio/mix.test.ts diff --git a/src/bench/cli.ts b/src/bench/cli.ts index 826d013..7b55adf 100644 --- a/src/bench/cli.ts +++ b/src/bench/cli.ts @@ -15,7 +15,10 @@ function parseSeeds(s: string): number[] { function arg(args: string[], name: string, def: string): string { const i = args.indexOf(`--${name}`); - return i >= 0 && args[i + 1] ? args[i + 1]! : def; + if (i < 0) return def; + const v = args[i + 1]; + if (v === undefined || v.startsWith("--")) throw new Error(`missing value for --${name}`); + return v; } async function main() { @@ -23,7 +26,7 @@ async function main() { switch (cmd) { case "gen": { const tiers = arg(rest, "tiers", "t1a,t1b,t1c,t1d").split(",") as TierId[]; - for (const t of tiers) if (!(t in TIERS)) throw new Error(`unknown tier: ${t}`); + for (const t of tiers) if (!Object.hasOwn(TIERS, t)) throw new Error(`unknown tier: ${t}`); const seeds = parseSeeds(arg(rest, "seeds", "1-5")); const root = arg(rest, "root", "data"); for (const t of tiers) for (const s of seeds) { diff --git a/src/bench/dataset.ts b/src/bench/dataset.ts index 4fe6244..9f1579d 100644 --- a/src/bench/dataset.ts +++ b/src/bench/dataset.ts @@ -10,16 +10,16 @@ import { mixStemsScaled } from "../audio/mix.ts"; import { readWav, writeWav } from "../audio/wav.ts"; import { encodeMp3 } from "../audio/mp3.ts"; -const SF2_PATH = "node_modules/@coderline/alphatab/dist/soundfont/sonivox.sf2"; +const SF2_PATH = join(import.meta.dir, "../../node_modules/@coderline/alphatab/dist/soundfont/sonivox.sf2"); const SR = 44100; /** - * Build one song into `/-/`. Idempotent: skipped when mix.mp3 exists and song.json matches. + * Build one song into `///`. Idempotent: skipped when mix.mp3 exists and song.json matches. * stems/.wav are exactly each track's contribution to mix.wav (post-fx, post-pan, gain, normalisation), * so the stems sum to the mix up to PCM16 rounding. */ export async function buildSong(tier: TierId, seed: number, root = "data"): Promise { - const dir = join(root, `${tier}-${seed}`); + const dir = join(root, tier, String(seed)); const song = generateSong(tier, seed); const songJson = JSON.stringify(song, null, 1); const jsonFile = Bun.file(join(dir, "song.json")); diff --git a/tests/audio/mix.test.ts b/tests/audio/mix.test.ts new file mode 100644 index 0000000..8d5c889 --- /dev/null +++ b/tests/audio/mix.test.ts @@ -0,0 +1,25 @@ +import { test, expect } from "bun:test"; +import { mixStemsScaled } from "../../src/audio/mix.ts"; +import type { Audio } from "../../src/core/types.ts"; + +test("mixStemsScaled: scaled stems sum to mix; scale is the normalisation factor", () => { + const mk = (n: number, amp: number): Audio => { + const x = Float32Array.from({ length: n }, (_, i) => amp * Math.sin(i / 7)); + return { sampleRate: 44100, channels: [x, x.slice()] }; + }; + const a = mk(1000, 0.9), b = mk(700, 0.8); + const { mix, scaled, scale } = mixStemsScaled([{ audio: a, gainDb: 0 }, { audio: b, gainDb: -3 }]); + // unnormalised peak + const gb = Math.pow(10, -3 / 20); + let peak = 0; + for (let i = 0; i < 1000; i++) peak = Math.max(peak, Math.abs(a.channels[0]![i]! + (i < 700 ? b.channels[0]![i]! * gb : 0))); + expect(peak).toBeGreaterThan(1); + expect(Math.abs(scale - Math.pow(10, -1 / 20) / peak)).toBeLessThan(1e-6); + expect(scaled.length).toBe(2); + let maxDiff = 0; + for (let c = 0; c < 2; c++) for (let i = 0; i < 1000; i++) { + const s = scaled[0]!.channels[c]![i]! + scaled[1]!.channels[c]![i]!; + maxDiff = Math.max(maxDiff, Math.abs(s - mix.channels[c]![i]!)); + } + expect(maxDiff).toBeLessThan(1e-6); +}); diff --git a/tests/bench/dataset.test.ts b/tests/bench/dataset.test.ts index 4f388c1..ff76f8c 100644 --- a/tests/bench/dataset.test.ts +++ b/tests/bench/dataset.test.ts @@ -35,3 +35,23 @@ test("buildSong writes all artefacts; loadSongDir reads them; stems sum to mix; await rm(root, { recursive: true, force: true }); } }, 120_000); + +test("multi-track t1b: stems on disk sum to mix.wav", async () => { + const root = await mkdtemp(join(tmpdir(), "stsheets-ds-")); + try { + const dir = await buildSong("t1b", 1, root); + expect(dir).toBe(join(root, "t1b", "1")); + const { song, mix, stems } = await loadSongDir(dir); + expect(song.tracks.length).toBeGreaterThan(1); + expect(stems.size).toBe(song.tracks.length); + let maxDiff = 0; + for (let c = 0; c < mix.channels.length; c++) for (let i = 0; i < mix.channels[c]!.length; i++) { + let s = 0; + for (const a of stems.values()) s += a.channels[c]![i] ?? 0; + maxDiff = Math.max(maxDiff, Math.abs(s - mix.channels[c]![i]!)); + } + expect(maxDiff).toBeLessThan(2e-3); + } finally { + await rm(root, { recursive: true, force: true }); + } +}, 120_000);