Skip to content

Conversation

@gulbrand
Copy link

@gulbrand gulbrand commented Jan 17, 2026

UPDATE on AI Usage

I've since reviewed the Rust Audio AI policy and want to be fully transparent: the implementation in this PR was generated by Claude Code under my direction and design. While I provided the architectural decisions, API design, and iterative feedback throughout development, the code itself was AI-generated rather than human-written with AI review.

I understand this likely falls outside the policy's acceptable use guidelines, regardless of the level of human oversight involved. I respect the community's concerns around maintainer burden and copyright, and I'll defer to the maintainers on how to proceed.

If this work is useful as a reference or design proposal, I'm happy for it to serve that purpose. If the preference is to close the PR, I completely understand.

Add synchronized duplex stream support

Summary

This PR introduces synchronized duplex streams to cpal, starting with CoreAudio support on macOS. Duplex streams provide hardware-level clock synchronization between audio input and output, eliminating the need for ring buffers and manual latency management and synchoronization between separate audio callbacks (one for input and one for output). With this change, one callback handles both input and output.

Development Note

Developed with assistance from Claude Code (Anthropic's AI coding assistant).

Motivation

Currently, applications requiring synchronized input/output (like DAWs, real-time effects, or audio analysis tools) must use separate input and output streams with ring buffers for synchronization. This approach:

  • Adds latency due to buffering
  • Requires manual synchronization logic
  • Can experience drift between input and output clocks
  • Is more complex to implement correctly

Duplex streams solve this by using a single device context for both input and output, guaranteeing sample-accurate alignment at the hardware level.

API Overview

use cpal::duplex::DuplexStreamConfig;
use cpal::traits::{DeviceTrait, HostTrait, StreamTrait};

let host = cpal::default_host();
let device = host.default_output_device()?;

let config = DuplexStreamConfig::symmetric(2, 48000, BufferSize::Fixed(512));

let stream = device.build_duplex_stream::<f32, _, _>(
    &config,
    |input, output, info| {
        // Process audio with guaranteed synchronization
        output.copy_from_slice(input);
    },
    |err| eprintln!("Stream error: {}", err),
    None,
)?;

stream.play()?;

Example Comparison

See examples/duplex_feedback.rs for a complete example. Compare with examples/feedback.rs to see the simplification:

Before (separate streams):

  • Ring buffer setup and management
  • Manual latency calculation
  • Two separate callbacks
  • Producer/consumer synchronization

After (duplex stream):

  • Single callback
  • Direct buffer copy
  • Hardware-guaranteed synchronization

Current Status

  • ✅ CoreAudio (macOS): Fully implemented and tested
  • ⏳ ASIO (Windows): Planned
  • ⏳ ALSA (Linux): Planned

Breaking Changes

DeviceTrait now required DuplexStream associated type and build_duplex_stream_raw() method. External implementations must add stubs returning StreamConfigNotSupported

Future Work

I have a lab setup ready to implement Windows and Linux support once the CoreAudio implementation and API design are reviewed and approved.

Feedback Requested

While I have some audio/DSP programming experience, I'm relatively new to this domain. I'd especially appreciate feedback on:

  1. API design: Is the DuplexStreamConfig / build_duplex_stream interface intuitive?
  2. Timestamp API: Does AudioTimestamp provide the right information for real-world use cases?
  3. Design flaws: Any architectural issues or better approaches I should consider?
  4. Platform compatibility: Will this design work well for Windows (WASAPI) and Linux (ALSA)?

Testing

All existing tests pass. New tests added:

  • Unit tests for duplex types (DuplexStreamConfig, AudioTimestamp, etc.)
  • Integration test for CoreAudio duplex stream creation and playback
  • Example demonstrating real-time audio passthrough

Add true duplex audio support with hardware-synchronized input/output
using a single HAL AudioUnit with both input and output enabled.

API additions:
- DuplexStreamConfig: configuration for duplex streams
- AudioTimestamp: hardware timing info (sample_time, host_time, rate_scalar)
- DuplexCallbackInfo: passed to callbacks with timestamp
- DeviceTrait::build_duplex_stream<T>(): typed convenience method
- DeviceTrait::build_duplex_stream_raw(): dynamic sample format support
- DeviceTrait::supports_duplex(): check device capability

Design decisions:
- Follows cpal's existing API patterns (typed + raw variants)
- Supports all sample formats (f32, i16, i24, etc.) via build_duplex_stream_raw
- DuplexStream implements StreamTrait (play/pause) like regular streams
- Xrun detection left to application via sample_time discontinuity tracking
- UnsupportedDuplexStream placeholder for backends without duplex support
Instead of silently zeroing the input buffer when AudioUnitRender fails,
now reports the error via the error callback while still continuing with
silence for graceful degradation. This follows the ALSA pattern of
"report but continue" and ensures users are notified of input capture
failures.

Also includes latency-adjusted capture/playback timestamps in
DuplexCallbackInfo for accurate timing information.
Demonstrates synchronized duplex streams for real-time audio processing.
Shows how duplex streams eliminate the need for ring buffer synchronization
and provide hardware-level clock alignment between input and output.
- Add duplex stream entries to CHANGELOG
- Fix clippy useless_conversion warning in duplex_feedback example
- Run rustfmt
Duplex streams are currently only implemented for CoreAudio.
Add platform guards so the example compiles cleanly on other platforms
with a helpful message about platform support status.
When --all-features is enabled, JACK and Custom backends need duplex
support to compile. Added unique DuplexStream wrapper types for each
backend that delegate to UnsupportedDuplexStream. This prevents
conflicting From implementations while maintaining API consistency.

- Add jack::DuplexStream and custom::DuplexStream wrapper types
- Implement build_duplex_stream_raw stubs returning StreamConfigNotSupported
- All backends now compile with --all-features
Added DuplexStream placeholder type and build_duplex_stream_raw stub
implementations to all backends that were missing duplex support:

- null: Fallback backend for unsupported platforms
- wasapi: Windows default backend
- asio: Windows low-latency backend
- aaudio: Android default backend
- coreaudio/ios: iOS backend
- emscripten: WebAssembly/Emscripten backend
- webaudio: WebAssembly/Web Audio API backend
- audioworklet: WebAssembly/Audio Worklet backend

All implementations return StreamConfigNotSupported, following the
existing precedent where backends that don't support a feature (like
webaudio input streams) return this error instead of being feature-flagged.
@gulbrand gulbrand marked this pull request as ready for review January 17, 2026 20:02
Removed unreachable JACK-related configuration code from the macOS-only
duplex_feedback example. Since duplex streams are currently only supported
on macOS, the JACK host selection code (which is Linux/BSD-specific) was
dead code that would never execute.

Simplified from 145 lines to 97 lines by using cpal::default_host() directly.
Added explicit BREAKING notice for DeviceTrait changes. External
implementations of DeviceTrait must now provide DuplexStream type
and build_duplex_stream_raw() method.
Reduces duplex stub code by ~50% across all backends by providing a default
implementation in DeviceTrait that returns StreamConfigNotSupported.

Changes:
- Add default implementation for build_duplex_stream_raw() in DeviceTrait
- Remove build_duplex_stream_raw implementations from all backends
- Keep minimal DuplexStream wrapper structs for type distinction
- Wrapper structs delegate play/pause to inner UnsupportedDuplexStream

This maintains the breaking change (backends must provide DuplexStream type)
but significantly reduces boilerplate code per backend from ~30 to ~15 lines.
Add missing StreamTrait implementation for iOS backend's DuplexStream wrapper.
This fixes cross-compilation to aarch64-apple-ios target.
Add missing StreamTrait implementations for platform-specific backends that
couldn't be tested on macOS:
- aaudio (Android)
- asio (Windows)
- wasapi (Windows)
- alsa (Linux)
- null (WASI)

Each implementation delegates play/pause to the inner UnsupportedDuplexStream.
use crate::{PauseStreamError, PlayStreamError, SampleRate, StreamInstant};

/// Hardware timestamp information from the audio device.
Copy link

@damyanp damyanp Jan 19, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is there something inherent about supporting duplex streams that makes AudioTimestamp required? Or is this something that would also be useful for normal streams?

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

No I don't think the duplex stream case has a unique or special need for AudioTimestamp (one caveat below). I think it would be helpful for input and output streams as well. But, adding support to input and output streams might be a breaking change? (I'm unsure).

I added it to support sample accurate operations in the application. For example, a DAW with automation events on nodes in a graph needs to know with high degrees of accuracy the sample position as a reference point for position and offset calculations for ramp ups, ramp downs, parameter automation, etc.

I believe it is technically possible to implement those without the hardware clock derived sample offset, but I think the edge case count increases when not doing so.

The only caveat I can think of for duplex is during simultaneous playback and recording in a DAW where its far more convenient to the user for the software to calculate latencies and offsets and adjust the final position of the recorded audio on the timeline. The math is precise (nearly perfectly so) with hardware clock sample offset rather than application-based timing calculations. Not critical though, just far simpler and more precise.

If removing this would be more consistent with the existing API would help, I can do that and then perhaps this concept could be introduced across all APIs in a future PR.

What do you think?

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

My opinion here shouldn't have a huge amount of weight since I'm pretty new to this community. But I'll give it anyway :)

For something like this that seems to have had several attempts over the years, I think it might be worth figuring out what the minimum surface area would be for the feature and try and get that supported for all the target platforms.

I haven't looked closely enough to figure out if AudioTimestamp is something that can only be obtained from host-specific data? If it's something that could be entirely derived from information that's already in OutputCallbackInfo then it seems like a no-brainer to remove it for now.

If it is an abstraction that can be provided by all hosts, and would be useful for simplex as well as duplex, then IMO it'd be good to add it to both in one go. I'm not sure what the bar is for introducing breaking changes.

Similar to the concern with breaking changes, I'd suggest having proofs of concept, if not full implementations, for all platforms so we can be confident that the proposed API can work everywhere and won't need to be revised as soon as, say, Windows support is added.

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks for the feedback. This is helpful.

If I'm reading the feedback correctly, then if I remove the AudioTimestamp change from this, it greatly simplifies the scope since we don't need to worry about testing all the different platforms and host implementations for compatibility with the AudioTimestamp interface. Doing all of that work would be time consuming and is arguably orthogonal to adding duplex support to one platform / host.

Am I understanding the feedback correctly? (If so, I agree and I will remove AudioTimestamp)

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes, that's an accurate summary of my feedback.

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Great, thanks, I'll remove AudioTimestamp

fn play(&mut self) -> Result<(), PlayStreamError> {
if !self.playing {
if let Err(e) = self.audio_unit.start() {
let description = format!("{e}");
Copy link

@Decodetalkers Decodetalkers Jan 22, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think this line can just be e.to_string()

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yep, I changed the rest of them as well. Thanks!

let (ready_tx, ready_rx) = mpsc::channel();

let disconnect_tx_clone = disconnect_tx.clone();
std::thread::spawn(move || {

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

maybe we also need to collect the thread handler?

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ah, good catch. I need to think about this one a bit more, but I think your suggestion is correct because the thread should be joined during drop and without the handle, drop can't join.

I need to make sure I understand how this works. I'll update this comment when I'm more confident on this.

Thanks for pointing this out.

impl DuplexDisconnectManager {
fn new(
device_id: AudioDeviceID,
stream_weak: Weak<Mutex<DuplexStreamInner>>,
Copy link

@Decodetalkers Decodetalkers Jan 22, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

what is the meaning of Weak<Mutex>? I cannot understand

/// Backend implementations should replace this with their own type once
/// duplex support is implemented.
pub struct UnsupportedDuplexStream {
_private: (),

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I do not know why we need a _private here.. And maybe here just use #[derive(Default)] will be ok

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think your suggestion is correct. I am not sure but I think this was added in anticipation of this maybe, possibly, needing an additional field for one of the backends. But now that I've implemented the necessary traits for each backend, it is clear this is not needed. I'll remove this and make this a unit struct.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants