/** * Playback maths. * * The device glue is cpal and cannot be compiled here, so what this covers is * everything the glue hands work to: the waveform bucketing, the resampler * that runs when a 44.1k file meets a 48k output, the per-frame channel sum * behind the mute and solo chips, and the arithmetic the elapsed time is read * from. Those are the parts that can be wrong in a way you would hear rather * than a way that fails outright. * * Run: node build/test-play.js */ const fs = require("fs"); const os = require("os"); const path = require("path"); const assert = require("assert"); const { build } = require("./make-sample.js"); const wav = require("./wav-convert.js"); const root = fs.mkdtempSync(path.join(os.tmpdir(), "bwf-play-")); const results = []; function check(name, fn) { try { fn(); results.push(["PASS", name]); } catch (e) { results.push(["FAIL", name + " — " + e.message]); } } /* ---- the waveform ---- */ const stereo = path.join(root, "stereo.wav"); fs.writeFileSync(stereo, build({ bits: 24, channels: 2, seconds: 1, amplitude: 0.5 })); check("peaks come back one column at a time, with the file's shape", () => { const p = wav.peaks(stereo, 100); assert.strictEqual(p.min.length, 100); assert.strictEqual(p.max.length, 100); assert.strictEqual(p.channels, 2); assert.strictEqual(p.sampleRate, 48000); assert.strictEqual(p.frames, 48000); assert(Math.abs(p.seconds - 1) < 1e-9, "duration reads " + p.seconds); }); check("a column spans the file, not just the first samples", () => { // A 440 Hz sine at 0.5: every column of a 1-second file holds whole // cycles, so each one should reach close to the peak in both directions. const p = wav.peaks(stereo, 50); for (let i = 0; i < 50; i++) { assert(p.max[i] > 0.4, "column " + i + " tops out at " + p.max[i]); assert(p.min[i] < -0.4, "column " + i + " bottoms out at " + p.min[i]); } }); check("the loudest channel is the one that shows", () => { // make-sample puts channel 2 at half of channel 1, so the picture should // follow channel 1 rather than an average of the two. const p = wav.peaks(stereo, 20); const loudest = Math.max.apply(null, Array.from(p.max)); assert(loudest > 0.49 && loudest <= 0.5001, "peak reads " + loudest); }); check("silence draws a line rather than nothing", () => { const quiet = path.join(root, "quiet.wav"); fs.writeFileSync(quiet, build({ bits: 24, channels: 1, seconds: 1, amplitude: 0 })); const p = wav.peaks(quiet, 16); assert(Array.from(p.min).every((v) => v === 0), "min is not flat"); assert(Array.from(p.max).every((v) => v === 0), "max is not flat"); }); check("more columns than frames still draws a line, not a row of gaps", () => { // Asked for 64 columns of an 8-frame file, the browser build gives every // column a sample by widening any empty one. Leaving the gaps at zero // would draw eight spikes on a flat line instead of a waveform, and the // two builds would disagree about the same file. const tiny = path.join(root, "tiny.wav"); fs.writeFileSync(tiny, build({ bits: 16, channels: 1, seconds: 1, sampleRate: 8 })); // 440 Hz sampled at 8 Hz is 55 whole cycles per sample, so make-sample's // sine comes out as eight zeroes. Written by hand instead. const shape = wav.parse(tiny); const bytes = Buffer.from(shape.buf); for (let f = 0; f < 8; f++) { bytes.writeInt16LE((f + 1) * 4000, shape.dataOffset + f * 2); } fs.writeFileSync(tiny, bytes); const p = wav.peaks(tiny, 64); assert.strictEqual(p.min.length, 64); assert.strictEqual(p.frames, 8); const drawn = Array.from(p.max).map((v) => Math.round(v * 32768 / 4000)); // Eight frames spread evenly over sixty-four columns: each one drawn // eight times, so the picture is a staircase rather than eight spikes. const want = []; for (let f = 1; f <= 8; f++) { for (let i = 0; i < 8; i++) { want.push(f); } } assert.deepStrictEqual(drawn, want, "columns read " + drawn.join(", ")); }); check("a column boundary lands where the browser build puts it", () => { // Ten frames into four columns is 2, 3, 2, 3 — not 3, 2, 3, 2. One frame // either side of every boundary, which is invisible on a real take and // exactly the sort of thing that quietly diverges between two builds. const ten = path.join(root, "ten.wav"); fs.writeFileSync(ten, build({ bits: 16, channels: 1, seconds: 1, sampleRate: 10 })); const source = wav.parse(ten); const raw = Buffer.from(source.buf); // A ramp, so which frames landed in which column can be read off directly. for (let f = 0; f < 10; f++) { raw.writeInt16LE(Math.round((f + 1) * 3000), source.dataOffset + f * 2); } fs.writeFileSync(ten, raw); const p = wav.peaks(ten, 4); const tops = Array.from(p.max).map((v) => Math.round(v * 32768 / 3000)); assert.deepStrictEqual(tops, [2, 5, 7, 10], "columns topped out at " + tops.join(", ")); }); /* ---- the resampler ---- */ /** Runs a whole signal through the resampler in blocks, as the reader does. */ function through(samples, channels, ratio, blockFrames) { const state = wav.resampleState(); const out = []; for (let at = 0; at < samples.length; at += blockFrames * channels) { const block = samples.slice(at, at + blockFrames * channels); wav.resample(block, channels, ratio, state, out); } return out; } check("matching rates come through untouched, sample for sample", () => { // The reader skips the resampler entirely at 1:1, but the maths has to // agree with that decision or a rate change would sound like a step. const input = new Float32Array(1000); for (let i = 0; i < input.length; i++) { input[i] = Math.sin(i / 10); } const out = through(input, 1, 1, 128); for (let i = 0; i < out.length; i++) { assert(Math.abs(out[i] - input[i]) < 1e-6, "sample " + i + " moved to " + out[i]); } }); check("a ramp stays a ramp across block boundaries", () => { // The join between two blocks is where a resampler goes wrong: it either // repeats a sample or drops one, and a straight line makes that visible. const frames = 4800; const input = new Float32Array(frames); for (let i = 0; i < frames; i++) { input[i] = i / frames; } const ratio = 44100 / 48000; const out = through(input, 1, ratio, 512); for (let i = 1; i < out.length; i++) { const step = out[i] - out[i - 1]; assert(step > 0, "the ramp went backwards at " + i); assert(Math.abs(step - ratio / frames) < 1e-6, "uneven step at " + i + ": " + step); } }); check("the output length follows the ratio", () => { const frames = 48000; const input = new Float32Array(frames); const out = through(input, 1, 44100 / 48000, 1024); // 44.1k of source at 48k out is about 48000/44100 as many frames, less // the one frame the interpolator always holds back. const want = frames * 48000 / 44100; assert(Math.abs(out.length - want) < 4, "got " + out.length + ", wanted about " + want); }); check("channels stay in their own lanes", () => { const frames = 600; const input = new Float32Array(frames * 2); for (let i = 0; i < frames; i++) { input[i * 2] = 1; input[i * 2 + 1] = -1; } const out = through(input, 2, 96000 / 48000, 64); for (let i = 0; i < out.length; i += 2) { assert(Math.abs(out[i] - 1) < 1e-6, "left drifted at " + i); assert(Math.abs(out[i + 1] + 1) < 1e-6, "right drifted at " + i); } }); check("one frame in is held, not emitted as a guess", () => { const state = wav.resampleState(); const out = []; wav.resample(new Float32Array([0.5]), 1, 0.5, state, out); assert.strictEqual(out.length, 0, "it invented " + out.length + " frames"); assert.strictEqual(state.carry.length, 1, "it didn't keep the frame"); }); /* ---- the channel sum behind the chips ---- */ check("every channel on sums them all", () => { assert.strictEqual(wav.mixFrame([0.25, 0.25], [1, 1]), 0.5); }); check("a muted channel contributes nothing", () => { assert.strictEqual(wav.mixFrame([0.5, 0.5], [1, 0]), 0.5); assert.strictEqual(wav.mixFrame([0.5, 0.5], [0, 0]), 0); }); check("soloing one track is every other gain at zero", () => { assert.strictEqual(wav.mixFrame([0.1, 0.7, 0.2, 0.3], [0, 1, 0, 0]), 0.7); }); check("a sum past full scale is clamped, not wrapped", () => { // Four hot tracks summed will pass 1.0. Wrapping sounds like the file is // broken; clamping sounds like the monitor is loud, which is the truth. assert.strictEqual(wav.mixFrame([0.5, 0.5, 0.5, 0.5], [1, 1, 1, 1]), 1); assert.strictEqual(wav.mixFrame([-0.5, -0.5, -0.5], [1, 1, 1]), -1); }); check("a channel with no gain given is treated as on", () => { // The gains array is whatever the frontend last sent; a file with more // channels than that must not fall silent. assert.strictEqual(wav.mixFrame([0.25, 0.25], [1]), 0.5); }); /* ---- the transport clock ---- */ check("elapsed counts from where the file was started", () => { // Seeking restarts the stream, so the frames the device has taken are // counted from the seek point rather than from the top of the file. assert.strictEqual(wav.elapsed(48000 * 10, 48000, 48000), 11); assert.strictEqual(wav.elapsed(0, 0, 48000), 0); }); check("elapsed uses the device's rate, not the file's", () => { // The frames counted are the ones written to the output, so a 44.1k file // on a 48k device still reports real seconds. assert.strictEqual(wav.elapsed(0, 48000, 48000), 1); }); check("no output means no clock, rather than a divide by zero", () => { assert.strictEqual(wav.elapsed(0, 1000, 0), 0); }); /* ---- the transform behind the spectrogram ---- */ check("the fast transform agrees with the slow, obviously-correct one", () => { // The FFT is written by hand because nothing here can be compiled where // it is written, so it is checked against a plain DFT: the one version // nobody can get subtly wrong. const n = 64; const signal = []; for (let i = 0; i < n; i++) { signal.push(Math.sin(i / 3) + 0.3 * Math.cos(i / 7) - 0.1 * i / n); } const slow = wav.dft(signal); const re = Float32Array.from(signal); const im = new Float32Array(n); wav.fft(re, im); for (let k = 0; k < n; k++) { assert(Math.abs(re[k] - slow[k][0]) < 1e-3, "bin " + k + " real: " + re[k] + " vs " + slow[k][0]); assert(Math.abs(im[k] - slow[k][1]) < 1e-3, "bin " + k + " imaginary: " + im[k] + " vs " + slow[k][1]); } }); check("a pure tone lands in the bin it belongs to", () => { // Eight cycles across 256 samples is bin 8, and nowhere else. const n = 256; const re = new Float32Array(n); const im = new Float32Array(n); for (let i = 0; i < n; i++) { re[i] = Math.sin(2 * Math.PI * 8 * i / n); } wav.fft(re, im); const power = []; for (let k = 0; k < n / 2; k++) { power.push(Math.sqrt(re[k] * re[k] + im[k] * im[k])); } let loudest = 0; for (let k = 1; k < power.length; k++) { if (power[k] > power[loudest]) loudest = k; } assert.strictEqual(loudest, 8, "the tone landed in bin " + loudest); }); check("a spectrogram is the shape it was asked for", () => { const p = wav.spectrogram(stereo, 40, 256, [1, 1]); assert.strictEqual(p.columns, 40); assert.strictEqual(p.bins, 128); assert.strictEqual(p.cells.length, 40 * 128); assert.strictEqual(p.sampleRate, 48000); }); check("the 440 Hz test tone shows up where 440 Hz belongs", () => { // 48k over a 1024-point window is 46.9 Hz a bin, so 440 Hz is bin 9. const p = wav.spectrogram(stereo, 8, 1024, [1, 1]); const bins = p.bins; let loudest = 1; for (let bin = 2; bin < bins; bin++) { if (p.cells[4 * bins + bin] > p.cells[4 * bins + loudest]) loudest = bin; } assert(Math.abs(loudest - 9) <= 1, "the tone read as bin " + loudest + ", not 9"); }); check("silence is the floor, not a picture of nothing in particular", () => { const quiet = path.join(root, "hush.wav"); fs.writeFileSync(quiet, build({ bits: 24, channels: 1, seconds: 1, amplitude: 0 })); const p = wav.spectrogram(quiet, 10, 256, [1]); assert(Array.from(p.cells).every((v) => v === 0), "silence came back with something in it"); }); check("muting a channel takes it out of the picture", () => { // The point of following the chips: solo the boom and you see the boom, // not the mono sum of everything. const both = wav.spectrogram(stereo, 6, 512, [1, 1]); const muted = wav.spectrogram(stereo, 6, 512, [0, 0]); assert(Array.from(muted.cells).every((v) => v === 0), "muting every channel still drew something"); assert(Array.from(both.cells).some((v) => v > 0), "nothing was drawn at all"); }); check("a meter reads each channel's own peak", () => { // What the audio callback raises into its meter cells: the largest // magnitude seen per channel across a block, positive or negative. const peaks = wav.channelPeaks([0.1, -0.9, 0.5, 0.2, -0.3, 0.4], 2); assert.deepStrictEqual(peaks, [0.5, 0.9], "read " + JSON.stringify(peaks) + " — a negative trough counts as level"); }); check("a meter on silence reads nothing, and no channel is left out", () => { assert.deepStrictEqual(wav.channelPeaks(new Array(64).fill(0), 4), [0, 0, 0, 0], "silence metered above zero"); // A ragged tail must not spill one channel's samples into another's peak. const ragged = wav.channelPeaks([0.2, 0.4, 0.8], 2); assert.deepStrictEqual(ragged, [0.2, 0.4], "a half frame at the end leaked: " + JSON.stringify(ragged)); assert.deepStrictEqual(wav.channelPeaks([], 2), [0, 0], "an empty block should read zero"); }); console.log(""); results.forEach(([s, n]) => console.log((s === "PASS" ? " ok " : " FAIL") + " " + n)); const failed = results.filter(([s]) => s === "FAIL").length; console.log("\n" + (results.length - failed) + "/" + results.length + " checks passed"); fs.rmSync(root, { recursive: true, force: true }); process.exit(failed ? 1 : 0);