1158 lines
39 KiB
JavaScript
1158 lines
39 KiB
JavaScript
|
|
/* 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();
|