From 40bca1a5081e49545214b0859bd5ea60c515bfe4 Mon Sep 17 00:00:00 2001 From: Emil Ernerfeldt Date: Mon, 21 Sep 2026 12:34:19 +0200 Subject: [PATCH 1/2] Load ALSA at runtime with dlopen instead of linking it `libasound.so.2` is now loaded with `libloading` when the output device is created, rather than being linked at build time via `alsa-sys`. This means: - A binary using tinyaudio can start on a machine without ALSA installed (minimal containers, headless servers). Previously the dynamic loader failed before `main` ran. Now `run_output_device` returns an error instead. - Building no longer requires `libasound2-dev` / `pkg-config`. The ALSA call sequence (hw/sw params, S16_LE, period/buffer sizes, recover loop) is unchanged; only the way the symbols are resolved differs. MSRV is bumped to 1.71 as required by `libloading` 0.8. The previous `rust-version = "1.56"` was already stale since `dep:` feature syntax needs 1.60. Co-Authored-By: Claude Fable 5.1 --- CHANGELOG.md | 7 ++ Cargo.toml | 6 +- README.md | 22 +++--- src/alsa.rs | 190 +++++++++++++++++++++++++++++++++++++++------------ 4 files changed, 167 insertions(+), 58 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 1720140..e97d63a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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. diff --git a/Cargo.toml b/Cargo.toml index a554cec..827d963 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -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" [workspace] members = ["android-examples", "wasm-examples", "ios-example/Rust-TinyAudioExample"] @@ -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] @@ -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] diff --git a/README.md b/README.md index e866fc7..6c58623 100644 --- a/README.md +++ b/README.md @@ -48,14 +48,6 @@ 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: @@ -63,10 +55,20 @@ changed by specifying the `pulse` feature: 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 diff --git a/src/alsa.rs b/src/alsa.rs index 9bf76fc..a7fba03 100644 --- a/src/alsa.rs +++ b/src/alsa.rs @@ -1,13 +1,21 @@ //! 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, @@ -15,31 +23,118 @@ use std::{ thread::JoinHandle, }; -pub struct AlsaSoundDevice { - playback_device: *mut snd_pcm_t, - thread_handle: Option>, - is_running: Arc, +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> { + // 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:: $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 { + let message = CStr::from_ptr((self.snd_strerror)(err_code) as *const _) + .to_bytes() + .to_vec(); + String::from_utf8(message).unwrap() + } } -} -pub fn check(err_code: c_int) -> Result<(), Box> { - if err_code < 0 { - Err(err_code_to_string(err_code).into()) - } else { - Ok(()) + fn check(&self, err_code: c_int) -> Result<(), Box> { + if err_code < 0 { + Err(self.err_code_to_string(err_code).into()) + } else { + Ok(()) + } } } +pub struct AlsaSoundDevice { + alsa: Arc, + playback_device: *mut snd_pcm_t, + thread_handle: Option>, + is_running: Arc, +} + +unsafe impl Send for AlsaSoundDevice {} + impl BaseAudioOutputDevice for AlsaSoundDevice {} impl AudioOutputDevice for AlsaSoundDevice { @@ -48,77 +143,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], @@ -129,6 +227,7 @@ impl AudioOutputDevice for AlsaSoundDevice { .run_in_thread()?; Ok(Self { + alsa, playback_device, is_running, thread_handle: Some(thread_handle), @@ -148,12 +247,13 @@ impl Drop for AlsaSoundDevice { .unwrap(); unsafe { - snd_pcm_close(self.playback_device); + (self.alsa.snd_pcm_close)(self.playback_device); } } } struct DataSender { + alsa: Arc, playback_device: *mut snd_pcm_t, callback: C, data_buffer: Vec, @@ -187,15 +287,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; } From 35d83598a5e495f0cc1722c4851b850117fc6b28 Mon Sep 17 00:00:00 2001 From: Emil Ernerfeldt Date: Mon, 21 Sep 2026 12:43:34 +0200 Subject: [PATCH 2/2] Use lossy UTF-8 conversion for ALSA error messages Avoids a panic if snd_strerror ever returns a non-UTF-8 string. Co-Authored-By: Claude Fable 5.1 --- src/alsa.rs | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/src/alsa.rs b/src/alsa.rs index a7fba03..bf92d22 100644 --- a/src/alsa.rs +++ b/src/alsa.rs @@ -110,10 +110,9 @@ 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 { - let message = CStr::from_ptr((self.snd_strerror)(err_code) as *const _) - .to_bytes() - .to_vec(); - String::from_utf8(message).unwrap() + CStr::from_ptr((self.snd_strerror)(err_code) as *const _) + .to_string_lossy() + .into_owned() } }