Skip to content

Conversation

joe-wd
Copy link

@joe-wd joe-wd commented Sep 24, 2025

Current cpal implementation results in noisy audio on Android. Please consider defaulting to AudioPerformanceMode::LowLatency.

I forked and made this change as suggested in #902, and it resolved the noise issues. This should be the default.

@roderickvd
Copy link
Member

Thank you for the PR! I'd be curious to hear more details about the "noisy audio" issue you're experiencing. What does it sound like exactly? And which buffer size and sample rate are you using?

I understand that low latency mode trades battery efficiency for reduced audio delay. While your fix may work as a workaround, I suspect there might be an underlying issue that should work properly even in default performance mode. We could explore adding low latency as a configurable option in #1010 rather than making it the default.

A few potential root causes I'm considering:

  • The data callback makes system calls for both callback and capture timestamps, which aren't real-time safe
  • Buffer size variations could occur with certain sample rates like 44100 Hz
  • We don't appear to be using frames_per_data_callback(), which the docs
    indicates is necessary for consistent callback buffer sizes: "Note that numFrames can vary unless
    AAudioStreamBuilder_setFramesPerDataCallback() is called."

Could you test an alternative fix? In src/host/aaudio/mod.rs, try modifying the configure_for_device function around line 218:

match &config.buffer_size {
    BufferSize::Default => builder,
    BufferSize::Fixed(size) => builder
        .frames_per_data_callback(*size as i32)
        .buffer_capacity_in_frames((*size * 2) as i32), // Double-buffering
}

If this resolves the noise problem, we could consider implementing low latency and other performance modes as an opt-in feature instead of the default behavior in #1010.

@roderickvd roderickvd linked an issue Sep 24, 2025 that may be closed by this pull request
@joe-wd
Copy link
Author

joe-wd commented Sep 26, 2025

Thank you for responding and considering the issue! I tested your suggested change and it does make a significant difference -- I'd say it gets us about 90% of the way there. We will adopt it this change as well.

Our general use case is high-end audio playback, sample rate 48000, 2-channel (stereo). The majority of the time we're simply copying directly from a ring buffer to the output buffer. When transitioning from one track to the next, we're performing a Hann window crossfade, so we're copying from two different ring buffers, applying gain, and mixing into the final buffer. All of that should be handled easily by any modern Android device. I verified that the ring buffers keep up (our reads from the ring are never waiting for data).

Without your suggested change, we found that the callback buffer sizes on Android are all over the place, ranging from 12 (really!) to 4000+, and suspected that this was the main problem. Requesting a fixed buffer size made no difference. With your change, the buffer sizes are consistent and as requested.

Regarding noise, our QA and users reported popping and crackly output, which suggests that we were missing the output buffer deadline, probably when the buffer size is very small. One difficulty here is that the callback passes an OutputCallbackInfo with the OutputStreamTimestamp:

pub struct OutputStreamTimestamp {
    /// The instant the stream's data callback was invoked.
    pub callback: StreamInstant,
    /// The predicted instant that data written will be delivered to the device for playback.
    ///
    /// E.g. The instant data will be played by a DAC.
    pub playback: StreamInstant,
}

On Android, the callback instant is not a reasonable value (it is some value far in the past), so we can't confidently know the real deadline. But I assume we were missing it.

Again, thanks for replying and the suggested change. For our work, we want to keep AudioPerformanceMode::LowLatency as well, so I hope you'll consider it at least as an opt-in.

@roderickvd
Copy link
Member

Thank you for responding and considering the issue! I tested your suggested change and it does make a significant difference -- I'd say it gets us about 90% of the way there. We will adopt it this change as well.

Great, thanks for the feedback. Let's extract it into a separate PR.

I verified that the ring buffers keep up (our reads from the ring are never waiting for data).

That's a clear data point. So there's no zero padding of the input / output buffers then that could cause audible glitches.

Without your suggested change, we found that the callback buffer sizes on Android are all over the place, ranging from 12 (really!) to 4000+, and suspected that this was the main problem. Requesting a fixed buffer size made no difference. With your change, the buffer sizes are consistent and as requested.

What's the buffer size you're setting?
Does noise become better / worse when you request higher / lower buffer sizes?
Finally, as a quick test, what happens if you do buffer_capacity_in_frames((*size * 4) as i32) instead of * 2?

One difficulty here is that the callback passes an OutputCallbackInfo with the OutputStreamTimestamp:

On Android, the callback instant is not a reasonable value (it is some value far in the past), so we can't confidently know the real deadline. But I assume we were missing it.

The origin of StreamInstant is not guaranteed to be related to UNIX epoch time or something like it. In this case, the callback stream instant is the duration that has elapsed since its creation.

@roderickvd
Copy link
Member

Superseded by #1025

@roderickvd roderickvd closed this Sep 28, 2025
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Android won't adhere to fixed buffer size

2 participants