#![deny(missing_docs)]
mod internal;
use std::fmt;
use volumecontrol_core::{AudioDevice as AudioDeviceTrait, AudioError, DeviceInfo};
#[derive(Debug)]
pub struct AudioDevice {
id: String,
name: String,
}
impl fmt::Display for AudioDevice {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(f, "{} ({})", self.name, self.id)
}
}
#[cfg(feature = "coreaudio")]
impl AudioDevice {
fn from_raw_id(raw_id: internal::AudioObjectID) -> Result<Self, AudioError> {
let name = internal::get_device_name(raw_id)?;
Ok(Self {
id: raw_id.to_string(),
name,
})
}
}
impl AudioDeviceTrait for AudioDevice {
fn from_default() -> Result<Self, AudioError> {
#[cfg(feature = "coreaudio")]
{
let raw_id = internal::get_default_device_id()?;
Self::from_raw_id(raw_id)
}
#[cfg(not(feature = "coreaudio"))]
Err(AudioError::Unsupported)
}
fn from_id(id: &str) -> Result<Self, AudioError> {
#[cfg(feature = "coreaudio")]
{
let raw_id: internal::AudioObjectID =
id.parse().map_err(|_| AudioError::DeviceNotFound)?;
let ids = internal::list_device_ids()?;
if !ids.contains(&raw_id) {
return Err(AudioError::DeviceNotFound);
}
Self::from_raw_id(raw_id)
}
#[cfg(not(feature = "coreaudio"))]
{
let _ = id;
Err(AudioError::Unsupported)
}
}
fn from_name(name: &str) -> Result<Self, AudioError> {
#[cfg(feature = "coreaudio")]
{
let name_lower = name.to_lowercase();
for raw_id in internal::list_device_ids()? {
let device_name = internal::get_device_name(raw_id)?;
if device_name.to_lowercase().contains(&name_lower) {
return Self::from_raw_id(raw_id);
}
}
Err(AudioError::DeviceNotFound)
}
#[cfg(not(feature = "coreaudio"))]
{
let _ = name;
Err(AudioError::Unsupported)
}
}
fn list() -> Result<Vec<DeviceInfo>, AudioError> {
#[cfg(feature = "coreaudio")]
{
let ids = internal::list_device_ids()?;
let mut devices = Vec::with_capacity(ids.len());
for raw_id in ids {
let name = internal::get_device_name(raw_id)?;
devices.push(DeviceInfo {
id: raw_id.to_string(),
name,
});
}
Ok(devices)
}
#[cfg(not(feature = "coreaudio"))]
Err(AudioError::Unsupported)
}
fn get_vol(&self) -> Result<u8, AudioError> {
#[cfg(feature = "coreaudio")]
{
let raw_id: internal::AudioObjectID =
self.id.parse().map_err(|_| AudioError::DeviceNotFound)?;
internal::get_volume(raw_id)
}
#[cfg(not(feature = "coreaudio"))]
Err(AudioError::Unsupported)
}
fn set_vol(&self, vol: u8) -> Result<(), AudioError> {
#[cfg(feature = "coreaudio")]
{
let raw_id: internal::AudioObjectID =
self.id.parse().map_err(|_| AudioError::DeviceNotFound)?;
internal::set_volume(raw_id, vol)
}
#[cfg(not(feature = "coreaudio"))]
{
let _ = vol;
Err(AudioError::Unsupported)
}
}
fn is_mute(&self) -> Result<bool, AudioError> {
#[cfg(feature = "coreaudio")]
{
let raw_id: internal::AudioObjectID =
self.id.parse().map_err(|_| AudioError::DeviceNotFound)?;
internal::get_mute(raw_id)
}
#[cfg(not(feature = "coreaudio"))]
Err(AudioError::Unsupported)
}
fn set_mute(&self, muted: bool) -> Result<(), AudioError> {
#[cfg(feature = "coreaudio")]
{
let raw_id: internal::AudioObjectID =
self.id.parse().map_err(|_| AudioError::DeviceNotFound)?;
internal::set_mute(raw_id, muted)
}
#[cfg(not(feature = "coreaudio"))]
{
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]
fn display_format_is_name_paren_id() {
let device = AudioDevice {
id: "73".to_string(),
name: "MacBook Pro Speakers".to_string(),
};
assert_eq!(device.to_string(), "MacBook Pro Speakers (73)");
}
#[cfg(not(feature = "coreaudio"))]
#[test]
fn default_returns_unsupported_without_feature() {
let result = AudioDevice::from_default();
assert!(matches!(result.unwrap_err(), AudioError::Unsupported));
}
#[cfg(not(feature = "coreaudio"))]
#[test]
fn from_id_returns_unsupported_without_feature() {
let result = AudioDevice::from_id("test-id");
assert!(matches!(result.unwrap_err(), AudioError::Unsupported));
}
#[cfg(not(feature = "coreaudio"))]
#[test]
fn from_name_returns_unsupported_without_feature() {
let result = AudioDevice::from_name("test-name");
assert!(matches!(result.unwrap_err(), AudioError::Unsupported));
}
#[cfg(not(feature = "coreaudio"))]
#[test]
fn list_returns_unsupported_without_feature() {
let result = AudioDevice::list();
assert!(matches!(result.unwrap_err(), AudioError::Unsupported));
}
#[cfg(all(feature = "coreaudio", target_os = "macos"))]
#[test]
fn default_returns_ok() {
let device = AudioDevice::from_default();
assert!(device.is_ok(), "expected Ok, got {device:?}");
}
#[cfg(all(feature = "coreaudio", target_os = "macos"))]
#[test]
fn list_returns_nonempty() {
let devices = AudioDevice::list().expect("list()");
assert!(
!devices.is_empty(),
"expected at least one audio device from list()"
);
for info in &devices {
assert!(!info.id.is_empty(), "device id must not be empty");
assert!(!info.name.is_empty(), "device name must not be empty");
}
}
#[cfg(all(feature = "coreaudio", target_os = "macos"))]
#[test]
fn from_id_valid_id_returns_ok() {
let default_device = AudioDevice::from_default().expect("from_default()");
let found = AudioDevice::from_id(default_device.id());
assert!(found.is_ok(), "from_id with valid id should succeed");
assert_eq!(found.unwrap().id(), default_device.id());
}
#[cfg(all(feature = "coreaudio", target_os = "macos"))]
#[test]
fn from_id_invalid_id_returns_not_found() {
let result = AudioDevice::from_id("not-a-number");
assert!(
matches!(result.unwrap_err(), AudioError::DeviceNotFound),
"non-numeric id should return DeviceNotFound"
);
}
#[cfg(all(feature = "coreaudio", target_os = "macos"))]
#[test]
fn from_name_partial_match_returns_ok() {
let default_device = AudioDevice::from_default().expect("from_default()");
let partial: String = default_device.name().chars().take(3).collect();
let found = AudioDevice::from_name(&partial);
assert!(
found.is_ok(),
"from_name with partial match '{partial}' should succeed"
);
}
#[cfg(all(feature = "coreaudio", target_os = "macos"))]
#[test]
fn from_name_case_insensitive_match_returns_ok() {
let default_device = AudioDevice::from_default().expect("from_default()");
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)"
);
}
#[cfg(all(feature = "coreaudio", target_os = "macos"))]
#[test]
fn from_name_no_match_returns_not_found() {
let result = AudioDevice::from_name("\x00\x01\x02");
assert!(
matches!(result.unwrap_err(), AudioError::DeviceNotFound),
"unrecognised name should return DeviceNotFound"
);
}
#[cfg(all(feature = "coreaudio", target_os = "macos"))]
#[test]
fn get_vol_returns_valid_range() {
let device = AudioDevice::from_default().expect("from_default()");
let vol = device.get_vol().expect("get_vol()");
assert!(vol <= 100, "volume must be in 0..=100, got {vol}");
}
#[cfg(all(feature = "coreaudio", target_os = "macos"))]
#[test]
fn set_vol_roundtrip() {
let device = AudioDevice::from_default().expect("from_default()");
let original = device.get_vol().expect("get_vol()");
device.set_vol(original).expect("set_vol()");
let after = device.get_vol().expect("get_vol() after set");
assert!(
original.abs_diff(after) <= 1,
"volume changed unexpectedly: {original} -> {after}"
);
}
#[cfg(all(feature = "coreaudio", target_os = "macos"))]
#[test]
fn set_mute_roundtrip() {
let device = AudioDevice::from_default().expect("from_default()");
let original = device.is_mute().expect("is_mute()");
device.set_mute(original).expect("set_mute()");
let after = device.is_mute().expect("is_mute() after set");
assert_eq!(original, after, "mute state changed unexpectedly");
}
}