Skip to content

Conversation

@roderickvd
Copy link
Member

Implements missing microphone/input support for WebAudio and Emscripten backends. Previously, calling any input-related methods would return empty results or panic.

Implementation

Uses navigator.mediaDevices.getUserMedia() to request microphone access, then creates a MediaStreamAudioSourceNode connected to a ScriptProcessorNode for audio capture. The async getUserMedia Promise is handled via wasm_bindgen_futures::spawn_local(), allowing the synchronous API to return immediately while permission is requested in the background.

Status

This is very preliminary and mostly untested code. Please test and report back here.

Both backends were returning empty configs or panicking when trying
to use microphone input. Now properly implements getUserMedia() with
async support.
@Tahinli
Copy link

Tahinli commented Jan 19, 2026

finally we have something for web even if it's just a draft. thank you thank you ❤️ we have been waiting this for literally years. ⛑️

@roderickvd
Copy link
Member Author

@Tahinli do let me know if this works OK or better yet: take over for any fixes and enhancements as required. I don't use this feature myself, so anyone that does will be in the best position to implement it appropriately.

@Tahinli
Copy link

Tahinli commented Jan 27, 2026

@Tahinli do let me know if this works OK or better yet: take over for any fixes and enhancements as required. I don't use this feature myself, so anyone that does will be in the best position to implement it appropriately.

PR wasn't working alone and since I'm not very good at JavaScript I got help from AI. After these fixes I'm able to get audio correctly. Some people are little sensitive about AI so I wanted to inform from the beginning.

  1. The code uses wasm_bindgen_futures::spawn_local() and JsFuture but the crate isn't
    declared for wasm32-unknown-unknown:

Cargo.toml - needs to add wasm-bindgen-futures here:
[target.'cfg(all(target_arch = "wasm32", target_os = "unknown"))'.dependencies]
wasm-bindgen = { version = "0.2.58", optional = true }
wasm-bindgen-futures = { version = "0.4.33", optional = true } # <- missing

And update the feature:
[features]
wasm-bindgen = ["dep:wasm-bindgen", "dep:wasm-bindgen-futures"]

  1. ScriptProcessorNode with 0 output channels doesn't fire onaudioprocess

At line 617, the processor is created with 0 output channels:
ctx_clone.create_script_processor_with_buffer_size_and_number_of_input_channels_and_numb
er_of_output_channels(
buffer_size_frames as u32,
n_channels as u32,
0, // <- Some browsers require at least 1 output channel
)

Changing to 1 output channel fixes the issue.

  1. Audio nodes get garbage collected

The MediaStreamAudioSourceNode and ScriptProcessorNode are created inside the async
block but aren't stored anywhere. When the async block completes, they go out of scope
and get garbage collected, stopping the audio pipeline.

Quick fix:
// After connecting nodes, keep them alive:
std::mem::forget(source);
std::mem::forget(processor);

A proper fix would store these in the Stream struct so they can be cleaned up when the
stream is dropped.

Additional Notes

  • The ScriptProcessorNode deprecation warning appears (recommending AudioWorkletNode),
    but it still works for now
  • After applying these fixes, audio capture works correctly with ~4096 sample buffers at
    44.1kHz

Test Code

use cpal::traits::{DeviceTrait, HostTrait, StreamTrait};
use std::collections::VecDeque;
use std::sync::{Arc, Mutex};
use wasm_bindgen::prelude::*;
use wasm_bindgen::JsValue;

#[wasm_bindgen(start)]
pub fn main() {
 console_error_panic_hook::set_once();
 log("CPAL Web Input Test initialized");
}

/// Loopback test - hear your own voice!
#[wasm_bindgen]
pub fn test_loopback() {
 log("Starting loopback test (hear your voice)...");

 let host = cpal::default_host();
 log(&format!("Host ID: {:?}", host.id()));

 let device = match host.default_input_device() {
     Some(d) => d,
     None => {
         log("No input device found!");
         return;
     }
 };

 let config = match device.default_input_config() {
     Ok(c) => c,
     Err(e) => {
         log(&format!("No default input config: {:?}", e));
         return;
     }
 };

 log(&format!(
     "Config: {} channels, {} Hz",
     config.channels(),
     config.sample_rate().0
 ));

 // Shared ring buffer for audio data
 let buffer_size = config.sample_rate().0 as usize; // 1 second buffer
 let ring_buffer: Arc<Mutex<VecDeque<f32>>> = Arc::new(Mutex::new(VecDeque::with_capacity(buffer_size)));

 let ring_buffer_input = ring_buffer.clone();
 let ring_buffer_output = ring_buffer.clone();

 // Build input stream
 let input_stream = device.build_input_stream(
     &config.clone().into(),
     move |data: &[f32], _: &cpal::InputCallbackInfo| {
         if let Ok(mut buf) = ring_buffer_input.try_lock() {
             for &sample in data {
                 if buf.len() >= buffer_size {
                     buf.pop_front();
                 }
                 buf.push_back(sample);
             }
         }
     },
     move |err| {
         web_sys::console::error_1(&format!("Input error: {:?}", err).into());
     },
     None,
 );

 // Build output stream
 let output_stream = device.build_output_stream(
     &config.into(),
     move |data: &mut [f32], _: &cpal::OutputCallbackInfo| {
         if let Ok(mut buf) = ring_buffer_output.try_lock() {
             for sample in data.iter_mut() {
                 *sample = buf.pop_front().unwrap_or(0.0);
             }
         } else {
             // If we can't lock, output silence
             for sample in data.iter_mut() {
                 *sample = 0.0;
             }
         }
     },
     move |err| {
         web_sys::console::error_1(&format!("Output error: {:?}", err).into());
     },
     None,
 );

 match (input_stream, output_stream) {
     (Ok(input), Ok(output)) => {
         log("Streams created successfully!");
         if let Err(e) = input.play() {
             log(&format!("Failed to play input: {:?}", e));
             return;
         }
         if let Err(e) = output.play() {
             log(&format!("Failed to play output: {:?}", e));
             return;
         }
         log("🎤➡️🔊 Loopback active! Speak into your mic...");
         log("(Use headphones to avoid feedback!)");

         // Keep streams alive
         std::mem::forget(input);
         std::mem::forget(output);
     }
     (Err(e), _) => log(&format!("Failed to create input stream: {:?}", e)),
     (_, Err(e)) => log(&format!("Failed to create output stream: {:?}", e)),
 }
}

#[wasm_bindgen]
pub fn check_webaudio() {
 log("Checking WebAudio availability...");

 // Check AudioContext directly via JS
 let global = js_sys::global();
 let audio_ctx = js_sys::Reflect::get(&global, &JsValue::from_str("AudioContext"));
 log(&format!("AudioContext from global: {:?}", audio_ctx.map(|v: JsValue| v.is_truthy())));

 // Check window.AudioContext
 if let Some(window) = web_sys::window() {
     log("window object: available");
     let audio_ctx_from_window = js_sys::Reflect::get(&window, &JsValue::from_str("AudioContext"));
     log(&format!("AudioContext from window: {:?}", audio_ctx_from_window.map(|v: JsValue| v.is_truthy())));
 } else {
     log("window object: NOT available");
 }

 // Try creating an AudioContext directly
 match web_sys::AudioContext::new() {
     Ok(ctx) => {
         log(&format!("Created AudioContext: sample_rate={}", ctx.sample_rate()));
         let _ = ctx.close();
     }
     Err(e) => {
         log(&format!("Failed to create AudioContext: {:?}", e));
     }
 }
}

#[wasm_bindgen]
pub fn test_input_stream() {
 log("Starting input stream test...");

 // First check WebAudio
 check_webaudio();

 let host = cpal::default_host();
 log(&format!("Host ID: {:?}", host.id()));

 // List input devices
 match host.input_devices() {
     Ok(devices) => {
         let devices: Vec<_> = devices.collect();
         log(&format!("Found {} input device(s)", devices.len()));
         for (i, device) in devices.iter().enumerate() {
             if let Ok(name) = device.name() {
                 log(&format!("  Device {}: {}", i, name));
             }
         }
     }
     Err(e) => {
         log(&format!("Error listing input devices: {:?}", e));
     }
 }

 // Try to get default input device
 match host.default_input_device() {
     Some(device) => {
         let name = device.name().unwrap_or_else(|_| "Unknown".to_string());
         log(&format!("Default input device: {}", name));

         // Get supported input configs
         match device.supported_input_configs() {
             Ok(configs) => {
                 let configs: Vec<_> = configs.collect();
                 log(&format!("Supported input configs: {}", configs.len()));
                 for (i, config) in configs.iter().enumerate() {
                     log(&format!(
                         "  Config {}: channels={}, sample_rate={}-{}, format={:?}",
                         i,
                         config.channels(),
                         config.min_sample_rate().0,
                         config.max_sample_rate().0,
                         config.sample_format()
                     ));
                 }
             }
             Err(e) => {
                 log(&format!("Error getting supported configs: {:?}", e));
             }
         }

         // Try to get default input config
         match device.default_input_config() {
             Ok(config) => {
                 log(&format!(
                     "Default input config: channels={}, sample_rate={}, format={:?}",
                     config.channels(),
                     config.sample_rate().0,
                     config.sample_format()
                 ));

                 // Try to build an input stream
                 log("Attempting to build input stream...");
                 let sample_count = std::sync::Arc::new(std::sync::atomic::AtomicU32::new(0));
                 let sample_count_clone = sample_count.clone();

                 let stream = device.build_input_stream(
                     &config.into(),
                     move |data: &[f32], _: &cpal::InputCallbackInfo| {
                         let count = sample_count_clone.fetch_add(
                             data.len() as u32,
                             std::sync::atomic::Ordering::Relaxed,
                         );
                         // Log first 10 callbacks, then every ~1 second
                         if count < 10 * 2048 || count % 48000 < data.len() as u32 {
                             let max_amplitude = data
                                 .iter()
                                 .map(|s| s.abs())
                                 .max_by(|a, b| a.partial_cmp(b).unwrap())
                                 .unwrap_or(0.0);
                             web_sys::console::log_1(
                                 &format!(
                                     "🎤 Audio data: {} samples, len={}, max amp: {:.4}",
                                     count, data.len(), max_amplitude
                                 )
                                 .into(),
                             );
                         }
                     },
                     move |err| {
                         // Make errors very visible
                         web_sys::console::error_1(
                             &format!("❌ INPUT STREAM ERROR: {:?}", err).into(),
                         );
                     },
                     None,
                 );

                 match stream {
                     Ok(stream) => {
                         log("Input stream created successfully!");
                         if let Err(e) = stream.play() {
                             log(&format!("Error playing stream: {:?}", e));
                         } else {
                             log("Stream is now playing! Speak into your microphone...");
                             // Keep stream alive by leaking it (for testing purposes)
                             std::mem::forget(stream);
                         }
                     }
                     Err(e) => {
                         log(&format!("Error building input stream: {:?}", e));
                     }
                 }
             }
             Err(e) => {
                 log(&format!("Error getting default input config: {:?}", e));
             }
         }
     }
     None => {
         log("No default input device found");
     }
 }
}

fn log(msg: &str) {
 web_sys::console::log_1(&msg.into());
}

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants