effectcraft/apps/effectcraft-web/js/host.js

336 lines
12 KiB
JavaScript

// Browser services for the EffectCraft web app (imported by the wasm module through
// wasm-bindgen; see src/persist.rs, src/audio.rs and src/worker.rs):
//
// - storage: the Origin Private File System, or IndexedDB where OPFS can't write;
// - audio: a Web Audio output (AudioWorklet, ScriptProcessorNode fallback) fed stereo blocks;
// - workers: a pool of Web Workers, each running its own instance of the engine.
//
// Plain functions only: the Rust side owns the logic.
// ------------------------------------------------------------------ storage
const DIR = "effectcraft";
const DB = "effectcraft";
const STORE = "entries";
let backend = null;
// OPFS file names can't hold "/": keys are percent-encoded.
const enc = (k) => encodeURIComponent(k);
const dec = (n) => decodeURIComponent(n);
async function opfs() {
if (!navigator.storage || !navigator.storage.getDirectory) throw new Error("no OPFS");
const root = await navigator.storage.getDirectory();
const dir = await root.getDirectoryHandle(DIR, { create: true });
// Probe a write: some browsers expose OPFS but only write it from workers.
const probe = await dir.getFileHandle(".probe", { create: true });
if (!probe.createWritable) throw new Error("OPFS is read-only on this thread");
const w = await probe.createWritable();
await w.write(new Uint8Array([1]));
await w.close();
await dir.removeEntry(".probe");
return {
name: "opfs",
async loadAll() {
const out = [];
for await (const [name, h] of dir.entries()) {
if (h.kind !== "file" || name.startsWith(".") || name.endsWith(".crswap")) continue;
const f = await h.getFile();
out.push({ key: dec(name), data: new Uint8Array(await f.arrayBuffer()), modified: f.lastModified });
}
return out;
},
async put(key, data) {
// createWritable writes a swap file and replaces the entry on close: atomic.
const h = await dir.getFileHandle(enc(key), { create: true });
const w = await h.createWritable();
await w.write(data);
await w.close();
},
async del(key) {
try { await dir.removeEntry(enc(key)); } catch (e) { if (e.name !== "NotFoundError") throw e; }
},
};
}
function req(r) {
return new Promise((res, rej) => { r.onsuccess = () => res(r.result); r.onerror = () => rej(r.error); });
}
async function idb() {
if (!self.indexedDB) throw new Error("no IndexedDB");
const open = indexedDB.open(DB, 1);
open.onupgradeneeded = () => open.result.createObjectStore(STORE, { keyPath: "key" });
const db = await req(open);
const tx = (mode) => db.transaction(STORE, mode).objectStore(STORE);
return {
name: "indexeddb",
async loadAll() {
const all = await req(tx("readonly").getAll());
return all.map((r) => ({ key: r.key, data: new Uint8Array(r.data), modified: r.modified }));
},
async put(key, data, modified) {
await req(tx("readwrite").put({ key, data: data.slice().buffer, modified }));
},
async del(key) {
await req(tx("readwrite").delete(key));
},
};
}
function memory() {
const m = new Map();
return {
name: "memory",
async loadAll() { return [...m.values()]; },
async put(key, data, modified) { m.set(key, { key, data: data.slice(), modified }); },
async del(key) { m.delete(key); },
};
}
/// Open the store: OPFS, else IndexedDB, else memory (nothing persists). Resolves with the
/// backend's name. `prefer` ("opfs" | "indexeddb" | "memory") forces one (tests).
export async function storeOpen(prefer) {
const order = { opfs: [opfs, idb, memory], indexeddb: [idb, memory], memory: [memory] }[prefer] ?? [opfs, idb, memory];
for (const make of order) {
try { backend = await make(); break; } catch (e) { console.info(`storage: ${make.name}: ${e.message ?? e}`); }
}
// Ask the browser not to evict the store under storage pressure (granted for installed apps).
try { if (navigator.storage && navigator.storage.persist) await navigator.storage.persist(); } catch {}
return backend.name;
}
/// Every stored entry: [{key, data: Uint8Array, modified}].
export async function storeLoadAll() { return backend.loadAll(); }
export async function storePut(key, data, modified) { return backend.put(key, data, modified); }
export async function storeDelete(key) { return backend.del(key); }
/// {usage, quota, persisted, backend}.
export async function storeEstimate() {
const out = { backend: backend ? backend.name : null };
try {
const e = await navigator.storage.estimate();
out.usage = e.usage; out.quota = e.quota;
out.persisted = await navigator.storage.persisted();
} catch {}
return out;
}
// ------------------------------------------------------------------ audio
// One AudioContext for the page. Browsers start it suspended until the user interacts with the
// page, so the first pointer/key event resumes it ("unlock").
let ctx = null;
let node = null;
let kind = null;
// Sample frames handed to the output since `audioStart`, and the context time it started at.
let posted = 0;
let startTime = 0;
let underruns = 0;
// ScriptProcessor fallback: the queue lives on this thread.
let spQueue = [];
let spOffset = 0;
// Blocks pushed while the output node is being created.
let starting = false;
let pendingPush = [];
// Bumped by every start/stop: a start superseded while it awaited the worklet gives up.
let generation = 0;
function context() {
if (!ctx) {
const AC = self.AudioContext || self.webkitAudioContext;
if (!AC) return null;
ctx = new AC({ latencyHint: "interactive" });
}
return ctx;
}
function unlock() {
const c = context();
if (c && c.state !== "running") c.resume().catch(() => {});
}
/// Install the user-gesture unlock (called once at startup).
export function audioInstallUnlock() {
for (const ev of ["pointerdown", "keydown", "touchend"]) {
self.addEventListener(ev, unlock, { capture: true, passive: true });
}
}
/// {state, sampleRate, backend, posted, played, underruns, baseLatency, outputLatency}.
export function audioState() {
const c = ctx;
return {
state: c ? c.state : "none",
sampleRate: c ? c.sampleRate : 0,
backend: kind,
posted,
played: audioPlayed(),
underruns,
baseLatency: c ? c.baseLatency || 0 : 0,
outputLatency: c ? c.outputLatency || 0 : 0,
};
}
/// The output's sample rate (creates the context; 0 without Web Audio).
export function audioSampleRate() {
const c = context();
return c ? c.sampleRate : 0;
}
/// Start an output node; resolves when it is ready. Samples pushed before then queue up.
export async function audioStart(workletUrl) {
audioStop();
const c = context();
if (!c) throw new Error("Web Audio is not available");
unlock();
posted = 0;
underruns = 0;
spQueue = [];
spOffset = 0;
starting = true;
pendingPush = [];
const gen = ++generation;
if (c.audioWorklet && workletUrl) {
try {
if (!c.__ecWorklet) {
await c.audioWorklet.addModule(workletUrl);
c.__ecWorklet = true;
}
if (gen !== generation) return;
node = new AudioWorkletNode(c, "effectcraft-output", { numberOfInputs: 0, outputChannelCount: [2] });
node.port.onmessage = (e) => { if (e.data && e.data.underrun) underruns += e.data.underrun; };
kind = "worklet";
} catch (e) {
console.warn("audio: AudioWorklet unavailable, using ScriptProcessorNode", e);
node = null;
}
}
if (!node) {
node = c.createScriptProcessor(2048, 0, 2);
node.onaudioprocess = (e) => {
const l = e.outputBuffer.getChannelData(0);
const r = e.outputBuffer.getChannelData(1);
let i = 0;
while (i < l.length && spQueue.length) {
const b = spQueue[0];
const n = Math.min(l.length - i, (b.length - spOffset) >> 1);
for (let k = 0; k < n; k++) { l[i + k] = b[spOffset + 2 * k]; r[i + k] = b[spOffset + 2 * k + 1]; }
i += n; spOffset += 2 * n;
if (spOffset >= b.length) { spQueue.shift(); spOffset = 0; }
}
if (i < l.length) { underruns += l.length - i; l.fill(0, i); r.fill(0, i); }
};
kind = "scriptProcessor";
}
starting = false;
if (!node) return;
node.connect(c.destination);
startTime = c.currentTime;
const p = pendingPush;
pendingPush = [];
for (const b of p) send(b);
}
function send(samples) {
if (kind === "worklet") node.port.postMessage(samples, [samples.buffer]);
else spQueue.push(samples);
}
/// Queue interleaved stereo samples (Float32Array) for output.
export function audioPush(samples) {
posted += samples.length >> 1;
if (node && !starting) send(samples);
else if (starting) pendingPush.push(samples);
}
/// Sample frames played since `audioStart`, by the context clock (AudioContext.currentTime).
export function audioPlayed() {
if (!ctx || !node || starting) return 0;
return Math.max(0, Math.round((ctx.currentTime - startTime) * ctx.sampleRate));
}
/// Output latency in sample frames (what's handed over but not audible yet comes on top).
export function audioOutputLatency() {
if (!ctx) return 0;
return Math.round(((ctx.outputLatency || 0) + (ctx.baseLatency || 0)) * ctx.sampleRate);
}
export function audioStop() {
generation++;
starting = false;
pendingPush = [];
if (node) {
try { node.disconnect(); } catch {}
if (kind === "worklet") node.port.postMessage("stop");
}
node = null;
spQueue = [];
}
// ------------------------------------------------------------------ workers
// Idle workers are reused; each keeps the files it was sent (path → size) so a file goes to a
// worker once.
const idle = [];
const running = new Map(); // job id → {worker, done}
function spawn(base) {
const worker = new Worker(new URL("worker.js", base), { type: "module", name: "effectcraft-worker" });
const w = { worker, files: new Map(), ready: null };
w.ready = new Promise((res, rej) => {
worker.onmessage = (e) => { if (e.data && e.data.type === "ready") res(); };
worker.onerror = (e) => rej(new Error(e.message || "worker failed to start"));
});
worker.postMessage({ type: "init", module: self.effectcraftModule ?? null, base });
return w;
}
/// Run a job in a worker. `files`: [[path, Uint8Array]] the job reads; `onMessage(type, json,
/// path, bytes)` receives "reply" (json), "file" (path, bytes) and "error" (json = message).
export function workerRun(id, json, files, base, onMessage) {
const w = idle.pop() ?? spawn(base);
running.set(id, w);
w.ready.then(() => {
w.worker.onmessage = (e) => {
const m = e.data;
if (m.type === "reply") {
onMessage("reply", m.json, null, null);
if (m.done) {
running.delete(id);
idle.push(w);
}
} else if (m.type === "file") {
onMessage("file", null, m.path, new Uint8Array(m.bytes));
}
};
w.worker.onerror = (e) => {
running.delete(id);
onMessage("error", String(e.message || "worker error"), null, null);
w.worker.terminate();
};
for (const [path, bytes] of files) {
if (w.files.get(path) === bytes.length) continue;
w.files.set(path, bytes.length);
w.worker.postMessage({ type: "file", path, bytes });
}
w.worker.postMessage({ type: "job", json });
}, (e) => {
running.delete(id);
onMessage("error", String(e.message || e), null, null);
});
}
/// Stop a job: its worker is terminated (it can't be interrupted mid-job).
export function workerCancel(id) {
const w = running.get(id);
if (!w) return;
running.delete(id);
w.worker.terminate();
}
/// {running, idle}.
export function workerStats() {
return { running: running.size, idle: idle.length };
}