Files
mikkeli e50eff9122 Use relative asset and API URLs so the UI works under a path prefix
index.html and app.js requested /static/... and /api/... from the site
root, so serving the app behind a reverse proxy at a sub-path (e.g.
https://host/uj/ with the prefix stripped) 404'd every asset and call.
Relative URLs resolve against the page, which is the same at / and at
/uj/; direct access is unchanged.

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

1158 lines
39 KiB
JavaScript
Raw Permalink 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.
*
* The desktop renderer (`audio_visualization_widget.AudioVisualizationWidget`)
* draws a `PlotSpec` with pyqtgraph. This draws the same `PlotSpec` payload with
* uPlot (curves/bands) and a plain canvas (spectrogram heatmap). It owns the
* things the desktop widget owns: the dataset colour cycle, lin/log handling,
* reference-line dragging, and overlay.
*
* Two renderers exist only because pyqtgraph's ImageItem is affine-only, so the
* desktop app has to resample the STFT onto a log-spaced grid before drawing it.
* A canvas can map rows to pixels through an arbitrary function, so this side
* draws the native linear rows and maps them, which is both faster and sharper.
*/
import * as store from './audio-store.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 state = {
metrics: [],
metric: null,
files: [], // [{file_id, name, song_name, ...}] — analysis metadata only
overlay: [], // file_id list currently drawn
selected: null, // file_id highlighted in the list
xMode: 'absolute',
yLog: null, // null = use the metric's default
yLogAllowed: false,
xLog: null,
xLogAllowed: false,
// 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 /api/plot response
uplot: null,
lastAxes: null,
busy: false,
// Which metrics the server has already computed for which track. Tracked
// client-side because the server holds no audio: after a reload we know what
// still needs re-sending.
analysed: {}, // file_id -> Set(metric_id)
objectUrls: {}, // file_id -> blob URL for playback
};
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 paletteFor(fileId) {
const i = state.files.findIndex((f) => f.file_id === fileId);
return PALETTE[(i < 0 ? 0 : i) % PALETTE.length];
}
function currentRefs() {
return state.refs;
}
// ---- API -------------------------------------------------------------------
async function api(path, options) {
const res = await fetch(path, options);
let body = null;
try { body = await res.json(); } catch (_) { /* non-JSON error page */ }
if (!res.ok) {
const detail = body && body.detail ? body.detail : `HTTP ${res.status}`;
throw new Error(detail);
}
return body;
}
async function loadMetrics() {
const data = await api('api/metrics');
state.metrics = data.metrics;
state.metric = data.default;
const select = el('metric-select');
select.innerHTML = '';
data.metrics.forEach((m) => {
const opt = document.createElement('option');
opt.value = m.id;
opt.textContent = m.display_name;
if (m.id === data.default) opt.selected = true;
select.appendChild(opt);
});
}
// ---- file list -------------------------------------------------------------
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 () => {
const ids = collectOverlayFromDOM();
await api('api/overlay', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ file_ids: ids }),
});
state.overlay = ids;
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}\nSHA-256 ${f.file_id}… · held in browser storage`;
const remove = document.createElement('button');
remove.className = 'file-remove';
remove.type = 'button';
remove.textContent = '×';
remove.title = 'Forget this track (drops the analysis and removes it from browser storage)';
remove.addEventListener('click', async (ev) => {
ev.stopPropagation();
await api(`api/file?file_id=${encodeURIComponent(f.file_id)}`, { method: 'DELETE' });
// The server only ever held numbers; the audio is ours to delete.
await store.remove(f.file_id);
releaseObjectUrl(f.file_id);
delete state.analysed[f.file_id];
state.files = state.files.filter((x) => x.file_id !== f.file_id);
state.overlay = state.overlay.filter((p) => p !== f.file_id);
if (state.selected === f.file_id) state.selected = null;
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 = state.files.find((f) => f.file_id === state.selected);
const held = file ? !!(state.analysed[file.file_id]) : false;
const rows = file ? [
['Track', file.song_name],
['Length', fmtDuration(file.duration)],
['Sample rate', `${(file.sample_rate / 1000).toFixed(1)} kHz`],
['Channels', file.channels === 1 ? 'mono' : `${file.channels}`],
['Max amplitude', fmt(file.max_amplitude, 3)],
['Avg amplitude', fmt(file.avg_amplitude, 3)],
['Content hash', `${file.file_id.slice(0, 16)}…`],
['Audio held by', 'this browser (OPFS)'],
['Metrics cached', analysedFor(file.file_id).length
? analysedFor(file.file_id).join(', ') : '—'],
] : [['Track', 'nothing selected']];
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');
}
/** log10 with a floor, for log axes over data that may reach zero. */
function log10f(v, floor) {
return Math.log10(Math.max(v, floor));
}
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) => xsSet.push(...s.x));
});
if (!xsSet.length) return null;
let x = Array.from(new Set(xsSet)).sort((a, b) => a - b);
// Guard against a pathological x axis (a single-sample file yields one point).
if (x.length === 1) x = [x[0], x[0] + 1e-3];
function resample(srcX, srcY) {
const out = new Array(x.length).fill(null);
if (!srcX.length) return out;
let j = 0;
for (let i = 0; i < x.length; i++) {
while (j < srcX.length - 1 && srcX[j + 1] <= x[i]) j++;
const x0 = srcX[j];
const x1 = j + 1 < srcX.length ? srcX[j + 1] : x0;
if (x[i] < srcX[0] || x[i] > srcX[srcX.length - 1]) { out[i] = null; continue; }
const y0 = srcY[j];
const y1 = j + 1 < srcY.length ? srcY[j + 1] : y0;
if (y0 === null || (x1 > x0 && y0 !== null && y1 !== null)) {
const t = x1 > x0 ? (x[i] - x0) / (x1 - x0) : 0;
out[i] = (y0 === null || y1 === null) ? (y0 === null ? y1 : y0) : y0 + (y1 - y0) * t;
} else {
out[i] = y0 === null ? y1 : y0;
}
}
return out;
}
const data = [x];
const series = [];
datasets.forEach((d) => {
const colour = 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.
const isEnvelope = Math.min(...seg.lo.filter((v) => v !== null), 0) < 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),
points: { show: false },
_ref: null,
});
series.push({
label: '',
stroke: colour, width: isEnvelope ? 1 : 1.4,
fill: isEnvelope ? hexToRgba(colour, 0.28) : null,
points: { show: false },
_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,
points: { show: false },
_ref: null,
});
});
});
// Reference lines become extra series with filled values, so uPlot draws them
// as part of the same resampled grid instead of needing an annotation overlay.
const refs = currentRefs();
if (refs.length) {
const axis = payload.axes || {};
refs.forEach((ref) => {
let v = ref.value;
data.push(new Array(x.length).fill(v).map((value, i) => {
const lo = axis.y_range ? axis.y_range[0] : 0;
const hi = axis.y_range ? axis.y_range[1] : 1;
return axis.y_log ? Math.max(value, lo) : value;
}));
series.push({
label: ref.label || `${ref.value}`,
stroke: ref.color || '#ffb74d',
width: 1,
dash: dashFor(ref.style),
fill: null,
points: { show: false },
_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 = d.palette || 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 = (u, min, max) => [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, space) => formatTicks(vals, space, 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, space) => formatTicks(vals, space, xLog),
};
return { scales, axes: [xAxis, yAxis] };
}
function formatTicks(vals, space, 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 host = el('plot-host');
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[hm.y.length - 1], 1e-6), hm.y[0]];
const yLog = yLogActive();
const rows = hm.z.length;
const cols = hm.x.length;
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;
for (let r = 0; r < rows; r++) {
const row = hm.z[r];
for (let c = 0; c < cols; c++) {
const t = (row[c] - hm.z_min) / span;
const idx = Math.max(0, Math.min(255, Math.round(t * 255)));
const [cr, cg, cb] = lut[idx];
const p = (r * cols + c) * 4;
img.data[p] = cr; img.data[p + 1] = cg; img.data[p + 2] = cb; img.data[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.
const destTop = padTop;
ctx.save();
ctx.translate(padLeft, destTop + 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: decade lines on a log axis, ~6 even steps on a linear one.
const yTicks = [];
if (yLog) {
for (let e = Math.ceil(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.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 14 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);
}
let _magma = null;
function buildMagmaLut(n) {
if (_magma && _magma.length === n) return _magma;
// 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),
];
}
_magma = 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];
const heatmap = dataset && dataset.payload.heatmap;
if (heatmap) {
renderHeatmap(payload);
return;
}
const colorFor = (d) => d.payload.series[0]?.color || d.payload.segments[0]?.color || d.palette || paletteFor(d.file_id);
const built = buildUplotData(payload, colorFor);
if (!built) return;
const { scales, axes } = buildAxes(payload.axes || (dataset && dataset.payload.axes));
const [w, h] = plotSize();
const host = el('plot-host');
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, 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;
over.addEventListener('mousedown', (e) => {
if (e.button !== 0) return;
const { top, height } = over.getBoundingClientRect();
const yFrac = (e.clientY - top) / height;
// Convert pixel y to a value using the live scale, then find the
// nearest reference series and start moving it.
const yMin = u.scales.y.min, yMax = u.scales.y.max;
const yLog = !!u.scales.y.log;
const valueAt = (frac) => (yLog
? Math.pow(10, Math.log10(yMax) - frac * (Math.log10(yMax) - Math.log10(yMin)))
: yMax - frac * (yMax - yMin));
const target = valueAt(yFrac);
let best = null, bestDist = Infinity;
seriesList.forEach((s, i) => {
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 = { ref: best, yLog };
over.style.cursor = 'ns-resize';
e.preventDefault();
});
window.addEventListener('mousemove', (e) => {
if (!dragging) return;
const { top, height } = over.getBoundingClientRect();
const frac = Math.min(1, Math.max(0, (e.clientY - top) / height));
const yMin = u.scales.y.min, yMax = u.scales.y.max;
dragging.ref.value = dragging.yLog
? Math.pow(10, Math.log10(yMax) - frac * (Math.log10(yMax) - Math.log10(yMin)))
: yMax - frac * (yMax - yMin);
syncRefInputs();
redrawRefLine(u, dragging.ref);
});
window.addEventListener('mouseup', () => {
if (!dragging) return;
dragging = null;
over.style.cursor = '';
});
},
},
};
}
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);
});
}
function redrawRefLine(u, ref) {
// Cheapest correct redraw: re-set the data for the reference series.
const idx = u.series.findIndex((s, i) => i > 0 && u.series[i].label === (ref.label || `${ref.value}`));
u.setData(u.data.map((col, i) => (i === 0 ? col : col)), false);
}
// ---- orchestration ---------------------------------------------------------
async function refreshPlot() {
if (state.busy) return;
state.busy = true;
const t0 = performance.now();
setStatus('Rendering…', 'working');
try {
const refs = currentRefs();
const params = new URLSearchParams({
metric: state.metric,
x_mode: state.xMode,
refs: JSON.stringify(refs.map((r) => ({
value: r.value, color: r.color, style: r.style, label: r.label,
}))),
});
if (state.yLog !== null) params.set('y_log', String(state.yLog));
if (state.xLog !== null) params.set('x_log', String(state.xLog));
const payload = await api(`api/plot?${params.toString()}`);
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 ms = performance.now() - t0;
setTiming(`${ms.toFixed(0)} ms round trip`);
if (payload.warnings && payload.warnings.length) {
setStatus(payload.warnings.join(' · '), 'warn');
} 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;
}
}
/**
* Ingest files: copy into browser storage, then hand the bytes to the server
* one track at a time for the currently selected metric.
*
* The order matters — the file is durable in OPFS before any request goes out,
* so a network failure leaves the audio re-analysable rather than lost.
*/
async function ingest(fileList) {
const files = Array.from(fileList).filter((f) => /\.(wav|mp3|flac|ogg|oga|m4a|aiff?|opus)$/i.test(f.name));
if (!files.length) {
if (fileList.length) setStatus('No supported audio in that selection', 'warn');
return;
}
const problem = await store.storageProblem(files.reduce((n, f) => n + f.size, 0));
if (problem) {
setStatus(`Cannot store audio locally: ${problem}`, 'error');
return;
}
const failures = [];
for (let i = 0; i < files.length; i += 1) {
const f = files[i];
const tag = `(${i + 1}/${files.length}) ${f.name}`;
setStatus(`Storing ${tag} in browser…`, 'working');
try {
const stored = await store.put(f, (done, total) => {
const pct = total ? Math.round((done / total) * 100) : 0;
setStatus(`Storing ${tag} — ${pct}%`, 'working');
});
setStatus(`Analysing ${tag}…`, 'working');
await analyseOne(stored.fileId, f.name);
ensureOverlay(stored.fileId);
} catch (err) {
failures.push(`${f.name}: ${err.message || err}`);
}
}
const s = await api('api/session');
state.files = s.files;
state.overlay = s.overlay;
if (!state.selected && state.files.length) state.selected = state.files[0].file_id;
renderFileList();
renderMetadata();
await refreshPlot();
if (failures.length) setStatus(failures.join(' · '), 'warn');
else setStatus(`Ready — ${files.length} file${files.length > 1 ? 's' : ''} stored in this browser`);
}
/**
* Send one stored track to the server for one metric.
*
* The response carries the metadata (so the file list can be built without a
* second round trip) and is recorded in `state.analysed`.
*/
async function analyseOne(fileId, name, metric = state.metric) {
const url = `api/analyse?` + new URLSearchParams({
file_id: fileId,
name,
metric: metric || state.metric || '',
}).toString();
const res = await store.send(fileId, url, { method: 'POST' });
if (!res.ok) {
let detail = `HTTP ${res.status}`;
try { detail = (await res.json()).detail || detail; } catch (_) { /* not JSON */ }
throw new Error(detail);
}
const body = await res.json();
markAnalysed(fileId, metric);
return body;
}
/** Make sure a track is in the overlay, so a fresh upload is visible at once. */
function ensureOverlay(fileId) {
if (!state.overlay.includes(fileId)) {
state.overlay = [...state.overlay, fileId];
api('api/overlay', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ file_ids: state.overlay }),
}).catch(() => { /* next refreshPlot reconciles */ });
}
}
function analysedFor(fileId) {
return Array.from(state.analysed[fileId] || []);
}
function markAnalysed(fileId, metric) {
if (!metric) return;
if (!state.analysed[fileId]) state.analysed[fileId] = new Set();
state.analysed[fileId].add(metric);
}
/**
* After a page reload the server still remembers the analysis but we no longer
* know which metrics we asked for. Ask each track for the current metric: the
* server answers from cache when it can, so this is cheap and re-sends bytes
* only for combinations it has never seen.
*/
/**
* Make sure every track in the overlay has the current metric computed.
*
* Cheap when the server has it cached: the request still carries the bytes,
* because that is the only way to name the track — but no decode happens.
*/
async function ensureMetricForVisible() {
const wanted = state.metric;
for (const fileId of state.overlay) {
if (analysedFor(fileId).includes(wanted)) continue;
const f = state.files.find((x) => x.file_id === fileId);
if (!f) continue;
try {
setStatus(`Analysing ${f.name} — ${wanted}…`, 'working');
await analyseOne(fileId, f.name, wanted);
} catch (err) {
setStatus(`${f.name}: ${err.message || err}`, 'warn');
}
}
}
async function rehydrateAnalysed() {
for (const f of state.files) {
try {
await analyseOne(f.file_id, f.name, state.metric);
} catch (_) {
// The audio may be gone from OPFS (cleared storage); refreshPlot will
// report it as not computed rather than pretending.
}
}
}
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();
// The server computes one metric per request and keeps no audio, so a new
// metric means re-offering bytes for the tracks we want to draw. Re-sends
// for anything already computed come back from cache without decoding.
await ensureMetricForVisible();
await refreshPlot();
});
el('x-mode-select').addEventListener('change', async (e) => {
state.xMode = e.target.value;
await refreshPlot();
});
el('toggle-y-log').addEventListener('click', async () => {
const active = yLogActive();
state.yLog = !active;
await refreshPlot();
});
el('refresh-plot').addEventListener('click', () => refreshPlot());
el('pick-files').addEventListener('click', () => el('file-input').click());
el('file-input').addEventListener('change', async (e) => {
await ingest(e.target.files);
e.target.value = '';
e.target.value = '';
});
el('add-ref').addEventListener('click', () => {
const refs = currentRefs();
const axis = (state.payload && state.payload.axes) || {};
const range = axis.y_range || state.payload?.datasets?.[0]?.payload?.y_bounds || [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();
});
}
async function boot() {
wireEvents();
try {
if (!store.opfsSupported()) {
setStatus('This browser cannot store audio locally (no OPFS) — the web UI needs it to avoid writing to the host', 'error');
} else {
// An upload killed mid-write leaves a partial file behind; clear those out.
const swept = await store.sweepIncomplete();
if (swept) console.info(`cleared ${swept} incomplete upload(s)`);
}
await loadMetrics();
const s = await api('api/session');
state.files = s.files;
state.overlay = s.overlay;
state.selected = state.files.length ? state.files[0].file_id : null;
state.refs = refsFor(state.metric);
renderFileList();
renderMetadata();
renderRefList();
if (state.files.length) {
// The server remembers the analysis from the previous page load; the
// audio is in our OPFS. Re-offer bytes only where the server has nothing.
setStatus('Reconnecting to stored audio…', 'working');
await rehydrateAnalysed();
await refreshPlot();
} else {
setStatus(store.opfsSupported()
? 'Drop audio files to begin — they stay in this browser'
: 'Cannot run without local browser storage');
}
} catch (err) {
setStatus(`Startup failed: ${err.message}`, 'error');
}
}
boot();