Files
effectcraft/apps/effectcraft-web/src/audio.rs
T
Brandon ThomasandClaude Opus 5.5 0bee1ab2da M15.2: web app depth (WEB-1): browser storage, Web Audio, worker renders, WebGPU viewer, PWA
- 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>
2026-10-02 23:42:00 -04:00

110 lines
3.6 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! 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();
}
}
}