Files
uj-mastering-master/web/app.js
T

1198 lines
40 KiB
JavaScript
Raw Normal View History

2026-09-14 01:44:48 +09:00
/* Audio Mastering Analysis Toolkit — web front end.
*
2026-09-14 10:16:52 +09:00
* 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.
2026-09-14 01:44:48 +09:00
*
2026-09-14 10:16:52 +09:00
* 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).
2026-09-14 01:44:48 +09:00
*/
2026-09-14 10:16:52 +09:00
import { decodeFile } from './decode.js';
import { METRICS, METRIC_BY_ID, DEFAULT_METRIC_ID, applyRelativeX } from './metrics.js';
2026-09-14 01:44:48 +09:00
'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',
];
2026-09-14 10:16:52 +09:00
const AUDIO_EXTENSIONS = /\.(wav|mp3|flac|ogg|oga|m4a|aiff?|opus)$/i;
2026-09-14 01:44:48 +09:00
const state = {
2026-09-14 10:16:52 +09:00
metric: DEFAULT_METRIC_ID,
files: [], // track metadata, in the order tracks were added
2026-09-14 01:44:48 +09:00
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: [],
2026-09-14 10:16:52 +09:00
payload: null, // last built plot payload
2026-09-14 01:44:48 +09:00
uplot: null,
busy: false,
2026-09-14 10:16:52 +09:00
refreshAgain: false,
results: {}, // file_id -> {metric_id: computed data}
timings: {}, // file_id -> {metric_id: compute ms}
nextId: 1,
2026-09-14 01:44:48 +09:00
};
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')}`;
}
2026-09-14 10:16:52 +09:00
function fmtSeconds(ms) {
return ms >= 1000 ? `${(ms / 1000).toFixed(1)} s` : `${Math.round(ms)} ms`;
}
2026-09-14 01:44:48 +09:00
function paletteFor(fileId) {
const i = state.files.findIndex((f) => f.file_id === fileId);
return PALETTE[(i < 0 ? 0 : i) % PALETTE.length];
}
2026-09-14 10:16:52 +09:00
function fileById(fileId) {
return state.files.find((f) => f.file_id === fileId);
}
2026-09-14 01:44:48 +09:00
function currentRefs() {
return state.refs;
}
2026-09-14 10:16:52 +09:00
// ---- 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 -------------------------------------------------------
2026-09-14 01:44:48 +09:00
2026-09-14 10:16:52 +09:00
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'));
}
2026-09-14 01:44:48 +09:00
}
2026-09-14 10:16:52 +09:00
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;
2026-09-14 01:44:48 +09:00
}
2026-09-14 10:16:52 +09:00
// ---- file list -------------------------------------------------------------
function populateMetricSelect() {
2026-09-14 01:44:48 +09:00
const select = el('metric-select');
select.innerHTML = '';
2026-09-14 10:16:52 +09:00
METRICS.forEach((m) => {
2026-09-14 01:44:48 +09:00
const opt = document.createElement('option');
opt.value = m.id;
2026-09-14 10:16:52 +09:00
opt.textContent = m.displayName;
if (m.id === state.metric) opt.selected = true;
2026-09-14 01:44:48 +09:00
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 () => {
2026-09-14 10:16:52 +09:00
state.overlay = collectOverlayFromDOM();
2026-09-14 01:44:48 +09:00
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;
2026-09-14 10:16:52 +09:00
name.title = `${f.song_name}\n${(f.format || 'audio').toUpperCase()} · ${(f.size / 1048576).toFixed(1)} MB · held in this tab only`;
2026-09-14 01:44:48 +09:00
const remove = document.createElement('button');
remove.className = 'file-remove';
remove.type = 'button';
remove.textContent = '×';
2026-09-14 10:16:52 +09:00
remove.title = 'Forget this track (discards its audio and analysis)';
2026-09-14 01:44:48 +09:00
remove.addEventListener('click', async (ev) => {
ev.stopPropagation();
2026-09-14 10:16:52 +09:00
forgetTrack(f.file_id);
2026-09-14 01:44:48 +09:00
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 = '';
2026-09-14 10:16:52 +09:00
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(', ') : '—'],
];
}
2026-09-14 01:44:48 +09:00
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');
}
2026-09-14 10:16:52 +09:00
/** 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;
2026-09-14 01:44:48 +09:00
}
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);
2026-09-14 10:16:52 +09:00
series.forEach((s) => s.x.forEach((v) => { if (v !== null) xsSet.push(v); }));
2026-09-14 01:44:48 +09:00
});
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 = [];
2026-09-14 10:16:52 +09:00
const constant = (v) => new Array(x.length).fill(v);
2026-09-14 01:44:48 +09:00
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.
2026-09-14 10:16:52 +09:00
let loMin = 0;
for (const v of seg.lo) if (v !== null && v < loMin) loMin = v;
const isEnvelope = loMin < 0;
2026-09-14 01:44:48 +09:00
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,
});
});
});
2026-09-14 10:16:52 +09:00
// 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));
2026-09-14 01:44:48 +09:00
series.push({
2026-09-14 10:16:52 +09:00
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),
2026-09-14 01:44:48 +09:00
fill: null,
2026-09-14 10:16:52 +09:00
_ref: null,
2026-09-14 01:44:48 +09:00
});
});
2026-09-14 10:16:52 +09:00
});
// 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,
});
});
2026-09-14 01:44:48 +09:00
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';
2026-09-14 10:16:52 +09:00
sw.style.background = paletteFor(d.file_id);
2026-09-14 01:44:48 +09:00
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]));
2026-09-14 10:16:52 +09:00
scales.y.range = () => [Math.max(lo, 1e-9), hi];
2026-09-14 01:44:48 +09:00
}
}
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,
2026-09-14 10:16:52 +09:00
values: (u, vals) => formatTicks(vals, yLog),
2026-09-14 01:44:48 +09:00
};
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,
2026-09-14 10:16:52 +09:00
values: (u, vals) => formatTicks(vals, xLog),
2026-09-14 01:44:48 +09:00
};
return { scales, axes: [xAxis, yAxis] };
}
2026-09-14 10:16:52 +09:00
function formatTicks(vals, isLog) {
2026-09-14 01:44:48 +09:00
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]];
2026-09-14 10:16:52 +09:00
const yRange = axes.y_range || [Math.max(hm.y[0], 1e-6), hm.y[hm.y.length - 1]];
2026-09-14 01:44:48 +09:00
const yLog = yLogActive();
2026-09-14 10:16:52 +09:00
const { rows, cols } = hm;
2026-09-14 01:44:48 +09:00
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;
2026-09-14 10:16:52 +09:00
const pixels = img.data;
2026-09-14 01:44:48 +09:00
for (let r = 0; r < rows; r++) {
2026-09-14 10:16:52 +09:00
const base = r * cols;
2026-09-14 01:44:48 +09:00
for (let c = 0; c < cols; c++) {
2026-09-14 10:16:52 +09:00
const t = (hm.z[base + c] - hm.z_min) / span;
2026-09-14 01:44:48 +09:00
const idx = Math.max(0, Math.min(255, Math.round(t * 255)));
2026-09-14 10:16:52 +09:00
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;
2026-09-14 01:44:48 +09:00
}
}
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();
2026-09-14 10:16:52 +09:00
ctx.translate(padLeft, padTop + plotH);
2026-09-14 01:44:48 +09:00
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';
2026-09-14 10:16:52 +09:00
// y ticks: 1-2-5 per decade on a log axis, ~6 even steps on a linear one.
2026-09-14 01:44:48 +09:00
const yTicks = [];
if (yLog) {
2026-09-14 10:16:52 +09:00
for (let e = Math.floor(Math.log10(Math.max(yRange[0], 1))); e <= Math.log10(yRange[1]); e++) {
2026-09-14 01:44:48 +09:00
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();
2026-09-14 10:16:52 +09:00
ctx.textAlign = i === n ? 'right' : 'center';
2026-09-14 01:44:48 +09:00
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
2026-09-14 10:16:52 +09:00
// gets this free from pyqtgraph; here it is drawn as a 12 px strip.
2026-09-14 01:44:48 +09:00
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);
}
2026-09-14 10:16:52 +09:00
const magmaLuts = new Map();
2026-09-14 01:44:48 +09:00
function buildMagmaLut(n) {
2026-09-14 10:16:52 +09:00
if (magmaLuts.has(n)) return magmaLuts.get(n);
2026-09-14 01:44:48 +09:00
// 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),
];
}
2026-09-14 10:16:52 +09:00
magmaLuts.set(n, lut);
2026-09-14 01:44:48 +09:00
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];
2026-09-14 10:16:52 +09:00
if (dataset && dataset.payload.heatmap) {
2026-09-14 01:44:48 +09:00
renderHeatmap(payload);
return;
}
2026-09-14 10:16:52 +09:00
const built = buildUplotData(payload, (d) => paletteFor(d.file_id));
2026-09-14 01:44:48 +09:00
if (!built) return;
2026-09-14 10:16:52 +09:00
const { scales, axes } = buildAxes(payload.axes);
2026-09-14 01:44:48 +09:00
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)],
2026-09-14 10:16:52 +09:00
}, built.data, el('plot-host'));
2026-09-14 01:44:48 +09:00
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;
2026-09-14 10:16:52 +09:00
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);
};
2026-09-14 01:44:48 +09:00
over.addEventListener('mousedown', (e) => {
if (e.button !== 0) return;
const { top, height } = over.getBoundingClientRect();
2026-09-14 10:16:52 +09:00
const target = valueAt((e.clientY - top) / height);
2026-09-14 01:44:48 +09:00
const yMin = u.scales.y.min, yMax = u.scales.y.max;
let best = null, bestDist = Infinity;
2026-09-14 10:16:52 +09:00
seriesList.forEach((s) => {
2026-09-14 01:44:48 +09:00
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;
2026-09-14 10:16:52 +09:00
dragging = best;
2026-09-14 01:44:48 +09:00
over.style.cursor = 'ns-resize';
e.preventDefault();
});
window.addEventListener('mousemove', (e) => {
if (!dragging) return;
const { top, height } = over.getBoundingClientRect();
2026-09-14 10:16:52 +09:00
dragging.value = valueAt(Math.min(1, Math.max(0, (e.clientY - top) / height)));
2026-09-14 01:44:48 +09:00
syncRefInputs();
});
window.addEventListener('mouseup', () => {
if (!dragging) return;
dragging = null;
over.style.cursor = '';
2026-09-14 10:16:52 +09:00
redrawRefs();
2026-09-14 01:44:48 +09:00
});
},
},
};
}
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 ---------------------------------------------------------
2026-09-14 10:16:52 +09:00
/**
* 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,
};
}
2026-09-14 01:44:48 +09:00
async function refreshPlot() {
2026-09-14 10:16:52 +09:00
if (state.busy) {
state.refreshAgain = true;
return;
}
2026-09-14 01:44:48 +09:00
state.busy = true;
try {
2026-09-14 10:16:52 +09:00
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}`);
}
}
2026-09-14 01:44:48 +09:00
2026-09-14 10:16:52 +09:00
const t0 = performance.now();
const payload = buildPlotPayload();
2026-09-14 01:44:48 +09:00
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);
2026-09-14 10:16:52 +09:00
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');
2026-09-14 01:44:48 +09:00
} catch (err) {
setStatus(String(err.message || err), 'error');
} finally {
state.busy = false;
2026-09-14 10:16:52 +09:00
if (state.refreshAgain) {
state.refreshAgain = false;
refreshPlot();
}
2026-09-14 01:44:48 +09:00
}
}
/**
2026-09-14 10:16:52 +09:00
* 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.
2026-09-14 01:44:48 +09:00
*/
async function ingest(fileList) {
2026-09-14 10:16:52 +09:00
const files = Array.from(fileList).filter((f) => AUDIO_EXTENSIONS.test(f.name));
2026-09-14 01:44:48 +09:00
if (!files.length) {
if (fileList.length) setStatus('No supported audio in that selection', 'warn');
return;
}
const failures = [];
2026-09-14 10:16:52 +09:00
const added = [];
2026-09-14 01:44:48 +09:00
for (let i = 0; i < files.length; i += 1) {
const f = files[i];
const tag = `(${i + 1}/${files.length}) ${f.name}`;
2026-09-14 10:16:52 +09:00
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');
2026-09-14 01:44:48 +09:00
try {
2026-09-14 10:16:52 +09:00
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,
2026-09-14 01:44:48 +09:00
});
2026-09-14 10:16:52 +09:00
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);
2026-09-14 01:44:48 +09:00
} catch (err) {
failures.push(`${f.name}: ${err.message || err}`);
}
}
await refreshPlot();
2026-09-14 10:16:52 +09:00
added.forEach(prefetch);
2026-09-14 01:44:48 +09:00
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 () => {
2026-09-14 10:16:52 +09:00
state.yLog = !yLogActive();
2026-09-14 01:44:48 +09:00
await refreshPlot();
});
el('refresh-plot').addEventListener('click', () => refreshPlot());
el('pick-files').addEventListener('click', () => el('file-input').click());
el('file-input').addEventListener('change', async (e) => {
2026-09-14 10:16:52 +09:00
const picked = Array.from(e.target.files);
2026-09-14 01:44:48 +09:00
e.target.value = '';
2026-09-14 10:16:52 +09:00
await ingest(picked);
2026-09-14 01:44:48 +09:00
});
el('add-ref').addEventListener('click', () => {
const refs = currentRefs();
const axis = (state.payload && state.payload.axes) || {};
2026-09-14 10:16:52 +09:00
const range = axis.y_range || [0, 1];
2026-09-14 01:44:48 +09:00
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();
});
}
2026-09-14 10:16:52 +09:00
function boot() {
populateMetricSelect();
state.refs = refsFor(state.metric);
2026-09-14 01:44:48 +09:00
wireEvents();
2026-09-14 10:16:52 +09:00
renderFileList();
renderMetadata();
renderRefList();
setStatus('Drop audio files to begin — they never leave this tab');
2026-09-14 01:44:48 +09:00
}
boot();
2026-09-14 10:16:52 +09:00
// 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],
};