Skip to content

Commit d5bfdc8

Browse files
committed
feat: add duplex stream API (interface only; backends to follow)
Introduce the public duplex stream contract without any backend implementation. Hosts inherit trait defaults: supports_duplex() returns false and build_duplex_stream_raw returns UnsupportedOperation.
1 parent 2e7e09c commit d5bfdc8

5 files changed

Lines changed: 204 additions & 4 deletions

File tree

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
1919
- `realtime` feature for real-time audio thread scheduling without a D-Bus build dependency.
2020
- `StreamTrait::now()` to query the current instant on the stream's clock.
2121
- `StreamTrait::buffer_size()` to query the stream's current buffer size in frames per callback.
22+
- Added duplex stream API (interface only; backends to follow).
2223
- `SAMPLE_RATE_CD` (44100 Hz) and `SAMPLE_RATE_48K` (48000 Hz) constants.
2324
- `SupportedStreamConfigRange::try_with_standard_sample_rate()` and `with_standard_sample_rate()`
2425
to select 48 kHz or 44.1 kHz from a range.

src/duplex.rs

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,47 @@
1+
use crate::{ChannelCount, InputStreamTimestamp, OutputStreamTimestamp, SampleRate};
2+
3+
/// Information relevant to a single call to the user's duplex stream data callback.
4+
///
5+
/// Combines the input and output timestamps for the callback. Because a duplex stream's input and
6+
/// output share a single clock, both timestamps are drawn from the same time source.
7+
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
8+
pub struct DuplexCallbackInfo {
9+
input_timestamp: InputStreamTimestamp,
10+
output_timestamp: OutputStreamTimestamp,
11+
}
12+
13+
impl DuplexCallbackInfo {
14+
/// Construct a `DuplexCallbackInfo` from its input and output timestamps.
15+
pub fn new(
16+
input_timestamp: InputStreamTimestamp,
17+
output_timestamp: OutputStreamTimestamp,
18+
) -> Self {
19+
Self {
20+
input_timestamp,
21+
output_timestamp,
22+
}
23+
}
24+
25+
/// The timestamp for the captured input data passed to the callback.
26+
pub fn input_timestamp(&self) -> InputStreamTimestamp {
27+
self.input_timestamp
28+
}
29+
30+
/// The timestamp for the output data written by the callback.
31+
pub fn output_timestamp(&self) -> OutputStreamTimestamp {
32+
self.output_timestamp
33+
}
34+
}
35+
36+
/// The configuration shared by both directions of a duplex stream.
37+
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
38+
pub struct DuplexStreamConfig {
39+
/// The number of input (capture) channels.
40+
pub input_channels: ChannelCount,
41+
/// The number of output (playback) channels.
42+
pub output_channels: ChannelCount,
43+
/// The sample rate driving both directions.
44+
pub sample_rate: SampleRate,
45+
/// The desired buffer size, in frames per callback.
46+
pub buffer_size: crate::BufferSize,
47+
}

src/lib.rs

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -184,6 +184,7 @@ pub use sample_format::{FromSample, Sample, SampleFormat, SizedSample, I24, U24}
184184
use wasm_bindgen::prelude::*;
185185

186186
pub mod device_description;
187+
mod duplex;
187188
mod error;
188189
mod host;
189190
pub mod platform;
@@ -489,6 +490,7 @@ pub struct Data {
489490
sample_format: SampleFormat,
490491
}
491492

493+
pub use duplex::{DuplexCallbackInfo, DuplexStreamConfig};
492494
pub use timestamp::{
493495
InputCallbackInfo, InputStreamTimestamp, OutputCallbackInfo, OutputStreamTimestamp,
494496
StreamInstant,

src/platform/mod.rs

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -525,6 +525,46 @@ macro_rules! impl_platform_host {
525525
)*
526526
}
527527
}
528+
529+
fn supports_duplex(&self) -> bool {
530+
match self.0 {
531+
$(
532+
$(#[cfg($feat)])?
533+
DeviceInner::$HostVariant(ref d) => d.supports_duplex(),
534+
)*
535+
}
536+
}
537+
538+
fn build_duplex_stream_raw<D, E>(
539+
&self,
540+
config: crate::DuplexStreamConfig,
541+
sample_format: crate::SampleFormat,
542+
data_callback: D,
543+
error_callback: E,
544+
timeout: Option<std::time::Duration>,
545+
) -> Result<Self::Stream, crate::Error>
546+
where
547+
D: FnMut(&crate::Data, &mut crate::Data, &crate::DuplexCallbackInfo)
548+
+ Send
549+
+ 'static,
550+
E: FnMut(crate::Error) + Send + 'static,
551+
{
552+
match self.0 {
553+
$(
554+
$(#[cfg($feat)])?
555+
DeviceInner::$HostVariant(ref d) => d
556+
.build_duplex_stream_raw(
557+
config,
558+
sample_format,
559+
data_callback,
560+
error_callback,
561+
timeout,
562+
)
563+
.map(StreamInner::$HostVariant)
564+
.map(Stream::from),
565+
)*
566+
}
567+
}
528568
}
529569

530570
impl crate::traits::HostTrait for Host {

src/traits.rs

Lines changed: 114 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -12,9 +12,9 @@ use std::{
1212
};
1313

1414
use crate::{
15-
Data, DeviceDescription, DeviceId, Error, InputCallbackInfo, InputDevices, OutputCallbackInfo,
16-
OutputDevices, SampleFormat, SizedSample, StreamConfig, StreamInstant, SupportedStreamConfig,
17-
SupportedStreamConfigRange,
15+
Data, DeviceDescription, DeviceId, DuplexCallbackInfo, DuplexStreamConfig, Error, ErrorKind,
16+
InputCallbackInfo, InputDevices, OutputCallbackInfo, OutputDevices, SampleFormat, SizedSample,
17+
StreamConfig, StreamInstant, SupportedStreamConfig, SupportedStreamConfigRange,
1818
};
1919

2020
/// A [`Host`] provides access to the available audio devices on the system.
@@ -114,10 +114,12 @@ pub trait DeviceTrait: PartialEq + Eq + Hash + Debug + Display {
114114
type SupportedInputConfigs: Iterator<Item = SupportedStreamConfigRange>;
115115
/// The iterator type yielding supported output stream formats.
116116
type SupportedOutputConfigs: Iterator<Item = SupportedStreamConfigRange>;
117-
/// The stream type created by [`build_input_stream_raw`] and [`build_output_stream_raw`].
117+
/// The stream type created by [`build_input_stream_raw`] and [`build_output_stream_raw`],
118+
/// and [`build_duplex_stream_raw`].
118119
///
119120
/// [`build_input_stream_raw`]: Self::build_input_stream_raw
120121
/// [`build_output_stream_raw`]: Self::build_output_stream_raw
122+
/// [`build_duplex_stream_raw`]: Self::build_duplex_stream_raw
121123
type Stream: StreamTrait;
122124

123125
/// Structured description of the device with metadata.
@@ -160,6 +162,18 @@ pub trait DeviceTrait: PartialEq + Eq + Hash + Debug + Display {
160162
.is_ok_and(|mut iter| iter.next().is_some())
161163
}
162164

165+
/// True if the device can build a synchronized duplex stream where the captured input and
166+
/// rendered output share a single clock.
167+
///
168+
/// Returning `true` is a contract that input and output sides will run from one device-level
169+
/// callback, or an OS driver aggregate (such as an Aggregate Device on MacOS).
170+
///
171+
/// The default implementation returns `false`; hosts that can guarantee a shared clock should
172+
/// override.
173+
fn supports_duplex(&self) -> bool {
174+
false
175+
}
176+
163177
/// An iterator yielding input stream configurations that are supported by the device.
164178
///
165179
/// # Errors
@@ -407,6 +421,102 @@ pub trait DeviceTrait: PartialEq + Eq + Hash + Debug + Display {
407421
where
408422
D: FnMut(&mut Data, &OutputCallbackInfo) + Send + 'static,
409423
E: FnMut(Error) + Send + 'static;
424+
425+
/// Create a synchronized duplex stream whose input and output share the same clock
426+
/// or OS provided bidirectional aggregate device (MacOS). MacOS Aggregate device drift
427+
/// compensation is not required.
428+
///
429+
/// # Parameters
430+
///
431+
/// * `config` - Channels, sample rate, and buffer size shared by both directions.
432+
/// * `data_callback` - Called periodically with captured input and a mutable output buffer.
433+
/// * `error_callback` - Called when a stream error occurs (e.g., device disconnected).
434+
/// * `timeout` - Time to wait for the backend to initialize the stream. `None` waits
435+
/// indefinitely. Note: not all backends honor this value.
436+
///
437+
/// # Errors
438+
///
439+
/// - [`ErrorKind::UnsupportedOperation`] if the host or device does not support duplex
440+
/// streams.
441+
/// - [`ErrorKind::UnsupportedConfig`] if the sample rate, channel counts, buffer size, or
442+
/// sample format is not supported by the device.
443+
/// - [`ErrorKind::DeviceNotAvailable`] if the device has been disconnected.
444+
/// - [`ErrorKind::DeviceBusy`] if the device is temporarily in use by another application.
445+
/// - [`ErrorKind::PermissionDenied`] if the process lacks permission to access the device
446+
/// (e.g. microphone access on macOS).
447+
/// - [`ErrorKind::InvalidInput`] if the configuration parameters are invalid.
448+
/// - [`ErrorKind::StreamInvalidated`] if the device's sample rate or buffer size changed
449+
/// during stream creation, or an internal lock was poisoned.
450+
/// - [`ErrorKind::ResourceExhausted`] if the host fails to spawn an internal monitoring
451+
/// thread.
452+
/// - [`ErrorKind::BackendError`] for unclassified backend failures.
453+
///
454+
/// [`ErrorKind::UnsupportedOperation`]: crate::ErrorKind::UnsupportedOperation
455+
/// [`ErrorKind::UnsupportedConfig`]: crate::ErrorKind::UnsupportedConfig
456+
/// [`ErrorKind::DeviceNotAvailable`]: crate::ErrorKind::DeviceNotAvailable
457+
/// [`ErrorKind::DeviceBusy`]: crate::ErrorKind::DeviceBusy
458+
/// [`ErrorKind::PermissionDenied`]: crate::ErrorKind::PermissionDenied
459+
/// [`ErrorKind::InvalidInput`]: crate::ErrorKind::InvalidInput
460+
/// [`ErrorKind::StreamInvalidated`]: crate::ErrorKind::StreamInvalidated
461+
/// [`ErrorKind::ResourceExhausted`]: crate::ErrorKind::ResourceExhausted
462+
/// [`ErrorKind::BackendError`]: crate::ErrorKind::BackendError
463+
fn build_duplex_stream<T, D, E>(
464+
&self,
465+
config: DuplexStreamConfig,
466+
mut data_callback: D,
467+
error_callback: E,
468+
timeout: Option<Duration>,
469+
) -> Result<Self::Stream, Error>
470+
where
471+
T: SizedSample,
472+
D: FnMut(&[T], &mut [T], &DuplexCallbackInfo) + Send + 'static,
473+
E: FnMut(Error) + Send + 'static,
474+
{
475+
self.build_duplex_stream_raw(
476+
config,
477+
T::FORMAT,
478+
move |input, output, info| {
479+
data_callback(
480+
input
481+
.as_slice()
482+
.expect("host supplied incorrect sample type"),
483+
output
484+
.as_slice_mut()
485+
.expect("host supplied incorrect sample type"),
486+
info,
487+
)
488+
},
489+
error_callback,
490+
timeout,
491+
)
492+
}
493+
494+
/// Create a dynamically typed synchronized duplex stream.
495+
///
496+
/// Hosts that support duplex streams must override this method;
497+
/// the default implementation returns [`ErrorKind::UnsupportedOperation`].
498+
///
499+
/// See [`build_duplex_stream`](Self::build_duplex_stream) for parameter and error
500+
/// documentation.
501+
///
502+
/// [`ErrorKind::UnsupportedOperation`]: crate::ErrorKind::UnsupportedOperation
503+
fn build_duplex_stream_raw<D, E>(
504+
&self,
505+
_config: DuplexStreamConfig,
506+
_sample_format: SampleFormat,
507+
_data_callback: D,
508+
_error_callback: E,
509+
_timeout: Option<Duration>,
510+
) -> Result<Self::Stream, Error>
511+
where
512+
D: FnMut(&Data, &mut Data, &DuplexCallbackInfo) + Send + 'static,
513+
E: FnMut(Error) + Send + 'static,
514+
{
515+
Err(Error::with_message(
516+
ErrorKind::UnsupportedOperation,
517+
"duplex streams are not supported by this host",
518+
))
519+
}
410520
}
411521

412522
/// A stream created from [`Device`](DeviceTrait), with methods to control it.

0 commit comments

Comments
 (0)