Skip to main content

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}