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}