Files
uj-mastering-master/web/app.js
T
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

1482 lines
50 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/* 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),
};