/* 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 colour cycle, lin/log, draggable reference lines, overlay). */ import { decodeFile } from './decode.js'; import { METRICS, METRIC_BY_ID, DEFAULT_METRIC_ID, applyRelativeX } from './metrics.js'; 'use strict'; // ---- palette --------------------------------------------------------------- // 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', ]; const AUDIO_EXTENSIONS = /\.(wav|mp3|flac|ogg|oga|m4a|aiff?|opus)$/i; 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, 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`; } 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 currentRefs() { return state.refs; } // ---- 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 = state.results[trackId] && state.results[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 (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) { METRICS.forEach((m) => { if (m.id !== state.metric) requestMetric(trackId, m.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 ------------------------------------------------------------- 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}\n${(f.format || 'audio').toUpperCase()} · ${(f.size / 1048576).toFixed(1)} MB · held in this tab only`; const remove = document.createElement('button'); remove.className = 'file-remove'; remove.type = 'button'; remove.textContent = '×'; remove.title = 'Forget this track (discards its audio and analysis)'; remove.addEventListener('click', async (ev) => { ev.stopPropagation(); forgetTrack(f.file_id); renderFileList(); renderMetadata(); await refreshPlot(); }); li.append(check, swatch, name, 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); let rows = [['Track', 'nothing selected']]; if (file) { const computed = METRICS .filter((m) => state.results[file.file_id] && state.results[file.file_id][m.id]) .map((m) => m.displayName); rows = [ ['Track', file.song_name], ['Length', fmtDuration(file.duration)], ['Sample rate', `${(file.sample_rate / 1000).toFixed(1)} kHz${file.native_rate_known ? '' : ' (header not recognised — resampled)'}`], ['Channels', file.channels === 1 ? 'mono' : `${file.channels} (analysed as mono)`], ['Max amplitude', fmt(file.max_amplitude, 3)], ['Avg amplitude', fmt(file.avg_amplitude, 3)], ['Format', `${(file.format || '?').toUpperCase()}, ${(file.size / 1048576).toFixed(1)} MB`], ['Audio held by', 'this tab only (memory)'], ['Metrics computed', computed.length ? computed.join(', ') : '—'], ]; } rows.forEach(([k, v]) => { const dt = document.createElement('dt'); dt.textContent = k; const dd = document.createElement('dd'); dd.textContent = v; if (!file) dd.className = 'muted'; dl.append(dt, dd); }); } // ---- 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; redrawRefs(); }); const value = document.createElement('input'); value.type = 'number'; value.step = '0.1'; value.value = ref.value; value.title = 'Position on the value axis'; value.addEventListener('change', () => { ref.value = parseFloat(value.value) || 0; redrawRefs(); }); const label = document.createElement('input'); label.type = 'text'; label.value = ref.label || ''; label.placeholder = 'tag'; label.addEventListener('change', () => { ref.label = label.value; redrawRefs(); }); const remove = document.createElement('button'); remove.className = 'ref-remove'; remove.type = 'button'; remove.textContent = '×'; remove.addEventListener('click', () => { currentRefs().splice(index, 1); renderRefList(); redrawRefs(); }); li.append(color, value, label, remove); list.appendChild(li); }); } // ---- 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 buildUplotData(payload, colorFor) { // 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 = []; const constant = (v) => new Array(x.length).fill(v); datasets.forEach((d) => { const colour = colorFor(d); d.payload.segments.forEach((seg, si) => { const single = datasets.length === 1; const tag = seg.label || 'band'; data.push(resample(seg.x, seg.lo)); data.push(resample(seg.x, seg.hi)); // Bands draw as two lines; only the second gets a legend entry so the // pair reads as one dataset. 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({ label: single ? (si === 0 ? tag : '') : (si === 0 ? `${d.label}: ${tag}` : ''), stroke: colour, width: isEnvelope ? 1 : 1.4, fill: isEnvelope ? null : hexToRgba(colour, 0.22), _ref: null, }); series.push({ label: '', stroke: colour, width: isEnvelope ? 1 : 1.4, fill: isEnvelope ? hexToRgba(colour, 0.28) : null, _ref: null, }); }); d.payload.series.forEach((line) => { const single = datasets.length === 1; data.push(resample(line.x, line.y)); series.push({ label: single ? line.label : `${d.label}: ${line.label}`, stroke: line.color || colour, width: line.width || 1.4, fill: null, _ref: null, }); }); }); // The metric's own reference lines (-14 LUFS, integrated loudness, 0 dBTP…). // Identical lines from several tracks are drawn once; per-track lines such as // "Integrated" are prefixed with the track when more than one is drawn. const seen = new Set(); datasets.forEach((d) => { (d.payload.hlines || []).forEach((hl) => { const key = `${hl.y}|${hl.label}`; if (seen.has(key)) return; seen.add(key); data.push(constant(hl.y)); series.push({ label: datasets.length > 1 && /^Integrated/.test(hl.label) ? `${d.label}: ${hl.label}` : hl.label, stroke: themeColor(hl.color), width: hl.width || 1, dash: dashFor(hl.style), fill: null, _ref: null, }); }); }); // User reference lines become extra series with filled values, so uPlot draws // them as part of the same resampled grid instead of an annotation overlay. const axis = payload.axes || {}; currentRefs().forEach((ref) => { const lo = axis.y_range ? axis.y_range[0] : 0; data.push(constant(axis.y_log ? Math.max(ref.value, lo) : ref.value)); series.push({ label: ref.label || `${ref.value}`, stroke: ref.color || '#ffb74d', width: 1, dash: dashFor(ref.style), fill: null, _ref: ref, }); }); return { x, data, series }; } 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 renderLegend(payload) { const box = el('legend'); box.innerHTML = ''; payload.datasets.forEach((d) => { const item = document.createElement('span'); item.className = 'item'; const sw = document.createElement('span'); sw.className = 'swatch'; sw.style.background = paletteFor(d.file_id); const text = document.createElement('span'); text.textContent = d.label; item.append(sw, text); box.appendChild(item); }); const notes = new Set(); payload.datasets.forEach((d) => (d.payload.annotations || []).forEach((n) => notes.add( payload.datasets.length > 1 ? `${d.label}: ${n}` : n))); notes.forEach((n) => { const item = document.createElement('span'); item.className = 'item annotation'; item.textContent = n; box.appendChild(item); }); } function destroyPlot() { if (state.uplot) { state.uplot.destroy(); state.uplot = null; } el('heatmap').hidden = true; 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 buildAxes(axes) { if (!axes) return { scales: {}, axes: [] }; const yLog = yLogActive(); const xLog = state.xLog === null ? !!axes.x_log : state.xLog; const scales = { x: { time: false, log: xLog ? 10 : false }, y: { log: yLog ? 10 : false }, }; // Explicit ranges: the metric's own defaults, but only for the axes it frames. // Letting uPlot auto-range a log axis over a series whose values start at 0 // produces an unreadable scale, so ranged axes are applied as given. if (axes.x_range) scales.x.range = [axes.x_range[0], axes.x_range[1]]; if (axes.y_range) { scales.y.range = (u, min, max) => (axes.y_range ? axes.y_range : [min, max]); } else if (yLog) { const bounds = state.payload.datasets .map((d) => d.payload.y_bounds) .filter(Boolean); if (bounds.length) { const lo = Math.min(...bounds.map((b) => Math.abs(b[0]) || 1e-3)); const hi = Math.max(...bounds.map((b) => b[1])); scales.y.range = () => [Math.max(lo, 1e-9), hi]; } } const yAxis = { stroke: '#8b93a3', grid: { stroke: 'rgba(255,255,255,.07)', width: 1 }, ticks: { stroke: 'rgba(255,255,255,.12)' }, font: '11px system-ui, sans-serif', label: axes.y_label, labelFont: '11px system-ui, sans-serif', labelSize: 16, size: 58, values: (u, vals) => formatTicks(vals, yLog), }; const xAxis = { stroke: '#8b93a3', grid: { stroke: 'rgba(255,255,255,.07)', width: 1 }, ticks: { stroke: 'rgba(255,255,255,.12)' }, font: '11px system-ui, sans-serif', label: axes.x_label, labelFont: '11px system-ui, sans-serif', labelSize: 16, size: 40, values: (u, vals) => formatTicks(vals, xLog), }; return { scales, axes: [xAxis, yAxis] }; } function formatTicks(vals, isLog) { return vals.map((v) => { if (v === null || v === undefined) return ''; if (isLog) { if (v >= 1000) return `${(v / 1000).toFixed(v >= 10000 ? 0 : 1)}k`; if (v >= 1) return `${Math.round(v)}`; return v.toPrecision(1); } const abs = Math.abs(v); if (abs >= 1000) return `${(v / 1000).toFixed(1)}k`; if (abs >= 100) return v.toFixed(0); if (abs >= 1) return v.toFixed(1); if (abs === 0) return '0'; return v.toFixed(2); }); } /** Draw the linear-row STFT onto a canvas, mapping rows to pixels in y. */ function renderHeatmap(payload) { const hm = payload.datasets[0].payload.heatmap; const canvas = el('heatmap'); const [w, h] = plotSize(); const dpr = window.devicePixelRatio || 1; canvas.hidden = false; 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'); const padLeft = 58, padBottom = 40, padTop = 8, padRight = 10; const plotW = Math.max(1, w - padLeft - padRight); const plotH = Math.max(1, h - padTop - padBottom); const axes = payload.axes || {}; const xRange = axes.x_range || [hm.x[0], hm.x[hm.x.length - 1]]; const yRange = axes.y_range || [Math.max(hm.y[0], 1e-6), hm.y[hm.y.length - 1]]; const yLog = yLogActive(); const { rows, cols } = hm; const lut = buildMagmaLut(256); // One offscreen pixel per STFT cell, then one scaled drawImage: the browser's // bilinear scale is the interpolation, which keeps this O(cells) in JS and // lets the GPU do the resampling for the display size. const 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 idx = Math.max(0, Math.min(255, Math.round(t * 255))); const rgb = lut[idx]; 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); // Rows are linear in frequency, so a linear y axis is a single stretch. A log // axis needs row-by-row drawing: for each destination scanline work out which // source row's frequency band it covers and blit that single row, expanded. ctx.clearRect(0, 0, canvas.width, canvas.height); ctx.save(); ctx.scale(dpr, dpr); if (!yLog) { // z row 0 is the lowest frequency; canvas y=0 is the top, so flip by scaling. ctx.save(); ctx.translate(padLeft, padTop + plotH); ctx.scale(1, -1); ctx.imageSmoothingEnabled = true; ctx.drawImage(src, 0, 0, cols, rows, 0, 0, plotW, plotH); ctx.restore(); } else { const yLo = Math.max(yRange[0], hm.y0); const yHi = yRange[1]; const logLo = Math.log10(yLo); const logHi = Math.log10(yHi); const dy = hm.dy; for (let py = 0; py < plotH; py++) { // canvas top = high frequency const freqHi = Math.pow(10, logHi - (py / plotH) * (logHi - logLo)); const freqLo = Math.pow(10, logHi - ((py + 1) / plotH) * (logHi - logLo)); let rHi = Math.round((freqHi - hm.y0) / dy) - 1; let rLo = Math.round((freqLo - hm.y0) / dy); rHi = Math.max(0, Math.min(rows - 1, rHi)); rLo = Math.max(0, Math.min(rows - 1, rLo)); const srcRow = Math.max(0, Math.min(rows - 1, Math.floor((rHi + rLo) / 2))); ctx.drawImage(src, 0, srcRow, cols, 1, padLeft, padTop + py, plotW, 1); } } drawHeatmapAxes(ctx, { padLeft, padTop, plotW, plotH, xRange, yRange, yLog, hm, axes }); ctx.restore(); } function drawHeatmapAxes(ctx, o) { const { padLeft, padTop, plotW, plotH, xRange, yRange, yLog, hm, axes } = o; ctx.font = '11px system-ui, sans-serif'; ctx.strokeStyle = 'rgba(255,255,255,.12)'; ctx.fillStyle = '#8b93a3'; // y ticks: 1-2-5 per decade on a log axis, ~6 even steps on a linear one. const yTicks = []; if (yLog) { for (let e = Math.floor(Math.log10(Math.max(yRange[0], 1))); e <= Math.log10(yRange[1]); e++) { const f = Math.pow(10, e); for (const m of [1, 2, 5]) { const v = m * f; if (v >= yRange[0] && v <= yRange[1]) yTicks.push(v); } } } else { const n = 5; for (let i = 0; i <= n; i++) yTicks.push(yRange[0] + (yRange[1] - yRange[0]) * i / n); } const yPos = (v) => { if (yLog) { const t = (Math.log10(v) - Math.log10(Math.max(yRange[0], 1e-6))) / (Math.log10(yRange[1]) - Math.log10(Math.max(yRange[0], 1e-6))); return padTop + plotH * (1 - t); } const t = (v - yRange[0]) / ((yRange[1] - yRange[0]) || 1); return padTop + plotH * (1 - t); }; ctx.textAlign = 'right'; ctx.textBaseline = 'middle'; yTicks.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(v >= 1000 ? `${(v / 1000).toFixed(1)}k` : `${Math.round(v)}`, padLeft - 7, y); }); ctx.textAlign = 'center'; ctx.textBaseline = 'top'; const n = 6; for (let i = 0; i <= n; i++) { const t = i / n; const x = padLeft + plotW * t; const v = xRange[0] + (xRange[1] - xRange[0]) * t; ctx.beginPath(); ctx.moveTo(x, padTop + plotH); ctx.lineTo(x, padTop + plotH + 4); ctx.stroke(); ctx.textAlign = i === n ? 'right' : 'center'; ctx.fillText(v.toFixed(1), x, padTop + plotH + 7); } ctx.fillStyle = '#8b93a3'; 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(); drawColorbar(ctx, hm); } function drawColorbar(ctx, hm) { // A compact colourbar in the top-right of the plot area — the desktop app // gets this free from pyqtgraph; here it is drawn as a 12 px strip. const x = el('plot-host').clientWidth - 74; const y = 14, w = 12, h = 96; const lut = buildMagmaLut(64); for (let i = 0; i < h; i++) { const t = 1 - i / (h - 1); const [r, g, b] = lut[Math.round(t * 63)]; ctx.fillStyle = `rgb(${r},${g},${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 = '10px system-ui, sans-serif'; 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; } function redrawRefs() { if (state.payload) drawPayload(state.payload); } function drawPayload(payload) { destroyPlot(); el('placeholder').hidden = payload.datasets.length > 0; refreshYLogButton(); const dataset = payload.datasets[0]; if (dataset && dataset.payload.heatmap) { renderHeatmap(payload); return; } const built = buildUplotData(payload, (d) => paletteFor(d.file_id)); if (!built) return; const { scales, axes } = buildAxes(payload.axes); const [w, h] = plotSize(); const seriesSpec = [{ label: 'x', value: (u, v) => (v == null ? '--' : v.toFixed(2)) }]; built.series.forEach((s) => { seriesSpec.push({ label: s.label, stroke: s.stroke, width: s.width, fill: s.fill || undefined, dash: s.dash || undefined, points: { show: false }, value: (u, v) => (v == null ? '--' : v.toFixed(3)), }); }); state.uplot = new uPlot({ width: w, height: h, padding: [8, 10, 8, 0], cursor: { drag: { x: true, y: false, setScale: false }, sync: { key: 'ujm' }, points: { size: 6 }, }, legend: { show: true, live: true }, scales, axes, series: seriesSpec, plugins: [refLineDragPlugin(built.series)], }, built.data, el('plot-host')); el('placeholder').hidden = true; } /** Drag a reference line vertically: the desktop app's draggable HLine. */ function refLineDragPlugin(seriesList) { return { hooks: { ready: (u) => { const over = u.over; let dragging = null; const valueAt = (frac) => { const yMin = u.scales.y.min, yMax = u.scales.y.max; return u.scales.y.log ? Math.pow(10, Math.log10(yMax) - frac * (Math.log10(yMax) - Math.log10(yMin))) : yMax - frac * (yMax - yMin); }; over.addEventListener('mousedown', (e) => { if (e.button !== 0) return; const { top, height } = over.getBoundingClientRect(); const target = valueAt((e.clientY - top) / height); const yMin = u.scales.y.min, yMax = u.scales.y.max; let best = null, bestDist = Infinity; seriesList.forEach((s) => { if (!s._ref) return; const dist = Math.abs(s._ref.value - target) / ((yMax - yMin) || 1); if (dist < bestDist) { bestDist = dist; best = s._ref; } }); // Only grab a line you actually clicked near (6 % of the axis). if (!best || bestDist > 0.06) return; dragging = best; over.style.cursor = 'ns-resize'; e.preventDefault(); }); window.addEventListener('mousemove', (e) => { if (!dragging) return; const { top, height } = over.getBoundingClientRect(); dragging.value = valueAt(Math.min(1, Math.max(0, (e.clientY - top) / height))); syncRefInputs(); }); window.addEventListener('mouseup', () => { if (!dragging) return; dragging = null; over.style.cursor = ''; redrawRefs(); }); }, }, }; } 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); }); } // ---- 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 = state.results[f.file_id] && state.results[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 || (state.results[fileId] && state.results[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`; renderLegend(payload); 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 — they never leave this tab'); 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; renderRefList(); await refreshPlot(); }); el('x-mode-select').addEventListener('change', async (e) => { state.xMode = e.target.value; await refreshPlot(); }); el('toggle-y-log').addEventListener('click', async () => { state.yLog = !yLogActive(); 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 axis = (state.payload && state.payload.axes) || {}; const range = axis.y_range || [0, 1]; const centre = axis.y_log ? Math.pow(10, (Math.log10(Math.max(range[0], 1e-3)) + Math.log10(range[1])) / 2) : (range[0] + range[1]) / 2; refs.push({ value: Number(centre.toFixed(2)), color: PALETTE[(refs.length + 3) % PALETTE.length], style: 'dash', label: '', }); renderRefList(); redrawRefs(); }); el('clear-refs').addEventListener('click', () => { state.refsByMetric[state.metric] = []; state.refs = state.refsByMetric[state.metric]; renderRefList(); redrawRefs(); }); // 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 — they never leave this tab'); } 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) => state.results[fileId] && state.results[fileId][metricId], };