1use crate::{
2 auto_resume::setup_autoresume, error::JsContext, instant::Instant,
3 wasm_processor::ProcessorHost,
4};
5use firewheel::{
6 StreamInfo,
7 backend::{AudioBackend, DeviceInfoSimple},
8 collector::ArcGc,
9 processor::FirewheelProcessor,
10};
11use std::{
12 cell::RefCell,
13 num::NonZeroU32,
14 rc::Rc,
15 sync::{atomic::AtomicBool, mpsc},
16 time::Duration,
17};
18use wasm_bindgen::{JsCast, JsValue, prelude::wasm_bindgen};
19use web_sys::{AudioContext, AudioContextOptions, AudioWorkletNode};
20
21pub struct WebAudioBackend {
35 processor: mpsc::Sender<FirewheelProcessor<Self>>,
36 is_dropped: Rc<AtomicBool>,
37 alive: ArcGc<AtomicBool>,
38 web_context: AudioContext,
39 processor_node: Rc<RefCell<Option<AudioWorkletNode>>>,
40}
41
42impl Drop for WebAudioBackend {
43 fn drop(&mut self) {
44 self.alive
45 .store(false, std::sync::atomic::Ordering::Relaxed);
46
47 if let Some(node) = self.processor_node.borrow().as_ref() {
48 if let Err(e) = node.disconnect() {
49 log::error!("Failed to disconnect `AudioWorkletNode`: {e:?}");
50 }
51 }
52
53 if let Err(e) = self.web_context.close() {
54 log::error!("Failed to close `AudioContext`: {e:?}");
55 }
56 }
57}
58
59impl core::fmt::Debug for WebAudioBackend {
60 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
61 f.debug_struct("WasmBackend")
62 .field("is_dropped", &self.is_dropped)
63 .field("alive", &self.alive)
64 .field("web_context", &self.web_context)
65 .finish_non_exhaustive()
66 }
67}
68
69#[derive(Debug)]
71pub enum WebAudioStartError {
72 Initialization(String),
74 WorkletCreation(String),
76}
77
78impl core::fmt::Display for WebAudioStartError {
79 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
80 match self {
81 Self::Initialization(e) => {
82 write!(f, "Failed to initialize Web Audio API object: {e}")
83 }
84 Self::WorkletCreation(e) => {
85 write!(f, "Failed to create the backend audio worklet: {e}")
86 }
87 }
88 }
89}
90
91impl std::error::Error for WebAudioStartError {}
92
93#[derive(Debug)]
95pub enum WebAudioStreamError {
96 UnexpectedDrop,
98}
99
100impl core::fmt::Display for WebAudioStreamError {
101 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
102 match self {
103 Self::UnexpectedDrop => {
104 write!(f, "The `AudioWorkletNode` was unexpectedly dropped")
105 }
106 }
107 }
108}
109
110impl std::error::Error for WebAudioStreamError {}
111
112#[derive(Debug, Default, Clone)]
114pub struct WebAudioConfig {
115 pub sample_rate: Option<NonZeroU32>,
120
121 pub request_input: bool,
130}
131
132#[wasm_bindgen]
136extern "C" {
137 #[wasm_bindgen(js_name = AudioTimestamp)]
138 pub type AudioTimestamp;
139
140 #[wasm_bindgen(method, getter, js_name = contextTime)]
141 pub fn context_time(this: &AudioTimestamp) -> f64;
142
143 #[wasm_bindgen(method, getter, js_name = performanceTime)]
144 pub fn performance_time(this: &AudioTimestamp) -> f64;
145}
146
147#[wasm_bindgen]
148extern "C" {
149 type AudioContextExt;
150
151 #[wasm_bindgen(method, js_name = getOutputTimestamp)]
152 fn get_output_timestamp(this: &AudioContextExt) -> AudioTimestamp;
153}
154
155fn get_output_timestamp(ctx: &AudioContext) -> AudioTimestamp {
156 let ext: AudioContextExt = ctx.clone().unchecked_into();
157 ext.get_output_timestamp()
158}
159
160impl AudioBackend for WebAudioBackend {
161 type Enumerator = ();
162 type Instant = Instant;
163 type Config = WebAudioConfig;
164 type StartStreamError = WebAudioStartError;
165 type StreamError = WebAudioStreamError;
166
167 fn delay_from_last_process(&self, _: Self::Instant) -> Option<Duration> {
168 let timestamp = get_output_timestamp(&self.web_context);
169 let performance_time = timestamp.performance_time();
170 let now = web_sys::window()?.performance()?.now();
171
172 Some(Duration::from_secs_f64(
173 (now - performance_time).max(0.0) / 1000.0,
174 ))
175 }
176
177 fn enumerator() -> Self::Enumerator {}
178
179 fn input_devices_simple(&mut self) -> Vec<firewheel::backend::DeviceInfoSimple> {
180 vec![DeviceInfoSimple {
181 name: "default input".into(),
182 id: "default input".into(),
183 }]
184 }
185
186 fn output_devices_simple(&mut self) -> Vec<DeviceInfoSimple> {
187 vec![DeviceInfoSimple {
188 name: "default input".into(),
189 id: "default input".into(),
190 }]
191 }
192
193 fn start_stream(config: Self::Config) -> Result<(Self, StreamInfo), Self::StartStreamError> {
194 let (sender, receiver) = mpsc::channel();
195
196 let context = match config.sample_rate {
197 Some(sample_rate) => {
198 let options = AudioContextOptions::new();
199 options.set_sample_rate(sample_rate.get() as f32);
200 web_sys::AudioContext::new_with_context_options(&options)
201 .map_err(|e| WebAudioStartError::Initialization(format!("{e:?}")))?
202 }
203 None => web_sys::AudioContext::new()
204 .map_err(|e| WebAudioStartError::Initialization(format!("{e:?}")))?,
205 };
206
207 let _ = context.suspend();
208
209 let sample_rate = context.sample_rate();
210 let inputs = if config.request_input { 2 } else { 0 };
211 let outputs = 2;
212
213 let alive = ArcGc::new(AtomicBool::new(true));
214 let processor_node = Rc::new(RefCell::new(None));
215 let is_dropped = Rc::new(AtomicBool::new(false));
216
217 wasm_bindgen_futures::spawn_local({
218 let context = context.clone();
219 let processor_node = processor_node.clone();
220 let alive = alive.clone();
221 let is_dropped = is_dropped.clone();
222 async move {
223 let result = prepare_context(
224 context.clone(),
225 inputs,
226 outputs,
227 receiver,
228 alive,
229 is_dropped,
230 processor_node,
231 )
232 .await;
233
234 match result {
235 Ok(firewheel_worklet) if inputs > 0 => {
236 let result = crate::auto_resume::setup_autoresume(
237 context.clone(),
238 move || {
239 let window = web_sys::window().expect("Window should be available");
241 let navigator = window.navigator();
242 let media_devices = navigator
243 .media_devices()
244 .expect("`mediaDevices` should be available");
245
246 let constraints = web_sys::MediaStreamConstraints::new();
247 constraints.set_audio(&JsValue::TRUE);
248
249 let get_user_media_promise = media_devices
250 .get_user_media_with_constraints(&constraints)
251 .expect("Failed to call getUserMedia");
252
253 let context = context.clone();
254 let firewheel_worklet = firewheel_worklet.clone();
255 wasm_bindgen_futures::spawn_local(async move {
256 let future = wasm_bindgen_futures::JsFuture::from(
257 get_user_media_promise,
258 );
259 match future.await {
260 Ok(media_stream_jsvalue) => {
261 let media_stream: web_sys::MediaStream =
262 media_stream_jsvalue
263 .dyn_into()
264 .expect("Failed to cast to MediaStream");
265
266 let options =
268 web_sys::MediaStreamAudioSourceOptions::new(
269 &media_stream,
270 );
271 let audio_source_node =
272 web_sys::MediaStreamAudioSourceNode::new(
273 &context, &options,
274 )
275 .expect(
276 "Failed to create MediaStreamAudioSourceNode",
277 );
278
279 if let Err(e) = audio_source_node
280 .connect_with_audio_node(&firewheel_worklet)
281 {
282 log::error!(
283 "Failed to connect media stream to Firewheel worklet: {e:?}"
284 );
285 }
286 }
287 Err(err) => {
288 log::error!("Failed to acquire audio input: {err:?}");
290 }
291 }
292 });
293 },
294 );
295
296 if let Err(e) = result {
297 log::error!("Failed to set up autoresume: {e:?}");
298 };
299 }
300 Ok(_) => {
301 if let Err(e) = setup_autoresume(context.clone(), || ()) {
302 log::error!("Failed to set up autoresume: {e:?}");
303 }
304 }
305 Err(e) => {
306 log::error!("Failed to initialize Web Audio backend: {e:?}");
307 log::warn!(
308 "Audio initialization failed. \
309 Ensure the document is served with appropriate cross origin isolation headers \
310 (https://developer.mozilla.org/en-US/docs/Web/API/Window/crossOriginIsolated) \
311 and compile your wasm with the `+atomics` target feature."
312 );
313 }
314 }
315 }
316 });
317
318 Ok((
319 Self {
320 web_context: context,
321 is_dropped,
322 processor: sender,
323 processor_node,
324 alive,
325 },
326 StreamInfo {
327 sample_rate: NonZeroU32::new(sample_rate as u32)
328 .expect("Web Audio API sample rate should be non-zero"),
329 max_block_frames: NonZeroU32::new(crate::BLOCK_FRAMES as u32).unwrap(),
330 num_stream_in_channels: inputs as u32,
331 num_stream_out_channels: outputs as u32,
332 input_device_id: Some("default input".into()),
333 output_device_id: "default output".into(),
334 ..Default::default()
335 },
336 ))
337 }
338
339 fn set_processor(&mut self, processor: FirewheelProcessor<Self>) {
340 if self.processor.send(processor).is_err() {
341 self.is_dropped
342 .store(true, std::sync::atomic::Ordering::Relaxed);
343 }
344 }
345
346 fn poll_status(&mut self) -> Result<(), Self::StreamError> {
347 if self.is_dropped.load(std::sync::atomic::Ordering::Relaxed) {
348 Err(WebAudioStreamError::UnexpectedDrop)
349 } else {
350 Ok(())
351 }
352 }
353}
354
355fn create_buffer(len: usize) -> &'static mut [f32] {
359 let mut vec = Vec::new();
360 vec.reserve_exact(len);
361 vec.extend(std::iter::repeat_n(0f32, len));
362 Vec::leak(vec)
363}
364
365async fn prepare_context(
366 context: AudioContext,
367 inputs: usize,
368 outputs: usize,
369 receiver: mpsc::Receiver<FirewheelProcessor<WebAudioBackend>>,
370 alive: ArcGc<AtomicBool>,
371 is_dropeed: Rc<AtomicBool>,
372 processor_node: Rc<RefCell<Option<AudioWorkletNode>>>,
373) -> Result<AudioWorkletNode, String> {
374 let mod_url = crate::dynamic_module::dependent_module!("./js/audio-worklet.js")
375 .context("loading dynamic context")?;
376
377 wasm_bindgen_futures::JsFuture::from(
378 context
379 .audio_worklet()
380 .context("fetching audio worklet")?
381 .add_module(mod_url.trim_start_matches('.'))
382 .context("creating audio worklet module")?,
383 )
384 .await
385 .context("creating audio worklet module")?;
386
387 let wrapper = ProcessorHost {
388 processor: None,
389 receiver,
390 alive,
391 inputs,
392 input_buffers: create_buffer(inputs * crate::BLOCK_FRAMES),
393 outputs,
394 output_buffers: create_buffer(outputs * crate::BLOCK_FRAMES),
395 };
396 let wrapper = wrapper.pack();
397
398 let node = web_sys::AudioWorkletNode::new_with_options(&context, "WasmProcessor", &{
399 let options = web_sys::AudioWorkletNodeOptions::new();
400
401 let output_channels = js_sys::Array::of1(&outputs.into());
402 options.set_output_channel_count(&output_channels);
403
404 options.set_number_of_inputs(if inputs > 0 { 1 } else { 0 });
405 options.set_number_of_outputs(1);
406 options.set_channel_count(2);
407
408 options.set_processor_options(Some(&js_sys::Array::of3(
409 &wasm_bindgen::module(),
410 &wasm_bindgen::memory(),
411 &wrapper.into(),
412 )));
413 options
414 })
415 .context("creating audio worklet instance")?;
416
417 let closure = wasm_bindgen::prelude::Closure::<dyn Fn(web_sys::ErrorEvent)>::new(
418 move |data: web_sys::ErrorEvent| {
419 let message = data.message();
420 is_dropeed.store(true, std::sync::atomic::Ordering::Relaxed);
421 log::error!("encountered error in Firewheel `AudioWorkletNode`: {message}");
422 },
423 );
424 node.set_onprocessorerror(Some(closure.as_ref().unchecked_ref()));
425 closure.forget();
426
427 node.connect_with_audio_node(&context.destination())
428 .context("connecting audio worklet to destination")?;
429
430 *processor_node.borrow_mut() = Some(node.clone());
431
432 Ok(node)
433}