// SPDX-License-Identifier: Apache-2.0 // // Metric registry for the browser: display names and `build_spec` ports. // // Mirrors `metrics.METRICS`. Computation lives in dsp.js and runs in the // analysis worker; `buildSpec` is cheap and runs on the main thread whenever the // view changes, turning computed data into the plot payload the renderer draws — // the same shape `metrics_json.serialize_spec` used to put on the wire: // {axes, hlines, annotations, segments (bands), series (curves), heatmap}. const numbers = (arr) => Array.from(arr, (v) => (Number.isFinite(v) ? v : null)); const extent = (times) => (times.length ? [times[0], times[times.length - 1]] : null); function payload({ axes, bands = [], curves = [], hlines = [], heatmap = null, annotations = [] }) { return { title: '', axes: { x_label: '', y_label: '', y_log: false, x_log: false, y_range: null, x_range: null, y_log_allowed: false, x_log_allowed: false, ...axes, }, hlines: hlines.map((h) => ({ label: '', color: '#888888', style: 'dot', width: 0.8, ...h })), annotations, segments: bands.map((b) => ({ kind: 'band', x: numbers(b.x), lo: numbers(b.lo), hi: numbers(b.hi), label: b.label || '', color: null, })), series: curves.map((c) => ({ kind: 'line', x: numbers(c.x), y: numbers(c.y), label: c.label || '', width: c.width ?? 1.4, color: c.color ?? null, })), heatmap, y_bounds: null, }; } export const METRICS = [ { id: 'rms_power', displayName: 'RMS Power', buildSpec(data) { let peak = 0; for (const v of data.rms) if (v > peak) peak = v; // Adaptive headroom: loud masters get a taller scale. const ymax = data.rms.length && peak > 0.3 ? 0.6 : 0.3; return payload({ axes: { x_label: 'Time (seconds)', y_label: 'Power', y_range: [0.0, ymax], x_range: extent(data.times) }, bands: [{ x: data.times, lo: new Float64Array(data.rms.length), hi: data.rms, label: 'RMS power' }], }); }, }, { id: 'waveform', displayName: 'Waveform', buildSpec(data) { return payload({ axes: { x_label: 'Time (seconds)', y_label: 'Amplitude', y_range: [-1.1, 1.1], x_range: extent(data.times) }, bands: [{ x: data.times, lo: data.lo, hi: data.hi, label: 'Waveform' }], }); }, }, { id: 'lufs', displayName: 'LUFS', buildSpec(data) { const hlines = [{ y: -14.0, label: '-14 LUFS (streaming target)', style: 'dot' }]; const annotations = []; if (Number.isFinite(data.integrated)) { hlines.push({ y: data.integrated, label: `Integrated: ${data.integrated.toFixed(1)} LUFS`, color: '#e76f51', style: 'dash', width: 1.5, perTrack: true, // differs per track: coloured by track when overlaid }); } if (Number.isFinite(data.lra)) annotations.push(`LRA: ${data.lra.toFixed(1)} LU`); return payload({ axes: { x_label: 'Time (seconds)', y_label: 'LUFS', y_range: [-50.0, 0.0], x_range: extent(data.times) }, curves: [{ x: data.times, y: data.lufs, label: 'Short-term (3 s)' }], hlines, annotations, }); }, }, { id: 'crest_factor', displayName: 'Crest Factor', buildSpec(data) { return payload({ axes: { x_label: 'Time (seconds)', y_label: 'Crest factor (dB)', y_range: [0.0, 25.0], x_range: extent(data.times) }, curves: [{ x: data.times, y: data.crest_db, label: 'Crest factor (1 s)' }], hlines: [ { y: 12.0, label: '12 dB', style: 'dot' }, { y: 6.0, label: '6 dB (squashed)', style: 'dot' }, ], }); }, }, { id: 'psr', displayName: 'PSR', buildSpec(data) { return payload({ axes: { x_label: 'Time (seconds)', y_label: 'PSR (LU)', y_range: [0.0, 25.0], x_range: extent(data.times) }, curves: [{ x: data.times, y: data.psr, label: 'PSR (3 s)' }], hlines: [ { y: 10.0, label: '10 LU (good punch)', style: 'dot' }, { y: 4.0, label: '4 LU (squashed)', style: 'dot' }, ], }); }, }, { id: 'true_peak', displayName: 'True Peak', buildSpec(data) { const annotations = []; if (Number.isFinite(data.integrated_tp_db)) annotations.push(`Max: ${data.integrated_tp_db.toFixed(2)} dBTP`); return payload({ axes: { x_label: 'Time (seconds)', y_label: 'dBTP', y_range: [-30.0, 6.0], x_range: extent(data.times) }, curves: [{ x: data.times, y: data.tp_db, label: 'True Peak (250 ms)', width: 1.0 }], hlines: [ { y: 0.0, label: '0 dBTP (clip)', color: '#000000', style: 'dash', width: 1.0 }, { y: -1.0, label: '-1 dBTP (typical ceiling)', style: 'dot' }, ], annotations, }); }, }, { id: 'spectrogram', displayName: 'Spectrogram', buildSpec(data, view) { const yLog = view && view.yLog !== null && view.yLog !== undefined ? view.yLog : true; return payload({ axes: { x_label: 'Time (seconds)', y_label: 'Frequency (Hz)', y_log: yLog, y_log_allowed: true, y_range: [20.0, data.nyquist], x_range: extent(data.times), }, heatmap: { x: Array.from(data.times), y: data.freqs, z: data.s_db, // Float32Array, row-major rows x cols rows: data.rows, cols: data.cols, z_min: -80.0, z_max: 0.0, cmap: 'magma', label: 'Power (dB)', // Rows are uniform in frequency: the renderer addresses them in Hz, // which is what lets it draw a log axis without resampling. y0: data.freqs.length ? data.freqs[0] : 0, dy: data.freqs.length > 1 ? data.freqs[1] - data.freqs[0] : 1, }, }); }, }, ]; export const METRIC_BY_ID = Object.fromEntries(METRICS.map((m) => [m.id, m])); export const DEFAULT_METRIC_ID = 'rms_power'; /** * plotspec.apply_x_mode: rewrite a payload's x values to 0-100 % of the * dataset's own span. A pure view transform — returns a new payload and leaves * the cached one untouched. */ export function applyRelativeX(p) { const xs = [...p.series.map((s) => s.x), ...p.segments.map((s) => s.x)]; if (p.heatmap) xs.push(p.heatmap.x); const nonEmpty = xs.filter((x) => x.length); if (!nonEmpty.length) return p; const lo = Math.min(...nonEmpty.map((x) => x[0])); const hi = Math.max(...nonEmpty.map((x) => x[x.length - 1])); const span = hi - lo || 1.0; const rel = (x) => x.map((v) => (v === null ? null : ((v - lo) / span) * 100.0)); return { ...p, axes: { ...p.axes, x_label: 'Position (%)', x_range: [0.0, 100.0] }, series: p.series.map((s) => ({ ...s, x: rel(s.x) })), segments: p.segments.map((s) => ({ ...s, x: rel(s.x) })), heatmap: p.heatmap ? { ...p.heatmap, x: rel(p.heatmap.x) } : null, }; }