From be00832d91b987182e5068c8d281b7c49483a492 Mon Sep 17 00:00:00 2001 From: Roderick van Domburg Date: Wed, 31 Dec 2025 00:21:30 +0100 Subject: [PATCH 01/11] chore(alsa): bump alsa-rs to 0.11 * add Debug derives * improve error handling --- Cargo.toml | 2 +- src/host/alsa/mod.rs | 27 +++++++++++++++++++-------- 2 files changed, 20 insertions(+), 9 deletions(-) diff --git a/Cargo.toml b/Cargo.toml index 429b30b27..3c484f2dd 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -85,7 +85,7 @@ num-traits = { version = "0.2", optional = true } jack = { version = "0.13", optional = true } [target.'cfg(any(target_os = "linux", target_os = "dragonfly", target_os = "freebsd", target_os = "netbsd"))'.dependencies] -alsa = "0.10" +alsa = "0.11" libc = "0.2" audio_thread_priority = { version = "0.34", optional = true } jack = { version = "0.13", optional = true } diff --git a/src/host/alsa/mod.rs b/src/host/alsa/mod.rs index 884da59c9..9f72af388 100644 --- a/src/host/alsa/mod.rs +++ b/src/host/alsa/mod.rs @@ -85,6 +85,9 @@ mod enumerate; const DEFAULT_DEVICE: &str = "default"; +// TODO: Not yet defined in rust-lang/libc crate +const LIBC_ENOTSUPP: libc::c_int = 524; + /// The default Linux and BSD host type. #[derive(Debug)] pub struct Host; @@ -221,8 +224,10 @@ impl DeviceTrait for Device { } } +#[derive(Debug)] struct TriggerSender(libc::c_int); +#[derive(Debug)] struct TriggerReceiver(libc::c_int); impl TriggerSender { @@ -265,7 +270,7 @@ impl Drop for TriggerReceiver { } } -#[derive(Default)] +#[derive(Default, Debug)] struct DeviceHandles { playback: Option, capture: Option, @@ -310,7 +315,7 @@ impl DeviceHandles { } } -#[derive(Clone)] +#[derive(Clone, Debug)] pub struct Device { pcm_id: String, desc: Option, @@ -371,7 +376,9 @@ impl Device { Err((_, libc::ENOENT)) | Err((_, libc::EBUSY)) | Err((_, libc::EPERM)) - | Err((_, libc::EAGAIN)) => return Err(BuildStreamError::DeviceNotAvailable), + | Err((_, libc::EAGAIN)) + | Err((_, libc::ENODEV)) + | Err((_, LIBC_ENOTSUPP)) => return Err(BuildStreamError::DeviceNotAvailable), Err((_, libc::EINVAL)) => return Err(BuildStreamError::InvalidArgument), Err((e, _)) => return Err(e.into()), Ok(handle) => handle, @@ -475,7 +482,9 @@ impl Device { Err((_, libc::ENOENT)) | Err((_, libc::EBUSY)) | Err((_, libc::EPERM)) - | Err((_, libc::EAGAIN)) => { + | Err((_, libc::EAGAIN)) + | Err((_, libc::ENODEV)) + | Err((_, LIBC_ENOTSUPP)) => { return Err(SupportedStreamConfigsError::DeviceNotAvailable) } Err((_, libc::EINVAL)) => return Err(SupportedStreamConfigsError::InvalidArgument), @@ -674,6 +683,7 @@ impl Default for Device { } } +#[derive(Debug)] struct StreamInner { // Flag used to check when to stop polling, regardless of the state of the stream // (e.g. broken due to a disconnected device). @@ -716,6 +726,7 @@ struct StreamInner { // Assume that the ALSA library is built with thread safe option. unsafe impl Sync for StreamInner {} +#[derive(Debug)] pub struct Stream { /// The high-priority audio processing thread calling callbacks. /// Option used for moving out in destructor. @@ -1426,9 +1437,8 @@ fn set_sw_params_from_format( } let start_threshold = match stream_type { alsa::Direction::Playback => { - // Always use 2-period double-buffering: one period playing from hardware, one - // period queued in the software buffer. This ensures consistent low latency - // regardless of the total buffer size. + // Start playback when 2 periods are filled. This ensures consistent low-latency + // startup regardless of total buffer size (whether 2 or more periods). 2 * period } alsa::Direction::Capture => 1, @@ -1441,7 +1451,8 @@ fn set_sw_params_from_format( let target_avail = match stream_type { alsa::Direction::Playback => { // Wake when buffer level drops to one period remaining (avail >= buffer - period). - // This maintains double-buffering by refilling when we're down to one period. + // This ensures we can always write one full period. Works correctly regardless + // of total periods: 2-period buffer wakes at period, 4-period at 3*period, etc. buffer - period } alsa::Direction::Capture => { From 0bb9d796ca5f0c1293e340a7cda42fbfc110ae3b Mon Sep 17 00:00:00 2001 From: Roderick van Domburg Date: Wed, 31 Dec 2025 00:22:11 +0100 Subject: [PATCH 02/11] fix(alsa): suppress raw ALSA errors during enumeration on Linux Fixes #384 --- examples/enumerate.rs | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/examples/enumerate.rs b/examples/enumerate.rs index 7d824c3ac..6e2680961 100644 --- a/examples/enumerate.rs +++ b/examples/enumerate.rs @@ -15,6 +15,10 @@ extern crate cpal; use cpal::traits::{DeviceTrait, HostTrait}; fn main() -> Result<(), anyhow::Error> { + // To print raw ALSA errors to stderr during enumeration, comment out the line below: + #[cfg(target_os = "linux")] + let _silence_alsa_errors = alsa::Output::local_error_handler()?; + println!("Supported hosts:\n {:?}", cpal::ALL_HOSTS); let available_hosts = cpal::available_hosts(); println!("Available hosts:\n {available_hosts:?}"); From 045ca6dd7a9451451d39cef091351663c6e62819 Mon Sep 17 00:00:00 2001 From: Roderick van Domburg Date: Thu, 1 Jan 2026 14:16:13 +0100 Subject: [PATCH 03/11] fix(alsa): remove device handle caching to fix duplex config queries Remove handle caching introduced in #506. The caching held devices open during enumeration, which prevented querying both input and output configs on duplex devices (EBUSY errors) and blocked other applications from accessing the devices. For the rare hardware where rapid open/close is problematic (like some NVIDIA HDMI cards), applications can now implement retry logic using the new DeviceBusy error variant, which separates retriable errors (EBUSY, EAGAIN) from permanent failures (ENOENT, EPERM, etc). Fixes: - #615 - #634 --- src/error.rs | 16 ++++++ src/host/alsa/enumerate.rs | 7 +-- src/host/alsa/mod.rs | 101 +++++++++---------------------------- 3 files changed, 41 insertions(+), 83 deletions(-) diff --git a/src/error.rs b/src/error.rs index 016d7cdba..4e4d1f6a9 100644 --- a/src/error.rs +++ b/src/error.rs @@ -122,6 +122,9 @@ pub enum SupportedStreamConfigsError { /// The device no longer exists. This can happen if the device is disconnected while the /// program is running. DeviceNotAvailable, + /// The device is temporarily busy. This can happen when another application or stream + /// is using the device. Retrying after a short delay may succeed. + DeviceBusy, /// We called something the C-Layer did not understand InvalidArgument, /// See the [`BackendSpecificError`] docs for more information about this error variant. @@ -133,6 +136,7 @@ impl Display for SupportedStreamConfigsError { match self { Self::BackendSpecific { err } => err.fmt(f), Self::DeviceNotAvailable => f.write_str("The requested device is no longer available. For example, it has been unplugged."), + Self::DeviceBusy => f.write_str("The requested device is temporarily busy. Another application or stream may be using it."), Self::InvalidArgument => f.write_str("Invalid argument passed to the backend. For example, this happens when trying to read capture capabilities when the device does not support it.") } } @@ -152,6 +156,9 @@ pub enum DefaultStreamConfigError { /// The device no longer exists. This can happen if the device is disconnected while the /// program is running. DeviceNotAvailable, + /// The device is temporarily busy. This can happen when another application or stream + /// is using the device. Retrying after a short delay may succeed. + DeviceBusy, /// Returned if e.g. the default input format was requested on an output-only audio device. StreamTypeNotSupported, /// See the [`BackendSpecificError`] docs for more information about this error variant. @@ -165,6 +172,9 @@ impl Display for DefaultStreamConfigError { Self::DeviceNotAvailable => f.write_str( "The requested device is no longer available. For example, it has been unplugged.", ), + Self::DeviceBusy => f.write_str( + "The requested device is temporarily busy. Another application or stream may be using it.", + ), Self::StreamTypeNotSupported => { f.write_str("The requested stream type is not supported by the device.") } @@ -185,6 +195,9 @@ pub enum BuildStreamError { /// The device no longer exists. This can happen if the device is disconnected while the /// program is running. DeviceNotAvailable, + /// The device is temporarily busy. This can happen when another application or stream + /// is using the device. Retrying after a short delay may succeed. + DeviceBusy, /// The specified stream configuration is not supported. StreamConfigNotSupported, /// We called something the C-Layer did not understand @@ -205,6 +218,9 @@ impl Display for BuildStreamError { Self::DeviceNotAvailable => f.write_str( "The requested device is no longer available. For example, it has been unplugged.", ), + Self::DeviceBusy => f.write_str( + "The requested device is temporarily busy. Another application or stream may be using it.", + ), Self::StreamConfigNotSupported => { f.write_str("The requested stream configuration is not supported by the device.") } diff --git a/src/host/alsa/enumerate.rs b/src/host/alsa/enumerate.rs index 194d37d2d..075fbeffe 100644 --- a/src/host/alsa/enumerate.rs +++ b/src/host/alsa/enumerate.rs @@ -1,7 +1,4 @@ -use std::{ - collections::HashSet, - sync::{Arc, Mutex}, -}; +use std::collections::HashSet; use super::{alsa, Device}; use crate::{BackendSpecificError, DeviceDirection, DevicesError}; @@ -55,7 +52,6 @@ pub fn devices() -> Result { pcm_id, desc: Some(format_device_description(&phys_dev, prefix)), direction: phys_dev.direction, - handles: Arc::new(Mutex::new(Default::default())), }); } } @@ -161,7 +157,6 @@ impl TryFrom for Device { pcm_id: pcm_id.to_owned(), desc: hint.desc, direction, - handles: Arc::new(Mutex::new(Default::default())), }) } } diff --git a/src/host/alsa/mod.rs b/src/host/alsa/mod.rs index 9f72af388..b4309e64c 100644 --- a/src/host/alsa/mod.rs +++ b/src/host/alsa/mod.rs @@ -9,7 +9,7 @@ use std::{ cmp, sync::{ atomic::{AtomicBool, Ordering}, - Arc, Mutex, + Arc, }, thread::{self, JoinHandle}, time::Duration, @@ -270,57 +270,11 @@ impl Drop for TriggerReceiver { } } -#[derive(Default, Debug)] -struct DeviceHandles { - playback: Option, - capture: Option, -} - -impl DeviceHandles { - /// Get a mutable reference to the `Option` for a specific `stream_type`. - /// If the `Option` is `None`, the `alsa::PCM` will be opened and placed in - /// the `Option` before returning. If `try_open()` returns `Ok` the contained - /// `Option` is guaranteed to be `Some(..)`. - fn try_open( - &mut self, - pcm_id: &str, - stream_type: alsa::Direction, - ) -> Result<&mut Option, alsa::Error> { - let handle = match stream_type { - alsa::Direction::Playback => &mut self.playback, - alsa::Direction::Capture => &mut self.capture, - }; - - if handle.is_none() { - *handle = Some(alsa::pcm::PCM::new(pcm_id, stream_type, true)?); - } - - Ok(handle) - } - - /// Get a mutable reference to the `alsa::PCM` handle for a specific `stream_type`. - /// If the handle is not yet opened, it will be opened and stored in `self`. - fn get_mut( - &mut self, - pcm_id: &str, - stream_type: alsa::Direction, - ) -> Result<&mut alsa::PCM, alsa::Error> { - Ok(self.try_open(pcm_id, stream_type)?.as_mut().unwrap()) - } - - /// Take ownership of the `alsa::PCM` handle for a specific `stream_type`. - /// If the handle is not yet opened, it will be opened and returned. - fn take(&mut self, name: &str, stream_type: alsa::Direction) -> Result { - Ok(self.try_open(name, stream_type)?.take().unwrap()) - } -} - #[derive(Clone, Debug)] pub struct Device { pcm_id: String, desc: Option, direction: DeviceDirection, - handles: Arc>, } impl PartialEq for Device { @@ -366,19 +320,16 @@ impl Device { } } - let handle = match self - .handles - .lock() - .unwrap() - .take(&self.pcm_id, stream_type) + let handle = match alsa::pcm::PCM::new(&self.pcm_id, stream_type, true) .map_err(|e| (e, e.errno())) { Err((_, libc::ENOENT)) - | Err((_, libc::EBUSY)) | Err((_, libc::EPERM)) - | Err((_, libc::EAGAIN)) | Err((_, libc::ENODEV)) | Err((_, LIBC_ENOTSUPP)) => return Err(BuildStreamError::DeviceNotAvailable), + Err((_, libc::EBUSY)) | Err((_, libc::EAGAIN)) => { + return Err(BuildStreamError::DeviceBusy) + } Err((_, libc::EINVAL)) => return Err(BuildStreamError::InvalidArgument), Err((e, _)) => return Err(e.into()), Ok(handle) => handle, @@ -470,29 +421,23 @@ impl Device { &self, stream_t: alsa::Direction, ) -> Result, SupportedStreamConfigsError> { - // Open device handle and cache it for reuse in build_stream_inner(). - // This avoids opening the device twice in the common workflow: - // 1. Query supported configs (opens and caches handle) - // 2. Build stream (takes cached handle, or opens if not cached) - let mut guard = self.handles.lock().unwrap(); - let pcm = match guard - .get_mut(&self.pcm_id, stream_t) - .map_err(|e| (e, e.errno())) - { - Err((_, libc::ENOENT)) - | Err((_, libc::EBUSY)) - | Err((_, libc::EPERM)) - | Err((_, libc::EAGAIN)) - | Err((_, libc::ENODEV)) - | Err((_, LIBC_ENOTSUPP)) => { - return Err(SupportedStreamConfigsError::DeviceNotAvailable) - } - Err((_, libc::EINVAL)) => return Err(SupportedStreamConfigsError::InvalidArgument), - Err((e, _)) => return Err(e.into()), - Ok(pcm) => pcm, - }; + let pcm = + match alsa::pcm::PCM::new(&self.pcm_id, stream_t, true).map_err(|e| (e, e.errno())) { + Err((_, libc::ENOENT)) + | Err((_, libc::EPERM)) + | Err((_, libc::ENODEV)) + | Err((_, LIBC_ENOTSUPP)) => { + return Err(SupportedStreamConfigsError::DeviceNotAvailable) + } + Err((_, libc::EBUSY)) | Err((_, libc::EAGAIN)) => { + return Err(SupportedStreamConfigsError::DeviceBusy) + } + Err((_, libc::EINVAL)) => return Err(SupportedStreamConfigsError::InvalidArgument), + Err((e, _)) => return Err(e.into()), + Ok(pcm) => pcm, + }; - let hw_params = alsa::pcm::HwParams::any(pcm)?; + let hw_params = alsa::pcm::HwParams::any(&pcm)?; // Test both LE and BE formats to detect what the hardware actually supports. // LE is listed first as it's the common case for most audio hardware. @@ -632,6 +577,9 @@ impl Device { Err(SupportedStreamConfigsError::DeviceNotAvailable) => { return Err(DefaultStreamConfigError::DeviceNotAvailable); } + Err(SupportedStreamConfigsError::DeviceBusy) => { + return Err(DefaultStreamConfigError::DeviceBusy); + } Err(SupportedStreamConfigsError::InvalidArgument) => { // this happens sometimes when querying for input and output capabilities, but // the device supports only one @@ -678,7 +626,6 @@ impl Default for Device { pcm_id: DEFAULT_DEVICE.to_owned(), desc: Some("Default Audio Device".to_string()), direction: DeviceDirection::Unknown, - handles: Arc::new(Mutex::new(Default::default())), } } } From 1902978935de00fd62f3c81575bff624f791b0ad Mon Sep 17 00:00:00 2001 From: Roderick van Domburg Date: Thu, 1 Jan 2026 22:43:07 +0100 Subject: [PATCH 04/11] feat(alsa): properly check and free ALSA global config When the last Host is dropped, free the global ALSA config cache via alsa::config::update_free_global. This reduces Valgrind errors. --- src/host/alsa/enumerate.rs | 105 +++++++++++++++++-------------------- src/host/alsa/mod.rs | 65 ++++++++++++++++------- 2 files changed, 95 insertions(+), 75 deletions(-) diff --git a/src/host/alsa/enumerate.rs b/src/host/alsa/enumerate.rs index 075fbeffe..b8c777e77 100644 --- a/src/host/alsa/enumerate.rs +++ b/src/host/alsa/enumerate.rs @@ -1,6 +1,6 @@ use std::collections::HashSet; -use super::{alsa, Device}; +use super::{alsa, Device, Host}; use crate::{BackendSpecificError, DeviceDirection, DevicesError}; const HW_PREFIX: &str = "hw"; @@ -18,46 +18,60 @@ struct PhysicalDevice { /// Iterator over available ALSA PCM devices (physical hardware and virtual/plugin devices). pub type Devices = std::vec::IntoIter; -/// Enumerates all available ALSA PCM devices (physical hardware and virtual/plugin devices). -/// -/// We enumerate both ALSA hints and physical devices because: -/// - Hints provide virtual devices, user configurations, and card-specific devices with metadata -/// - Physical probing provides traditional numeric naming (hw:CARD=0,DEV=0) for compatibility -pub fn devices() -> Result { - let mut devices = Vec::new(); - let mut seen_pcm_ids = HashSet::new(); - - let physical_devices = physical_devices(); - - // Add all hint devices, including virtual devices - if let Ok(hints) = alsa::device_name::HintIter::new_str(None, "pcm") { - for hint in hints { - if let Ok(device) = Device::try_from(hint) { - seen_pcm_ids.insert(device.pcm_id.clone()); - devices.push(device); +impl Host { + /// Enumerates all available ALSA PCM devices (physical hardware and virtual/plugin devices). + /// + /// We enumerate both ALSA hints and physical devices because: + /// - Hints provide virtual devices, user configs, and card-specific devices with metadata + /// - Physical probing provides traditional numeric naming (hw:CARD=0,DEV=0) for compatibility + pub(super) fn enumerate_devices(&self) -> Result { + let mut devices = Vec::new(); + let mut seen_pcm_ids = HashSet::new(); + + let physical_devices = physical_devices(); + + // Add all hint devices, including virtual devices + if let Ok(hints) = alsa::device_name::HintIter::new_str(None, "pcm") { + for hint in hints { + if let Some(pcm_id) = hint.name { + // Per ALSA docs (https://alsa-project.org/alsa-doc/alsa-lib/group___hint.html), + // NULL IOID means both Input/Output. Whether a stream can actually open in a + // given direction can only be determined by attempting to open it. + let direction = hint.direction.map_or(DeviceDirection::Duplex, Into::into); + let device = Device { + pcm_id, + desc: hint.desc, + direction, + _host: self.inner.clone(), + }; + + seen_pcm_ids.insert(device.pcm_id.clone()); + devices.push(device); + } } } - } - // Add hw:/plughw: for all physical devices with numeric index (traditional naming) - for phys_dev in physical_devices { - for prefix in [HW_PREFIX, PLUGHW_PREFIX] { - let pcm_id = format!( - "{}:CARD={},DEV={}", - prefix, phys_dev.card_index, phys_dev.device_index - ); - - if seen_pcm_ids.insert(pcm_id.clone()) { - devices.push(Device { - pcm_id, - desc: Some(format_device_description(&phys_dev, prefix)), - direction: phys_dev.direction, - }); + // Add hw:/plughw: for all physical devices with numeric index (traditional naming) + for phys_dev in physical_devices { + for prefix in [HW_PREFIX, PLUGHW_PREFIX] { + let pcm_id = format!( + "{}:CARD={},DEV={}", + prefix, phys_dev.card_index, phys_dev.device_index + ); + + if seen_pcm_ids.insert(pcm_id.clone()) { + devices.push(Device { + pcm_id, + desc: Some(format_device_description(&phys_dev, prefix)), + direction: phys_dev.direction, + _host: self.inner.clone(), + }); + } } } - } - Ok(devices.into_iter()) + Ok(devices.into_iter()) + } } /// Formats device description in ALSA style: "Card Name, Device Name\nPurpose" @@ -140,27 +154,6 @@ impl From for DevicesError { } } -impl TryFrom for Device { - type Error = BackendSpecificError; - - fn try_from(hint: alsa::device_name::Hint) -> Result { - let pcm_id = hint.name.ok_or_else(|| Self::Error { - description: "ALSA hint missing PCM ID".to_string(), - })?; - - // Per ALSA docs (https://alsa-project.org/alsa-doc/alsa-lib/group___hint.html), - // NULL IOID means both Input/Output. Whether a stream can actually open in a given - // direction can only be determined by attempting to open it. - let direction = hint.direction.map_or(DeviceDirection::Duplex, Into::into); - - Ok(Self { - pcm_id: pcm_id.to_owned(), - desc: hint.desc, - direction, - }) - } -} - impl From for DeviceDirection { fn from(direction: alsa::Direction) -> Self { match direction { diff --git a/src/host/alsa/mod.rs b/src/host/alsa/mod.rs index b4309e64c..2686d5b54 100644 --- a/src/host/alsa/mod.rs +++ b/src/host/alsa/mod.rs @@ -8,7 +8,7 @@ extern crate libc; use std::{ cmp, sync::{ - atomic::{AtomicBool, Ordering}, + atomic::{AtomicBool, AtomicUsize, Ordering}, Arc, }, thread::{self, JoinHandle}, @@ -89,12 +89,26 @@ const DEFAULT_DEVICE: &str = "default"; const LIBC_ENOTSUPP: libc::c_int = 524; /// The default Linux and BSD host type. -#[derive(Debug)] -pub struct Host; +#[derive(Debug, Clone)] +pub struct Host { + inner: Arc, +} impl Host { pub fn new() -> Result { - Ok(Host) + let inner = HostInner::new().map_err(|_| crate::HostUnavailable)?; + Ok(Host { + inner: Arc::new(inner), + }) + } + + fn new_default(&self) -> Device { + Device { + pcm_id: DEFAULT_DEVICE.to_owned(), + desc: Some("Default Audio Device".to_string()), + direction: DeviceDirection::Unknown, + _host: self.inner.clone(), + } } } @@ -108,15 +122,39 @@ impl HostTrait for Host { } fn devices(&self) -> Result { - enumerate::devices() + self.enumerate_devices() } fn default_input_device(&self) -> Option { - Some(Device::default()) + Some(self.new_default()) } fn default_output_device(&self) -> Option { - Some(Device::default()) + Some(self.new_default()) + } +} + +/// Global count of active ALSA `Host` instances. +static HOST_COUNT: AtomicUsize = AtomicUsize::new(0); + +/// Inner `Host` state that is shared between `Host`, `Device`, and `Stream` via `Arc`. +#[derive(Debug)] +pub(super) struct HostInner; + +impl HostInner { + fn new() -> Result { + alsa::config::update()?; + HOST_COUNT.fetch_add(1, Ordering::SeqCst); + Ok(HostInner) + } +} + +impl Drop for HostInner { + fn drop(&mut self) { + // Only free the global ALSA config cache when all Hosts, Devices, and Streams are dropped. + if HOST_COUNT.fetch_sub(1, Ordering::SeqCst) == 1 { + let _ = alsa::config::update_free_global(); + } } } @@ -275,6 +313,7 @@ pub struct Device { pcm_id: String, desc: Option, direction: DeviceDirection, + _host: Arc, } impl PartialEq for Device { @@ -618,18 +657,6 @@ impl Device { } } -impl Default for Device { - fn default() -> Self { - // "default" is a virtual ALSA device that redirects to the configured default. We cannot - // determine its actual capabilities without opening it, so we return Unknown direction. - Self { - pcm_id: DEFAULT_DEVICE.to_owned(), - desc: Some("Default Audio Device".to_string()), - direction: DeviceDirection::Unknown, - } - } -} - #[derive(Debug)] struct StreamInner { // Flag used to check when to stop polling, regardless of the state of the stream From 13a29504d12eb572c9c295285ab1396fdb957a40 Mon Sep 17 00:00:00 2001 From: Roderick van Domburg Date: Thu, 1 Jan 2026 23:10:24 +0100 Subject: [PATCH 05/11] docs: update CHANGELOG with ALSA fixes and additions --- CHANGELOG.md | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index acec63096..a196c7fa5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,17 +9,23 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added -- **ALSA**: `Default` implementation for `Device` (returns the ALSA "default" device). +- `DeviceBusy` error variant for retriable device access errors (EBUSY, EAGAIN). +- **ALSA**: `Debug` implementations for `Host`, `Device`, `Stream`, and internal types. +- **ALSA**: Example demonstrating ALSA error suppression during enumeration. - **CI**: Checks default/no-default/all feature sets with platform-dependent MSRV for JACK. ### Fixed - **ALSA**: Device enumeration now includes both hints and physical cards. +- **ALSA**: Enumerating input and output devices no longer interferes with each other. +- **ALSA**: Device handles are no longer exclusively held between operations. +- **ALSA**: Valgrind memory leak reports from ALSA global configuration cache. - **JACK**: No longer builds on iOS. - **WASM**: WasmBindgen no longer crashes (regression from 0.17.0). ### Changed +- **ALSA**: Update `alsa` dependency from 0.10 to 0.11. - **ALSA**: Devices now report direction from hint metadata and physical hardware probing. ## [0.17.0] - 2025-12-20 From f677e3a071daef532bbb544e037bf01e82b1d3d7 Mon Sep 17 00:00:00 2001 From: Roderick van Domburg Date: Thu, 1 Jan 2026 23:40:26 +0100 Subject: [PATCH 06/11] fix(alsa): Keep Host alive in StreamInner --- src/host/alsa/mod.rs | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/src/host/alsa/mod.rs b/src/host/alsa/mod.rs index 2686d5b54..a77fef8bb 100644 --- a/src/host/alsa/mod.rs +++ b/src/host/alsa/mod.rs @@ -419,6 +419,7 @@ impl Device { silence_template, can_pause, creation_instant, + _host: self._host.clone(), }; Ok(stream_inner) @@ -695,6 +696,9 @@ struct StreamInner { // If this field is `None` then the elapsed duration between `get_trigger_htstamp` and // `get_htstamp` is used. creation_instant: Option, + + // Keep Host alive to prevent premature ALSA config cleanup + _host: Arc, } // Assume that the ALSA library is built with thread safe option. From eeb126e3bc559ce7b8d5289fd96e4d4911e76338 Mon Sep 17 00:00:00 2001 From: Roderick van Domburg Date: Thu, 1 Jan 2026 23:40:50 +0100 Subject: [PATCH 07/11] chore(alsa): raise MSRV to 1.82 --- .github/workflows/platforms.yml | 2 +- CHANGELOG.md | 1 + README.md | 2 +- 3 files changed, 3 insertions(+), 2 deletions(-) diff --git a/.github/workflows/platforms.yml b/.github/workflows/platforms.yml index 646f4b83c..e4e923077 100644 --- a/.github/workflows/platforms.yml +++ b/.github/workflows/platforms.yml @@ -25,7 +25,7 @@ on: env: # MSRV varies by backend due to platform-specific dependencies MSRV_AAUDIO: "1.82" - MSRV_ALSA: "1.77" + MSRV_ALSA: "1.82" MSRV_COREAUDIO: "1.80" MSRV_JACK: "1.82" MSRV_WASIP1: "1.78" diff --git a/CHANGELOG.md b/CHANGELOG.md index a196c7fa5..ad1d2df5e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -26,6 +26,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Changed - **ALSA**: Update `alsa` dependency from 0.10 to 0.11. +- **ALSA**: MSRV increased from 1.77 to 1.82 (required by alsa-sys 0.4.0). - **ALSA**: Devices now report direction from hint metadata and physical hardware probing. ## [0.17.0] - 2025-12-20 diff --git a/README.md b/README.md index 620e80ec5..47d92c694 100644 --- a/README.md +++ b/README.md @@ -10,7 +10,7 @@ Low-level library for audio input and output in pure Rust. The minimum Rust version required depends on which audio backend and features you're using, as each platform has different dependencies: - **AAudio (Android):** Rust **1.82** (due to `ndk` crate requirements) -- **ALSA (Linux/BSD):** Rust **1.77** (due to `alsa-sys` crate requirements) +- **ALSA (Linux/BSD):** Rust **1.82** (due to `alsa-sys` crate requirements) - **CoreAudio (macOS/iOS):** Rust **1.80** (due to `coreaudio-rs` crate requirements) - **JACK (Linux/BSD/macOS/Windows):** Rust **1.82** (due to `jack` crate requirements) - **WASAPI/ASIO (Windows):** Rust **1.82** (due to `windows` crate requirements) From b2efa980e87e55774b2b7de7cc3c7188c215810f Mon Sep 17 00:00:00 2001 From: Roderick van Domburg Date: Thu, 1 Jan 2026 23:43:56 +0100 Subject: [PATCH 08/11] refactor(alsa): construct HostInner with map after config update --- src/host/alsa/mod.rs | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/src/host/alsa/mod.rs b/src/host/alsa/mod.rs index a77fef8bb..2e3368552 100644 --- a/src/host/alsa/mod.rs +++ b/src/host/alsa/mod.rs @@ -143,9 +143,10 @@ pub(super) struct HostInner; impl HostInner { fn new() -> Result { - alsa::config::update()?; - HOST_COUNT.fetch_add(1, Ordering::SeqCst); - Ok(HostInner) + alsa::config::update().map(|_| { + HOST_COUNT.fetch_add(1, Ordering::SeqCst); + HostInner + }) } } From 99e7cddbdb5964c1d8f0f69953592310a18854b5 Mon Sep 17 00:00:00 2001 From: Roderick van Domburg Date: Fri, 2 Jan 2026 11:43:52 +0100 Subject: [PATCH 09/11] chore: bump MSRV to 1.78 and update CI --- CHANGELOG.md | 1 + Cargo.toml | 2 +- 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index ad1d2df5e..4e193229f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -25,6 +25,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Changed +- Overall MSRV increased to 1.78. - **ALSA**: Update `alsa` dependency from 0.10 to 0.11. - **ALSA**: MSRV increased from 1.77 to 1.82 (required by alsa-sys 0.4.0). - **ALSA**: Devices now report direction from hint metadata and physical hardware probing. diff --git a/Cargo.toml b/Cargo.toml index 3c484f2dd..fb47eb19a 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -7,7 +7,7 @@ documentation = "https://docs.rs/cpal" license = "Apache-2.0" keywords = ["audio", "sound"] edition = "2021" -rust-version = "1.77" +rust-version = "1.78" [features] # ASIO backend for Windows From 701735413dc515fad9d368b1755743bbb3c76716 Mon Sep 17 00:00:00 2001 From: Roderick van Domburg Date: Sun, 4 Jan 2026 20:03:31 +0100 Subject: [PATCH 10/11] refactor(alsa): change HostInner to AlsaContext * Initialize global ALSA config on first context creation and free it when the last context is dropped using ALSA_CONTEXT_COUNT. * Re-add Device::default and use it for Host default devices. --- CHANGELOG.md | 1 + src/host/alsa/enumerate.rs | 4 +-- src/host/alsa/mod.rs | 65 +++++++++++++++++++++----------------- 3 files changed, 39 insertions(+), 31 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 4e193229f..b8cfeddf1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added - `DeviceBusy` error variant for retriable device access errors (EBUSY, EAGAIN). +- **ALSA**: `Default` implementation for `Device` (returns the ALSA "default" device). - **ALSA**: `Debug` implementations for `Host`, `Device`, `Stream`, and internal types. - **ALSA**: Example demonstrating ALSA error suppression during enumeration. - **CI**: Checks default/no-default/all feature sets with platform-dependent MSRV for JACK. diff --git a/src/host/alsa/enumerate.rs b/src/host/alsa/enumerate.rs index b8c777e77..0b880df97 100644 --- a/src/host/alsa/enumerate.rs +++ b/src/host/alsa/enumerate.rs @@ -42,7 +42,7 @@ impl Host { pcm_id, desc: hint.desc, direction, - _host: self.inner.clone(), + _context: self.inner.clone(), }; seen_pcm_ids.insert(device.pcm_id.clone()); @@ -64,7 +64,7 @@ impl Host { pcm_id, desc: Some(format_device_description(&phys_dev, prefix)), direction: phys_dev.direction, - _host: self.inner.clone(), + _context: self.inner.clone(), }); } } diff --git a/src/host/alsa/mod.rs b/src/host/alsa/mod.rs index 2e3368552..bb6a55ef2 100644 --- a/src/host/alsa/mod.rs +++ b/src/host/alsa/mod.rs @@ -91,25 +91,16 @@ const LIBC_ENOTSUPP: libc::c_int = 524; /// The default Linux and BSD host type. #[derive(Debug, Clone)] pub struct Host { - inner: Arc, + inner: Arc, } impl Host { pub fn new() -> Result { - let inner = HostInner::new().map_err(|_| crate::HostUnavailable)?; + let inner = AlsaContext::new().map_err(|_| crate::HostUnavailable)?; Ok(Host { inner: Arc::new(inner), }) } - - fn new_default(&self) -> Device { - Device { - pcm_id: DEFAULT_DEVICE.to_owned(), - desc: Some("Default Audio Device".to_string()), - direction: DeviceDirection::Unknown, - _host: self.inner.clone(), - } - } } impl HostTrait for Host { @@ -126,34 +117,35 @@ impl HostTrait for Host { } fn default_input_device(&self) -> Option { - Some(self.new_default()) + Some(Device::default()) } fn default_output_device(&self) -> Option { - Some(self.new_default()) + Some(Device::default()) } } -/// Global count of active ALSA `Host` instances. -static HOST_COUNT: AtomicUsize = AtomicUsize::new(0); +/// Global count of active ALSA context instances. +static ALSA_CONTEXT_COUNT: AtomicUsize = AtomicUsize::new(0); -/// Inner `Host` state that is shared between `Host`, `Device`, and `Stream` via `Arc`. +/// ALSA backend context shared between `Host`, `Device`, and `Stream` via `Arc`. #[derive(Debug)] -pub(super) struct HostInner; +pub(super) struct AlsaContext; -impl HostInner { +impl AlsaContext { fn new() -> Result { - alsa::config::update().map(|_| { - HOST_COUNT.fetch_add(1, Ordering::SeqCst); - HostInner - }) + // Initialize global ALSA config cache on first context creation. + if ALSA_CONTEXT_COUNT.fetch_add(1, Ordering::SeqCst) == 0 { + alsa::config::update()?; + } + Ok(Self) } } -impl Drop for HostInner { +impl Drop for AlsaContext { fn drop(&mut self) { - // Only free the global ALSA config cache when all Hosts, Devices, and Streams are dropped. - if HOST_COUNT.fetch_sub(1, Ordering::SeqCst) == 1 { + // Free the global ALSA config cache when the last context is dropped. + if ALSA_CONTEXT_COUNT.fetch_sub(1, Ordering::SeqCst) == 1 { let _ = alsa::config::update_free_global(); } } @@ -314,7 +306,7 @@ pub struct Device { pcm_id: String, desc: Option, direction: DeviceDirection, - _host: Arc, + _context: Arc, } impl PartialEq for Device { @@ -420,7 +412,7 @@ impl Device { silence_template, can_pause, creation_instant, - _host: self._host.clone(), + _context: self._context.clone(), }; Ok(stream_inner) @@ -659,6 +651,21 @@ impl Device { } } +impl Default for Device { + fn default() -> Self { + // "default" is a virtual ALSA device that redirects to the configured default. We cannot + // determine its actual capabilities without opening it, so we return Unknown direction. + Self { + pcm_id: DEFAULT_DEVICE.to_owned(), + desc: Some("Default Audio Device".to_string()), + direction: DeviceDirection::Unknown, + _context: Arc::new( + AlsaContext::new().expect("Failed to initialize ALSA configuration"), + ), + } + } +} + #[derive(Debug)] struct StreamInner { // Flag used to check when to stop polling, regardless of the state of the stream @@ -698,8 +705,8 @@ struct StreamInner { // `get_htstamp` is used. creation_instant: Option, - // Keep Host alive to prevent premature ALSA config cleanup - _host: Arc, + // Keep ALSA context alive to prevent premature ALSA config cleanup + _context: Arc, } // Assume that the ALSA library is built with thread safe option. From 98ad34639217e972b127f86ef366d89565b731fc Mon Sep 17 00:00:00 2001 From: Roderick van Domburg Date: Sun, 4 Jan 2026 20:19:30 +0100 Subject: [PATCH 11/11] docs: move changes to Unreleased section --- CHANGELOG.md | 30 +++++++++++++++++++++--------- 1 file changed, 21 insertions(+), 9 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index b8cfeddf1..90a4e5e37 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,32 +5,43 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). -## [0.17.1] - 2026-01-04 +## [Unreleased] ### Added - `DeviceBusy` error variant for retriable device access errors (EBUSY, EAGAIN). -- **ALSA**: `Default` implementation for `Device` (returns the ALSA "default" device). - **ALSA**: `Debug` implementations for `Host`, `Device`, `Stream`, and internal types. - **ALSA**: Example demonstrating ALSA error suppression during enumeration. -- **CI**: Checks default/no-default/all feature sets with platform-dependent MSRV for JACK. + +### Changed + +- Overall MSRV increased to 1.78. +- **ALSA**: Update `alsa` dependency from 0.10 to 0.11. +- **ALSA**: MSRV increased from 1.77 to 1.82 (required by alsa-sys 0.4.0). ### Fixed -- **ALSA**: Device enumeration now includes both hints and physical cards. - **ALSA**: Enumerating input and output devices no longer interferes with each other. - **ALSA**: Device handles are no longer exclusively held between operations. - **ALSA**: Valgrind memory leak reports from ALSA global configuration cache. -- **JACK**: No longer builds on iOS. -- **WASM**: WasmBindgen no longer crashes (regression from 0.17.0). + +## [0.17.1] - 2026-01-04 + +### Added + +- **ALSA**: `Default` implementation for `Device` (returns the ALSA "default" device). +- **CI**: Checks default/no-default/all feature sets with platform-dependent MSRV for JACK. ### Changed -- Overall MSRV increased to 1.78. -- **ALSA**: Update `alsa` dependency from 0.10 to 0.11. -- **ALSA**: MSRV increased from 1.77 to 1.82 (required by alsa-sys 0.4.0). - **ALSA**: Devices now report direction from hint metadata and physical hardware probing. +### Fixed + +- **ALSA**: Device enumeration now includes both hints and physical cards. +- **JACK**: No longer builds on iOS. +- **WASM**: WasmBindgen no longer crashes (regression from 0.17.0). + ## [0.17.0] - 2025-12-20 ### Added @@ -1043,6 +1054,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Initial commit. +[Unreleased]: https://github.com/RustAudio/cpal/compare/v0.17.1...HEAD [0.17.1]: https://github.com/RustAudio/cpal/compare/v0.17.0...v0.17.1 [0.17.0]: https://github.com/RustAudio/cpal/compare/v0.16.0...v0.17.0 [0.16.0]: https://github.com/RustAudio/cpal/compare/v0.15.3...v0.16.0