Skip to main content

volumecontrol/
lib.rs

1//! Cross-platform crate to control system audio volume.
2//!
3//! This crate provides a unified [`AudioDevice`] type that works on Linux,
4//! Windows, and macOS. The correct backend is selected automatically at
5//! compile time; no feature flags or sub-crate imports are required.
6//!
7//! | Platform | Backend              | System library required |
8//! |----------|----------------------|-------------------------|
9//! | Linux    | PulseAudio           | `libpulse-dev`          |
10//! | Windows  | WASAPI               | built-in                |
11//! | macOS    | CoreAudio            | built-in                |
12//!
13//! # Example
14//!
15//! ```no_run
16//! use volumecontrol::AudioDevice;
17//!
18//! fn main() -> Result<(), volumecontrol::AudioError> {
19//!     let device = AudioDevice::from_default()?;
20//!     println!("{device}");  // e.g. "Speakers ({0.0.0.00000000}.{…})"
21//!     println!("Current volume: {}%", device.get_vol()?);
22//!     Ok(())
23//! }
24//! ```
25
26#![deny(missing_docs)]
27
28use std::fmt;
29
30pub use volumecontrol_core::AudioError;
31pub use volumecontrol_core::DeviceInfo;
32
33use volumecontrol_core::AudioDevice as _;
34
35#[cfg(target_os = "linux")]
36use volumecontrol_linux::AudioDevice as Inner;
37
38#[cfg(target_os = "windows")]
39use volumecontrol_windows::AudioDevice as Inner;
40
41#[cfg(target_os = "macos")]
42use volumecontrol_macos::AudioDevice as Inner;
43
44#[cfg(not(any(target_os = "linux", target_os = "windows", target_os = "macos")))]
45compile_error!(
46    "volumecontrol does not support the current target OS. \
47     Supported targets: linux, windows, macos."
48);
49
50/// A cross-platform audio output device.
51///
52/// Wraps the platform-appropriate backend and exposes a uniform API for
53/// querying and changing the system volume and mute state.  No trait imports
54/// are required to use the methods below.
55#[derive(Debug)]
56pub struct AudioDevice(Inner);
57
58impl fmt::Display for AudioDevice {
59    /// Formats the device by delegating to the inner backend.
60    ///
61    /// The conventional format is `"name (id)"`,
62    /// e.g. `"Speakers ({0.0.0.00000000}.{…})"`.
63    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
64        self.0.fmt(f)
65    }
66}
67
68impl AudioDevice {
69    /// Returns the system default audio output device.
70    ///
71    /// # Errors
72    ///
73    /// Returns an error if the default device cannot be resolved.
74    pub fn from_default() -> Result<Self, AudioError> {
75        Inner::from_default().map(Self)
76    }
77
78    /// Returns the audio device identified by `id`.
79    ///
80    /// # Errors
81    ///
82    /// Returns [`AudioError::DeviceNotFound`] if no device with the given
83    /// identifier exists, or another error if the lookup fails.
84    pub fn from_id(id: &str) -> Result<Self, AudioError> {
85        Inner::from_id(id).map(Self)
86    }
87
88    /// Returns the first audio device whose name contains `name`.
89    ///
90    /// # Errors
91    ///
92    /// Returns [`AudioError::DeviceNotFound`] if no matching device is found,
93    /// or another error if the lookup fails.
94    pub fn from_name(name: &str) -> Result<Self, AudioError> {
95        Inner::from_name(name).map(Self)
96    }
97
98    /// Lists all available audio devices.
99    ///
100    /// # Errors
101    ///
102    /// Returns an error if the device list cannot be retrieved.
103    pub fn list() -> Result<Vec<DeviceInfo>, AudioError> {
104        Inner::list()
105    }
106
107    /// Returns the current volume level in the range `0..=100`.
108    ///
109    /// # Errors
110    ///
111    /// Returns an error if the volume cannot be read.
112    pub fn get_vol(&self) -> Result<u8, AudioError> {
113        self.0.get_vol()
114    }
115
116    /// Sets the volume level.
117    ///
118    /// `vol` is clamped to `0..=100` before being applied.
119    ///
120    /// # Errors
121    ///
122    /// Returns an error if the volume cannot be set.
123    pub fn set_vol(&self, vol: u8) -> Result<(), AudioError> {
124        self.0.set_vol(vol)
125    }
126
127    /// Returns `true` if the device is currently muted.
128    ///
129    /// # Errors
130    ///
131    /// Returns an error if the mute state cannot be read.
132    pub fn is_mute(&self) -> Result<bool, AudioError> {
133        self.0.is_mute()
134    }
135
136    /// Mutes or unmutes the device.
137    ///
138    /// # Errors
139    ///
140    /// Returns an error if the mute state cannot be changed.
141    pub fn set_mute(&self, muted: bool) -> Result<(), AudioError> {
142        self.0.set_mute(muted)
143    }
144
145    /// Returns the unique identifier for this device.
146    ///
147    /// The value is the same opaque string that [`Self::list`] yields as
148    /// [`DeviceInfo::id`] and that [`Self::from_id`]
149    /// accepts as its argument.  It is guaranteed to be non-empty.
150    ///
151    /// # Platform-specific formats
152    ///
153    /// | Platform | Format                                                  |
154    /// |----------|---------------------------------------------------------|
155    /// | Linux    | PulseAudio sink name (e.g. `alsa_output.pci-0000_…`)    |
156    /// | Windows  | WASAPI endpoint ID (e.g. `{0.0.0.00000000}.{…}`)       |
157    /// | macOS    | CoreAudio device UID (numeric string, e.g. `"73"`)      |
158    pub fn id(&self) -> &str {
159        self.0.id()
160    }
161
162    /// Returns the human-readable display name of this device.
163    ///
164    /// The value is the same string that [`Self::list`] yields as
165    /// [`DeviceInfo::name`] and that [`Self::from_name`] uses for
166    /// substring matching.  It is guaranteed to be non-empty.
167    ///
168    /// # Platform-specific formats
169    ///
170    /// | Platform | Format                                                  |
171    /// |----------|---------------------------------------------------------|
172    /// | Linux    | PulseAudio sink description (e.g. `"Built-in Audio"`)   |
173    /// | Windows  | WASAPI endpoint friendly name (e.g. `"Speakers"`)       |
174    /// | macOS    | CoreAudio device name (e.g. `"MacBook Pro Speakers"`)   |
175    pub fn name(&self) -> &str {
176        self.0.name()
177    }
178}
179
180// ── Tests ────────────────────────────────────────────────────────────────────
181
182#[cfg(test)]
183mod tests {
184    use super::*;
185
186    // A bogus device id guaranteed not to match any real device.
187    // The format matches what each backend considers an invalid lookup:
188    // - Windows: a GUID-style path that no WASAPI endpoint will carry
189    // - macOS: a non-numeric string (CoreAudio ids are integers)
190    // - Linux / other: a PulseAudio sink name that cannot exist
191    #[cfg(target_os = "windows")]
192    const BOGUS_ID: &str = "volumecontrol-test-nonexistent-{00000000-0000-0000-0000-000000000000}";
193    #[cfg(target_os = "macos")]
194    const BOGUS_ID: &str = "not-a-number";
195    #[cfg(not(any(target_os = "windows", target_os = "macos")))]
196    const BOGUS_ID: &str = "__nonexistent_sink_xyz__";
197
198    // A bogus device name guaranteed not to match any real audio device.
199    const BOGUS_NAME: &str = "zzz-volumecontrol-test-nonexistent-device-name";
200
201    /// `Display` output for the default device must follow `"name (id)"`.
202    #[test]
203    fn display_contains_name_and_id() {
204        let device = AudioDevice::from_default().expect("from_default()");
205        let s = device.to_string();
206        assert!(
207            s.contains(device.name()),
208            "Display output should contain the device name; got: {s}"
209        );
210        assert!(
211            s.contains(device.id()),
212            "Display output should contain the device id; got: {s}"
213        );
214        // Verify the exact "name (id)" format.
215        let expected = format!("{} ({})", device.name(), device.id());
216        assert_eq!(s, expected);
217    }
218
219    /// The default device must have a non-empty id and name.
220    #[test]
221    fn default_device_id_and_name_nonempty() {
222        let device = AudioDevice::from_default().expect("from_default()");
223        assert!(!device.id().is_empty(), "device id must not be empty");
224        assert!(!device.name().is_empty(), "device name must not be empty");
225    }
226
227    /// The default device must be resolvable when an audio device is present.
228    #[test]
229    fn default_returns_ok() {
230        let result = AudioDevice::from_default();
231        assert!(result.is_ok(), "expected Ok, got {result:?}");
232    }
233
234    /// `list()` must return at least one device with non-empty id and name.
235    #[test]
236    fn list_returns_nonempty() {
237        let devices = AudioDevice::list().expect("list()");
238        assert!(
239            !devices.is_empty(),
240            "expected at least one audio device from list()"
241        );
242        for info in &devices {
243            assert!(!info.id.is_empty(), "device id must not be empty");
244            assert!(!info.name.is_empty(), "device name must not be empty");
245        }
246    }
247
248    /// Looking up a device by id obtained from `list()` must succeed.
249    #[test]
250    fn from_id_valid_id_returns_ok() {
251        let devices = AudioDevice::list().expect("list()");
252        let first = devices.first().expect("at least one device in list");
253        let found = AudioDevice::from_id(&first.id);
254        assert!(
255            found.is_ok(),
256            "from_id with a valid id should succeed, got {found:?}"
257        );
258    }
259
260    /// A bogus device id must return an error.
261    #[test]
262    fn from_id_nonexistent_returns_err() {
263        let result = AudioDevice::from_id(BOGUS_ID);
264        assert!(result.is_err(), "expected an error, got {result:?}");
265    }
266
267    /// A partial description substring of a listed device must match.
268    #[test]
269    fn from_name_partial_match_returns_ok() {
270        let devices = AudioDevice::list().expect("list()");
271        let first = devices.first().expect("at least one device in list");
272        let partial: String = first.name.chars().take(3).collect();
273        let found = AudioDevice::from_name(&partial);
274        assert!(
275            found.is_ok(),
276            "from_name with partial match '{partial}' should succeed"
277        );
278    }
279
280    /// A name that matches no device must return an error.
281    #[test]
282    fn from_name_no_match_returns_err() {
283        let result = AudioDevice::from_name(BOGUS_NAME);
284        assert!(result.is_err(), "expected an error, got {result:?}");
285    }
286
287    /// The reported volume must always be within the valid `0..=100` range.
288    #[test]
289    fn get_vol_returns_valid_range() {
290        let device = AudioDevice::from_default().expect("from_default()");
291        let vol = device.get_vol().expect("get_vol()");
292        assert!(vol <= 100, "volume must be in 0..=100, got {vol}");
293    }
294
295    /// Setting the volume to a different value must be reflected when read back.
296    ///
297    /// The original volume is restored so that other tests are not affected.
298    /// Run with `--test-threads=1` to avoid races.
299    #[test]
300    fn set_vol_changes_volume() {
301        let device = AudioDevice::from_default().expect("from_default()");
302        let original = device.get_vol().expect("get_vol()");
303        let target: u8 = if original >= 50 { 30 } else { 70 };
304        device.set_vol(target).expect("set_vol()");
305        let after = device.get_vol().expect("get_vol() after set");
306        // Allow ±1 rounding error due to floating-point ↔ integer conversion.
307        assert!(
308            after.abs_diff(target) <= 1,
309            "expected volume near {target}, got {after}"
310        );
311        device.set_vol(original).expect("restore original volume");
312    }
313
314    /// Toggling the mute state must be reflected when read back.
315    ///
316    /// The original mute state is restored so that other tests are not affected.
317    /// Run with `--test-threads=1` to avoid races.
318    #[test]
319    fn set_mute_changes_mute_state() {
320        let device = AudioDevice::from_default().expect("from_default()");
321        let original = device.is_mute().expect("is_mute()");
322        let target = !original;
323        device.set_mute(target).expect("set_mute()");
324        let after = device.is_mute().expect("is_mute() after set");
325        assert_eq!(after, target, "mute state should be {target}, got {after}");
326        device
327            .set_mute(original)
328            .expect("restore original mute state");
329    }
330}