Files
mikkeli aed2d54b45 Match the desktop plot interactions in the web UI
- 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
2026-09-15 17:15:01 +09:00

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,
};
}