Skip to main content

firewheel_web_audio/
backend.rs

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
21/// The main-thread host for the Web Audio API backend.
22///
23/// This backend relies on Wasm multi-threading. The Firewheel
24/// audio nodes are processed within a Web Audio `AudioWorkletNode`
25/// that shares its memory with the initializing Wasm module.
26///
27/// When the audio context is created, `firewheel-web-audio` will begin listening for
28/// a number of user input events that will permit the context to be resumed. If
29/// [`WebAudioConfig::request_input`] is `true`, it will also prompt the user for
30/// input and connect the input in the Web Audio graph.
31///
32/// When dropped, the underlying `AudioContext` is closed and all
33/// resources are released.
34pub 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/// Errors related to initializing the web audio stream.
70#[derive(Debug)]
71pub enum WebAudioStartError {
72    /// An error occurred during Web Audio context initialization.
73    Initialization(String),
74    /// An error occurred when constructing the `AudioWorkletNode`.
75    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/// Errors encountered while the web audio stream is running.
94#[derive(Debug)]
95pub enum WebAudioStreamError {
96    /// The `AudioWorkletNode` was unexpectedly dropped.
97    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/// The Web Audio backend's configuration.
113#[derive(Debug, Default, Clone)]
114pub struct WebAudioConfig {
115    /// The desired sample rate.
116    ///
117    /// If no sample rate is requested, it will be selected automatically
118    /// by the Web Audio API.
119    pub sample_rate: Option<NonZeroU32>,
120
121    /// Ask the browser to request an input device,
122    /// allowing the user to supply a microphone or other input.
123    ///
124    /// When set to `true`, the
125    /// [`FirewheelConfig::num_graph_inputs`][firewheel::FirewheelConfig::num_graph_inputs]
126    /// field must be set to [`ChannelCount::STEREO`][firewheel::core::channel_config::ChannelCount::STEREO].
127    ///
128    /// If input is not requested, the Firewheel graph inputs will be silent.
129    pub request_input: bool,
130}
131
132/// Manual javascript bindings to access the audio context's timing information.
133///
134/// https://developer.mozilla.org/en-US/docs/Web/API/AudioContext/getOutputTimestamp
135#[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                                // Request microphone access
240                                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                                            // Create MediaStreamAudioSourceNode
267                                            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                                            // Handle the error (e.g., user denied microphone access)
289                                            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
355/// Since it's a reasonable expectation that creating contexts
356/// will be infrequent and the buffer sizes small, leaking the
357/// buffers is totally fine.
358fn 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}