Skip to main content

volumecontrol_macos/
lib.rs

1//! macOS CoreAudio 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 `coreaudio` feature is **not** enabled every method returns
11//! [`AudioError::Unsupported`], which allows the crate to be compiled on any
12//! platform without the CoreAudio SDK.
13//!
14//! When the `coreaudio` feature **is** enabled the implementation bridges to
15//! the native macOS CoreAudio Hardware Abstraction Layer (HAL) via the
16//! `objc2_core_audio` bindings.  All unsafe interactions with CoreAudio are
17//! contained in the `internal` module.
18//!
19//! # Feature flags
20//!
21//! | Feature     | Description                                         | Requires           |
22//! |-------------|-----------------------------------------------------|--------------------|
23//! | `coreaudio` | Enable the real CoreAudio backend via `objc2-core-audio` | macOS target only |
24//!
25//! # Example
26//!
27//! ```no_run
28//! use volumecontrol_macos::AudioDevice;
29//! use volumecontrol_core::AudioDevice as _;
30//!
31//! fn main() -> Result<(), volumecontrol_core::AudioError> {
32//!     let device = AudioDevice::from_default()?;
33//!     println!("{device}");  // e.g. "MacBook Pro Speakers (73)"
34//!     println!("Current volume: {}%", device.get_vol()?);
35//!     Ok(())
36//! }
37//! ```
38
39#![deny(missing_docs)]
40
41mod internal;
42
43use std::fmt;
44
45use volumecontrol_core::{AudioDevice as AudioDeviceTrait, AudioError, DeviceInfo};
46
47/// Represents a CoreAudio audio output device (macOS).
48///
49/// # Feature flags
50///
51/// Real CoreAudio integration requires the `coreaudio` feature and must be
52/// built for a macOS target.  Without the feature every method returns
53/// [`AudioError::Unsupported`].
54#[derive(Debug)]
55pub struct AudioDevice {
56    /// CoreAudio `AudioObjectID` (serialized as a string for the public API).
57    id: String,
58    /// Human-readable device name (`kAudioObjectPropertyName`).
59    name: String,
60}
61
62impl fmt::Display for AudioDevice {
63    /// Formats the device as `"name (id)"`.
64    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
65        write!(f, "{} ({})", self.name, self.id)
66    }
67}
68
69#[cfg(feature = "coreaudio")]
70impl AudioDevice {
71    /// Constructs an [`AudioDevice`] from a raw CoreAudio `AudioObjectID`.
72    fn from_raw_id(raw_id: internal::AudioObjectID) -> Result<Self, AudioError> {
73        let name = internal::get_device_name(raw_id)?;
74        Ok(Self {
75            id: raw_id.to_string(),
76            name,
77        })
78    }
79}
80
81impl AudioDeviceTrait for AudioDevice {
82    fn from_default() -> Result<Self, AudioError> {
83        #[cfg(feature = "coreaudio")]
84        {
85            let raw_id = internal::get_default_device_id()?;
86            Self::from_raw_id(raw_id)
87        }
88        #[cfg(not(feature = "coreaudio"))]
89        Err(AudioError::Unsupported)
90    }
91
92    fn from_id(id: &str) -> Result<Self, AudioError> {
93        #[cfg(feature = "coreaudio")]
94        {
95            // The public `id` is the decimal string representation of the
96            // `AudioObjectID`.  Parse it back and verify the device exists by
97            // fetching its name.
98            let raw_id: internal::AudioObjectID =
99                id.parse().map_err(|_| AudioError::DeviceNotFound)?;
100            // Listing devices lets us confirm this ID is valid.
101            let ids = internal::list_device_ids()?;
102            if !ids.contains(&raw_id) {
103                return Err(AudioError::DeviceNotFound);
104            }
105            Self::from_raw_id(raw_id)
106        }
107        #[cfg(not(feature = "coreaudio"))]
108        {
109            let _ = id;
110            Err(AudioError::Unsupported)
111        }
112    }
113
114    fn from_name(name: &str) -> Result<Self, AudioError> {
115        #[cfg(feature = "coreaudio")]
116        {
117            // Case-insensitive substring match: returns the first device
118            // whose name contains `name`.  This gives callers flexibility
119            // (e.g. "airpods" matches "AirPods Pro").
120            let name_lower = name.to_lowercase();
121            for raw_id in internal::list_device_ids()? {
122                let device_name = internal::get_device_name(raw_id)?;
123                if device_name.to_lowercase().contains(&name_lower) {
124                    return Self::from_raw_id(raw_id);
125                }
126            }
127            Err(AudioError::DeviceNotFound)
128        }
129        #[cfg(not(feature = "coreaudio"))]
130        {
131            let _ = name;
132            Err(AudioError::Unsupported)
133        }
134    }
135
136    fn list() -> Result<Vec<DeviceInfo>, AudioError> {
137        #[cfg(feature = "coreaudio")]
138        {
139            let ids = internal::list_device_ids()?;
140            let mut devices = Vec::with_capacity(ids.len());
141            for raw_id in ids {
142                let name = internal::get_device_name(raw_id)?;
143                devices.push(DeviceInfo {
144                    id: raw_id.to_string(),
145                    name,
146                });
147            }
148            Ok(devices)
149        }
150        #[cfg(not(feature = "coreaudio"))]
151        Err(AudioError::Unsupported)
152    }
153
154    fn get_vol(&self) -> Result<u8, AudioError> {
155        #[cfg(feature = "coreaudio")]
156        {
157            let raw_id: internal::AudioObjectID =
158                self.id.parse().map_err(|_| AudioError::DeviceNotFound)?;
159            internal::get_volume(raw_id)
160        }
161        #[cfg(not(feature = "coreaudio"))]
162        Err(AudioError::Unsupported)
163    }
164
165    fn set_vol(&self, vol: u8) -> Result<(), AudioError> {
166        #[cfg(feature = "coreaudio")]
167        {
168            let raw_id: internal::AudioObjectID =
169                self.id.parse().map_err(|_| AudioError::DeviceNotFound)?;
170            internal::set_volume(raw_id, vol)
171        }
172        #[cfg(not(feature = "coreaudio"))]
173        {
174            let _ = vol;
175            Err(AudioError::Unsupported)
176        }
177    }
178
179    fn is_mute(&self) -> Result<bool, AudioError> {
180        #[cfg(feature = "coreaudio")]
181        {
182            let raw_id: internal::AudioObjectID =
183                self.id.parse().map_err(|_| AudioError::DeviceNotFound)?;
184            internal::get_mute(raw_id)
185        }
186        #[cfg(not(feature = "coreaudio"))]
187        Err(AudioError::Unsupported)
188    }
189
190    fn set_mute(&self, muted: bool) -> Result<(), AudioError> {
191        #[cfg(feature = "coreaudio")]
192        {
193            let raw_id: internal::AudioObjectID =
194                self.id.parse().map_err(|_| AudioError::DeviceNotFound)?;
195            internal::set_mute(raw_id, muted)
196        }
197        #[cfg(not(feature = "coreaudio"))]
198        {
199            let _ = muted;
200            Err(AudioError::Unsupported)
201        }
202    }
203
204    fn id(&self) -> &str {
205        &self.id
206    }
207
208    fn name(&self) -> &str {
209        &self.name
210    }
211}
212
213#[cfg(test)]
214mod tests {
215    use super::*;
216    use volumecontrol_core::AudioDevice as AudioDeviceTrait;
217
218    /// `Display` output must follow the `"name (id)"` format.
219    #[test]
220    fn display_format_is_name_paren_id() {
221        let device = AudioDevice {
222            id: "73".to_string(),
223            name: "MacBook Pro Speakers".to_string(),
224        };
225        assert_eq!(device.to_string(), "MacBook Pro Speakers (73)");
226    }
227
228    // ── stub tests (no coreaudio feature) ────────────────────────────────────
229    // These tests are only compiled and run when the `coreaudio` feature is
230    // disabled; with the feature enabled the methods do real work instead of
231    // returning `Unsupported`.
232
233    #[cfg(not(feature = "coreaudio"))]
234    #[test]
235    fn default_returns_unsupported_without_feature() {
236        let result = AudioDevice::from_default();
237        assert!(matches!(result.unwrap_err(), AudioError::Unsupported));
238    }
239
240    #[cfg(not(feature = "coreaudio"))]
241    #[test]
242    fn from_id_returns_unsupported_without_feature() {
243        let result = AudioDevice::from_id("test-id");
244        assert!(matches!(result.unwrap_err(), AudioError::Unsupported));
245    }
246
247    #[cfg(not(feature = "coreaudio"))]
248    #[test]
249    fn from_name_returns_unsupported_without_feature() {
250        let result = AudioDevice::from_name("test-name");
251        assert!(matches!(result.unwrap_err(), AudioError::Unsupported));
252    }
253
254    #[cfg(not(feature = "coreaudio"))]
255    #[test]
256    fn list_returns_unsupported_without_feature() {
257        let result = AudioDevice::list();
258        assert!(matches!(result.unwrap_err(), AudioError::Unsupported));
259    }
260
261    // ── real-world tests (coreaudio feature, macOS only) ─────────────────────
262    // These tests exercise the actual CoreAudio stack and therefore only run on
263    // macOS with a real audio hardware HAL available.
264
265    #[cfg(all(feature = "coreaudio", target_os = "macos"))]
266    #[test]
267    fn default_returns_ok() {
268        let device = AudioDevice::from_default();
269        assert!(device.is_ok(), "expected Ok, got {device:?}");
270    }
271
272    #[cfg(all(feature = "coreaudio", target_os = "macos"))]
273    #[test]
274    fn list_returns_nonempty() {
275        let devices = AudioDevice::list().expect("list()");
276        assert!(
277            !devices.is_empty(),
278            "expected at least one audio device from list()"
279        );
280        // Every entry must have a non-empty id and name.
281        for info in &devices {
282            assert!(!info.id.is_empty(), "device id must not be empty");
283            assert!(!info.name.is_empty(), "device name must not be empty");
284        }
285    }
286
287    #[cfg(all(feature = "coreaudio", target_os = "macos"))]
288    #[test]
289    fn from_id_valid_id_returns_ok() {
290        // Use the default device's id to look up via `from_id`.
291        let default_device = AudioDevice::from_default().expect("from_default()");
292        let found = AudioDevice::from_id(default_device.id());
293        assert!(found.is_ok(), "from_id with valid id should succeed");
294        assert_eq!(found.unwrap().id(), default_device.id());
295    }
296
297    #[cfg(all(feature = "coreaudio", target_os = "macos"))]
298    #[test]
299    fn from_id_invalid_id_returns_not_found() {
300        let result = AudioDevice::from_id("not-a-number");
301        assert!(
302            matches!(result.unwrap_err(), AudioError::DeviceNotFound),
303            "non-numeric id should return DeviceNotFound"
304        );
305    }
306
307    #[cfg(all(feature = "coreaudio", target_os = "macos"))]
308    #[test]
309    fn from_name_partial_match_returns_ok() {
310        // Build a partial name from the first few characters of the default
311        // device name to guarantee a match without hard-coding a device name.
312        let default_device = AudioDevice::from_default().expect("from_default()");
313        let partial: String = default_device.name().chars().take(3).collect();
314        let found = AudioDevice::from_name(&partial);
315        assert!(
316            found.is_ok(),
317            "from_name with partial match '{partial}' should succeed"
318        );
319    }
320
321    #[cfg(all(feature = "coreaudio", target_os = "macos"))]
322    #[test]
323    fn from_name_case_insensitive_match_returns_ok() {
324        // Convert the default device name to uppercase and verify it still
325        // matches — confirming that `from_name` is case-insensitive.
326        let default_device = AudioDevice::from_default().expect("from_default()");
327        let upper = default_device.name().to_uppercase();
328        let found = AudioDevice::from_name(&upper);
329        assert!(
330            found.is_ok(),
331            "from_name with uppercase query '{upper}' should succeed (case-insensitive)"
332        );
333    }
334
335    #[cfg(all(feature = "coreaudio", target_os = "macos"))]
336    #[test]
337    fn from_name_no_match_returns_not_found() {
338        let result = AudioDevice::from_name("\x00\x01\x02");
339        assert!(
340            matches!(result.unwrap_err(), AudioError::DeviceNotFound),
341            "unrecognised name should return DeviceNotFound"
342        );
343    }
344
345    #[cfg(all(feature = "coreaudio", target_os = "macos"))]
346    #[test]
347    fn get_vol_returns_valid_range() {
348        let device = AudioDevice::from_default().expect("from_default()");
349        let vol = device.get_vol().expect("get_vol()");
350        assert!(vol <= 100, "volume must be in 0..=100, got {vol}");
351    }
352
353    #[cfg(all(feature = "coreaudio", target_os = "macos"))]
354    #[test]
355    fn set_vol_roundtrip() {
356        let device = AudioDevice::from_default().expect("from_default()");
357        let original = device.get_vol().expect("get_vol()");
358        device.set_vol(original).expect("set_vol()");
359        let after = device.get_vol().expect("get_vol() after set");
360        // Allow ±1 rounding error due to f32 ↔ u8 conversion.
361        assert!(
362            original.abs_diff(after) <= 1,
363            "volume changed unexpectedly: {original} -> {after}"
364        );
365    }
366
367    #[cfg(all(feature = "coreaudio", target_os = "macos"))]
368    #[test]
369    fn set_mute_roundtrip() {
370        let device = AudioDevice::from_default().expect("from_default()");
371        let original = device.is_mute().expect("is_mute()");
372        device.set_mute(original).expect("set_mute()");
373        let after = device.is_mute().expect("is_mute() after set");
374        assert_eq!(original, after, "mute state changed unexpectedly");
375    }
376}