From 386e7e722551f281da2dacc2d019b1ade36c6896 Mon Sep 17 00:00:00 2001 From: Roderick van Domburg Date: Sun, 23 Aug 2026 21:10:14 +0200 Subject: [PATCH 1/3] fix(duplex): keep each direction's own default channel count Matching both directions on a shared count opened a mono microphone as stereo. Also drops the shared-clock wording from build_duplex_stream. --- src/traits.rs | 155 ++++++++++++++++++++++++-------------------------- 1 file changed, 74 insertions(+), 81 deletions(-) diff --git a/src/traits.rs b/src/traits.rs index 4eec778cd..73209bdc8 100644 --- a/src/traits.rs +++ b/src/traits.rs @@ -244,12 +244,16 @@ pub trait DeviceTrait: PartialEq + Eq + Hash + Debug + Display + Send + Sync { /// 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. + /// A duplex stream drives both directions at one sample rate, so the default implementation + /// picks a rate that both support, preferring one returned by + /// [`default_input_config`](Self::default_input_config) or + /// [`default_output_config`](Self::default_output_config). Channel counts are independent, so + /// each direction keeps its own default count where that rate allows it: a mono microphone + /// paired with stereo output gives one input channel and two output channels. + /// + /// The result is built from what each direction reports on its own. A device whose two + /// directions cannot both run at their full capability may still reject the configuration in + /// [`build_duplex_stream`](Self::build_duplex_stream). /// /// # Errors /// @@ -271,95 +275,85 @@ pub trait DeviceTrait: PartialEq + Eq + Hash + Debug + Display + Send + Sync { let inputs: Vec<_> = self.supported_input_configs()?.collect(); let outputs: Vec<_> = self.supported_output_configs()?.collect(); + let default_input = self.default_input_config().ok(); + let default_output = self.default_output_config().ok(); 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()), + default_input.map(|c| c.sample_rate()), + default_output.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, - }); + for rate in [SAMPLE_RATE_48K, SAMPLE_RATE_CD] { + if !candidate_rates.contains(&rate) { + candidate_rates.push(rate); } } - // 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()) + let overlaps = |rate: SampleRate| { + inputs.iter().any(|r| r.contains_rate(rate)) + && outputs.iter().any(|r| r.contains_rate(rate)) + }; + let sample_rate = candidate_rates + .iter() + .copied() + .find(|&rate| overlaps(rate)) + .or_else(|| { + // Otherwise take the highest rate at which the two directions overlap. + 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() + }) + .ok_or_else(|| { + Error::with_message( + ErrorKind::UnsupportedConfig, + "no sample rate is supported by both input and output", + ) + })?; + + // Keep the direction's own default channel count if it is available at the chosen rate, + // since that is what a stream in this direction alone would have used. + let best_channels = |configs: &[SupportedStreamConfigRange], + preferred: Option, + none_supported_message| { + preferred + .filter(|&channels| { + configs + .iter() + .any(|r| r.channels() == channels && r.contains_rate(sample_rate)) + }) + .or_else(|| { + 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")?, + input_channels: best_channels( + &inputs, + default_input.map(|c| c.channels()), + "no supported input configuration", + )?, + output_channels: best_channels( + &outputs, + default_output.map(|c| c.channels()), + "no supported output configuration", + )?, sample_rate, buffer_size: BufferSize::Default, }) @@ -567,9 +561,8 @@ pub trait DeviceTrait: PartialEq + Eq + Hash + Debug + Display + Send + Sync { D: FnMut(&mut Data, &CallbackInfo) + Send + 'static, E: FnMut(Error) + Send + 'static; - /// Create a synchronized duplex stream whose input and output share the same clock - /// or OS provided bidirectional aggregate device (macOS). macOS Aggregate device drift - /// compensation is not required. + /// Create a duplex stream that captures input and renders output from one device-level + /// callback. /// /// cpal does not compensate for drift between the two directions, and does not mix or remap /// channels between them. From 6177bb9ff5b8c9a7ba0af3765f042da27f99c24f Mon Sep 17 00:00:00 2001 From: Roderick van Domburg Date: Sun, 23 Aug 2026 21:51:45 +0200 Subject: [PATCH 2/3] fix(audioworklet): always deliver the configured duplex input channels The graph reports none until the capture source connects, so the callback saw an empty slice. Also clears the region a skipped quantum would repeat. --- src/host/audioworklet/mod.rs | 120 +++++++++++++++++-------------- src/host/audioworklet/worklet.js | 11 +-- 2 files changed, 68 insertions(+), 63 deletions(-) diff --git a/src/host/audioworklet/mod.rs b/src/host/audioworklet/mod.rs index ff1558802..b25503580 100644 --- a/src/host/audioworklet/mod.rs +++ b/src/host/audioworklet/mod.rs @@ -914,58 +914,61 @@ impl DeviceTrait for Device { 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), + &WasmAudioDuplexProcessor::new( + input_channels, + 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, }, - xrun: false, - }, - CallbackInfo { - timestamp: StreamTimestamp { - callback, - device: callback + (buffer_duration + latency), + CallbackInfo { + timestamp: StreamTimestamp { + callback, + device: callback + (buffer_duration + latency), + }, + xrun: false, }, - xrun: false, - }, - ); - (data_callback)(&input, &mut output, &info); - }, - )) + ); + (data_callback)(&input, &mut output, &info); + }, + ), + ) .pack() .into(), ))); @@ -1267,14 +1270,18 @@ type AudioDuplexCallback = Box; pub struct WasmAudioDuplexProcessor { input_buffer: Vec, output_buffer: Vec, + /// The input channel count from the stream configuration. The graph reports none until the + /// capture source connects, so its count cannot stand in for this one. + input_channels: u32, callback: AudioDuplexCallback, } impl WasmAudioDuplexProcessor { - pub fn new(callback: AudioDuplexCallback) -> Self { + pub fn new(input_channels: u32, callback: AudioDuplexCallback) -> Self { Self { input_buffer: Vec::new(), output_buffer: Vec::new(), + input_channels, callback, } } @@ -1284,8 +1291,11 @@ impl WasmAudioDuplexProcessor { 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); + pub fn prepare(&mut self, output_channels: u32, frame_size: u32) -> u32 { + // JS interleaves only the channels the graph delivers, so a quantum it skips would + // otherwise repeat the one before it. + let input_len = resize_interleaved(&mut self.input_buffer, self.input_channels, frame_size); + self.input_buffer[..input_len].fill(f32::EQUILIBRIUM); resize_interleaved(&mut self.output_buffer, output_channels, frame_size); self.input_buffer.as_mut_ptr() as _ } @@ -1298,13 +1308,12 @@ impl WasmAudioDuplexProcessor { /// 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 input_len = self.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. @@ -1312,6 +1321,7 @@ impl WasmAudioDuplexProcessor { input_buffer, output_buffer, callback, + .. } = self; output_buffer[..output_len].fill(f32::EQUILIBRIUM); callback( diff --git a/src/host/audioworklet/worklet.js b/src/host/audioworklet/worklet.js index 51d3e7708..efccf1c96 100644 --- a/src/host/audioworklet/worklet.js +++ b/src/host/audioworklet/worklet.js @@ -133,15 +133,11 @@ registerProcessor("CpalDuplexProcessor", class WasmDuplexProcessor extends CpalP 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. + // rather than stalling the graph waiting for it; the processor still hands the callback + // the configured number of input channels. 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 - ); + const input_ptr = this.processor.prepare(output_channels_count, frame_size); if (!this.interleave(input_channels, input_ptr, frame_size)) { return false; // Safely stop the node } @@ -151,7 +147,6 @@ registerProcessor("CpalDuplexProcessor", class WasmDuplexProcessor extends CpalP const output_ptr = this.processor.output_buffer_ptr(); this.processor.process( - input_channels_count, output_channels_count, frame_size, sampleRate, From 0eb25ec7630386f5aff4bab942a6be3699503f2f Mon Sep 17 00:00:00 2001 From: Roderick van Domburg Date: Sun, 23 Aug 2026 21:52:43 +0200 Subject: [PATCH 3/3] doc(changelog): list default_duplex_config with the duplex API --- CHANGELOG.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 827f892fe..bc3a292d7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,7 +11,7 @@ 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. -- `DeviceTrait::build_duplex_stream()`, `build_duplex_stream_raw()`, and `supports_duplex()` for capture and playback from one device-level callback. +- `DeviceTrait::build_duplex_stream()`, `build_duplex_stream_raw()`, `default_duplex_config()`, 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.