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 acec63096..90a4e5e37 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,26 @@ 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). +## [Unreleased] + +### Added + +- `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. + +### 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**: 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. + ## [0.17.1] - 2026-01-04 ### Added @@ -12,16 +32,16 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - **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 + +- **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). -### Changed - -- **ALSA**: Devices now report direction from hint metadata and physical hardware probing. - ## [0.17.0] - 2025-12-20 ### Added @@ -1034,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 diff --git a/Cargo.toml b/Cargo.toml index 429b30b27..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 @@ -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/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) 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:?}"); 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..0b880df97 100644 --- a/src/host/alsa/enumerate.rs +++ b/src/host/alsa/enumerate.rs @@ -1,9 +1,6 @@ -use std::{ - collections::HashSet, - sync::{Arc, Mutex}, -}; +use std::collections::HashSet; -use super::{alsa, Device}; +use super::{alsa, Device, Host}; use crate::{BackendSpecificError, DeviceDirection, DevicesError}; const HW_PREFIX: &str = "hw"; @@ -21,47 +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, + _context: 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, - handles: Arc::new(Mutex::new(Default::default())), - }); + // 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, + _context: self.inner.clone(), + }); + } } } - } - Ok(devices.into_iter()) + Ok(devices.into_iter()) + } } /// Formats device description in ALSA style: "Card Name, Device Name\nPurpose" @@ -144,28 +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, - handles: Arc::new(Mutex::new(Default::default())), - }) - } -} - 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 884da59c9..bb6a55ef2 100644 --- a/src/host/alsa/mod.rs +++ b/src/host/alsa/mod.rs @@ -8,8 +8,8 @@ extern crate libc; use std::{ cmp, sync::{ - atomic::{AtomicBool, Ordering}, - Arc, Mutex, + atomic::{AtomicBool, AtomicUsize, Ordering}, + Arc, }, thread::{self, JoinHandle}, time::Duration, @@ -85,13 +85,21 @@ 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; +#[derive(Debug, Clone)] +pub struct Host { + inner: Arc, +} impl Host { pub fn new() -> Result { - Ok(Host) + let inner = AlsaContext::new().map_err(|_| crate::HostUnavailable)?; + Ok(Host { + inner: Arc::new(inner), + }) } } @@ -105,7 +113,7 @@ impl HostTrait for Host { } fn devices(&self) -> Result { - enumerate::devices() + self.enumerate_devices() } fn default_input_device(&self) -> Option { @@ -117,6 +125,32 @@ impl HostTrait for Host { } } +/// Global count of active ALSA context instances. +static ALSA_CONTEXT_COUNT: AtomicUsize = AtomicUsize::new(0); + +/// ALSA backend context shared between `Host`, `Device`, and `Stream` via `Arc`. +#[derive(Debug)] +pub(super) struct AlsaContext; + +impl AlsaContext { + fn new() -> Result { + // 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 AlsaContext { + fn drop(&mut self) { + // 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(); + } + } +} + impl DeviceTrait for Device { type SupportedInputConfigs = SupportedInputConfigs; type SupportedOutputConfigs = SupportedOutputConfigs; @@ -221,8 +255,10 @@ impl DeviceTrait for Device { } } +#[derive(Debug)] struct TriggerSender(libc::c_int); +#[derive(Debug)] struct TriggerReceiver(libc::c_int); impl TriggerSender { @@ -265,57 +301,12 @@ impl Drop for TriggerReceiver { } } -#[derive(Default)] -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)] +#[derive(Clone, Debug)] pub struct Device { pcm_id: String, desc: Option, direction: DeviceDirection, - handles: Arc>, + _context: Arc, } impl PartialEq for Device { @@ -361,17 +352,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)) => return Err(BuildStreamError::DeviceNotAvailable), + | 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, @@ -422,6 +412,7 @@ impl Device { silence_template, can_pause, creation_instant, + _context: self._context.clone(), }; Ok(stream_inner) @@ -463,27 +454,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)) => { - 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. @@ -623,6 +610,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 @@ -669,11 +659,14 @@ 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())), + _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 // (e.g. broken due to a disconnected device). @@ -711,11 +704,15 @@ struct StreamInner { // If this field is `None` then the elapsed duration between `get_trigger_htstamp` and // `get_htstamp` is used. creation_instant: Option, + + // Keep ALSA context alive to prevent premature ALSA config cleanup + _context: Arc, } // 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 +1423,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 +1437,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 => {