Files
uj-mastering-master/web/app.js
T
mikkeli e8da88b6c1 Run the web UI entirely in the browser
The FastAPI backend kept one analysis session shared by every visitor and
spooled each upload to the server's /tmp, and the browser copied audio into
persistent OPFS storage. None of that fits a tool for unreleased masters, so
the web UI is now a static site that decodes and analyses in the tab:

- web/decode.js: WAV/AIFF PCM and float read directly with libsndfile's
  scaling (bit-identical to librosa.load; Chromium's WAV decoder scales
  positive int16 by 1/32767), other formats via decodeAudioData on an
  OfflineAudioContext at the header-sniffed native rate. ID3 song names.
- web/dsp.js: ports of every Metric.compute with the same numerics
  (pyloudnorm K-weighting via lfilter's DF2T recurrence, BS.1770 gating,
  EBU LRA, resample_poly's Kaiser filter for true peak, librosa.stft and
  amplitude_to_db), streaming passes with no signal-length intermediates.
- web/metrics.js: build_spec ports producing the existing plot payload.
- web/analysis-worker.js + app.js: per-tab track list and result cache, a
  worker job queue where the visible metric jumps background prefetch, and
  the renderer, which now also draws the metrics' own reference lines.

Deployment is nginx-unprivileged serving web/ read-only with
Content-Security-Policy connect-src 'none', so the page cannot make network
requests at all, and no access log. No secure context is needed, so plain
http on a LAN works. The Python web backend, its smoke test, the web extra
and the OPFS store are removed; the desktop app is unchanged.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015UXA39Sh9coAx4FhRkd8R2
2026-09-14 10:16:52 +09:00

1198 lines
40 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 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],
};