BWF Analyser: browser page and macOS app
Reads and edits BWF metadata for production sound. One source tree builds a single self-contained page and a native Tauri app with a Rust audio engine and WAV writer. Around 370 checks across seven test suites. First commit of the existing state, so that from here every change can be seen and undone.
This commit is contained in:
@@ -0,0 +1,348 @@
|
||||
/**
|
||||
* 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);
|
||||
Reference in New Issue
Block a user