#![deny(missing_docs)]
mod internal;
use std::fmt;
use volumecontrol_core::{AudioDevice as AudioDeviceTrait, AudioError, DeviceInfo};
#[cfg(feature = "wasapi")]
use std::sync::Mutex;
#[cfg(feature = "wasapi")]
use windows::Win32::Media::Audio::Endpoints::IAudioEndpointVolume;
pub struct AudioDevice {
id: String,
name: String,
#[cfg(feature = "wasapi")]
endpoint: Mutex<IAudioEndpointVolume>,
}
impl fmt::Debug for AudioDevice {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.debug_struct("AudioDevice")
.field("id", &self.id)
.field("name", &self.name)
.finish_non_exhaustive()
}
}
impl fmt::Display for AudioDevice {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(f, "{} ({})", self.name, self.id)
}
}
#[cfg(feature = "wasapi")]
impl AudioDevice {
fn with_endpoint<T>(
&self,
op: impl Fn(&IAudioEndpointVolume) -> Result<T, internal::wasapi::EndpointError>,
) -> Result<T, AudioError> {
let _com = internal::wasapi::ComGuard::new()?;
let guard = self
.endpoint
.lock()
.map_err(|_| AudioError::EndpointLockPoisoned)?;
match op(&guard) {
Ok(v) => Ok(v),
Err(internal::wasapi::EndpointError::Error(e)) => Err(e),
Err(internal::wasapi::EndpointError::DeviceInvalidated) => {
drop(guard);
self.try_refresh_endpoint()?;
let guard = self
.endpoint
.lock()
.map_err(|_| AudioError::EndpointLockPoisoned)?;
match op(&guard) {
Ok(v) => Ok(v),
Err(internal::wasapi::EndpointError::Error(e)) => Err(e),
Err(internal::wasapi::EndpointError::DeviceInvalidated) => {
Err(AudioError::DeviceNotFound)
}
}
}
}
}
fn try_refresh_endpoint(&self) -> Result<(), AudioError> {
let enumerator = internal::wasapi::create_enumerator()?;
let device = internal::wasapi::get_device_by_id(&enumerator, &self.id)?;
let new_endpoint = internal::wasapi::endpoint_volume(&device)?;
*self
.endpoint
.lock()
.map_err(|_| AudioError::EndpointLockPoisoned)? = new_endpoint;
Ok(())
}
}
impl AudioDeviceTrait for AudioDevice {
fn from_default() -> Result<Self, AudioError> {
#[cfg(feature = "wasapi")]
{
let _com = internal::wasapi::ComGuard::new()?;
let enumerator = internal::wasapi::create_enumerator()?;
let device = internal::wasapi::get_default_device(&enumerator)?;
let id = internal::wasapi::device_id(&device)?;
let name = internal::wasapi::device_name(&device)?;
let endpoint = internal::wasapi::endpoint_volume(&device)?;
Ok(Self {
id,
name,
endpoint: Mutex::new(endpoint),
})
}
#[cfg(not(feature = "wasapi"))]
Err(AudioError::Unsupported)
}
fn from_id(id: &str) -> Result<Self, AudioError> {
#[cfg(feature = "wasapi")]
{
let _com = internal::wasapi::ComGuard::new()?;
let enumerator = internal::wasapi::create_enumerator()?;
let device = internal::wasapi::get_device_by_id(&enumerator, id)?;
let resolved_id = internal::wasapi::device_id(&device)?;
let name = internal::wasapi::device_name(&device)?;
let endpoint = internal::wasapi::endpoint_volume(&device)?;
Ok(Self {
id: resolved_id,
name,
endpoint: Mutex::new(endpoint),
})
}
#[cfg(not(feature = "wasapi"))]
{
let _ = id;
Err(AudioError::Unsupported)
}
}
fn from_name(name: &str) -> Result<Self, AudioError> {
#[cfg(feature = "wasapi")]
{
let _com = internal::wasapi::ComGuard::new()?;
let enumerator = internal::wasapi::create_enumerator()?;
let devices = internal::wasapi::list_devices(&enumerator)?;
let needle = name.to_lowercase();
let info = devices
.into_iter()
.find(|d| d.name.to_lowercase().contains(&needle))
.ok_or(AudioError::DeviceNotFound)?;
let device = internal::wasapi::get_device_by_id(&enumerator, &info.id)?;
let endpoint = internal::wasapi::endpoint_volume(&device)?;
Ok(Self {
id: info.id,
name: info.name,
endpoint: Mutex::new(endpoint),
})
}
#[cfg(not(feature = "wasapi"))]
{
let _ = name;
Err(AudioError::Unsupported)
}
}
fn list() -> Result<Vec<DeviceInfo>, AudioError> {
#[cfg(feature = "wasapi")]
{
let _com = internal::wasapi::ComGuard::new()?;
let enumerator = internal::wasapi::create_enumerator()?;
internal::wasapi::list_devices(&enumerator)
}
#[cfg(not(feature = "wasapi"))]
Err(AudioError::Unsupported)
}
fn get_vol(&self) -> Result<u8, AudioError> {
#[cfg(feature = "wasapi")]
{
self.with_endpoint(internal::wasapi::get_volume)
}
#[cfg(not(feature = "wasapi"))]
Err(AudioError::Unsupported)
}
fn set_vol(&self, vol: u8) -> Result<(), AudioError> {
#[cfg(feature = "wasapi")]
{
self.with_endpoint(|ep| internal::wasapi::set_volume(ep, vol))
}
#[cfg(not(feature = "wasapi"))]
{
let _ = vol;
Err(AudioError::Unsupported)
}
}
fn is_mute(&self) -> Result<bool, AudioError> {
#[cfg(feature = "wasapi")]
{
self.with_endpoint(internal::wasapi::get_mute)
}
#[cfg(not(feature = "wasapi"))]
Err(AudioError::Unsupported)
}
fn set_mute(&self, muted: bool) -> Result<(), AudioError> {
#[cfg(feature = "wasapi")]
{
self.with_endpoint(|ep| internal::wasapi::set_mute(ep, muted))
}
#[cfg(not(feature = "wasapi"))]
{
let _ = muted;
Err(AudioError::Unsupported)
}
}
fn id(&self) -> &str {
&self.id
}
fn name(&self) -> &str {
&self.name
}
}
#[cfg(test)]
mod tests {
use super::*;
use volumecontrol_core::AudioDevice as AudioDeviceTrait;
#[test]
#[cfg(not(feature = "wasapi"))]
fn display_format_is_name_paren_id() {
let device = AudioDevice {
id: "{0.0.0.00000000}.{E9B0A576-1234-5678-ABCD-000000000000}".to_string(),
name: "Speakers".to_string(),
};
assert_eq!(
device.to_string(),
"Speakers ({0.0.0.00000000}.{E9B0A576-1234-5678-ABCD-000000000000})"
);
}
#[test]
#[cfg(not(feature = "wasapi"))]
fn default_returns_unsupported_without_feature() {
assert!(matches!(
AudioDevice::from_default(),
Err(AudioError::Unsupported)
));
}
#[test]
#[cfg(not(feature = "wasapi"))]
fn from_id_returns_unsupported_without_feature() {
assert!(matches!(
AudioDevice::from_id("test-id"),
Err(AudioError::Unsupported)
));
}
#[test]
#[cfg(not(feature = "wasapi"))]
fn from_name_returns_unsupported_without_feature() {
assert!(matches!(
AudioDevice::from_name("test-name"),
Err(AudioError::Unsupported)
));
}
#[test]
#[cfg(not(feature = "wasapi"))]
fn list_returns_unsupported_without_feature() {
assert!(matches!(AudioDevice::list(), Err(AudioError::Unsupported)));
}
#[test]
#[cfg(not(feature = "wasapi"))]
fn get_vol_returns_unsupported_without_feature() {
let device = AudioDevice {
id: String::from("stub-id"),
name: String::from("stub-name"),
};
assert!(matches!(device.get_vol(), Err(AudioError::Unsupported)));
}
#[test]
#[cfg(not(feature = "wasapi"))]
fn set_vol_returns_unsupported_without_feature() {
let device = AudioDevice {
id: String::from("stub-id"),
name: String::from("stub-name"),
};
assert!(matches!(device.set_vol(50), Err(AudioError::Unsupported)));
}
#[test]
#[cfg(not(feature = "wasapi"))]
fn is_mute_returns_unsupported_without_feature() {
let device = AudioDevice {
id: String::from("stub-id"),
name: String::from("stub-name"),
};
assert!(matches!(device.is_mute(), Err(AudioError::Unsupported)));
}
#[test]
#[cfg(not(feature = "wasapi"))]
fn set_mute_returns_unsupported_without_feature() {
let device = AudioDevice {
id: String::from("stub-id"),
name: String::from("stub-name"),
};
assert!(matches!(
device.set_mute(true),
Err(AudioError::Unsupported)
));
}
#[cfg(feature = "wasapi")]
const BOGUS_ID: &str = "volumecontrol-test-nonexistent-{00000000-0000-0000-0000-000000000000}";
#[cfg(feature = "wasapi")]
const BOGUS_NAME: &str = "zzz-volumecontrol-test-nonexistent-device-name";
#[test]
#[cfg(feature = "wasapi")]
fn from_id_bogus_returns_not_found() {
let result = AudioDevice::from_id(BOGUS_ID);
assert!(
matches!(
result,
Err(AudioError::DeviceNotFound | AudioError::InitializationFailed(_))
),
"expected DeviceNotFound or InitializationFailed, got {result:?}"
);
}
#[test]
#[cfg(feature = "wasapi")]
fn from_name_bogus_returns_not_found() {
let result = AudioDevice::from_name(BOGUS_NAME);
assert!(
matches!(
result,
Err(AudioError::DeviceNotFound | AudioError::InitializationFailed(_))
),
"expected DeviceNotFound or InitializationFailed, got {result:?}"
);
}
#[test]
#[cfg(feature = "wasapi")]
fn from_name_case_insensitive_match_returns_ok() {
let default_device = AudioDevice::from_default().expect("from_default() failed");
let upper = default_device.name().to_uppercase();
let found = AudioDevice::from_name(&upper);
assert!(
found.is_ok(),
"from_name with uppercase query '{upper}' should succeed (case-insensitive)"
);
}
#[test]
#[cfg(feature = "wasapi")]
fn list_returns_non_empty_vec() {
let devices = AudioDevice::list().expect("list() failed on Windows");
assert!(
!devices.is_empty(),
"list() returned an empty Vec on Windows"
);
}
#[test]
#[cfg(feature = "wasapi")]
fn default_device_always_found() {
AudioDevice::from_default()
.expect("from_default() failed — no default audio device on Windows");
}
#[test]
#[cfg(feature = "wasapi")]
fn default_device_volume_round_trip() {
let device = AudioDevice::from_default().expect("from_default() failed");
let original_vol = device.get_vol().expect("get_vol() failed");
assert!(
original_vol <= 100,
"get_vol returned {original_vol}, out of range"
);
let target_vol: u8 = if original_vol >= 50 { 25 } else { 75 };
device.set_vol(target_vol).expect("set_vol() failed");
let new_vol = device.get_vol().expect("get_vol() after set_vol() failed");
assert_eq!(new_vol, target_vol, "volume did not change to {target_vol}");
let _ = device.set_vol(original_vol);
}
#[test]
#[cfg(feature = "wasapi")]
fn default_device_mute_round_trip() {
let device = AudioDevice::from_default().expect("from_default() failed");
let original = device.is_mute().expect("is_mute() failed");
device.set_mute(!original).expect("set_mute() failed");
let toggled = device.is_mute().expect("is_mute() after set_mute() failed");
assert_eq!(
toggled, !original,
"mute state did not toggle to {}",
!original
);
let _ = device.set_mute(original);
}
}