feat(macos): add CoreAudio device probing and sample rate switching

Add coreaudio_direct module using coreaudio-rs safe wrappers for:
- Device capability probing (supported sample rates, transport type)
- Automatic sample rate switching per-track
- Device lookup by name for CoreAudio↔CPAL bridging

Uses a fork branch of coreaudio-rs that adds get_available_sample_rates
and get_device_transport_type helpers (PR RustAudio/coreaudio-rs#149).
This commit is contained in:
Afonso Jorge Ramos committed 2026-03-31 02:19:59 +02:00
1 parent 0ce54c3f10
commit c51f07fe65
4 files changed
+186 -1

No files matched your search

+5
View File
@@ -39,3 +39,8 @@ dirs = "5"
[target.'cfg(target_os = "linux")'.dependencies]
# ALSA for direct hardware access (bit-perfect)
alsa = "0.9"
[target.'cfg(target_os = "macos")'.dependencies]
# CoreAudio for device probing, sample rate switching, and (future) hog mode / exclusive mode
# Using fork branch until https://github.com/RustAudio/coreaudio-rs/pull/149 is merged
coreaudio-rs = { git = "https://github.com/afonsojramos/coreaudio-rs.git", branch = "feat/device-helpers" }
+165
View File
@@ -0,0 +1,165 @@
//! CoreAudio direct access for macOS
//!
//! Provides device capability probing and sample rate switching on macOS
//! using the coreaudio-rs safe wrappers.
//!
//! Phase 1: Device probing + nominal sample rate switching (shared mode)
//! Phase 2 (future): Hog mode + integer mode + IO proc for bit-perfect playback
#[cfg(target_os = "macos")]
use coreaudio::audio_unit::macos_helpers;
#[cfg(target_os = "macos")]
use coreaudio::audio_unit::Scope;
/// CoreAudio device ID (re-exported so callers don't need objc2_core_audio)
#[cfg(target_os = "macos")]
pub type AudioDeviceID = u32;
// CoreAudio transport type constants (FourCC values from AudioHardware.h)
#[cfg(target_os = "macos")]
mod transport_types {
pub const BUILT_IN: u32 = 0x626c746e; // 'bltn'
pub const USB: u32 = 0x75736220; // 'usb '
pub const BLUETOOTH: u32 = 0x626c7565; // 'blue'
pub const BLUETOOTH_LE: u32 = 0x626c6561; // 'blea'
pub const HDMI: u32 = 0x68646d69; // 'hdmi'
pub const DISPLAY_PORT: u32 = 0x64707274; // 'dprt'
pub const THUNDERBOLT: u32 = 0x7468756e; // 'thun'
pub const FIREWIRE: u32 = 0x31333934; // '1394'
pub const VIRTUAL: u32 = 0x76697274; // 'virt'
pub const AGGREGATE: u32 = 0x67727570; // 'grup'
}
/// Common audio sample rates to check against device capabilities
#[cfg(target_os = "macos")]
const COMMON_SAMPLE_RATES: &[u32] = &[
44100, 48000, 88200, 96000, 176400, 192000, 352800, 384000, 705600, 768000,
];
/// Query supported sample rates for a CoreAudio device.
/// Returns discrete rates from the device's available nominal sample rate ranges.
#[cfg(target_os = "macos")]
pub fn query_supported_sample_rates(device_id: AudioDeviceID) -> Result<Vec<u32>, String> {
let ranges = macos_helpers::get_available_sample_rates(device_id)
.map_err(|e| format!("Failed to get sample rate ranges: {:?}", e))?;
let mut rates = Vec::new();
for range in &ranges {
if (range.mMinimum - range.mMaximum).abs() < 0.5 {
// Point value (min == max)
rates.push(range.mMinimum as u32);
} else {
// Continuous range — check which common rates fall within it
for &rate in COMMON_SAMPLE_RATES {
let rate_f = rate as f64;
if rate_f >= range.mMinimum && rate_f <= range.mMaximum {
rates.push(rate);
}
}
}
}
rates.sort_unstable();
rates.dedup();
Ok(rates)
}
/// Set the nominal sample rate of a device.
/// Delegates to coreaudio-rs which handles async confirmation with a 2-second timeout.
#[cfg(target_os = "macos")]
pub fn set_nominal_sample_rate(device_id: AudioDeviceID, target_rate: u32) -> Result<(), String> {
log::info!(
"[CoreAudio] Switching sample rate to {}Hz on device {}",
target_rate,
device_id
);
macos_helpers::set_device_sample_rate(device_id, target_rate as f64)
.map_err(|e| format!("Failed to set sample rate to {}Hz: {:?}", target_rate, e))?;
log::info!("[CoreAudio] Sample rate switched to {}Hz", target_rate);
Ok(())
}
/// Get the default output device ID.
#[cfg(target_os = "macos")]
pub fn get_default_output_device() -> Result<AudioDeviceID, String> {
macos_helpers::get_default_device_id(false)
.ok_or_else(|| "No default output device found".to_string())
}
/// Get all output device IDs.
#[cfg(target_os = "macos")]
pub fn get_output_device_ids() -> Result<Vec<AudioDeviceID>, String> {
macos_helpers::get_audio_device_ids_for_scope(Scope::Output)
.map_err(|e| format!("Failed to enumerate output devices: {:?}", e))
}
/// Get the name of a CoreAudio device.
#[cfg(target_os = "macos")]
pub fn get_device_name(device_id: AudioDeviceID) -> Result<String, String> {
macos_helpers::get_device_name(device_id)
.map_err(|e| format!("Failed to get device name: {:?}", e))
}
/// Find a CoreAudio output device ID by its name.
#[cfg(target_os = "macos")]
pub fn find_device_by_name(name: &str) -> Result<Option<AudioDeviceID>, String> {
// get_device_id_from_name: input=false means output device
Ok(macos_helpers::get_device_id_from_name(name, false))
}
/// Get the transport type of a device (USB, built-in, Bluetooth, etc.)
#[cfg(target_os = "macos")]
pub fn get_device_transport_type(device_id: AudioDeviceID) -> Option<String> {
let transport = macos_helpers::get_device_transport_type(device_id).ok()?;
let transport_str = if transport == transport_types::BUILT_IN {
"built-in"
} else if transport == transport_types::USB {
"usb"
} else if transport == transport_types::BLUETOOTH
|| transport == transport_types::BLUETOOTH_LE
{
"bluetooth"
} else if transport == transport_types::HDMI
|| transport == transport_types::DISPLAY_PORT
{
"hdmi"
} else if transport == transport_types::THUNDERBOLT {
"thunderbolt"
} else if transport == transport_types::FIREWIRE {
"firewire"
} else if transport == transport_types::VIRTUAL {
"virtual"
} else if transport == transport_types::AGGREGATE {
"aggregate"
} else {
"unknown"
};
Some(transport_str.to_string())
}
// ---- Non-macOS stubs ----
/// Query supported sample rates (stub for non-macOS)
#[cfg(not(target_os = "macos"))]
pub fn query_supported_sample_rates(_device_name: &str) -> Result<Vec<u32>, String> {
Ok(Vec::new())
}
/// Get the current nominal sample rate (stub for non-macOS)
#[cfg(not(target_os = "macos"))]
pub fn get_nominal_sample_rate_by_name(_device_name: &str) -> Result<u32, String> {
Err("CoreAudio is only available on macOS".to_string())
}
/// Set the nominal sample rate (stub for non-macOS)
#[cfg(not(target_os = "macos"))]
pub fn set_nominal_sample_rate_by_name(
_device_name: &str,
_target_rate: u32,
) -> Result<(), String> {
Err("CoreAudio is only available on macOS".to_string())
}
+1
View File
@@ -34,6 +34,7 @@ pub mod alsa_backend;
#[cfg(target_os = "linux")]
pub mod pulse_backend;
pub mod alsa_direct;
pub mod coreaudio_direct;
pub mod analysis;
pub mod analyzer_tap;
pub mod diagnostic;
+15 -1
View File
@@ -1077,6 +1077,19 @@ dependencies = [
"objc2-core-foundation",
]
[[package]]
name = "coreaudio-rs"
version = "0.14.0"
source = "git+https://github.com/afonsojramos/coreaudio-rs.git?branch=feat%2Fdevice-helpers#ce3fec9bce0b43ece78bb8801c87be9889e5710e"
dependencies = [
"bitflags 2.11.0",
"libc",
"objc2-audio-toolbox",
"objc2-core-audio",
"objc2-core-audio-types",
"objc2-core-foundation",
]
[[package]]
name = "cpal"
version = "0.17.1"
@@ -1084,7 +1097,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5b1f9c7312f19fc2fa12fd7acaf38de54e8320ba10d1a02dcbe21038def51ccb"
dependencies = [
"alsa 0.10.0",
"coreaudio-rs",
"coreaudio-rs 0.13.0",
"dasp_sample",
"jni",
"js-sys",
@@ -4496,6 +4509,7 @@ version = "0.1.0"
dependencies = [
"alsa 0.9.1",
"async-trait",
"coreaudio-rs 0.14.0",
"dirs 5.0.1",
"ebur128",
"log",