mirror of
https://github.com/storytold/effectcraft.git
synced 2026-10-25 10:36:56 -06:00
- Persistence: settings, shortcuts, recent projects, imported media, saved projects and auto-saves live in OPFS (IndexedDB fallback) through an in-memory mirror flushed in the background (apps/effectcraft-web/src/store.rs, persist.rs); ConfigStore gains FileOps so auto-save / recovery work in the browser store; the session snapshot restores the last project + editor state on reload; File > Open Recent works from storage. - Audio: Web Audio output (AudioWorklet, ScriptProcessor fallback) fed by AudioPlayback::pump on wasm32; A/V sync from AudioContext.currentTime; meters; unlock on first gesture. - Background jobs: engine::offload (WorkerRequest/WorkerReply protocol, Offload trait, run_request); Render Queue, tracker, mask tracker, Warp Stabilizer and Roto Freeze run in Web Workers (a second engine instance on the shared compiled module), progress streamed back; cancel terminates the worker. - GPU: WebGPU viewer frames drawn straight from the compositor's texture (gpu_display on wasm32 when the viewer draws plainly); async readback (Gpu::read_display_async) for pixels. - PWA: manifest, service worker precaching the build (xtask fills list/version), icons copied from assets/app-icon; COOP/COEP kept from cached responses; offline reload works. - window.effectcraft: saveToBrowser, listStored, removeStored, flush; info() reports storage, audio, gpu, workers. - Tests: native store + protocol + offload (render, track, cancel) tests; smoke.mjs extended (GPU path, worker render responsiveness, audio gesture + playback, reload persistence, Open Recent, offline reload). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
110 lines
3.6 KiB
Rust
110 lines
3.6 KiB
Rust
//! Audio preview through Web Audio (`js/host.js`): an AudioWorklet (ScriptProcessorNode where
|
||
//! worklets are unavailable) plays the blocks the UI mixes ahead
|
||
//! ([`effectcraft_ui_egui::audio::AudioPlayback::pump`]).
|
||
//!
|
||
//! Every UI frame [`WebAudioOut::pump`] moves samples from the feed to the output so it stays
|
||
//! ~150 ms ahead of what has played. A/V sync follows `AudioContext.currentTime`: frames played
|
||
//! = (currentTime − start) × rate, so the device latency the playback clock subtracts is
|
||
//! "handed over but not played yet" plus the context's output latency.
|
||
//!
|
||
//! Browsers keep the AudioContext suspended until the user interacts with the page; the host
|
||
//! resumes it on the first pointer or key event.
|
||
|
||
use std::sync::Arc;
|
||
|
||
use effectcraft_ui_egui::audio::{AudioDevice, AudioFeed};
|
||
use wasm_bindgen::prelude::*;
|
||
|
||
#[wasm_bindgen(module = "/js/host.js")]
|
||
extern "C" {
|
||
#[wasm_bindgen(js_name = audioInstallUnlock)]
|
||
pub fn install_unlock();
|
||
#[wasm_bindgen(js_name = audioState)]
|
||
pub fn state() -> JsValue;
|
||
#[wasm_bindgen(js_name = audioSampleRate)]
|
||
fn sample_rate() -> f64;
|
||
#[wasm_bindgen(js_name = audioStart)]
|
||
fn audio_start(worklet_url: &str) -> js_sys::Promise;
|
||
#[wasm_bindgen(js_name = audioPush)]
|
||
fn audio_push(samples: js_sys::Float32Array);
|
||
#[wasm_bindgen(js_name = audioPlayed)]
|
||
fn audio_played() -> f64;
|
||
#[wasm_bindgen(js_name = audioOutputLatency)]
|
||
fn audio_output_latency() -> f64;
|
||
#[wasm_bindgen(js_name = audioStop)]
|
||
fn audio_stop();
|
||
}
|
||
|
||
/// How far ahead of the playhead the output is kept fed.
|
||
const LEAD_SECONDS: f64 = 0.15;
|
||
|
||
pub struct WebAudioOut {
|
||
rate: u32,
|
||
feed: Option<Arc<AudioFeed>>,
|
||
/// Sample frames handed to the output since start.
|
||
posted: u64,
|
||
buf: Vec<f32>,
|
||
}
|
||
|
||
/// The Settings ▸ Audio factory: Web Audio's default output (`None` without Web Audio).
|
||
pub fn open(_: &effectcraft_ui_egui::audio::AudioOutput) -> Option<Box<dyn AudioDevice>> {
|
||
let rate = sample_rate();
|
||
(rate > 0.0).then(|| Box::new(WebAudioOut { rate: rate as u32, feed: None, posted: 0, buf: Vec::new() }) as Box<dyn AudioDevice>)
|
||
}
|
||
|
||
fn worklet_url() -> String {
|
||
crate::page_url("audio-worklet.js")
|
||
}
|
||
|
||
impl AudioDevice for WebAudioOut {
|
||
fn sample_rate(&self) -> u32 {
|
||
self.rate
|
||
}
|
||
|
||
fn start(&mut self, feed: Arc<AudioFeed>) -> Result<(), String> {
|
||
self.feed = Some(feed);
|
||
self.posted = 0;
|
||
let p = audio_start(&worklet_url());
|
||
wasm_bindgen_futures::spawn_local(async move {
|
||
if let Err(e) = wasm_bindgen_futures::JsFuture::from(p).await {
|
||
log::warn!("audio output: {e:?}");
|
||
}
|
||
});
|
||
Ok(())
|
||
}
|
||
|
||
fn stop(&mut self) {
|
||
self.feed = None;
|
||
audio_stop();
|
||
}
|
||
|
||
fn latency_frames(&self) -> u64 {
|
||
let played = audio_played() as u64;
|
||
self.posted.saturating_sub(played) + audio_output_latency() as u64
|
||
}
|
||
|
||
fn pump(&mut self) {
|
||
let Some(feed) = &self.feed else { return };
|
||
let played = audio_played() as u64;
|
||
let lead = (LEAD_SECONDS * self.rate as f64) as u64;
|
||
let want = (played + lead).saturating_sub(self.posted) as usize;
|
||
// Only real samples: the output plays silence by itself when it runs dry.
|
||
let n = want.min(feed.queued_frames());
|
||
if n == 0 {
|
||
return;
|
||
}
|
||
self.buf.resize(n * 2, 0.0);
|
||
feed.pull(&mut self.buf);
|
||
audio_push(js_sys::Float32Array::from(&self.buf[..]));
|
||
self.posted += n as u64;
|
||
}
|
||
}
|
||
|
||
impl Drop for WebAudioOut {
|
||
fn drop(&mut self) {
|
||
if self.feed.is_some() {
|
||
audio_stop();
|
||
}
|
||
}
|
||
}
|