import { test, expect } from "bun:test"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { readWav, writeWav, toMono, resample } from "../../src/audio/wav.ts"; import { pan, applyFx, applyReverb } from "../../src/audio/fx.ts"; import { mixStems } from "../../src/audio/mix.ts"; import { renderStems } from "../../src/audio/render.ts"; import { generateSong } from "../../src/gen/generate.ts"; import { GUITAR_STANDARD, type Audio, type Song } from "../../src/core/types.ts"; const SF2 = new Uint8Array(await Bun.file("node_modules/@coderline/alphatab/dist/soundfont/sonivox.sf2").arrayBuffer()); const rms = (x: Float32Array) => Math.sqrt(x.reduce((s, v) => s + v * v, 0) / Math.max(1, x.length)); test("WAV round trip (PCM16)", async () => { const sr = 44100; const ch = () => Float32Array.from({ length: sr }, () => Math.random() * 2 - 1); const a: Audio = { sampleRate: sr, channels: [ch(), ch()] }; const p = join(tmpdir(), `stsheets-rt-${process.pid}.wav`); await writeWav(p, a); const b = await readWav(p); expect(b.sampleRate).toBe(sr); expect(b.channels.length).toBe(2); expect(b.channels[0]!.length).toBe(sr); let max = 0; for (let c = 0; c < 2; c++) for (let i = 0; i < sr; i++) max = Math.max(max, Math.abs(a.channels[c]![i]! - b.channels[c]![i]!)); expect(max).toBeLessThan(1e-4); }); test("toMono averages; resample preserves a tone's frequency/level", () => { expect(Array.from(toMono({ sampleRate: 1, channels: [Float32Array.of(1, 0), Float32Array.of(0, 1)] }))).toEqual([0.5, 0.5]); const sr = 44100, f = 440; const x = Float32Array.from({ length: sr }, (_, i) => Math.sin((2 * Math.PI * f * i) / sr)); const y = resample(x, sr, 22050); expect(y.length).toBe(22050); const ref = Float32Array.from({ length: 22050 }, (_, i) => Math.sin((2 * Math.PI * f * i) / 22050)); let err = 0; for (let i = 100; i < 22050 - 100; i++) err = Math.max(err, Math.abs(y[i]! - ref[i]!)); expect(err).toBeLessThan(5e-3); }); test("pan(-1) silences the right channel; centre is unity", () => { const n = 4410; const m = Float32Array.from({ length: n }, (_, i) => Math.sin(i / 10)); const a: Audio = { sampleRate: 44100, channels: [m, m.slice()] }; const p = pan(a, -1); expect(rms(p.channels[1]!)).toBeLessThan(1e-3 * rms(p.channels[0]!)); const c = pan(a, 0); expect(rms(c.channels[0]!) / rms(m)).toBeCloseTo(1, 3); }); test("fx keep length and are deterministic", () => { const n = 22050; const m = Float32Array.from({ length: n }, (_, i) => 0.5 * Math.sin(i / 7)); const a: Audio = { sampleRate: 44100, channels: [m, m.slice()] }; for (const p of ["none", "clean", "clean-chorus", "light-drive"] as const) { const o1 = applyFx(a, p, 44100), o2 = applyFx(a, p, 44100); expect(o1.channels[0]!.length).toBe(n); expect(o1.channels[0]).toEqual(o2.channels[0]!); expect(rms(o1.channels[0]!)).toBeGreaterThan(0.05); } const rv = applyReverb(a); expect(rv.channels[0]!.length).toBe(n); expect(rv.channels[0]).not.toEqual(m); }); test("mixStems peaks at -1 dBFS", () => { const x = Float32Array.from({ length: 1000 }, (_, i) => Math.sin(i / 5)); const mk = (g: number): Audio => ({ sampleRate: 44100, channels: [x.map((v) => v * g), x.map((v) => v * g)] }); const mix = mixStems([{ audio: mk(0.3), gainDb: 0 }, { audio: mk(0.2), gainDb: -6 }]); let peak = 0; for (const ch of mix.channels) for (const v of ch) peak = Math.max(peak, Math.abs(v)); expect(Math.abs(peak - Math.pow(10, -1 / 20))).toBeLessThan(1e-3); }); test("renderStems on t1b: one stem per track, equal length, audible", () => { const song = generateSong("t1b", 1); const stems = renderStems(song, SF2); expect(stems.size).toBe(3); const secs = (song.bars * song.timeSig[0] * 60) / song.bpm; const lens = new Set(); for (const [name, a] of stems) { lens.add(a.channels[0]!.length); expect(a.channels.length).toBe(2); expect(a.channels[0]!.length / a.sampleRate).toBeGreaterThanOrEqual(secs); expect(rms(a.channels[0]!)).toBeGreaterThan(1e-3); expect(name.length).toBeGreaterThan(0); } expect(lens.size).toBe(1); }, 60000); test("stems are isolated: stem energies sum to roughly the full-mix energy", () => { const song = generateSong("t1b", 1); const stems = renderStems(song, SF2); // Each track alone must differ from the others (muting works) and have limited overlap. const arr = [...stems.values()].map((a) => a.channels[0]!); const e = arr.map((x) => x.reduce((s, v) => s + v * v, 0)); const dot = (a: Float32Array, b: Float32Array) => { let s = 0; for (let i = 0; i < a.length; i++) s += a[i]! * b[i]!; return s; }; for (let i = 0; i < arr.length; i++) for (let j = i + 1; j < arr.length; j++) { expect(Math.abs(dot(arr[i]!, arr[j]!)) / Math.sqrt(e[i]! * e[j]!)).toBeLessThan(0.3); } }, 60000); test("onset latency: guitar note at tick 960 (bpm 60) starts at 1.0 s +-20 ms", () => { const song: Song = { title: "lat", tier: "t", seed: 1, bpm: 60, timeSig: [4, 4], bars: 2, key: "C", tracks: [{ name: "Lead", role: "guitar", program: 25, tuning: GUITAR_STANDARD, pan: 0, fx: "none", gainDb: 0, notes: [{ tick: 960, dur: 960, pitch: 64, string: 1, fret: 0 }], }], }; const a = renderStems(song, SF2).get("Lead")!; const x = a.channels[0]!; const win = Math.round(0.005 * a.sampleRate); const env: number[] = []; for (let i = 0; i + win <= x.length; i += 1) { if (i % 8 !== 0) { continue; } let s = 0; for (let k = 0; k < win; k++) s += x[i + k]! * x[i + k]!; env.push(Math.sqrt(s / win)); } const max = Math.max(...env); const first = env.findIndex((v) => v > 0.1 * max); const t = (first * 8) / a.sampleRate; console.log(`measured onset: ${t.toFixed(4)} s`); expect(t).toBeGreaterThanOrEqual(0.98); expect(t).toBeLessThanOrEqual(1.02); });