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
7 changes: 7 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,3 +1,10 @@
# Unreleased

- The `ALSA` backend now loads `libasound.so.2` at runtime with `dlopen` (via `libloading`) instead of linking it
at build time. Building no longer requires `libasound2-dev`, and a binary can start on a machine without ALSA
installed; `run_output_device` returns an error instead.
- Bumped MSRV to 1.71 (required by `libloading`).

# 2.0.0

- PulseAudio support for Linux. See `README.md` for detailed instructions of how to use it.
Expand Down
6 changes: 3 additions & 3 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@ homepage = "https://github.com/mrDIMAS/tinyaudio"
documentation = "https://docs.rs/tinyaudio"
repository = "https://github.com/mrDIMAS/tinyaudio"
readme = "README.md"
rust-version = "1.56"
rust-version = "1.71"

@emilk emilk Sep 21, 2026 •

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

note: we can get away with a lower MSRV if we use an older libloading. Up to you!


[workspace]
members = ["android-examples", "wasm-examples", "ios-example/Rust-TinyAudioExample"]
Expand All @@ -30,7 +30,7 @@ opt-level = 3

[features]
default = ["alsa"]
alsa = ["dep:alsa-sys"]
alsa = ["dep:libloading"]
pulse = ["dep:libpulse-sys"]

[target.'cfg(target_os = "android")'.dependencies]
Expand Down Expand Up @@ -59,7 +59,7 @@ features = [
]

[target.'cfg(any(target_os = "linux", target_os = "freebsd"))'.dependencies]
alsa-sys = { version = "0.3.1", optional = true }
libloading = { version = "0.8", optional = true }
libpulse-sys = { version = "1.23.0", optional = true }

[target.'cfg(any(target_os = "macos", target_os = "ios"))'.dependencies]
Expand Down
22 changes: 12 additions & 10 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -48,25 +48,27 @@ that initializes an audio device. See `wasm-examples`

## Linux details

Do not forget to install the required development libraries, otherwise the crate won't compile:

```shell
sudo apt-get install libasound2-dev libudev-dev pkg-config
```

### Backends

Linux supports two audio "backends" - `ALSA` and `PulseAudio`. By default, this crate uses `ALSA`, but this can be
changed by specifying the `pulse` feature:

```toml
tinyaudio = { version = "2", default-features = false, features = ["pulse"] }
```

`PulseAudio` backend requires `libpulse-dev` to be installed:
### ALSA

The `ALSA` backend loads `libasound.so.2` at runtime (via `dlopen`), so no development headers are needed to build
the crate. If the library is missing on the machine that runs your program, the program will still start, but
`run_output_device` will return an error. On Debian/Ubuntu the runtime library is provided by the `libasound2`
package, which is installed on practically all desktop systems.

### PulseAudio

`PulseAudio` backend links `libpulse` at build time and requires `libpulse-dev` to be installed, otherwise the
crate won't compile:

```shell
sudo apt-get install libpulse-dev
sudo apt-get install libpulse-dev pkg-config
```

## Examples
Expand Down
189 changes: 144 additions & 45 deletions src/alsa.rs
Original file line number Diff line number Diff line change
@@ -1,45 +1,139 @@
//! Linux output device via `alsa`.
//!
//! `libasound.so.2` is loaded at runtime with `dlopen` instead of being linked at build time.
//! This means that a binary using this crate can still start on a machine without ALSA installed
//! (e.g. a minimal container or a headless server); only [`run_output_device`](crate::run_output_device)
//! will fail with an error. It also means that the ALSA development headers are not required to
//! build the crate.

#![cfg(all(any(target_os = "linux", target_os = "freebsd"), feature = "alsa"))]
// Type aliases below mirror the C names from `alsa/pcm.h`.
#![allow(non_camel_case_types)]

use crate::{AudioOutputDevice, BaseAudioOutputDevice, OutputDeviceParameters};
use alsa_sys::*;
use libloading::Library;
use std::{
error::Error,
ffi::{CStr, CString},
os::raw::c_int,
os::raw::{c_char, c_int, c_long, c_uint, c_ulong, c_void},
sync::{
atomic::{AtomicBool, Ordering},
Arc,
},
thread::JoinHandle,
};

pub struct AlsaSoundDevice {
playback_device: *mut snd_pcm_t,
thread_handle: Option<JoinHandle<()>>,
is_running: Arc<AtomicBool>,
const ALSA_LIBRARY_NAME: &str = "libasound.so.2";

// Opaque ALSA types.
type snd_pcm_t = c_void;
type snd_pcm_hw_params_t = c_void;
type snd_pcm_sw_params_t = c_void;

// Scalar ALSA types.
type snd_pcm_stream_t = c_int;
type snd_pcm_access_t = c_int;
type snd_pcm_format_t = c_int;
type snd_pcm_uframes_t = c_ulong;
type snd_pcm_sframes_t = c_long;

const SND_PCM_STREAM_PLAYBACK: snd_pcm_stream_t = 0;
const SND_PCM_ACCESS_RW_INTERLEAVED: snd_pcm_access_t = 3;
const SND_PCM_FORMAT_S16_LE: snd_pcm_format_t = 2;

/// Declares a struct holding the ALSA library and the function pointers loaded from it, together
/// with a `load` function that fills it in.
macro_rules! alsa_functions {
($( fn $name:ident($($arg:ty),* $(,)?) -> $ret:ty; )*) => {
struct Alsa {
$( $name: unsafe extern "C" fn($($arg),*) -> $ret, )*
// Must be kept alive for as long as the function pointers above are in use. Declared
// last so that it is dropped last, although the function pointers have no destructor.
_library: Library,
}

impl Alsa {
fn load() -> Result<Self, Box<dyn Error>> {
// SAFETY: loading libasound runs its initializers, which have no preconditions.
let library = unsafe { Library::new(ALSA_LIBRARY_NAME) }.map_err(|err| {
format!("Failed to load {ALSA_LIBRARY_NAME}: {err}")
})?;

// SAFETY: the signatures declared via the macro match the ALSA C API.
unsafe {
Ok(Self {
$(
$name: *library
.get::<unsafe extern "C" fn($($arg),*) -> $ret>(
concat!(stringify!($name), "\0").as_bytes(),
)
.map_err(|err| {
format!(
"Failed to load `{}` from {ALSA_LIBRARY_NAME}: {err}",
stringify!($name)
)
})?,
)*
_library: library,
})
}
}
}
};
}

unsafe impl Send for AlsaSoundDevice {}
alsa_functions! {
fn snd_strerror(c_int) -> *const c_char;
fn snd_pcm_open(*mut *mut snd_pcm_t, *const c_char, snd_pcm_stream_t, c_int) -> c_int;
fn snd_pcm_close(*mut snd_pcm_t) -> c_int;
fn snd_pcm_prepare(*mut snd_pcm_t) -> c_int;
fn snd_pcm_writei(*mut snd_pcm_t, *const c_void, snd_pcm_uframes_t) -> snd_pcm_sframes_t;
fn snd_pcm_recover(*mut snd_pcm_t, c_int, c_int) -> c_int;
fn snd_pcm_hw_params_malloc(*mut *mut snd_pcm_hw_params_t) -> c_int;
fn snd_pcm_hw_params_free(*mut snd_pcm_hw_params_t) -> ();
fn snd_pcm_hw_params_any(*mut snd_pcm_t, *mut snd_pcm_hw_params_t) -> c_int;
fn snd_pcm_hw_params_set_access(*mut snd_pcm_t, *mut snd_pcm_hw_params_t, snd_pcm_access_t) -> c_int;
fn snd_pcm_hw_params_set_format(*mut snd_pcm_t, *mut snd_pcm_hw_params_t, snd_pcm_format_t) -> c_int;
fn snd_pcm_hw_params_set_rate_near(*mut snd_pcm_t, *mut snd_pcm_hw_params_t, *mut c_uint, *mut c_int) -> c_int;
fn snd_pcm_hw_params_set_channels(*mut snd_pcm_t, *mut snd_pcm_hw_params_t, c_uint) -> c_int;
fn snd_pcm_hw_params_set_period_size_near(*mut snd_pcm_t, *mut snd_pcm_hw_params_t, *mut snd_pcm_uframes_t, *mut c_int) -> c_int;
fn snd_pcm_hw_params_set_buffer_size_near(*mut snd_pcm_t, *mut snd_pcm_hw_params_t, *mut snd_pcm_uframes_t) -> c_int;
fn snd_pcm_hw_params(*mut snd_pcm_t, *mut snd_pcm_hw_params_t) -> c_int;
fn snd_pcm_sw_params_malloc(*mut *mut snd_pcm_sw_params_t) -> c_int;
fn snd_pcm_sw_params_current(*mut snd_pcm_t, *mut snd_pcm_sw_params_t) -> c_int;
fn snd_pcm_sw_params_set_avail_min(*mut snd_pcm_t, *mut snd_pcm_sw_params_t, snd_pcm_uframes_t) -> c_int;
fn snd_pcm_sw_params_set_start_threshold(*mut snd_pcm_t, *mut snd_pcm_sw_params_t, snd_pcm_uframes_t) -> c_int;
fn snd_pcm_sw_params(*mut snd_pcm_t, *mut snd_pcm_sw_params_t) -> c_int;
}

pub fn err_code_to_string(err_code: c_int) -> String {
unsafe {
let message = CStr::from_ptr(snd_strerror(err_code) as *const _)
.to_bytes()
.to_vec();
String::from_utf8(message).unwrap()
impl Alsa {
fn err_code_to_string(&self, err_code: c_int) -> String {
// SAFETY: `snd_strerror` returns a pointer to a static string for any error code.
unsafe {
CStr::from_ptr((self.snd_strerror)(err_code) as *const _)
.to_string_lossy()
.into_owned()
}
}
}

pub fn check(err_code: c_int) -> Result<(), Box<dyn Error>> {
if err_code < 0 {
Err(err_code_to_string(err_code).into())
} else {
Ok(())
fn check(&self, err_code: c_int) -> Result<(), Box<dyn Error>> {
if err_code < 0 {
Err(self.err_code_to_string(err_code).into())
} else {
Ok(())
}
}
}

pub struct AlsaSoundDevice {
alsa: Arc<Alsa>,
playback_device: *mut snd_pcm_t,
thread_handle: Option<JoinHandle<()>>,
is_running: Arc<AtomicBool>,
}

unsafe impl Send for AlsaSoundDevice {}

impl BaseAudioOutputDevice for AlsaSoundDevice {}

impl AudioOutputDevice for AlsaSoundDevice {
Expand All @@ -48,77 +142,80 @@ impl AudioOutputDevice for AlsaSoundDevice {
C: FnMut(&mut [f32]) + Send + 'static,
Self: Sized,
{
let alsa = Arc::new(Alsa::load()?);

unsafe {
let name = CString::new("default").unwrap();
let frame_count = params.channel_sample_count;
let mut playback_device = std::ptr::null_mut();
check(snd_pcm_open(
alsa.check((alsa.snd_pcm_open)(
&mut playback_device,
name.as_ptr() as *const _,
SND_PCM_STREAM_PLAYBACK,
0,
))?;
let mut hw_params = std::ptr::null_mut();
check(snd_pcm_hw_params_malloc(&mut hw_params))?;
check(snd_pcm_hw_params_any(playback_device, hw_params))?;
alsa.check((alsa.snd_pcm_hw_params_malloc)(&mut hw_params))?;
alsa.check((alsa.snd_pcm_hw_params_any)(playback_device, hw_params))?;
let access = SND_PCM_ACCESS_RW_INTERLEAVED;
check(snd_pcm_hw_params_set_access(
alsa.check((alsa.snd_pcm_hw_params_set_access)(
playback_device,
hw_params,
access,
))?;
check(snd_pcm_hw_params_set_format(
alsa.check((alsa.snd_pcm_hw_params_set_format)(
playback_device,
hw_params,
SND_PCM_FORMAT_S16_LE,
))?;
let mut exact_rate = params.sample_rate as ::std::os::raw::c_uint;
check(snd_pcm_hw_params_set_rate_near(
let mut exact_rate = params.sample_rate as c_uint;
alsa.check((alsa.snd_pcm_hw_params_set_rate_near)(
playback_device,
hw_params,
&mut exact_rate,
std::ptr::null_mut(),
))?;
check(snd_pcm_hw_params_set_channels(
alsa.check((alsa.snd_pcm_hw_params_set_channels)(
playback_device,
hw_params,
params.channels_count as ::std::os::raw::c_uint,
params.channels_count as c_uint,
))?;
let mut _exact_period = frame_count as snd_pcm_uframes_t;
let mut _direction = 0;
check(snd_pcm_hw_params_set_period_size_near(
alsa.check((alsa.snd_pcm_hw_params_set_period_size_near)(
playback_device,
hw_params,
&mut _exact_period,
&mut _direction,
))?;
let mut exact_size = (frame_count * 2) as ::std::os::raw::c_ulong;
check(snd_pcm_hw_params_set_buffer_size_near(
let mut exact_size = (frame_count * 2) as snd_pcm_uframes_t;
alsa.check((alsa.snd_pcm_hw_params_set_buffer_size_near)(
playback_device,
hw_params,
&mut exact_size,
))?;
check(snd_pcm_hw_params(playback_device, hw_params))?;
snd_pcm_hw_params_free(hw_params);
alsa.check((alsa.snd_pcm_hw_params)(playback_device, hw_params))?;
(alsa.snd_pcm_hw_params_free)(hw_params);
let mut sw_params = std::ptr::null_mut();
check(snd_pcm_sw_params_malloc(&mut sw_params))?;
check(snd_pcm_sw_params_current(playback_device, sw_params))?;
check(snd_pcm_sw_params_set_avail_min(
alsa.check((alsa.snd_pcm_sw_params_malloc)(&mut sw_params))?;
alsa.check((alsa.snd_pcm_sw_params_current)(playback_device, sw_params))?;
alsa.check((alsa.snd_pcm_sw_params_set_avail_min)(
playback_device,
sw_params,
frame_count as ::std::os::raw::c_ulong,
frame_count as snd_pcm_uframes_t,
))?;
check(snd_pcm_sw_params_set_start_threshold(
alsa.check((alsa.snd_pcm_sw_params_set_start_threshold)(
playback_device,
sw_params,
frame_count as ::std::os::raw::c_ulong,
frame_count as snd_pcm_uframes_t,
))?;
check(snd_pcm_sw_params(playback_device, sw_params))?;
check(snd_pcm_prepare(playback_device))?;
alsa.check((alsa.snd_pcm_sw_params)(playback_device, sw_params))?;
alsa.check((alsa.snd_pcm_prepare)(playback_device))?;

let is_running = Arc::new(AtomicBool::new(true));

let thread_handle = DataSender {
alsa: alsa.clone(),
playback_device,
callback: data_callback,
data_buffer: vec![0.0f32; params.channel_sample_count * params.channels_count],
Expand All @@ -129,6 +226,7 @@ impl AudioOutputDevice for AlsaSoundDevice {
.run_in_thread()?;

Ok(Self {
alsa,
playback_device,
is_running,
thread_handle: Some(thread_handle),
Expand All @@ -148,12 +246,13 @@ impl Drop for AlsaSoundDevice {
.unwrap();

unsafe {
snd_pcm_close(self.playback_device);
(self.alsa.snd_pcm_close)(self.playback_device);
}
}
}

struct DataSender<C> {
alsa: Arc<Alsa>,
playback_device: *mut snd_pcm_t,
callback: C,
data_buffer: Vec<f32>,
Expand Down Expand Up @@ -187,15 +286,15 @@ where

'try_loop: for _ in 0..10 {
unsafe {
let err = snd_pcm_writei(
let err = (self.alsa.snd_pcm_writei)(
self.playback_device,
self.output_buffer.as_ptr() as *const _,
self.params.channel_sample_count as ::std::os::raw::c_ulong,
self.params.channel_sample_count as snd_pcm_uframes_t,
) as i32;

if err < 0 {
// Try to recover from any errors and re-send data.
snd_pcm_recover(self.playback_device, err, 1);
(self.alsa.snd_pcm_recover)(self.playback_device, err, 1);
} else {
break 'try_loop;
}
Expand Down
Loading