// Determinism is the contract: same song, same seed, same frames -> same // pixels. This holds even in processes started at different times, which is // what makes preview === export possible. import { test } from 'node:test'; import assert from 'node:assert/strict'; import { Rng, hashString, hashSamples } from '../src/rng.js'; import { Fft, hann } from '../src/audio/fft.js'; import { analyse } from '../src/audio/analyse.js'; import { decodeWav, mono } from '../src/audio/wav.js'; test('rng is seeded and repeatable', () => { const a = new Rng(42); const b = new Rng(42); assert.deepEqual(Array.from({ length: 20 }, () => a.next()), Array.from({ length: 20 }, () => b.next())); const c = new Rng(43); assert.notDeepEqual(Array.from({ length: 20 }, () => a.next()), Array.from({ length: 20 }, () => c.next())); assert.ok(hashString('drone') !== hashString('runner')); }); test('fft finds a sine peak', () => { const N = 2048; const fft = new Fft(N); const re = new Float32Array(N); const im = new Float32Array(N); const win = hann(N); const freq = 440; const sr = 44100; for (let i = 0; i < N; i++) re[i] = Math.sin((2 * Math.PI * freq * i) / sr) * win[i]; fft.forward(re, im); let best = 0; let peakBin = 0; for (let b = 10; b < N / 2; b++) { const m = Math.hypot(re[b], im[b]); if (m > best) { best = m; peakBin = b; } } // Parabolic interpolation of the peak bin. const m0 = Math.hypot(re[peakBin - 1], im[peakBin - 1]); const m1 = best; const m2 = Math.hypot(re[peakBin + 1], im[peakBin + 1]); const delta = 0.5 * (m0 - m2) / (m0 - 2 * m1 + m2); const detected = ((peakBin + delta) / N) * sr; assert.ok(Math.abs(detected - freq) < 1, `detected ${detected.toFixed(2)} vs 440`); }); test('analysis is deterministic for the same PCM', () => { const pcm = synth(3); const a = analyse(pcm, 44100); const b = analyse(pcm, 44100); assert.deepEqual(Array.from(a.energy), Array.from(b.energy)); assert.equal(a.summary.key, b.summary.key); assert.ok(a.frames > 50); }); test('two different signals analyse differently', () => { const low = analyse(synth(3, 110), 44100); const high = analyse(synth(3, 990), 44100); assert.ok(Math.abs(low.summary.centroid - high.summary.centroid) > 0.1); assert.ok(low.summary.flatness < 0.4, 'a pure tone is not noisy'); }); test('wav decode: mono pcm round-trips', () => { // A tiny hand-built 8-sample 16-bit mono WAV. const sr = 8000; const samples = [0, 16384, 32767, -16384, -32768, 0, 1000, -1000]; const data = Buffer.alloc(44 + samples.length * 2); data.write('RIFF', 0); data.writeUInt32LE(36 + samples.length * 2, 4); data.write('WAVEfmt ', 8); data.writeUInt32LE(16, 16); data.writeUInt16LE(1, 20); data.writeUInt16LE(1, 22); data.writeUInt32LE(sr, 24); data.writeUInt32LE(sr * 2, 28); data.writeUInt16LE(2, 32); data.writeUInt16LE(16, 34); data.write('data', 36); data.writeUInt32LE(samples.length * 2, 40); for (let i = 0; i < samples.length; i++) data.writeInt16LE(samples[i], 44 + i * 2); const wav = decodeWav(new Uint8Array(data)); assert.ok(wav); assert.equal(wav.sampleRate, sr); const arr = Array.from(mono(wav.channels)); assert.equal(arr[2], 32767 / 32768); assert.equal(arr[4], -32768 / 32768); }); test('pcm hash is content-based and stable', () => { // Long enough that the content stride sees both samples. const make = (v) => { const a = new Float32Array(3000); a[1994] = v; // a location the stride actually visits (997 → 1994) return a; }; assert.equal(hashSamples(make(0.5)), hashSamples(make(0.5))); assert.notEqual(hashSamples(make(0.5)), hashSamples(make(-0.5))); }); /** A few seconds of low/high sine, enough for a stable beat free analysis. */ function synth(seconds, freq = 220) { const sr = 44100; const n = Math.floor(sr * seconds); const out = new Float32Array(n); for (let i = 0; i < n; i++) out[i] = Math.sin((2 * Math.PI * freq * i) / sr) * 0.5; return out; }