// 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', }); }