Skip to content

Commit ca12b28

Browse files
committed
feat(duplex): add synchronized duplex stream support for CoreAudio
1 parent e7ac140 commit ca12b28

10 files changed

Lines changed: 903 additions & 6 deletions

File tree

CHANGELOG.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,13 +9,18 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
99

1010
### Added
1111

12+
- `DeviceTrait::build_duplex_stream` and `build_duplex_stream_raw` for synchronized input/output.
13+
- `duplex` module with `DuplexStreamConfig` and `DuplexCallbackInfo` types.
14+
- **CoreAudio**: Duplex stream support with hardware-synchronized input/output.
15+
- Example `duplex_feedback` demonstrating duplex stream usage.
1216
- `DeviceBusy` error variant for retriable device access errors (EBUSY, EAGAIN).
1317
- **ALSA**: `Debug` implementations for `Host`, `Device`, `Stream`, and internal types.
1418
- **ALSA**: Example demonstrating ALSA error suppression during enumeration.
1519
- **WASAPI**: Enable as-necessary resampling in the WASAPI server process.
1620

1721
### Changed
1822

23+
- **POTENTIALLY BREAKING**: `DeviceTrait` now includes `build_duplex_stream()` and `build_duplex_stream_raw()` methods. The default implementation returns `StreamConfigNotSupported`, so external implementations are compatible without changes.
1924
- Bump overall MSRV to 1.78.
2025
- **ALSA**: Update `alsa` dependency to 0.11.
2126
- **ALSA**: Bump MSRV to 1.82.

Cargo.toml

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -180,6 +180,9 @@ name = "record_wav"
180180
[[example]]
181181
name = "synth_tones"
182182

183+
[[example]]
184+
name = "duplex_feedback"
185+
183186
[package.metadata.docs.rs]
184187
all-features = true
185188
rustdoc-args = ["--cfg", "docsrs"]

README.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,7 @@ This library currently supports the following:
2828
- Enumerate known supported input and output stream formats for a device.
2929
- Get the current default input and output stream formats for a device.
3030
- Build and run input and output PCM streams on a chosen device with a given stream format.
31+
- Build and run duplex (simultaneous input/output) streams with hardware clock synchronization (macOS only, more platforms coming soon).
3132

3233
Currently, supported hosts include:
3334

@@ -209,6 +210,7 @@ CPAL comes with several examples demonstrating various features:
209210
- `beep` - Generate a simple sine wave tone
210211
- `enumerate` - List all available audio devices and their capabilities
211212
- `feedback` - Pass input audio directly to output (microphone loopback)
213+
- `duplex_feedback` - Hardware-synchronized duplex stream loopback (macOS only)
212214
- `record_wav` - Record audio from the default input device to a WAV file
213215
- `synth_tones` - Generate multiple tones simultaneously
214216

examples/duplex_feedback.rs

Lines changed: 104 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,104 @@
1+
//! Feeds back the input stream directly into the output stream using a duplex stream.
2+
//!
3+
//! Unlike the `feedback.rs` example which uses separate input/output streams with a ring buffer,
4+
//! duplex streams provide hardware-synchronized input/output without additional buffering.
5+
//!
6+
//! Note: Currently only supported on macOS (CoreAudio). Windows (WASAPI) and Linux (ALSA)
7+
//! implementations are planned.
8+
9+
#[cfg(target_os = "macos")]
10+
mod imp {
11+
use clap::Parser;
12+
use cpal::duplex::DuplexStreamConfig;
13+
use cpal::traits::{DeviceTrait, HostTrait, StreamTrait};
14+
use cpal::BufferSize;
15+
16+
#[derive(Parser, Debug)]
17+
#[command(version, about = "CPAL duplex feedback example", long_about = None)]
18+
struct Opt {
19+
/// The audio device to use (must support duplex operation)
20+
#[arg(short, long, value_name = "DEVICE")]
21+
device: Option<String>,
22+
23+
/// Number of input channels
24+
#[arg(long, value_name = "CHANNELS", default_value_t = 2)]
25+
input_channels: u16,
26+
27+
/// Number of output channels
28+
#[arg(long, value_name = "CHANNELS", default_value_t = 2)]
29+
output_channels: u16,
30+
31+
/// Sample rate in Hz
32+
#[arg(short, long, value_name = "RATE", default_value_t = 48000)]
33+
sample_rate: u32,
34+
35+
/// Buffer size in frames
36+
#[arg(short, long, value_name = "FRAMES", default_value_t = 512)]
37+
buffer_size: u32,
38+
}
39+
40+
pub fn run() -> anyhow::Result<()> {
41+
let opt = Opt::parse();
42+
let host = cpal::default_host();
43+
44+
// Find the device by device ID or use default
45+
let device = if let Some(device_id_str) = opt.device {
46+
let device_id = device_id_str.parse().expect("failed to parse device id");
47+
host.device_by_id(&device_id)
48+
.unwrap_or_else(|| panic!("failed to find device with id: {}", device_id_str))
49+
} else {
50+
host.default_output_device()
51+
.expect("no default output device")
52+
};
53+
54+
println!("Using device: \"{}\"", device.description()?.name());
55+
56+
// Create duplex stream configuration.
57+
let config = DuplexStreamConfig {
58+
input_channels: opt.input_channels,
59+
output_channels: opt.output_channels,
60+
sample_rate: opt.sample_rate,
61+
buffer_size: BufferSize::Fixed(opt.buffer_size),
62+
};
63+
64+
println!("Building duplex stream with config: {config:?}");
65+
66+
let stream = device.build_duplex_stream::<f32, _, _>(
67+
&config,
68+
move |input, output, _info| {
69+
output.fill(0.0);
70+
let copy_len = input.len().min(output.len());
71+
output[..copy_len].copy_from_slice(&input[..copy_len]);
72+
},
73+
|err| eprintln!("Stream error: {err}"),
74+
None,
75+
)?;
76+
77+
println!("Successfully built duplex stream.");
78+
println!(
79+
"Input: {} channels, Output: {} channels, Sample rate: {} Hz, Buffer size: {} frames",
80+
opt.input_channels, opt.output_channels, opt.sample_rate, opt.buffer_size
81+
);
82+
83+
println!("Starting duplex stream...");
84+
stream.play()?;
85+
86+
println!("Playing for 10 seconds... (speak into your microphone)");
87+
std::thread::sleep(std::time::Duration::from_secs(10));
88+
89+
drop(stream);
90+
println!("Done!");
91+
Ok(())
92+
}
93+
}
94+
95+
fn main() {
96+
#[cfg(target_os = "macos")]
97+
imp::run().unwrap();
98+
99+
#[cfg(not(target_os = "macos"))]
100+
{
101+
eprintln!("Duplex streams are currently only supported on macOS.");
102+
eprintln!("Windows (WASAPI) and Linux (ALSA) support is planned.");
103+
}
104+
}

src/duplex.rs

Lines changed: 79 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,79 @@
1+
//! Duplex audio stream support with synchronized input/output.
2+
//!
3+
//! This module provides types for building duplex (simultaneous input/output) audio streams
4+
//! with hardware clock synchronization.
5+
//!
6+
//! # Overview
7+
//!
8+
//! Unlike separate input and output streams which may have independent clocks, a duplex stream
9+
//! uses a single device context for both input and output, ensuring they share the same
10+
//! hardware clock. This is essential for applications like:
11+
//!
12+
//! - DAWs (Digital Audio Workstations)
13+
//! - Real-time audio effects processing
14+
//! - Audio measurement and analysis
15+
//! - Any application requiring sample-accurate I/O synchronization
16+
//!
17+
//! See `examples/duplex_feedback.rs` for a working example.
18+
19+
use crate::{InputStreamTimestamp, OutputStreamTimestamp, SampleRate};
20+
21+
/// Information passed to duplex callbacks.
22+
///
23+
/// This contains timing information for the current audio buffer, combining
24+
/// both input and output timing. A duplex stream has a single callback invocation
25+
/// that provides synchronized input and output data.
26+
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
27+
pub struct DuplexCallbackInfo {
28+
input_timestamp: InputStreamTimestamp,
29+
output_timestamp: OutputStreamTimestamp,
30+
}
31+
32+
impl DuplexCallbackInfo {
33+
/// Create a new DuplexCallbackInfo.
34+
///
35+
/// Note: Both timestamps will share the same `callback` instant since there is
36+
/// only one callback invocation for a duplex stream.
37+
pub fn new(
38+
input_timestamp: InputStreamTimestamp,
39+
output_timestamp: OutputStreamTimestamp,
40+
) -> Self {
41+
Self {
42+
input_timestamp,
43+
output_timestamp,
44+
}
45+
}
46+
47+
/// The timestamp for the input portion of the duplex stream.
48+
///
49+
/// Contains the callback instant and when the input data was captured.
50+
pub fn input_timestamp(&self) -> InputStreamTimestamp {
51+
self.input_timestamp
52+
}
53+
54+
/// The timestamp for the output portion of the duplex stream.
55+
///
56+
/// Contains the callback instant and when the output data will be played.
57+
pub fn output_timestamp(&self) -> OutputStreamTimestamp {
58+
self.output_timestamp
59+
}
60+
}
61+
62+
/// Configuration for a duplex audio stream.
63+
///
64+
/// Unlike separate input/output streams, duplex streams require matching
65+
/// configuration for both directions since they share a single device context.
66+
#[derive(Clone, Debug, Eq, PartialEq)]
67+
pub struct DuplexStreamConfig {
68+
/// Number of input channels.
69+
pub input_channels: u16,
70+
71+
/// Number of output channels.
72+
pub output_channels: u16,
73+
74+
/// Sample rate in Hz.
75+
pub sample_rate: SampleRate,
76+
77+
/// Requested buffer size in frames.
78+
pub buffer_size: crate::BufferSize,
79+
}

0 commit comments

Comments
 (0)