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
22 changes: 5 additions & 17 deletions .github/workflows/platforms.yml
Original file line number Diff line number Diff line change
Expand Up @@ -25,9 +25,8 @@ 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.80"
MSRV_WASIP1: "1.78"
MSRV_WASM: "1.82"
MSRV_WINDOWS: "1.82"
Expand Down Expand Up @@ -66,24 +65,19 @@ jobs:
with:
toolchain: ${{ env.MSRV_ALSA }}

- name: Install Rust MSRV (${{ env.MSRV_JACK }})
uses: dtolnay/rust-toolchain@master
with:
toolchain: ${{ env.MSRV_JACK }}

- name: Rust Cache
uses: Swatinem/rust-cache@v2
with:
key: linux-${{ matrix.arch }}

- name: Check examples
run: cargo check --examples --verbose --workspace
run: cargo +${{ env.MSRV_ALSA }} check --examples --verbose --workspace

- name: Run tests (no features)
run: cargo +${{ env.MSRV_ALSA }} test --workspace --no-default-features --verbose

- name: Run tests (all features)
run: cargo +${{ env.MSRV_JACK }} test --workspace --all-features --verbose
run: cargo +${{ env.MSRV_ALSA }} test --workspace --all-features --verbose

# Linux ARMv7 (cross-compilation)
linux-armv7:
Expand All @@ -97,12 +91,6 @@ jobs:
toolchain: ${{ env.MSRV_ALSA }}
targets: armv7-unknown-linux-gnueabihf

- name: Install Rust MSRV (${{ env.MSRV_JACK }})
uses: dtolnay/rust-toolchain@master
with:
toolchain: ${{ env.MSRV_JACK }}
targets: armv7-unknown-linux-gnueabihf

- name: Rust Cache
uses: Swatinem/rust-cache@v2
with:
Expand All @@ -117,7 +105,7 @@ jobs:
run: cross +${{ env.MSRV_ALSA }} test --target armv7-unknown-linux-gnueabihf --workspace --no-default-features --verbose

- name: Run tests (all features)
run: cross +${{ env.MSRV_JACK }} test --target armv7-unknown-linux-gnueabihf --workspace --all-features --verbose
run: cross +${{ env.MSRV_ALSA }} test --target armv7-unknown-linux-gnueabihf --workspace --all-features --verbose

# Windows (x86_64 and i686)
windows:
Expand Down Expand Up @@ -179,7 +167,7 @@ jobs:
uses: Swatinem/rust-cache@v2

- name: Check examples
run: cargo check --examples --verbose --workspace
run: cargo +${{ env.MSRV_COREAUDIO }} check --examples --verbose --workspace

- name: Run tests (no features)
run: cargo test --workspace --no-default-features --verbose
Expand Down
14 changes: 12 additions & 2 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,14 +8,24 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
## [Unreleased]

### 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.

### Fixed

- **ALSA**: Device enumeration now includes both hints and physical cards.
- **ALSA**: Duplex device configuration queries.
- **ALSA**: Memory leaks from ALSA global configuration cache.

### Changed
- **ALSA**: Devices now report direction from hint metadata and physical hardware probing.

- Overall MSRV increased to 1.78.
- **ALSA**: Update `alsa` dependency from 0.10 to 0.11.
- **ALSA**: MSRV increased to 1.82.
- **ALSA**: Devices now report direction from hint metadata and physical hardware probing instead of querying supported configs.
- **ALSA**: Device handles are no longer exclusively held between operations.

## [0.17.0] - 2025-12-20

Expand Down
4 changes: 2 additions & 2 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -84,7 +84,7 @@ asio-sys = { version = "0.2", path = "asio-sys", optional = true }
num-traits = { version = "0.2", 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 }
Expand Down
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.80** (due to `jack` crate requirements)
- **WASAPI/ASIO (Windows):** Rust **1.82** (due to `windows` crate requirements)
Expand Down
4 changes: 4 additions & 0 deletions examples/enumerate.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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()?;
Copy link

Copilot AI Jan 1, 2026

Choose a reason for hiding this comment

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

The alsa crate is used here but not imported. You need to add extern crate alsa; at the top of the file (after the existing extern crate declarations) for this code to compile.

Copilot uses AI. Check for mistakes.

println!("Supported hosts:\n {:?}", cpal::ALL_HOSTS);
let available_hosts = cpal::available_hosts();
println!("Available hosts:\n {available_hosts:?}");
Expand Down
16 changes: 16 additions & 0 deletions src/error.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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.")
}
}
Expand All @@ -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.
Expand All @@ -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.")
}
Expand All @@ -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
Expand All @@ -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.")
}
Expand Down
112 changes: 50 additions & 62 deletions src/host/alsa/enumerate.rs
Original file line number Diff line number Diff line change
@@ -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";
Expand All @@ -21,47 +18,60 @@ struct PhysicalDevice {
/// Iterator over available ALSA PCM devices (physical hardware and virtual/plugin devices).
pub type Devices = std::vec::IntoIter<Device>;

/// 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<Devices, DevicesError> {
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<Devices, DevicesError> {
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,
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,
_host: self.inner.clone(),
});
}
}
}
}

Ok(devices.into_iter())
Ok(devices.into_iter())
}
}

/// Formats device description in ALSA style: "Card Name, Device Name\nPurpose"
Expand Down Expand Up @@ -144,28 +154,6 @@ impl From<alsa::Error> for DevicesError {
}
}

impl TryFrom<alsa::device_name::Hint> for Device {
type Error = BackendSpecificError;

fn try_from(hint: alsa::device_name::Hint) -> Result<Self, Self::Error> {
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<alsa::Direction> for DeviceDirection {
fn from(direction: alsa::Direction) -> Self {
match direction {
Expand Down
Loading