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
1482 lines
50 KiB
JavaScript
1482 lines
50 KiB
JavaScript
/* Audio Mastering Analysis Toolkit — web front end.
|
||
*
|
||
* Everything happens in this tab. A dropped file is decoded here (decode.js),
|
||
* its mono signal is handed to a Web Worker that runs the metrics (dsp.js), and
|
||
* the results are drawn from `PlotSpec`-shaped payloads (metrics.js) with uPlot
|
||
* for curves/bands and a plain canvas for the spectrogram. Nothing is uploaded
|
||
* and nothing is stored: the page is not even allowed to make network requests
|
||
* (see deploy/nginx.conf), and closing the tab discards the audio and results.
|
||
*
|
||
* The roles mirror the desktop app: this module is MainWindow plus
|
||
* AnalysisResultsManager (track list, overlay set, per-metric result cache,
|
||
* background prefetch), and the renderer half is AudioVisualizationWidget
|
||
* (dataset colours, in-plot legend, lin/log, wheel zoom with per-axis
|
||
* modifiers, drag-to-pan, draggable reference lines, overlay).
|
||
*/
|
||
|
||
import { decodeFile } from './decode.js';
|
||
import { METRICS, METRIC_BY_ID, DEFAULT_METRIC_ID, applyRelativeX } from './metrics.js';
|
||
|
||
'use strict';
|
||
|
||
// ---- palette & fonts ---------------------------------------------------------
|
||
// Same job as `dataset_color` on the desktop: a song keeps one colour wherever
|
||
// it appears, and overlaid tracks stay distinguishable. Tuned for a dark plot.
|
||
const PALETTE = [
|
||
'#4fc3f7', '#ffb74d', '#81c784', '#ba68c8',
|
||
'#f06292', '#4db6ac', '#ffd54f', '#9575cd',
|
||
'#a1887f', '#e57373', '#64b5f6', '#aed581',
|
||
];
|
||
|
||
// The UI prefers M PLUS 1 Code (the desktop app's font) and falls back to the
|
||
// system UI font where it isn't installed. The stack lives in app.css; canvas
|
||
// and uPlot text read it from there so everything uses the same family.
|
||
const FONT = getComputedStyle(document.documentElement).getPropertyValue('--font').trim()
|
||
|| 'system-ui, sans-serif';
|
||
const font = (px) => `${px}px ${FONT}`;
|
||
|
||
const AUDIO_EXTENSIONS = /\.(wav|mp3|flac|ogg|oga|m4a|aiff?|aifc|opus)$/i;
|
||
|
||
// Loudness figures shown in the metadata panel and file list come from these,
|
||
// so they are prefetched before the other metrics.
|
||
const PREFETCH_FIRST = ['lufs', 'true_peak'];
|
||
|
||
const state = {
|
||
metric: DEFAULT_METRIC_ID,
|
||
files: [], // track metadata, in the order tracks were added
|
||
overlay: [], // file_id list currently drawn
|
||
selected: null, // file_id highlighted in the list
|
||
xMode: 'absolute',
|
||
yLog: null, // null = use the metric's default
|
||
xLog: null,
|
||
// Reference lines are kept per metric, as on the desktop: -14 LUFS means
|
||
// nothing on a spectrogram, so switching away and back must not lose them.
|
||
refsByMetric: {},
|
||
refs: [],
|
||
payload: null, // last built plot payload
|
||
uplot: null,
|
||
heatmapGeom: null, // geometry of the spectrogram last drawn (for interaction)
|
||
// User zoom/pan window in data units; null = the metric's default framing.
|
||
// Survives redraws and overlay changes; reset when the units change.
|
||
zoom: { x: null, y: null },
|
||
busy: false,
|
||
refreshAgain: false,
|
||
results: {}, // file_id -> {metric_id: computed data}
|
||
timings: {}, // file_id -> {metric_id: compute ms}
|
||
nextId: 1,
|
||
};
|
||
|
||
const el = (id) => document.getElementById(id);
|
||
|
||
// ---- small helpers ---------------------------------------------------------
|
||
|
||
function setStatus(text, kind) {
|
||
const node = el('status');
|
||
node.textContent = text;
|
||
node.className = 'status' + (kind ? ' ' + kind : '');
|
||
}
|
||
|
||
function setTiming(text) {
|
||
el('timing').textContent = text || '';
|
||
}
|
||
|
||
function fmt(value, digits = 2) {
|
||
if (value === null || value === undefined || Number.isNaN(value)) return '—';
|
||
return Number(value).toFixed(digits);
|
||
}
|
||
|
||
function fmtDuration(seconds) {
|
||
if (!isFinite(seconds)) return '—';
|
||
const m = Math.floor(seconds / 60);
|
||
const s = seconds - m * 60;
|
||
return `${m}:${s.toFixed(2).padStart(5, '0')}`;
|
||
}
|
||
|
||
function fmtSeconds(ms) {
|
||
return ms >= 1000 ? `${(ms / 1000).toFixed(1)} s` : `${Math.round(ms)} ms`;
|
||
}
|
||
|
||
/** Decimal places needed to print multiples of `step` exactly. */
|
||
function stepDigits(step) {
|
||
if (!(step > 0)) return 2;
|
||
const text = String(Number(step.toPrecision(6)));
|
||
if (text.includes('e-')) return Math.min(8, Number(text.split('e-')[1]));
|
||
return Math.min(8, (text.split('.')[1] || '').length);
|
||
}
|
||
|
||
function formatTick(value, step) {
|
||
const v = Math.abs(value) < step * 1e-6 ? 0 : value;
|
||
return v.toFixed(stepDigits(step));
|
||
}
|
||
|
||
function paletteFor(fileId) {
|
||
const i = state.files.findIndex((f) => f.file_id === fileId);
|
||
return PALETTE[(i < 0 ? 0 : i) % PALETTE.length];
|
||
}
|
||
|
||
function fileById(fileId) {
|
||
return state.files.find((f) => f.file_id === fileId);
|
||
}
|
||
|
||
function resultFor(fileId, metricId) {
|
||
return state.results[fileId] && state.results[fileId][metricId];
|
||
}
|
||
|
||
function currentRefs() {
|
||
return state.refs;
|
||
}
|
||
|
||
function resetZoom() {
|
||
state.zoom = { x: null, y: null };
|
||
}
|
||
|
||
// ---- analysis worker -------------------------------------------------------
|
||
// One worker, one metric at a time. Jobs the user is waiting on (the metric on
|
||
// screen) jump ahead of background prefetch, like the desktop's PrefetchWorker
|
||
// yielding to a foreground compute.
|
||
|
||
const worker = new Worker(new URL('./analysis-worker.js', import.meta.url), { type: 'module' });
|
||
const jobs = new Map(); // "trackId:metricId" -> job
|
||
const queue = [];
|
||
let running = null;
|
||
let reqSeq = 0;
|
||
|
||
function requestMetric(trackId, metricId, { background = false } = {}) {
|
||
const cached = resultFor(trackId, metricId);
|
||
if (cached) return Promise.resolve(cached);
|
||
|
||
const key = `${trackId}:${metricId}`;
|
||
const existing = jobs.get(key);
|
||
if (existing) {
|
||
if (!background) existing.background = false;
|
||
return existing.promise;
|
||
}
|
||
const job = { key, trackId, metricId, background };
|
||
job.promise = new Promise((resolve, reject) => { job.resolve = resolve; job.reject = reject; });
|
||
jobs.set(key, job);
|
||
queue.push(job);
|
||
pump();
|
||
return job.promise;
|
||
}
|
||
|
||
function pump() {
|
||
if (running || !queue.length) return;
|
||
let i = queue.findIndex((j) => !j.background);
|
||
if (i < 0) i = 0;
|
||
running = queue.splice(i, 1)[0];
|
||
running.reqId = ++reqSeq;
|
||
worker.postMessage({
|
||
type: 'compute', reqId: running.reqId, trackId: running.trackId, metricId: running.metricId,
|
||
});
|
||
}
|
||
|
||
function finishRunning(fn) {
|
||
const job = running;
|
||
running = null;
|
||
jobs.delete(job.key);
|
||
fn(job);
|
||
pump();
|
||
}
|
||
|
||
worker.onmessage = (event) => {
|
||
const msg = event.data;
|
||
if (!running || msg.reqId !== running.reqId) return;
|
||
finishRunning((job) => {
|
||
if (msg.type === 'result') {
|
||
// The track may have been removed while this was computing.
|
||
if (state.results[msg.trackId]) {
|
||
state.results[msg.trackId][msg.metricId] = msg.data;
|
||
state.timings[msg.trackId][msg.metricId] = msg.ms;
|
||
if (PREFETCH_FIRST.includes(msg.metricId)) renderFileList();
|
||
if (msg.trackId === state.selected) renderMetadata();
|
||
}
|
||
job.resolve(msg.data);
|
||
} else {
|
||
job.reject(new Error(msg.message));
|
||
}
|
||
});
|
||
};
|
||
|
||
worker.onerror = (event) => {
|
||
event.preventDefault();
|
||
if (running) finishRunning((job) => job.reject(new Error(event.message || 'analysis worker failed')));
|
||
};
|
||
|
||
function prefetch(trackId) {
|
||
const order = [...PREFETCH_FIRST, ...METRICS.map((m) => m.id).filter((id) => !PREFETCH_FIRST.includes(id))];
|
||
order.forEach((id) => {
|
||
if (id !== state.metric) requestMetric(trackId, id, { background: true }).catch(() => {});
|
||
});
|
||
}
|
||
|
||
// ---- track lifecycle -------------------------------------------------------
|
||
|
||
function forgetTrack(fileId) {
|
||
for (let i = queue.length - 1; i >= 0; i--) {
|
||
if (queue[i].trackId === fileId) {
|
||
const [job] = queue.splice(i, 1);
|
||
jobs.delete(job.key);
|
||
job.reject(new Error('track removed'));
|
||
}
|
||
}
|
||
worker.postMessage({ type: 'drop', trackId: fileId });
|
||
delete state.results[fileId];
|
||
delete state.timings[fileId];
|
||
state.files = state.files.filter((x) => x.file_id !== fileId);
|
||
state.overlay = state.overlay.filter((p) => p !== fileId);
|
||
if (state.selected === fileId) state.selected = state.files.length ? state.files[0].file_id : null;
|
||
}
|
||
|
||
// ---- file list & metadata ----------------------------------------------------
|
||
|
||
function populateMetricSelect() {
|
||
const select = el('metric-select');
|
||
select.innerHTML = '';
|
||
METRICS.forEach((m) => {
|
||
const opt = document.createElement('option');
|
||
opt.value = m.id;
|
||
opt.textContent = m.displayName;
|
||
if (m.id === state.metric) opt.selected = true;
|
||
select.appendChild(opt);
|
||
});
|
||
}
|
||
|
||
function renderFileList() {
|
||
const list = el('file-list');
|
||
list.innerHTML = '';
|
||
el('file-count').textContent = String(state.files.length);
|
||
el('file-empty').hidden = state.files.length > 0;
|
||
|
||
state.files.forEach((f) => {
|
||
const li = document.createElement('li');
|
||
li.className = 'file-row' + (f.file_id === state.selected ? ' selected' : '');
|
||
li.dataset.id = f.file_id;
|
||
|
||
const check = document.createElement('input');
|
||
check.type = 'checkbox';
|
||
check.checked = state.overlay.includes(f.file_id);
|
||
check.title = 'Include in the overlay';
|
||
check.addEventListener('click', (ev) => ev.stopPropagation());
|
||
check.addEventListener('change', async () => {
|
||
state.overlay = collectOverlayFromDOM();
|
||
await refreshPlot();
|
||
});
|
||
|
||
const swatch = document.createElement('span');
|
||
swatch.className = 'file-swatch';
|
||
swatch.style.background = paletteFor(f.file_id);
|
||
|
||
const name = document.createElement('span');
|
||
name.className = 'file-name';
|
||
name.textContent = f.name;
|
||
name.title = f.song_name;
|
||
|
||
const loudness = document.createElement('span');
|
||
loudness.className = 'file-lufs';
|
||
const lufs = resultFor(f.file_id, 'lufs');
|
||
if (lufs && Number.isFinite(lufs.integrated)) {
|
||
loudness.textContent = lufs.integrated.toFixed(1);
|
||
loudness.title = 'Integrated loudness (LUFS)';
|
||
}
|
||
|
||
const remove = document.createElement('button');
|
||
remove.className = 'file-remove';
|
||
remove.type = 'button';
|
||
remove.textContent = '×';
|
||
remove.title = 'Remove this track';
|
||
remove.addEventListener('click', async (ev) => {
|
||
ev.stopPropagation();
|
||
forgetTrack(f.file_id);
|
||
renderFileList();
|
||
renderMetadata();
|
||
await refreshPlot();
|
||
});
|
||
|
||
li.append(check, swatch, name, loudness, remove);
|
||
li.addEventListener('click', () => {
|
||
state.selected = f.file_id;
|
||
renderFileList();
|
||
renderMetadata();
|
||
});
|
||
list.appendChild(li);
|
||
});
|
||
}
|
||
|
||
function collectOverlayFromDOM() {
|
||
return Array.from(document.querySelectorAll('#file-list .file-row'))
|
||
.filter((row) => row.querySelector('input[type=checkbox]').checked)
|
||
.map((row) => row.dataset.id);
|
||
}
|
||
|
||
function renderMetadata() {
|
||
const dl = el('metadata');
|
||
dl.innerHTML = '';
|
||
const file = fileById(state.selected);
|
||
|
||
const addRow = (label, value, className = '') => {
|
||
const dt = document.createElement('dt');
|
||
dt.textContent = label;
|
||
const dd = document.createElement('dd');
|
||
dd.textContent = value;
|
||
if (className) { dt.className = className; dd.className = className; }
|
||
dl.append(dt, dd);
|
||
};
|
||
|
||
if (!file) {
|
||
addRow('Track', 'nothing selected', 'muted');
|
||
return;
|
||
}
|
||
|
||
const lufs = resultFor(file.file_id, 'lufs');
|
||
const tp = resultFor(file.file_id, 'true_peak');
|
||
const pending = '…';
|
||
const channels = file.channels === 1 ? 'Mono' : file.channels === 2 ? 'Stereo' : `${file.channels} channels`;
|
||
|
||
addRow('Track', file.song_name);
|
||
addRow('Length', fmtDuration(file.duration));
|
||
addRow('Sample rate', `${(file.sample_rate / 1000).toFixed(1)} kHz${file.native_rate_known ? '' : ' (assumed)'}`);
|
||
addRow('Channels', channels);
|
||
addRow('Format', `${(file.format || '?').toUpperCase()} · ${(file.size / 1048576).toFixed(1)} MB`);
|
||
addRow('Integrated',
|
||
!lufs ? pending : Number.isFinite(lufs.integrated) ? `${lufs.integrated.toFixed(1)} LUFS` : 'below gate',
|
||
'group strong');
|
||
addRow('Loudness range', !lufs ? pending : Number.isFinite(lufs.lra) ? `${lufs.lra.toFixed(1)} LU` : '—');
|
||
addRow('True peak', !tp ? pending : tp.integrated_tp_db > -200 ? `${tp.integrated_tp_db.toFixed(2)} dBTP` : '—');
|
||
addRow('Max amplitude', fmt(file.max_amplitude, 3), 'group');
|
||
addRow('Avg amplitude', fmt(file.avg_amplitude, 3));
|
||
}
|
||
|
||
// ---- reference lines -------------------------------------------------------
|
||
|
||
function refsFor(metric) {
|
||
if (!state.refsByMetric[metric]) state.refsByMetric[metric] = [];
|
||
return state.refsByMetric[metric];
|
||
}
|
||
|
||
function renderRefList() {
|
||
const list = el('ref-list');
|
||
list.innerHTML = '';
|
||
const refs = currentRefs();
|
||
el('ref-count').textContent = String(refs.length);
|
||
|
||
refs.forEach((ref, index) => {
|
||
const li = document.createElement('li');
|
||
li.className = 'ref-row';
|
||
|
||
const color = document.createElement('input');
|
||
color.type = 'color';
|
||
color.value = /^#[0-9a-f]{6}$/i.test(ref.color) ? ref.color : '#ffb74d';
|
||
color.addEventListener('input', () => { ref.color = color.value; redrawLines(); });
|
||
|
||
const value = document.createElement('input');
|
||
value.type = 'number';
|
||
value.step = '0.1';
|
||
value.value = ref.value.toFixed(2);
|
||
value.title = 'Position on the value axis';
|
||
value.addEventListener('change', () => { ref.value = parseFloat(value.value) || 0; redrawLines(); });
|
||
|
||
const label = document.createElement('input');
|
||
label.type = 'text';
|
||
label.value = ref.label || '';
|
||
label.placeholder = 'tag';
|
||
label.addEventListener('change', () => { ref.label = label.value; redrawLines(); });
|
||
|
||
const remove = document.createElement('button');
|
||
remove.className = 'ref-remove';
|
||
remove.type = 'button';
|
||
remove.textContent = '×';
|
||
remove.addEventListener('click', () => {
|
||
currentRefs().splice(index, 1);
|
||
renderRefList();
|
||
redrawLines();
|
||
});
|
||
|
||
li.append(color, value, label, remove);
|
||
list.appendChild(li);
|
||
});
|
||
}
|
||
|
||
function syncRefInputs() {
|
||
const rows = document.querySelectorAll('#ref-list .ref-row');
|
||
currentRefs().forEach((ref, i) => {
|
||
const input = rows[i] && rows[i].querySelector('input[type=number]');
|
||
if (input && document.activeElement !== input) input.value = ref.value.toFixed(2);
|
||
});
|
||
}
|
||
|
||
/** Repaint reference/guide lines without rebuilding the plot. */
|
||
function redrawLines() {
|
||
if (state.uplot) state.uplot.redraw(false, false);
|
||
}
|
||
|
||
// ---- plotting --------------------------------------------------------------
|
||
|
||
function yLogActive() {
|
||
if (state.payload && state.payload.axes) {
|
||
const axis = state.payload.axes;
|
||
if (!axis.y_log_allowed) return false;
|
||
return state.yLog === null ? axis.y_log : state.yLog;
|
||
}
|
||
return state.yLog === true;
|
||
}
|
||
|
||
function refreshYLogButton() {
|
||
const btn = el('toggle-y-log');
|
||
const axis = state.payload && state.payload.axes;
|
||
const allowed = !!(axis && axis.y_log_allowed);
|
||
btn.disabled = !allowed;
|
||
const on = allowed && yLogActive();
|
||
btn.textContent = `Y: ${on ? 'log' : 'lin'}`;
|
||
btn.className = 'toggle ' + (on ? 'on' : 'off');
|
||
}
|
||
|
||
/** Metric-supplied line colours are chosen for the desktop's light plot. */
|
||
function themeColor(color) {
|
||
if (!color || color === '#888888') return '#8b93a3';
|
||
if (color === '#000000') return '#e8eaf0';
|
||
return color;
|
||
}
|
||
|
||
function dashFor(style) {
|
||
if (style === 'solid') return null;
|
||
if (style === 'dot') return [2, 3];
|
||
return [6, 4];
|
||
}
|
||
|
||
function hexToRgba(hex, alpha) {
|
||
const h = hex.replace('#', '');
|
||
const full = h.length === 3 ? h.split('').map((c) => c + c).join('') : h;
|
||
const n = parseInt(full, 16);
|
||
const r = (n >> 16) & 255, g = (n >> 8) & 255, b = n & 255;
|
||
return `rgba(${r},${g},${b},${alpha})`;
|
||
}
|
||
|
||
function buildUplotData(payload) {
|
||
// uPlot needs one shared x array and one column per series. Series may have
|
||
// different x arrays (overlaid tracks of different lengths), so they are all
|
||
// resampled onto the union of their own x values. For absolute time that is
|
||
// exact (every series has a value at every sample it drew anyway); for
|
||
// relative mode the x arrays are already normalised to 0–100 %.
|
||
const datasets = payload.datasets;
|
||
const xsSet = [];
|
||
datasets.forEach((d) => {
|
||
const series = d.payload.series.concat(d.payload.segments);
|
||
series.forEach((s) => s.x.forEach((v) => { if (v !== null) xsSet.push(v); }));
|
||
});
|
||
if (!xsSet.length) return null;
|
||
|
||
let x = Array.from(new Set(xsSet)).sort((a, b) => a - b);
|
||
|
||
// Guard against a pathological x axis (a single-sample file yields one point).
|
||
if (x.length === 1) x = [x[0], x[0] + 1e-3];
|
||
|
||
function resample(srcX, srcY) {
|
||
const out = new Array(x.length).fill(null);
|
||
if (!srcX.length) return out;
|
||
let j = 0;
|
||
for (let i = 0; i < x.length; i++) {
|
||
while (j < srcX.length - 1 && srcX[j + 1] <= x[i]) j++;
|
||
const x0 = srcX[j];
|
||
const x1 = j + 1 < srcX.length ? srcX[j + 1] : x0;
|
||
if (x[i] < srcX[0] || x[i] > srcX[srcX.length - 1]) { out[i] = null; continue; }
|
||
const y0 = srcY[j];
|
||
const y1 = j + 1 < srcY.length ? srcY[j + 1] : y0;
|
||
if (y0 === null || (x1 > x0 && y0 !== null && y1 !== null)) {
|
||
const t = x1 > x0 ? (x[i] - x0) / (x1 - x0) : 0;
|
||
out[i] = (y0 === null || y1 === null) ? (y0 === null ? y1 : y0) : y0 + (y1 - y0) * t;
|
||
} else {
|
||
out[i] = y0 === null ? y1 : y0;
|
||
}
|
||
}
|
||
return out;
|
||
}
|
||
|
||
const data = [x];
|
||
const series = [];
|
||
|
||
datasets.forEach((d) => {
|
||
const colour = paletteFor(d.file_id);
|
||
|
||
d.payload.segments.forEach((seg) => {
|
||
data.push(resample(seg.x, seg.lo));
|
||
data.push(resample(seg.x, seg.hi));
|
||
// Bands draw as two lines. Styling differs by type: an RMS power fill is
|
||
// a solid area from zero, a waveform envelope is a bright line pair.
|
||
let loMin = 0;
|
||
for (const v of seg.lo) if (v !== null && v < loMin) loMin = v;
|
||
const isEnvelope = loMin < 0;
|
||
series.push({ stroke: colour, width: isEnvelope ? 1 : 1.4, fill: isEnvelope ? null : hexToRgba(colour, 0.22) });
|
||
series.push({ stroke: colour, width: isEnvelope ? 1 : 1.4, fill: isEnvelope ? hexToRgba(colour, 0.28) : null });
|
||
});
|
||
|
||
d.payload.series.forEach((line) => {
|
||
data.push(resample(line.x, line.y));
|
||
series.push({ stroke: line.color || colour, width: line.width || 1.4, fill: null });
|
||
});
|
||
});
|
||
|
||
return { x, data, series };
|
||
}
|
||
|
||
/**
|
||
* Horizontal lines to paint over the data: the metric's own guides
|
||
* (-14 LUFS, 0 dBTP, …) and the user's reference lines.
|
||
*
|
||
* Guides identical across tracks are drawn once; per-track guides (integrated
|
||
* loudness) take the track's colour when several tracks are overlaid.
|
||
*/
|
||
function collectLines(payload) {
|
||
const lines = [];
|
||
if (!payload) return lines;
|
||
const multi = payload.datasets.length > 1;
|
||
const seen = new Set();
|
||
payload.datasets.forEach((d) => {
|
||
(d.payload.hlines || []).forEach((hl) => {
|
||
const key = `${hl.y}|${hl.label}`;
|
||
if (!hl.perTrack && seen.has(key)) return;
|
||
seen.add(key);
|
||
lines.push({
|
||
value: hl.y,
|
||
color: hl.perTrack && multi ? paletteFor(d.file_id) : themeColor(hl.color),
|
||
width: hl.width || 1,
|
||
dash: dashFor(hl.style),
|
||
label: hl.label,
|
||
side: 'right',
|
||
});
|
||
});
|
||
});
|
||
currentRefs().forEach((ref) => {
|
||
lines.push({
|
||
value: ref.value,
|
||
color: ref.color || '#ffb74d',
|
||
width: 1.4,
|
||
dash: dashFor(ref.style),
|
||
label: ref.label || ref.value.toFixed(2),
|
||
side: 'left',
|
||
handle: true,
|
||
});
|
||
});
|
||
return lines;
|
||
}
|
||
|
||
/** uPlot draw hook: paint `lines` over the plot area, labels riding on them. */
|
||
function drawPlotLines(u, lines) {
|
||
const { left, top, width, height } = u.bbox; // canvas pixels
|
||
const visible = lines
|
||
.map((ln) => ({ ...ln, y: Math.round(u.valToPos(ln.value, 'y', true)) + 0.5 }))
|
||
.filter((ln) => ln.y >= top - 1 && ln.y <= top + height + 1);
|
||
if (!visible.length) return;
|
||
const ctx = u.ctx;
|
||
const ratio = u.over.clientWidth ? width / u.over.clientWidth : 1;
|
||
|
||
ctx.save();
|
||
ctx.beginPath();
|
||
ctx.rect(left, top, width, height);
|
||
ctx.clip();
|
||
|
||
// Every line first, then every label, so no label is struck through by a
|
||
// line drawn after it.
|
||
visible.forEach((ln) => {
|
||
ctx.strokeStyle = ln.color;
|
||
ctx.lineWidth = ln.width * ratio;
|
||
ctx.setLineDash((ln.dash || []).map((v) => v * ratio));
|
||
ctx.beginPath();
|
||
ctx.moveTo(left, ln.y);
|
||
ctx.lineTo(left + width, ln.y);
|
||
ctx.stroke();
|
||
ctx.setLineDash([]);
|
||
if (ln.handle) {
|
||
ctx.fillStyle = ln.color;
|
||
ctx.beginPath();
|
||
ctx.moveTo(left, ln.y - 6 * ratio);
|
||
ctx.lineTo(left + 11 * ratio, ln.y);
|
||
ctx.lineTo(left, ln.y + 6 * ratio);
|
||
ctx.closePath();
|
||
ctx.fill();
|
||
}
|
||
});
|
||
|
||
// uPlot leaves its axis text alignment on the shared context; set our own.
|
||
ctx.font = font(10.5 * ratio);
|
||
ctx.textAlign = 'left';
|
||
ctx.textBaseline = 'middle';
|
||
const placed = { left: [], right: [] };
|
||
const pad = 3 * ratio;
|
||
const h = 15 * ratio;
|
||
visible.forEach((ln) => {
|
||
if (!ln.label) return;
|
||
const tw = ctx.measureText(ln.label).width;
|
||
const x = ln.side === 'left' ? left + 16 * ratio : left + width - tw - 10 * ratio;
|
||
// Above the line, unless that would leave the plot; nudge past labels
|
||
// already placed on the same side so near-coincident lines stay legible.
|
||
let ty = ln.y - h - 2 * ratio < top ? ln.y + 2 * ratio : ln.y - h - 2 * ratio;
|
||
for (const other of placed[ln.side]) {
|
||
if (Math.abs(other - ty) < h) ty = other + (ty >= other ? h : -h);
|
||
}
|
||
placed[ln.side].push(ty);
|
||
ctx.fillStyle = 'rgba(13, 15, 20, 0.8)';
|
||
ctx.fillRect(x - pad, ty, tw + 2 * pad, h);
|
||
ctx.fillStyle = ln.color;
|
||
ctx.fillText(ln.label, x, ty + h / 2);
|
||
});
|
||
ctx.restore();
|
||
}
|
||
|
||
/** The in-plot legend: a swatch per track (or series) plus scalar readouts. */
|
||
function renderPlotLegend(payload, position) {
|
||
const box = el('plot-legend');
|
||
box.innerHTML = '';
|
||
const multi = payload.datasets.length > 1;
|
||
const add = (text, colour) => {
|
||
const entry = document.createElement('div');
|
||
entry.className = colour ? 'entry' : 'entry note';
|
||
if (colour) {
|
||
const swatch = document.createElement('span');
|
||
swatch.className = 'swatch';
|
||
swatch.style.background = colour;
|
||
entry.append(swatch);
|
||
}
|
||
const label = document.createElement('span');
|
||
label.textContent = text;
|
||
entry.append(label);
|
||
box.append(entry);
|
||
};
|
||
|
||
payload.datasets.forEach((d) => {
|
||
const colour = paletteFor(d.file_id);
|
||
const labels = d.payload.heatmap
|
||
? []
|
||
: [...d.payload.segments, ...d.payload.series].map((s) => s.label).filter(Boolean);
|
||
if (multi || !labels.length) add(d.label, colour);
|
||
else labels.forEach((l) => add(l, colour));
|
||
});
|
||
payload.datasets.forEach((d) => (d.payload.annotations || []).forEach((note) => {
|
||
add(multi ? `${d.label}: ${note}` : note, null);
|
||
}));
|
||
|
||
Object.assign(box.style, { top: 'auto', left: 'auto', right: 'auto' }, position);
|
||
box.hidden = !box.childElementCount;
|
||
}
|
||
|
||
function destroyPlot() {
|
||
if (state.uplot) { state.uplot.destroy(); state.uplot = null; }
|
||
el('heatmap').hidden = true;
|
||
el('plot-legend').hidden = true;
|
||
state.heatmapGeom = null;
|
||
const host = el('plot-host');
|
||
Array.from(host.querySelectorAll('.uplot')).forEach((n) => n.remove());
|
||
}
|
||
|
||
function plotSize() {
|
||
const host = el('plot-host');
|
||
const rect = host.getBoundingClientRect();
|
||
return [Math.max(320, Math.floor(rect.width)), Math.max(180, Math.floor(rect.height))];
|
||
}
|
||
|
||
function padRange(min, max) {
|
||
if (!(Number.isFinite(min) && Number.isFinite(max))) return [0, 1];
|
||
if (min === max) return [min - 1, max + 1];
|
||
const pad = (max - min) * 0.05;
|
||
return [min - pad, max + pad];
|
||
}
|
||
|
||
function buildAxes(payload) {
|
||
const axes = payload.axes;
|
||
const yLog = yLogActive();
|
||
|
||
// Frame the union of the tracks' own spans, so overlaid tracks of different
|
||
// lengths don't clip to whichever happens to be first.
|
||
const spans = payload.datasets.map((d) => d.payload.axes.x_range).filter(Boolean);
|
||
const xDefault = spans.length
|
||
? [Math.min(...spans.map((s) => s[0])), Math.max(...spans.map((s) => s[1]))]
|
||
: null;
|
||
|
||
// Range functions consult the zoom window first, so a redraw (new track,
|
||
// overlay toggle, resize) keeps whatever the user zoomed or panned to.
|
||
const scales = {
|
||
x: { time: false, range: (u, min, max) => state.zoom.x || xDefault || padRange(min, max) },
|
||
y: {
|
||
distr: yLog ? 3 : 1,
|
||
log: 10,
|
||
range: (u, min, max) => state.zoom.y || axes.y_range || padRange(min, max),
|
||
},
|
||
};
|
||
|
||
const axisStyle = {
|
||
stroke: '#8b93a3',
|
||
grid: { stroke: 'rgba(255,255,255,.07)', width: 1 },
|
||
ticks: { stroke: 'rgba(255,255,255,.12)' },
|
||
font: font(11),
|
||
labelFont: font(11),
|
||
labelSize: 16,
|
||
};
|
||
return {
|
||
scales,
|
||
axes: [
|
||
{ ...axisStyle, label: axes.x_label, size: 40, values: (u, vals, i, space, incr) => vals.map((v) => formatTick(v, incr)) },
|
||
{ ...axisStyle, label: axes.y_label, size: 58, values: (u, vals, i, space, incr) => vals.map((v) => formatTick(v, incr)) },
|
||
],
|
||
};
|
||
}
|
||
|
||
function drawPayload(payload) {
|
||
destroyPlot();
|
||
el('placeholder').hidden = payload.datasets.length > 0;
|
||
refreshYLogButton();
|
||
|
||
const dataset = payload.datasets[0];
|
||
if (!dataset) return;
|
||
if (dataset.payload.heatmap) {
|
||
renderHeatmap(payload);
|
||
return;
|
||
}
|
||
|
||
const built = buildUplotData(payload);
|
||
if (!built) return;
|
||
const { scales, axes } = buildAxes(payload);
|
||
const [w, h] = plotSize();
|
||
|
||
const u = new uPlot({
|
||
width: w,
|
||
height: h,
|
||
padding: [10, 14, 6, 6],
|
||
cursor: {
|
||
drag: { x: false, y: false, setScale: false },
|
||
points: { show: false },
|
||
// Double-click is ours (reset to the metric's framing), not uPlot's autoscale.
|
||
bind: { dblclick: () => () => {} },
|
||
},
|
||
legend: { show: false },
|
||
scales,
|
||
axes,
|
||
series: [{}, ...built.series.map((s) => ({
|
||
stroke: s.stroke, width: s.width, fill: s.fill || undefined, points: { show: false },
|
||
}))],
|
||
hooks: {
|
||
draw: [(plot) => drawPlotLines(plot, collectLines(state.payload))],
|
||
// uPlot sizes its plot area after construction, so the legend is placed
|
||
// once that layout exists (and again on every resize).
|
||
ready: [(plot) => { attachInteractions(plot.root, uplotAdapter(plot)); placeLegend(plot); }],
|
||
setSize: [placeLegend],
|
||
},
|
||
}, built.data, el('plot-host'));
|
||
state.uplot = u;
|
||
|
||
function placeLegend(plot) {
|
||
renderPlotLegend(payload, {
|
||
top: `${plot.over.offsetTop + 10}px`,
|
||
right: `${plot.root.clientWidth - plot.over.offsetLeft - plot.over.clientWidth + 10}px`,
|
||
});
|
||
}
|
||
}
|
||
|
||
// ---- spectrogram -------------------------------------------------------------
|
||
|
||
const heatmapImages = new WeakMap(); // z array -> offscreen canvas, one pixel per cell
|
||
|
||
function heatmapImage(hm) {
|
||
let src = heatmapImages.get(hm.z);
|
||
if (src) return src;
|
||
const { rows, cols } = hm;
|
||
const lut = buildMagmaLut(256);
|
||
src = document.createElement('canvas');
|
||
src.width = cols;
|
||
src.height = rows;
|
||
const sctx = src.getContext('2d');
|
||
const img = sctx.createImageData(cols, rows);
|
||
const span = (hm.z_max - hm.z_min) || 1;
|
||
const pixels = img.data;
|
||
for (let r = 0; r < rows; r++) {
|
||
const base = r * cols;
|
||
for (let c = 0; c < cols; c++) {
|
||
const t = (hm.z[base + c] - hm.z_min) / span;
|
||
const rgb = lut[Math.max(0, Math.min(255, Math.round(t * 255)))];
|
||
const p = (base + c) * 4;
|
||
pixels[p] = rgb[0]; pixels[p + 1] = rgb[1]; pixels[p + 2] = rgb[2]; pixels[p + 3] = 255;
|
||
}
|
||
}
|
||
sctx.putImageData(img, 0, 0);
|
||
heatmapImages.set(hm.z, src);
|
||
return src;
|
||
}
|
||
|
||
/** Keep a view window inside `full` (in the axis's own lin/log domain). */
|
||
function clampRange(range, full, log) {
|
||
const toD = (v) => (log ? Math.log10(v) : v);
|
||
const fromD = (v) => (log ? 10 ** v : v);
|
||
let a = toD(range[0]);
|
||
let b = toD(range[1]);
|
||
const fa = toD(full[0]);
|
||
const fb = toD(full[1]);
|
||
const minSpan = (fb - fa) * 1e-3;
|
||
if (b - a >= fb - fa) return [full[0], full[1]];
|
||
if (b - a < minSpan) { const c = (a + b) / 2; a = c - minSpan / 2; b = c + minSpan / 2; }
|
||
if (a < fa) { b += fa - a; a = fa; }
|
||
if (b > fb) { a -= b - fb; b = fb; }
|
||
return [fromD(a), fromD(b)];
|
||
}
|
||
|
||
function heatmapGeometry(payload) {
|
||
const hm = payload.datasets[0].payload.heatmap;
|
||
const axes = payload.axes || {};
|
||
const [w, h] = plotSize();
|
||
const padLeft = 58, padBottom = 40, padTop = 10, padRight = 14;
|
||
const yLog = yLogActive();
|
||
const xFull = axes.x_range || [hm.x[0], hm.x[hm.x.length - 1]];
|
||
const yFull = axes.y_range || [hm.y[0], hm.y[hm.y.length - 1]];
|
||
return {
|
||
hm, axes, w, h, padLeft, padTop, yLog, xFull, yFull,
|
||
plotW: Math.max(1, w - padLeft - padRight),
|
||
plotH: Math.max(1, h - padTop - padBottom),
|
||
xView: clampRange(state.zoom.x || xFull, xFull, false),
|
||
yView: clampRange(state.zoom.y || yFull, yFull, yLog),
|
||
};
|
||
}
|
||
|
||
/** Draw the visible part of the linear-row STFT, mapping rows to pixels in y. */
|
||
function renderHeatmap(payload) {
|
||
const g = heatmapGeometry(payload);
|
||
state.heatmapGeom = g;
|
||
const { hm, w, h, padLeft, padTop, plotW, plotH, xView, yView, yLog } = g;
|
||
|
||
const canvas = el('heatmap');
|
||
const dpr = window.devicePixelRatio || 1;
|
||
canvas.hidden = false;
|
||
if (canvas.width !== Math.floor(w * dpr) || canvas.height !== Math.floor(h * dpr)) {
|
||
canvas.width = Math.floor(w * dpr);
|
||
canvas.height = Math.floor(h * dpr);
|
||
canvas.style.width = `${w}px`;
|
||
canvas.style.height = `${h}px`;
|
||
}
|
||
const ctx = canvas.getContext('2d');
|
||
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
|
||
ctx.clearRect(0, 0, w, h);
|
||
|
||
const src = heatmapImage(hm);
|
||
const { rows, cols } = hm;
|
||
|
||
// Time -> source column coordinate: the track's time span covers [0, cols].
|
||
const t0 = hm.x[0];
|
||
const tSpan = (hm.x[cols - 1] - t0) || 1;
|
||
const toCol = (t) => ((t - t0) / tSpan) * cols;
|
||
const fromCol = (c) => t0 + (c / cols) * tSpan;
|
||
const xPix = (t) => padLeft + ((t - xView[0]) / (xView[1] - xView[0])) * plotW;
|
||
const cA = Math.max(0, toCol(xView[0]));
|
||
const cB = Math.min(cols, toCol(xView[1]));
|
||
const dxA = xPix(fromCol(cA));
|
||
const dxB = xPix(fromCol(cB));
|
||
|
||
ctx.save();
|
||
ctx.beginPath();
|
||
ctx.rect(padLeft, padTop, plotW, plotH);
|
||
ctx.clip();
|
||
ctx.imageSmoothingEnabled = true;
|
||
if (cB > cA) {
|
||
if (!yLog) {
|
||
// Rows are linear in frequency: one stretched blit of the visible block.
|
||
// Row r is centred on y0 + r*dy; image row 0 (lowest bin) goes at the bottom.
|
||
const toRow = (f) => (f - hm.y0) / hm.dy + 0.5;
|
||
const fromRow = (r) => hm.y0 + (r - 0.5) * hm.dy;
|
||
const yPix = (f) => padTop + plotH * (1 - (f - yView[0]) / (yView[1] - yView[0]));
|
||
const rA = Math.max(0, toRow(yView[0]));
|
||
const rB = Math.min(rows, toRow(yView[1]));
|
||
if (rB > rA) {
|
||
const top = yPix(fromRow(rB));
|
||
const bottom = yPix(fromRow(rA));
|
||
ctx.save();
|
||
ctx.translate(0, top + bottom);
|
||
ctx.scale(1, -1);
|
||
ctx.drawImage(src, cA, rA, cB - cA, rB - rA, dxA, top, dxB - dxA, bottom - top);
|
||
ctx.restore();
|
||
}
|
||
} else {
|
||
// Log axis: each scanline shows the row at its frequency.
|
||
const la = Math.log10(yView[0]);
|
||
const lb = Math.log10(yView[1]);
|
||
for (let py = 0; py < plotH; py++) {
|
||
const f = 10 ** (lb - ((py + 0.5) / plotH) * (lb - la));
|
||
const r = Math.floor((f - hm.y0) / hm.dy + 0.5);
|
||
if (r < 0 || r >= rows) continue;
|
||
ctx.drawImage(src, cA, r, cB - cA, 1, dxA, padTop + py, dxB - dxA, 1);
|
||
}
|
||
}
|
||
}
|
||
ctx.restore();
|
||
|
||
drawHeatmapAxes(ctx, g);
|
||
drawColorbar(ctx, g);
|
||
renderPlotLegend(payload, { top: `${padTop + 10}px`, left: `${padLeft + 10}px` });
|
||
}
|
||
|
||
function niceTicks(a, b, target) {
|
||
const span = b - a;
|
||
if (!(span > 0)) return [a];
|
||
const raw = span / Math.max(1, target);
|
||
const mag = 10 ** Math.floor(Math.log10(raw));
|
||
const step = [1, 2, 2.5, 5, 10].map((m) => m * mag).find((s) => s >= raw * 0.999);
|
||
const ticks = [];
|
||
for (let i = Math.ceil(a / step - 1e-9); i * step <= b + step * 1e-9; i++) ticks.push(i * step);
|
||
return ticks;
|
||
}
|
||
|
||
function logTicks(a, b) {
|
||
const ticks = [];
|
||
for (let e = Math.floor(Math.log10(a)); e <= Math.ceil(Math.log10(b)); e++) {
|
||
for (const m of [1, 2, 5]) {
|
||
const v = m * 10 ** e;
|
||
if (v >= a && v <= b) ticks.push(v);
|
||
}
|
||
}
|
||
return ticks.length >= 3 ? ticks : niceTicks(a, b, 5);
|
||
}
|
||
|
||
function fmtHz(v) {
|
||
if (v >= 1000) return `${Number((v / 1000).toPrecision(4))}k`;
|
||
return `${Number(v.toPrecision(4))}`;
|
||
}
|
||
|
||
function drawHeatmapAxes(ctx, g) {
|
||
const { padLeft, padTop, plotW, plotH, xView, yView, yLog, axes } = g;
|
||
ctx.font = font(11);
|
||
ctx.strokeStyle = 'rgba(255,255,255,.12)';
|
||
ctx.fillStyle = '#8b93a3';
|
||
|
||
const yPos = (v) => {
|
||
const t = yLog
|
||
? (Math.log10(v) - Math.log10(yView[0])) / (Math.log10(yView[1]) - Math.log10(yView[0]))
|
||
: (v - yView[0]) / (yView[1] - yView[0]);
|
||
return padTop + plotH * (1 - t);
|
||
};
|
||
ctx.textAlign = 'right';
|
||
ctx.textBaseline = 'middle';
|
||
(yLog ? logTicks(yView[0], yView[1]) : niceTicks(yView[0], yView[1], 6)).forEach((v) => {
|
||
const y = yPos(v);
|
||
if (y < padTop - 1 || y > padTop + plotH + 1) return;
|
||
ctx.beginPath(); ctx.moveTo(padLeft - 4, y); ctx.lineTo(padLeft, y); ctx.stroke();
|
||
ctx.fillText(fmtHz(v), padLeft - 7, y);
|
||
});
|
||
|
||
const xTicks = niceTicks(xView[0], xView[1], Math.max(3, Math.floor(plotW / 110)));
|
||
const xStep = xTicks.length > 1 ? xTicks[1] - xTicks[0] : 1;
|
||
ctx.textBaseline = 'top';
|
||
xTicks.forEach((v) => {
|
||
const x = padLeft + ((v - xView[0]) / (xView[1] - xView[0])) * plotW;
|
||
if (x < padLeft - 1 || x > padLeft + plotW + 1) return;
|
||
ctx.beginPath(); ctx.moveTo(x, padTop + plotH); ctx.lineTo(x, padTop + plotH + 4); ctx.stroke();
|
||
ctx.textAlign = x > padLeft + plotW - 24 ? 'right' : 'center';
|
||
ctx.fillText(formatTick(v, xStep), x, padTop + plotH + 7);
|
||
});
|
||
|
||
ctx.textAlign = 'center';
|
||
ctx.fillText(axes.x_label || 'Time (seconds)', padLeft + plotW / 2, padTop + plotH + 22);
|
||
ctx.save();
|
||
ctx.translate(13, padTop + plotH / 2);
|
||
ctx.rotate(-Math.PI / 2);
|
||
ctx.textBaseline = 'middle';
|
||
ctx.fillText(axes.y_label || 'Frequency (Hz)', 0, 0);
|
||
ctx.restore();
|
||
}
|
||
|
||
function drawColorbar(ctx, g) {
|
||
// A compact colourbar in the plot's top-right corner — the desktop app gets
|
||
// this free from pyqtgraph; here it is a 12 px strip on a dimmed backing.
|
||
const { hm, padLeft, padTop, plotW } = g;
|
||
const w = 12, h = 96;
|
||
const x = padLeft + plotW - 58;
|
||
const y = padTop + 10;
|
||
const lut = buildMagmaLut(64);
|
||
ctx.fillStyle = 'rgba(13, 15, 20, 0.7)';
|
||
ctx.fillRect(x - 6, y - 6, 58, h + 12);
|
||
for (let i = 0; i < h; i++) {
|
||
const [r, gr, b] = lut[Math.round((1 - i / (h - 1)) * 63)];
|
||
ctx.fillStyle = `rgb(${r},${gr},${b})`;
|
||
ctx.fillRect(x, y + i, w, 1);
|
||
}
|
||
ctx.strokeStyle = 'rgba(255,255,255,.25)';
|
||
ctx.strokeRect(x, y, w, h);
|
||
ctx.fillStyle = '#8b93a3';
|
||
ctx.font = font(10);
|
||
ctx.textAlign = 'left';
|
||
ctx.textBaseline = 'middle';
|
||
ctx.fillText(`${hm.z_max.toFixed(0)} dB`, x + w + 4, y + 4);
|
||
ctx.fillText(`${hm.z_min.toFixed(0)} dB`, x + w + 4, y + h - 4);
|
||
}
|
||
|
||
const magmaLuts = new Map();
|
||
function buildMagmaLut(n) {
|
||
if (magmaLuts.has(n)) return magmaLuts.get(n);
|
||
// matplotlib's 'magma' control points (256-entry table, sampled).
|
||
const stops = [
|
||
[0.000, 0, 0, 4], [0.125, 28, 16, 68], [0.250, 79, 18, 123], [0.375, 129, 37, 129],
|
||
[0.500, 181, 54, 122], [0.625, 229, 80, 100], [0.750, 251, 135, 97], [0.875, 254, 194, 135],
|
||
[1.000, 252, 253, 191],
|
||
];
|
||
const lut = new Array(n);
|
||
for (let i = 0; i < n; i++) {
|
||
const t = i / (n - 1);
|
||
let a = stops[0], b = stops[stops.length - 1];
|
||
for (let s = 0; s < stops.length - 1; s++) {
|
||
if (t >= stops[s][0] && t <= stops[s + 1][0]) { a = stops[s]; b = stops[s + 1]; break; }
|
||
}
|
||
const span = (b[0] - a[0]) || 1;
|
||
const u = (t - a[0]) / span;
|
||
lut[i] = [
|
||
Math.round(a[1] + (b[1] - a[1]) * u),
|
||
Math.round(a[2] + (b[2] - a[2]) * u),
|
||
Math.round(a[3] + (b[3] - a[3]) * u),
|
||
];
|
||
}
|
||
magmaLuts.set(n, lut);
|
||
return lut;
|
||
}
|
||
|
||
// ---- zoom, pan & line dragging -------------------------------------------------
|
||
// The desktop ViewBox semantics: scroll zooms both axes around the cursor,
|
||
// Ctrl+scroll zooms time only, Shift+scroll the value axis only, and scrolling
|
||
// over an axis zooms just that axis. Dragging the plot pans; dragging near a
|
||
// reference line (or its triangle handle) moves the line. Both update live.
|
||
// Double-click returns to the metric's own framing.
|
||
|
||
const toDomain = (v, log) => (log ? Math.log10(v) : v);
|
||
const fromDomain = (v, log) => (log ? 10 ** v : v);
|
||
|
||
function zoomRange(range, frac, factor, log) {
|
||
const a = toDomain(range[0], log);
|
||
const b = toDomain(range[1], log);
|
||
const anchor = a + frac * (b - a);
|
||
return [fromDomain(anchor - (anchor - a) * factor, log), fromDomain(anchor + (b - anchor) * factor, log)];
|
||
}
|
||
|
||
function shiftRange(range, frac, log) {
|
||
const a = toDomain(range[0], log);
|
||
const b = toDomain(range[1], log);
|
||
const d = frac * (b - a);
|
||
return [fromDomain(a + d, log), fromDomain(b + d, log)];
|
||
}
|
||
|
||
/** 'plot', 'x' (under the plot), 'y' (left of it) or null. */
|
||
function regionAt(e, rect) {
|
||
if (!rect) return null;
|
||
const fx = (e.clientX - rect.left) / rect.width;
|
||
const fy = (e.clientY - rect.top) / rect.height;
|
||
const inX = fx >= 0 && fx <= 1;
|
||
const inY = fy >= 0 && fy <= 1;
|
||
if (inX && inY) return 'plot';
|
||
if (inY && fx < 0) return 'y';
|
||
if (inX && fy > 1) return 'x';
|
||
return null;
|
||
}
|
||
|
||
let drag = null;
|
||
let pendingMove = null;
|
||
let moveFrame = 0;
|
||
|
||
function attachInteractions(target, adapter) {
|
||
target.addEventListener('wheel', (e) => {
|
||
const rect = adapter.rect();
|
||
const region = regionAt(e, rect);
|
||
if (!region) return;
|
||
e.preventDefault();
|
||
let zoomX = region !== 'y';
|
||
let zoomY = region !== 'x';
|
||
if (region === 'plot' && e.ctrlKey !== e.shiftKey) {
|
||
zoomX = e.ctrlKey;
|
||
zoomY = e.shiftKey;
|
||
}
|
||
// Shift+wheel arrives as horizontal scroll in most browsers.
|
||
let delta = e.deltaY || e.deltaX;
|
||
if (e.deltaMode === 1) delta *= 16;
|
||
else if (e.deltaMode === 2) delta *= rect.height;
|
||
const factor = Math.exp(Math.max(-250, Math.min(250, delta)) * 0.002);
|
||
const cur = adapter.ranges();
|
||
const fx = (e.clientX - rect.left) / rect.width;
|
||
const fy = (e.clientY - rect.top) / rect.height;
|
||
adapter.apply(
|
||
zoomX ? zoomRange(cur.x, fx, factor, adapter.log.x) : null,
|
||
zoomY ? zoomRange(cur.y, 1 - fy, factor, adapter.log.y) : null,
|
||
);
|
||
}, { passive: false });
|
||
|
||
target.addEventListener('mousedown', (e) => {
|
||
if (e.button !== 0) return;
|
||
const rect = adapter.rect();
|
||
if (regionAt(e, rect) !== 'plot') return;
|
||
const ref = adapter.hitRef ? adapter.hitRef(e.clientY) : null;
|
||
const cur = adapter.ranges();
|
||
drag = ref
|
||
? { kind: 'ref', adapter, ref }
|
||
: { kind: 'pan', adapter, rect, startX: e.clientX, startY: e.clientY, x: cur.x, y: cur.y };
|
||
document.body.classList.add(ref ? 'dragging-ref' : 'dragging-pan');
|
||
e.preventDefault();
|
||
});
|
||
|
||
target.addEventListener('dblclick', (e) => {
|
||
if (!regionAt(e, adapter.rect())) return;
|
||
e.preventDefault();
|
||
adapter.reset();
|
||
});
|
||
|
||
target.addEventListener('mousemove', (e) => {
|
||
if (drag) return;
|
||
const region = regionAt(e, adapter.rect());
|
||
const nearRef = region === 'plot' && adapter.hitRef && adapter.hitRef(e.clientY);
|
||
target.style.cursor = nearRef ? 'ns-resize' : region === 'plot' ? 'grab' : '';
|
||
});
|
||
}
|
||
|
||
function applyDrag(e) {
|
||
const a = drag.adapter;
|
||
if (drag.kind === 'ref') {
|
||
drag.ref.value = a.valueAtY(e.clientY);
|
||
syncRefInputs();
|
||
a.redrawLines();
|
||
return;
|
||
}
|
||
const fx = (e.clientX - drag.startX) / drag.rect.width;
|
||
const fy = (e.clientY - drag.startY) / drag.rect.height;
|
||
a.apply(shiftRange(drag.x, -fx, a.log.x), shiftRange(drag.y, fy, a.log.y));
|
||
}
|
||
|
||
window.addEventListener('mousemove', (e) => {
|
||
if (!drag) return;
|
||
pendingMove = e;
|
||
if (!moveFrame) {
|
||
moveFrame = requestAnimationFrame(() => {
|
||
moveFrame = 0;
|
||
if (drag && pendingMove) applyDrag(pendingMove);
|
||
});
|
||
}
|
||
});
|
||
|
||
window.addEventListener('mouseup', () => {
|
||
if (!drag) return;
|
||
if (pendingMove) applyDrag(pendingMove); // land exactly where the button was released
|
||
drag = null;
|
||
pendingMove = null;
|
||
document.body.classList.remove('dragging-ref', 'dragging-pan');
|
||
});
|
||
|
||
function uplotAdapter(u) {
|
||
const overTop = () => u.over.getBoundingClientRect().top;
|
||
return {
|
||
log: { x: false, y: u.scales.y.distr === 3 },
|
||
rect: () => u.over.getBoundingClientRect(),
|
||
ranges: () => ({ x: [u.scales.x.min, u.scales.x.max], y: [u.scales.y.min, u.scales.y.max] }),
|
||
apply: (x, y) => {
|
||
if (x) state.zoom.x = x;
|
||
if (y) state.zoom.y = y;
|
||
u.batch(() => {
|
||
if (x) u.setScale('x', { min: x[0], max: x[1] });
|
||
if (y) u.setScale('y', { min: y[0], max: y[1] });
|
||
});
|
||
},
|
||
reset: () => {
|
||
resetZoom();
|
||
drawPayload(state.payload);
|
||
},
|
||
hitRef: (clientY) => {
|
||
const top = overTop();
|
||
let best = null;
|
||
let bestDist = 7;
|
||
currentRefs().forEach((ref) => {
|
||
const d = Math.abs(u.valToPos(ref.value, 'y') + top - clientY);
|
||
if (d < bestDist) { bestDist = d; best = ref; }
|
||
});
|
||
return best;
|
||
},
|
||
valueAtY: (clientY) => u.posToVal(clientY - overTop(), 'y'),
|
||
redrawLines: () => u.redraw(false, false),
|
||
};
|
||
}
|
||
|
||
const heatmapAdapter = {
|
||
get log() { return { x: false, y: !!(state.heatmapGeom && state.heatmapGeom.yLog) }; },
|
||
rect: () => {
|
||
const g = state.heatmapGeom;
|
||
if (!g) return null;
|
||
const r = el('heatmap').getBoundingClientRect();
|
||
return { left: r.left + g.padLeft, top: r.top + g.padTop, width: g.plotW, height: g.plotH };
|
||
},
|
||
ranges: () => ({ x: state.heatmapGeom.xView, y: state.heatmapGeom.yView }),
|
||
apply: (x, y) => {
|
||
const g = state.heatmapGeom;
|
||
if (x) state.zoom.x = clampRange(x, g.xFull, false);
|
||
if (y) state.zoom.y = clampRange(y, g.yFull, g.yLog);
|
||
renderHeatmap(state.payload);
|
||
},
|
||
reset: () => {
|
||
resetZoom();
|
||
renderHeatmap(state.payload);
|
||
},
|
||
hitRef: null,
|
||
};
|
||
|
||
// ---- orchestration ---------------------------------------------------------
|
||
|
||
/**
|
||
* The browser's `/api/plot`: one payload per overlaid track for the current
|
||
* metric, built from cached results and the current view state.
|
||
*/
|
||
function buildPlotPayload() {
|
||
const metric = METRIC_BY_ID[state.metric];
|
||
const view = { yLog: state.yLog, xLog: state.xLog, xMode: state.xMode };
|
||
const datasets = [];
|
||
const warnings = [];
|
||
|
||
state.overlay.map(fileById).filter(Boolean).forEach((f, index) => {
|
||
const data = resultFor(f.file_id, metric.id);
|
||
if (!data) {
|
||
warnings.push(`${f.song_name}: ${metric.displayName} not available`);
|
||
return;
|
||
}
|
||
let spec;
|
||
try {
|
||
spec = metric.buildSpec(data, view);
|
||
} catch (err) {
|
||
warnings.push(`${f.song_name}: ${err.message || err}`);
|
||
return;
|
||
}
|
||
if (state.xMode === 'relative') spec = applyRelativeX(spec);
|
||
datasets.push({ file_id: f.file_id, label: f.song_name, index, payload: spec });
|
||
});
|
||
|
||
// Heatmaps don't overlay: show the first drawn track, as on the desktop.
|
||
if (datasets.length > 1 && datasets[0].payload.heatmap) {
|
||
warnings.push(`${metric.displayName}: one track at a time — showing ${datasets[0].label}`);
|
||
datasets.splice(1);
|
||
}
|
||
|
||
return {
|
||
metric: metric.id,
|
||
metric_name: metric.displayName,
|
||
datasets,
|
||
axes: datasets.length ? datasets[0].payload.axes : null,
|
||
warnings,
|
||
};
|
||
}
|
||
|
||
async function refreshPlot() {
|
||
if (state.busy) {
|
||
state.refreshAgain = true;
|
||
return;
|
||
}
|
||
state.busy = true;
|
||
try {
|
||
const metric = METRIC_BY_ID[state.metric];
|
||
const failures = [];
|
||
for (const fileId of state.overlay) {
|
||
const f = fileById(fileId);
|
||
if (!f || resultFor(fileId, metric.id)) continue;
|
||
setStatus(`Computing ${metric.displayName} — ${f.name}…`, 'working');
|
||
try {
|
||
await requestMetric(fileId, metric.id);
|
||
} catch (err) {
|
||
failures.push(`${f.name}: ${err.message || err}`);
|
||
}
|
||
}
|
||
|
||
const t0 = performance.now();
|
||
const payload = buildPlotPayload();
|
||
state.payload = payload;
|
||
el('plot-title').textContent = payload.datasets.length
|
||
? `${payload.metric_name}${payload.datasets.length > 1 ? ` — ${payload.datasets.length} tracks` : ` — ${payload.datasets[0].label}`}`
|
||
: `${payload.metric_name} — nothing selected`;
|
||
drawPayload(payload);
|
||
|
||
const computeMs = payload.datasets
|
||
.map((d) => state.timings[d.file_id] && state.timings[d.file_id][payload.metric])
|
||
.filter((v) => v !== undefined);
|
||
const drawMs = performance.now() - t0;
|
||
setTiming(computeMs.length
|
||
? `${payload.metric_name} computed in ${fmtSeconds(Math.max(...computeMs))} · drawn in ${fmtSeconds(drawMs)}`
|
||
: `drawn in ${fmtSeconds(drawMs)}`);
|
||
|
||
const warnings = failures.concat(payload.warnings);
|
||
if (warnings.length) setStatus(warnings.join(' · '), 'warn');
|
||
else if (!state.files.length) setStatus('Drop audio files to begin');
|
||
else if (!payload.datasets.length) setStatus('Tick a file to draw it', 'warn');
|
||
else setStatus('Ready');
|
||
} catch (err) {
|
||
setStatus(String(err.message || err), 'error');
|
||
} finally {
|
||
state.busy = false;
|
||
if (state.refreshAgain) {
|
||
state.refreshAgain = false;
|
||
refreshPlot();
|
||
}
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Decode dropped/picked files one at a time, hand each mono signal to the
|
||
* worker, draw the current metric, then prefetch the rest in the background.
|
||
*/
|
||
async function ingest(fileList) {
|
||
const files = Array.from(fileList).filter((f) => AUDIO_EXTENSIONS.test(f.name));
|
||
if (!files.length) {
|
||
if (fileList.length) setStatus('No supported audio in that selection', 'warn');
|
||
return;
|
||
}
|
||
|
||
const failures = [];
|
||
const added = [];
|
||
for (let i = 0; i < files.length; i += 1) {
|
||
const f = files[i];
|
||
const tag = `(${i + 1}/${files.length}) ${f.name}`;
|
||
const key = `${f.name}|${f.size}|${f.lastModified}`;
|
||
if (state.files.some((x) => x.key === key)) {
|
||
failures.push(`${f.name}: already loaded`);
|
||
continue;
|
||
}
|
||
|
||
setStatus(`Decoding ${tag}…`, 'working');
|
||
try {
|
||
const t0 = performance.now();
|
||
const audio = await decodeFile(f);
|
||
const decodeMs = performance.now() - t0;
|
||
const fileId = `t${state.nextId++}`;
|
||
state.results[fileId] = {};
|
||
state.timings[fileId] = {};
|
||
worker.postMessage(
|
||
{ type: 'load', trackId: fileId, mono: audio.mono, sampleRate: audio.sampleRate },
|
||
[audio.mono.buffer],
|
||
);
|
||
state.files.push({
|
||
file_id: fileId,
|
||
key,
|
||
name: f.name,
|
||
song_name: audio.songName,
|
||
format: audio.format,
|
||
size: audio.size,
|
||
sample_rate: audio.sampleRate,
|
||
native_rate_known: audio.nativeRateKnown,
|
||
channels: audio.channels,
|
||
duration: audio.duration,
|
||
max_amplitude: audio.maxAmplitude,
|
||
avg_amplitude: audio.avgAmplitude,
|
||
});
|
||
if (!state.overlay.includes(fileId)) state.overlay.push(fileId);
|
||
if (!state.selected) state.selected = fileId;
|
||
added.push(fileId);
|
||
renderFileList();
|
||
renderMetadata();
|
||
setTiming(`${f.name} decoded in ${fmtSeconds(decodeMs)}`);
|
||
|
||
setStatus(`Analysing ${tag} — ${METRIC_BY_ID[state.metric].displayName}…`, 'working');
|
||
await requestMetric(fileId, state.metric);
|
||
} catch (err) {
|
||
failures.push(`${f.name}: ${err.message || err}`);
|
||
}
|
||
}
|
||
|
||
await refreshPlot();
|
||
added.forEach(prefetch);
|
||
if (failures.length) setStatus(failures.join(' · '), 'warn');
|
||
}
|
||
|
||
function wireEvents() {
|
||
el('metric-select').addEventListener('change', async (e) => {
|
||
state.metric = e.target.value;
|
||
state.refs = refsFor(state.metric);
|
||
state.yLog = null;
|
||
state.xLog = null;
|
||
resetZoom();
|
||
renderRefList();
|
||
await refreshPlot();
|
||
});
|
||
|
||
el('x-mode-select').addEventListener('change', async (e) => {
|
||
state.xMode = e.target.value;
|
||
state.zoom.x = null;
|
||
await refreshPlot();
|
||
});
|
||
|
||
el('toggle-y-log').addEventListener('click', async () => {
|
||
state.yLog = !yLogActive();
|
||
state.zoom.y = null;
|
||
await refreshPlot();
|
||
});
|
||
|
||
el('refresh-plot').addEventListener('click', () => refreshPlot());
|
||
|
||
el('pick-files').addEventListener('click', () => el('file-input').click());
|
||
el('file-input').addEventListener('change', async (e) => {
|
||
const picked = Array.from(e.target.files);
|
||
e.target.value = '';
|
||
await ingest(picked);
|
||
});
|
||
|
||
el('add-ref').addEventListener('click', () => {
|
||
const refs = currentRefs();
|
||
const u = state.uplot;
|
||
const axis = (state.payload && state.payload.axes) || {};
|
||
const range = u ? [u.scales.y.min, u.scales.y.max] : axis.y_range || [0, 1];
|
||
refs.push({
|
||
value: Number(((range[0] + range[1]) / 2).toFixed(2)),
|
||
color: PALETTE[(refs.length + 3) % PALETTE.length],
|
||
style: 'dash',
|
||
label: '',
|
||
});
|
||
renderRefList();
|
||
redrawLines();
|
||
});
|
||
|
||
el('clear-refs').addEventListener('click', () => {
|
||
state.refsByMetric[state.metric] = [];
|
||
state.refs = state.refsByMetric[state.metric];
|
||
renderRefList();
|
||
redrawLines();
|
||
});
|
||
|
||
attachInteractions(el('heatmap'), heatmapAdapter);
|
||
|
||
// Drag & drop onto the window, like the desktop app's setAcceptDrops(True).
|
||
const overlay = el('drop-overlay');
|
||
let dragDepth = 0;
|
||
window.addEventListener('dragenter', (e) => {
|
||
if (!Array.from(e.dataTransfer.types || []).includes('Files')) return;
|
||
dragDepth++;
|
||
overlay.hidden = false;
|
||
});
|
||
window.addEventListener('dragleave', () => {
|
||
dragDepth = Math.max(0, dragDepth - 1);
|
||
if (!dragDepth) overlay.hidden = true;
|
||
});
|
||
window.addEventListener('dragover', (e) => e.preventDefault());
|
||
window.addEventListener('drop', async (e) => {
|
||
e.preventDefault();
|
||
dragDepth = 0;
|
||
overlay.hidden = true;
|
||
if (e.dataTransfer.files && e.dataTransfer.files.length) await ingest(e.dataTransfer.files);
|
||
});
|
||
|
||
window.addEventListener('resize', () => {
|
||
if (state.payload) drawPayload(state.payload);
|
||
});
|
||
|
||
document.addEventListener('keydown', (e) => {
|
||
if (e.target.tagName === 'INPUT' || e.target.tagName === 'SELECT') return;
|
||
if (e.key === 'r') refreshPlot();
|
||
if (e.key === 'l' && !el('toggle-y-log').disabled) el('toggle-y-log').click();
|
||
});
|
||
}
|
||
|
||
function boot() {
|
||
populateMetricSelect();
|
||
state.refs = refsFor(state.metric);
|
||
wireEvents();
|
||
renderFileList();
|
||
renderMetadata();
|
||
renderRefList();
|
||
setStatus('Drop audio files to begin');
|
||
}
|
||
|
||
boot();
|
||
|
||
// Test hook for the browser end-to-end checks: read-only views of state.
|
||
window.__ujm = {
|
||
state,
|
||
ready: () => !running && !queue.length,
|
||
results: (fileId, metricId) => resultFor(fileId, metricId),
|
||
};
|