Files
uj-mastering-master/static/audio-store.js
T
mikkeli b5c12bfcb4 Send stored audio as a Blob body so large files upload on HTTP/1.1
Files over 8 MB were sent as a ReadableStream with duplex: 'half'. Chrome
only allows streaming request bodies over HTTP/2+, so every such upload
failed with "Failed to fetch" on HTTP/1.1 (e.g. http://localhost). A Blob
body is read lazily from OPFS by the browser, so it keeps the "never hold
the whole master in memory" property without depending on the protocol.

Verified in headless Chromium: a 33 MB WAV drops and analyses both at
http://localhost:8050 and behind Cloudflare/Caddy at a /uj/ prefix.

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

188 lines
6.0 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;
}
/**
* Send stored audio to an endpoint as a raw body.
*
* The OPFS File is passed to fetch as-is: the browser reads a Blob body lazily
* from its backing store, so a 200 MB master is never materialised in memory.
* (A ReadableStream body with duplex: 'half' was used before, but Chrome only
* allows streaming uploads over HTTP/2+, so any file past the old 8 MB cutoff
* failed with "Failed to fetch" on HTTP/1.1 — e.g. http://localhost.)
*/
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');
const res = await fetch(url, {
method,
headers: {'Content-Type': 'application/octet-stream'},
body: blob,
});
if (onProgress) onProgress(blob.size, blob.size);
return res;
}