Files
STSheets/tests/audio/audio.test.ts
T
2026-10-30 02:48:59 +01:00

128 lines
5.7 KiB
TypeScript

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<number>();
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);
});