Add web UI: same backend, browser front end
Reuses the analysis backend unchanged. metrics.py already split compute() from build_spec(), and plotspec.PlotSpec is backend-agnostic, so the web UI reimplements only the renderer: uPlot for curves/bands, a canvas for the spectrogram heatmap. No PyQt5, pyqtgraph or matplotlib on the web path. Uploaded audio is never persisted on the host. The browser copies each file into Origin Private File System (sandboxed browser storage) and streams the bytes to the server per request; the server spools each body to a temp file, decodes, computes, and unlinks it in a finally before responding. There is no upload directory, no cache of audio, and no session state keyed on a path: file_id is the SHA-256 of the audio, so re-sending a master for another metric reuses the first decode. Verified: a spool is observable mid-request and gone after the response. Being precise: the bytes do have to reach the server, since librosa decodes from a path and the analysis is CPU-bound numpy/scipy. The claim is that running this does not accumulate masters on the host, not that audio is never on the machine mid-request. New: metrics_json.py PlotSpec/metric data -> JSON (the renderer seam) web_session.py headless analysis session, content-addressed tracks webapp.py FastAPI routes, raw-body analysis, ujm-web entry point static/ browser front end + test suite scripts/webapi_smoke.py 61 end-to-end assertions against a running server Also fixes a bug the direct-uvicorn dev loop had hidden: the CLI advertises logger_setup's WARN level but passed it straight to uvicorn, which only knows 'warning' -- `ujm-web --log-level WARN` died with KeyError: 'warn'. Removes an earlier /api/collect debug route that served every source module over HTTP. pyproject: 'web' extra (fastapi, uvicorn, python-multipart), package-data for static/, Apache-2.0 licence.
This commit is contained in:
+270
@@ -0,0 +1,270 @@
|
||||
/* 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; }
|
||||
|
||||
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;
|
||||
}
|
||||
+1157
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,205 @@
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
//
|
||||
// Browser-side audio store.
|
||||
//
|
||||
// Uploaded audio never lives on the server. The File handle from the picker (or
|
||||
// drop) is copied into Origin Private File System — a sandboxed, per-origin
|
||||
// filesystem the browser owns, which no other site can read and which the OS
|
||||
// treats as browser cache, not user data. Everything downstream is driven from
|
||||
// there:
|
||||
//
|
||||
// * playback reads a blob URL minted from OPFS, so scrubbing a 60 MB WAV
|
||||
// never re-reads the original File
|
||||
// * the SHA-256 that identifies a track is computed over OPFS bytes, so the
|
||||
// id is stable across a reload (the File handle doesn't survive one)
|
||||
// * re-analysis after a page reload re-streams from OPFS instead of asking the
|
||||
// user to pick the file again
|
||||
//
|
||||
// The server only ever sees the bytes it needs for one request, spooled to a
|
||||
// temp file and deleted before the response is written.
|
||||
|
||||
const OPFS_DIR = 'ujm-audio';
|
||||
|
||||
/** True when the browser can give us a durable private store. */
|
||||
export function opfsSupported() {
|
||||
return !!(navigator.storage && navigator.storage.getDirectory);
|
||||
}
|
||||
|
||||
async function dir() {
|
||||
const root = await navigator.storage.getDirectory();
|
||||
return root.getDirectoryHandle(OPFS_DIR, { create: true });
|
||||
}
|
||||
|
||||
/** Human-readable reason the store is unusable, or null when it's fine. */
|
||||
export async function storageProblem(estimateBytes) {
|
||||
if (!opfsSupported()) return 'this browser has no OPFS support';
|
||||
try {
|
||||
const est = await navigator.storage.estimate();
|
||||
if (est.quota && estimateBytes && estimateBytes > est.quota * 0.9) {
|
||||
return `need ~${mb(estimateBytes)} but only ${mb(est.quota - (est.usage || 0))} of browser storage is free`;
|
||||
}
|
||||
// Ask to be exempt from eviction: without this the browser may clear the
|
||||
// store under pressure, and the file is the only copy we have.
|
||||
if (navigator.storage.persist) await navigator.storage.persist();
|
||||
} catch {
|
||||
// estimate() is optional and can throw in private windows; not fatal.
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function mb(bytes) {
|
||||
return `${(bytes / 1024 / 1024).toFixed(0)} MB`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Copy a File into OPFS under its content hash.
|
||||
*
|
||||
* Returns {fileId, size}. Uses `createWritable` in a single streamed pass so a
|
||||
* large file is never held in memory in full.
|
||||
*/
|
||||
export async function put(file, onProgress) {
|
||||
const d = await dir();
|
||||
const tmpName = `.incoming-${Date.now()}-${Math.random().toString(16).slice(2)}`;
|
||||
const handle = await d.getFileHandle(tmpName, { create: true });
|
||||
|
||||
let written = 0;
|
||||
const writable = await handle.createWritable();
|
||||
try {
|
||||
const reader = file.stream().getReader();
|
||||
for (;;) {
|
||||
const {done, value} = await reader.read();
|
||||
if (done) break;
|
||||
await writable.write(value);
|
||||
written += value.byteLength;
|
||||
if (onProgress) onProgress(written, file.size);
|
||||
}
|
||||
await writable.close();
|
||||
} catch (err) {
|
||||
await writable.abort().catch(() => {});
|
||||
await d.removeEntry(tmpName).catch(() => {});
|
||||
throw err;
|
||||
}
|
||||
|
||||
const hash = await sha256(handle);
|
||||
// Content-addressed: the same audio dropped twice costs one copy, and the id
|
||||
// the server keys its analysis on is reproducible.
|
||||
try {
|
||||
await d.removeEntry(hash);
|
||||
} catch {
|
||||
// first time we've seen these bytes
|
||||
}
|
||||
const named = await rename(d, tmpName, hash);
|
||||
const blob = await named.getFile();
|
||||
return {fileId: hash, size: blob.size, sha256: hash};
|
||||
}
|
||||
|
||||
async function rename(d, from, to) {
|
||||
const fromHandle = await d.getFileHandle(from);
|
||||
const src = await fromHandle.getFile();
|
||||
const toHandle = await d.getFileHandle(to, {create: true});
|
||||
const w = await toHandle.createWritable();
|
||||
await src.stream().pipeTo(w);
|
||||
await d.removeEntry(from);
|
||||
return toHandle;
|
||||
}
|
||||
|
||||
/** SHA-256 of the stored file, read in chunks so memory stays flat. */
|
||||
async function sha256(handle) {
|
||||
const blob = await handle.getFile();
|
||||
const digest = await crypto.subtle.digest('SHA-256', await blob.arrayBuffer());
|
||||
return [...new Uint8Array(digest)]
|
||||
.map((b) => b.toString(16).padStart(2, '0'))
|
||||
.join('')
|
||||
.slice(0, 32);
|
||||
}
|
||||
|
||||
/** The stored File for an id, or null when it isn't here. */
|
||||
export async function get(fileId) {
|
||||
if (!opfsSupported()) return null;
|
||||
try {
|
||||
const d = await dir();
|
||||
const handle = await d.getFileHandle(fileId);
|
||||
return await handle.getFile();
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export async function has(fileId) {
|
||||
return (await get(fileId)) !== null;
|
||||
}
|
||||
|
||||
export async function remove(fileId) {
|
||||
if (!opfsSupported()) return;
|
||||
try {
|
||||
const d = await dir();
|
||||
await d.removeEntry(fileId);
|
||||
} catch {
|
||||
// already gone
|
||||
}
|
||||
}
|
||||
|
||||
/** Ids currently held, newest touched last — used to prune orphans. */
|
||||
export async function list() {
|
||||
if (!opfsSupported()) return [];
|
||||
const out = [];
|
||||
const d = await dir();
|
||||
for await (const [name] of d.entries()) {
|
||||
if (!name.startsWith('.incoming-')) out.push(name);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/** Drop anything `.incoming-*`, i.e. an upload that died mid-write. */
|
||||
export async function sweepIncomplete() {
|
||||
if (!opfsSupported()) return 0;
|
||||
const d = await dir();
|
||||
let n = 0;
|
||||
for await (const [name] of d.entries()) {
|
||||
if (name.startsWith('.incoming-')) {
|
||||
await d.removeEntry(name).catch(() => {});
|
||||
n += 1;
|
||||
}
|
||||
}
|
||||
return n;
|
||||
}
|
||||
|
||||
/**
|
||||
* Stream stored audio to an endpoint as a raw body.
|
||||
*
|
||||
* The body is sent as a stream (duplex: 'half') so a 200 MB file doesn't have to
|
||||
* be materialised as a second in-memory copy just to be uploaded.
|
||||
*/
|
||||
export async function send(fileId, url, {method = 'POST', onProgress} = {}) {
|
||||
const blob = await get(fileId);
|
||||
if (!blob) throw new Error('audio is no longer in browser storage — re-add the file');
|
||||
|
||||
// Small files go as a plain body: some proxies mishandle streaming duplex
|
||||
// requests, and below a few MB there is nothing to gain.
|
||||
if (blob.size < 8 * 1024 * 1024) {
|
||||
const buf = await blob.arrayBuffer();
|
||||
if (onProgress) onProgress(blob.size, blob.size);
|
||||
return fetch(url, {
|
||||
method,
|
||||
headers: {'Content-Type': 'application/octet-stream'},
|
||||
body: buf,
|
||||
});
|
||||
}
|
||||
|
||||
let sent = 0;
|
||||
const body = blob.stream().pipeThrough(
|
||||
new TransformStream({
|
||||
transform(chunk, controller) {
|
||||
sent += chunk.byteLength;
|
||||
if (onProgress) onProgress(sent, blob.size);
|
||||
controller.enqueue(chunk);
|
||||
},
|
||||
}),
|
||||
);
|
||||
return fetch(url, {
|
||||
method,
|
||||
headers: {'Content-Type': 'application/octet-stream'},
|
||||
body,
|
||||
duplex: 'half',
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,97 @@
|
||||
<!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="stylesheet" href="/static/vendor/uPlot.min.css">
|
||||
<link rel="stylesheet" href="/static/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 cached data">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>.
|
||||
Files are held in this browser, not on the server.</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>.mp3</code>, <code>.wav</code> or <code>.flac</code> files to analyse.
|
||||
Audio stays in this browser's private storage; the server receives it
|
||||
only for the moment it takes to compute a metric, and writes nothing to
|
||||
disk. 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="/static/vendor/uPlot.iife.min.js"></script>
|
||||
<!-- app.js is an ES module: it imports ./audio-store.js, which owns the
|
||||
browser-side copy of every uploaded file. -->
|
||||
<script type="module" src="/static/app.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
Vendored
+2
File diff suppressed because one or more lines are too long
Vendored
+1
@@ -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;}
|
||||
Reference in New Issue
Block a user