Skip to content
Open
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
10 changes: 10 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -37,9 +37,19 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
### Fixed

- **ALSA**: Fix a remaining timestamp segfault on 32-bit platforms with a 64-bit kernel `time_t`.
- **ALSA**: Improved enumeration accuracy for supported format, channel and rate combinations.
- **ASIO**: `Stream` no longer risks blocking or panicking in the driver callback while another stream is being created or destroyed.
- **ASIO**: Output streams now start with silence instead of undefined content in the first buffer period.
- **AudioWorklet**: Fix processor construction failures not being reported to `error_callback`.
- **CoreAudio**: Fix the device running at a different sample rate from the stream on hardware that reports a continuous rate range.
- **CoreAudio**: Fix `supported_configs()` only reporting `F32`, even on hardware that also supports other sample formats.
- **JACK**: Channel enumeration is capped at the physical system port count again.
- **JACK**: Streams no longer panic when the server delivers a larger period than the negotiated buffer size.
- **PipeWire**: Fix an empty chunk being emitted when a cycle requests no frames.
- **PipeWire**: Fix capture reading from the wrong offset in the buffer on some devices.
- **WASAPI**: Device enumeration no longer panics if the COM enumerator fails to initialize.
- **WASAPI**: Output streams now start with silence instead of undefined content in the render buffer.
- **WASAPI**: Fix `I64` and `F64` incorrectly reported as supported output formats.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Do you have a source for the claim that this was incorrect? I can't find anything that forbids WASAPI devices from using these formats

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

I don't, the MSDN docs are woefully quiet about what's supported and what's not. I believe that 64-bit isn't a thing in WASAPI at all. As proof of the contrary I've not found any implementations of it either. Could you test on a Windows box?

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Scratch that, I just realized that we're specifically talking about formats supported by AUDCLNT_STREAMFLAGS_AUTOCONVERTPCM, not by the device itself. Your commit is correct, i64 and f64 are not supported (IAudioClient::Initialize() returns E_INVALIDARG). See also #1343


## [0.18.2] - 2026-08-16

Expand Down
98 changes: 54 additions & 44 deletions src/host/alsa/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -535,71 +535,81 @@ impl Device {
//SND_PCM_FORMAT_U18_3BE,
];

let min_rate = hw_params.get_rate_min()?;
let max_rate = hw_params.get_rate_max()?;

let sample_rates = if min_rate == max_rate || hw_params.test_rate(min_rate + 1).is_ok() {
// Fixed rate or continuous range.
vec![(min_rate, max_rate)]
} else {
// Discrete rates: probe the standard list plus the hardware's own min and max so
// that rates outside `COMMON_SAMPLE_RATES` are not missed.
let mut probe: Vec<SampleRate> = COMMON_SAMPLE_RATES.to_vec();
probe.push(min_rate);
probe.push(max_rate);
probe.sort_unstable();
probe.dedup();
probe
.into_iter()
.filter(|&r| (min_rate..=max_rate).contains(&r) && hw_params.test_rate(r).is_ok())
.map(|r| (r, r))
.collect()
};

let min_channels = hw_params.get_channels_min()?;
// 64 = AES10 (MADI) maximum; also prevents spinning on plugins like plughw that report u32::MAX.
const CHANNEL_ENUM_CAP: u32 = 64;
let max_channels = hw_params
.get_channels_max()?
.min(CHANNEL_ENUM_CAP)
.min(ChannelCount::MAX as u32);

let supported_channels: Vec<ChannelCount> =
if min_channels == max_channels || hw_params.test_channels(min_channels + 1).is_ok() {
(min_channels..=max_channels)
.map(|c| c as ChannelCount)
.collect()
} else {
(min_channels..=max_channels)
.filter(|&c| hw_params.test_channels(c).is_ok())
.map(|c| c as ChannelCount)
.collect()
};

let mut output =
Vec::with_capacity(FORMATS.len() * supported_channels.len() * sample_rates.len());
let mut output = Vec::new();
let mut seen_formats: Vec<SampleFormat> = Vec::with_capacity(FORMATS.len());

// Key: (channels, physical width in bits) with 4 physical widths (8/16/32/64 bits)
let mut buffer_size_cache: HashMap<(ChannelCount, u32), SupportedBufferSize> =
HashMap::with_capacity(supported_channels.len() * 4);
HashMap::new();

// `test_*` checks a value, it doesn't apply it, so format/channels/rate are each set on a
// clone in sequence rather than tested independently against the same unconstrained params.
for &(sample_format, alsa_format) in FORMATS.iter() {
if seen_formats.contains(&sample_format) || hw_params.test_format(alsa_format).is_err()
{
if seen_formats.contains(&sample_format) {
continue;
}
let format_params = hw_params.clone();
if format_params.set_format(alsa_format).is_err() {
continue;
}
seen_formats.push(sample_format);
let width = alsa_format.physical_width().unwrap_or(0) as u32;

for &channels in &supported_channels {
let (Ok(min_channels), Ok(max_channels)) = (
format_params.get_channels_min(),
format_params.get_channels_max(),
) else {
continue;
};
let max_channels = max_channels
.min(CHANNEL_ENUM_CAP)
.min(ChannelCount::MAX as u32);

for raw_channels in min_channels..=max_channels {
let channel_params = format_params.clone();
if channel_params.set_channels(raw_channels).is_err() {
continue;
}
let channels = raw_channels as ChannelCount;

let buffer_size =
*buffer_size_cache
.entry((channels, width))
.or_insert_with(|| {
supported_period_size_range(&hw_params, alsa_format, channels)
});

let (Ok(min_rate), Ok(max_rate)) =
(channel_params.get_rate_min(), channel_params.get_rate_max())
else {
continue;
};

let sample_rates =
if min_rate == max_rate || channel_params.test_rate(min_rate + 1).is_ok() {
// Fixed rate or continuous range.
vec![(min_rate, max_rate)]
} else {
// Discrete rates: probe the standard list plus the hardware's own min and max
// so that rates outside `COMMON_SAMPLE_RATES` are not missed.
let mut probe: Vec<SampleRate> = COMMON_SAMPLE_RATES.to_vec();
probe.push(min_rate);
probe.push(max_rate);
probe.sort_unstable();
probe.dedup();
probe
.into_iter()
.filter(|&r| {
(min_rate..=max_rate).contains(&r)
&& channel_params.test_rate(r).is_ok()
})
.map(|r| (r, r))
.collect()
};

for &(min_rate, max_rate) in sample_rates.iter() {
output.push(SupportedStreamConfigRange {
channels,
Expand Down
35 changes: 31 additions & 4 deletions src/host/asio/stream.rs
Original file line number Diff line number Diff line change
Expand Up @@ -223,8 +223,9 @@ impl Device {
}
last_buffer_index = callback_info.buffer_index;

// There is 0% chance of lock contention the host only locks when recreating streams.
let stream_lock = asio_streams.lock().unwrap();
let Ok(stream_lock) = asio_streams.lock() else {
return;
};
Comment on lines 225 to +228

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

The commit message says "do not block", but .lock() is a blocking function, no?

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Indeed, I should have written "do not panic".

let asio_stream = match stream_lock.input {
Some(ref asio_stream) => asio_stream,
None => return,
Expand Down Expand Up @@ -448,6 +449,8 @@ impl Device {
let driver = Arc::new(driver);
let asio_streams = driver.streams();

prefill_output_silence(&driver, &asio_streams);

if let Err(e) = driver.start() {
driver.remove_event_callback(driver_event_callback_id);
driver.remove_callback(callback_id);
Expand Down Expand Up @@ -577,8 +580,9 @@ impl Device {
}
last_buffer_index = callback_info.buffer_index;

// There is 0% chance of lock contention the host only locks when recreating streams.
let mut stream_lock = asio_streams.lock().unwrap();
let Ok(mut stream_lock) = asio_streams.lock() else {
return;
};
let asio_stream = match stream_lock.output {
Some(ref mut asio_stream) => asio_stream,
None => return,
Expand Down Expand Up @@ -854,6 +858,8 @@ impl Device {
let driver = Arc::new(driver);
let asio_streams = driver.streams();

prefill_output_silence(&driver, &asio_streams);

if let Err(e) = driver.start() {
driver.remove_event_callback(driver_event_callback_id);
driver.remove_callback(callback_id);
Expand Down Expand Up @@ -1273,6 +1279,27 @@ unsafe fn asio_channel_slice_mut<T>(
unsafe { std::slice::from_raw_parts_mut(buff_ptr, channel_length) }
}

// ASIOStart() plays buffer half 1 immediately, before the first bufferSwitch can fill it;
// half 0 is covered by that first callback.
fn prefill_output_silence(driver: &sys::Driver, asio_streams: &Mutex<sys::AsioStreams>) {
Comment on lines +1282 to +1284

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Shouldn't this use the data callback instead of generating silence? Also I think this isn't actually necessary at all:

ASIO 2.3 specification, secion II.5:

The audio streaming starts after the ASIOStart() call. Prior to starting the hardware streaming the driver will issue one or more bufferSwitch() or bufferSwitchTimeInfo() callbacks to fill its first output buffer(s). Since the hardware did not provide any input data yet the input channels' buffers should be filled with silence by the driver.

To me this clearly states that pre-filling is the driver's responsibility, not the host's. In fact, if the initial callback invocations happen within ASIOCreateBuffers() (on the calling thread), then the silence ends up overwriting actual stream data.

@roderickvd roderickvd Aug 24, 2026

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

What I based it on is https://github.com/dechamps/ASIOUtil/blob/master/BUFFERS.md which also states this and then goes on with two more pieces of the spec and conludes that:

the host should fill buffer 1 prior to calling ASIOStart() ...

It does not seem filled via bufferSwitch callbacks.

Is this synthesis incorrect? I thought that this source was pretty authoritative but it'd be great if you could verify.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

That is an awesome resource, and I have a thing or two to say about it (will create an issue there). But it doesn't actually seem to contain the conclusion you quoted, Ctrl + F "it is not filled" yields 0 results. Is this paraphrased?

My interpretation is still that the driver should invoke the callback right after buffer creation to populate the buffers, but it is indeed a bit ambiguous

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Fixed the quote for what was my deduction.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

I left a thorough comment there, I recommend you read it too. Bottom like is that the spec contradicts itself, but it can be somewhat shoehorned into what I said earlier

if let Ok(mut streams) = asio_streams.lock() {
if let Some(ref mut output) = streams.output {
if let Some(sample_format) = driver
.output_data_type()
.ok()
.and_then(|ty| super::device::convert_data_type(&ty))
{
let byte_len = output.buffer_size as usize * sample_format.sample_size();
for ch_ix in 0..output.buffer_infos.len() {
let channel =
unsafe { asio_channel_slice_mut::<u8>(output, 1, ch_ix, Some(byte_len)) };
fill_equilibrium(channel, sample_format);
}
}
}
}
}

fn load_driver_err(e: sys::LoadDriverError) -> Error {
match e {
sys::LoadDriverError::LoadDriverFailed | sys::LoadDriverError::DriverAlreadyExists => {
Expand Down
89 changes: 58 additions & 31 deletions src/host/coreaudio/macos/device.rs
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ use coreaudio::audio_unit::{
audio_format::LinearPcmFlags,
macos_helpers::{
RateListener, audio_unit_from_device_id_uninitialized, find_matching_physical_format,
get_device_name, set_device_physical_stream_format,
get_device_name, get_supported_physical_stream_formats, set_device_physical_stream_format,
},
render_callback::{self, data},
};
Expand Down Expand Up @@ -526,18 +526,7 @@ impl Device {
n_channels += buf.mNumberChannels as usize;
}

// TODO: macOS should support U8, I16, I32, F32 and F64. This should allow for using
// I16 but just use F32 for now as it's the default anyway.
let sample_format = SampleFormat::F32;

// Get available sample rate ranges.
// The property "kAudioDevicePropertyAvailableNominalSampleRates" returns a list of pairs of
// minimum and maximum sample rates but most of the devices returns pairs of same values though the underlying mechanism is unclear.
// This may cause issues when, for example, sorting the configs by the sample rates.
// We follows the implementation of RtAudio, which returns single element of config
// when all the pairs have the same values and returns multiple elements otherwise.
// See https://github.com/thestk/rtaudio/blob/master/RtAudio.cpp#L1369C1-L1375C39

property_address.mSelector = kAudioDevicePropertyAvailableNominalSampleRates;
let mut data_size = 0u32;
let status = AudioObjectGetPropertyDataSize(
Expand Down Expand Up @@ -575,19 +564,55 @@ impl Device {
}
let buffer_size = get_io_buffer_frame_size_range(self.audio_device_id)?;

// Most hardware reports discrete rates (mMinimum == mMaximum); some aggregate or
// virtual devices report continuous ranges.
let fmts: Vec<_> = ranges
.iter()
.map(|range| SupportedStreamConfigRange {
channels: n_channels as ChannelCount,
min_sample_rate: range.mMinimum as u32,
max_sample_rate: range.mMaximum as u32,
buffer_size,
sample_format,
})
.collect();
Ok(fmts.into_iter())
// AUHAL always converts to and from F32 regardless of the physical format, so
// advertise it at every nominal rate (most hardware reports discrete rates, i.e.
// mMinimum == mMaximum; some aggregate or virtual devices report continuous ranges).
let f32_fmts = ranges.iter().map(|range| SupportedStreamConfigRange {
channels: n_channels as ChannelCount,
min_sample_rate: range.mMinimum as u32,
max_sample_rate: range.mMaximum as u32,
buffer_size,
sample_format: SampleFormat::F32,
});

// The hardware's own physical formats, so integer-only devices advertise their
// bit-perfect paths instead of only the AUHAL-converted F32 one.
let physical_fmts = get_supported_physical_stream_formats(self.audio_device_id)
.unwrap_or_default()
.into_iter()
.filter_map(|fmt| {
let Some(coreaudio::audio_unit::AudioFormat::LinearPCM(flags)) =
coreaudio::audio_unit::AudioFormat::from_format_and_flag(
fmt.mFormat.mFormatID,
Some(fmt.mFormat.mFormatFlags),
)
else {
return None;
};
let sample_format = match CoreAudioSampleFormat::from_flags_and_bits_per_sample(
flags,
fmt.mFormat.mBitsPerChannel,
)? {
CoreAudioSampleFormat::I8 => SampleFormat::I8,
CoreAudioSampleFormat::I16 => SampleFormat::I16,
CoreAudioSampleFormat::I24 => SampleFormat::I24,
CoreAudioSampleFormat::I32 => SampleFormat::I32,
// Already covered by f32_fmts at every rate, not just this row's range.
CoreAudioSampleFormat::F32 => return None,
};
Some(SupportedStreamConfigRange {
channels: fmt.mFormat.mChannelsPerFrame as ChannelCount,
min_sample_rate: fmt.mSampleRateRange.mMinimum as u32,
max_sample_rate: fmt.mSampleRateRange.mMaximum as u32,
buffer_size,
sample_format,
})
});

Ok(f32_fmts
.chain(physical_fmts)
.collect::<Vec<_>>()
.into_iter())
}
}

Expand Down Expand Up @@ -711,14 +736,15 @@ impl Device {

// Set the physical stream format (bit depth + sample rate) on the hardware device.
// This avoids unnecessary format conversions, which is especially important on aggregate
// devices. Falls back to sample-rate-only if no matching physical format is available.
if set_physical_format(
// devices. Falls back to sample-rate-only if no matching physical format is available, or
// if the closest match found doesn't actually run at the requested rate.
if !set_physical_format(
self.audio_device_id,
config.sample_rate,
config.channels,
sample_format,
)
.is_err()
.is_ok_and(|asbd| (asbd.mSampleRate - config.sample_rate as f64).abs() < 1.0)
{
set_sample_rate(self.audio_device_id, config.sample_rate, timeout)?;
}
Expand Down Expand Up @@ -845,14 +871,15 @@ impl Device {

// Best-effort: set the physical stream format (bit depth + sample rate) on the hardware.
// This avoids unnecessary conversions, especially on aggregate devices. Not an error if
// it fails — the AudioUnit will handle format conversion as before.
if set_physical_format(
// it fails: the AudioUnit will handle format conversion as before. Also falls back if the
// closest match found doesn't actually run at the requested rate.
if !set_physical_format(
self.audio_device_id,
config.sample_rate,
config.channels,
sample_format,
)
.is_err()
.is_ok_and(|asbd| (asbd.mSampleRate - config.sample_rate as f64).abs() < 1.0)
{
set_sample_rate(self.audio_device_id, config.sample_rate, timeout)?;
}
Expand Down
Loading
Loading