Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down
120 changes: 65 additions & 55 deletions src/host/audioworklet/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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(),
)));
Expand Down Expand Up @@ -1267,14 +1270,18 @@ type AudioDuplexCallback = Box<dyn FnMut(&[f32], &mut [f32], u32, u32, f64)>;
pub struct WasmAudioDuplexProcessor {
input_buffer: Vec<f32>,
output_buffer: Vec<f32>,
/// 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,
}
}
Expand All @@ -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 _
}
Expand All @@ -1298,20 +1308,20 @@ 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.
let Self {
input_buffer,
output_buffer,
callback,
..
} = self;
output_buffer[..output_len].fill(f32::EQUILIBRIUM);
callback(
Expand Down
11 changes: 3 additions & 8 deletions src/host/audioworklet/worklet.js
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
Expand All @@ -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,
Expand Down
155 changes: 74 additions & 81 deletions src/traits.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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
///
Expand All @@ -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<SampleRate> = [
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<ChannelCount>,
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,
})
Expand Down Expand Up @@ -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.
Expand Down
Loading