5df802a574
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.
206 lines
6.4 KiB
JavaScript
206 lines
6.4 KiB
JavaScript
// 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',
|
|
});
|
|
}
|