e8da88b6c1
The FastAPI backend kept one analysis session shared by every visitor and spooled each upload to the server's /tmp, and the browser copied audio into persistent OPFS storage. None of that fits a tool for unreleased masters, so the web UI is now a static site that decodes and analyses in the tab: - web/decode.js: WAV/AIFF PCM and float read directly with libsndfile's scaling (bit-identical to librosa.load; Chromium's WAV decoder scales positive int16 by 1/32767), other formats via decodeAudioData on an OfflineAudioContext at the header-sniffed native rate. ID3 song names. - web/dsp.js: ports of every Metric.compute with the same numerics (pyloudnorm K-weighting via lfilter's DF2T recurrence, BS.1770 gating, EBU LRA, resample_poly's Kaiser filter for true peak, librosa.stft and amplitude_to_db), streaming passes with no signal-length intermediates. - web/metrics.js: build_spec ports producing the existing plot payload. - web/analysis-worker.js + app.js: per-tab track list and result cache, a worker job queue where the visible metric jumps background prefetch, and the renderer, which now also draws the metrics' own reference lines. Deployment is nginx-unprivileged serving web/ read-only with Content-Security-Policy connect-src 'none', so the page cannot make network requests at all, and no access log. No secure context is needed, so plain http on a LAN works. The Python web backend, its smoke test, the web extra and the OPFS store are removed; the desktop app is unchanged. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015UXA39Sh9coAx4FhRkd8R2
695 lines
23 KiB
JavaScript
695 lines
23 KiB
JavaScript
// SPDX-License-Identifier: Apache-2.0
|
|
//
|
|
// Metric computation, ported from `metrics.py` / `master_core.py` to run in the
|
|
// browser.
|
|
//
|
|
// Pure functions over a mono Float32Array at the file's native sample rate — no
|
|
// DOM, no Web Audio — so the same module runs inside the analysis Web Worker and
|
|
// under Node for the parity tests (tests/parity), which check every metric
|
|
// against the Python implementation on identical samples.
|
|
//
|
|
// The numerics follow the Python where it matters:
|
|
// * running sums of squares are sequential float64 sums, like np.cumsum, but
|
|
// sampled only at the window/block edges that are read — the same values
|
|
// without an N-length array;
|
|
// * K-weighting is pyloudnorm's biquad pair run through scipy.signal.lfilter's
|
|
// direct-form-II-transposed recurrence in float64, streamed sample by sample;
|
|
// * true peak reproduces scipy.signal.resample_poly(y, 4, 1) — the same 81-tap
|
|
// Kaiser(beta=5) firwin filter and zero-padded alignment — then an exact
|
|
// sliding maximum (what maximum_filter1d sampled at window centres yields);
|
|
// * the spectrogram follows librosa.stft (centred, zero padded, periodic Hann)
|
|
// and amplitude_to_db(ref=np.max, top_db=80), dropping the DC row.
|
|
// What remains is float32 rounding where numpy works in float32 (resampling,
|
|
// RMS, spectrogram dB): far below anything a plot or readout can show.
|
|
|
|
const EPS = 1e-12; // keeps 20*log10 finite on perfect silence
|
|
const LUFS_OFFSET = -0.691; // BS.1770
|
|
const ABS_GATE = -70.0; // BS.1770 absolute gate
|
|
const SILENCE_FLOOR = -70.0;
|
|
|
|
const toDbfs = (v) => 20 * Math.log10(Math.max(v, EPS));
|
|
|
|
/** Number of full windows of `windowN` stepped by `hopN` over `n` samples. */
|
|
function windowCount(n, windowN, hopN) {
|
|
return 1 + Math.floor((n - windowN) / hopN);
|
|
}
|
|
|
|
function sampleStream(x) {
|
|
let i = 0;
|
|
return () => x[i++];
|
|
}
|
|
|
|
function absStream(x) {
|
|
let i = 0;
|
|
return () => Math.abs(x[i++]);
|
|
}
|
|
|
|
function lowerBound(sorted, value) {
|
|
let lo = 0;
|
|
let hi = sorted.length;
|
|
while (lo < hi) {
|
|
const mid = (lo + hi) >>> 1;
|
|
if (sorted[mid] < value) lo = mid + 1;
|
|
else hi = mid;
|
|
}
|
|
return lo;
|
|
}
|
|
|
|
/**
|
|
* csq(i) = sum of x[k]^2 for k < i — np.concatenate(([0], np.cumsum(x * x))) —
|
|
* at the requested indices only.
|
|
*
|
|
* `next()` yields x[0], x[1], ... in order, so a filtered signal can be streamed
|
|
* straight in. Indices past `n` read csq(n), i.e. a zero-padded tail.
|
|
*/
|
|
function prefixSquares(n, next, indexArrays) {
|
|
let total = 0;
|
|
for (const a of indexArrays) total += a.length;
|
|
const marks = new Float64Array(total);
|
|
let offset = 0;
|
|
for (const a of indexArrays) {
|
|
marks.set(a, offset);
|
|
offset += a.length;
|
|
}
|
|
marks.sort();
|
|
|
|
const values = new Float64Array(marks.length);
|
|
let m = 0;
|
|
while (m < marks.length && marks[m] <= 0) values[m++] = 0;
|
|
let acc = 0;
|
|
for (let k = 0; k < n && m < marks.length; k++) {
|
|
const v = next();
|
|
acc += v * v;
|
|
while (m < marks.length && marks[m] === k + 1) values[m++] = acc;
|
|
}
|
|
while (m < marks.length) values[m++] = acc;
|
|
return (index) => values[lowerBound(marks, index)];
|
|
}
|
|
|
|
/**
|
|
* max(x[s .. s + windowN)) for s = 0, hopN, 2*hopN, ... (`count` windows).
|
|
*
|
|
* A monotonic deque: one O(N) pass, O(windowN + hopN) memory, exact maxima.
|
|
*/
|
|
function windowMaxima(count, windowN, hopN, next) {
|
|
const out = new Float64Array(Math.max(count, 0));
|
|
if (count <= 0) return out;
|
|
const cap = windowN + hopN + 1;
|
|
const idx = new Float64Array(cap);
|
|
const val = new Float64Array(cap);
|
|
let head = 0;
|
|
let size = 0;
|
|
let w = 0;
|
|
let end = windowN; // exclusive end of window w
|
|
const last = (count - 1) * hopN + windowN;
|
|
|
|
for (let k = 0; k < last; k++) {
|
|
const v = next();
|
|
while (size > 0) {
|
|
let back = head + size - 1;
|
|
if (back >= cap) back -= cap;
|
|
if (val[back] <= v) size--;
|
|
else break;
|
|
}
|
|
let pos = head + size;
|
|
if (pos >= cap) pos -= cap;
|
|
idx[pos] = k;
|
|
val[pos] = v;
|
|
size++;
|
|
|
|
if (k + 1 === end) {
|
|
const start = end - windowN;
|
|
while (idx[head] < start) {
|
|
head++;
|
|
if (head === cap) head = 0;
|
|
size--;
|
|
}
|
|
out[w++] = val[head];
|
|
if (w === count) break;
|
|
end += hopN;
|
|
}
|
|
}
|
|
return out;
|
|
}
|
|
|
|
// ---- K-weighting and loudness ------------------------------------------------
|
|
|
|
/** pyloudnorm IIRfilter.generate_coefficients, normalised by a0. */
|
|
function rbjBiquad(type, G, Q, fc, rate) {
|
|
const A = 10 ** (G / 40.0);
|
|
const w0 = 2.0 * Math.PI * (fc / rate);
|
|
const alpha = Math.sin(w0) / (2.0 * Q);
|
|
let b0, b1, b2, a0, a1, a2;
|
|
if (type === 'high_shelf') {
|
|
b0 = A * (A + 1 + (A - 1) * Math.cos(w0) + 2 * Math.sqrt(A) * alpha);
|
|
b1 = -2 * A * (A - 1 + (A + 1) * Math.cos(w0));
|
|
b2 = A * (A + 1 + (A - 1) * Math.cos(w0) - 2 * Math.sqrt(A) * alpha);
|
|
a0 = A + 1 - (A - 1) * Math.cos(w0) + 2 * Math.sqrt(A) * alpha;
|
|
a1 = 2 * (A - 1 - (A + 1) * Math.cos(w0));
|
|
a2 = A + 1 - (A - 1) * Math.cos(w0) - 2 * Math.sqrt(A) * alpha;
|
|
} else if (type === 'high_pass') {
|
|
b0 = (1 + Math.cos(w0)) / 2;
|
|
b1 = -(1 + Math.cos(w0));
|
|
b2 = (1 + Math.cos(w0)) / 2;
|
|
a0 = 1 + alpha;
|
|
a1 = -2 * Math.cos(w0);
|
|
a2 = 1 - alpha;
|
|
} else {
|
|
throw new Error(`unsupported filter type ${type}`);
|
|
}
|
|
return { b0: b0 / a0, b1: b1 / a0, b2: b2 / a0, a1: a1 / a0, a2: a2 / a0 };
|
|
}
|
|
|
|
/** pyloudnorm Meter(rate) 'K-weighting': high shelf, then high pass. */
|
|
export function kWeightingCoefficients(rate) {
|
|
return [
|
|
rbjBiquad('high_shelf', 4.0, 1 / Math.sqrt(2), 1500.0, rate),
|
|
rbjBiquad('high_pass', 0.0, 0.5, 38.0, rate),
|
|
];
|
|
}
|
|
|
|
/**
|
|
* The K-weighted signal as a stream (metrics._kweight).
|
|
*
|
|
* Each stage is scipy's lfilter recurrence — y = z0 + b0*x;
|
|
* z0 = z1 + x*b1 - y*a1; z1 = x*b2 - y*a2 — in the same operation order, so the
|
|
* filtered samples match pyloudnorm's whole-array filtering.
|
|
*/
|
|
function kWeightedStream(mono, rate) {
|
|
const [s, p] = kWeightingCoefficients(rate);
|
|
let i = 0;
|
|
let sz0 = 0, sz1 = 0, pz0 = 0, pz1 = 0;
|
|
return () => {
|
|
const x = mono[i++];
|
|
const y1 = sz0 + s.b0 * x;
|
|
sz0 = sz1 + x * s.b1 - y1 * s.a1;
|
|
sz1 = x * s.b2 - y1 * s.a2;
|
|
const y2 = pz0 + p.b0 * y1;
|
|
pz0 = pz1 + y1 * p.b1 - y2 * p.a1;
|
|
pz1 = y1 * p.b2 - y2 * p.a2;
|
|
return y2;
|
|
};
|
|
}
|
|
|
|
/** np.round: round half to even. */
|
|
function roundHalfEven(x) {
|
|
const r = Math.round(x);
|
|
return Math.abs(x % 1) === 0.5 && r % 2 !== 0 ? r - 1 : r;
|
|
}
|
|
|
|
/** Block edges as metrics._block_loudness lays them out, or null when none fit. */
|
|
function blockEdges(nSignal, sr, blockS, stepPct) {
|
|
const T = nSignal / sr;
|
|
const nBlocks = Math.trunc(roundHalfEven((T - blockS) / (blockS * stepPct)) + 1);
|
|
if (!(nBlocks >= 1)) return null;
|
|
const lo = new Float64Array(nBlocks);
|
|
const up = new Float64Array(nBlocks);
|
|
for (let j = 0; j < nBlocks; j++) {
|
|
lo[j] = Math.trunc(blockS * (j * stepPct) * sr);
|
|
up[j] = Math.min(Math.trunc(blockS * (j * stepPct + 1) * sr), nSignal);
|
|
}
|
|
return { lo, up, n: nBlocks, divisor: blockS * sr };
|
|
}
|
|
|
|
function blockLoudness(blocks, csq) {
|
|
const z = new Float64Array(blocks.n);
|
|
const l = new Float64Array(blocks.n);
|
|
for (let j = 0; j < blocks.n; j++) {
|
|
z[j] = (csq(blocks.up[j]) - csq(blocks.lo[j])) / blocks.divisor;
|
|
l[j] = LUFS_OFFSET + 10.0 * Math.log10(z[j]);
|
|
}
|
|
return { z, l };
|
|
}
|
|
|
|
/** metrics._integrated_lufs: two-stage gated mean of the 400 ms blocks. */
|
|
function gatedIntegrated(z, l) {
|
|
let sum = 0;
|
|
let count = 0;
|
|
for (let j = 0; j < l.length; j++) {
|
|
if (l[j] >= ABS_GATE) { sum += z[j]; count++; }
|
|
}
|
|
if (!count) return -Infinity;
|
|
const gammaR = LUFS_OFFSET + 10.0 * Math.log10(sum / count) - 10.0;
|
|
sum = 0;
|
|
count = 0;
|
|
for (let j = 0; j < l.length; j++) {
|
|
if (l[j] > gammaR && l[j] > ABS_GATE) { sum += z[j]; count++; }
|
|
}
|
|
if (!count) return -Infinity;
|
|
return LUFS_OFFSET + 10.0 * Math.log10(sum / count);
|
|
}
|
|
|
|
/** np.percentile(values, q), default 'linear' method, on a sorted array. */
|
|
function percentileSorted(sorted, q) {
|
|
const index = (q / 100) * (sorted.length - 1);
|
|
const lo = Math.floor(index);
|
|
const hi = Math.min(lo + 1, sorted.length - 1);
|
|
const t = index - lo;
|
|
const a = sorted[lo];
|
|
const b = sorted[hi];
|
|
return t >= 0.5 ? b - (b - a) * (1 - t) : a + (b - a) * t;
|
|
}
|
|
|
|
/** metrics._loudness_range: EBU Tech 3342 LRA from 3 s block loudness. */
|
|
function loudnessRange(l) {
|
|
const absGated = [];
|
|
for (let j = 0; j < l.length; j++) if (l[j] >= ABS_GATE) absGated.push(l[j]);
|
|
if (!absGated.length) return NaN;
|
|
let energy = 0;
|
|
for (const v of absGated) energy += 10.0 ** (v / 10.0);
|
|
const stlIntegrated = 10.0 * Math.log10(energy / absGated.length);
|
|
const rel = Float64Array.from(absGated.filter((v) => v >= stlIntegrated - 20.0));
|
|
if (!rel.length) return NaN;
|
|
rel.sort();
|
|
return percentileSorted(rel, 95) - percentileSorted(rel, 10);
|
|
}
|
|
|
|
/**
|
|
* Every loudness quantity from one K-weighting pass: the ungated short-term
|
|
* (3 s / 0.5 s) series, integrated loudness and LRA.
|
|
*
|
|
* Memoised on `memo` (the AudioFile attribute caching in Python) so LUFS and PSR
|
|
* share it. Returned arrays are owned by the memo — callers copy before handing
|
|
* them out.
|
|
*/
|
|
function loudness(mono, sr, memo) {
|
|
if (memo && memo.loudness) return memo.loudness;
|
|
const n = mono.length;
|
|
const windowN = Math.max(Math.trunc(3.0 * sr), 1);
|
|
const hopN = Math.max(Math.trunc(0.5 * sr), 1);
|
|
|
|
const stCount = n >= windowN ? windowCount(n, windowN, hopN) : 0;
|
|
const stStarts = new Float64Array(stCount);
|
|
const stEnds = new Float64Array(stCount);
|
|
for (let i = 0; i < stCount; i++) {
|
|
stStarts[i] = i * hopN;
|
|
stEnds[i] = i * hopN + windowN;
|
|
}
|
|
const integ = blockEdges(n, sr, 0.4, 0.25);
|
|
// LRA appends 1.5 s of silence; indices past n read csq(n), which is exactly that.
|
|
const lraBlocks = blockEdges(n + Math.trunc(1.5 * sr), sr, 3.0, 0.03);
|
|
|
|
const wanted = [stStarts, stEnds, Float64Array.of(0, n)];
|
|
if (integ) wanted.push(integ.lo, integ.up);
|
|
if (lraBlocks) wanted.push(lraBlocks.lo, lraBlocks.up);
|
|
const csq = prefixSquares(n, kWeightedStream(mono, sr), wanted);
|
|
|
|
let times;
|
|
let lufs;
|
|
if (n < windowN) {
|
|
const ms = n ? csq(n) / n : 0;
|
|
times = Float64Array.of(n / (2.0 * sr));
|
|
lufs = Float64Array.of(LUFS_OFFSET + 10.0 * Math.log10(Math.max(ms, EPS)));
|
|
} else {
|
|
times = new Float64Array(stCount);
|
|
lufs = new Float64Array(stCount);
|
|
for (let i = 0; i < stCount; i++) {
|
|
const ms = (csq(stEnds[i]) - csq(stStarts[i])) / windowN;
|
|
lufs[i] = LUFS_OFFSET + 10.0 * Math.log10(Math.max(ms, EPS));
|
|
times[i] = (stStarts[i] + windowN / 2.0) / sr;
|
|
}
|
|
}
|
|
|
|
let integrated = -Infinity;
|
|
if (integ) {
|
|
const { z, l } = blockLoudness(integ, csq);
|
|
integrated = gatedIntegrated(z, l);
|
|
}
|
|
|
|
let lra = NaN;
|
|
if (n >= Math.trunc(3.0 * sr) && lraBlocks) {
|
|
lra = loudnessRange(blockLoudness(lraBlocks, csq).l);
|
|
}
|
|
|
|
const result = { times, lufs, integrated, lra };
|
|
if (memo) memo.loudness = result;
|
|
return result;
|
|
}
|
|
|
|
// ---- metrics -------------------------------------------------------------------
|
|
|
|
/** RMSPowerMetric: librosa.feature.rms, 10 s frames / 2 s hop, centred. */
|
|
export function computeRmsPower(mono, sr, window = 10, hop = 2) {
|
|
const n = mono.length;
|
|
const frameLength = window * sr;
|
|
const hopLength = hop * sr;
|
|
const half = Math.floor(frameLength / 2);
|
|
const padded = n + 2 * half;
|
|
const count = padded >= frameLength ? 1 + Math.floor((padded - frameLength) / hopLength) : 0;
|
|
|
|
// Frame i covers padded[i*hop, i*hop + frameLength): the zero padding adds
|
|
// nothing to the sum but still counts in the mean, as in librosa.
|
|
const starts = new Float64Array(count);
|
|
const ends = new Float64Array(count);
|
|
for (let i = 0; i < count; i++) {
|
|
const a = i * hopLength - half;
|
|
starts[i] = Math.min(Math.max(a, 0), n);
|
|
ends[i] = Math.min(Math.max(a + frameLength, 0), n);
|
|
}
|
|
const csq = prefixSquares(n, sampleStream(mono), [starts, ends]);
|
|
|
|
const times = new Float64Array(count);
|
|
const rms = new Float64Array(count);
|
|
for (let i = 0; i < count; i++) {
|
|
times[i] = (i * hopLength) / sr;
|
|
rms[i] = Math.sqrt((csq(ends[i]) - csq(starts[i])) / frameLength);
|
|
}
|
|
return { times, rms };
|
|
}
|
|
|
|
/** WaveformMetric: min/max envelope over ~4000 columns. */
|
|
export function computeWaveform(mono, sr, targetColumns = 4000) {
|
|
const n = mono.length;
|
|
if (n <= targetColumns) {
|
|
const times = new Float64Array(n);
|
|
for (let i = 0; i < n; i++) times[i] = i / sr;
|
|
return { times, lo: Float32Array.from(mono), hi: Float32Array.from(mono) };
|
|
}
|
|
const chunk = Math.floor(n / targetColumns);
|
|
const times = new Float64Array(targetColumns);
|
|
const lo = new Float32Array(targetColumns);
|
|
const hi = new Float32Array(targetColumns);
|
|
for (let c = 0; c < targetColumns; c++) {
|
|
let mn = Infinity;
|
|
let mx = -Infinity;
|
|
const base = c * chunk;
|
|
for (let k = base; k < base + chunk; k++) {
|
|
const v = mono[k];
|
|
if (v < mn) mn = v;
|
|
if (v > mx) mx = v;
|
|
}
|
|
lo[c] = mn;
|
|
hi[c] = mx;
|
|
times[c] = (c * chunk + chunk / 2) / sr;
|
|
}
|
|
return { times, lo, hi };
|
|
}
|
|
|
|
/** LUFSMetric: short-term series clipped to [-70, 0], plus integrated and LRA. */
|
|
export function computeLufs(mono, sr, memo) {
|
|
const L = loudness(mono, sr, memo);
|
|
const lufs = new Float64Array(L.lufs.length);
|
|
for (let i = 0; i < lufs.length; i++) {
|
|
const v = Number.isFinite(L.lufs[i]) ? L.lufs[i] : SILENCE_FLOOR;
|
|
lufs[i] = Math.min(Math.max(v, SILENCE_FLOOR), 0.0);
|
|
}
|
|
return { times: L.times.slice(), lufs, integrated: L.integrated, lra: L.lra };
|
|
}
|
|
|
|
/** CrestFactorMetric: 20*log10(peak / RMS) per 1 s window, 0.25 s hop. */
|
|
export function computeCrestFactor(mono, sr) {
|
|
const n = mono.length;
|
|
const windowN = Math.trunc(1.0 * sr);
|
|
const hopN = Math.trunc(0.25 * sr);
|
|
|
|
if (n < windowN) {
|
|
let peak = 0;
|
|
let sumSq = 0;
|
|
for (let i = 0; i < n; i++) {
|
|
const v = mono[i];
|
|
if (Math.abs(v) > peak) peak = Math.abs(v);
|
|
sumSq += v * v;
|
|
}
|
|
const rms = n ? Math.sqrt(sumSq / n) : 0;
|
|
return {
|
|
times: Float64Array.of(n / (2.0 * sr)),
|
|
crest_db: Float64Array.of(20.0 * Math.log10(Math.max(peak, EPS) / Math.max(rms, EPS))),
|
|
};
|
|
}
|
|
|
|
const count = windowCount(n, windowN, hopN);
|
|
const starts = new Float64Array(count);
|
|
const ends = new Float64Array(count);
|
|
for (let i = 0; i < count; i++) {
|
|
starts[i] = i * hopN;
|
|
ends[i] = i * hopN + windowN;
|
|
}
|
|
const csq = prefixSquares(n, sampleStream(mono), [starts, ends]);
|
|
const peaks = windowMaxima(count, windowN, hopN, absStream(mono));
|
|
|
|
const times = new Float64Array(count);
|
|
const crest = new Float64Array(count);
|
|
for (let i = 0; i < count; i++) {
|
|
const meanSq = (csq(ends[i]) - csq(starts[i])) / windowN;
|
|
const rms = Math.sqrt(Math.max(meanSq, EPS));
|
|
crest[i] = 20.0 * Math.log10(Math.max(peaks[i], EPS) / rms);
|
|
times[i] = (starts[i] + windowN / 2.0) / sr;
|
|
}
|
|
return { times, crest_db: crest };
|
|
}
|
|
|
|
/** PSRMetric: sample peak minus short-term LUFS (3 s), NaN below the gate. */
|
|
export function computePsr(mono, sr, memo) {
|
|
const n = mono.length;
|
|
const windowN = Math.max(Math.trunc(3.0 * sr), 1);
|
|
const hopN = Math.max(Math.trunc(0.5 * sr), 1);
|
|
const L = loudness(mono, sr, memo);
|
|
const count = L.times.length;
|
|
|
|
let peaksDb;
|
|
if (n < windowN) {
|
|
let peak = 0;
|
|
for (let i = 0; i < n; i++) peak = Math.max(peak, Math.abs(mono[i]));
|
|
peaksDb = Float64Array.of(n ? toDbfs(peak) : SILENCE_FLOOR);
|
|
} else {
|
|
peaksDb = windowMaxima(count, windowN, hopN, absStream(mono)).map(toDbfs);
|
|
}
|
|
|
|
const psr = new Float64Array(count);
|
|
for (let i = 0; i < count; i++) {
|
|
const l = L.lufs[i];
|
|
psr[i] = Number.isFinite(l) && l > SILENCE_FLOOR ? peaksDb[i] - l : NaN;
|
|
}
|
|
return { times: L.times.slice(), psr };
|
|
}
|
|
|
|
// ---- true peak -------------------------------------------------------------------
|
|
|
|
const OVERSAMPLE = 4;
|
|
let truePeakTaps = null;
|
|
|
|
function besselI0(x) {
|
|
const q = (x * x) / 4;
|
|
let term = 1;
|
|
let sum = 1;
|
|
for (let k = 1; k < 500; k++) {
|
|
term *= q / (k * k);
|
|
sum += term;
|
|
if (term < 1e-17 * sum) break;
|
|
}
|
|
return sum;
|
|
}
|
|
|
|
/**
|
|
* The anti-imaging filter resample_poly(x, 4, 1) builds for float32 input:
|
|
* firwin(81, 1/4, window=('kaiser', 5.0)), cast to float32, times 4.
|
|
*/
|
|
function truePeakFilter() {
|
|
if (truePeakTaps) return truePeakTaps;
|
|
const numtaps = 2 * 10 * OVERSAMPLE + 1;
|
|
const cutoff = 1.0 / OVERSAMPLE;
|
|
const beta = 5.0;
|
|
const alpha = 0.5 * (numtaps - 1);
|
|
const i0beta = besselI0(beta);
|
|
const h = new Float64Array(numtaps);
|
|
let sum = 0;
|
|
for (let k = 0; k < numtaps; k++) {
|
|
const m = k - alpha;
|
|
const x = Math.PI * (m === 0 ? 1e-20 : cutoff * m);
|
|
const sinc = Math.sin(x) / x;
|
|
const r = (k - alpha) / alpha;
|
|
h[k] = cutoff * sinc * (besselI0(beta * Math.sqrt(1 - r * r)) / i0beta);
|
|
sum += h[k];
|
|
}
|
|
truePeakTaps = new Float32Array(numtaps);
|
|
for (let k = 0; k < numtaps; k++) truePeakTaps[k] = Math.fround(h[k] / sum) * OVERSAMPLE;
|
|
return truePeakTaps;
|
|
}
|
|
|
|
/**
|
|
* |resample_poly(x, 4, 1)| as a stream, one output sample per call.
|
|
*
|
|
* resample_poly zero-stuffs x, convolves with the taps and keeps outputs
|
|
* [41, 41 + 4n); output k therefore sums x[s] * h[40 + k - 4s] over the input
|
|
* samples within the filter's reach — 20 or 21 taps per output.
|
|
*/
|
|
function upsampledAbsStream(x, h) {
|
|
const n = x.length;
|
|
const half = 10 * OVERSAMPLE;
|
|
let k = 0;
|
|
return () => {
|
|
const q = Math.floor(k / OVERSAMPLE);
|
|
const r = k - OVERSAMPLE * q;
|
|
const sLo = Math.max(0, r === 0 ? q - 10 : q - 9);
|
|
const sHi = Math.min(n - 1, q + 10);
|
|
let acc = 0;
|
|
for (let s = sLo; s <= sHi; s++) acc += x[s] * h[half + r + OVERSAMPLE * (q - s)];
|
|
k++;
|
|
return Math.abs(Math.fround(acc));
|
|
};
|
|
}
|
|
|
|
/** TruePeakMetric: 4x oversampled dBTP per 250 ms window, 100 ms hop. */
|
|
export function computeTruePeak(mono, sr) {
|
|
const n = mono.length;
|
|
const windowN = Math.trunc(0.25 * sr);
|
|
const hopN = Math.trunc(0.1 * sr);
|
|
const h = truePeakFilter();
|
|
|
|
if (n < windowN) {
|
|
let peak = 0;
|
|
const next = upsampledAbsStream(mono, h);
|
|
for (let k = 0; k < n * OVERSAMPLE; k++) peak = Math.max(peak, next());
|
|
const db = toDbfs(peak);
|
|
return { times: Float64Array.of(n / (2.0 * sr)), tp_db: Float64Array.of(db), integrated_tp_db: db };
|
|
}
|
|
|
|
const count = windowCount(n, windowN, hopN);
|
|
const peaks = windowMaxima(count, windowN * OVERSAMPLE, hopN * OVERSAMPLE, upsampledAbsStream(mono, h));
|
|
const times = new Float64Array(count);
|
|
const tpDb = new Float64Array(count);
|
|
let max = -Infinity;
|
|
for (let i = 0; i < count; i++) {
|
|
tpDb[i] = toDbfs(peaks[i]);
|
|
times[i] = (i * hopN + windowN / 2.0) / sr;
|
|
if (tpDb[i] > max) max = tpDb[i];
|
|
}
|
|
return { times, tp_db: tpDb, integrated_tp_db: max };
|
|
}
|
|
|
|
// ---- spectrogram -----------------------------------------------------------------
|
|
|
|
const N_FFT = 4096;
|
|
const SPEC_TARGET_COLUMNS = 4000;
|
|
const SPEC_DB_FLOOR = -80.0;
|
|
const fftPlans = new Map();
|
|
|
|
/** In-place radix-2 complex FFT for `size` (a power of two). */
|
|
function fftPlan(size) {
|
|
if (fftPlans.has(size)) return fftPlans.get(size);
|
|
const levels = Math.round(Math.log2(size));
|
|
const rev = new Uint32Array(size);
|
|
for (let i = 0; i < size; i++) {
|
|
let r = 0;
|
|
for (let b = 0; b < levels; b++) r |= ((i >> b) & 1) << (levels - 1 - b);
|
|
rev[i] = r;
|
|
}
|
|
const cos = new Float64Array(size / 2);
|
|
const sin = new Float64Array(size / 2);
|
|
for (let i = 0; i < size / 2; i++) {
|
|
cos[i] = Math.cos((2 * Math.PI * i) / size);
|
|
sin[i] = Math.sin((2 * Math.PI * i) / size);
|
|
}
|
|
const plan = (re, im) => {
|
|
for (let i = 0; i < size; i++) {
|
|
const j = rev[i];
|
|
if (j > i) {
|
|
let t = re[i]; re[i] = re[j]; re[j] = t;
|
|
t = im[i]; im[i] = im[j]; im[j] = t;
|
|
}
|
|
}
|
|
for (let len = 2; len <= size; len <<= 1) {
|
|
const halfLen = len >> 1;
|
|
const step = size / len;
|
|
for (let i = 0; i < size; i += len) {
|
|
for (let j = 0; j < halfLen; j++) {
|
|
const c = cos[j * step];
|
|
const s = sin[j * step];
|
|
const u = i + j;
|
|
const v = u + halfLen;
|
|
const tre = re[v] * c + im[v] * s;
|
|
const tim = im[v] * c - re[v] * s;
|
|
re[v] = re[u] - tre;
|
|
im[v] = im[u] - tim;
|
|
re[u] += tre;
|
|
im[u] += tim;
|
|
}
|
|
}
|
|
}
|
|
};
|
|
fftPlans.set(size, plan);
|
|
return plan;
|
|
}
|
|
|
|
/**
|
|
* SpectrogramMetric: |STFT| in dB relative to the loudest bin, floored at -80.
|
|
*
|
|
* `s_db` is row-major (rows x cols), row 0 the lowest non-DC bin — the same
|
|
* layout as the Python `s_db[1:, :]`.
|
|
*/
|
|
export function computeSpectrogram(mono, sr) {
|
|
const n = mono.length;
|
|
const half = N_FFT / 2;
|
|
const hop = Math.max(N_FFT / 4, Math.floor(n / SPEC_TARGET_COLUMNS));
|
|
const cols = 1 + Math.floor(n / hop);
|
|
const rows = half; // bins 1..N_FFT/2
|
|
|
|
const window = new Float64Array(N_FFT); // periodic Hann (fftbins=True)
|
|
for (let j = 0; j < N_FFT; j++) window[j] = 0.5 - 0.5 * Math.cos((2 * Math.PI * j) / N_FFT);
|
|
|
|
const fft = fftPlan(N_FFT);
|
|
const re = new Float64Array(N_FFT);
|
|
const im = new Float64Array(N_FFT);
|
|
const sDb = new Float32Array(rows * cols);
|
|
let maxPower = 0;
|
|
|
|
for (let c = 0; c < cols; c++) {
|
|
// Centred framing: frame c is padded[c*hop, c*hop + N_FFT) with N_FFT/2
|
|
// zeros either side of the signal.
|
|
const offset = c * hop - half;
|
|
for (let j = 0; j < N_FFT; j++) {
|
|
const t = offset + j;
|
|
re[j] = t >= 0 && t < n ? mono[t] * window[j] : 0;
|
|
im[j] = 0;
|
|
}
|
|
fft(re, im);
|
|
const p0 = re[0] * re[0] + im[0] * im[0];
|
|
if (p0 > maxPower) maxPower = p0;
|
|
for (let b = 1; b <= half; b++) {
|
|
const p = re[b] * re[b] + im[b] * im[b];
|
|
if (p > maxPower) maxPower = p;
|
|
sDb[(b - 1) * cols + c] = p;
|
|
}
|
|
}
|
|
|
|
// amplitude_to_db(ref=np.max, amin=1e-5, top_db=80): in power terms
|
|
// 10*log10(max(1e-10, p)) - 10*log10(max(1e-10, max p)), then clipped to
|
|
// (maximum - 80). The maximum of that is 0 at the reference bin, so the clip
|
|
// is a fixed -80 floor.
|
|
const amin = 1e-5 ** 2;
|
|
const refDb = 10.0 * Math.log10(Math.max(amin, maxPower));
|
|
for (let i = 0; i < sDb.length; i++) {
|
|
sDb[i] = Math.max(10.0 * Math.log10(Math.max(amin, sDb[i])) - refDb, SPEC_DB_FLOOR);
|
|
}
|
|
|
|
const binHz = 1.0 / (N_FFT * (1.0 / sr)); // np.fft.rfftfreq spacing
|
|
const freqs = new Float64Array(rows);
|
|
for (let b = 0; b < rows; b++) freqs[b] = (b + 1) * binHz;
|
|
const times = new Float64Array(cols);
|
|
for (let c = 0; c < cols; c++) times[c] = (c * hop + half) / sr;
|
|
|
|
return { freqs, times, s_db: sDb, rows, cols, nyquist: sr / 2.0 };
|
|
}
|
|
|
|
// ---- registry ----------------------------------------------------------------------
|
|
|
|
/** metric id -> compute(mono, sampleRate, memo). `memo` is per track. */
|
|
export const COMPUTE = {
|
|
rms_power: (mono, sr) => computeRmsPower(mono, sr),
|
|
waveform: (mono, sr) => computeWaveform(mono, sr),
|
|
lufs: (mono, sr, memo) => computeLufs(mono, sr, memo),
|
|
crest_factor: (mono, sr) => computeCrestFactor(mono, sr),
|
|
psr: (mono, sr, memo) => computePsr(mono, sr, memo),
|
|
true_peak: (mono, sr) => computeTruePeak(mono, sr),
|
|
spectrogram: (mono, sr) => computeSpectrogram(mono, sr),
|
|
};
|
|
|
|
/** The ArrayBuffers inside a computed result, for a zero-copy postMessage. */
|
|
export function transferables(data) {
|
|
const buffers = new Set();
|
|
for (const value of Object.values(data)) {
|
|
if (ArrayBuffer.isView(value)) buffers.add(value.buffer);
|
|
}
|
|
return [...buffers];
|
|
}
|