volumecontrol_windows/lib.rs
1//! Windows WASAPI 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 `wasapi` feature is **not** enabled every method returns
11//! [`AudioError::Unsupported`], which allows the crate to compile on any
12//! platform without the Windows SDK.
13//!
14//! # Feature flags
15//!
16//! | Feature | Description | Requires |
17//! |----------|------------------------------------------------|-----------------------|
18//! | `wasapi` | Enable the real WASAPI backend via `windows` | Windows target only |
19//!
20//! # Example
21//!
22//! ```no_run
23//! use volumecontrol_windows::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. "Speakers ({0.0.0.00000000}.{…})"
29//! println!("Current volume: {}%", device.get_vol()?);
30//! Ok(())
31//! }
32//! ```
33
34#![deny(missing_docs)]
35
36mod internal;
37
38use std::fmt;
39
40use volumecontrol_core::{AudioDevice as AudioDeviceTrait, AudioError, DeviceInfo};
41
42#[cfg(feature = "wasapi")]
43use std::sync::Mutex;
44
45#[cfg(feature = "wasapi")]
46use windows::Win32::Media::Audio::Endpoints::IAudioEndpointVolume;
47
48/// Represents a WASAPI audio output device (Windows).
49///
50/// # Feature flags
51///
52/// Real WASAPI integration requires the `wasapi` feature and must be built
53/// for a Windows target. Without the feature every method returns
54/// [`AudioError::Unsupported`].
55///
56/// # Thread safety
57///
58/// `AudioDevice` is [`Send`] because all COM interface pointers in the
59/// `windows` crate are `Send + Sync`: `AddRef` / `Release` are guaranteed to
60/// be thread-safe by the COM specification, and `windows-rs` marks every COM
61/// interface accordingly. COM is initialised with `COINIT_MULTITHREADED` (the
62/// multi-threaded apartment), so the cached endpoint can be used from any
63/// thread in the process without cross-apartment marshalling.
64pub struct AudioDevice {
65 /// WASAPI endpoint identifier (GUID string).
66 id: String,
67 /// Friendly device name.
68 name: String,
69 /// Cached [`IAudioEndpointVolume`] interface.
70 ///
71 /// Wrapped in a [`Mutex`] to allow transparent re-initialisation on
72 /// `AUDCLNT_E_DEVICE_INVALIDATED` errors using only a shared reference
73 /// (`&self`). Only present when the `wasapi` feature is enabled.
74 #[cfg(feature = "wasapi")]
75 endpoint: Mutex<IAudioEndpointVolume>,
76}
77
78impl fmt::Debug for AudioDevice {
79 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
80 // The `endpoint` field (a COM interface pointer) is intentionally
81 // omitted: it contains no useful human-readable information and
82 // exposing raw COM interface addresses in debug output would be
83 // confusing. `finish_non_exhaustive` signals that the struct has
84 // additional fields.
85 f.debug_struct("AudioDevice")
86 .field("id", &self.id)
87 .field("name", &self.name)
88 .finish_non_exhaustive()
89 }
90}
91
92impl fmt::Display for AudioDevice {
93 /// Formats the device as `"name (id)"`.
94 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
95 write!(f, "{} ({})", self.name, self.id)
96 }
97}
98
99#[cfg(feature = "wasapi")]
100impl AudioDevice {
101 /// Calls `op` with the cached [`IAudioEndpointVolume`], retrying once
102 /// after an automatic cache refresh if the endpoint signals
103 /// [`EndpointError::DeviceInvalidated`] (`AUDCLNT_E_DEVICE_INVALIDATED`).
104 ///
105 /// A [`ComGuard`] is created for the duration of the call to ensure COM is
106 /// initialised on the calling thread.
107 ///
108 /// # Errors
109 ///
110 /// - On `DeviceInvalidated` the cache is refreshed via
111 /// [`try_refresh_endpoint`]; if the refresh itself fails, that error is
112 /// returned. If the retry still returns `DeviceInvalidated` (device
113 /// disappeared between calls) `AudioError::DeviceNotFound` is returned.
114 /// - On any other [`EndpointError::Error`] the wrapped [`AudioError`] is
115 /// propagated unchanged.
116 /// - Returns [`AudioError::EndpointLockPoisoned`] if the endpoint mutex is
117 /// poisoned (a thread panicked while holding the lock).
118 ///
119 /// [`ComGuard`]: internal::wasapi::ComGuard
120 /// [`try_refresh_endpoint`]: AudioDevice::try_refresh_endpoint
121 fn with_endpoint<T>(
122 &self,
123 op: impl Fn(&IAudioEndpointVolume) -> Result<T, internal::wasapi::EndpointError>,
124 ) -> Result<T, AudioError> {
125 let _com = internal::wasapi::ComGuard::new()?;
126 let guard = self
127 .endpoint
128 .lock()
129 .map_err(|_| AudioError::EndpointLockPoisoned)?;
130 match op(&guard) {
131 Ok(v) => Ok(v),
132 Err(internal::wasapi::EndpointError::Error(e)) => Err(e),
133 Err(internal::wasapi::EndpointError::DeviceInvalidated) => {
134 // Release the lock before refreshing so `try_refresh_endpoint`
135 // can also acquire it.
136 drop(guard);
137 // AUDCLNT_E_DEVICE_INVALIDATED — refresh cache and retry once.
138 self.try_refresh_endpoint()?;
139 let guard = self
140 .endpoint
141 .lock()
142 .map_err(|_| AudioError::EndpointLockPoisoned)?;
143 match op(&guard) {
144 Ok(v) => Ok(v),
145 Err(internal::wasapi::EndpointError::Error(e)) => Err(e),
146 // Still invalidated after a fresh endpoint: device is gone.
147 Err(internal::wasapi::EndpointError::DeviceInvalidated) => {
148 Err(AudioError::DeviceNotFound)
149 }
150 }
151 }
152 }
153 }
154
155 /// Re-resolves the device by its cached ID and replaces the stored
156 /// [`IAudioEndpointVolume`] with a freshly activated one.
157 ///
158 /// Called by [`with_endpoint`] when an endpoint operation returns
159 /// [`EndpointError::DeviceInvalidated`]
160 /// (`AUDCLNT_E_DEVICE_INVALIDATED`).
161 /// The caller is responsible for ensuring COM is already initialised on the
162 /// current thread (i.e. a [`ComGuard`] is alive in the calling scope).
163 ///
164 /// # Errors
165 ///
166 /// Returns [`AudioError::DeviceNotFound`] if the device no longer exists,
167 /// [`AudioError::InitializationFailed`] on other COM failures, or
168 /// [`AudioError::EndpointLockPoisoned`] if the endpoint mutex is poisoned.
169 ///
170 /// [`with_endpoint`]: AudioDevice::with_endpoint
171 /// [`ComGuard`]: internal::wasapi::ComGuard
172 fn try_refresh_endpoint(&self) -> Result<(), AudioError> {
173 let enumerator = internal::wasapi::create_enumerator()?;
174 let device = internal::wasapi::get_device_by_id(&enumerator, &self.id)?;
175 let new_endpoint = internal::wasapi::endpoint_volume(&device)?;
176 *self
177 .endpoint
178 .lock()
179 .map_err(|_| AudioError::EndpointLockPoisoned)? = new_endpoint;
180 Ok(())
181 }
182}
183
184impl AudioDeviceTrait for AudioDevice {
185 /// Returns the system default audio render device.
186 ///
187 /// # Errors
188 ///
189 /// Returns [`AudioError::InitializationFailed`] if COM cannot be
190 /// initialised or if the default device cannot be resolved.
191 /// Returns [`AudioError::Unsupported`] when the `wasapi` feature is
192 /// not enabled.
193 fn from_default() -> Result<Self, AudioError> {
194 #[cfg(feature = "wasapi")]
195 {
196 let _com = internal::wasapi::ComGuard::new()?;
197 let enumerator = internal::wasapi::create_enumerator()?;
198 let device = internal::wasapi::get_default_device(&enumerator)?;
199 let id = internal::wasapi::device_id(&device)?;
200 let name = internal::wasapi::device_name(&device)?;
201 let endpoint = internal::wasapi::endpoint_volume(&device)?;
202 Ok(Self {
203 id,
204 name,
205 endpoint: Mutex::new(endpoint),
206 })
207 }
208 #[cfg(not(feature = "wasapi"))]
209 Err(AudioError::Unsupported)
210 }
211
212 /// Returns the audio device identified by `id`.
213 ///
214 /// # Errors
215 ///
216 /// Returns [`AudioError::DeviceNotFound`] if no device with the given
217 /// identifier exists.
218 /// Returns [`AudioError::InitializationFailed`] if COM cannot be
219 /// initialised or another lookup failure occurs.
220 /// Returns [`AudioError::Unsupported`] when the `wasapi` feature is
221 /// not enabled.
222 fn from_id(id: &str) -> Result<Self, AudioError> {
223 #[cfg(feature = "wasapi")]
224 {
225 let _com = internal::wasapi::ComGuard::new()?;
226 let enumerator = internal::wasapi::create_enumerator()?;
227 let device = internal::wasapi::get_device_by_id(&enumerator, id)?;
228 let resolved_id = internal::wasapi::device_id(&device)?;
229 let name = internal::wasapi::device_name(&device)?;
230 let endpoint = internal::wasapi::endpoint_volume(&device)?;
231 Ok(Self {
232 id: resolved_id,
233 name,
234 endpoint: Mutex::new(endpoint),
235 })
236 }
237 #[cfg(not(feature = "wasapi"))]
238 {
239 let _ = id;
240 Err(AudioError::Unsupported)
241 }
242 }
243
244 /// Returns the first audio device whose name contains `name`
245 /// (case-insensitive substring match).
246 ///
247 /// # Errors
248 ///
249 /// Returns [`AudioError::DeviceNotFound`] if no matching device is found.
250 /// Returns [`AudioError::InitializationFailed`] if COM cannot be
251 /// initialised or another lookup failure occurs.
252 /// Returns [`AudioError::Unsupported`] when the `wasapi` feature is
253 /// not enabled.
254 fn from_name(name: &str) -> Result<Self, AudioError> {
255 #[cfg(feature = "wasapi")]
256 {
257 let _com = internal::wasapi::ComGuard::new()?;
258 let enumerator = internal::wasapi::create_enumerator()?;
259 let devices = internal::wasapi::list_devices(&enumerator)?;
260
261 let needle = name.to_lowercase();
262 let info = devices
263 .into_iter()
264 .find(|d| d.name.to_lowercase().contains(&needle))
265 .ok_or(AudioError::DeviceNotFound)?;
266
267 // Re-resolve the IMMDevice from its ID to activate the endpoint.
268 let device = internal::wasapi::get_device_by_id(&enumerator, &info.id)?;
269 let endpoint = internal::wasapi::endpoint_volume(&device)?;
270
271 Ok(Self {
272 id: info.id,
273 name: info.name,
274 endpoint: Mutex::new(endpoint),
275 })
276 }
277 #[cfg(not(feature = "wasapi"))]
278 {
279 let _ = name;
280 Err(AudioError::Unsupported)
281 }
282 }
283
284 /// Lists all available audio render devices.
285 ///
286 /// # Errors
287 ///
288 /// Returns [`AudioError::ListFailed`] if the device list cannot be
289 /// retrieved.
290 /// Returns [`AudioError::InitializationFailed`] if COM cannot be
291 /// initialised.
292 /// Returns [`AudioError::Unsupported`] when the `wasapi` feature is
293 /// not enabled.
294 fn list() -> Result<Vec<DeviceInfo>, AudioError> {
295 #[cfg(feature = "wasapi")]
296 {
297 let _com = internal::wasapi::ComGuard::new()?;
298 let enumerator = internal::wasapi::create_enumerator()?;
299 internal::wasapi::list_devices(&enumerator)
300 }
301 #[cfg(not(feature = "wasapi"))]
302 Err(AudioError::Unsupported)
303 }
304
305 /// Returns the current volume level in the range `0..=100`.
306 ///
307 /// # Errors
308 ///
309 /// Returns [`AudioError::GetVolumeFailed`] if the volume cannot be read.
310 /// Returns [`AudioError::DeviceNotFound`] if this device no longer exists.
311 /// Returns [`AudioError::Unsupported`] when the `wasapi` feature is
312 /// not enabled.
313 fn get_vol(&self) -> Result<u8, AudioError> {
314 #[cfg(feature = "wasapi")]
315 {
316 self.with_endpoint(internal::wasapi::get_volume)
317 }
318 #[cfg(not(feature = "wasapi"))]
319 Err(AudioError::Unsupported)
320 }
321
322 /// Sets the volume level.
323 ///
324 /// `vol` is clamped to `0..=100` before being applied.
325 ///
326 /// # Errors
327 ///
328 /// Returns [`AudioError::SetVolumeFailed`] if the volume cannot be set.
329 /// Returns [`AudioError::DeviceNotFound`] if this device no longer exists.
330 /// Returns [`AudioError::Unsupported`] when the `wasapi` feature is
331 /// not enabled.
332 fn set_vol(&self, vol: u8) -> Result<(), AudioError> {
333 #[cfg(feature = "wasapi")]
334 {
335 self.with_endpoint(|ep| internal::wasapi::set_volume(ep, vol))
336 }
337 #[cfg(not(feature = "wasapi"))]
338 {
339 let _ = vol;
340 Err(AudioError::Unsupported)
341 }
342 }
343
344 /// Returns `true` if the device is currently muted.
345 ///
346 /// # Errors
347 ///
348 /// Returns [`AudioError::GetMuteFailed`] if the mute state cannot be read.
349 /// Returns [`AudioError::DeviceNotFound`] if this device no longer exists.
350 /// Returns [`AudioError::Unsupported`] when the `wasapi` feature is
351 /// not enabled.
352 fn is_mute(&self) -> Result<bool, AudioError> {
353 #[cfg(feature = "wasapi")]
354 {
355 self.with_endpoint(internal::wasapi::get_mute)
356 }
357 #[cfg(not(feature = "wasapi"))]
358 Err(AudioError::Unsupported)
359 }
360
361 /// Mutes or unmutes the device.
362 ///
363 /// # Errors
364 ///
365 /// Returns [`AudioError::SetMuteFailed`] if the mute state cannot be
366 /// changed.
367 /// Returns [`AudioError::DeviceNotFound`] if this device no longer exists.
368 /// Returns [`AudioError::Unsupported`] when the `wasapi` feature is
369 /// not enabled.
370 fn set_mute(&self, muted: bool) -> Result<(), AudioError> {
371 #[cfg(feature = "wasapi")]
372 {
373 self.with_endpoint(|ep| internal::wasapi::set_mute(ep, muted))
374 }
375 #[cfg(not(feature = "wasapi"))]
376 {
377 let _ = muted;
378 Err(AudioError::Unsupported)
379 }
380 }
381
382 fn id(&self) -> &str {
383 &self.id
384 }
385
386 fn name(&self) -> &str {
387 &self.name
388 }
389}
390
391#[cfg(test)]
392mod tests {
393 use super::*;
394 use volumecontrol_core::AudioDevice as AudioDeviceTrait;
395
396 /// `Display` output must follow the `"name (id)"` format.
397 ///
398 /// The test is gated on `not(wasapi)` because constructing an
399 /// [`AudioDevice`] directly without a valid COM endpoint is only safe when
400 /// the `wasapi` feature is disabled (no `endpoint` field exists).
401 #[test]
402 #[cfg(not(feature = "wasapi"))]
403 fn display_format_is_name_paren_id() {
404 let device = AudioDevice {
405 id: "{0.0.0.00000000}.{E9B0A576-1234-5678-ABCD-000000000000}".to_string(),
406 name: "Speakers".to_string(),
407 };
408 assert_eq!(
409 device.to_string(),
410 "Speakers ({0.0.0.00000000}.{E9B0A576-1234-5678-ABCD-000000000000})"
411 );
412 }
413
414 // ------------------------------------------------------------------
415 // Stub-path tests — only compiled and run when `wasapi` is disabled.
416 // ------------------------------------------------------------------
417
418 #[test]
419 #[cfg(not(feature = "wasapi"))]
420 fn default_returns_unsupported_without_feature() {
421 assert!(matches!(
422 AudioDevice::from_default(),
423 Err(AudioError::Unsupported)
424 ));
425 }
426
427 #[test]
428 #[cfg(not(feature = "wasapi"))]
429 fn from_id_returns_unsupported_without_feature() {
430 assert!(matches!(
431 AudioDevice::from_id("test-id"),
432 Err(AudioError::Unsupported)
433 ));
434 }
435
436 #[test]
437 #[cfg(not(feature = "wasapi"))]
438 fn from_name_returns_unsupported_without_feature() {
439 assert!(matches!(
440 AudioDevice::from_name("test-name"),
441 Err(AudioError::Unsupported)
442 ));
443 }
444
445 #[test]
446 #[cfg(not(feature = "wasapi"))]
447 fn list_returns_unsupported_without_feature() {
448 assert!(matches!(AudioDevice::list(), Err(AudioError::Unsupported)));
449 }
450
451 #[test]
452 #[cfg(not(feature = "wasapi"))]
453 fn get_vol_returns_unsupported_without_feature() {
454 let device = AudioDevice {
455 id: String::from("stub-id"),
456 name: String::from("stub-name"),
457 };
458 assert!(matches!(device.get_vol(), Err(AudioError::Unsupported)));
459 }
460
461 #[test]
462 #[cfg(not(feature = "wasapi"))]
463 fn set_vol_returns_unsupported_without_feature() {
464 let device = AudioDevice {
465 id: String::from("stub-id"),
466 name: String::from("stub-name"),
467 };
468 assert!(matches!(device.set_vol(50), Err(AudioError::Unsupported)));
469 }
470
471 #[test]
472 #[cfg(not(feature = "wasapi"))]
473 fn is_mute_returns_unsupported_without_feature() {
474 let device = AudioDevice {
475 id: String::from("stub-id"),
476 name: String::from("stub-name"),
477 };
478 assert!(matches!(device.is_mute(), Err(AudioError::Unsupported)));
479 }
480
481 #[test]
482 #[cfg(not(feature = "wasapi"))]
483 fn set_mute_returns_unsupported_without_feature() {
484 let device = AudioDevice {
485 id: String::from("stub-id"),
486 name: String::from("stub-name"),
487 };
488 assert!(matches!(
489 device.set_mute(true),
490 Err(AudioError::Unsupported)
491 ));
492 }
493
494 // ------------------------------------------------------------------
495 // Real-world WASAPI tests — only compiled and run with `wasapi` feature.
496 // These run on Windows CI runners that always have at least one audio
497 // endpoint available.
498 // ------------------------------------------------------------------
499
500 /// A device ID that is guaranteed to not match any real WASAPI endpoint.
501 #[cfg(feature = "wasapi")]
502 const BOGUS_ID: &str = "volumecontrol-test-nonexistent-{00000000-0000-0000-0000-000000000000}";
503
504 /// A device name that is guaranteed to not match any real audio device.
505 #[cfg(feature = "wasapi")]
506 const BOGUS_NAME: &str = "zzz-volumecontrol-test-nonexistent-device-name";
507
508 /// `from_id` with a clearly invalid ID must return `DeviceNotFound` or a
509 /// graceful `InitializationFailed` — never a panic or `Unsupported`.
510 #[test]
511 #[cfg(feature = "wasapi")]
512 fn from_id_bogus_returns_not_found() {
513 let result = AudioDevice::from_id(BOGUS_ID);
514 assert!(
515 matches!(
516 result,
517 Err(AudioError::DeviceNotFound | AudioError::InitializationFailed(_))
518 ),
519 "expected DeviceNotFound or InitializationFailed, got {result:?}"
520 );
521 }
522
523 /// `from_name` with a clearly invalid name must return `DeviceNotFound` or
524 /// `InitializationFailed` — never a panic or `Unsupported`.
525 #[test]
526 #[cfg(feature = "wasapi")]
527 fn from_name_bogus_returns_not_found() {
528 let result = AudioDevice::from_name(BOGUS_NAME);
529 assert!(
530 matches!(
531 result,
532 Err(AudioError::DeviceNotFound | AudioError::InitializationFailed(_))
533 ),
534 "expected DeviceNotFound or InitializationFailed, got {result:?}"
535 );
536 }
537
538 /// `from_name` must match regardless of the case of the query string.
539 #[test]
540 #[cfg(feature = "wasapi")]
541 fn from_name_case_insensitive_match_returns_ok() {
542 let default_device = AudioDevice::from_default().expect("from_default() failed");
543 let upper = default_device.name().to_uppercase();
544 let found = AudioDevice::from_name(&upper);
545 assert!(
546 found.is_ok(),
547 "from_name with uppercase query '{upper}' should succeed (case-insensitive)"
548 );
549 }
550
551 /// On Windows there is always at least one audio render endpoint; `list()`
552 /// must succeed and return a non-empty `Vec`.
553 #[test]
554 #[cfg(feature = "wasapi")]
555 fn list_returns_non_empty_vec() {
556 let devices = AudioDevice::list().expect("list() failed on Windows");
557 assert!(
558 !devices.is_empty(),
559 "list() returned an empty Vec on Windows"
560 );
561 }
562
563 /// On Windows there is always a default audio render endpoint; `default()`
564 /// must succeed.
565 #[test]
566 #[cfg(feature = "wasapi")]
567 fn default_device_always_found() {
568 AudioDevice::from_default()
569 .expect("from_default() failed — no default audio device on Windows");
570 }
571
572 /// `get_vol` must return a value in `0..=100`; `set_vol` to a different
573 /// level must be reflected by the next `get_vol` call. The original volume
574 /// is restored when the test finishes.
575 #[test]
576 #[cfg(feature = "wasapi")]
577 fn default_device_volume_round_trip() {
578 let device = AudioDevice::from_default().expect("from_default() failed");
579
580 let original_vol = device.get_vol().expect("get_vol() failed");
581 assert!(
582 original_vol <= 100,
583 "get_vol returned {original_vol}, out of range"
584 );
585
586 // Pick a target volume that differs from the current one.
587 let target_vol: u8 = if original_vol >= 50 { 25 } else { 75 };
588
589 device.set_vol(target_vol).expect("set_vol() failed");
590
591 let new_vol = device.get_vol().expect("get_vol() after set_vol() failed");
592 assert_eq!(new_vol, target_vol, "volume did not change to {target_vol}");
593
594 // Restore original volume — best-effort; ignore errors on cleanup.
595 let _ = device.set_vol(original_vol);
596 }
597
598 /// `set_mute(!original)` must flip the mute state; the change must be
599 /// visible via `is_mute`. The original state is restored afterwards.
600 #[test]
601 #[cfg(feature = "wasapi")]
602 fn default_device_mute_round_trip() {
603 let device = AudioDevice::from_default().expect("from_default() failed");
604
605 let original = device.is_mute().expect("is_mute() failed");
606
607 // Toggle to the opposite state.
608 device.set_mute(!original).expect("set_mute() failed");
609
610 let toggled = device.is_mute().expect("is_mute() after set_mute() failed");
611 assert_eq!(
612 toggled, !original,
613 "mute state did not toggle to {}",
614 !original
615 );
616
617 // Restore — best-effort; ignore errors on cleanup.
618 let _ = device.set_mute(original);
619 }
620}