/* 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), };