#!/usr/bin/env node // Compare the spectral balance of several audio windows, in octave-ish bands. // Used to work out what makes the record's shouts sound bigger than ours. // node bandcmp.mjs "label=file[@start:dur]" ... import { execFileSync } from "node:child_process"; const SR = 48000; const BANDS = [[50, 100], [100, 200], [200, 400], [400, 800], [800, 1600], [1600, 3200], [3200, 6400], [6400, 12000]]; function fft(re, im) { const n = re.length; for (let i = 1, j = 0; i < n; i += 1) { let bit = n >> 1; for (; j & bit; bit >>= 1) j ^= bit; j ^= bit; if (i < j) { [re[i], re[j]] = [re[j], re[i]]; [im[i], im[j]] = [im[j], im[i]]; } } for (let len = 2; len <= n; len <<= 1) { const ang = -2 * Math.PI / len; const wr = Math.cos(ang), wi = Math.sin(ang); for (let i = 0; i < n; i += len) { let cr = 1, ci = 0; for (let k = 0; k < len / 2; k += 1) { const ur = re[i + k], ui = im[i + k]; const vr = re[i + k + len / 2] * cr - im[i + k + len / 2] * ci; const vi = re[i + k + len / 2] * ci + im[i + k + len / 2] * cr; re[i + k] = ur + vr; im[i + k] = ui + vi; re[i + k + len / 2] = ur - vr; im[i + k + len / 2] = ui - vi; const ncr = cr * wr - ci * wi; ci = cr * wi + ci * wr; cr = ncr; } } } } function spectrum(x) { const N = 4096, hop = 2048; const acc = new Float64Array(N / 2); let frames = 0; for (let s = 0; s + N <= x.length; s += hop) { const re = new Float64Array(N), im = new Float64Array(N); for (let i = 0; i < N; i += 1) re[i] = x[s + i] * (0.5 - 0.5 * Math.cos(2 * Math.PI * i / (N - 1))); fft(re, im); for (let k = 0; k < N / 2; k += 1) acc[k] += Math.sqrt(re[k] * re[k] + im[k] * im[k]); frames += 1; } for (let k = 0; k < acc.length; k += 1) acc[k] /= Math.max(1, frames); return acc; } const args = process.argv.slice(2).map((a) => { const [label, rest] = a.split("="); const m = rest.match(/^(.*?)(?:@([\d.]+):([\d.]+))?$/); return { label, file: m[1], ss: m[2], dur: m[3] }; }); const rows = []; for (const a of args) { const pre = a.ss ? ["-ss", a.ss, "-t", a.dur] : []; const raw = execFileSync("ffmpeg", ["-v", "error", ...pre, "-i", a.file, "-ac", "1", "-ar", String(SR), "-f", "s16le", "-"], { maxBuffer: 1 << 30 }); const i16 = new Int16Array(raw.buffer, raw.byteOffset, raw.length / 2); const x = new Float64Array(i16.length); for (let i = 0; i < i16.length; i += 1) x[i] = i16[i] / 32768; const sp = spectrum(x); const N = 4096; const band = BANDS.map(([lo, hi]) => { let e = 0; for (let k = Math.round(lo * N / SR); k < Math.min(sp.length, Math.round(hi * N / SR)); k += 1) e += sp[k] * sp[k]; return e; }); const total = band.reduce((s, v) => s + v, 0); // report each band RELATIVE to the clip's own total, so level differences do // not hide the shape difference -- shape is the question here. rows.push({ label: a.label, pct: band.map((v) => (100 * v / total)) }); } console.log("share of energy per band (%), so level is factored out:"); console.log("band".padEnd(14) + rows.map((r) => r.label.padStart(11)).join("")); BANDS.forEach(([lo, hi], i) => { console.log(`${lo}-${hi}`.padEnd(14) + rows.map((r) => r.pct[i].toFixed(1).padStart(11)).join("")); });