feat: implement input stream support for web backends#1044
feat: implement input stream support for web backends#1044roderickvd wants to merge 1 commit intomasterfrom
Conversation
Both backends were returning empty configs or panicking when trying to use microphone input. Now properly implements getUserMedia() with async support.
|
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. ⛑️ |
|
@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.
Cargo.toml - needs to add wasm-bindgen-futures here: And update the feature:
At line 617, the processor is created with 0 output channels: Changing to 1 output channel fixes the issue.
The MediaStreamAudioSourceNode and ScriptProcessorNode are created inside the async Quick fix: A proper fix would store these in the Stream struct so they can be cleaned up when the Additional Notes
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());
} |
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 aMediaStreamAudioSourceNodeconnected to aScriptProcessorNodefor audio capture. The async getUserMedia Promise is handled viawasm_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.