Skip to main content

volumecontrol_linux/
lib.rs

1//! Linux PulseAudio volume control backend.
2//!
3//! This crate exposes an [`AudioDevice`] type that implements
4//! [`volumecontrol_core::AudioDevice`].  It exists primarily as an
5//! implementation detail of the
6//! [`volumecontrol`](https://crates.io/crates/volumecontrol) crate, which
7//! selects the correct backend automatically.  If cross-platform support is not
8//! a concern you may depend on this crate directly.
9//!
10//! When the `pulseaudio` feature is **not** enabled every method returns
11//! [`AudioError::Unsupported`], which allows the crate to compile on any
12//! platform without the PulseAudio development headers.
13//!
14//! # Feature flags
15//!
16//! | Feature      | Description                                             | Requires              |
17//! |--------------|---------------------------------------------------------|-----------------------|
18//! | `pulseaudio` | Enable the real PulseAudio backend via `libpulse-binding` | `libpulse-dev` system package |
19//!
20//! # Example
21//!
22//! ```no_run
23//! use volumecontrol_linux::AudioDevice;
24//! use volumecontrol_core::AudioDevice as _;
25//!
26//! fn main() -> Result<(), volumecontrol_core::AudioError> {
27//!     let device = AudioDevice::from_default()?;
28//!     println!("{device}");  // e.g. "Built-in Audio (alsa_output.pci-…)"
29//!     println!("Current volume: {}%", device.get_vol()?);
30//!     Ok(())
31//! }
32//! ```
33
34#![deny(missing_docs)]
35
36use std::fmt;
37
38use volumecontrol_core::{AudioDevice as AudioDeviceTrait, AudioError, DeviceInfo};
39
40#[cfg(feature = "pulseaudio")]
41use std::{cell::RefCell, rc::Rc};
42
43#[cfg(feature = "pulseaudio")]
44mod pulse;
45
46/// Represents a PulseAudio audio output device.
47///
48/// # Feature flags
49///
50/// Real PulseAudio integration requires the `pulseaudio` feature and the
51/// `libpulse-dev` system package.  Without the feature every method returns
52/// [`AudioError::Unsupported`].
53///
54/// # Thread safety
55///
56/// When the `pulseaudio` feature is enabled, `AudioDevice` is **not** `Send`
57/// because it holds a cached PulseAudio connection (`Mainloop` and
58/// `Context` from `libpulse-binding` are `!Send`).  Use on a single thread
59/// only.  A threaded-mainloop wrapper that restores `Send + Sync` may be
60/// added in a future release.
61pub struct AudioDevice {
62    /// PulseAudio sink name used as the unique device identifier.
63    id: String,
64    /// Human-readable sink description.
65    name: String,
66    /// Cached PulseAudio connection, lazily initialised on first use and
67    /// reconnected automatically after a server disconnect.
68    ///
69    /// `None` only when the struct is built directly in tests (bypassing the
70    /// constructors); in that case the first method call will try to connect.
71    #[cfg(feature = "pulseaudio")]
72    conn: Rc<RefCell<Option<pulse::PulseConnection>>>,
73}
74
75impl fmt::Debug for AudioDevice {
76    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
77        f.debug_struct("AudioDevice")
78            .field("id", &self.id)
79            .field("name", &self.name)
80            .finish_non_exhaustive()
81    }
82}
83
84impl fmt::Display for AudioDevice {
85    /// Formats the device as `"name (id)"`.
86    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
87        write!(f, "{} ({})", self.name, self.id)
88    }
89}
90
91#[cfg(feature = "pulseaudio")]
92impl AudioDevice {
93    /// Returns a mutable reference to the cached [`pulse::PulseConnection`],
94    /// creating a fresh connection if the slot is empty.
95    ///
96    /// Each [`pulse::PulseConnection`] method already calls `ensure_ready()`
97    /// internally, so callers do not need to handle reconnection themselves.
98    fn get_or_connect(
99        opt: &mut Option<pulse::PulseConnection>,
100    ) -> Result<&mut pulse::PulseConnection, AudioError> {
101        if opt.is_none() {
102            *opt = Some(pulse::PulseConnection::new()?);
103        }
104        opt.as_mut()
105            .ok_or_else(|| AudioError::InitializationFailed("connection slot was empty".into()))
106    }
107}
108
109impl AudioDeviceTrait for AudioDevice {
110    fn from_default() -> Result<Self, AudioError> {
111        #[cfg(feature = "pulseaudio")]
112        {
113            let mut conn = pulse::PulseConnection::new()?;
114            let sink_name = conn.default_sink_name()?;
115            let snap = conn.sink_by_name(&sink_name)?;
116            Ok(AudioDevice {
117                id: snap.name,
118                name: snap.description,
119                conn: Rc::new(RefCell::new(Some(conn))),
120            })
121        }
122        #[cfg(not(feature = "pulseaudio"))]
123        Err(AudioError::Unsupported)
124    }
125
126    fn from_id(id: &str) -> Result<Self, AudioError> {
127        #[cfg(feature = "pulseaudio")]
128        {
129            let mut conn = pulse::PulseConnection::new()?;
130            let snap = conn.sink_by_name(id)?;
131            Ok(AudioDevice {
132                id: snap.name,
133                name: snap.description,
134                conn: Rc::new(RefCell::new(Some(conn))),
135            })
136        }
137        #[cfg(not(feature = "pulseaudio"))]
138        {
139            let _ = id;
140            Err(AudioError::Unsupported)
141        }
142    }
143
144    fn from_name(name: &str) -> Result<Self, AudioError> {
145        #[cfg(feature = "pulseaudio")]
146        {
147            let mut conn = pulse::PulseConnection::new()?;
148            let snap = conn.sink_matching_description(name)?;
149            Ok(AudioDevice {
150                id: snap.name,
151                name: snap.description,
152                conn: Rc::new(RefCell::new(Some(conn))),
153            })
154        }
155        #[cfg(not(feature = "pulseaudio"))]
156        {
157            let _ = name;
158            Err(AudioError::Unsupported)
159        }
160    }
161
162    fn list() -> Result<Vec<DeviceInfo>, AudioError> {
163        #[cfg(feature = "pulseaudio")]
164        {
165            pulse::PulseConnection::new()?.list_sinks()
166        }
167        #[cfg(not(feature = "pulseaudio"))]
168        Err(AudioError::Unsupported)
169    }
170
171    fn get_vol(&self) -> Result<u8, AudioError> {
172        #[cfg(feature = "pulseaudio")]
173        {
174            let mut guard = self.conn.borrow_mut();
175            let conn = Self::get_or_connect(&mut guard)?;
176            Ok(conn.sink_by_name(&self.id)?.volume)
177        }
178        #[cfg(not(feature = "pulseaudio"))]
179        Err(AudioError::Unsupported)
180    }
181
182    fn set_vol(&self, vol: u8) -> Result<(), AudioError> {
183        #[cfg(feature = "pulseaudio")]
184        {
185            let mut guard = self.conn.borrow_mut();
186            let conn = Self::get_or_connect(&mut guard)?;
187            conn.set_sink_volume(&self.id, vol)
188        }
189        #[cfg(not(feature = "pulseaudio"))]
190        {
191            let _ = vol;
192            Err(AudioError::Unsupported)
193        }
194    }
195
196    fn is_mute(&self) -> Result<bool, AudioError> {
197        #[cfg(feature = "pulseaudio")]
198        {
199            let mut guard = self.conn.borrow_mut();
200            let conn = Self::get_or_connect(&mut guard)?;
201            Ok(conn.sink_by_name(&self.id)?.mute)
202        }
203        #[cfg(not(feature = "pulseaudio"))]
204        Err(AudioError::Unsupported)
205    }
206
207    fn set_mute(&self, muted: bool) -> Result<(), AudioError> {
208        #[cfg(feature = "pulseaudio")]
209        {
210            let mut guard = self.conn.borrow_mut();
211            let conn = Self::get_or_connect(&mut guard)?;
212            conn.set_sink_mute(&self.id, muted)
213        }
214        #[cfg(not(feature = "pulseaudio"))]
215        {
216            let _ = muted;
217            Err(AudioError::Unsupported)
218        }
219    }
220
221    fn id(&self) -> &str {
222        &self.id
223    }
224
225    fn name(&self) -> &str {
226        &self.name
227    }
228}
229
230#[cfg(test)]
231mod tests {
232    use super::*;
233    use volumecontrol_core::AudioDevice as AudioDeviceTrait;
234
235    /// `Display` output must follow the `"name (id)"` format.
236    #[test]
237    fn display_format_is_name_paren_id() {
238        let device = AudioDevice {
239            id: "alsa_output.pci-0000_00_1b.0.analog-stereo".to_string(),
240            name: "Built-in Audio Analog Stereo".to_string(),
241            #[cfg(feature = "pulseaudio")]
242            conn: std::rc::Rc::new(std::cell::RefCell::new(None)),
243        };
244        assert_eq!(
245            device.to_string(),
246            "Built-in Audio Analog Stereo (alsa_output.pci-0000_00_1b.0.analog-stereo)"
247        );
248    }
249
250    #[cfg(not(feature = "pulseaudio"))]
251    #[test]
252    fn default_returns_unsupported_without_feature() {
253        let result = AudioDevice::from_default();
254        assert!(matches!(result.unwrap_err(), AudioError::Unsupported));
255    }
256
257    #[cfg(not(feature = "pulseaudio"))]
258    #[test]
259    fn from_id_returns_unsupported_without_feature() {
260        let result = AudioDevice::from_id("test-id");
261        assert!(matches!(result.unwrap_err(), AudioError::Unsupported));
262    }
263
264    #[cfg(not(feature = "pulseaudio"))]
265    #[test]
266    fn from_name_returns_unsupported_without_feature() {
267        let result = AudioDevice::from_name("test-name");
268        assert!(matches!(result.unwrap_err(), AudioError::Unsupported));
269    }
270
271    #[cfg(not(feature = "pulseaudio"))]
272    #[test]
273    fn list_returns_unsupported_without_feature() {
274        let result = AudioDevice::list();
275        assert!(matches!(result.unwrap_err(), AudioError::Unsupported));
276    }
277
278    /// When the `pulseaudio` feature is disabled, every `&self` method on an
279    /// `AudioDevice` must return `Err(AudioError::Unsupported)`.
280    #[cfg(not(feature = "pulseaudio"))]
281    #[test]
282    fn self_methods_return_unsupported_without_feature() {
283        // Construct a dummy device directly; the public constructors also
284        // return `Unsupported` without the feature.
285        let device = AudioDevice {
286            id: String::new(),
287            name: String::new(),
288        };
289        assert!(matches!(
290            device.get_vol().unwrap_err(),
291            AudioError::Unsupported
292        ));
293        assert!(matches!(
294            device.set_vol(50).unwrap_err(),
295            AudioError::Unsupported
296        ));
297        assert!(matches!(
298            device.is_mute().unwrap_err(),
299            AudioError::Unsupported
300        ));
301        assert!(matches!(
302            device.set_mute(false).unwrap_err(),
303            AudioError::Unsupported
304        ));
305    }
306
307    // ── Tests for the `pulseaudio` feature ───────────────────────────────────
308    //
309    // These tests do not require a running PulseAudio server.  When no server
310    // is available every method that opens a connection returns
311    // `Err(AudioError::InitializationFailed(_))`.  When a server is running
312    // but the requested resource does not exist the constructors return
313    // `Err(AudioError::DeviceNotFound)`.
314
315    /// Looks up a sink ID that is guaranteed not to exist.
316    /// Expects `DeviceNotFound` (server running, no such sink) or
317    /// `InitializationFailed` (no server running).
318    #[cfg(feature = "pulseaudio")]
319    #[test]
320    fn from_id_fails_for_nonexistent_sink() {
321        let result = AudioDevice::from_id("__nonexistent_sink_xyz__");
322        assert!(result.is_err(), "expected an error, got Ok");
323        let err = result.unwrap_err();
324        assert!(
325            matches!(
326                err,
327                AudioError::DeviceNotFound | AudioError::InitializationFailed(_)
328            ),
329            "unexpected error variant: {err:?}"
330        );
331    }
332
333    /// Searches by a description that is guaranteed not to match any sink.
334    #[cfg(feature = "pulseaudio")]
335    #[test]
336    fn from_name_fails_for_nonexistent_description() {
337        let result = AudioDevice::from_name("__nonexistent_description_xyz__");
338        assert!(result.is_err(), "expected an error, got Ok");
339        let err = result.unwrap_err();
340        assert!(
341            matches!(
342                err,
343                AudioError::DeviceNotFound | AudioError::InitializationFailed(_)
344            ),
345            "unexpected error variant: {err:?}"
346        );
347    }
348
349    /// `list()` must either succeed (returns `Ok`) or fail with
350    /// `InitializationFailed` — it must never panic or return an unexpected
351    /// error variant.
352    #[cfg(feature = "pulseaudio")]
353    #[test]
354    fn list_returns_ok_or_init_failed() {
355        let result = AudioDevice::list();
356        match &result {
357            Ok(_) => {}
358            Err(AudioError::InitializationFailed(_)) => {}
359            Err(e) => panic!("unexpected error from list(): {e:?}"),
360        }
361    }
362
363    /// `from_default()` must either succeed, return `DeviceNotFound` (no default
364    /// sink configured), or return `InitializationFailed` (no server).
365    #[cfg(feature = "pulseaudio")]
366    #[test]
367    fn default_returns_ok_or_known_error() {
368        let result = AudioDevice::from_default();
369        match &result {
370            Ok(_) => {}
371            Err(AudioError::InitializationFailed(_)) | Err(AudioError::DeviceNotFound) => {}
372            Err(e) => panic!("unexpected error from from_default(): {e:?}"),
373        }
374    }
375
376    /// `get_vol`, `is_mute`, and `set_vol` on a device whose sink ID does not
377    /// exist return `DeviceNotFound` (server running) or `InitializationFailed`
378    /// (no server).
379    ///
380    /// The device is constructed with `conn: None` so that the first method
381    /// call will attempt to connect (and fail gracefully if no server is
382    /// present).
383    #[cfg(feature = "pulseaudio")]
384    #[test]
385    fn self_methods_fail_for_nonexistent_sink() {
386        let device = AudioDevice {
387            id: "__nonexistent_sink_xyz__".to_string(),
388            name: String::new(),
389            conn: Rc::new(RefCell::new(None)),
390        };
391
392        let result = device.get_vol();
393        assert!(result.is_err(), "get_vol: expected error, got Ok");
394        assert!(
395            matches!(
396                result.unwrap_err(),
397                AudioError::DeviceNotFound | AudioError::InitializationFailed(_)
398            ),
399            "get_vol: unexpected error variant"
400        );
401
402        let result = device.is_mute();
403        assert!(result.is_err(), "is_mute: expected error, got Ok");
404        assert!(
405            matches!(
406                result.unwrap_err(),
407                AudioError::DeviceNotFound | AudioError::InitializationFailed(_)
408            ),
409            "is_mute: unexpected error variant"
410        );
411
412        // set_vol fetches the current ChannelVolumes first (via sink_by_name),
413        // so a missing sink surfaces as DeviceNotFound before any write.
414        let result = device.set_vol(50);
415        assert!(result.is_err(), "set_vol: expected error, got Ok");
416        assert!(
417            matches!(
418                result.unwrap_err(),
419                AudioError::DeviceNotFound | AudioError::InitializationFailed(_)
420            ),
421            "set_vol: unexpected error variant"
422        );
423    }
424
425    // ── real-world tests (pulseaudio feature, Linux only) ─────────────────────
426    //
427    // These tests exercise the actual PulseAudio stack and therefore require a
428    // running PulseAudio server with at least one available sink.  In CI a
429    // virtual null sink is provisioned before this test suite runs.
430
431    /// The default device must be resolvable when PulseAudio is running.
432    #[cfg(all(feature = "pulseaudio", target_os = "linux"))]
433    #[test]
434    fn default_returns_ok() {
435        let device = AudioDevice::from_default();
436        assert!(device.is_ok(), "expected Ok, got {device:?}");
437    }
438
439    /// `list()` must return at least one device, each with a non-empty id and name.
440    #[cfg(all(feature = "pulseaudio", target_os = "linux"))]
441    #[test]
442    fn list_returns_nonempty() {
443        let devices = AudioDevice::list().expect("list()");
444        assert!(
445            !devices.is_empty(),
446            "expected at least one audio device from list()"
447        );
448        for info in &devices {
449            assert!(!info.id.is_empty(), "device id must not be empty");
450            assert!(!info.name.is_empty(), "device name must not be empty");
451        }
452    }
453
454    /// Looking up the default device by its sink name must succeed and return
455    /// the same id.
456    #[cfg(all(feature = "pulseaudio", target_os = "linux"))]
457    #[test]
458    fn from_id_valid_id_returns_ok() {
459        let default_device = AudioDevice::from_default().expect("from_default()");
460        let found_device = match AudioDevice::from_id(default_device.id()) {
461            Ok(d) => d,
462            Err(e) => panic!("from_id with valid id should succeed, got {e:?}"),
463        };
464        assert_eq!(found_device.id(), default_device.id());
465    }
466
467    /// A sink name that does not exist must return `DeviceNotFound`.
468    #[cfg(all(feature = "pulseaudio", target_os = "linux"))]
469    #[test]
470    fn from_id_nonexistent_returns_not_found() {
471        let result = AudioDevice::from_id("__nonexistent_sink_xyz__");
472        match result {
473            Err(AudioError::DeviceNotFound) => {}
474            other => panic!("expected DeviceNotFound, got {other:?}"),
475        }
476    }
477
478    /// A partial description substring of the default device must match.
479    #[cfg(all(feature = "pulseaudio", target_os = "linux"))]
480    #[test]
481    fn from_name_partial_match_returns_ok() {
482        let default_device = AudioDevice::from_default().expect("from_default()");
483        let partial: String = default_device.name().chars().take(3).collect();
484        let found = AudioDevice::from_name(&partial);
485        assert!(
486            found.is_ok(),
487            "from_name with partial match '{partial}' should succeed"
488        );
489    }
490
491    /// `from_name` must match regardless of the case of the query string.
492    #[cfg(all(feature = "pulseaudio", target_os = "linux"))]
493    #[test]
494    fn from_name_case_insensitive_match_returns_ok() {
495        // Convert the default device name to uppercase and verify it still
496        // matches — confirming that `from_name` is case-insensitive.
497        let default_device = AudioDevice::from_default().expect("from_default()");
498        let upper = default_device.name().to_uppercase();
499        let found = AudioDevice::from_name(&upper);
500        assert!(
501            found.is_ok(),
502            "from_name with uppercase query '{upper}' should succeed (case-insensitive)"
503        );
504    }
505
506    /// A description that matches no sink must return `DeviceNotFound`.
507    #[cfg(all(feature = "pulseaudio", target_os = "linux"))]
508    #[test]
509    fn from_name_no_match_returns_not_found() {
510        let result = AudioDevice::from_name("\x00\x01\x02");
511        match result {
512            Err(AudioError::DeviceNotFound) => {}
513            other => panic!("expected DeviceNotFound, got {other:?}"),
514        }
515    }
516
517    /// The reported volume must always be within the valid `0..=100` range.
518    #[cfg(all(feature = "pulseaudio", target_os = "linux"))]
519    #[test]
520    fn get_vol_returns_valid_range() {
521        let device = AudioDevice::from_default().expect("from_default()");
522        let vol = device.get_vol().expect("get_vol()");
523        assert!(vol <= 100, "volume must be in 0..=100, got {vol}");
524    }
525
526    /// Setting the volume to a different value must be reflected when read back.
527    ///
528    /// The original volume is restored at the end of the test so that other
529    /// tests are not affected (run with `--test-threads=1` to avoid races).
530    #[cfg(all(feature = "pulseaudio", target_os = "linux"))]
531    #[test]
532    fn set_vol_changes_volume() {
533        let device = AudioDevice::from_default().expect("from_default()");
534        let original = device.get_vol().expect("get_vol()");
535        // Choose a target value that is clearly different from the original.
536        let target: u8 = if original >= 50 { 30 } else { 70 };
537        device.set_vol(target).expect("set_vol()");
538        let after = device.get_vol().expect("get_vol() after set");
539        // Allow ±1 rounding error due to f32 ↔ u8 conversion.
540        assert!(
541            after.abs_diff(target) <= 1,
542            "expected volume near {target}, got {after}"
543        );
544        // Restore the original volume.
545        device.set_vol(original).expect("restore original volume");
546    }
547
548    /// Toggling the mute state must be reflected when read back.
549    ///
550    /// The original mute state is restored at the end of the test so that
551    /// other tests are not affected (run with `--test-threads=1` to avoid races).
552    #[cfg(all(feature = "pulseaudio", target_os = "linux"))]
553    #[test]
554    fn set_mute_changes_mute_state() {
555        let device = AudioDevice::from_default().expect("from_default()");
556        let original = device.is_mute().expect("is_mute()");
557        // Toggle to the opposite state.
558        let target = !original;
559        device.set_mute(target).expect("set_mute()");
560        let after = device.is_mute().expect("is_mute() after set");
561        assert_eq!(after, target, "mute state should be {target}, got {after}");
562        // Restore the original mute state.
563        device
564            .set_mute(original)
565            .expect("restore original mute state");
566    }
567}