Files
uj-mastering-master/static/audio-store.js
T

206 lines
6.4 KiB
JavaScript
Raw Normal View History

2026-09-14 01:44:48 +09:00
// 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',
});
}