aed2d54b45
- Wheel zoom around the cursor on both curve plots and the spectrogram: Ctrl zooms time only, Shift values only, scrolling over an axis zooms that axis; drag pans live; double-click resets. The zoom window survives redraws, overlay changes and resizes, and resets when units change. - Guide and reference lines are painted in a uPlot draw hook, so a dragged reference line (now with a triangle handle and label) follows the cursor instead of jumping on release. Per-track integrated lines take the track's colour when overlaid. - In-plot legend like pyqtgraph's: a swatch per track plus LRA / max dBTP. - Metadata shows integrated loudness, LRA and max true peak (prefetched first) instead of debug rows; the file list shows each track's LUFS. - UI, canvas and axis text prefer M PLUS 1 Code, else the system font. - Spectrogram image is cached offscreen so panning is cheap; its tick labels adapt to the zoom level. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015UXA39Sh9coAx4FhRkd8R2
185 lines
6.8 KiB
JavaScript
185 lines
6.8 KiB
JavaScript
// 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,
|
|
};
|
|
}
|