Run the web UI entirely in the browser

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

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

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

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015UXA39Sh9coAx4FhRkd8R2
This commit is contained in:
2026-09-14 10:16:52 +09:00
parent b5c12bfcb4
commit e8da88b6c1
22 changed files with 1827 additions and 2980 deletions
+49
View File
@@ -0,0 +1,49 @@
// SPDX-License-Identifier: Apache-2.0
//
// Analysis worker: the browser's AnalysisResultsManager worker thread.
//
// Holds each loaded track's mono signal (transferred in, never copied) and runs
// one metric at a time on request, so a 10-minute spectrogram never blocks the
// page. Everything stays in this tab's memory; closing the tab frees it.
//
// Messages in: {type: 'load', trackId, mono, sampleRate}
// {type: 'compute', reqId, trackId, metricId}
// {type: 'drop', trackId}
// Messages out: {type: 'result', reqId, trackId, metricId, data, ms}
// {type: 'error', reqId, trackId, metricId, message}
import { COMPUTE, transferables } from './dsp.js';
const tracks = new Map(); // trackId -> {mono, sampleRate, memo}
self.onmessage = (event) => {
const msg = event.data;
if (msg.type === 'load') {
tracks.set(msg.trackId, { mono: msg.mono, sampleRate: msg.sampleRate, memo: {} });
return;
}
if (msg.type === 'drop') {
tracks.delete(msg.trackId);
return;
}
if (msg.type !== 'compute') return;
const { reqId, trackId, metricId } = msg;
const track = tracks.get(trackId);
const compute = COMPUTE[metricId];
if (!track || !compute) {
self.postMessage({
type: 'error', reqId, trackId, metricId,
message: track ? `unknown metric ${metricId}` : 'track is no longer loaded',
});
return;
}
try {
const t0 = performance.now();
const data = compute(track.mono, track.sampleRate, track.memo);
const ms = performance.now() - t0;
self.postMessage({ type: 'result', reqId, trackId, metricId, data, ms }, transferables(data));
} catch (err) {
self.postMessage({ type: 'error', reqId, trackId, metricId, message: String((err && err.message) || err) });
}
};
+275
View File
@@ -0,0 +1,275 @@
/* Audio Mastering Analysis Toolkit — web front end.
Dark by default: a spectrogram is read against its surroundings, and the
desktop app's pythonic defaults are what these numbers were tuned against. */
:root {
--bg: #12141a;
--panel: #191c24;
--panel-2: #1f232d;
--line: #2b303c;
--text: #e8eaf0;
--muted: #939aa8;
--accent: #4fc3f7;
--accent-dim: #2b7fa3;
--warn: #ffb74d;
--error: #ef5350;
--ok: #81c784;
--radius: 6px;
--mono: ui-monospace, "Cascadia Mono", "Source Code Pro", Menlo, Consolas, monospace;
}
* { box-sizing: border-box; }
/* The UA's [hidden] { display: none } loses to any class that sets display
(e.g. .drop-overlay's flex), which left the overlay permanently covering the
page. Make the attribute win everywhere. */
[hidden] { display: none !important; }
html, body {
margin: 0;
height: 100%;
background: var(--bg);
color: var(--text);
font: 13px/1.45 system-ui, -apple-system, "Segoe UI", Roboto, "Noto Sans", sans-serif;
overflow: hidden;
}
/* ---- top bar ---- */
.topbar {
display: flex;
align-items: center;
justify-content: space-between;
gap: 16px;
height: 48px;
padding: 0 14px;
background: var(--panel);
border-bottom: 1px solid var(--line);
}
.brand { display: flex; align-items: baseline; gap: 8px; }
.logo { color: var(--accent); font-size: 16px; }
.brand-name { font-weight: 600; letter-spacing: .2px; }
.brand-sub {
font-size: 10px;
text-transform: uppercase;
letter-spacing: 1px;
color: var(--bg);
background: var(--accent-dim);
border-radius: 3px;
padding: 1px 5px;
}
.topbar-controls { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; }
.field { display: flex; align-items: center; gap: 6px; color: var(--muted); font-size: 12px; }
select, .btn, .toggle {
background: var(--panel-2);
color: var(--text);
border: 1px solid var(--line);
border-radius: var(--radius);
padding: 5px 9px;
font: inherit;
font-size: 12px;
cursor: pointer;
}
select:hover, .btn:hover, .toggle:hover { border-color: var(--accent-dim); }
select:focus-visible, .btn:focus-visible, .toggle:focus-visible { outline: 2px solid var(--accent); outline-offset: 1px; }
.btn.primary { background: var(--accent-dim); border-color: var(--accent); color: #eaf7ff; }
.btn.primary:hover { background: var(--accent); color: #06202b; }
.btn.small { padding: 3px 7px; font-size: 11px; }
.btn:disabled, .toggle:disabled { opacity: .45; cursor: not-allowed; }
.toggle.on { border-color: var(--accent); color: var(--accent); }
.toggle.off { color: var(--muted); }
.separator { width: 1px; height: 22px; background: var(--line); }
/* ---- layout ---- */
.layout {
display: grid;
grid-template-columns: 288px minmax(0, 1fr);
height: calc(100% - 48px);
}
.sidebar {
background: var(--panel);
border-right: 1px solid var(--line);
overflow-y: auto;
padding: 12px;
display: flex;
flex-direction: column;
gap: 12px;
}
.panel {
background: var(--panel-2);
border: 1px solid var(--line);
border-radius: var(--radius);
padding: 10px;
}
.panel h2 {
margin: 0 0 8px;
font-size: 11px;
text-transform: uppercase;
letter-spacing: .8px;
color: var(--muted);
display: flex;
align-items: center;
gap: 6px;
}
.badge {
background: var(--line);
color: var(--text);
border-radius: 8px;
padding: 0 6px;
font-size: 10px;
font-variant-numeric: tabular-nums;
}
/* ---- file list ---- */
.file-list { list-style: none; margin: 0; padding: 0; display: flex; flex-direction: column; gap: 2px; }
.file-row {
display: flex;
align-items: center;
gap: 7px;
padding: 5px 6px;
border-radius: 4px;
cursor: pointer;
border: 1px solid transparent;
}
.file-row:hover { background: #252a35; }
.file-row.selected { background: #232a38; border-color: var(--accent-dim); }
.file-row input[type=checkbox] { margin: 0; accent-color: var(--accent-dim); flex: none; }
.file-swatch { width: 9px; height: 9px; border-radius: 2px; flex: none; }
.file-name {
flex: 1;
min-width: 0;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
font-size: 12px;
}
.file-remove {
flex: none;
border: 0;
background: none;
color: var(--muted);
cursor: pointer;
font-size: 14px;
line-height: 1;
padding: 0 2px;
opacity: 0;
}
.file-row:hover .file-remove { opacity: 1; }
.file-remove:hover { color: var(--error); }
.empty, .hint { color: var(--muted); font-size: 11.5px; margin: 4px 0 0; }
.hint code { background: #000; padding: 0 3px; border-radius: 3px; }
/* ---- metadata ---- */
.metadata { display: grid; grid-template-columns: auto 1fr; gap: 3px 10px; margin: 0; font-size: 12px; }
.metadata dt { color: var(--muted); }
.metadata dd { margin: 0; text-align: right; font-variant-numeric: tabular-nums; overflow-wrap: anywhere; }
.metadata dd.muted { color: var(--muted); }
/* ---- reference lines ---- */
.ref-list { list-style: none; margin: 0 0 8px; padding: 0; display: flex; flex-direction: column; gap: 3px; }
.ref-row { display: flex; align-items: center; gap: 6px; font-size: 12px; }
.ref-row input[type=color] { width: 20px; height: 18px; padding: 0; border: 1px solid var(--line); background: none; }
.ref-row input[type=text] { width: 62px; }
.ref-row input[type=number] { width: 62px; }
.ref-row input { background: var(--bg); color: var(--text); border: 1px solid var(--line); border-radius: 4px; padding: 2px 4px; font: inherit; font-size: 11px; }
.ref-row .ref-remove { border: 0; background: none; color: var(--muted); cursor: pointer; }
.ref-row .ref-remove:hover { color: var(--error); }
.ref-actions { display: flex; gap: 6px; }
/* ---- plot stage ---- */
.stage { display: flex; flex-direction: column; min-width: 0; padding: 10px 12px 0; }
.plot-head { display: flex; align-items: center; justify-content: space-between; gap: 10px; min-height: 24px; }
.plot-head h1 { margin: 0; font-size: 13px; font-weight: 600; color: var(--text); }
.legend { display: flex; flex-wrap: wrap; gap: 10px; font-size: 11.5px; color: var(--muted); }
.legend .item { display: flex; align-items: center; gap: 5px; }
.legend .swatch { width: 9px; height: 9px; border-radius: 2px; }
.legend .annotation { font-family: var(--mono); color: var(--text); }
.plot-host {
position: relative;
flex: 1;
min-height: 200px;
margin-top: 6px;
background: #0d0f14;
border: 1px solid var(--line);
border-radius: var(--radius);
overflow: hidden;
}
.plot-host .uplot { position: absolute; inset: 0; width: 100% !important; height: 100% !important; }
.plot-host canvas.heatmap { position: absolute; inset: 0; width: 100%; height: 100%; cursor: crosshair; }
.placeholder {
position: absolute;
inset: 0;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
text-align: center;
color: var(--muted);
gap: 6px;
padding: 20px;
}
.ph-title { font-size: 15px; color: var(--text); margin: 0; }
.ph-body { margin: 0; max-width: 44ch; font-size: 12px; }
.ph-body code { background: #000; padding: 0 3px; border-radius: 3px; }
.statusbar {
display: flex;
justify-content: space-between;
gap: 12px;
padding: 6px 2px;
font-size: 11.5px;
color: var(--muted);
min-height: 24px;
}
.status.working { color: var(--accent); }
.status.error { color: var(--error); }
.status.warn { color: var(--warn); }
.timing { font-family: var(--mono); }
/* ---- drag & drop ---- */
.drop-overlay {
position: fixed;
inset: 0;
background: rgba(10, 20, 30, .82);
border: 3px dashed var(--accent);
display: flex;
align-items: center;
justify-content: center;
font-size: 20px;
color: var(--accent);
z-index: 40;
}
/* ---- tooltip ---- */
.tooltip {
position: fixed;
background: #05070b;
border: 1px solid var(--line);
border-radius: 4px;
padding: 5px 7px;
font: 11px/1.4 var(--mono);
color: var(--text);
pointer-events: none;
z-index: 30;
white-space: nowrap;
}
+1197
View File
File diff suppressed because it is too large Load Diff
+435
View File
@@ -0,0 +1,435 @@
// SPDX-License-Identifier: Apache-2.0
//
// Decode a dropped audio file entirely inside this tab.
//
// This is the browser counterpart of `master_core.AudioFile.__init__`: tags ->
// song name, `librosa.load(sr=None)` -> native-rate samples, `librosa.to_mono`
// -> the mono signal every metric reads. Nothing is uploaded or stored: the File
// is read into memory, decoded, downmixed, and the compressed bytes and
// per-channel buffers are dropped as soon as the mono signal exists.
//
// Two decode paths:
// * WAV and AIFF PCM/float are read directly with libsndfile's sample scaling,
// so the signal is bit-identical to what librosa hands the desktop metrics
// (Chromium's WAV decoder scales positive int16 by 1/32767, a small but
// systematic gain skew; browsers don't decode AIFF at all).
// * Everything else goes through decodeAudioData on an OfflineAudioContext
// running at the rate sniffed from the container header, so nothing is
// resampled — the same "analyse the full band" guarantee as `sr=None`.
const MIN_CONTEXT_RATE = 3000; // OfflineAudioContext's legal range
const MAX_CONTEXT_RATE = 768000;
const FALLBACK_RATE = 48000;
/**
* Decode `file` to a mono Float32Array at its native sample rate.
*
* Returns {name, songName, format, sampleRate, nativeRateKnown, channels,
* duration, mono, maxAmplitude, avgAmplitude, size}.
*/
export async function decodeFile(file) {
const bytes = await file.arrayBuffer();
const view = new DataView(bytes);
// Everything that reads the raw bytes must run before decodeAudioData, which
// detaches the ArrayBuffer.
const format = sniffFormat(view);
const tags = format === 'mp3' ? readId3(view) : null;
let decoded = format === 'wav' || format === 'aiff' ? readPcm(view, format) : null;
let nativeRateKnown = true;
if (!decoded) {
const sniffed = sniffSampleRate(view, format);
nativeRateKnown = sniffed !== null && sniffed >= MIN_CONTEXT_RATE && sniffed <= MAX_CONTEXT_RATE;
const sampleRate = nativeRateKnown ? sniffed : FALLBACK_RATE;
const ctx = new OfflineAudioContext({ numberOfChannels: 1, length: 1, sampleRate });
let buffer;
try {
buffer = await ctx.decodeAudioData(bytes);
} catch (err) {
throw new Error(`this browser cannot decode ${format || 'this file'} (${err.message || err})`);
}
decoded = { sampleRate: buffer.sampleRate, channels: buffer.numberOfChannels, mono: bufferToMono(buffer) };
}
const { mono, sampleRate, channels } = decoded;
let maxAmplitude = 0;
let absSum = 0;
for (let i = 0; i < mono.length; i++) {
const a = Math.abs(mono[i]);
if (a > maxAmplitude) maxAmplitude = a;
absSum += a;
}
// Same naming rule as AudioFile: "artist - title" when both tags exist.
const songName = tags && tags.artist && tags.title
? `${tags.artist} - ${tags.title}`
: file.name;
return {
name: file.name,
songName,
format,
sampleRate,
nativeRateKnown,
channels,
duration: mono.length / sampleRate,
mono,
maxAmplitude,
avgAmplitude: mono.length ? absSum / mono.length : 0,
size: file.size,
};
}
/**
* `librosa.to_mono`: the float32 mean across channels.
*
* Accumulating in a Float32Array rounds each partial sum to float32 exactly as
* numpy's float32 `mean(axis=0)` does, so downmixes match bit for bit.
*/
function bufferToMono(buffer) {
const channels = buffer.numberOfChannels;
if (channels === 1) return new Float32Array(buffer.getChannelData(0));
const n = buffer.length;
const mono = new Float32Array(n);
for (let c = 0; c < channels; c++) {
const data = buffer.getChannelData(c);
for (let i = 0; i < n; i++) mono[i] += data[i];
}
for (let i = 0; i < n; i++) mono[i] /= channels;
return mono;
}
// ---- direct PCM (WAV / AIFF) ------------------------------------------------
function ascii(view, offset, length) {
if (offset < 0 || offset + length > view.byteLength) return '';
let s = '';
for (let i = 0; i < length; i++) s += String.fromCharCode(view.getUint8(offset + i));
return s;
}
/**
* A reader for one sample as libsndfile's sf_read_float returns it, or null
* for encodings left to the browser decoder.
*/
function sampleReader(view, encoding, bytesPerSample, littleEndian) {
if (encoding === 'int') {
switch (bytesPerSample) {
case 1: return null; // handled by the caller: WAV is unsigned, AIFF signed
case 2: return (p) => view.getInt16(p, littleEndian) / 0x8000;
case 3: return littleEndian
? (p) => (((view.getUint8(p) | (view.getUint8(p + 1) << 8) | (view.getUint8(p + 2) << 16)) << 8) >> 8) / 0x800000
: (p) => (((view.getUint8(p + 2) | (view.getUint8(p + 1) << 8) | (view.getUint8(p) << 16)) << 8) >> 8) / 0x800000;
case 4: return (p) => Math.fround(view.getInt32(p, littleEndian)) / 0x80000000;
default: return null;
}
}
if (encoding === 'float') {
if (bytesPerSample === 4) return (p) => view.getFloat32(p, littleEndian);
if (bytesPerSample === 8) return (p) => Math.fround(view.getFloat64(p, littleEndian));
}
return null;
}
/** Interleaved frames -> float32 mono, channel sums in channel order. */
function interleavedToMono(view, dataOffset, frames, channels, bytesPerSample, read) {
const mono = new Float32Array(frames);
const scratch = new Float32Array(1);
for (let f = 0; f < frames; f++) {
const base = dataOffset + f * channels * bytesPerSample;
scratch[0] = 0;
for (let c = 0; c < channels; c++) scratch[0] += read(base + c * bytesPerSample);
mono[f] = channels === 1 ? scratch[0] : scratch[0] / channels;
}
return mono;
}
function readPcm(view, format) {
return format === 'wav' ? readWav(view) : readAiff(view);
}
function readWav(view) {
let fmt = null;
let dataOffset = -1;
let dataSize = 0;
let p = 12;
while (p + 8 <= view.byteLength) {
const id = ascii(view, p, 4);
const size = view.getUint32(p + 4, true);
if (id === 'fmt ' && p + 24 <= view.byteLength) {
let code = view.getUint16(p + 8, true);
// WAVE_FORMAT_EXTENSIBLE: the real format code leads the SubFormat GUID.
if (code === 0xfffe && size >= 40 && p + 34 <= view.byteLength) code = view.getUint16(p + 32, true);
fmt = {
code,
channels: view.getUint16(p + 10, true),
sampleRate: view.getUint32(p + 12, true),
blockAlign: view.getUint16(p + 20, true),
};
} else if (id === 'data') {
dataOffset = p + 8;
// Streamed/RF64 files may carry a placeholder size; trust the file length.
dataSize = size === 0xffffffff || dataOffset + size > view.byteLength ? view.byteLength - dataOffset : size;
break;
}
p += 8 + size + (size & 1);
}
if (!fmt || dataOffset < 0 || !fmt.channels || !fmt.blockAlign) return null;
const bytesPerSample = fmt.blockAlign / fmt.channels;
const frames = Math.floor(dataSize / fmt.blockAlign);
let read;
if (fmt.code === 1 && bytesPerSample === 1) read = (q) => (view.getUint8(q) - 128) / 0x80;
else if (fmt.code === 1) read = sampleReader(view, 'int', bytesPerSample, true);
else if (fmt.code === 3) read = sampleReader(view, 'float', bytesPerSample, true);
if (!read) return null;
return {
sampleRate: fmt.sampleRate,
channels: fmt.channels,
mono: interleavedToMono(view, dataOffset, frames, fmt.channels, bytesPerSample, read),
};
}
function readAiff(view) {
const aifc = ascii(view, 8, 4) === 'AIFC';
let comm = null;
let dataOffset = -1;
let dataSize = 0;
let p = 12;
while (p + 8 <= view.byteLength) {
const id = ascii(view, p, 4);
const size = view.getUint32(p + 4, false);
if (id === 'COMM' && p + 26 <= view.byteLength) {
comm = {
channels: view.getUint16(p + 8, false),
frames: view.getUint32(p + 10, false),
bits: view.getUint16(p + 14, false),
sampleRate: aiffRate(view, p + 16),
compression: aifc && p + 30 <= view.byteLength ? ascii(view, p + 26, 4) : 'NONE',
};
} else if (id === 'SSND' && p + 16 <= view.byteLength) {
dataOffset = p + 16 + view.getUint32(p + 8, false);
dataSize = Math.min(size - 8, view.byteLength - dataOffset);
}
p += 8 + size + (size & 1);
}
if (!comm || dataOffset < 0 || !comm.channels) return null;
const bytesPerSample = Math.ceil(comm.bits / 8);
let read = null;
const c = comm.compression;
if ((c === 'NONE' || c === 'twos') && bytesPerSample === 1) read = (q) => view.getInt8(q) / 0x80;
else if (c === 'NONE' || c === 'twos') read = sampleReader(view, 'int', bytesPerSample, false);
else if (c === 'sowt') read = sampleReader(view, 'int', bytesPerSample, true);
else if (c === 'fl32' || c === 'FL32') read = sampleReader(view, 'float', 4, false);
else if (c === 'fl64' || c === 'FL64') read = sampleReader(view, 'float', 8, false);
if (!read) return null;
const width = c.toLowerCase() === 'fl32' ? 4 : c.toLowerCase() === 'fl64' ? 8 : bytesPerSample;
const frames = Math.min(comm.frames, Math.floor(dataSize / (width * comm.channels)));
return {
sampleRate: comm.sampleRate,
channels: comm.channels,
mono: interleavedToMono(view, dataOffset, frames, comm.channels, width, read),
};
}
/** 80-bit IEEE 754 extended float (AIFF COMM sample rate). */
function aiffRate(view, q) {
const exponent = view.getUint16(q, false) & 0x7fff;
const mantissa = view.getUint32(q + 2, false) * 2 ** 32 + view.getUint32(q + 6, false);
return Math.round(mantissa * 2 ** (exponent - 16383 - 63));
}
// ---- container sniffing (browser-decoded formats) ---------------------------
function syncsafe(view, offset) {
return ((view.getUint8(offset) & 0x7f) << 21) | ((view.getUint8(offset + 1) & 0x7f) << 14)
| ((view.getUint8(offset + 2) & 0x7f) << 7) | (view.getUint8(offset + 3) & 0x7f);
}
/** Byte offset just past a leading ID3v2 tag (0 when there is none). */
function skipId3(view) {
if (ascii(view, 0, 3) !== 'ID3' || view.byteLength < 10) return 0;
const footer = (view.getUint8(5) & 0x10) ? 10 : 0;
return 10 + syncsafe(view, 6) + footer;
}
export function sniffFormat(view) {
const riff = ascii(view, 0, 4);
if ((riff === 'RIFF' || riff === 'RF64') && ascii(view, 8, 4) === 'WAVE') return 'wav';
if (riff === 'FORM' && /^AIF[FC]$/.test(ascii(view, 8, 4))) return 'aiff';
if (riff === 'OggS') return 'ogg';
if (ascii(view, 4, 4) === 'ftyp') return 'mp4';
const start = skipId3(view);
if (ascii(view, start, 4) === 'fLaC') return 'flac';
if (findMpegFrame(view, start) !== null) return 'mp3';
return null;
}
export function sniffSampleRate(view, format) {
switch (format) {
case 'wav': return wavRate(view);
case 'aiff': return aiffCommRate(view);
case 'flac': return flacRate(view, skipId3(view));
case 'mp3': {
const frame = findMpegFrame(view, skipId3(view));
return frame ? frame.sampleRate : null;
}
case 'ogg': return oggRate(view);
case 'mp4': return mp4Rate(view);
default: return null;
}
}
function wavRate(view) {
let p = 12;
while (p + 8 <= view.byteLength) {
const id = ascii(view, p, 4);
const size = view.getUint32(p + 4, true);
if (id === 'fmt ' && p + 16 <= view.byteLength) return view.getUint32(p + 12, true);
p += 8 + size + (size & 1);
}
return null;
}
function aiffCommRate(view) {
let p = 12;
while (p + 8 <= view.byteLength) {
const size = view.getUint32(p + 4, false);
if (ascii(view, p, 4) === 'COMM' && p + 26 <= view.byteLength) return aiffRate(view, p + 16);
p += 8 + size + (size & 1);
}
return null;
}
function flacRate(view, start) {
// STREAMINFO is always the first metadata block: 4-byte marker, 4-byte block
// header, then 10 bytes of block/frame sizes before the 20-bit sample rate.
const q = start + 8 + 10;
if (q + 3 > view.byteLength) return null;
return (view.getUint8(q) << 12) | (view.getUint8(q + 1) << 4) | (view.getUint8(q + 2) >> 4);
}
const MPEG_RATES = { 3: [44100, 48000, 32000], 2: [22050, 24000, 16000], 0: [11025, 12000, 8000] };
const L3_KBPS = {
v1: [0, 32, 40, 48, 56, 64, 80, 96, 112, 128, 160, 192, 224, 256, 320],
v2: [0, 8, 16, 24, 32, 40, 48, 56, 64, 80, 96, 112, 128, 144, 160],
};
function parseMpegHeader(view, p) {
if (p + 4 > view.byteLength) return null;
const b1 = view.getUint8(p + 1), b2 = view.getUint8(p + 2);
if (view.getUint8(p) !== 0xff || (b1 & 0xe0) !== 0xe0) return null;
const version = (b1 >> 3) & 3;
const layer = (b1 >> 1) & 3;
const bitrateIndex = (b2 >> 4) & 15;
const rateIndex = (b2 >> 2) & 3;
if (version === 1 || layer === 0 || bitrateIndex === 15 || rateIndex === 3) return null;
const sampleRate = MPEG_RATES[version][rateIndex];
let frameLength = null;
if (layer === 1 && bitrateIndex !== 0) { // Layer III
const kbps = (version === 3 ? L3_KBPS.v1 : L3_KBPS.v2)[bitrateIndex];
const coeff = version === 3 ? 144 : 72;
frameLength = Math.floor((coeff * kbps * 1000) / sampleRate) + ((b2 >> 1) & 1);
}
return { sampleRate, frameLength };
}
/**
* First plausible MPEG audio frame at or after `start`.
*
* A lone 0xFFE sync pattern is common in arbitrary bytes, so a Layer III frame
* is only accepted when another header with the same rate follows at its
* computed length.
*/
function findMpegFrame(view, start) {
const limit = Math.min(view.byteLength - 4, start + 64 * 1024);
for (let p = start; p < limit; p++) {
const head = parseMpegHeader(view, p);
if (!head) continue;
if (head.frameLength === null) return head;
const next = parseMpegHeader(view, p + head.frameLength);
if (next && next.sampleRate === head.sampleRate) return head;
}
return null;
}
function oggRate(view) {
const segments = view.byteLength > 26 ? view.getUint8(26) : 0;
const packet = 27 + segments;
if (ascii(view, packet, 7) === '\x01vorbis' && packet + 16 <= view.byteLength) {
return view.getUint32(packet + 12, true);
}
// Opus always decodes at 48 kHz; its header's "input rate" is informational.
if (ascii(view, packet, 8) === 'OpusHead') return 48000;
return null;
}
function mp4Rate(view) {
// Audio sample entries (mp4a/alac/Opus/fLaC) carry a 16.16 fixed-point rate
// 32 bytes into the box. moov may sit at the end of the file, so scan it all.
const bytes = new Uint8Array(view.buffer, view.byteOffset, view.byteLength);
const types = ['mp4a', 'alac', 'Opus', 'fLaC'].map((t) => [...t].map((c) => c.charCodeAt(0)));
for (let i = 4; i + 30 < bytes.length; i++) {
for (const t of types) {
if (bytes[i] === t[0] && bytes[i + 1] === t[1] && bytes[i + 2] === t[2] && bytes[i + 3] === t[3]) {
const rate = view.getUint16(i - 4 + 32, false);
if (rate >= MIN_CONTEXT_RATE) return rate;
}
}
}
return null;
}
// ---- ID3 tags ---------------------------------------------------------------
/** {artist, title} from an ID3v2.2/2.3/2.4 tag (EasyID3's TPE1/TIT2), or null. */
export function readId3(view) {
if (ascii(view, 0, 3) !== 'ID3' || view.byteLength < 10) return null;
const major = view.getUint8(3);
const flags = view.getUint8(5);
const end = Math.min(10 + syncsafe(view, 6), view.byteLength);
let p = 10;
if (flags & 0x40) p += major === 4 ? syncsafe(view, 10) : view.getUint32(10, false) + 4;
const v22 = major === 2;
const idLen = v22 ? 3 : 4;
const headerLen = v22 ? 6 : 10;
const wanted = v22 ? { TT2: 'title', TP1: 'artist' } : { TIT2: 'title', TPE1: 'artist' };
const out = {};
while (p + headerLen <= end) {
const id = ascii(view, p, idLen);
if (!/^[A-Z0-9]+$/.test(id)) break; // reached padding
let size;
if (v22) size = (view.getUint8(p + 3) << 16) | (view.getUint8(p + 4) << 8) | view.getUint8(p + 5);
else if (major === 4) size = syncsafe(view, p + 4);
else size = view.getUint32(p + 4, false);
const body = p + headerLen;
if (size < 0 || body + size > end) break;
if (wanted[id] && size > 1) {
const text = decodeId3Text(new Uint8Array(view.buffer, view.byteOffset + body, size));
if (text) out[wanted[id]] = text;
}
p = body + size;
}
return out;
}
function decodeId3Text(bytes) {
const encoding = bytes[0];
let data = bytes.subarray(1);
let label = 'latin1';
if (encoding === 1) {
if (data[0] === 0xfe && data[1] === 0xff) { label = 'utf-16be'; data = data.subarray(2); }
else { label = 'utf-16le'; if (data[0] === 0xff && data[1] === 0xfe) data = data.subarray(2); }
} else if (encoding === 2) {
label = 'utf-16be';
} else if (encoding === 3) {
label = 'utf-8';
}
// v2.4 separates multiple values with NUL; EasyID3 reports the first.
return new TextDecoder(label).decode(data).split('\0')[0].trim();
}
+694
View File
@@ -0,0 +1,694 @@
// SPDX-License-Identifier: Apache-2.0
//
// Metric computation, ported from `metrics.py` / `master_core.py` to run in the
// browser.
//
// Pure functions over a mono Float32Array at the file's native sample rate — no
// DOM, no Web Audio — so the same module runs inside the analysis Web Worker and
// under Node for the parity tests (tests/parity), which check every metric
// against the Python implementation on identical samples.
//
// The numerics follow the Python where it matters:
// * running sums of squares are sequential float64 sums, like np.cumsum, but
// sampled only at the window/block edges that are read — the same values
// without an N-length array;
// * K-weighting is pyloudnorm's biquad pair run through scipy.signal.lfilter's
// direct-form-II-transposed recurrence in float64, streamed sample by sample;
// * true peak reproduces scipy.signal.resample_poly(y, 4, 1) — the same 81-tap
// Kaiser(beta=5) firwin filter and zero-padded alignment — then an exact
// sliding maximum (what maximum_filter1d sampled at window centres yields);
// * the spectrogram follows librosa.stft (centred, zero padded, periodic Hann)
// and amplitude_to_db(ref=np.max, top_db=80), dropping the DC row.
// What remains is float32 rounding where numpy works in float32 (resampling,
// RMS, spectrogram dB): far below anything a plot or readout can show.
const EPS = 1e-12; // keeps 20*log10 finite on perfect silence
const LUFS_OFFSET = -0.691; // BS.1770
const ABS_GATE = -70.0; // BS.1770 absolute gate
const SILENCE_FLOOR = -70.0;
const toDbfs = (v) => 20 * Math.log10(Math.max(v, EPS));
/** Number of full windows of `windowN` stepped by `hopN` over `n` samples. */
function windowCount(n, windowN, hopN) {
return 1 + Math.floor((n - windowN) / hopN);
}
function sampleStream(x) {
let i = 0;
return () => x[i++];
}
function absStream(x) {
let i = 0;
return () => Math.abs(x[i++]);
}
function lowerBound(sorted, value) {
let lo = 0;
let hi = sorted.length;
while (lo < hi) {
const mid = (lo + hi) >>> 1;
if (sorted[mid] < value) lo = mid + 1;
else hi = mid;
}
return lo;
}
/**
* csq(i) = sum of x[k]^2 for k < i — np.concatenate(([0], np.cumsum(x * x))) —
* at the requested indices only.
*
* `next()` yields x[0], x[1], ... in order, so a filtered signal can be streamed
* straight in. Indices past `n` read csq(n), i.e. a zero-padded tail.
*/
function prefixSquares(n, next, indexArrays) {
let total = 0;
for (const a of indexArrays) total += a.length;
const marks = new Float64Array(total);
let offset = 0;
for (const a of indexArrays) {
marks.set(a, offset);
offset += a.length;
}
marks.sort();
const values = new Float64Array(marks.length);
let m = 0;
while (m < marks.length && marks[m] <= 0) values[m++] = 0;
let acc = 0;
for (let k = 0; k < n && m < marks.length; k++) {
const v = next();
acc += v * v;
while (m < marks.length && marks[m] === k + 1) values[m++] = acc;
}
while (m < marks.length) values[m++] = acc;
return (index) => values[lowerBound(marks, index)];
}
/**
* max(x[s .. s + windowN)) for s = 0, hopN, 2*hopN, ... (`count` windows).
*
* A monotonic deque: one O(N) pass, O(windowN + hopN) memory, exact maxima.
*/
function windowMaxima(count, windowN, hopN, next) {
const out = new Float64Array(Math.max(count, 0));
if (count <= 0) return out;
const cap = windowN + hopN + 1;
const idx = new Float64Array(cap);
const val = new Float64Array(cap);
let head = 0;
let size = 0;
let w = 0;
let end = windowN; // exclusive end of window w
const last = (count - 1) * hopN + windowN;
for (let k = 0; k < last; k++) {
const v = next();
while (size > 0) {
let back = head + size - 1;
if (back >= cap) back -= cap;
if (val[back] <= v) size--;
else break;
}
let pos = head + size;
if (pos >= cap) pos -= cap;
idx[pos] = k;
val[pos] = v;
size++;
if (k + 1 === end) {
const start = end - windowN;
while (idx[head] < start) {
head++;
if (head === cap) head = 0;
size--;
}
out[w++] = val[head];
if (w === count) break;
end += hopN;
}
}
return out;
}
// ---- K-weighting and loudness ------------------------------------------------
/** pyloudnorm IIRfilter.generate_coefficients, normalised by a0. */
function rbjBiquad(type, G, Q, fc, rate) {
const A = 10 ** (G / 40.0);
const w0 = 2.0 * Math.PI * (fc / rate);
const alpha = Math.sin(w0) / (2.0 * Q);
let b0, b1, b2, a0, a1, a2;
if (type === 'high_shelf') {
b0 = A * (A + 1 + (A - 1) * Math.cos(w0) + 2 * Math.sqrt(A) * alpha);
b1 = -2 * A * (A - 1 + (A + 1) * Math.cos(w0));
b2 = A * (A + 1 + (A - 1) * Math.cos(w0) - 2 * Math.sqrt(A) * alpha);
a0 = A + 1 - (A - 1) * Math.cos(w0) + 2 * Math.sqrt(A) * alpha;
a1 = 2 * (A - 1 - (A + 1) * Math.cos(w0));
a2 = A + 1 - (A - 1) * Math.cos(w0) - 2 * Math.sqrt(A) * alpha;
} else if (type === 'high_pass') {
b0 = (1 + Math.cos(w0)) / 2;
b1 = -(1 + Math.cos(w0));
b2 = (1 + Math.cos(w0)) / 2;
a0 = 1 + alpha;
a1 = -2 * Math.cos(w0);
a2 = 1 - alpha;
} else {
throw new Error(`unsupported filter type ${type}`);
}
return { b0: b0 / a0, b1: b1 / a0, b2: b2 / a0, a1: a1 / a0, a2: a2 / a0 };
}
/** pyloudnorm Meter(rate) 'K-weighting': high shelf, then high pass. */
export function kWeightingCoefficients(rate) {
return [
rbjBiquad('high_shelf', 4.0, 1 / Math.sqrt(2), 1500.0, rate),
rbjBiquad('high_pass', 0.0, 0.5, 38.0, rate),
];
}
/**
* The K-weighted signal as a stream (metrics._kweight).
*
* Each stage is scipy's lfilter recurrence — y = z0 + b0*x;
* z0 = z1 + x*b1 - y*a1; z1 = x*b2 - y*a2 — in the same operation order, so the
* filtered samples match pyloudnorm's whole-array filtering.
*/
function kWeightedStream(mono, rate) {
const [s, p] = kWeightingCoefficients(rate);
let i = 0;
let sz0 = 0, sz1 = 0, pz0 = 0, pz1 = 0;
return () => {
const x = mono[i++];
const y1 = sz0 + s.b0 * x;
sz0 = sz1 + x * s.b1 - y1 * s.a1;
sz1 = x * s.b2 - y1 * s.a2;
const y2 = pz0 + p.b0 * y1;
pz0 = pz1 + y1 * p.b1 - y2 * p.a1;
pz1 = y1 * p.b2 - y2 * p.a2;
return y2;
};
}
/** np.round: round half to even. */
function roundHalfEven(x) {
const r = Math.round(x);
return Math.abs(x % 1) === 0.5 && r % 2 !== 0 ? r - 1 : r;
}
/** Block edges as metrics._block_loudness lays them out, or null when none fit. */
function blockEdges(nSignal, sr, blockS, stepPct) {
const T = nSignal / sr;
const nBlocks = Math.trunc(roundHalfEven((T - blockS) / (blockS * stepPct)) + 1);
if (!(nBlocks >= 1)) return null;
const lo = new Float64Array(nBlocks);
const up = new Float64Array(nBlocks);
for (let j = 0; j < nBlocks; j++) {
lo[j] = Math.trunc(blockS * (j * stepPct) * sr);
up[j] = Math.min(Math.trunc(blockS * (j * stepPct + 1) * sr), nSignal);
}
return { lo, up, n: nBlocks, divisor: blockS * sr };
}
function blockLoudness(blocks, csq) {
const z = new Float64Array(blocks.n);
const l = new Float64Array(blocks.n);
for (let j = 0; j < blocks.n; j++) {
z[j] = (csq(blocks.up[j]) - csq(blocks.lo[j])) / blocks.divisor;
l[j] = LUFS_OFFSET + 10.0 * Math.log10(z[j]);
}
return { z, l };
}
/** metrics._integrated_lufs: two-stage gated mean of the 400 ms blocks. */
function gatedIntegrated(z, l) {
let sum = 0;
let count = 0;
for (let j = 0; j < l.length; j++) {
if (l[j] >= ABS_GATE) { sum += z[j]; count++; }
}
if (!count) return -Infinity;
const gammaR = LUFS_OFFSET + 10.0 * Math.log10(sum / count) - 10.0;
sum = 0;
count = 0;
for (let j = 0; j < l.length; j++) {
if (l[j] > gammaR && l[j] > ABS_GATE) { sum += z[j]; count++; }
}
if (!count) return -Infinity;
return LUFS_OFFSET + 10.0 * Math.log10(sum / count);
}
/** np.percentile(values, q), default 'linear' method, on a sorted array. */
function percentileSorted(sorted, q) {
const index = (q / 100) * (sorted.length - 1);
const lo = Math.floor(index);
const hi = Math.min(lo + 1, sorted.length - 1);
const t = index - lo;
const a = sorted[lo];
const b = sorted[hi];
return t >= 0.5 ? b - (b - a) * (1 - t) : a + (b - a) * t;
}
/** metrics._loudness_range: EBU Tech 3342 LRA from 3 s block loudness. */
function loudnessRange(l) {
const absGated = [];
for (let j = 0; j < l.length; j++) if (l[j] >= ABS_GATE) absGated.push(l[j]);
if (!absGated.length) return NaN;
let energy = 0;
for (const v of absGated) energy += 10.0 ** (v / 10.0);
const stlIntegrated = 10.0 * Math.log10(energy / absGated.length);
const rel = Float64Array.from(absGated.filter((v) => v >= stlIntegrated - 20.0));
if (!rel.length) return NaN;
rel.sort();
return percentileSorted(rel, 95) - percentileSorted(rel, 10);
}
/**
* Every loudness quantity from one K-weighting pass: the ungated short-term
* (3 s / 0.5 s) series, integrated loudness and LRA.
*
* Memoised on `memo` (the AudioFile attribute caching in Python) so LUFS and PSR
* share it. Returned arrays are owned by the memo — callers copy before handing
* them out.
*/
function loudness(mono, sr, memo) {
if (memo && memo.loudness) return memo.loudness;
const n = mono.length;
const windowN = Math.max(Math.trunc(3.0 * sr), 1);
const hopN = Math.max(Math.trunc(0.5 * sr), 1);
const stCount = n >= windowN ? windowCount(n, windowN, hopN) : 0;
const stStarts = new Float64Array(stCount);
const stEnds = new Float64Array(stCount);
for (let i = 0; i < stCount; i++) {
stStarts[i] = i * hopN;
stEnds[i] = i * hopN + windowN;
}
const integ = blockEdges(n, sr, 0.4, 0.25);
// LRA appends 1.5 s of silence; indices past n read csq(n), which is exactly that.
const lraBlocks = blockEdges(n + Math.trunc(1.5 * sr), sr, 3.0, 0.03);
const wanted = [stStarts, stEnds, Float64Array.of(0, n)];
if (integ) wanted.push(integ.lo, integ.up);
if (lraBlocks) wanted.push(lraBlocks.lo, lraBlocks.up);
const csq = prefixSquares(n, kWeightedStream(mono, sr), wanted);
let times;
let lufs;
if (n < windowN) {
const ms = n ? csq(n) / n : 0;
times = Float64Array.of(n / (2.0 * sr));
lufs = Float64Array.of(LUFS_OFFSET + 10.0 * Math.log10(Math.max(ms, EPS)));
} else {
times = new Float64Array(stCount);
lufs = new Float64Array(stCount);
for (let i = 0; i < stCount; i++) {
const ms = (csq(stEnds[i]) - csq(stStarts[i])) / windowN;
lufs[i] = LUFS_OFFSET + 10.0 * Math.log10(Math.max(ms, EPS));
times[i] = (stStarts[i] + windowN / 2.0) / sr;
}
}
let integrated = -Infinity;
if (integ) {
const { z, l } = blockLoudness(integ, csq);
integrated = gatedIntegrated(z, l);
}
let lra = NaN;
if (n >= Math.trunc(3.0 * sr) && lraBlocks) {
lra = loudnessRange(blockLoudness(lraBlocks, csq).l);
}
const result = { times, lufs, integrated, lra };
if (memo) memo.loudness = result;
return result;
}
// ---- metrics -------------------------------------------------------------------
/** RMSPowerMetric: librosa.feature.rms, 10 s frames / 2 s hop, centred. */
export function computeRmsPower(mono, sr, window = 10, hop = 2) {
const n = mono.length;
const frameLength = window * sr;
const hopLength = hop * sr;
const half = Math.floor(frameLength / 2);
const padded = n + 2 * half;
const count = padded >= frameLength ? 1 + Math.floor((padded - frameLength) / hopLength) : 0;
// Frame i covers padded[i*hop, i*hop + frameLength): the zero padding adds
// nothing to the sum but still counts in the mean, as in librosa.
const starts = new Float64Array(count);
const ends = new Float64Array(count);
for (let i = 0; i < count; i++) {
const a = i * hopLength - half;
starts[i] = Math.min(Math.max(a, 0), n);
ends[i] = Math.min(Math.max(a + frameLength, 0), n);
}
const csq = prefixSquares(n, sampleStream(mono), [starts, ends]);
const times = new Float64Array(count);
const rms = new Float64Array(count);
for (let i = 0; i < count; i++) {
times[i] = (i * hopLength) / sr;
rms[i] = Math.sqrt((csq(ends[i]) - csq(starts[i])) / frameLength);
}
return { times, rms };
}
/** WaveformMetric: min/max envelope over ~4000 columns. */
export function computeWaveform(mono, sr, targetColumns = 4000) {
const n = mono.length;
if (n <= targetColumns) {
const times = new Float64Array(n);
for (let i = 0; i < n; i++) times[i] = i / sr;
return { times, lo: Float32Array.from(mono), hi: Float32Array.from(mono) };
}
const chunk = Math.floor(n / targetColumns);
const times = new Float64Array(targetColumns);
const lo = new Float32Array(targetColumns);
const hi = new Float32Array(targetColumns);
for (let c = 0; c < targetColumns; c++) {
let mn = Infinity;
let mx = -Infinity;
const base = c * chunk;
for (let k = base; k < base + chunk; k++) {
const v = mono[k];
if (v < mn) mn = v;
if (v > mx) mx = v;
}
lo[c] = mn;
hi[c] = mx;
times[c] = (c * chunk + chunk / 2) / sr;
}
return { times, lo, hi };
}
/** LUFSMetric: short-term series clipped to [-70, 0], plus integrated and LRA. */
export function computeLufs(mono, sr, memo) {
const L = loudness(mono, sr, memo);
const lufs = new Float64Array(L.lufs.length);
for (let i = 0; i < lufs.length; i++) {
const v = Number.isFinite(L.lufs[i]) ? L.lufs[i] : SILENCE_FLOOR;
lufs[i] = Math.min(Math.max(v, SILENCE_FLOOR), 0.0);
}
return { times: L.times.slice(), lufs, integrated: L.integrated, lra: L.lra };
}
/** CrestFactorMetric: 20*log10(peak / RMS) per 1 s window, 0.25 s hop. */
export function computeCrestFactor(mono, sr) {
const n = mono.length;
const windowN = Math.trunc(1.0 * sr);
const hopN = Math.trunc(0.25 * sr);
if (n < windowN) {
let peak = 0;
let sumSq = 0;
for (let i = 0; i < n; i++) {
const v = mono[i];
if (Math.abs(v) > peak) peak = Math.abs(v);
sumSq += v * v;
}
const rms = n ? Math.sqrt(sumSq / n) : 0;
return {
times: Float64Array.of(n / (2.0 * sr)),
crest_db: Float64Array.of(20.0 * Math.log10(Math.max(peak, EPS) / Math.max(rms, EPS))),
};
}
const count = windowCount(n, windowN, hopN);
const starts = new Float64Array(count);
const ends = new Float64Array(count);
for (let i = 0; i < count; i++) {
starts[i] = i * hopN;
ends[i] = i * hopN + windowN;
}
const csq = prefixSquares(n, sampleStream(mono), [starts, ends]);
const peaks = windowMaxima(count, windowN, hopN, absStream(mono));
const times = new Float64Array(count);
const crest = new Float64Array(count);
for (let i = 0; i < count; i++) {
const meanSq = (csq(ends[i]) - csq(starts[i])) / windowN;
const rms = Math.sqrt(Math.max(meanSq, EPS));
crest[i] = 20.0 * Math.log10(Math.max(peaks[i], EPS) / rms);
times[i] = (starts[i] + windowN / 2.0) / sr;
}
return { times, crest_db: crest };
}
/** PSRMetric: sample peak minus short-term LUFS (3 s), NaN below the gate. */
export function computePsr(mono, sr, memo) {
const n = mono.length;
const windowN = Math.max(Math.trunc(3.0 * sr), 1);
const hopN = Math.max(Math.trunc(0.5 * sr), 1);
const L = loudness(mono, sr, memo);
const count = L.times.length;
let peaksDb;
if (n < windowN) {
let peak = 0;
for (let i = 0; i < n; i++) peak = Math.max(peak, Math.abs(mono[i]));
peaksDb = Float64Array.of(n ? toDbfs(peak) : SILENCE_FLOOR);
} else {
peaksDb = windowMaxima(count, windowN, hopN, absStream(mono)).map(toDbfs);
}
const psr = new Float64Array(count);
for (let i = 0; i < count; i++) {
const l = L.lufs[i];
psr[i] = Number.isFinite(l) && l > SILENCE_FLOOR ? peaksDb[i] - l : NaN;
}
return { times: L.times.slice(), psr };
}
// ---- true peak -------------------------------------------------------------------
const OVERSAMPLE = 4;
let truePeakTaps = null;
function besselI0(x) {
const q = (x * x) / 4;
let term = 1;
let sum = 1;
for (let k = 1; k < 500; k++) {
term *= q / (k * k);
sum += term;
if (term < 1e-17 * sum) break;
}
return sum;
}
/**
* The anti-imaging filter resample_poly(x, 4, 1) builds for float32 input:
* firwin(81, 1/4, window=('kaiser', 5.0)), cast to float32, times 4.
*/
function truePeakFilter() {
if (truePeakTaps) return truePeakTaps;
const numtaps = 2 * 10 * OVERSAMPLE + 1;
const cutoff = 1.0 / OVERSAMPLE;
const beta = 5.0;
const alpha = 0.5 * (numtaps - 1);
const i0beta = besselI0(beta);
const h = new Float64Array(numtaps);
let sum = 0;
for (let k = 0; k < numtaps; k++) {
const m = k - alpha;
const x = Math.PI * (m === 0 ? 1e-20 : cutoff * m);
const sinc = Math.sin(x) / x;
const r = (k - alpha) / alpha;
h[k] = cutoff * sinc * (besselI0(beta * Math.sqrt(1 - r * r)) / i0beta);
sum += h[k];
}
truePeakTaps = new Float32Array(numtaps);
for (let k = 0; k < numtaps; k++) truePeakTaps[k] = Math.fround(h[k] / sum) * OVERSAMPLE;
return truePeakTaps;
}
/**
* |resample_poly(x, 4, 1)| as a stream, one output sample per call.
*
* resample_poly zero-stuffs x, convolves with the taps and keeps outputs
* [41, 41 + 4n); output k therefore sums x[s] * h[40 + k - 4s] over the input
* samples within the filter's reach — 20 or 21 taps per output.
*/
function upsampledAbsStream(x, h) {
const n = x.length;
const half = 10 * OVERSAMPLE;
let k = 0;
return () => {
const q = Math.floor(k / OVERSAMPLE);
const r = k - OVERSAMPLE * q;
const sLo = Math.max(0, r === 0 ? q - 10 : q - 9);
const sHi = Math.min(n - 1, q + 10);
let acc = 0;
for (let s = sLo; s <= sHi; s++) acc += x[s] * h[half + r + OVERSAMPLE * (q - s)];
k++;
return Math.abs(Math.fround(acc));
};
}
/** TruePeakMetric: 4x oversampled dBTP per 250 ms window, 100 ms hop. */
export function computeTruePeak(mono, sr) {
const n = mono.length;
const windowN = Math.trunc(0.25 * sr);
const hopN = Math.trunc(0.1 * sr);
const h = truePeakFilter();
if (n < windowN) {
let peak = 0;
const next = upsampledAbsStream(mono, h);
for (let k = 0; k < n * OVERSAMPLE; k++) peak = Math.max(peak, next());
const db = toDbfs(peak);
return { times: Float64Array.of(n / (2.0 * sr)), tp_db: Float64Array.of(db), integrated_tp_db: db };
}
const count = windowCount(n, windowN, hopN);
const peaks = windowMaxima(count, windowN * OVERSAMPLE, hopN * OVERSAMPLE, upsampledAbsStream(mono, h));
const times = new Float64Array(count);
const tpDb = new Float64Array(count);
let max = -Infinity;
for (let i = 0; i < count; i++) {
tpDb[i] = toDbfs(peaks[i]);
times[i] = (i * hopN + windowN / 2.0) / sr;
if (tpDb[i] > max) max = tpDb[i];
}
return { times, tp_db: tpDb, integrated_tp_db: max };
}
// ---- spectrogram -----------------------------------------------------------------
const N_FFT = 4096;
const SPEC_TARGET_COLUMNS = 4000;
const SPEC_DB_FLOOR = -80.0;
const fftPlans = new Map();
/** In-place radix-2 complex FFT for `size` (a power of two). */
function fftPlan(size) {
if (fftPlans.has(size)) return fftPlans.get(size);
const levels = Math.round(Math.log2(size));
const rev = new Uint32Array(size);
for (let i = 0; i < size; i++) {
let r = 0;
for (let b = 0; b < levels; b++) r |= ((i >> b) & 1) << (levels - 1 - b);
rev[i] = r;
}
const cos = new Float64Array(size / 2);
const sin = new Float64Array(size / 2);
for (let i = 0; i < size / 2; i++) {
cos[i] = Math.cos((2 * Math.PI * i) / size);
sin[i] = Math.sin((2 * Math.PI * i) / size);
}
const plan = (re, im) => {
for (let i = 0; i < size; i++) {
const j = rev[i];
if (j > i) {
let t = re[i]; re[i] = re[j]; re[j] = t;
t = im[i]; im[i] = im[j]; im[j] = t;
}
}
for (let len = 2; len <= size; len <<= 1) {
const halfLen = len >> 1;
const step = size / len;
for (let i = 0; i < size; i += len) {
for (let j = 0; j < halfLen; j++) {
const c = cos[j * step];
const s = sin[j * step];
const u = i + j;
const v = u + halfLen;
const tre = re[v] * c + im[v] * s;
const tim = im[v] * c - re[v] * s;
re[v] = re[u] - tre;
im[v] = im[u] - tim;
re[u] += tre;
im[u] += tim;
}
}
}
};
fftPlans.set(size, plan);
return plan;
}
/**
* SpectrogramMetric: |STFT| in dB relative to the loudest bin, floored at -80.
*
* `s_db` is row-major (rows x cols), row 0 the lowest non-DC bin — the same
* layout as the Python `s_db[1:, :]`.
*/
export function computeSpectrogram(mono, sr) {
const n = mono.length;
const half = N_FFT / 2;
const hop = Math.max(N_FFT / 4, Math.floor(n / SPEC_TARGET_COLUMNS));
const cols = 1 + Math.floor(n / hop);
const rows = half; // bins 1..N_FFT/2
const window = new Float64Array(N_FFT); // periodic Hann (fftbins=True)
for (let j = 0; j < N_FFT; j++) window[j] = 0.5 - 0.5 * Math.cos((2 * Math.PI * j) / N_FFT);
const fft = fftPlan(N_FFT);
const re = new Float64Array(N_FFT);
const im = new Float64Array(N_FFT);
const sDb = new Float32Array(rows * cols);
let maxPower = 0;
for (let c = 0; c < cols; c++) {
// Centred framing: frame c is padded[c*hop, c*hop + N_FFT) with N_FFT/2
// zeros either side of the signal.
const offset = c * hop - half;
for (let j = 0; j < N_FFT; j++) {
const t = offset + j;
re[j] = t >= 0 && t < n ? mono[t] * window[j] : 0;
im[j] = 0;
}
fft(re, im);
const p0 = re[0] * re[0] + im[0] * im[0];
if (p0 > maxPower) maxPower = p0;
for (let b = 1; b <= half; b++) {
const p = re[b] * re[b] + im[b] * im[b];
if (p > maxPower) maxPower = p;
sDb[(b - 1) * cols + c] = p;
}
}
// amplitude_to_db(ref=np.max, amin=1e-5, top_db=80): in power terms
// 10*log10(max(1e-10, p)) - 10*log10(max(1e-10, max p)), then clipped to
// (maximum - 80). The maximum of that is 0 at the reference bin, so the clip
// is a fixed -80 floor.
const amin = 1e-5 ** 2;
const refDb = 10.0 * Math.log10(Math.max(amin, maxPower));
for (let i = 0; i < sDb.length; i++) {
sDb[i] = Math.max(10.0 * Math.log10(Math.max(amin, sDb[i])) - refDb, SPEC_DB_FLOOR);
}
const binHz = 1.0 / (N_FFT * (1.0 / sr)); // np.fft.rfftfreq spacing
const freqs = new Float64Array(rows);
for (let b = 0; b < rows; b++) freqs[b] = (b + 1) * binHz;
const times = new Float64Array(cols);
for (let c = 0; c < cols; c++) times[c] = (c * hop + half) / sr;
return { freqs, times, s_db: sDb, rows, cols, nyquist: sr / 2.0 };
}
// ---- registry ----------------------------------------------------------------------
/** metric id -> compute(mono, sampleRate, memo). `memo` is per track. */
export const COMPUTE = {
rms_power: (mono, sr) => computeRmsPower(mono, sr),
waveform: (mono, sr) => computeWaveform(mono, sr),
lufs: (mono, sr, memo) => computeLufs(mono, sr, memo),
crest_factor: (mono, sr) => computeCrestFactor(mono, sr),
psr: (mono, sr, memo) => computePsr(mono, sr, memo),
true_peak: (mono, sr) => computeTruePeak(mono, sr),
spectrogram: (mono, sr) => computeSpectrogram(mono, sr),
};
/** The ArrayBuffers inside a computed result, for a zero-copy postMessage. */
export function transferables(data) {
const buffers = new Set();
for (const value of Object.values(data)) {
if (ArrayBuffer.isView(value)) buffers.add(value.buffer);
}
return [...buffers];
}
+98
View File
@@ -0,0 +1,98 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Audio Mastering Analysis Toolkit</title>
<link rel="icon" href="data:,">
<link rel="stylesheet" href="vendor/uPlot.min.css">
<link rel="stylesheet" href="app.css">
</head>
<body>
<header class="topbar">
<div class="brand">
<span class="logo">◉</span>
<span class="brand-name">uj-mastering-master</span>
<span class="brand-sub">web</span>
</div>
<div class="topbar-controls">
<label class="field">
<span>Metric</span>
<select id="metric-select" title="Which metric to display"></select>
</label>
<label class="field">
<span>X axis</span>
<select id="x-mode-select" title="Absolute time, or each track normalised to its own length">
<option value="absolute">Absolute (s)</option>
<option value="relative">Relative (%)</option>
</select>
</label>
<button id="toggle-y-log" class="toggle" type="button">Y: log</button>
<button id="refresh-plot" class="btn" type="button" title="Redraw from the computed results">Refresh</button>
<div class="separator"></div>
<button id="pick-files" class="btn primary" type="button">Add audio…</button>
<input id="file-input" type="file" accept=".mp3,.wav,.flac,.ogg,.oga,.m4a,.aif,.aiff,.opus" multiple hidden>
</div>
</header>
<main class="layout">
<aside class="sidebar">
<section class="panel">
<h2>Files <span id="file-count" class="badge">0</span></h2>
<ul id="file-list" class="file-list"></ul>
<p id="file-empty" class="empty">Drop audio files here, or use <em>Add audio…</em>.
They are analysed in this tab and never uploaded.</p>
</section>
<section class="panel">
<h2>Metadata</h2>
<dl id="metadata" class="metadata">
<dt>Track</dt><dd class="muted">nothing selected</dd>
</dl>
</section>
<section class="panel">
<h2>Reference lines <span id="ref-count" class="badge">0</span></h2>
<ul id="ref-list" class="ref-list"></ul>
<div class="ref-actions">
<button id="add-ref" class="btn small" type="button">Add at centre</button>
<button id="clear-refs" class="btn small" type="button">Clear</button>
</div>
<p class="hint">Drag a line on the plot to move it. Sets are kept per metric.</p>
</section>
</aside>
<section class="stage">
<div class="plot-head">
<h1 id="plot-title">No track loaded</h1>
<div id="legend" class="legend"></div>
</div>
<div id="plot-host" class="plot-host">
<div id="placeholder" class="placeholder">
<p class="ph-title">Audio Mastering Analysis Toolkit</p>
<p class="ph-body">Drop <code>.wav</code>, <code>.flac</code> or <code>.mp3</code> files to analyse.
Decoding and every metric run inside this tab: the audio is never uploaded
or saved anywhere (this page isn't even permitted to make network requests),
and closing the tab discards it. Analysis runs at the file's native sample rate.</p>
</div>
<canvas id="heatmap" class="heatmap" hidden></canvas>
</div>
<div class="statusbar">
<span id="status" class="status">Ready</span>
<span id="timing" class="timing"></span>
</div>
</section>
</main>
<div id="drop-overlay" class="drop-overlay" hidden><span>Drop to analyse</span></div>
<script src="vendor/uPlot.iife.min.js"></script>
<!-- app.js is an ES module: it decodes with decode.js and runs the metrics
from dsp.js in analysis-worker.js. -->
<script type="module" src="app.js"></script>
</body>
</html>
+183
View File
@@ -0,0 +1,183 @@
// SPDX-License-Identifier: Apache-2.0
//
// Metric registry for the browser: display names and `build_spec` ports.
//
// Mirrors `metrics.METRICS`. Computation lives in dsp.js and runs in the
// analysis worker; `buildSpec` is cheap and runs on the main thread whenever the
// view changes, turning computed data into the plot payload the renderer draws —
// the same shape `metrics_json.serialize_spec` used to put on the wire:
// {axes, hlines, annotations, segments (bands), series (curves), heatmap}.
const numbers = (arr) => Array.from(arr, (v) => (Number.isFinite(v) ? v : null));
const extent = (times) => (times.length ? [times[0], times[times.length - 1]] : null);
function payload({ axes, bands = [], curves = [], hlines = [], heatmap = null, annotations = [] }) {
return {
title: '',
axes: {
x_label: '', y_label: '', y_log: false, x_log: false,
y_range: null, x_range: null, y_log_allowed: false, x_log_allowed: false,
...axes,
},
hlines: hlines.map((h) => ({ label: '', color: '#888888', style: 'dot', width: 0.8, ...h })),
annotations,
segments: bands.map((b) => ({
kind: 'band', x: numbers(b.x), lo: numbers(b.lo), hi: numbers(b.hi),
label: b.label || '', color: null,
})),
series: curves.map((c) => ({
kind: 'line', x: numbers(c.x), y: numbers(c.y),
label: c.label || '', width: c.width ?? 1.4, color: c.color ?? null,
})),
heatmap,
y_bounds: null,
};
}
export const METRICS = [
{
id: 'rms_power',
displayName: 'RMS Power',
buildSpec(data) {
let peak = 0;
for (const v of data.rms) if (v > peak) peak = v;
// Adaptive headroom: loud masters get a taller scale.
const ymax = data.rms.length && peak > 0.3 ? 0.6 : 0.3;
return payload({
axes: { x_label: 'Time (seconds)', y_label: 'Power', y_range: [0.0, ymax], x_range: extent(data.times) },
bands: [{ x: data.times, lo: new Float64Array(data.rms.length), hi: data.rms, label: 'RMS power' }],
});
},
},
{
id: 'waveform',
displayName: 'Waveform',
buildSpec(data) {
return payload({
axes: { x_label: 'Time (seconds)', y_label: 'Amplitude', y_range: [-1.1, 1.1], x_range: extent(data.times) },
bands: [{ x: data.times, lo: data.lo, hi: data.hi, label: 'Waveform' }],
});
},
},
{
id: 'lufs',
displayName: 'LUFS',
buildSpec(data) {
const hlines = [{ y: -14.0, label: '-14 LUFS (streaming target)', style: 'dot' }];
const annotations = [];
if (Number.isFinite(data.integrated)) {
hlines.push({
y: data.integrated, label: `Integrated: ${data.integrated.toFixed(1)} LUFS`,
color: '#e76f51', style: 'dash', width: 1.5,
});
}
if (Number.isFinite(data.lra)) annotations.push(`LRA: ${data.lra.toFixed(1)} LU`);
return payload({
axes: { x_label: 'Time (seconds)', y_label: 'LUFS', y_range: [-50.0, 0.0], x_range: extent(data.times) },
curves: [{ x: data.times, y: data.lufs, label: 'Short-term (3 s)' }],
hlines,
annotations,
});
},
},
{
id: 'crest_factor',
displayName: 'Crest Factor',
buildSpec(data) {
return payload({
axes: { x_label: 'Time (seconds)', y_label: 'Crest factor (dB)', y_range: [0.0, 25.0], x_range: extent(data.times) },
curves: [{ x: data.times, y: data.crest_db, label: 'Crest factor (1 s)' }],
hlines: [
{ y: 12.0, label: '12 dB', style: 'dot' },
{ y: 6.0, label: '6 dB (squashed)', style: 'dot' },
],
});
},
},
{
id: 'psr',
displayName: 'PSR',
buildSpec(data) {
return payload({
axes: { x_label: 'Time (seconds)', y_label: 'PSR (LU)', y_range: [0.0, 25.0], x_range: extent(data.times) },
curves: [{ x: data.times, y: data.psr, label: 'PSR (3 s)' }],
hlines: [
{ y: 10.0, label: '10 LU (good punch)', style: 'dot' },
{ y: 4.0, label: '4 LU (squashed)', style: 'dot' },
],
});
},
},
{
id: 'true_peak',
displayName: 'True Peak',
buildSpec(data) {
const annotations = [];
if (Number.isFinite(data.integrated_tp_db)) annotations.push(`Max: ${data.integrated_tp_db.toFixed(2)} dBTP`);
return payload({
axes: { x_label: 'Time (seconds)', y_label: 'dBTP', y_range: [-30.0, 6.0], x_range: extent(data.times) },
curves: [{ x: data.times, y: data.tp_db, label: 'True Peak (250 ms)', width: 1.0 }],
hlines: [
{ y: 0.0, label: '0 dBTP (clip)', color: '#000000', style: 'dash', width: 1.0 },
{ y: -1.0, label: '-1 dBTP (typical ceiling)', style: 'dot' },
],
annotations,
});
},
},
{
id: 'spectrogram',
displayName: 'Spectrogram',
buildSpec(data, view) {
const yLog = view && view.yLog !== null && view.yLog !== undefined ? view.yLog : true;
return payload({
axes: {
x_label: 'Time (seconds)', y_label: 'Frequency (Hz)',
y_log: yLog, y_log_allowed: true,
y_range: [20.0, data.nyquist], x_range: extent(data.times),
},
heatmap: {
x: Array.from(data.times),
y: data.freqs,
z: data.s_db, // Float32Array, row-major rows x cols
rows: data.rows,
cols: data.cols,
z_min: -80.0,
z_max: 0.0,
cmap: 'magma',
label: 'Power (dB)',
// Rows are uniform in frequency: the renderer addresses them in Hz,
// which is what lets it draw a log axis without resampling.
y0: data.freqs.length ? data.freqs[0] : 0,
dy: data.freqs.length > 1 ? data.freqs[1] - data.freqs[0] : 1,
},
});
},
},
];
export const METRIC_BY_ID = Object.fromEntries(METRICS.map((m) => [m.id, m]));
export const DEFAULT_METRIC_ID = 'rms_power';
/**
* plotspec.apply_x_mode: rewrite a payload's x values to 0-100 % of the
* dataset's own span. A pure view transform — returns a new payload and leaves
* the cached one untouched.
*/
export function applyRelativeX(p) {
const xs = [...p.series.map((s) => s.x), ...p.segments.map((s) => s.x)];
if (p.heatmap) xs.push(p.heatmap.x);
const nonEmpty = xs.filter((x) => x.length);
if (!nonEmpty.length) return p;
const lo = Math.min(...nonEmpty.map((x) => x[0]));
const hi = Math.max(...nonEmpty.map((x) => x[x.length - 1]));
const span = hi - lo || 1.0;
const rel = (x) => x.map((v) => (v === null ? null : ((v - lo) / span) * 100.0));
return {
...p,
axes: { ...p.axes, x_label: 'Position (%)', x_range: [0.0, 100.0] },
series: p.series.map((s) => ({ ...s, x: rel(s.x) })),
segments: p.segments.map((s) => ({ ...s, x: rel(s.x) })),
heatmap: p.heatmap ? { ...p.heatmap, x: rel(p.heatmap.x) } : null,
};
}
+2
View File
File diff suppressed because one or more lines are too long
+1
View File
@@ -0,0 +1 @@
.uplot, .uplot *, .uplot *::before, .uplot *::after {box-sizing: border-box;}.uplot {font-family: system-ui, -apple-system, "Segoe UI", Roboto, "Helvetica Neue", Arial, "Noto Sans", sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji";line-height: 1.5;width: min-content;}.u-title {text-align: center;font-size: 18px;font-weight: bold;}.u-wrap {position: relative;user-select: none;}.u-over, .u-under {position: absolute;}.u-under {overflow: hidden;}.uplot canvas {display: block;position: relative;width: 100%;height: 100%;}.u-axis {position: absolute;}.u-legend {font-size: 14px;margin: auto;text-align: center;}.u-inline {display: block;}.u-inline * {display: inline-block;}.u-inline tr {margin-right: 16px;}.u-legend th {font-weight: 600;}.u-legend th > * {vertical-align: middle;display: inline-block;}.u-legend .u-marker {width: 1em;height: 1em;margin-right: 4px;background-clip: padding-box !important;}.u-inline.u-live th::after {content: ":";vertical-align: middle;}.u-inline:not(.u-live) .u-value {display: none;}.u-series > * {padding: 4px;}.u-series th {cursor: pointer;}.u-legend .u-off > * {opacity: 0.3;}.u-select {background: rgba(0,0,0,0.07);position: absolute;pointer-events: none;}.u-cursor-x, .u-cursor-y {position: absolute;left: 0;top: 0;pointer-events: none;will-change: transform;}.u-hz .u-cursor-x, .u-vt .u-cursor-y {height: 100%;border-right: 1px dashed #607D8B;}.u-hz .u-cursor-y, .u-vt .u-cursor-x {width: 100%;border-bottom: 1px dashed #607D8B;}.u-cursor-pt {position: absolute;top: 0;left: 0;border-radius: 50%;border: 0 solid;pointer-events: none;will-change: transform;/*this has to be !important since we set inline "background" shorthand */background-clip: padding-box !important;}.u-axis.u-off, .u-select.u-off, .u-cursor-x.u-off, .u-cursor-y.u-off, .u-cursor-pt.u-off {display: none;}