diff --git a/.github/workflows/platforms.yml b/.github/workflows/platforms.yml
index 8d904683c..1c8317ab0 100644
--- a/.github/workflows/platforms.yml
+++ b/.github/workflows/platforms.yml
@@ -476,8 +476,8 @@ jobs:
- name: Check all features (no atomics)
run: cargo +${{ env.MSRV_WASM }} check --workspace --all-features --verbose --target ${{ env.TARGET }}
- - name: Build wasm-beep example
- working-directory: ./examples/wasm-beep
+ - name: Build webaudio example
+ working-directory: ./examples/webaudio
run: trunk build
env:
RUSTUP_TOOLCHAIN: ${{ env.MSRV_WASM }}
@@ -516,8 +516,8 @@ jobs:
- name: Check all features
run: cargo +nightly check --workspace --all-features --verbose -Z build-std=std,panic_abort --target ${{ env.TARGET }}
- - name: Build audioworklet-beep example
- working-directory: ./examples/audioworklet-beep
+ - name: Build audioworklet example
+ working-directory: ./examples/audioworklet
run: trunk build
env:
RUSTUP_TOOLCHAIN: nightly
diff --git a/CHANGELOG.md b/CHANGELOG.md
index b163261e7..827f892fe 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -11,8 +11,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- `StreamTrait::stop` ends a stream gracefully, draining buffered audio before halting (blocking up to a caller-supplied timeout). Dropping a stream still halts immediately without draining.
- `CallbackInfo::xrun()` reports buffer over/underruns via the data callback.
-- **AudioWorklet**: Input streams are now supported.
-- **WebAudio**: Input streams are now supported.
+- `DeviceTrait::build_duplex_stream()`, `build_duplex_stream_raw()`, and `supports_duplex()` for capture and playback from one device-level callback.
+- **AudioWorklet**: Input and duplex streams are now supported.
+- **WebAudio**: Input and duplex streams are now supported.
### Changed
@@ -22,6 +23,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- `StreamTrait::play` is renamed to `start`.
- `InputCallbackInfo`/`OutputCallbackInfo` merged into `CallbackInfo`.
- `InputStreamTimestamp`/`OutputStreamTimestamp` merged into `StreamTimestamp`; `capture`/`playback` renamed `device`.
+- Renamed the `wasm-beep` and `audioworklet-beep` examples to `webaudio` and `audioworklet`.
- **ALSA**: Update `alsa` dependency to 0.12.
- **Linux**: `realtime` can now promote threads without requiring `realtime-dbus`.
@@ -38,6 +40,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- **ALSA**: Fix a remaining timestamp segfault on 32-bit platforms with a 64-bit kernel `time_t`.
- **AudioWorklet**: Fix processor construction failures not being reported to `error_callback`.
+- **AudioWorklet**: Fix dropouts in output streams when the callback buffer grows.
- **JACK**: Channel enumeration is capped at the physical system port count again.
- **WASAPI**: Device enumeration no longer panics if the COM enumerator fails to initialize.
@@ -115,7 +118,6 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- `realtime` feature for real-time audio thread scheduling without a D-Bus build dependency.
- `StreamTrait::now()` to query the current instant on the stream's clock.
- `StreamTrait::buffer_size()` to query the stream's current buffer size in frames per callback.
-- `DeviceTrait::build_duplex_stream()`, `build_duplex_stream_raw()`, and `supports_duplex()` for synchronized capture and playback on a shared clock (no backend support yet).
- `SAMPLE_RATE_CD` (44100 Hz) and `SAMPLE_RATE_48K` (48000 Hz) constants.
- `SupportedStreamConfigRange::try_with_standard_sample_rate()` and `with_standard_sample_rate()`
to select 48 kHz or 44.1 kHz from a range.
diff --git a/Cargo.toml b/Cargo.toml
index 2810d6b91..06d86479f 100644
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -236,6 +236,9 @@ name = "beep"
[[example]]
name = "enumerate"
+[[example]]
+name = "duplex"
+
[[example]]
name = "feedback"
diff --git a/README.md b/README.md
index 13ab29a1b..e7bf06599 100644
--- a/README.md
+++ b/README.md
@@ -63,14 +63,14 @@ The `audioworklet` backend additionally requires `-Zbuild-std` with atomics supp
| Feature | Platform | Description |
| ------- | -------- | ----------- |
| `asio` | Windows | ASIO backend for low-latency audio, bypassing the Windows audio stack. Requires ASIO drivers and LLVM/Clang. See the [ASIO setup guide](#compiling-for-asio). |
-| `audioworklet` | WebAssembly (`wasm32-unknown-unknown`) | Audio Worklet backend for lower-latency web audio than the default Web Audio API, running audio on a dedicated thread. Requires atomics support (`RUSTFLAGS="-C target-feature=+atomics,+bulk-memory,+mutable-globals"`) and `Cross-Origin` headers for `SharedArrayBuffer`. See the `audioworklet-beep` example. |
+| `audioworklet` | WebAssembly (`wasm32-unknown-unknown`) | Audio Worklet backend for lower-latency web audio than the default Web Audio API, running audio on a dedicated thread. Requires atomics support (`RUSTFLAGS="-C target-feature=+atomics,+bulk-memory,+mutable-globals"`) and `Cross-Origin` headers for `SharedArrayBuffer`. See the `audioworklet` example. |
| `custom` | All | User-defined backend implementations for audio systems not natively supported by CPAL. See `examples/custom.rs`. |
| `jack` | Linux, BSD, macOS, Windows | JACK Audio Connection Kit backend for pro-audio routing and inter-application connectivity. Requires `libjack-jackd2-dev` (Debian/Ubuntu) or `jack-devel` (Fedora). |
| `pipewire` | Linux, BSD | PipeWire media server backend. Requires `libpipewire-0.3-dev` (Debian/Ubuntu) or `pipewire-devel` (Fedora). |
| `pulseaudio` | Linux, BSD | PulseAudio sound server backend. Requires `libpulse-dev` (Debian/Ubuntu) or `pulseaudio-libs-devel` (Fedora). |
| `realtime` | Android, Linux, Windows | Raises the audio callback thread to real-time or high-priority scheduling for lower latency. On Linux, requires `CAP_SYS_NICE`, root, or an `rtprio` limit granted via `limits.conf` or systemd, unless `realtime-dbus` is also enabled. |
| `realtime-dbus` | Linux | Uses `rtkit` via D-Bus for RT scheduling on Linux desktop systems. Implies `realtime` on all platforms. Requires `libdbus-1-dev` on Linux. |
-| `wasm-bindgen` | WebAssembly (`wasm32-unknown-unknown`) | Web Audio API backend for browser-based audio; required for any WebAssembly audio support. See the `wasm-beep` example. |
+| `wasm-bindgen` | WebAssembly (`wasm32-unknown-unknown`) | Web Audio API backend for browser-based audio; required for any WebAssembly audio support. See the `webaudio` example. |
See the [beep example](examples/beep.rs) for selecting the backend at runtime.
diff --git a/examples/audioworklet-beep/index.html b/examples/audioworklet-beep/index.html
deleted file mode 100644
index 6e3139fc6..000000000
--- a/examples/audioworklet-beep/index.html
+++ /dev/null
@@ -1,17 +0,0 @@
-
-
-
-
Recording plays your microphone back live. Wear headphones to avoid feedback howl.
-
-
-
\ No newline at end of file
diff --git a/examples/audioworklet-beep/.cargo/config.toml b/examples/audioworklet/.cargo/config.toml
similarity index 100%
rename from examples/audioworklet-beep/.cargo/config.toml
rename to examples/audioworklet/.cargo/config.toml
diff --git a/examples/audioworklet-beep/.gitignore b/examples/audioworklet/.gitignore
similarity index 100%
rename from examples/audioworklet-beep/.gitignore
rename to examples/audioworklet/.gitignore
diff --git a/examples/audioworklet-beep/Cargo.toml b/examples/audioworklet/Cargo.toml
similarity index 91%
rename from examples/audioworklet-beep/Cargo.toml
rename to examples/audioworklet/Cargo.toml
index ebf6bc2ec..b0b57dccc 100644
--- a/examples/audioworklet-beep/Cargo.toml
+++ b/examples/audioworklet/Cargo.toml
@@ -1,6 +1,6 @@
[package]
-name = "audioworklet-beep"
-description = "cpal beep example for WebAssembly on an AudioWorklet"
+name = "audioworklet"
+description = "cpal AudioWorklet example for WebAssembly"
version = "0.2.0"
edition = "2024"
rust-version = "1.85"
diff --git a/examples/audioworklet-beep/README.md b/examples/audioworklet/README.md
similarity index 100%
rename from examples/audioworklet-beep/README.md
rename to examples/audioworklet/README.md
diff --git a/examples/audioworklet-beep/Trunk.toml b/examples/audioworklet/Trunk.toml
similarity index 100%
rename from examples/audioworklet-beep/Trunk.toml
rename to examples/audioworklet/Trunk.toml
diff --git a/examples/audioworklet/index.html b/examples/audioworklet/index.html
new file mode 100644
index 000000000..35346584a
--- /dev/null
+++ b/examples/audioworklet/index.html
@@ -0,0 +1,28 @@
+
+
+
+
+
+ cpal AudioWorklet example
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
Both loopback modes play your microphone back live. Wear headphones to avoid feedback howl.
+
The two-stream mode bridges an independent input and output stream with a ring buffer. The
+ duplex mode runs both directions from one callback on one clock, so it needs no ring buffer
+ and the round trip is audibly shorter.
+
+
+
diff --git a/examples/audioworklet-beep/rust-toolchain.toml b/examples/audioworklet/rust-toolchain.toml
similarity index 100%
rename from examples/audioworklet-beep/rust-toolchain.toml
rename to examples/audioworklet/rust-toolchain.toml
diff --git a/examples/audioworklet-beep/src/lib.rs b/examples/audioworklet/src/lib.rs
similarity index 70%
rename from examples/audioworklet-beep/src/lib.rs
rename to examples/audioworklet/src/lib.rs
index d39979ef0..f03af811e 100644
--- a/examples/audioworklet-beep/src/lib.rs
+++ b/examples/audioworklet/src/lib.rs
@@ -1,8 +1,8 @@
use std::{cell::Cell, rc::Rc};
use cpal::{
- Device, Error, ErrorKind, FromSample, HostId, Sample, SampleFormat, SizedSample, Stream,
- StreamConfig,
+ Device, DuplexCallbackInfo, Error, ErrorKind, FromSample, HostId, Sample, SampleFormat,
+ SizedSample, Stream, StreamConfig,
traits::{DeviceTrait, HostTrait, StreamTrait},
};
use ringbuf::{
@@ -25,6 +25,8 @@ pub fn main_js() -> Result<(), JsValue> {
let stop_button = document.get_element_by_id("stop").unwrap();
let record_button = document.get_element_by_id("record").unwrap();
let stop_record_button = document.get_element_by_id("stop-record").unwrap();
+ let duplex_button = document.get_element_by_id("duplex").unwrap();
+ let stop_duplex_button = document.get_element_by_id("stop-duplex").unwrap();
// stream needs to be referenced from the "play" and "stop" closures
let stream = Rc::new(Cell::new(None));
@@ -76,6 +78,31 @@ pub fn main_js() -> Result<(), JsValue> {
closure.forget();
}
+ // duplex loopback is a single stream, so one slot holds the whole thing
+ let duplex_stream = Rc::new(Cell::new(None));
+
+ // set up duplex button
+ {
+ let duplex_stream = duplex_stream.clone();
+ let closure = Closure::::new(move |_event: web_sys::MouseEvent| {
+ duplex_stream.set(Some(duplex()));
+ });
+ duplex_button
+ .add_event_listener_with_callback("mousedown", closure.as_ref().unchecked_ref())?;
+ closure.forget();
+ }
+
+ // set up stop-duplex button
+ {
+ let closure = Closure::::new(move |_event: web_sys::MouseEvent| {
+ // stop the stream by dropping it; releases the microphone
+ duplex_stream.take();
+ });
+ stop_duplex_button
+ .add_event_listener_with_callback("mousedown", closure.as_ref().unchecked_ref())?;
+ closure.forget();
+ }
+
Ok(())
}
@@ -166,6 +193,56 @@ fn record() -> (Stream, Stream) {
(input_stream, output_stream)
}
+/// The same live microphone loopback as [`record`], but as one duplex stream instead of two
+/// independent ones. `AudioWorkletProcessor.process(inputs, outputs)` hands both directions to a
+/// single callback on a single clock, so no ring buffer is needed to bridge them and the round
+/// trip is a callback rather than a delay line. Wear headphones: routing a live mic to speakers
+/// risks feedback howl.
+fn duplex() -> Stream {
+ let host = cpal::host_from_id(HostId::AudioWorklet).expect("AudioWorklet host not available");
+
+ let device = host
+ .default_output_device()
+ .expect("failed to find a default output device");
+ assert!(
+ device.supports_duplex(),
+ "duplex streams need `navigator.mediaDevices`, which browsers expose only in a \
+ secure context: serve this page over HTTPS or from localhost"
+ );
+
+ let config = device.default_duplex_config().unwrap();
+
+ let err_fn = |err: Error| match err.kind() {
+ ErrorKind::DeviceChanged | ErrorKind::RealtimeDenied => {
+ console::log_1(&format!("{err}").into())
+ }
+ _ => console::error_1(&format!("Stream error: {err}").into()),
+ };
+
+ // WebAudio processes exclusively in f32, so there is no sample format to match on here.
+ let input_channels = config.input_channels as usize;
+ let output_channels = config.output_channels as usize;
+ let stream = device
+ .build_duplex_stream(
+ config,
+ move |input: &[f32], output: &mut [f32], _: &DuplexCallbackInfo| {
+ // The two directions can carry different channel counts, so mix each captured
+ // frame down to mono and fan it back out across the output frame.
+ for (captured, rendered) in input
+ .chunks(input_channels)
+ .zip(output.chunks_mut(output_channels))
+ {
+ rendered.fill(captured.iter().sum::() / input_channels as f32);
+ }
+ },
+ err_fn,
+ None,
+ )
+ .unwrap();
+ stream.start().unwrap();
+ stream
+}
+
fn build_input(device: &Device, config: StreamConfig, mut producer: HeapProd) -> Stream
where
T: Sample + SizedSample,
diff --git a/examples/duplex.rs b/examples/duplex.rs
new file mode 100644
index 000000000..eab833941
--- /dev/null
+++ b/examples/duplex.rs
@@ -0,0 +1,187 @@
+//! Feeds the input stream directly back into the output stream, from a single duplex callback.
+//!
+//! A duplex stream drives both directions from one device callback on one clock, so neither the
+//! ring buffer nor the delay is needed. It requires a device that [`DeviceTrait::supports_duplex`].
+//!
+//! Where a platform exposes no natively duplex device, you may be able to compose one on the
+//! system: using an Aggregate Device on macOS (with Audio MIDI Setup), or an `asym` PCM in
+//! `~/.asoundrc` on ALSA.
+//!
+//! For simplicity this example requires both directions to use the same sample format, though
+//! [`DeviceTrait::build_duplex_stream`] does not.
+
+use clap::Parser;
+use cpal::{
+ Device, DuplexCallbackInfo, DuplexStreamConfig, Error, ErrorKind, HostId, SampleFormat,
+ SizedSample,
+ traits::{DeviceTrait, HostTrait, StreamTrait},
+};
+
+#[derive(Parser, Debug)]
+#[command(version, about = "CPAL duplex example", long_about = None)]
+struct Opt {
+ /// The duplex audio device to use. Defaults to the first device reporting duplex support.
+ #[arg(short, long, value_name = "DEVICE")]
+ device: Option,
+
+ /// Use the JACK host. Requires `--features jack`.
+ #[arg(long, default_value_t = false)]
+ jack: bool,
+
+ /// Use the PipeWire host. Requires `--features pipewire`.
+ #[arg(long, default_value_t = false)]
+ pipewire: bool,
+
+ /// Use the ASIO host. Requires `--features asio`.
+ #[arg(long, default_value_t = false)]
+ asio: bool,
+}
+
+fn main() -> anyhow::Result<()> {
+ let opt = Opt::parse();
+
+ // JACK/PipeWire/ASIO support must be enabled at compile time, and is
+ // only available on some platforms.
+ #[allow(unused_mut, unused_assignments)]
+ let mut jack_host_id: Result = Err(ErrorKind::HostUnavailable.into());
+ #[allow(unused_mut, unused_assignments)]
+ let mut pipewire_host_id: Result = Err(ErrorKind::HostUnavailable.into());
+ #[allow(unused_mut, unused_assignments)]
+ let mut asio_host_id: Result = Err(ErrorKind::HostUnavailable.into());
+
+ #[cfg(any(
+ target_os = "linux",
+ target_os = "dragonfly",
+ target_os = "freebsd",
+ target_os = "netbsd"
+ ))]
+ {
+ #[cfg(feature = "jack")]
+ {
+ jack_host_id = Ok(HostId::Jack);
+ }
+
+ #[cfg(feature = "pipewire")]
+ {
+ pipewire_host_id = Ok(HostId::PipeWire);
+ }
+ }
+
+ #[cfg(target_os = "windows")]
+ {
+ #[cfg(feature = "asio")]
+ {
+ asio_host_id = Ok(HostId::Asio);
+ }
+ }
+
+ // Manually check for flags. Can be passed through cargo with -- e.g.
+ // cargo run --release --example duplex --features jack -- --jack
+ let host = if opt.jack {
+ jack_host_id
+ .and_then(cpal::host_from_id)
+ .expect("make sure `--features jack` is specified, and the platform is supported")
+ } else if opt.pipewire {
+ pipewire_host_id
+ .and_then(cpal::host_from_id)
+ .expect("make sure `--features pipewire` is specified, and the platform is supported")
+ } else if opt.asio {
+ asio_host_id
+ .and_then(cpal::host_from_id)
+ .expect("make sure `--features asio` is specified, and the platform is supported")
+ } else {
+ cpal::default_host()
+ };
+
+ let device = if let Some(device) = opt.device {
+ let id = &device.parse().expect("failed to parse device id");
+ host.device_by_id(id).expect("failed to find device")
+ } else {
+ host.devices()?
+ .find(|device| device.supports_duplex())
+ .ok_or_else(|| anyhow::anyhow!("no device on this host reports duplex support."))?
+ };
+
+ if !device.supports_duplex() {
+ anyhow::bail!(
+ "device \"{}\" does not support duplex streams",
+ device.id()?
+ );
+ }
+ println!("Using duplex device: \"{}\"", device.id()?);
+
+ let input_config = device.default_input_config()?;
+ let output_config = device.default_output_config()?;
+ assert_eq!(
+ input_config.sample_format(),
+ output_config.sample_format(),
+ "both directions must share a sample format for this example"
+ );
+
+ let config = device.default_duplex_config()?;
+
+ match input_config.sample_format() {
+ SampleFormat::I8 => run::(&device, config),
+ SampleFormat::I16 => run::(&device, config),
+ SampleFormat::I32 => run::(&device, config),
+ SampleFormat::I64 => run::(&device, config),
+ SampleFormat::U8 => run::(&device, config),
+ SampleFormat::U16 => run::(&device, config),
+ SampleFormat::U32 => run::(&device, config),
+ SampleFormat::U64 => run::(&device, config),
+ SampleFormat::F32 => run::(&device, config),
+ SampleFormat::F64 => run::(&device, config),
+ sample_format => panic!("Unsupported sample format '{sample_format}'"),
+ }
+}
+
+fn run(device: &Device, config: DuplexStreamConfig) -> anyhow::Result<()>
+where
+ T: SizedSample + Send + 'static,
+{
+ let input_channels = config.input_channels as usize;
+ let output_channels = config.output_channels as usize;
+
+ println!(
+ "Attempting to build a duplex stream with {} samples and `{config:?}`.",
+ T::FORMAT
+ );
+ let shared_channels = input_channels.min(output_channels);
+ let stream = device.build_duplex_stream(
+ config,
+ move |input: &[T], output: &mut [T], _: &DuplexCallbackInfo| {
+ // Both directions arrive together, so captured audio goes straight out with nothing
+ // buffered in between.
+ //
+ // The channel counts do not need to match. The below maps channels one to one and
+ // silences any that are not shared. In any normal application you would implement
+ // proper mixing and gain compensation.
+ for (captured, rendered) in input
+ .chunks(input_channels)
+ .zip(output.chunks_mut(output_channels))
+ {
+ rendered[..shared_channels].copy_from_slice(&captured[..shared_channels]);
+ rendered[shared_channels..].fill(T::EQUILIBRIUM);
+ }
+ },
+ err_fn,
+ None,
+ )?;
+ println!("Successfully built the stream.");
+
+ stream.start()?;
+ println!("Playing for 10 seconds...");
+ std::thread::sleep(std::time::Duration::from_secs(10));
+ drop(stream);
+ println!("Done!");
+ Ok(())
+}
+
+fn err_fn(err: Error) {
+ match err.kind() {
+ ErrorKind::DeviceChanged | ErrorKind::RealtimeDenied => {
+ eprintln!("{err}")
+ }
+ _ => eprintln!("Stream error: {err}"),
+ }
+}
diff --git a/examples/feedback.rs b/examples/feedback.rs
index b5ee1ce47..b7488ac81 100644
--- a/examples/feedback.rs
+++ b/examples/feedback.rs
@@ -247,7 +247,7 @@ where
output_stream.start()?;
// Run for 10 seconds before closing.
- println!("Playing for 10 seconds... ");
+ println!("Playing for 10 seconds...");
std::thread::sleep(std::time::Duration::from_secs(10));
drop(input_stream);
drop(output_stream);
diff --git a/examples/wasm-beep/index.html b/examples/wasm-beep/index.html
deleted file mode 100644
index 344eecdf3..000000000
--- a/examples/wasm-beep/index.html
+++ /dev/null
@@ -1,14 +0,0 @@
-
-
-
-
- cpal beep example
-
-
-
-
-
-
-
Recording plays your microphone back live. Wear headphones to avoid feedback howl.
-
-
diff --git a/examples/wasm-beep/.gitignore b/examples/webaudio/.gitignore
similarity index 100%
rename from examples/wasm-beep/.gitignore
rename to examples/webaudio/.gitignore
diff --git a/examples/wasm-beep/Cargo.toml b/examples/webaudio/Cargo.toml
similarity index 93%
rename from examples/wasm-beep/Cargo.toml
rename to examples/webaudio/Cargo.toml
index eec5778f7..7739d5dff 100644
--- a/examples/wasm-beep/Cargo.toml
+++ b/examples/webaudio/Cargo.toml
@@ -1,6 +1,6 @@
[package]
-name = "wasm-beep"
-description = "cpal beep example for WebAssembly"
+name = "webaudio"
+description = "cpal Web Audio example for WebAssembly"
version = "0.2.0"
edition = "2024"
rust-version = "1.85"
diff --git a/examples/wasm-beep/README.md b/examples/webaudio/README.md
similarity index 100%
rename from examples/wasm-beep/README.md
rename to examples/webaudio/README.md
diff --git a/examples/webaudio/index.html b/examples/webaudio/index.html
new file mode 100644
index 000000000..2e97ac7d5
--- /dev/null
+++ b/examples/webaudio/index.html
@@ -0,0 +1,28 @@
+
+
+
+
+
+ cpal Web Audio example
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
Both loopback modes play your microphone back live. Wear headphones to avoid feedback howl.
+
The two-stream mode bridges an independent input and output stream with a ring buffer. The
+ duplex mode runs both directions from one callback on one clock, so it needs no ring buffer
+ and the round trip is audibly shorter.
+
+
+
diff --git a/examples/wasm-beep/src/lib.rs b/examples/webaudio/src/lib.rs
similarity index 71%
rename from examples/wasm-beep/src/lib.rs
rename to examples/webaudio/src/lib.rs
index 1e9c8f0b7..7a55b6674 100644
--- a/examples/wasm-beep/src/lib.rs
+++ b/examples/webaudio/src/lib.rs
@@ -1,7 +1,8 @@
use std::{cell::Cell, rc::Rc};
use cpal::{
- Device, Error, ErrorKind, FromSample, Sample, SampleFormat, SizedSample, Stream, StreamConfig,
+ Device, DuplexCallbackInfo, Error, ErrorKind, FromSample, Sample, SampleFormat, SizedSample,
+ Stream, StreamConfig,
traits::{DeviceTrait, HostTrait, StreamTrait},
};
use ringbuf::{
@@ -24,6 +25,8 @@ pub fn main_js() -> Result<(), JsValue> {
let stop_button = document.get_element_by_id("stop").unwrap();
let record_button = document.get_element_by_id("record").unwrap();
let stop_record_button = document.get_element_by_id("stop-record").unwrap();
+ let duplex_button = document.get_element_by_id("duplex").unwrap();
+ let stop_duplex_button = document.get_element_by_id("stop-duplex").unwrap();
// stream needs to be referenced from the "play" and "stop" closures
let stream = Rc::new(Cell::new(None));
@@ -75,6 +78,31 @@ pub fn main_js() -> Result<(), JsValue> {
closure.forget();
}
+ // duplex loopback is a single stream, so one slot holds the whole thing
+ let duplex_stream = Rc::new(Cell::new(None));
+
+ // set up duplex button
+ {
+ let duplex_stream = duplex_stream.clone();
+ let closure = Closure::::new(move |_event: web_sys::MouseEvent| {
+ duplex_stream.set(Some(duplex()));
+ });
+ duplex_button
+ .add_event_listener_with_callback("mousedown", closure.as_ref().unchecked_ref())?;
+ closure.forget();
+ }
+
+ // set up stop-duplex button
+ {
+ let closure = Closure::::new(move |_event: web_sys::MouseEvent| {
+ // stop the stream by dropping it; releases the microphone
+ duplex_stream.take();
+ });
+ stop_duplex_button
+ .add_event_listener_with_callback("mousedown", closure.as_ref().unchecked_ref())?;
+ closure.forget();
+ }
+
Ok(())
}
@@ -163,6 +191,54 @@ fn record() -> (Stream, Stream) {
(input_stream, output_stream)
}
+/// The same live microphone loopback as [`record`], but as one duplex stream instead of two
+/// independent ones. Both directions run from a single callback on a single clock, so no ring
+/// buffer is needed to bridge them and the round trip is a callback rather than a delay line.
+/// Wear headphones: routing a live mic to speakers risks feedback howl.
+fn duplex() -> Stream {
+ let host = cpal::default_host();
+ let device = host
+ .default_output_device()
+ .expect("failed to find a default output device");
+ assert!(
+ device.supports_duplex(),
+ "duplex streams need `navigator.mediaDevices`, which browsers expose only in a \
+ secure context: serve this page over HTTPS or from localhost"
+ );
+
+ let config = device.default_duplex_config().unwrap();
+
+ let err_fn = |err: Error| match err.kind() {
+ ErrorKind::DeviceChanged | ErrorKind::RealtimeDenied => {
+ console::log_1(&format!("{err}").into())
+ }
+ _ => console::error_1(&format!("Stream error: {err}").into()),
+ };
+
+ // WebAudio processes exclusively in f32, so there is no sample format to match on here.
+ let input_channels = config.input_channels as usize;
+ let output_channels = config.output_channels as usize;
+ let stream = device
+ .build_duplex_stream(
+ config,
+ move |input: &[f32], output: &mut [f32], _: &DuplexCallbackInfo| {
+ // The two directions can carry different channel counts, so mix each captured
+ // frame down to mono and fan it back out across the output frame.
+ for (captured, rendered) in input
+ .chunks(input_channels)
+ .zip(output.chunks_mut(output_channels))
+ {
+ rendered.fill(captured.iter().sum::() / input_channels as f32);
+ }
+ },
+ err_fn,
+ None,
+ )
+ .unwrap();
+ stream.start().unwrap();
+ stream
+}
+
fn build_input(device: &Device, config: StreamConfig, mut producer: HeapProd) -> Stream
where
T: Sample + SizedSample,
diff --git a/src/device_description.rs b/src/device_description.rs
index f797aa00b..5618958e5 100644
--- a/src/device_description.rs
+++ b/src/device_description.rs
@@ -143,6 +143,9 @@ pub enum DeviceDirection {
Output,
/// Both input and output
+ ///
+ /// The device has endpoints in both directions. This does not automatically mean that both can
+ /// run from a single stream; see [`DeviceTrait::supports_duplex`](crate::traits::DeviceTrait::supports_duplex).
Duplex,
/// Direction unknown or not yet determined
diff --git a/src/duplex.rs b/src/duplex.rs
index 3800172ff..b98394f18 100644
--- a/src/duplex.rs
+++ b/src/duplex.rs
@@ -2,14 +2,13 @@ use crate::{BufferSize, CallbackInfo, ChannelCount, SampleRate};
/// Information relevant to a single call to the user's duplex stream data callback.
///
-/// Because a duplex stream's input and output share a single clock, `input.timestamp()` and
-/// `output.timestamp()` are drawn from the same time source. The two directions have independent
-/// buffers, so `input.xrun()` and `output.xrun()` can each report a glitch independently for the
-/// same invocation.
+/// `input.timestamp()` and `output.timestamp()` come from the same callback, so they are directly
+/// comparable. Both directions should run on one synchronized clock, but have independent buffers,
+/// so `input.xrun()` and `output.xrun()` can each report a glitch for the same invocation.
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
pub struct DuplexCallbackInfo {
- input: CallbackInfo,
- output: CallbackInfo,
+ pub(crate) input: CallbackInfo,
+ pub(crate) output: CallbackInfo,
}
impl DuplexCallbackInfo {
diff --git a/src/host/audioworklet/mod.rs b/src/host/audioworklet/mod.rs
index aeb077b17..ff1558802 100644
--- a/src/host/audioworklet/mod.rs
+++ b/src/host/audioworklet/mod.rs
@@ -1,7 +1,7 @@
//! Audio Worklet backend implementation.
//!
//! Available on WebAssembly with the `audioworklet` feature. Requires atomics support.
-//! See the `audioworklet-beep` example for setup instructions.
+//! See the `audioworklet` example for setup instructions.
use std::{
cell::RefCell,
@@ -21,9 +21,9 @@ use wasm_bindgen::prelude::*;
use crate::{
BufferSize, CallbackInfo, ChannelCount, Data, DeviceDescription, DeviceDescriptionBuilder,
- DeviceDirection, DeviceId, Error, ErrorKind, FrameCount, Sample, SampleFormat, SampleRate,
- StreamConfig, StreamInstant, StreamTimestamp, SupportedBufferSize, SupportedStreamConfig,
- SupportedStreamConfigRange,
+ DeviceDirection, DeviceId, DuplexCallbackInfo, DuplexStreamConfig, Error, ErrorKind,
+ FrameCount, Sample, SampleFormat, SampleRate, StreamConfig, StreamInstant, StreamTimestamp,
+ SupportedBufferSize, SupportedStreamConfig, SupportedStreamConfigRange,
host::frames_to_duration,
traits::{DeviceTrait, HostTrait, StreamTrait},
};
@@ -77,6 +77,7 @@ const DEFAULT_RENDER_SIZE: u64 = 128;
// Must match the names passed to `registerProcessor()` in worklet.js.
const OUTPUT_PROCESSOR_NAME: &str = "CpalProcessor";
const CAPTURE_PROCESSOR_NAME: &str = "CpalCaptureProcessor";
+const DUPLEX_PROCESSOR_NAME: &str = "CpalDuplexProcessor";
fn render_quantum_size_supported() -> bool {
(|| -> Option {
@@ -149,6 +150,21 @@ fn validate_config(config: &StreamConfig, sample_format: SampleFormat) -> Result
Ok(())
}
+/// Applies [`validate_config`] to each direction of a duplex configuration.
+fn validate_duplex_config(
+ config: &DuplexStreamConfig,
+ input_sample_format: SampleFormat,
+ output_sample_format: SampleFormat,
+) -> Result<(), Error> {
+ let per_direction = |channels| StreamConfig {
+ channels,
+ sample_rate: config.sample_rate,
+ buffer_size: config.buffer_size,
+ };
+ validate_config(&per_direction(config.input_channels), input_sample_format)?;
+ validate_config(&per_direction(config.output_channels), output_sample_format)
+}
+
/// The full matrix of channel counts x sample rates; identical for input and output, since
/// neither is known ahead of time (see the callers' doc comments).
fn supported_configs() -> Vec {
@@ -271,6 +287,11 @@ impl DeviceTrait for Device {
))
}
+ /// One `AudioWorkletNode` renders both directions from a single `process()` call per quantum.
+ fn supports_duplex(&self) -> bool {
+ crate::host::is_get_user_media_available()
+ }
+
fn supported_input_configs(&self) -> Result {
// The actual channel count and sample rate depend on the microphone getUserMedia()
// grants access to, which isn't known ahead of time; WebAudio resamples and up/downmixes
@@ -765,6 +786,293 @@ impl DeviceTrait for Device {
buffer_size_frames,
})
}
+
+ /// Create a duplex stream.
+ ///
+ /// # Async completion
+ ///
+ /// Behaves like [`build_input_stream_raw`](Self::build_input_stream_raw): this returns `Ok`
+ /// once the [`AudioContext`] exists, before microphone permission has been resolved and
+ /// before the worklet module has loaded. Failures after that point are delivered to
+ /// `error_callback`, and [`start`](crate::traits::StreamTrait::start) /
+ /// [`pause`](crate::traits::StreamTrait::pause) calls made in the meantime are queued.
+ ///
+ /// [`AudioContext`]: web_sys::AudioContext
+ fn build_duplex_stream_raw(
+ &self,
+ config: DuplexStreamConfig,
+ input_sample_format: SampleFormat,
+ output_sample_format: SampleFormat,
+ data_callback: D,
+ error_callback: E,
+ _timeout: Option,
+ ) -> Result
+ where
+ D: FnMut(&Data, &mut Data, &DuplexCallbackInfo) + Send + 'static,
+ E: FnMut(Error) + Send + 'static,
+ {
+ validate_duplex_config(&config, input_sample_format, output_sample_format)?;
+ // Keep both `device` timestamps monotonic: the polled outputLatency can drop when the
+ // page calls `setSinkId()` to switch output devices, pulling `device` backward.
+ let mut data_callback = crate::host::monotonic_duplex_callback(data_callback);
+
+ let input_channels = config.input_channels as u32;
+ let output_channels = config.output_channels as u32;
+
+ let stream_opts = web_sys::AudioContextOptions::new();
+ stream_opts.set_sample_rate(config.sample_rate as f32);
+ if let BufferSize::Fixed(n) = config.buffer_size {
+ let _ = js_sys::Reflect::set(
+ stream_opts.as_ref(),
+ &JsValue::from_str("renderSizeHint"),
+ &JsValue::from_f64(n as f64),
+ );
+ }
+
+ let audio_context =
+ web_sys::AudioContext::new_with_context_options(&stream_opts).map_err(|_| {
+ Error::with_message(
+ ErrorKind::UnsupportedConfig,
+ "Failed to create audio context",
+ )
+ })?;
+
+ let destination = audio_context.destination();
+ if output_channels > destination.max_channel_count() {
+ return Err(Error::with_message(
+ ErrorKind::UnsupportedConfig,
+ format!(
+ "Output channel count {} exceeds the destination's maximum of {}",
+ config.output_channels,
+ destination.max_channel_count()
+ ),
+ ));
+ }
+ destination.set_channel_count(output_channels);
+
+ // Chrome rounds renderSizeHint to a power of two; read back the actual quantum.
+ let actual_render_quantum =
+ js_sys::Reflect::get(audio_context.as_ref(), &JsValue::from("renderQuantumSize"))
+ .ok()
+ .and_then(|v| v.as_f64())
+ .map(|v| v as u64);
+
+ let initial_quantum = actual_render_quantum.unwrap_or(match config.buffer_size {
+ BufferSize::Fixed(n) => n as u64,
+ BufferSize::Default => DEFAULT_RENDER_SIZE,
+ });
+ let buffer_size_frames = Arc::new(AtomicU64::new(initial_quantum));
+ let buffer_size_frames_cb = buffer_size_frames.clone();
+
+ let current_time_bits = Arc::new(AtomicU64::new(audio_context.current_time().to_bits()));
+ let current_time_bits_cb = current_time_bits.clone();
+ let current_time_bits_init = current_time_bits.clone();
+
+ let latency_nanos = Arc::new(AtomicU64::new(total_latency_nanos(&audio_context)));
+ let latency_nanos_cb = latency_nanos.clone();
+
+ let (command_tx, mut command_rx) = mpsc::unbounded::();
+
+ let ctx = audio_context.clone();
+ wasm_bindgen_futures::spawn_local(async move {
+ let error_callback = Rc::new(RefCell::new(error_callback));
+
+ let media_stream = match crate::host::request_microphone().await {
+ Ok(stream) => stream,
+ Err(js_err) => {
+ (error_callback.borrow_mut())(crate::host::get_user_media_error(&js_err));
+ let _ = audio_context.close();
+ return;
+ }
+ };
+
+ let result: Result<
+ (
+ web_sys::MediaStreamAudioSourceNode,
+ web_sys::AudioWorkletNode,
+ ),
+ JsValue,
+ > = async {
+ let mod_url = dependent_module!("worklet.js")?;
+ wasm_bindgen_futures::JsFuture::from(ctx.audio_worklet()?.add_module(&mod_url)?)
+ .await?;
+
+ let source = ctx.create_media_stream_source(&media_stream)?;
+
+ let options = web_sys::AudioWorkletNodeOptions::new();
+ options.set_number_of_inputs(1);
+ options.set_number_of_outputs(1);
+ options.set_output_channel_count(&js_sys::Array::of1(&JsValue::from_f64(
+ output_channels as f64,
+ )));
+ // `config.input_channels` is a promise to the caller: every callback delivers
+ // exactly that many interleaved channels. Force WebAudio to up/downmix the
+ // microphone track to match, regardless of how many channels it actually carries.
+ options.set_channel_count(input_channels);
+ options.set_channel_count_mode(web_sys::ChannelCountMode::Explicit);
+
+ options.set_processor_options(Some(&js_sys::Array::of3(
+ &wasm_bindgen::module(),
+ &wasm_bindgen::memory(),
+ &WasmAudioDuplexProcessor::new(Box::new(
+ move |input_interleaved,
+ output_interleaved,
+ frame_size,
+ sample_rate,
+ now| {
+ buffer_size_frames_cb.store(frame_size as u64, Ordering::Relaxed);
+ current_time_bits_cb.store(now.to_bits(), Ordering::Relaxed);
+
+ let input = unsafe {
+ Data::from_parts(
+ input_interleaved.as_ptr() as *mut (),
+ input_interleaved.len(),
+ input_sample_format,
+ )
+ };
+ let mut output = unsafe {
+ Data::from_parts(
+ output_interleaved.as_mut_ptr() as *mut (),
+ output_interleaved.len(),
+ output_sample_format,
+ )
+ };
+
+ // One clock: both directions share the same `callback` instant.
+ let callback = StreamInstant::from_secs_f64(now);
+ let buffer_duration =
+ frames_to_duration(frame_size as FrameCount, sample_rate);
+ let latency =
+ Duration::from_nanos(latency_nanos_cb.load(Ordering::Relaxed));
+
+ let info = DuplexCallbackInfo::new(
+ CallbackInfo {
+ timestamp: StreamTimestamp {
+ callback,
+ device: callback
+ .checked_sub(buffer_duration)
+ .unwrap_or(callback),
+ },
+ xrun: false,
+ },
+ CallbackInfo {
+ timestamp: StreamTimestamp {
+ callback,
+ device: callback + (buffer_duration + latency),
+ },
+ xrun: false,
+ },
+ );
+ (data_callback)(&input, &mut output, &info);
+ },
+ ))
+ .pack()
+ .into(),
+ )));
+ let audio_worklet_node = web_sys::AudioWorkletNode::new_with_options(
+ &ctx,
+ DUPLEX_PROCESSOR_NAME,
+ &options,
+ )?;
+
+ let error_callback_setup = error_callback.clone();
+ let on_processor_error =
+ Closure::::new(move |_event: web_sys::Event| {
+ (error_callback_setup.borrow_mut())(Error::with_message(
+ ErrorKind::BackendError,
+ "AudioWorklet duplex processor failed to initialize or crashed",
+ ));
+ });
+ audio_worklet_node
+ .set_onprocessorerror(Some(on_processor_error.as_ref().unchecked_ref()));
+ on_processor_error.forget();
+
+ // Unlike the capture-only path, the node's output is the real playback signal,
+ // so it goes straight to the destination rather than through a muted gain.
+ source.connect_with_audio_node(&audio_worklet_node)?;
+ audio_worklet_node.connect_with_audio_node(&destination)?;
+
+ Ok((source, audio_worklet_node))
+ }
+ .await;
+
+ // Keep both alive until the Stream is dropped, or the source may be garbage-collected
+ // and silently kill the mic connection; see `build_input_stream_raw`.
+ let (_source, _audio_worklet_node) = match result {
+ Ok(nodes) => nodes,
+ Err(err) => {
+ let message = err
+ .as_string()
+ .unwrap_or_else(|| "Failed to initialize audio worklet".to_string());
+ (error_callback.borrow_mut())(Error::with_message(
+ ErrorKind::HostUnavailable,
+ message,
+ ));
+
+ crate::host::stop_tracks(&media_stream);
+ let _ = audio_context.close();
+ return;
+ }
+ };
+
+ current_time_bits_init.store(audio_context.current_time().to_bits(), Ordering::Relaxed);
+
+ // outputLatency can change at runtime (e.g. an output-device switch) but is only
+ // readable on the main thread, so poll it here and publish it to the worklet via the
+ // shared atomic.
+ let _latency_poller = web_sys::window().and_then(|window| {
+ let poll_ctx = audio_context.clone();
+ let poll_latency = latency_nanos.clone();
+ let closure = Closure::::new(move || {
+ poll_latency.store(total_latency_nanos(&poll_ctx), Ordering::Relaxed);
+ });
+ window
+ .set_interval_with_callback_and_timeout_and_arguments_0(
+ closure.as_ref().unchecked_ref(),
+ LATENCY_POLL_INTERVAL.as_millis() as i32,
+ )
+ .ok()
+ .map(|interval_id| LatencyPoller {
+ window,
+ interval_id,
+ _closure: closure,
+ })
+ });
+
+ // Process play/pause commands from any thread until Stream is dropped.
+ // Dropping Stream closes command_tx, which terminates this loop.
+ while let Some(cmd) = command_rx.next().await {
+ match cmd {
+ Command::Play => {
+ if audio_context.resume().is_err() {
+ (error_callback.borrow_mut())(Error::with_message(
+ ErrorKind::DeviceNotAvailable,
+ "Failed to resume audio context",
+ ));
+ }
+ }
+ Command::Pause => {
+ if audio_context.suspend().is_err() {
+ (error_callback.borrow_mut())(Error::with_message(
+ ErrorKind::DeviceNotAvailable,
+ "Failed to suspend audio context",
+ ));
+ }
+ }
+ }
+ }
+
+ // Stream dropped: release the microphone and close the AudioContext.
+ crate::host::stop_tracks(&media_stream);
+ let _ = audio_context.close();
+ });
+
+ Ok(Self::Stream {
+ command_tx,
+ current_time_bits,
+ buffer_size_frames,
+ })
+ }
}
impl StreamTrait for Stream {
@@ -814,6 +1122,15 @@ impl Iterator for Devices {
}
}
+/// Grows `buffer` so it can hold `channels * frame_size` interleaved samples, never shrinking it,
+/// and returns the length of the region actually in use.
+fn resize_interleaved(buffer: &mut Vec, channels: u32, frame_size: u32) -> usize {
+ let len = channels as usize * frame_size as usize;
+ buffer.resize(len.max(buffer.len()), f32::EQUILIBRIUM);
+ len
+}
+
+// The interleaved buffer, plus frame size, sample rate, and current time.
type AudioProcessorCallback = Box;
/// WasmAudioProcessor provides an interface for the JavaScript code
@@ -843,31 +1160,19 @@ impl WasmAudioProcessor {
sample_rate: u32,
current_time: f64,
) -> u32 {
- let frame_size = frame_size as usize;
-
- // Ensure there's enough space in the output buffer
- // This likely only occurs once, or very few times.
- let interleaved_buffer_size = channels as usize * frame_size;
- self.interleaved_buffer.resize(
- interleaved_buffer_size.max(self.interleaved_buffer.len()),
- f32::EQUILIBRIUM,
- );
+ let interleaved_buffer_size =
+ resize_interleaved(&mut self.interleaved_buffer, channels, frame_size);
self.interleaved_buffer[..interleaved_buffer_size].fill(f32::EQUILIBRIUM);
(self.callback)(
&mut self.interleaved_buffer[..interleaved_buffer_size],
- frame_size as u32,
+ frame_size,
sample_rate,
current_time,
);
// Returns a pointer to the raw interleaved buffer to Javascript so
// it can deinterleave it into the output buffers.
- //
- // Deinterleaving is done on the Javascript side because it's simpler and it may be faster.
- // Doing it this way avoids an extra copy and the JS deinterleaving code
- // is likely heavily optimized by the browser's JS engine,
- // although I have not tested that assumption.
self.interleaved_buffer.as_mut_ptr() as _
}
@@ -917,11 +1222,7 @@ impl WasmAudioCaptureProcessor {
/// Ensures the capture buffer can hold `channels * frame_size` samples and returns a pointer
/// for JS to interleave captured audio into.
pub fn capture_buffer_ptr(&mut self, channels: u32, frame_size: u32) -> u32 {
- let interleaved_buffer_size = channels as usize * frame_size as usize;
- self.interleaved_buffer.resize(
- interleaved_buffer_size.max(self.interleaved_buffer.len()),
- f32::EQUILIBRIUM,
- );
+ resize_interleaved(&mut self.interleaved_buffer, channels, frame_size);
self.interleaved_buffer.as_mut_ptr() as _
}
@@ -956,6 +1257,86 @@ impl WasmAudioCaptureProcessor {
}
}
+// The interleaved captured buffer and an interleaved buffer to render into, plus frame size,
+// sample rate, and current time.
+type AudioDuplexCallback = Box;
+
+/// WasmAudioDuplexProcessor provides an interface for the JavaScript code running in the
+/// AudioWorklet to hand captured audio to Rust and take rendered audio back in one call.
+#[wasm_bindgen]
+pub struct WasmAudioDuplexProcessor {
+ input_buffer: Vec,
+ output_buffer: Vec,
+ callback: AudioDuplexCallback,
+}
+
+impl WasmAudioDuplexProcessor {
+ pub fn new(callback: AudioDuplexCallback) -> Self {
+ Self {
+ input_buffer: Vec::new(),
+ output_buffer: Vec::new(),
+ callback,
+ }
+ }
+}
+
+#[wasm_bindgen]
+impl WasmAudioDuplexProcessor {
+ /// Sizes both buffers for this quantum and returns a pointer for JS to interleave captured
+ /// audio into. Must be called to grow Wasm memory before [`process`](Self::process) runs.
+ pub fn prepare(&mut self, input_channels: u32, output_channels: u32, frame_size: u32) -> u32 {
+ resize_interleaved(&mut self.input_buffer, input_channels, frame_size);
+ resize_interleaved(&mut self.output_buffer, output_channels, frame_size);
+ self.input_buffer.as_mut_ptr() as _
+ }
+
+ /// Pointer for JS to deinterleave out. Valid only once [`prepare`](Self::prepare) has run.
+ pub fn output_buffer_ptr(&mut self) -> u32 {
+ self.output_buffer.as_mut_ptr() as _
+ }
+
+ /// Invokes the Rust callback with the captured audio, plus the output buffer to fill.
+ pub fn process(
+ &mut self,
+ input_channels: u32,
+ output_channels: u32,
+ frame_size: u32,
+ sample_rate: u32,
+ current_time: f64,
+ ) {
+ let input_len = input_channels as usize * frame_size as usize;
+ let output_len = output_channels as usize * frame_size as usize;
+
+ // Destructured so the callback can hold both buffers at once.
+ let Self {
+ input_buffer,
+ output_buffer,
+ callback,
+ } = self;
+ output_buffer[..output_len].fill(f32::EQUILIBRIUM);
+ callback(
+ &input_buffer[..input_len],
+ &mut output_buffer[..output_len],
+ frame_size,
+ sample_rate,
+ current_time,
+ );
+ }
+
+ pub fn pack(self) -> usize {
+ Box::into_raw(Box::new(self)) as usize
+ }
+
+ /// # Safety
+ ///
+ /// The `val` parameter must be a value previously returned by `Self::pack`.
+ /// It must not have already been unpacked or deallocated, and must not be used after this call.
+ /// Using an invalid or already-consumed pointer will result in undefined behavior.
+ pub unsafe fn unpack(val: usize) -> Self {
+ unsafe { *Box::from_raw(val as *mut _) }
+ }
+}
+
/// Drives a `setInterval` that refreshes the shared output-latency value.
struct LatencyPoller {
window: web_sys::Window,
diff --git a/src/host/audioworklet/worklet.js b/src/host/audioworklet/worklet.js
index 583181e6e..51d3e7708 100644
--- a/src/host/audioworklet/worklet.js
+++ b/src/host/audioworklet/worklet.js
@@ -1,61 +1,105 @@
-registerProcessor("CpalProcessor", class WasmProcessor extends AudioWorkletProcessor {
- constructor(options) {
+// Shared plumbing for the cpal processors. Interleaving and deinterleaving are done here rather
+// than in Rust because it avoids an extra copy and the JS engine optimizes these loops well.
+class CpalProcessorBase extends AudioWorkletProcessor {
+ constructor(options, ProcessorClass) {
super();
let [module, memory, handle] = options.processorOptions;
bindgen.initSync({ module, memory });
- this.processor = bindgen.WasmAudioProcessor.unpack(handle);
+ this.processor = ProcessorClass.unpack(handle);
+ this.name = ProcessorClass.name;
this.memory = memory;
this.wasm_memory = new Float32Array(memory.buffer);
}
- process(inputs, outputs) {
- // Check if memory grew and update view
+ // Growing Wasm memory detaches the old view, so it has to be re-taken after any call into
+ // Rust that may have allocated. Both accessors below do this for their caller.
+ refresh_memory() {
if (this.wasm_memory.buffer !== this.memory.buffer) {
this.wasm_memory = new Float32Array(this.memory.buffer);
}
+ }
- const channels = outputs[0];
+ // Resolves `ptr` against the current Wasm memory, or returns -1 (having logged) if the range
+ // it addresses does not fit.
+ sample_offset(ptr, samples) {
+ this.refresh_memory();
+ const start = ptr / Float32Array.BYTES_PER_ELEMENT;
+ if (start + samples > this.wasm_memory.length) {
+ console.error(`${this.name}: Audio buffer out of bounds! Ptr:`, ptr, "Len:", samples);
+ return -1;
+ }
+ return start;
+ }
+
+ // Reads sequentially from `channels` and writes strided into Wasm at byte pointer `ptr`.
+ // Returns false if the buffer does not fit in Wasm memory.
+ interleave(channels, ptr, frame_size) {
const channels_count = channels.length;
- const frame_size = channels[0].length;
- const interleaved_ptr = this.processor.process(
- channels_count,
- frame_size,
- sampleRate,
- currentTime
- );
+ const start = this.sample_offset(ptr, frame_size * channels_count);
+ if (start < 0) {
+ return false;
+ }
- const interleaved_start = interleaved_ptr / Float32Array.BYTES_PER_ELEMENT;
const interleaved = this.wasm_memory;
+ for (let ch = 0; ch < channels_count; ch++) {
+ const channel = channels[ch];
+ let write_pos = start + ch;
- const total_samples = frame_size * channels_count;
- if (interleaved_start + total_samples > this.wasm_memory.length) {
- console.error("CpalProcessor: Audio buffer out of bounds! Ptr:", interleaved_ptr, "Len:", total_samples);
- return false; // Safely stop the node
+ for (let i = 0; i < frame_size; i++) {
+ interleaved[write_pos] = channel[i];
+ write_pos += channels_count;
+ }
+ }
+ return true;
+ }
+
+ // Reads strided from Wasm at byte pointer `ptr` and writes sequentially into `channels`.
+ // Returns false if the buffer does not fit in Wasm memory.
+ deinterleave(channels, ptr, frame_size) {
+ const channels_count = channels.length;
+ const start = this.sample_offset(ptr, frame_size * channels_count);
+ if (start < 0) {
+ return false;
}
- // Deinterleave: read strided from Wasm, write sequential to output
+ const interleaved = this.wasm_memory;
for (let ch = 0; ch < channels_count; ch++) {
const channel = channels[ch];
- let read_pos = interleaved_start + ch;
+ let read_pos = start + ch;
for (let i = 0; i < frame_size; i++) {
channel[i] = interleaved[read_pos];
read_pos += channels_count;
}
}
-
return true;
}
+}
+
+registerProcessor("CpalProcessor", class WasmProcessor extends CpalProcessorBase {
+ constructor(options) {
+ super(options, bindgen.WasmAudioProcessor);
+ }
+
+ process(inputs, outputs) {
+ const channels = outputs[0];
+ const frame_size = channels[0].length;
+
+ const interleaved_ptr = this.processor.process(
+ channels.length,
+ frame_size,
+ sampleRate,
+ currentTime
+ );
+
+ // Safely stop the node if the buffer does not fit.
+ return this.deinterleave(channels, interleaved_ptr, frame_size);
+ }
});
-registerProcessor("CpalCaptureProcessor", class WasmCaptureProcessor extends AudioWorkletProcessor {
+registerProcessor("CpalCaptureProcessor", class WasmCaptureProcessor extends CpalProcessorBase {
constructor(options) {
- super();
- let [module, memory, handle] = options.processorOptions;
- bindgen.initSync({ module, memory });
- this.processor = bindgen.WasmAudioCaptureProcessor.unpack(handle);
- this.memory = memory;
- this.wasm_memory = new Float32Array(memory.buffer);
+ super(options, bindgen.WasmAudioCaptureProcessor);
}
process(inputs) {
@@ -68,32 +112,52 @@ registerProcessor("CpalCaptureProcessor", class WasmCaptureProcessor extends Aud
const frame_size = channels[0].length;
const interleaved_ptr = this.processor.capture_buffer_ptr(channels_count, frame_size);
-
- // capture_buffer_ptr() may have grown Wasm memory; refresh the view before writing.
- if (this.wasm_memory.buffer !== this.memory.buffer) {
- this.wasm_memory = new Float32Array(this.memory.buffer);
- }
-
- const interleaved_start = interleaved_ptr / Float32Array.BYTES_PER_ELEMENT;
- const total_samples = frame_size * channels_count;
- if (interleaved_start + total_samples > this.wasm_memory.length) {
- console.error("CpalCaptureProcessor: Audio buffer out of bounds! Ptr:", interleaved_ptr, "Len:", total_samples);
+ if (!this.interleave(channels, interleaved_ptr, frame_size)) {
return false; // Safely stop the node
}
- // Interleave: read sequential from the input channels, write strided into Wasm
- for (let ch = 0; ch < channels_count; ch++) {
- const channel = channels[ch];
- let write_pos = interleaved_start + ch;
+ this.processor.process_captured(channels_count, frame_size, sampleRate, currentTime);
- for (let i = 0; i < frame_size; i++) {
- this.wasm_memory[write_pos] = channel[i];
- write_pos += channels_count;
- }
+ return true;
+ }
+});
+
+registerProcessor("CpalDuplexProcessor", class WasmDuplexProcessor extends CpalProcessorBase {
+ constructor(options) {
+ super(options, bindgen.WasmAudioDuplexProcessor);
+ }
+
+ process(inputs, outputs) {
+ const output_channels = outputs[0];
+ const output_channels_count = output_channels.length;
+ const frame_size = output_channels[0].length;
+
+ // inputs[0] is empty until the microphone source is connected. Keep rendering output
+ // with a zero-channel input rather than stalling the graph waiting for it.
+ const input_channels = inputs[0];
+ const input_channels_count = input_channels.length;
+
+ const input_ptr = this.processor.prepare(
+ input_channels_count,
+ output_channels_count,
+ frame_size
+ );
+ if (!this.interleave(input_channels, input_ptr, frame_size)) {
+ return false; // Safely stop the node
}
- this.processor.process_captured(channels_count, frame_size, sampleRate, currentTime);
+ // prepare() sized both buffers, so this cannot grow Wasm memory again. Read it before
+ // process() runs the user callback, which can.
+ const output_ptr = this.processor.output_buffer_ptr();
- return true;
+ this.processor.process(
+ input_channels_count,
+ output_channels_count,
+ frame_size,
+ sampleRate,
+ currentTime
+ );
+
+ return this.deinterleave(output_channels, output_ptr, frame_size);
}
});
diff --git a/src/host/mod.rs b/src/host/mod.rs
index b109acb59..b1df58e1d 100644
--- a/src/host/mod.rs
+++ b/src/host/mod.rs
@@ -307,6 +307,26 @@ where
}
}
+/// Wraps a duplex data callback so neither direction's `device` timestamp regresses across
+/// callbacks. The two directions are clamped independently.
+#[allow(dead_code)]
+pub(crate) fn monotonic_duplex_callback(
+ mut data_callback: D,
+) -> impl FnMut(&crate::Data, &mut crate::Data, &crate::DuplexCallbackInfo) + Send + 'static
+where
+ D: FnMut(&crate::Data, &mut crate::Data, &crate::DuplexCallbackInfo) + Send + 'static,
+{
+ let mut input_floor = 0u64;
+ let mut output_floor = 0u64;
+ move |input, output, info| {
+ let mut info = *info;
+ info.input.timestamp.device = non_decreasing(&mut input_floor, info.input.timestamp.device);
+ info.output.timestamp.device =
+ non_decreasing(&mut output_floor, info.output.timestamp.device);
+ data_callback(input, output, &info);
+ }
+}
+
/// Maps a rejected `getUserMedia()` promise to a [`crate::Error`], based on the DOMException
/// `name` the browser rejects with.
///
diff --git a/src/host/webaudio/mod.rs b/src/host/webaudio/mod.rs
index 6b2f3a299..f8cc3d700 100644
--- a/src/host/webaudio/mod.rs
+++ b/src/host/webaudio/mod.rs
@@ -25,11 +25,13 @@ use futures_util::StreamExt as _;
type OutputDataCallbackArc = Arc>;
type InputDataCallbackArc = Arc>;
+type DuplexDataCallbackArc = Arc>;
type CaptureGraphResult = Result<
(
MediaStreamAudioSourceNode,
ScriptProcessorNode,
- GainNode,
+ // Needed by capture-only paths.
+ Option,
Closure,
),
JsValue,
@@ -46,9 +48,9 @@ use self::{
};
use crate::{
BufferSize, CallbackInfo, ChannelCount, Data, DeviceDescription, DeviceDescriptionBuilder,
- DeviceDirection, DeviceId, Error, ErrorKind, FrameCount, Sample, SampleFormat, SampleRate,
- StreamConfig, StreamInstant, StreamTimestamp, SupportedBufferSize, SupportedStreamConfig,
- SupportedStreamConfigRange,
+ DeviceDirection, DeviceId, DuplexCallbackInfo, DuplexStreamConfig, Error, ErrorKind,
+ FrameCount, Sample, SampleFormat, SampleRate, StreamConfig, StreamInstant, StreamTimestamp,
+ SupportedBufferSize, SupportedStreamConfig, SupportedStreamConfigRange,
host::ErrorCallbackArc,
traits::{DeviceTrait, HostTrait, StreamTrait},
};
@@ -109,15 +111,15 @@ unsafe impl Send for Stream {}
unsafe impl Sync for Stream {}
// With atomics, all Stream fields auto-derive Send+Sync.
-/// Keeps the getUserMedia() capture graph alive for the lifetime of an input [`Stream`].
-/// Stopping the underlying tracks on drop releases the microphone and turns off the browser's
-/// capture indicator; merely disconnecting the WebAudio graph does not.
+/// Keeps the getUserMedia() capture graph alive for the lifetime of an input or duplex
+/// [`Stream`], and calls [`stop_tracks`](crate::host::stop_tracks) on drop.
#[cfg(not(target_feature = "atomics"))]
struct CaptureHandles {
media_stream: MediaStream,
_source: MediaStreamAudioSourceNode,
_processor: ScriptProcessorNode,
- _mute_gain: GainNode,
+ /// `None` on the duplex path, whose output goes straight to the destination.
+ _mute_gain: Option,
_on_audio_process: Closure,
}
@@ -275,6 +277,11 @@ impl DeviceTrait for Device {
Self::default_output_config(self)
}
+ /// One `ScriptProcessorNode` renders both directions from a single `onaudioprocess` event.
+ fn supports_duplex(&self) -> bool {
+ is_get_user_media_available()
+ }
+
/// Create an input stream capturing microphone audio via `getUserMedia()`.
///
/// # Async completion
@@ -306,18 +313,7 @@ impl DeviceTrait for Device {
let n_channels = config.channels as usize;
- let buffer_size_frames = match config.buffer_size {
- BufferSize::Fixed(v) => v as usize,
- BufferSize::Default => DEFAULT_BUFFER_SIZE,
- };
- if !SCRIPT_PROCESSOR_VALID_BUFFER_SIZES.contains(&buffer_size_frames) {
- return Err(Error::with_message(
- ErrorKind::UnsupportedConfig,
- format!(
- "Buffer size {buffer_size_frames} is not supported; must be one of {SCRIPT_PROCESSOR_VALID_BUFFER_SIZES:?}"
- ),
- ));
- }
+ let buffer_size_frames = script_processor_buffer_size(config.buffer_size)?;
let buffer_duration_secs = buffer_time_step_secs(buffer_size_frames, config.sample_rate);
let data_callback = crate::host::monotonic_input_callback(data_callback);
@@ -465,7 +461,7 @@ impl DeviceTrait for Device {
processor.set_onaudioprocess(Some(on_audio_process.as_ref().unchecked_ref()));
- Ok((source, processor, mute_gain, on_audio_process))
+ Ok((source, processor, Some(mute_gain), on_audio_process))
};
let (source, processor, mute_gain, on_audio_process) = match build_graph() {
@@ -946,6 +942,375 @@ impl DeviceTrait for Device {
})
}
}
+
+ /// Create a duplex stream.
+ ///
+ /// # Async completion
+ ///
+ /// Behaves like [`build_input_stream_raw`](Self::build_input_stream_raw): this returns `Ok`
+ /// once the [`AudioContext`] exists, before the browser has granted or denied microphone
+ /// access. Failures after that point are delivered to `error_callback`.
+ fn build_duplex_stream_raw(
+ &self,
+ config: DuplexStreamConfig,
+ input_sample_format: SampleFormat,
+ output_sample_format: SampleFormat,
+ data_callback: D,
+ error_callback: E,
+ _timeout: Option,
+ ) -> Result
+ where
+ D: FnMut(&Data, &mut Data, &DuplexCallbackInfo) + Send + 'static,
+ E: FnMut(Error) + Send + 'static,
+ {
+ validate_duplex_config(&config, input_sample_format, output_sample_format)?;
+
+ let input_channels = config.input_channels as usize;
+ let output_channels = config.output_channels as usize;
+ let buffer_size_frames = script_processor_buffer_size(config.buffer_size)?;
+ let buffer_duration_secs = buffer_time_step_secs(buffer_size_frames, config.sample_rate);
+
+ // Keep both `device` timestamps monotonic: outputLatency can drop (e.g. the page calls
+ // `setSinkId()` to switch output devices), which would pull `device` backward.
+ let data_callback = crate::host::monotonic_duplex_callback(data_callback);
+ let data_callback: DuplexDataCallbackArc = Arc::new(Mutex::new(data_callback));
+ let error_callback: ErrorCallbackArc = Arc::new(Mutex::new(error_callback));
+
+ let stream_opts = AudioContextOptions::new();
+ stream_opts.set_sample_rate(config.sample_rate as f32);
+ let ctx = AudioContext::new_with_context_options(&stream_opts).map_err(|_| {
+ Error::with_message(
+ ErrorKind::UnsupportedConfig,
+ "Failed to create audio context",
+ )
+ })?;
+
+ let destination = ctx.destination();
+ if config.output_channels as u32 > destination.max_channel_count() {
+ return Err(Error::with_message(
+ ErrorKind::UnsupportedConfig,
+ format!(
+ "Output channel count {} exceeds the destination's maximum of {}",
+ config.output_channels,
+ destination.max_channel_count()
+ ),
+ ));
+ }
+ destination.set_channel_count(config.output_channels as u32);
+
+ // baseLatency is fixed for the lifetime of the AudioContext.
+ let base_latency_secs = js_sys::Reflect::get(ctx.as_ref(), &JsValue::from("baseLatency"))
+ .ok()
+ .and_then(|v| v.as_f64())
+ .unwrap_or(0.0);
+
+ // SAFETY: see the SAFETY note in `build_output_stream_raw`; the same single-thread
+ // (or task-confined) reasoning applies here.
+ #[allow(clippy::arc_with_non_send_sync)]
+ let ctx = Arc::new(ctx);
+ let ctx_task = ctx.clone();
+
+ #[cfg(not(target_feature = "atomics"))]
+ let is_started = Arc::new(AtomicBool::new(false));
+ #[cfg(not(target_feature = "atomics"))]
+ let capture: Rc>> = Rc::new(Cell::new(None));
+ #[cfg(not(target_feature = "atomics"))]
+ let capture_weak = Rc::downgrade(&capture);
+ #[cfg(not(target_feature = "atomics"))]
+ let stream_config = StreamConfig {
+ channels: config.output_channels,
+ sample_rate: config.sample_rate,
+ buffer_size: config.buffer_size,
+ };
+
+ #[cfg(target_feature = "atomics")]
+ let current_time_bits = Arc::new(AtomicU64::new(ctx.current_time().to_bits()));
+ #[cfg(target_feature = "atomics")]
+ let current_time_bits_stream = current_time_bits.clone();
+ #[cfg(target_feature = "atomics")]
+ let (command_tx, mut command_rx) = mpsc::unbounded::();
+
+ wasm_bindgen_futures::spawn_local(async move {
+ let media_stream = match request_microphone().await {
+ Ok(stream) => stream,
+ Err(js_err) => {
+ error_callback.lock().unwrap_or_else(|e| e.into_inner())(
+ crate::host::get_user_media_error(&js_err),
+ );
+ return;
+ }
+ };
+
+ #[cfg(not(target_feature = "atomics"))]
+ let capture = match capture_weak.upgrade() {
+ Some(capture) => capture,
+ None => {
+ // The Stream was dropped while permission was pending; release the
+ // microphone instead of leaving a live capture session behind.
+ stop_tracks(&media_stream);
+ return;
+ }
+ };
+
+ let build_graph = || -> CaptureGraphResult {
+ let source = ctx_task.create_media_stream_source(&media_stream)?;
+ let processor = ctx_task
+ .create_script_processor_with_buffer_size_and_number_of_input_channels_and_number_of_output_channels(
+ buffer_size_frames as u32,
+ input_channels as u32,
+ output_channels as u32,
+ )?;
+ source.connect_with_audio_node(&processor)?;
+ processor.connect_with_audio_node(&ctx_task.destination())?;
+
+ let mut input_buffer_interleaved =
+ vec![f32::EQUILIBRIUM; input_channels * buffer_size_frames];
+ let mut output_buffer_interleaved =
+ vec![f32::EQUILIBRIUM; output_channels * buffer_size_frames];
+ let mut temporary_channel_buffer = vec![f32::EQUILIBRIUM; buffer_size_frames];
+ let ctx_cb = ctx_task.clone();
+ let data_callback_cb = data_callback.clone();
+ let error_callback_cb = error_callback.clone();
+ #[cfg(target_feature = "atomics")]
+ let current_time_bits_cb = current_time_bits.clone();
+
+ #[cfg(target_feature = "atomics")]
+ let temporary_channel_array_view = {
+ let temporary_channel_array = js_sys::ArrayBuffer::new(
+ (std::mem::size_of::() * buffer_size_frames) as u32,
+ );
+ js_sys::Float32Array::new(&temporary_channel_array)
+ };
+
+ let on_audio_process = Closure::wrap(Box::new(move |event: AudioProcessingEvent| {
+ let now = ctx_cb.current_time();
+ #[cfg(target_feature = "atomics")]
+ current_time_bits_cb.store(now.to_bits(), Ordering::Relaxed);
+
+ let (input_buffer, output_buffer) =
+ match (event.input_buffer(), event.output_buffer()) {
+ (Ok(input), Ok(output)) => (input, output),
+ _ => {
+ (error_callback_cb.lock().unwrap_or_else(|e| e.into_inner()))(
+ Error::with_message(
+ ErrorKind::StreamInvalidated,
+ "Failed to access the duplex audio buffers",
+ ),
+ );
+ return;
+ }
+ };
+
+ // Deinterleave from the browser's per-channel buffers into our interleaved
+ // scratch buffer.
+ for channel in 0..input_channels {
+ if input_buffer
+ .copy_from_channel(&mut temporary_channel_buffer, channel as i32)
+ .is_err()
+ {
+ (error_callback_cb.lock().unwrap_or_else(|e| e.into_inner()))(
+ Error::with_message(
+ ErrorKind::StreamInvalidated,
+ "Failed to copy captured audio",
+ ),
+ );
+ return;
+ }
+ for i in 0..buffer_size_frames {
+ input_buffer_interleaved[input_channels * i + channel] =
+ temporary_channel_buffer[i];
+ }
+ }
+
+ output_buffer_interleaved.fill(f32::EQUILIBRIUM);
+
+ let input = unsafe {
+ Data::from_parts(
+ input_buffer_interleaved.as_mut_ptr() as *mut (),
+ input_buffer_interleaved.len(),
+ input_sample_format,
+ )
+ };
+ let mut output = unsafe {
+ Data::from_parts(
+ output_buffer_interleaved.as_mut_ptr() as *mut (),
+ output_buffer_interleaved.len(),
+ output_sample_format,
+ )
+ };
+
+ // outputLatency can change at runtime, so read it each callback.
+ let output_latency_secs =
+ js_sys::Reflect::get(ctx_cb.as_ref(), &JsValue::from("outputLatency"))
+ .ok()
+ .and_then(|v| v.as_f64())
+ .unwrap_or(0.0);
+ let total_hw_latency_secs = {
+ let sum = base_latency_secs + output_latency_secs;
+ if sum.is_finite() { sum.max(0.0) } else { 0.0 }
+ };
+
+ // One clock: both directions share the same `callback` instant.
+ let callback = StreamInstant::from_secs_f64(now);
+ let info = DuplexCallbackInfo::new(
+ CallbackInfo {
+ timestamp: StreamTimestamp {
+ callback,
+ device: StreamInstant::from_secs_f64(
+ (now - buffer_duration_secs).max(0.0),
+ ),
+ },
+ xrun: false,
+ },
+ CallbackInfo {
+ timestamp: StreamTimestamp {
+ callback,
+ device: StreamInstant::from_secs_f64(
+ event.playback_time() + total_hw_latency_secs,
+ ),
+ },
+ xrun: false,
+ },
+ );
+
+ match data_callback_cb.lock() {
+ Ok(mut data_callback) => {
+ (data_callback.deref_mut())(&input, &mut output, &info)
+ }
+ Err(_) => {
+ (error_callback_cb.lock().unwrap_or_else(|e| e.into_inner()))(
+ Error::with_message(
+ ErrorKind::StreamInvalidated,
+ "Stream lock poisoned",
+ ),
+ );
+ return;
+ }
+ }
+
+ // Interleaved scratch back out into the browser's per-channel buffers.
+ for channel in 0..output_channels {
+ for i in 0..buffer_size_frames {
+ temporary_channel_buffer[i] =
+ output_buffer_interleaved[output_channels * i + channel];
+ }
+
+ #[cfg(not(target_feature = "atomics"))]
+ let copied = output_buffer
+ .copy_to_channel(&temporary_channel_buffer, channel as i32);
+
+ // copyToChannel cannot be directly copied into from a SharedArrayBuffer,
+ // which WASM memory is backed by if the 'atomics' flag is enabled.
+ #[cfg(target_feature = "atomics")]
+ let copied = {
+ temporary_channel_array_view.copy_from(&temporary_channel_buffer);
+ output_buffer
+ .unchecked_ref::()
+ .copy_to_channel(&temporary_channel_array_view, channel as i32)
+ };
+
+ if copied.is_err() {
+ (error_callback_cb.lock().unwrap_or_else(|e| e.into_inner()))(
+ Error::with_message(
+ ErrorKind::StreamInvalidated,
+ "Failed to copy rendered audio",
+ ),
+ );
+ return;
+ }
+ }
+ })
+ as Box);
+
+ processor.set_onaudioprocess(Some(on_audio_process.as_ref().unchecked_ref()));
+
+ // The processor's output is the playback signal, so it needs no muted gain.
+ Ok((source, processor, None, on_audio_process))
+ };
+
+ let (source, processor, _mute_gain, on_audio_process) = match build_graph() {
+ Ok(handles) => handles,
+ Err(js_err) => {
+ stop_tracks(&media_stream);
+ error_callback.lock().unwrap_or_else(|e| e.into_inner())(Error::with_message(
+ ErrorKind::UnsupportedConfig,
+ format!("Failed to initialize duplex graph: {js_err:?}"),
+ ));
+ return;
+ }
+ };
+
+ #[cfg(not(target_feature = "atomics"))]
+ {
+ capture.set(Some(CaptureHandles {
+ media_stream,
+ _source: source,
+ _processor: processor,
+ _mute_gain,
+ _on_audio_process: on_audio_process,
+ }));
+ }
+
+ #[cfg(target_feature = "atomics")]
+ {
+ let _source = source;
+ let _processor = processor;
+ let _on_audio_process = on_audio_process;
+ // Process play/pause commands from any thread until Stream is dropped.
+ // Dropping Stream closes command_tx, which terminates this loop.
+ while let Some(cmd) = command_rx.next().await {
+ match cmd {
+ Command::Play => {
+ if ctx_task.resume().is_err() {
+ error_callback.lock().unwrap_or_else(|e| e.into_inner())(
+ Error::with_message(
+ ErrorKind::DeviceNotAvailable,
+ "Failed to resume audio context",
+ ),
+ );
+ }
+ }
+ Command::Pause => {
+ if ctx_task.suspend().is_err() {
+ error_callback.lock().unwrap_or_else(|e| e.into_inner())(
+ Error::with_message(
+ ErrorKind::DeviceNotAvailable,
+ "Failed to suspend audio context",
+ ),
+ );
+ }
+ }
+ }
+ }
+ // Stream dropped: release the microphone and close the AudioContext.
+ stop_tracks(&media_stream);
+ let _ = ctx_task.close();
+ }
+ });
+
+ #[cfg(not(target_feature = "atomics"))]
+ {
+ Ok(Self::Stream {
+ ctx,
+ // The ScriptProcessorNode fires on its own once the context is running, so the
+ // buffer-source scheduling the output path needs has no counterpart here.
+ on_ended_closures: Vec::new(),
+ config: stream_config,
+ buffer_size_frames,
+ is_started,
+ _capture: capture,
+ })
+ }
+
+ #[cfg(target_feature = "atomics")]
+ {
+ Ok(Self::Stream {
+ command_tx,
+ current_time_bits: current_time_bits_stream,
+ buffer_size_frames,
+ })
+ }
+ }
}
// Without atomics: AudioContext is accessible directly from Stream.
@@ -1090,6 +1455,38 @@ fn validate_config(config: &StreamConfig, sample_format: SampleFormat) -> Result
Ok(())
}
+/// Gets a configured buffer size that `createScriptProcessor()` accepts.
+fn script_processor_buffer_size(buffer_size: BufferSize) -> Result {
+ let frames = match buffer_size {
+ BufferSize::Fixed(v) => v as usize,
+ BufferSize::Default => DEFAULT_BUFFER_SIZE,
+ };
+ if !SCRIPT_PROCESSOR_VALID_BUFFER_SIZES.contains(&frames) {
+ return Err(Error::with_message(
+ ErrorKind::UnsupportedConfig,
+ format!(
+ "Buffer size {frames} is not supported; must be one of {SCRIPT_PROCESSOR_VALID_BUFFER_SIZES:?}"
+ ),
+ ));
+ }
+ Ok(frames)
+}
+
+/// Applies [`validate_config`] to each direction of a duplex configuration.
+fn validate_duplex_config(
+ config: &DuplexStreamConfig,
+ input_sample_format: SampleFormat,
+ output_sample_format: SampleFormat,
+) -> Result<(), Error> {
+ let per_direction = |channels| StreamConfig {
+ channels,
+ sample_rate: config.sample_rate,
+ buffer_size: config.buffer_size,
+ };
+ validate_config(&per_direction(config.input_channels), input_sample_format)?;
+ validate_config(&per_direction(config.output_channels), output_sample_format)
+}
+
/// The full matrix of channel counts x sample rates for a given buffer-size range.
fn supported_configs(buffer_size: SupportedBufferSize) -> Vec {
(MIN_CHANNELS..=MAX_CHANNELS)
diff --git a/src/traits.rs b/src/traits.rs
index 2a9004ad5..4eec778cd 100644
--- a/src/traits.rs
+++ b/src/traits.rs
@@ -12,8 +12,9 @@ use std::{
};
use crate::{
- CallbackInfo, Data, DeviceDescription, DeviceId, DuplexCallbackInfo, DuplexStreamConfig, Error,
- ErrorKind, InputDevices, OutputDevices, SampleFormat, SizedSample, StreamConfig, StreamInstant,
+ BufferSize, CallbackInfo, ChannelCount, Data, DeviceDescription, DeviceId, DuplexCallbackInfo,
+ DuplexStreamConfig, Error, ErrorKind, InputDevices, OutputDevices, SAMPLE_RATE_48K,
+ SAMPLE_RATE_CD, SampleFormat, SampleRate, SizedSample, StreamConfig, StreamInstant,
SupportedStreamConfig, SupportedStreamConfigRange,
};
@@ -178,14 +179,11 @@ pub trait DeviceTrait: PartialEq + Eq + Hash + Debug + Display + Send + Sync {
.is_ok_and(|mut iter| iter.next().is_some())
}
- /// True if the device can build a synchronized duplex stream where the captured input and
- /// rendered output share a single clock.
+ /// True if the device supports audio input and output from one device-level callback,
+ /// otherwise false
///
- /// Returning `true` is a contract that input and output sides will run from one device-level
- /// callback, or an OS driver aggregate (such as an Aggregate Device on macOS).
- ///
- /// The default implementation returns `false`; hosts that can guarantee a shared clock should
- /// override.
+ /// This is stricter than [`DeviceDirection::Duplex`](crate::DeviceDirection::Duplex), which
+ /// only means the device has both directions.
fn supports_duplex(&self) -> bool {
false
}
@@ -244,6 +242,129 @@ pub trait DeviceTrait: PartialEq + Eq + Hash + Debug + Display + Send + Sync {
/// [`ErrorKind::UnsupportedOperation`]: crate::ErrorKind::UnsupportedOperation
fn default_output_config(&self) -> Result;
+ /// The default duplex stream configuration for the device.
+ ///
+ /// The default implementation prefers a channel count shared by both
+ /// [`supported_input_configs`](Self::supported_input_configs) and
+ /// [`supported_output_configs`](Self::supported_output_configs) (e.g. a stereo interface used
+ /// for stereo passthrough), reconciled on a shared sample rate. Only when no channel count is
+ /// achievable by both directions does it fall back to each direction's own preferred channel
+ /// count.
+ ///
+ /// # Errors
+ ///
+ /// - [`ErrorKind::DeviceNotAvailable`] if the device has been disconnected.
+ /// - [`ErrorKind::UnsupportedConfig`] if no sample rate is supported by both directions.
+ /// - [`ErrorKind::UnsupportedOperation`] if the host or device does not support duplex
+ /// streams.
+ ///
+ /// [`ErrorKind::DeviceNotAvailable`]: crate::ErrorKind::DeviceNotAvailable
+ /// [`ErrorKind::UnsupportedConfig`]: crate::ErrorKind::UnsupportedConfig
+ /// [`ErrorKind::UnsupportedOperation`]: crate::ErrorKind::UnsupportedOperation
+ fn default_duplex_config(&self) -> Result {
+ if !self.supports_duplex() {
+ return Err(Error::with_message(
+ ErrorKind::UnsupportedOperation,
+ "duplex streams are not supported by this device",
+ ));
+ }
+
+ let inputs: Vec<_> = self.supported_input_configs()?.collect();
+ let outputs: Vec<_> = self.supported_output_configs()?.collect();
+
+ let mut candidate_rates: Vec = [
+ self.default_input_config().ok().map(|c| c.sample_rate()),
+ self.default_output_config().ok().map(|c| c.sample_rate()),
+ ]
+ .into_iter()
+ .flatten()
+ .collect();
+ candidate_rates.dedup();
+ candidate_rates.sort_unstable_by(|a, b| b.cmp(a));
+ candidate_rates.extend([SAMPLE_RATE_48K, SAMPLE_RATE_CD]);
+
+ // Otherwise fall back to the highest rate at which the two directions actually overlap.
+ let shared_sample_rate =
+ |inputs: &[SupportedStreamConfigRange], outputs: &[SupportedStreamConfigRange]| {
+ let overlaps = |rate: SampleRate| {
+ inputs.iter().any(|r| r.contains_rate(rate))
+ && outputs.iter().any(|r| r.contains_rate(rate))
+ };
+ candidate_rates
+ .iter()
+ .copied()
+ .find(|&rate| overlaps(rate))
+ .or_else(|| {
+ inputs
+ .iter()
+ .flat_map(|i| {
+ outputs.iter().filter_map(move |o| {
+ let hi = i.max_sample_rate.min(o.max_sample_rate);
+ (i.min_sample_rate.max(o.min_sample_rate) <= hi).then_some(hi)
+ })
+ })
+ .max()
+ })
+ };
+
+ // cpal's own channel preference order (see `cmp_default_heuristics`): stereo, then mono,
+ // then ascending channel count.
+ let channel_rank = |channels: ChannelCount| (channels == 2, channels == 1, channels);
+
+ let matched_channels = inputs
+ .iter()
+ .map(|r| r.channels())
+ .filter(|&c| outputs.iter().any(|r| r.channels() == c))
+ .max_by_key(|&c| channel_rank(c));
+ if let Some(channels) = matched_channels {
+ let matched_inputs: Vec<_> = inputs
+ .iter()
+ .copied()
+ .filter(|r| r.channels() == channels)
+ .collect();
+ let matched_outputs: Vec<_> = outputs
+ .iter()
+ .copied()
+ .filter(|r| r.channels() == channels)
+ .collect();
+ if let Some(sample_rate) = shared_sample_rate(&matched_inputs, &matched_outputs) {
+ return Ok(DuplexStreamConfig {
+ input_channels: channels,
+ output_channels: channels,
+ sample_rate,
+ buffer_size: BufferSize::Default,
+ });
+ }
+ }
+
+ // No channel count is achievable by both directions (or none shares a rate at that
+ // count): fall back to each direction's own best channel count, reconciled only on a
+ // shared sample rate.
+ let sample_rate = shared_sample_rate(&inputs, &outputs).ok_or_else(|| {
+ Error::with_message(
+ ErrorKind::UnsupportedConfig,
+ "no sample rate is supported by both input and output",
+ )
+ })?;
+ let best_channels = |configs: &[SupportedStreamConfigRange], none_supported_message| {
+ configs
+ .iter()
+ .filter(|r| r.contains_rate(sample_rate))
+ .max_by(|a, b| a.cmp_default_heuristics(b))
+ .map(|r| r.channels())
+ .ok_or_else(|| {
+ Error::with_message(ErrorKind::UnsupportedConfig, none_supported_message)
+ })
+ };
+
+ Ok(DuplexStreamConfig {
+ input_channels: best_channels(&inputs, "no supported input configuration")?,
+ output_channels: best_channels(&outputs, "no supported output configuration")?,
+ sample_rate,
+ buffer_size: BufferSize::Default,
+ })
+ }
+
/// Create an input stream.
///
/// # Parameters
@@ -450,6 +571,9 @@ pub trait DeviceTrait: PartialEq + Eq + Hash + Debug + Display + Send + Sync {
/// or OS provided bidirectional aggregate device (macOS). macOS Aggregate device drift
/// compensation is not required.
///
+ /// cpal does not compensate for drift between the two directions, and does not mix or remap
+ /// channels between them.
+ ///
/// # Parameters
///
/// * `config` - Channels, sample rate, and buffer size shared by both directions.