Skip to content

Commit 503ae6c

Browse files
committed
feat(audioworklet): add mic loopback recording demo
Also fixes the build under a recent nightly rustc/LLVM, which stopped auto-exporting __heap_base, needed by wasm-bindgen's threading post-processing.
1 parent b691cbd commit 503ae6c

4 files changed

Lines changed: 133 additions & 3 deletions

File tree

examples/audioworklet-beep/.cargo/config.toml

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,8 @@ rustflags = [
1616
"link-arg=--export=__tls_align",
1717
"-C",
1818
"link-arg=--export=__tls_base",
19+
"-C",
20+
"link-arg=--export=__heap_base",
1921
]
2022

2123
[unstable]

examples/audioworklet-beep/Cargo.toml

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,9 @@ wasm-bindgen = "0.2"
2727
# logging them with `console.error`.
2828
console_error_panic_hook = "0.1"
2929

30+
# The `ringbuf` crate provides a lock-free ring buffer for passing audio between streams.
31+
ringbuf = "0.4"
32+
3033
# The `web-sys` crate allows you to interact with the various browser APIs,
3134
# like the DOM.
3235
[dependencies.web-sys]

examples/audioworklet-beep/index.html

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,9 @@
99
<body>
1010
<input id="play" type="button" value="beep" />
1111
<input id="stop" type="button" value="stop" />
12+
<input id="record" type="button" value="record" />
13+
<input id="stop-record" type="button" value="stop recording" />
14+
<p>Recording plays your microphone back live. Wear headphones to avoid feedback howl.</p>
1215
</body>
1316

1417
</html>

examples/audioworklet-beep/src/lib.rs

Lines changed: 125 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,13 @@
11
use std::{cell::Cell, rc::Rc};
22

33
use cpal::{
4-
traits::{DeviceTrait, HostTrait, StreamTrait},
54
Device, Error, ErrorKind, FromSample, HostId, Sample, SampleFormat, SizedSample, Stream,
65
StreamConfig,
6+
traits::{DeviceTrait, HostTrait, StreamTrait},
7+
};
8+
use ringbuf::{
9+
HeapCons, HeapProd, HeapRb,
10+
traits::{Consumer, Producer, Split},
711
};
812
use wasm_bindgen::prelude::*;
913
use web_sys::console;
@@ -19,6 +23,8 @@ pub fn main_js() -> Result<(), JsValue> {
1923
let document = gloo::utils::document();
2024
let play_button = document.get_element_by_id("play").unwrap();
2125
let stop_button = document.get_element_by_id("stop").unwrap();
26+
let record_button = document.get_element_by_id("record").unwrap();
27+
let stop_record_button = document.get_element_by_id("stop-record").unwrap();
2228

2329
// stream needs to be referenced from the "play" and "stop" closures
2430
let stream = Rc::new(Cell::new(None));
@@ -45,12 +51,36 @@ pub fn main_js() -> Result<(), JsValue> {
4551
closure.forget();
4652
}
4753

54+
// input stream needs its own slot; recording and playback run independently
55+
let record_stream = Rc::new(Cell::new(None));
56+
57+
// set up record button
58+
{
59+
let record_stream = record_stream.clone();
60+
let closure = Closure::<dyn FnMut(_)>::new(move |_event: web_sys::MouseEvent| {
61+
record_stream.set(Some(record()));
62+
});
63+
record_button
64+
.add_event_listener_with_callback("mousedown", closure.as_ref().unchecked_ref())?;
65+
closure.forget();
66+
}
67+
68+
// set up stop-record button
69+
{
70+
let closure = Closure::<dyn FnMut(_)>::new(move |_event: web_sys::MouseEvent| {
71+
// stop the stream by dropping it; releases the microphone
72+
record_stream.take();
73+
});
74+
stop_record_button
75+
.add_event_listener_with_callback("mousedown", closure.as_ref().unchecked_ref())?;
76+
closure.forget();
77+
}
78+
4879
Ok(())
4980
}
5081

5182
fn beep() -> Stream {
52-
let host =
53-
cpal::host_from_id(HostId::AudioWorklet).expect("AudioWorklet host not available");
83+
let host = cpal::host_from_id(HostId::AudioWorklet).expect("AudioWorklet host not available");
5484

5585
let device = host
5686
.default_output_device()
@@ -98,6 +128,98 @@ where
98128
stream
99129
}
100130

131+
/// Captures microphone input into a ring buffer and immediately plays it back, so you can hear
132+
/// your own voice. Wear headphones: routing a live mic to speakers risks feedback howl.
133+
fn record() -> (Stream, Stream) {
134+
let host = cpal::host_from_id(HostId::AudioWorklet).expect("AudioWorklet host not available");
135+
136+
let input_device = host
137+
.default_input_device()
138+
.expect("failed to find a default input device");
139+
let output_device = host
140+
.default_output_device()
141+
.expect("failed to find a default output device");
142+
143+
let input_config = input_device.default_input_config().unwrap();
144+
let output_config = output_device.default_output_config().unwrap();
145+
146+
// Bound end-to-end latency; once full, the producer drops the newest samples instead of
147+
// blocking.
148+
let max_buffered_samples =
149+
output_config.sample_rate() as usize * output_config.channels() as usize / 2;
150+
let ring = HeapRb::<f32>::new(max_buffered_samples);
151+
let (producer, consumer) = ring.split();
152+
153+
let input_stream = match input_config.sample_format() {
154+
SampleFormat::F32 => build_input::<f32>(&input_device, input_config.into(), producer),
155+
SampleFormat::I16 => build_input::<i16>(&input_device, input_config.into(), producer),
156+
SampleFormat::U16 => build_input::<u16>(&input_device, input_config.into(), producer),
157+
_ => panic!("unsupported sample format"),
158+
};
159+
let output_stream = match output_config.sample_format() {
160+
SampleFormat::F32 => build_output::<f32>(&output_device, output_config.into(), consumer),
161+
SampleFormat::I16 => build_output::<i16>(&output_device, output_config.into(), consumer),
162+
SampleFormat::U16 => build_output::<u16>(&output_device, output_config.into(), consumer),
163+
_ => panic!("unsupported sample format"),
164+
};
165+
166+
(input_stream, output_stream)
167+
}
168+
169+
fn build_input<T>(device: &Device, config: StreamConfig, mut producer: HeapProd<f32>) -> Stream
170+
where
171+
T: Sample + SizedSample,
172+
f32: FromSample<T>,
173+
{
174+
let err_fn = |err: Error| match err.kind() {
175+
ErrorKind::DeviceChanged | ErrorKind::RealtimeDenied => {
176+
console::log_1(&format!("{err}").into())
177+
}
178+
_ => console::error_1(&format!("Stream error: {err}").into()),
179+
};
180+
181+
let stream = device
182+
.build_input_stream(
183+
config,
184+
move |data: &[T], _| {
185+
producer.push_iter(data.iter().map(|&s| f32::from_sample(s)));
186+
},
187+
err_fn,
188+
None,
189+
)
190+
.unwrap();
191+
stream.start().unwrap();
192+
stream
193+
}
194+
195+
fn build_output<T>(device: &Device, config: StreamConfig, mut consumer: HeapCons<f32>) -> Stream
196+
where
197+
T: Sample + SizedSample + FromSample<f32>,
198+
{
199+
let err_fn = |err: Error| match err.kind() {
200+
ErrorKind::DeviceChanged | ErrorKind::RealtimeDenied => {
201+
console::log_1(&format!("{err}").into())
202+
}
203+
_ => console::error_1(&format!("Stream error: {err}").into()),
204+
};
205+
206+
let stream = device
207+
.build_output_stream(
208+
config,
209+
move |data: &mut [T], _| {
210+
for sample in data.iter_mut() {
211+
let value = consumer.try_pop().unwrap_or(f32::EQUILIBRIUM);
212+
*sample = T::from_sample(value);
213+
}
214+
},
215+
err_fn,
216+
None,
217+
)
218+
.unwrap();
219+
stream.start().unwrap();
220+
stream
221+
}
222+
101223
fn write_data<T>(output: &mut [T], channels: usize, next_sample: &mut dyn FnMut() -> f32)
102224
where
103225
T: Sample + FromSample<f32>,

0 commit comments

Comments
 (0)