From 3d0d2f3c88427b7a6483d016a8a1ec82ce72d301 Mon Sep 17 00:00:00 2001 From: ken <1610057945@qq.com> Date: Thu, 14 Aug 2025 17:54:52 +0800 Subject: [PATCH 01/10] Add development environment files --- .envrc | 1 + .gitignore | 1 + Cargo.toml | 41 +- flake.lock | 58 ++ flake.nix | 30 + src/host/coreaudio/macos/device.rs | 1109 ++++++++++++++++++++++++++++ src/host/coreaudio/macos/mod.rs | 929 +++-------------------- src/host/coreaudio/mod.rs | 7 + 8 files changed, 1339 insertions(+), 837 deletions(-) create mode 100644 .envrc create mode 100644 flake.lock create mode 100644 flake.nix create mode 100644 src/host/coreaudio/macos/device.rs diff --git a/.envrc b/.envrc new file mode 100644 index 000000000..3550a30f2 --- /dev/null +++ b/.envrc @@ -0,0 +1 @@ +use flake diff --git a/.gitignore b/.gitignore index 0289afe13..50f011359 100644 --- a/.gitignore +++ b/.gitignore @@ -4,3 +4,4 @@ .DS_Store recorded.wav rls*.log +/.direnv diff --git a/Cargo.toml b/Cargo.toml index 3014e1562..52cffd9cf 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -10,7 +10,10 @@ edition = "2021" rust-version = "1.70" [features] -asio = ["asio-sys", "num-traits"] # Only available on Windows. See README for setup instructions. +asio = [ + "asio-sys", + "num-traits", +] # Only available on Windows. See README for setup instructions. # Deprecated, the `oboe` backend has been removed oboe-shared-stdcxx = [] @@ -36,8 +39,8 @@ windows = { version = "0.54.0", features = [ "Win32_System_SystemServices", "Win32_System_Variant", "Win32_Media_Multimedia", - "Win32_UI_Shell_PropertiesSystem" -]} + "Win32_UI_Shell_PropertiesSystem", +] } audio_thread_priority = { version = "0.33.0", optional = true } asio-sys = { version = "0.2", path = "asio-sys", optional = true } num-traits = { version = "0.2.6", optional = true } @@ -60,6 +63,8 @@ objc2-core-audio = { version = "0.3.1", default-features = false, features = [ "std", "AudioHardware", "AudioHardwareDeprecated", + "objc2", + "objc2-foundation", ] } objc2-audio-toolbox = { version = "0.3.1", default-features = false, features = [ "std", @@ -70,20 +75,44 @@ objc2-core-audio-types = { version = "0.3.1", default-features = false, features "std", "CoreAudioBaseTypes", ] } +objc2-core-foundation = { version = "0.3.1" } +objc2-foundation = { version = "0.3.1" } +objc2 = { version = "0.6.2" } [target.'cfg(target_os = "emscripten")'.dependencies] wasm-bindgen = { version = "0.2.89" } wasm-bindgen-futures = "0.4.33" js-sys = { version = "0.3.35" } -web-sys = { version = "0.3.35", features = [ "AudioContext", "AudioContextOptions", "AudioBuffer", "AudioBufferSourceNode", "AudioNode", "AudioDestinationNode", "Window", "AudioContextState"] } +web-sys = { version = "0.3.35", features = [ + "AudioContext", + "AudioContextOptions", + "AudioBuffer", + "AudioBufferSourceNode", + "AudioNode", + "AudioDestinationNode", + "Window", + "AudioContextState", +] } [target.'cfg(all(target_arch = "wasm32", target_os = "unknown"))'.dependencies] wasm-bindgen = { version = "0.2.58", optional = true } js-sys = { version = "0.3.35" } -web-sys = { version = "0.3.35", features = [ "AudioContext", "AudioContextOptions", "AudioBuffer", "AudioBufferSourceNode", "AudioNode", "AudioDestinationNode", "Window", "AudioContextState"] } +web-sys = { version = "0.3.35", features = [ + "AudioContext", + "AudioContextOptions", + "AudioBuffer", + "AudioBufferSourceNode", + "AudioNode", + "AudioDestinationNode", + "Window", + "AudioContextState", +] } [target.'cfg(target_os = "android")'.dependencies] -ndk = { version = "0.9", default-features = false, features = ["audio", "api-level-26"]} +ndk = { version = "0.9", default-features = false, features = [ + "audio", + "api-level-26", +] } ndk-context = "0.1" jni = "0.21" num-derive = "0.4" diff --git a/flake.lock b/flake.lock new file mode 100644 index 000000000..4ba5481e3 --- /dev/null +++ b/flake.lock @@ -0,0 +1,58 @@ +{ + "nodes": { + "flake-utils": { + "inputs": { + "systems": "systems" + }, + "locked": { + "lastModified": 1731533236, + "narHash": "sha256-l0KFg5HjrsfsO/JpG+r7fRrqm12kzFHyUHqHCVpMMbI=", + "owner": "numtide", + "repo": "flake-utils", + "rev": "11707dc2f618dd54ca8739b309ec4fc024de578b", + "type": "github" + }, + "original": { + "id": "flake-utils", + "type": "indirect" + } + }, + "nixpkgs": { + "locked": { + "lastModified": 1755082269, + "narHash": "sha256-Ix7ALeaxv9tW4uBKWeJnaKpYZtZiX4H4Q/MhEmj4XYA=", + "owner": "NixOS", + "repo": "nixpkgs", + "rev": "d74de548348c46cf25cb1fcc4b74f38103a4590d", + "type": "github" + }, + "original": { + "id": "nixpkgs", + "type": "indirect" + } + }, + "root": { + "inputs": { + "flake-utils": "flake-utils", + "nixpkgs": "nixpkgs" + } + }, + "systems": { + "locked": { + "lastModified": 1681028828, + "narHash": "sha256-Vy1rq5AaRuLzOxct8nz4T6wlgyUR7zLU309k9mBC768=", + "owner": "nix-systems", + "repo": "default", + "rev": "da67096a3b9bf56a91d16901293e51ba5b49a27e", + "type": "github" + }, + "original": { + "owner": "nix-systems", + "repo": "default", + "type": "github" + } + } + }, + "root": "root", + "version": 7 +} diff --git a/flake.nix b/flake.nix new file mode 100644 index 000000000..14353a763 --- /dev/null +++ b/flake.nix @@ -0,0 +1,30 @@ +# SPDX-License-Identifier: Unlicense +{ + inputs = { + # nixpkgs.url = "github:NixOS/nixpkgs/nixpkgs-unstable"; + # systems.url = "github:nix-systems/default"; + }; + + outputs = + { + self, + nixpkgs, + flake-utils, + }: + flake-utils.lib.eachSystem nixpkgs.lib.systems.flakeExposed ( + system: + let + pkgs = import nixpkgs { inherit system; }; + in + { + devShells.default = pkgs.mkShellNoCC { + buildInputs = with pkgs; [ + cargo + rustc + rust-analyzer + rustfmt + ]; + }; + } + ); +} diff --git a/src/host/coreaudio/macos/device.rs b/src/host/coreaudio/macos/device.rs new file mode 100644 index 000000000..5d2329f0d --- /dev/null +++ b/src/host/coreaudio/macos/device.rs @@ -0,0 +1,1109 @@ +#![allow(deprecated)] +use super::OSStatus; +use super::Stream; +use super::{asbd_from_config, check_os_status, frames_to_duration, host_time_to_stream_instant}; +use crate::host::coreaudio::macos::StreamInner; +use crate::traits::{DeviceTrait, HostTrait, StreamTrait}; +use crate::{ + BackendSpecificError, BufferSize, BuildStreamError, ChannelCount, Data, + DefaultStreamConfigError, DeviceNameError, DevicesError, InputCallbackInfo, OutputCallbackInfo, + PauseStreamError, PlayStreamError, SampleFormat, SampleRate, StreamConfig, StreamError, + SupportedBufferSize, SupportedStreamConfig, SupportedStreamConfigRange, + SupportedStreamConfigsError, +}; +use coreaudio::audio_unit::render_callback::{self, data}; +use coreaudio::audio_unit::{AudioUnit, Element, Scope}; +use coreaudio::error::audio_unit; +use objc2::rc::{autoreleasepool, Retained}; +use objc2::AnyThread; +use objc2_audio_toolbox::{ + kAudioOutputUnitProperty_CurrentDevice, kAudioOutputUnitProperty_EnableIO, + kAudioUnitProperty_StreamFormat, +}; +use objc2_core_audio::kAudioEndPointDeviceIsPrivateKey; +use objc2_core_audio::AudioDeviceCreateIOProcID; +use objc2_core_audio::AudioDeviceIOProcID; +use objc2_core_audio::AudioHardwareCreateAggregateDevice; +use objc2_core_audio::{ + kAudioAggregateDeviceIsPrivateKey, kAudioAggregateDeviceNameKey, + kAudioAggregateDeviceTapAutoStartKey, kAudioAggregateDeviceTapListKey, + kAudioAggregateDeviceUIDKey, kAudioDevicePropertyAvailableNominalSampleRates, + kAudioDevicePropertyBufferFrameSize, kAudioDevicePropertyBufferFrameSizeRange, + kAudioDevicePropertyDeviceIsAlive, kAudioDevicePropertyDeviceUID, + kAudioDevicePropertyNominalSampleRate, kAudioDevicePropertyStreamConfiguration, + kAudioDevicePropertyStreamFormat, kAudioObjectPropertyElementMain, + kAudioObjectPropertyElementMaster, kAudioObjectPropertyScopeGlobal, + kAudioObjectPropertyScopeInput, kAudioObjectPropertyScopeOutput, + kAudioSubTapDriftCompensationKey, kAudioSubTapUIDKey, AudioDeviceID, + AudioHardwareCreateProcessTap, AudioObjectGetPropertyData, AudioObjectGetPropertyDataSize, + AudioObjectID, AudioObjectPropertyAddress, AudioObjectPropertyScope, + AudioObjectSetPropertyData, CATapDescription, CATapMuteBehavior, +}; +use objc2_core_audio_types::{ + AudioBuffer, AudioBufferList, AudioStreamBasicDescription, AudioValueRange, +}; +use objc2_core_foundation::kCFTypeArrayCallBacks; +use objc2_core_foundation::kCFTypeDictionaryKeyCallBacks; +use objc2_core_foundation::kCFTypeDictionaryValueCallBacks; +use objc2_core_foundation::CFMutableDictionary; +use objc2_core_foundation::CFType; +use objc2_core_foundation::Type; +use objc2_core_foundation::{ + kCFAllocatorDefault, CFArray, CFDictionary, CFDictionaryCreate, CFRetained, CFString, + CFStringCreateWithCString, +}; +use objc2_foundation::{ns_string, NSArray, NSDictionary, NSMutableDictionary, NSNumber, NSString}; +use std::ffi::c_void; +use std::ffi::CStr; + +pub use super::enumerate::{ + default_input_device, default_output_device, Devices, SupportedInputConfigs, + SupportedOutputConfigs, +}; +use std::cell::RefCell; +use std::fmt; +use std::mem::{self, MaybeUninit}; +use std::ptr::{null, NonNull}; +use std::rc::Rc; +use std::slice; +use std::sync::mpsc::{channel, RecvTimeoutError}; +use std::sync::{Arc, Mutex}; +use std::time::{Duration, Instant}; + +use super::property_listener::AudioObjectPropertyListener; +use coreaudio::audio_unit::macos_helpers::get_device_name; +type CFStringRef = *mut std::os::raw::c_void; + +/// Attempt to set the device sample rate to the provided rate. +/// Return an error if the requested sample rate is not supported by the device. +fn set_sample_rate( + audio_device_id: AudioObjectID, + target_sample_rate: SampleRate, +) -> Result<(), BuildStreamError> { + // Get the current sample rate. + let mut property_address = AudioObjectPropertyAddress { + mSelector: kAudioDevicePropertyNominalSampleRate, + mScope: kAudioObjectPropertyScopeGlobal, + mElement: kAudioObjectPropertyElementMaster, + }; + let mut sample_rate: f64 = 0.0; + let data_size = mem::size_of::() as u32; + let status = unsafe { + AudioObjectGetPropertyData( + audio_device_id, + NonNull::from(&property_address), + 0, + null(), + NonNull::from(&data_size), + NonNull::from(&mut sample_rate).cast(), + ) + }; + coreaudio::Error::from_os_status(status)?; + + // If the requested sample rate is different to the device sample rate, update the device. + if sample_rate as u32 != target_sample_rate.0 { + // Get available sample rate ranges. + property_address.mSelector = kAudioDevicePropertyAvailableNominalSampleRates; + let data_size = 0u32; + let status = unsafe { + AudioObjectGetPropertyDataSize( + audio_device_id, + NonNull::from(&property_address), + 0, + null(), + NonNull::from(&data_size), + ) + }; + coreaudio::Error::from_os_status(status)?; + let n_ranges = data_size as usize / mem::size_of::(); + let mut ranges: Vec = vec![]; + ranges.reserve_exact(data_size as usize); + let status = unsafe { + AudioObjectGetPropertyData( + audio_device_id, + NonNull::from(&property_address), + 0, + null(), + NonNull::from(&data_size), + NonNull::new(ranges.as_mut_ptr()).unwrap().cast(), + ) + }; + coreaudio::Error::from_os_status(status)?; + let ranges: *mut AudioValueRange = ranges.as_mut_ptr() as *mut _; + let ranges: &'static [AudioValueRange] = unsafe { slice::from_raw_parts(ranges, n_ranges) }; + + // Now that we have the available ranges, pick the one matching the desired rate. + let sample_rate = target_sample_rate.0; + let maybe_index = ranges + .iter() + .position(|r| r.mMinimum as u32 == sample_rate && r.mMaximum as u32 == sample_rate); + let range_index = match maybe_index { + None => return Err(BuildStreamError::StreamConfigNotSupported), + Some(i) => i, + }; + + let (send, recv) = channel::>(); + let sample_rate_address = AudioObjectPropertyAddress { + mSelector: kAudioDevicePropertyNominalSampleRate, + mScope: kAudioObjectPropertyScopeGlobal, + mElement: kAudioObjectPropertyElementMaster, + }; + // Send sample rate updates back on a channel. + let sample_rate_handler = move || { + let mut rate: f64 = 0.0; + let data_size = mem::size_of::() as u32; + + let result = unsafe { + AudioObjectGetPropertyData( + audio_device_id, + NonNull::from(&sample_rate_address), + 0, + null(), + NonNull::from(&data_size), + NonNull::from(&mut rate).cast(), + ) + }; + send.send(coreaudio::Error::from_os_status(result).map(|_| rate)) + .ok(); + }; + + let listener = AudioObjectPropertyListener::new( + audio_device_id, + sample_rate_address, + sample_rate_handler, + )?; + + // Finally, set the sample rate. + property_address.mSelector = kAudioDevicePropertyNominalSampleRate; + let status = unsafe { + AudioObjectSetPropertyData( + audio_device_id, + NonNull::from(&property_address), + 0, + null(), + data_size, + NonNull::from(&ranges[range_index]).cast(), + ) + }; + coreaudio::Error::from_os_status(status)?; + + // Wait for the reported_rate to change. + // + // This should not take longer than a few ms, but we timeout after 1 sec just in case. + // We loop over potentially several events from the channel to ensure + // that we catch the expected change in sample rate. + let mut timeout = Duration::from_secs(1); + let start = Instant::now(); + + loop { + match recv.recv_timeout(timeout) { + Err(err) => { + let description = match err { + RecvTimeoutError::Disconnected => { + "sample rate listener channel disconnected unexpectedly" + } + RecvTimeoutError::Timeout => { + "timeout waiting for sample rate update for device" + } + } + .to_string(); + return Err(BackendSpecificError { description }.into()); + } + Ok(Ok(reported_sample_rate)) => { + if reported_sample_rate == target_sample_rate.0 as f64 { + break; + } + } + Ok(Err(_)) => { + // TODO: should we consider collecting this error? + } + }; + timeout = timeout + .checked_sub(start.elapsed()) + .unwrap_or(Duration::ZERO); + } + listener.remove()?; + } + Ok(()) +} + +fn audio_unit_from_device(device: &Device, input: bool) -> Result { + let output_type = if is_default_device(device) && !input { + coreaudio::audio_unit::IOType::DefaultOutput + } else { + coreaudio::audio_unit::IOType::HalOutput + }; + let mut audio_unit = AudioUnit::new(output_type)?; + + if input { + // Enable input processing. + let enable_input = 1u32; + audio_unit.set_property( + kAudioOutputUnitProperty_EnableIO, + Scope::Input, + Element::Input, + Some(&enable_input), + )?; + + // Disable output processing. + let disable_output = 0u32; + audio_unit.set_property( + kAudioOutputUnitProperty_EnableIO, + Scope::Output, + Element::Output, + Some(&disable_output), + )?; + } + + audio_unit.set_property( + kAudioOutputUnitProperty_CurrentDevice, + Scope::Global, + Element::Output, + Some(&device.audio_device_id), + )?; + + Ok(audio_unit) +} + +fn get_io_buffer_frame_size_range( + audio_unit: &AudioUnit, +) -> Result { + let buffer_size_range: AudioValueRange = audio_unit.get_property( + kAudioDevicePropertyBufferFrameSizeRange, + Scope::Global, + Element::Output, + )?; + + Ok(SupportedBufferSize::Range { + min: buffer_size_range.mMinimum as u32, + max: buffer_size_range.mMaximum as u32, + }) +} + +/// Register the on-disconnect callback. +/// This will both stop the stream and call the error callback with DeviceNotAvailable. +/// This function should only be called once per stream. +fn add_disconnect_listener( + stream: &Stream, + error_callback: Arc>, +) -> Result<(), BuildStreamError> +where + E: FnMut(StreamError) + Send + 'static, +{ + let stream_inner_weak = Rc::downgrade(&stream.inner); + let mut stream_inner = stream.inner.borrow_mut(); + stream_inner._disconnect_listener = Some(AudioObjectPropertyListener::new( + stream_inner.device_id, + AudioObjectPropertyAddress { + mSelector: kAudioDevicePropertyDeviceIsAlive, + mScope: kAudioObjectPropertyScopeGlobal, + mElement: kAudioObjectPropertyElementMaster, + }, + move || { + if let Some(stream_inner_strong) = stream_inner_weak.upgrade() { + match stream_inner_strong.try_borrow_mut() { + Ok(mut stream_inner) => { + let _ = stream_inner.pause(); + } + Err(_) => { + // Could not acquire mutable borrow. This can occur if there are + // overlapping borrows, if the stream is already in use, or if a panic + // occurred during a previous borrow. Still notify about device + // disconnection even if we can't pause. + } + } + (error_callback.lock().unwrap())(StreamError::DeviceNotAvailable); + } + }, + )?); + Ok(()) +} + +impl DeviceTrait for Device { + type SupportedInputConfigs = SupportedInputConfigs; + type SupportedOutputConfigs = SupportedOutputConfigs; + type Stream = Stream; + + fn name(&self) -> Result { + Device::name(self) + } + + fn supported_input_configs( + &self, + ) -> Result { + Device::supported_input_configs(self) + } + + fn supported_output_configs( + &self, + ) -> Result { + Device::supported_output_configs(self) + } + + fn default_input_config(&self) -> Result { + Device::default_input_config(self) + } + + fn default_output_config(&self) -> Result { + Device::default_output_config(self) + } + + fn build_input_stream_raw( + &self, + config: &StreamConfig, + sample_format: SampleFormat, + data_callback: D, + error_callback: E, + timeout: Option, + ) -> Result + where + D: FnMut(&Data, &InputCallbackInfo) + Send + 'static, + E: FnMut(StreamError) + Send + 'static, + { + Device::build_input_stream_raw( + self, + config, + sample_format, + data_callback, + error_callback, + timeout, + ) + } + + fn build_output_stream_raw( + &self, + config: &StreamConfig, + sample_format: SampleFormat, + data_callback: D, + error_callback: E, + timeout: Option, + ) -> Result + where + D: FnMut(&mut Data, &OutputCallbackInfo) + Send + 'static, + E: FnMut(StreamError) + Send + 'static, + { + Device::build_output_stream_raw( + self, + config, + sample_format, + data_callback, + error_callback, + timeout, + ) + } +} + +#[derive(Clone, PartialEq, Eq)] +pub struct Device { + pub(crate) audio_device_id: AudioDeviceID, +} + +pub fn is_default_device(device: &Device) -> bool { + default_input_device() + .map(|d| d.audio_device_id == device.audio_device_id) + .unwrap_or(false) + || default_output_device() + .map(|d| d.audio_device_id == device.audio_device_id) + .unwrap_or(false) +} + +impl Device { + /// Construct a new device given its ID. + /// Useful for constructing hidden devices. + pub fn new(audio_device_id: AudioDeviceID) -> Self { + Self { audio_device_id } + } + + fn name(&self) -> Result { + get_device_name(self.audio_device_id).map_err(|err| DeviceNameError::BackendSpecific { + err: BackendSpecificError { + description: err.to_string(), + }, + }) + } + + fn uid(&self) -> Result, BackendSpecificError> { + let mut cfstring: CFStringRef = std::ptr::null_mut(); + let mut size = std::mem::size_of::() as u32; + + let property = AudioObjectPropertyAddress { + mSelector: kAudioDevicePropertyDeviceUID, + mScope: kAudioObjectPropertyScopeGlobal, + mElement: kAudioObjectPropertyElementMain, + }; + + let status = unsafe { + AudioObjectGetPropertyData( + self.audio_device_id, + NonNull::from(&property), + 0, + std::ptr::null(), + NonNull::from(&mut size), + NonNull::from(&mut cfstring).cast(), + ) + }; + check_os_status(status)?; + + if cfstring.is_null() { + return Err(BackendSpecificError { + description: "Device uid is null".to_string(), + }); + } + + let ns_string: Retained = unsafe { + // unwrap cuz cfstring!=null as checked before + Retained::retain(cfstring as *mut NSString).unwrap() + }; + + Ok(ns_string) + } + + // Logic re-used between `supported_input_configs` and `supported_output_configs`. + #[allow(clippy::cast_ptr_alignment)] + fn supported_configs( + &self, + scope: AudioObjectPropertyScope, + ) -> Result { + let mut property_address = AudioObjectPropertyAddress { + mSelector: kAudioDevicePropertyStreamConfiguration, + mScope: scope, + mElement: kAudioObjectPropertyElementMaster, + }; + + unsafe { + // Retrieve the devices audio buffer list. + let data_size = 0u32; + let status = AudioObjectGetPropertyDataSize( + self.audio_device_id, + NonNull::from(&property_address), + 0, + null(), + NonNull::from(&data_size), + ); + check_os_status(status)?; + + let mut audio_buffer_list: Vec = vec![]; + audio_buffer_list.reserve_exact(data_size as usize); + let status = AudioObjectGetPropertyData( + self.audio_device_id, + NonNull::from(&property_address), + 0, + null(), + NonNull::from(&data_size), + NonNull::new(audio_buffer_list.as_mut_ptr()).unwrap().cast(), + ); + check_os_status(status)?; + + let audio_buffer_list = audio_buffer_list.as_mut_ptr() as *mut AudioBufferList; + + // If there's no buffers, skip. + if (*audio_buffer_list).mNumberBuffers == 0 { + return Ok(vec![].into_iter()); + } + + // Count the number of channels as the sum of all channels in all output buffers. + let n_buffers = (*audio_buffer_list).mNumberBuffers as usize; + let first: *const AudioBuffer = (*audio_buffer_list).mBuffers.as_ptr(); + let buffers: &'static [AudioBuffer] = slice::from_raw_parts(first, n_buffers); + let mut n_channels = 0; + for buffer in buffers { + n_channels += buffer.mNumberChannels as usize; + } + + // TODO: macOS should support U8, I16, I32, F32 and F64. This should allow for using + // I16 but just use F32 for now as it's the default anyway. + let sample_format = SampleFormat::F32; + + // Get available sample rate ranges. + // The property "kAudioDevicePropertyAvailableNominalSampleRates" returns a list of pairs of + // minimum and maximum sample rates but most of the devices returns pairs of same values though the underlying mechanism is unclear. + // This may cause issues when, for example, sorting the configs by the sample rates. + // We follows the implementation of RtAudio, which returns single element of config + // when all the pairs have the same values and returns multiple elements otherwise. + // See https://github.com/thestk/rtaudio/blob/master/RtAudio.cpp#L1369C1-L1375C39 + + property_address.mSelector = kAudioDevicePropertyAvailableNominalSampleRates; + let data_size = 0u32; + let status = AudioObjectGetPropertyDataSize( + self.audio_device_id, + NonNull::from(&property_address), + 0, + null(), + NonNull::from(&data_size), + ); + check_os_status(status)?; + + let n_ranges = data_size as usize / mem::size_of::(); + let mut ranges: Vec = vec![]; + ranges.reserve_exact(data_size as usize); + let status = AudioObjectGetPropertyData( + self.audio_device_id, + NonNull::from(&property_address), + 0, + null(), + NonNull::from(&data_size), + NonNull::new(ranges.as_mut_ptr()).unwrap().cast(), + ); + check_os_status(status)?; + + let ranges: *mut AudioValueRange = ranges.as_mut_ptr() as *mut _; + let ranges: &'static [AudioValueRange] = slice::from_raw_parts(ranges, n_ranges); + + #[allow(non_upper_case_globals)] + let input = match scope { + kAudioObjectPropertyScopeInput => Ok(true), + kAudioObjectPropertyScopeOutput => Ok(false), + _ => Err(BackendSpecificError { + description: format!("unexpected scope (neither input nor output): {scope:?}"), + }), + }?; + let audio_unit = audio_unit_from_device(self, input)?; + let buffer_size = get_io_buffer_frame_size_range(&audio_unit)?; + + // Collect the supported formats for the device. + + let contains_different_sample_rates = ranges.iter().any(|r| r.mMinimum != r.mMaximum); + if ranges.is_empty() { + Ok(vec![].into_iter()) + } else if contains_different_sample_rates { + let res = ranges.iter().map(|range| SupportedStreamConfigRange { + channels: n_channels as ChannelCount, + min_sample_rate: SampleRate(range.mMinimum as u32), + max_sample_rate: SampleRate(range.mMaximum as u32), + buffer_size, + sample_format, + }); + Ok(res.collect::>().into_iter()) + } else { + let fmt = SupportedStreamConfigRange { + channels: n_channels as ChannelCount, + min_sample_rate: SampleRate( + ranges + .iter() + .map(|v| v.mMinimum as u32) + .min() + .expect("the list must not be empty"), + ), + max_sample_rate: SampleRate( + ranges + .iter() + .map(|v| v.mMaximum as u32) + .max() + .expect("the list must not be empty"), + ), + buffer_size, + sample_format, + }; + + Ok(vec![fmt].into_iter()) + } + } + } + + fn supported_input_configs( + &self, + ) -> Result { + self.supported_configs(kAudioObjectPropertyScopeInput) + } + + fn supported_output_configs( + &self, + ) -> Result { + self.supported_configs(kAudioObjectPropertyScopeOutput) + } + + fn default_config( + &self, + scope: AudioObjectPropertyScope, + ) -> Result { + fn default_config_error_from_os_status( + status: OSStatus, + ) -> Result<(), DefaultStreamConfigError> { + let err = match coreaudio::Error::from_os_status(status) { + Err(err) => err, + Ok(_) => return Ok(()), + }; + match err { + coreaudio::Error::AudioUnit( + coreaudio::error::AudioUnitError::FormatNotSupported, + ) + | coreaudio::Error::AudioCodec(_) + | coreaudio::Error::AudioFormat(_) => { + Err(DefaultStreamConfigError::StreamTypeNotSupported) + } + coreaudio::Error::AudioUnit(coreaudio::error::AudioUnitError::NoConnection) => { + Err(DefaultStreamConfigError::DeviceNotAvailable) + } + err => { + let description = format!("{err}"); + let err = BackendSpecificError { description }; + Err(err.into()) + } + } + } + + let property_address = AudioObjectPropertyAddress { + mSelector: kAudioDevicePropertyStreamFormat, + mScope: scope, + mElement: kAudioObjectPropertyElementMaster, + }; + + unsafe { + let mut asbd: AudioStreamBasicDescription = mem::zeroed(); + let data_size = mem::size_of::() as u32; + let status = AudioObjectGetPropertyData( + self.audio_device_id, + NonNull::from(&property_address), + 0, + null(), + NonNull::from(&data_size), + NonNull::from(&mut asbd).cast(), + ); + default_config_error_from_os_status(status)?; + + let sample_format = { + let audio_format = coreaudio::audio_unit::AudioFormat::from_format_and_flag( + asbd.mFormatID, + Some(asbd.mFormatFlags), + ); + let flags = match audio_format { + Some(coreaudio::audio_unit::AudioFormat::LinearPCM(flags)) => flags, + _ => return Err(DefaultStreamConfigError::StreamTypeNotSupported), + }; + let maybe_sample_format = + coreaudio::audio_unit::SampleFormat::from_flags_and_bits_per_sample( + flags, + asbd.mBitsPerChannel, + ); + match maybe_sample_format { + Some(coreaudio::audio_unit::SampleFormat::F32) => SampleFormat::F32, + Some(coreaudio::audio_unit::SampleFormat::I16) => SampleFormat::I16, + _ => return Err(DefaultStreamConfigError::StreamTypeNotSupported), + } + }; + + #[allow(non_upper_case_globals)] + let input = match scope { + kAudioObjectPropertyScopeInput => Ok(true), + kAudioObjectPropertyScopeOutput => Ok(false), + _ => Err(BackendSpecificError { + description: format!("unexpected scope (neither input nor output): {scope:?}"), + }), + }?; + let audio_unit = audio_unit_from_device(self, input)?; + let buffer_size = get_io_buffer_frame_size_range(&audio_unit)?; + + let config = SupportedStreamConfig { + sample_rate: SampleRate(asbd.mSampleRate as _), + channels: asbd.mChannelsPerFrame as _, + buffer_size, + sample_format, + }; + Ok(config) + } + } + + fn default_input_config(&self) -> Result { + self.default_config(kAudioObjectPropertyScopeInput) + } + + fn default_output_config(&self) -> Result { + self.default_config(kAudioObjectPropertyScopeOutput) + } + + /// Check if this device supports input (recording). + fn supports_input(&self) -> bool { + // Check if the device has input channels by trying to get its input configuration + self.supported_input_configs() + .map(|mut configs| configs.next().is_some()) + .unwrap_or(false) + } +} + +impl fmt::Debug for Device { + fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { + f.debug_struct("Device") + .field("audio_device_id", &self.audio_device_id) + .field("name", &self.name()) + .finish() + } +} + +impl Device { + #[allow(clippy::cast_ptr_alignment)] + #[allow(clippy::while_immutable_condition)] + #[allow(clippy::float_cmp)] + fn build_input_stream_raw( + &self, + config: &StreamConfig, + sample_format: SampleFormat, + mut data_callback: D, + error_callback: E, + _timeout: Option, + ) -> Result + where + D: FnMut(&Data, &InputCallbackInfo) + Send + 'static, + E: FnMut(StreamError) + Send + 'static, + { + // The scope and element for working with a device's input stream. + let scope = Scope::Output; + let element = Element::Input; + + // Potentially change the device sample rate to match the config. + set_sample_rate(self.audio_device_id, config.sample_rate)?; + + let mut audio_unit = if self.supports_input() { + audio_unit_from_device(self, true)? + } else { + let loopback_aggregate = self.get_loopback_record_device()?; + audio_unit_from_device(&loopback_aggregate, true)? + }; + + // Set the stream in interleaved mode. + let asbd = asbd_from_config(config, sample_format); + audio_unit.set_property(kAudioUnitProperty_StreamFormat, scope, element, Some(&asbd))?; + + // Set the buffersize + match config.buffer_size { + BufferSize::Fixed(v) => { + let buffer_size_range = get_io_buffer_frame_size_range(&audio_unit)?; + match buffer_size_range { + SupportedBufferSize::Range { min, max } => { + if v >= min && v <= max { + audio_unit.set_property( + kAudioDevicePropertyBufferFrameSize, + scope, + element, + Some(&v), + )? + } else { + return Err(BuildStreamError::StreamConfigNotSupported); + } + } + SupportedBufferSize::Unknown => (), + } + } + BufferSize::Default => (), + } + + let error_callback = Arc::new(Mutex::new(error_callback)); + let error_callback_disconnect = error_callback.clone(); + + // Register the callback that is being called by coreaudio whenever it needs data to be + // fed to the audio buffer. + let bytes_per_channel = sample_format.sample_size(); + let sample_rate = config.sample_rate; + type Args = render_callback::Args; + audio_unit.set_input_callback(move |args: Args| unsafe { + let ptr = (*args.data.data).mBuffers.as_ptr(); + let len = (*args.data.data).mNumberBuffers as usize; + let buffers: &[AudioBuffer] = slice::from_raw_parts(ptr, len); + + // TODO: Perhaps loop over all buffers instead? + let AudioBuffer { + mNumberChannels: channels, + mDataByteSize: data_byte_size, + mData: data, + } = buffers[0]; + + let data = data as *mut (); + let len = data_byte_size as usize / bytes_per_channel; + let data = Data::from_parts(data, len, sample_format); + + // TODO: Need a better way to get delay, for now we assume a double-buffer offset. + let callback = match host_time_to_stream_instant(args.time_stamp.mHostTime) { + Err(err) => { + (error_callback.lock().unwrap())(err.into()); + return Err(()); + } + Ok(cb) => cb, + }; + let buffer_frames = len / channels as usize; + let delay = frames_to_duration(buffer_frames, sample_rate); + let capture = callback + .sub(delay) + .expect("`capture` occurs before origin of alsa `StreamInstant`"); + let timestamp = crate::InputStreamTimestamp { callback, capture }; + + let info = InputCallbackInfo { timestamp }; + data_callback(&data, &info); + Ok(()) + })?; + + let stream = Stream::new(StreamInner { + playing: true, + _disconnect_listener: None, + audio_unit, + device_id: self.audio_device_id, + }); + + // If we didn't request the default device, stop the stream if the + // device disconnects. + if !is_default_device(self) { + add_disconnect_listener(&stream, error_callback_disconnect)?; + } + + stream.inner.borrow_mut().audio_unit.start()?; + + Ok(stream) + } + + fn get_loopback_record_device(&self) -> Result { + // 1 - Create tap + + // Empty list of processes as we want to record all processes + let processes = NSArray::new(); + let device_uid = self.uid()?; + let tap_desc = unsafe { + CATapDescription::initWithProcesses_andDeviceUID_withStream( + CATapDescription::alloc(), + &*processes, + device_uid.as_ref(), + 0, + ) + }; + unsafe { + tap_desc.setMuteBehavior(CATapMuteBehavior::Unmuted); // captured audio still goes to speakers + tap_desc.setName(ns_string!("cpal output recorder")); + tap_desc.setPrivate(true); // the Aggregate Device would be private + tap_desc.setExclusive(true); // the process list means exclude them + }; + + let mut _tap_obj_id: MaybeUninit = MaybeUninit::uninit(); + let _tap_obj_id = unsafe { + AudioHardwareCreateProcessTap(Some(tap_desc.as_ref()), _tap_obj_id.as_mut_ptr()); + _tap_obj_id.assume_init() + }; + let tap_uid = unsafe { tap_desc.UUID().UUIDString() }; + + // 2 - Create aggregate device + let aggregate_deivce_properties = create_audio_aggregate_device_properties(tap_uid); + let aggregate_device_id: AudioObjectID = 0; + let status = unsafe { + AudioHardwareCreateAggregateDevice( + aggregate_deivce_properties.as_ref(), + NonNull::from(&aggregate_device_id), + ) + }; + check_os_status(status)?; + + // TODO: impl Drop for Device and destroy the aggregate device + let aggregate_device = Device::new(aggregate_device_id); + Ok(aggregate_device) + } + + fn build_output_stream_raw( + &self, + config: &StreamConfig, + sample_format: SampleFormat, + mut data_callback: D, + error_callback: E, + _timeout: Option, + ) -> Result + where + D: FnMut(&mut Data, &OutputCallbackInfo) + Send + 'static, + E: FnMut(StreamError) + Send + 'static, + { + let mut audio_unit = audio_unit_from_device(self, false)?; + + // The scope and element for working with a device's output stream. + let scope = Scope::Input; + let element = Element::Output; + + // Set the stream in interleaved mode. + let asbd = asbd_from_config(config, sample_format); + audio_unit.set_property(kAudioUnitProperty_StreamFormat, scope, element, Some(&asbd))?; + + // Set the buffersize + match config.buffer_size { + BufferSize::Fixed(v) => { + let buffer_size_range = get_io_buffer_frame_size_range(&audio_unit)?; + match buffer_size_range { + SupportedBufferSize::Range { min, max } => { + if v >= min && v <= max { + audio_unit.set_property( + kAudioDevicePropertyBufferFrameSize, + scope, + element, + Some(&v), + )? + } else { + return Err(BuildStreamError::StreamConfigNotSupported); + } + } + SupportedBufferSize::Unknown => (), + } + } + BufferSize::Default => (), + } + + let error_callback = Arc::new(Mutex::new(error_callback)); + let error_callback_disconnect = error_callback.clone(); + + // Register the callback that is being called by coreaudio whenever it needs data to be + // fed to the audio buffer. + let bytes_per_channel = sample_format.sample_size(); + let sample_rate = config.sample_rate; + type Args = render_callback::Args; + audio_unit.set_render_callback(move |args: Args| unsafe { + // If `run()` is currently running, then a callback will be available from this list. + // Otherwise, we just fill the buffer with zeroes and return. + + let AudioBuffer { + mNumberChannels: channels, + mDataByteSize: data_byte_size, + mData: data, + } = (*args.data.data).mBuffers[0]; + + let data = data as *mut (); + let len = data_byte_size as usize / bytes_per_channel; + let mut data = Data::from_parts(data, len, sample_format); + + let callback = match host_time_to_stream_instant(args.time_stamp.mHostTime) { + Err(err) => { + (error_callback.lock().unwrap())(err.into()); + return Err(()); + } + Ok(cb) => cb, + }; + // TODO: Need a better way to get delay, for now we assume a double-buffer offset. + let buffer_frames = len / channels as usize; + let delay = frames_to_duration(buffer_frames, sample_rate); + let playback = callback + .add(delay) + .expect("`playback` occurs beyond representation supported by `StreamInstant`"); + let timestamp = crate::OutputStreamTimestamp { callback, playback }; + + let info = OutputCallbackInfo { timestamp }; + data_callback(&mut data, &info); + Ok(()) + })?; + + let stream = Stream::new(StreamInner { + playing: true, + _disconnect_listener: None, + audio_unit, + device_id: self.audio_device_id, + }); + + // If we didn't request the default device, stop the stream if the + // device disconnects. + if !is_default_device(self) { + add_disconnect_listener(&stream, error_callback_disconnect)?; + } + + stream.inner.borrow_mut().audio_unit.start()?; + + Ok(stream) + } +} + +fn to_cfstring(cstr: &'static CStr) -> CFRetained { + unsafe { + CFStringCreateWithCString( + kCFAllocatorDefault, + cstr.as_ptr(), + 0x08000100, /* UTF8 */ + ) + } + .unwrap() +} + +/// Rust reimplementation of the following: +/// ```c +/// tap_uid = [[tap_description UUID] UUIDString]; +/// taps = @[ +/// @{ +/// @kAudioSubTapUIDKey : (NSString*)tap_uid, +/// @kAudioSubTapDriftCompensationKey : @YES, +/// }, +/// ]; +/// +/// aggregate_device_properties = @{ +/// @kAudioAggregateDeviceNameKey : @"MiniMetersAggregateDevice", +/// @kAudioAggregateDeviceUIDKey : +/// @"com.josephlyncheski.MiniMetersAggregateDevice", +/// @kAudioAggregateDeviceTapListKey : taps, +/// @kAudioAggregateDeviceTapAutoStartKey : @NO, +/// // If we set this to NO then I believe we need to make the Tap public as +/// // well. +/// @kAudioAggregateDeviceIsPrivateKey : @YES, +/// }; +/// ``` +pub fn create_audio_aggregate_device_properties( + tap_uid: Retained, +) -> CFRetained { + let tap_inner = unsafe { + let dict = CFMutableDictionary::new( + kCFAllocatorDefault, + 2, + &kCFTypeDictionaryKeyCallBacks, + &kCFTypeDictionaryValueCallBacks, + ) + .unwrap(); + + CFMutableDictionary::set_value( + Some(dict.as_ref()), + &*to_cfstring(kAudioSubTapUIDKey) as *const _ as *const c_void, + &*tap_uid as *const _ as *const c_void, + ); + CFMutableDictionary::set_value( + Some(dict.as_ref()), + &*to_cfstring(kAudioSubTapDriftCompensationKey) as *const _ as *const c_void, + &*NSNumber::initWithBool(NSNumber::alloc(), true) as *const _ as *const c_void, + ); + + dict + }; + let _taps_list = [tap_inner]; + let taps = unsafe { + CFArray::new( + kCFAllocatorDefault, + _taps_list.as_ptr() as *mut *const c_void, + _taps_list.len() as _, + &kCFTypeArrayCallBacks, + ) + .unwrap() + }; + let aggregate_dev_properties = unsafe { + let dict = CFMutableDictionary::new( + kCFAllocatorDefault, + 5, + &kCFTypeDictionaryKeyCallBacks, + &kCFTypeDictionaryValueCallBacks, + ) + .unwrap(); + + CFMutableDictionary::set_value( + Some(dict.as_ref()), + &*to_cfstring(kAudioAggregateDeviceNameKey) as *const _ as *const c_void, + &*CFString::from_str("Cpal loopback record aggregate device") as *const _ + as *const c_void, + ); + CFMutableDictionary::set_value( + Some(dict.as_ref()), + &*to_cfstring(kAudioAggregateDeviceUIDKey) as *const _ as *const c_void, + &*CFString::from_str("com.cpal.LoopbackRecordAggregateDevice") as *const _ + as *const c_void, + ); + CFMutableDictionary::set_value( + Some(dict.as_ref()), + &*to_cfstring(kAudioAggregateDeviceTapListKey) as *const _ as *const c_void, + &*taps as *const _ as *const c_void, + ); + CFMutableDictionary::set_value( + Some(dict.as_ref()), + &*to_cfstring(kAudioAggregateDeviceTapAutoStartKey) as *const _ as *const c_void, + &*NSNumber::initWithBool(NSNumber::alloc(), false) as *const _ as *const c_void, + ); + CFMutableDictionary::set_value( + Some(dict.as_ref()), + &*to_cfstring(kAudioEndPointDeviceIsPrivateKey) as *const _ as *const c_void, + &*NSNumber::initWithBool(NSNumber::alloc(), true) as *const _ as *const c_void, + ); + + CFRetained::cast_unchecked::(dict) + }; + + aggregate_dev_properties +} diff --git a/src/host/coreaudio/macos/mod.rs b/src/host/coreaudio/macos/mod.rs index d83734d92..816c12509 100644 --- a/src/host/coreaudio/macos/mod.rs +++ b/src/host/coreaudio/macos/mod.rs @@ -47,8 +47,11 @@ pub use self::enumerate::{ use coreaudio::audio_unit::macos_helpers::get_device_name; use property_listener::AudioObjectPropertyListener; +mod device; pub mod enumerate; mod property_listener; +use device::is_default_device; +pub use device::Device; /// Coreaudio host, the default host on macOS. #[derive(Debug)] @@ -82,375 +85,6 @@ impl HostTrait for Host { } } -impl DeviceTrait for Device { - type SupportedInputConfigs = SupportedInputConfigs; - type SupportedOutputConfigs = SupportedOutputConfigs; - type Stream = Stream; - - fn name(&self) -> Result { - Device::name(self) - } - - fn supported_input_configs( - &self, - ) -> Result { - Device::supported_input_configs(self) - } - - fn supported_output_configs( - &self, - ) -> Result { - Device::supported_output_configs(self) - } - - fn default_input_config(&self) -> Result { - Device::default_input_config(self) - } - - fn default_output_config(&self) -> Result { - Device::default_output_config(self) - } - - fn build_input_stream_raw( - &self, - config: &StreamConfig, - sample_format: SampleFormat, - data_callback: D, - error_callback: E, - timeout: Option, - ) -> Result - where - D: FnMut(&Data, &InputCallbackInfo) + Send + 'static, - E: FnMut(StreamError) + Send + 'static, - { - Device::build_input_stream_raw( - self, - config, - sample_format, - data_callback, - error_callback, - timeout, - ) - } - - fn build_output_stream_raw( - &self, - config: &StreamConfig, - sample_format: SampleFormat, - data_callback: D, - error_callback: E, - timeout: Option, - ) -> Result - where - D: FnMut(&mut Data, &OutputCallbackInfo) + Send + 'static, - E: FnMut(StreamError) + Send + 'static, - { - Device::build_output_stream_raw( - self, - config, - sample_format, - data_callback, - error_callback, - timeout, - ) - } -} - -#[derive(Clone, PartialEq, Eq)] -pub struct Device { - pub(crate) audio_device_id: AudioDeviceID, -} - -fn is_default_device(device: &Device) -> bool { - default_input_device() - .map(|d| d.audio_device_id == device.audio_device_id) - .unwrap_or(false) - || default_output_device() - .map(|d| d.audio_device_id == device.audio_device_id) - .unwrap_or(false) -} - -impl Device { - /// Construct a new device given its ID. - /// Useful for constructing hidden devices. - pub fn new(audio_device_id: AudioDeviceID) -> Self { - Self { audio_device_id } - } - - fn name(&self) -> Result { - get_device_name(self.audio_device_id).map_err(|err| DeviceNameError::BackendSpecific { - err: BackendSpecificError { - description: err.to_string(), - }, - }) - } - - // Logic re-used between `supported_input_configs` and `supported_output_configs`. - #[allow(clippy::cast_ptr_alignment)] - fn supported_configs( - &self, - scope: AudioObjectPropertyScope, - ) -> Result { - let mut property_address = AudioObjectPropertyAddress { - mSelector: kAudioDevicePropertyStreamConfiguration, - mScope: scope, - mElement: kAudioObjectPropertyElementMaster, - }; - - unsafe { - // Retrieve the devices audio buffer list. - let data_size = 0u32; - let status = AudioObjectGetPropertyDataSize( - self.audio_device_id, - NonNull::from(&property_address), - 0, - null(), - NonNull::from(&data_size), - ); - check_os_status(status)?; - - let mut audio_buffer_list: Vec = vec![]; - audio_buffer_list.reserve_exact(data_size as usize); - let status = AudioObjectGetPropertyData( - self.audio_device_id, - NonNull::from(&property_address), - 0, - null(), - NonNull::from(&data_size), - NonNull::new(audio_buffer_list.as_mut_ptr()).unwrap().cast(), - ); - check_os_status(status)?; - - let audio_buffer_list = audio_buffer_list.as_mut_ptr() as *mut AudioBufferList; - - // If there's no buffers, skip. - if (*audio_buffer_list).mNumberBuffers == 0 { - return Ok(vec![].into_iter()); - } - - // Count the number of channels as the sum of all channels in all output buffers. - let n_buffers = (*audio_buffer_list).mNumberBuffers as usize; - let first: *const AudioBuffer = (*audio_buffer_list).mBuffers.as_ptr(); - let buffers: &'static [AudioBuffer] = slice::from_raw_parts(first, n_buffers); - let mut n_channels = 0; - for buffer in buffers { - n_channels += buffer.mNumberChannels as usize; - } - - // TODO: macOS should support I8, I16, I24, I32, F32 and F64. This should allow for - // using I16 but just use F32 for now as it's the default anyway. - let sample_format = SampleFormat::F32; - - // Get available sample rate ranges. - // The property "kAudioDevicePropertyAvailableNominalSampleRates" returns a list of pairs of - // minimum and maximum sample rates but most of the devices returns pairs of same values though the underlying mechanism is unclear. - // This may cause issues when, for example, sorting the configs by the sample rates. - // We follows the implementation of RtAudio, which returns single element of config - // when all the pairs have the same values and returns multiple elements otherwise. - // See https://github.com/thestk/rtaudio/blob/master/RtAudio.cpp#L1369C1-L1375C39 - - property_address.mSelector = kAudioDevicePropertyAvailableNominalSampleRates; - let data_size = 0u32; - let status = AudioObjectGetPropertyDataSize( - self.audio_device_id, - NonNull::from(&property_address), - 0, - null(), - NonNull::from(&data_size), - ); - check_os_status(status)?; - - let n_ranges = data_size as usize / mem::size_of::(); - let mut ranges: Vec = vec![]; - ranges.reserve_exact(data_size as usize); - let status = AudioObjectGetPropertyData( - self.audio_device_id, - NonNull::from(&property_address), - 0, - null(), - NonNull::from(&data_size), - NonNull::new(ranges.as_mut_ptr()).unwrap().cast(), - ); - check_os_status(status)?; - - let ranges: *mut AudioValueRange = ranges.as_mut_ptr() as *mut _; - let ranges: &'static [AudioValueRange] = slice::from_raw_parts(ranges, n_ranges); - - #[allow(non_upper_case_globals)] - let input = match scope { - kAudioObjectPropertyScopeInput => Ok(true), - kAudioObjectPropertyScopeOutput => Ok(false), - _ => Err(BackendSpecificError { - description: format!("unexpected scope (neither input nor output): {scope:?}"), - }), - }?; - let audio_unit = audio_unit_from_device(self, input)?; - let buffer_size = get_io_buffer_frame_size_range(&audio_unit)?; - - // Collect the supported formats for the device. - - let contains_different_sample_rates = ranges.iter().any(|r| r.mMinimum != r.mMaximum); - if ranges.is_empty() { - Ok(vec![].into_iter()) - } else if contains_different_sample_rates { - let res = ranges.iter().map(|range| SupportedStreamConfigRange { - channels: n_channels as ChannelCount, - min_sample_rate: SampleRate(range.mMinimum as u32), - max_sample_rate: SampleRate(range.mMaximum as u32), - buffer_size, - sample_format, - }); - Ok(res.collect::>().into_iter()) - } else { - let fmt = SupportedStreamConfigRange { - channels: n_channels as ChannelCount, - min_sample_rate: SampleRate( - ranges - .iter() - .map(|v| v.mMinimum as u32) - .min() - .expect("the list must not be empty"), - ), - max_sample_rate: SampleRate( - ranges - .iter() - .map(|v| v.mMaximum as u32) - .max() - .expect("the list must not be empty"), - ), - buffer_size, - sample_format, - }; - - Ok(vec![fmt].into_iter()) - } - } - } - - fn supported_input_configs( - &self, - ) -> Result { - self.supported_configs(kAudioObjectPropertyScopeInput) - } - - fn supported_output_configs( - &self, - ) -> Result { - self.supported_configs(kAudioObjectPropertyScopeOutput) - } - - fn default_config( - &self, - scope: AudioObjectPropertyScope, - ) -> Result { - fn default_config_error_from_os_status( - status: OSStatus, - ) -> Result<(), DefaultStreamConfigError> { - let err = match coreaudio::Error::from_os_status(status) { - Err(err) => err, - Ok(_) => return Ok(()), - }; - match err { - coreaudio::Error::AudioUnit( - coreaudio::error::AudioUnitError::FormatNotSupported, - ) - | coreaudio::Error::AudioCodec(_) - | coreaudio::Error::AudioFormat(_) => { - Err(DefaultStreamConfigError::StreamTypeNotSupported) - } - coreaudio::Error::AudioUnit(coreaudio::error::AudioUnitError::NoConnection) => { - Err(DefaultStreamConfigError::DeviceNotAvailable) - } - err => { - let description = format!("{err}"); - let err = BackendSpecificError { description }; - Err(err.into()) - } - } - } - - let property_address = AudioObjectPropertyAddress { - mSelector: kAudioDevicePropertyStreamFormat, - mScope: scope, - mElement: kAudioObjectPropertyElementMaster, - }; - - unsafe { - let mut asbd: AudioStreamBasicDescription = mem::zeroed(); - let data_size = mem::size_of::() as u32; - let status = AudioObjectGetPropertyData( - self.audio_device_id, - NonNull::from(&property_address), - 0, - null(), - NonNull::from(&data_size), - NonNull::from(&mut asbd).cast(), - ); - default_config_error_from_os_status(status)?; - - let sample_format = { - let audio_format = coreaudio::audio_unit::AudioFormat::from_format_and_flag( - asbd.mFormatID, - Some(asbd.mFormatFlags), - ); - let flags = match audio_format { - Some(coreaudio::audio_unit::AudioFormat::LinearPCM(flags)) => flags, - _ => return Err(DefaultStreamConfigError::StreamTypeNotSupported), - }; - let maybe_sample_format = - coreaudio::audio_unit::SampleFormat::from_flags_and_bits_per_sample( - flags, - asbd.mBitsPerChannel, - ); - match maybe_sample_format { - Some(coreaudio::audio_unit::SampleFormat::F32) => SampleFormat::F32, - Some(coreaudio::audio_unit::SampleFormat::I8) => SampleFormat::I8, - Some(coreaudio::audio_unit::SampleFormat::I16) => SampleFormat::I16, - Some(coreaudio::audio_unit::SampleFormat::I24) => SampleFormat::I24, - Some(coreaudio::audio_unit::SampleFormat::I32) => SampleFormat::I32, - _ => return Err(DefaultStreamConfigError::StreamTypeNotSupported), - } - }; - - #[allow(non_upper_case_globals)] - let input = match scope { - kAudioObjectPropertyScopeInput => Ok(true), - kAudioObjectPropertyScopeOutput => Ok(false), - _ => Err(BackendSpecificError { - description: format!("unexpected scope (neither input nor output): {scope:?}"), - }), - }?; - let audio_unit = audio_unit_from_device(self, input)?; - let buffer_size = get_io_buffer_frame_size_range(&audio_unit)?; - - let config = SupportedStreamConfig { - sample_rate: SampleRate(asbd.mSampleRate as _), - channels: asbd.mChannelsPerFrame as _, - buffer_size, - sample_format, - }; - Ok(config) - } - } - - fn default_input_config(&self) -> Result { - self.default_config(kAudioObjectPropertyScopeInput) - } - - fn default_output_config(&self) -> Result { - self.default_config(kAudioObjectPropertyScopeOutput) - } -} - -impl fmt::Debug for Device { - fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { - f.debug_struct("Device") - .field("audio_device_id", &self.audio_device_id) - .field("name", &self.name()) - .finish() - } -} - struct StreamInner { playing: bool, audio_unit: AudioUnit, @@ -490,456 +124,6 @@ impl StreamInner { } } -/// Register the on-disconnect callback. -/// This will both stop the stream and call the error callback with DeviceNotAvailable. -/// This function should only be called once per stream. -fn add_disconnect_listener( - stream: &Stream, - error_callback: Arc>, -) -> Result<(), BuildStreamError> -where - E: FnMut(StreamError) + Send + 'static, -{ - let stream_inner_weak = Rc::downgrade(&stream.inner); - let mut stream_inner = stream.inner.borrow_mut(); - stream_inner._disconnect_listener = Some(AudioObjectPropertyListener::new( - stream_inner.device_id, - AudioObjectPropertyAddress { - mSelector: kAudioDevicePropertyDeviceIsAlive, - mScope: kAudioObjectPropertyScopeGlobal, - mElement: kAudioObjectPropertyElementMaster, - }, - move || { - if let Some(stream_inner_strong) = stream_inner_weak.upgrade() { - match stream_inner_strong.try_borrow_mut() { - Ok(mut stream_inner) => { - let _ = stream_inner.pause(); - } - Err(_) => { - // Could not acquire mutable borrow. This can occur if there are - // overlapping borrows, if the stream is already in use, or if a panic - // occurred during a previous borrow. Still notify about device - // disconnection even if we can't pause. - } - } - (error_callback.lock().unwrap())(StreamError::DeviceNotAvailable); - } - }, - )?); - Ok(()) -} - -fn audio_unit_from_device(device: &Device, input: bool) -> Result { - let output_type = if is_default_device(device) && !input { - coreaudio::audio_unit::IOType::DefaultOutput - } else { - coreaudio::audio_unit::IOType::HalOutput - }; - let mut audio_unit = AudioUnit::new(output_type)?; - - if input { - // Enable input processing. - let enable_input = 1u32; - audio_unit.set_property( - kAudioOutputUnitProperty_EnableIO, - Scope::Input, - Element::Input, - Some(&enable_input), - )?; - - // Disable output processing. - let disable_output = 0u32; - audio_unit.set_property( - kAudioOutputUnitProperty_EnableIO, - Scope::Output, - Element::Output, - Some(&disable_output), - )?; - } - - audio_unit.set_property( - kAudioOutputUnitProperty_CurrentDevice, - Scope::Global, - Element::Output, - Some(&device.audio_device_id), - )?; - - Ok(audio_unit) -} - -impl Device { - #[allow(clippy::cast_ptr_alignment)] - #[allow(clippy::while_immutable_condition)] - #[allow(clippy::float_cmp)] - fn build_input_stream_raw( - &self, - config: &StreamConfig, - sample_format: SampleFormat, - mut data_callback: D, - error_callback: E, - _timeout: Option, - ) -> Result - where - D: FnMut(&Data, &InputCallbackInfo) + Send + 'static, - E: FnMut(StreamError) + Send + 'static, - { - // The scope and element for working with a device's input stream. - let scope = Scope::Output; - let element = Element::Input; - - // Potentially change the device sample rate to match the config. - set_sample_rate(self.audio_device_id, config.sample_rate)?; - - let mut audio_unit = audio_unit_from_device(self, true)?; - - // Set the stream in interleaved mode. - let asbd = asbd_from_config(config, sample_format); - audio_unit.set_property(kAudioUnitProperty_StreamFormat, scope, element, Some(&asbd))?; - - // Set the buffersize - match config.buffer_size { - BufferSize::Fixed(v) => { - let buffer_size_range = get_io_buffer_frame_size_range(&audio_unit)?; - match buffer_size_range { - SupportedBufferSize::Range { min, max } => { - if v >= min && v <= max { - audio_unit.set_property( - kAudioDevicePropertyBufferFrameSize, - scope, - element, - Some(&v), - )? - } else { - return Err(BuildStreamError::StreamConfigNotSupported); - } - } - SupportedBufferSize::Unknown => (), - } - } - BufferSize::Default => (), - } - - let error_callback = Arc::new(Mutex::new(error_callback)); - let error_callback_disconnect = error_callback.clone(); - - // Register the callback that is being called by coreaudio whenever it needs data to be - // fed to the audio buffer. - let bytes_per_channel = sample_format.sample_size(); - let sample_rate = config.sample_rate; - type Args = render_callback::Args; - audio_unit.set_input_callback(move |args: Args| unsafe { - let ptr = (*args.data.data).mBuffers.as_ptr(); - let len = (*args.data.data).mNumberBuffers as usize; - let buffers: &[AudioBuffer] = slice::from_raw_parts(ptr, len); - - // TODO: Perhaps loop over all buffers instead? - let AudioBuffer { - mNumberChannels: channels, - mDataByteSize: data_byte_size, - mData: data, - } = buffers[0]; - - let data = data as *mut (); - let len = data_byte_size as usize / bytes_per_channel; - let data = Data::from_parts(data, len, sample_format); - - // TODO: Need a better way to get delay, for now we assume a double-buffer offset. - let callback = match host_time_to_stream_instant(args.time_stamp.mHostTime) { - Err(err) => { - (error_callback.lock().unwrap())(err.into()); - return Err(()); - } - Ok(cb) => cb, - }; - let buffer_frames = len / channels as usize; - let delay = frames_to_duration(buffer_frames, sample_rate); - let capture = callback - .sub(delay) - .expect("`capture` occurs before origin of alsa `StreamInstant`"); - let timestamp = crate::InputStreamTimestamp { callback, capture }; - - let info = InputCallbackInfo { timestamp }; - data_callback(&data, &info); - Ok(()) - })?; - - let stream = Stream::new(StreamInner { - playing: true, - _disconnect_listener: None, - audio_unit, - device_id: self.audio_device_id, - }); - - // If we didn't request the default device, stop the stream if the - // device disconnects. - if !is_default_device(self) { - add_disconnect_listener(&stream, error_callback_disconnect)?; - } - - stream.inner.borrow_mut().audio_unit.start()?; - - Ok(stream) - } - - fn build_output_stream_raw( - &self, - config: &StreamConfig, - sample_format: SampleFormat, - mut data_callback: D, - error_callback: E, - _timeout: Option, - ) -> Result - where - D: FnMut(&mut Data, &OutputCallbackInfo) + Send + 'static, - E: FnMut(StreamError) + Send + 'static, - { - let mut audio_unit = audio_unit_from_device(self, false)?; - - // The scope and element for working with a device's output stream. - let scope = Scope::Input; - let element = Element::Output; - - // Set the stream in interleaved mode. - let asbd = asbd_from_config(config, sample_format); - audio_unit.set_property(kAudioUnitProperty_StreamFormat, scope, element, Some(&asbd))?; - - // Set the buffersize - match config.buffer_size { - BufferSize::Fixed(v) => { - let buffer_size_range = get_io_buffer_frame_size_range(&audio_unit)?; - match buffer_size_range { - SupportedBufferSize::Range { min, max } => { - if v >= min && v <= max { - audio_unit.set_property( - kAudioDevicePropertyBufferFrameSize, - scope, - element, - Some(&v), - )? - } else { - return Err(BuildStreamError::StreamConfigNotSupported); - } - } - SupportedBufferSize::Unknown => (), - } - } - BufferSize::Default => (), - } - - let error_callback = Arc::new(Mutex::new(error_callback)); - let error_callback_disconnect = error_callback.clone(); - - // Register the callback that is being called by coreaudio whenever it needs data to be - // fed to the audio buffer. - let bytes_per_channel = sample_format.sample_size(); - let sample_rate = config.sample_rate; - type Args = render_callback::Args; - audio_unit.set_render_callback(move |args: Args| unsafe { - // If `run()` is currently running, then a callback will be available from this list. - // Otherwise, we just fill the buffer with zeroes and return. - - let AudioBuffer { - mNumberChannels: channels, - mDataByteSize: data_byte_size, - mData: data, - } = (*args.data.data).mBuffers[0]; - - let data = data as *mut (); - let len = data_byte_size as usize / bytes_per_channel; - let mut data = Data::from_parts(data, len, sample_format); - - let callback = match host_time_to_stream_instant(args.time_stamp.mHostTime) { - Err(err) => { - (error_callback.lock().unwrap())(err.into()); - return Err(()); - } - Ok(cb) => cb, - }; - // TODO: Need a better way to get delay, for now we assume a double-buffer offset. - let buffer_frames = len / channels as usize; - let delay = frames_to_duration(buffer_frames, sample_rate); - let playback = callback - .add(delay) - .expect("`playback` occurs beyond representation supported by `StreamInstant`"); - let timestamp = crate::OutputStreamTimestamp { callback, playback }; - - let info = OutputCallbackInfo { timestamp }; - data_callback(&mut data, &info); - Ok(()) - })?; - - let stream = Stream::new(StreamInner { - playing: true, - _disconnect_listener: None, - audio_unit, - device_id: self.audio_device_id, - }); - - // If we didn't request the default device, stop the stream if the - // device disconnects. - if !is_default_device(self) { - add_disconnect_listener(&stream, error_callback_disconnect)?; - } - - stream.inner.borrow_mut().audio_unit.start()?; - - Ok(stream) - } -} - -/// Attempt to set the device sample rate to the provided rate. -/// Return an error if the requested sample rate is not supported by the device. -fn set_sample_rate( - audio_device_id: AudioObjectID, - target_sample_rate: SampleRate, -) -> Result<(), BuildStreamError> { - // Get the current sample rate. - let mut property_address = AudioObjectPropertyAddress { - mSelector: kAudioDevicePropertyNominalSampleRate, - mScope: kAudioObjectPropertyScopeGlobal, - mElement: kAudioObjectPropertyElementMaster, - }; - let mut sample_rate: f64 = 0.0; - let data_size = mem::size_of::() as u32; - let status = unsafe { - AudioObjectGetPropertyData( - audio_device_id, - NonNull::from(&property_address), - 0, - null(), - NonNull::from(&data_size), - NonNull::from(&mut sample_rate).cast(), - ) - }; - coreaudio::Error::from_os_status(status)?; - - // If the requested sample rate is different to the device sample rate, update the device. - if sample_rate as u32 != target_sample_rate.0 { - // Get available sample rate ranges. - property_address.mSelector = kAudioDevicePropertyAvailableNominalSampleRates; - let data_size = 0u32; - let status = unsafe { - AudioObjectGetPropertyDataSize( - audio_device_id, - NonNull::from(&property_address), - 0, - null(), - NonNull::from(&data_size), - ) - }; - coreaudio::Error::from_os_status(status)?; - let n_ranges = data_size as usize / mem::size_of::(); - let mut ranges: Vec = vec![]; - ranges.reserve_exact(data_size as usize); - let status = unsafe { - AudioObjectGetPropertyData( - audio_device_id, - NonNull::from(&property_address), - 0, - null(), - NonNull::from(&data_size), - NonNull::new(ranges.as_mut_ptr()).unwrap().cast(), - ) - }; - coreaudio::Error::from_os_status(status)?; - let ranges: *mut AudioValueRange = ranges.as_mut_ptr() as *mut _; - let ranges: &'static [AudioValueRange] = unsafe { slice::from_raw_parts(ranges, n_ranges) }; - - // Now that we have the available ranges, pick the one matching the desired rate. - let sample_rate = target_sample_rate.0; - let maybe_index = ranges - .iter() - .position(|r| r.mMinimum as u32 == sample_rate && r.mMaximum as u32 == sample_rate); - let range_index = match maybe_index { - None => return Err(BuildStreamError::StreamConfigNotSupported), - Some(i) => i, - }; - - let (send, recv) = channel::>(); - let sample_rate_address = AudioObjectPropertyAddress { - mSelector: kAudioDevicePropertyNominalSampleRate, - mScope: kAudioObjectPropertyScopeGlobal, - mElement: kAudioObjectPropertyElementMaster, - }; - // Send sample rate updates back on a channel. - let sample_rate_handler = move || { - let mut rate: f64 = 0.0; - let data_size = mem::size_of::() as u32; - - let result = unsafe { - AudioObjectGetPropertyData( - audio_device_id, - NonNull::from(&sample_rate_address), - 0, - null(), - NonNull::from(&data_size), - NonNull::from(&mut rate).cast(), - ) - }; - send.send(coreaudio::Error::from_os_status(result).map(|_| rate)) - .ok(); - }; - - let listener = AudioObjectPropertyListener::new( - audio_device_id, - sample_rate_address, - sample_rate_handler, - )?; - - // Finally, set the sample rate. - property_address.mSelector = kAudioDevicePropertyNominalSampleRate; - let status = unsafe { - AudioObjectSetPropertyData( - audio_device_id, - NonNull::from(&property_address), - 0, - null(), - data_size, - NonNull::from(&ranges[range_index]).cast(), - ) - }; - coreaudio::Error::from_os_status(status)?; - - // Wait for the reported_rate to change. - // - // This should not take longer than a few ms, but we timeout after 1 sec just in case. - // We loop over potentially several events from the channel to ensure - // that we catch the expected change in sample rate. - let mut timeout = Duration::from_secs(1); - let start = Instant::now(); - - loop { - match recv.recv_timeout(timeout) { - Err(err) => { - let description = match err { - RecvTimeoutError::Disconnected => { - "sample rate listener channel disconnected unexpectedly" - } - RecvTimeoutError::Timeout => { - "timeout waiting for sample rate update for device" - } - } - .to_string(); - return Err(BackendSpecificError { description }.into()); - } - Ok(Ok(reported_sample_rate)) => { - if reported_sample_rate == target_sample_rate.0 as f64 { - break; - } - } - Ok(Err(_)) => { - // TODO: should we consider collecting this error? - } - }; - timeout = timeout - .checked_sub(start.elapsed()) - .unwrap_or(Duration::ZERO); - } - listener.remove()?; - } - Ok(()) -} - #[derive(Clone)] pub struct Stream { inner: Rc>, @@ -967,17 +151,100 @@ impl StreamTrait for Stream { } } -fn get_io_buffer_frame_size_range( - audio_unit: &AudioUnit, -) -> Result { - let buffer_size_range: AudioValueRange = audio_unit.get_property( - kAudioDevicePropertyBufferFrameSizeRange, - Scope::Global, - Element::Output, - )?; +#[cfg(test)] +mod test { + use crate::{ + default_host, + traits::{DeviceTrait, HostTrait, StreamTrait}, + Sample, + }; + + #[test] + fn test_play() { + let host = default_host(); + let device = host.default_output_device().unwrap(); + + let mut supported_configs_range = device.supported_output_configs().unwrap(); + let supported_config = supported_configs_range + .next() + .unwrap() + .with_max_sample_rate(); + let config = supported_config.config(); + + let stream = device + .build_output_stream( + &config, + write_silence::, + move |err| println!("Error: {err}"), + None, // None=blocking, Some(Duration)=timeout + ) + .unwrap(); + stream.play().unwrap(); + std::thread::sleep(std::time::Duration::from_secs(1)); + } + + #[test] + fn test_record() { + let host = default_host(); + let device = host.default_input_device().unwrap(); + println!("Device: {:?}", device.name()); - Ok(SupportedBufferSize::Range { - min: buffer_size_range.mMinimum as u32, - max: buffer_size_range.mMaximum as u32, - }) + let mut supported_configs_range = device.supported_input_configs().unwrap(); + println!("Supported configs:"); + for config in supported_configs_range.clone() { + println!("{:?}", config) + } + let supported_config = supported_configs_range + .next() + .unwrap() + .with_max_sample_rate(); + let config = supported_config.config(); + + let stream = device + .build_input_stream( + &config, + move |data: &[f32], _: &crate::InputCallbackInfo| { + // react to stream events and read or write stream data here. + println!("Got data"); + }, + move |err| println!("Error: {err}"), + None, // None=blocking, Some(Duration)=timeout + ) + .unwrap(); + stream.play().unwrap(); + std::thread::sleep(std::time::Duration::from_secs(1)); + } + + #[test] + fn test_record_output() { + let host = default_host(); + let device = host.default_output_device().unwrap(); + + let mut supported_configs_range = device.supported_output_configs().unwrap(); + let supported_config = supported_configs_range + .next() + .unwrap() + .with_max_sample_rate(); + let config = supported_config.config(); + + let stream = device + .build_input_stream( + &config, + move |data: &[f32], _: &crate::InputCallbackInfo| { + // react to stream events and read or write stream data here. + println!("Got data"); + }, + move |err| println!("Error: {err}"), + None, // None=blocking, Some(Duration)=timeout + ) + .unwrap(); + stream.play().unwrap(); + std::thread::sleep(std::time::Duration::from_secs(1)); + } + + fn write_silence(data: &mut [T], _: &crate::OutputCallbackInfo) { + for sample in data.iter_mut() { + *sample = Sample::EQUILIBRIUM; + } + } } diff --git a/src/host/coreaudio/mod.rs b/src/host/coreaudio/mod.rs index 4310b6940..3c9511d34 100644 --- a/src/host/coreaudio/mod.rs +++ b/src/host/coreaudio/mod.rs @@ -1,3 +1,10 @@ +use std::mem::{self, MaybeUninit}; +use std::ptr::{null, NonNull}; + +use objc2_core_audio::{ + kAudioHardwareNoError, AudioDeviceID, AudioObjectGetPropertyData, + AudioObjectGetPropertyDataSize, AudioObjectID, AudioObjectPropertyAddress, +}; use objc2_core_audio_types::{ kAudioFormatFlagIsFloat, kAudioFormatFlagIsPacked, kAudioFormatFlagIsSignedInteger, kAudioFormatLinearPCM, AudioStreamBasicDescription, From 7b9e74326b15ee916556201ea5d1e7d0e1d42b86 Mon Sep 17 00:00:00 2001 From: ken <1610057945@qq.com> Date: Sat, 16 Aug 2025 21:14:37 +0800 Subject: [PATCH 02/10] It works now - resolved conflicts --- examples/record_wav.rs | 32 ++-- src/host/coreaudio/macos/device.rs | 243 +++----------------------- src/host/coreaudio/macos/loopback.rs | 244 +++++++++++++++++++++++++++ src/host/coreaudio/macos/mod.rs | 10 +- 4 files changed, 296 insertions(+), 233 deletions(-) create mode 100644 src/host/coreaudio/macos/loopback.rs diff --git a/examples/record_wav.rs b/examples/record_wav.rs index b10f76216..ec7283774 100644 --- a/examples/record_wav.rs +++ b/examples/record_wav.rs @@ -12,10 +12,16 @@ use std::sync::{Arc, Mutex}; #[derive(Parser, Debug)] #[command(version, about = "CPAL record_wav example", long_about = None)] struct Opt { - /// The audio device to use + /// The audio device to use. + /// For the default microphone, use "default". + /// For recording system output, use "default-output". #[arg(short, long, default_value_t = String::from("default"))] device: String, + /// How long to record, in seconds + #[arg(long, default_value_t = 3)] + duration: u64, + /// Use the JACK host #[cfg(all( any( @@ -69,20 +75,24 @@ fn main() -> Result<(), anyhow::Error> { let host = cpal::default_host(); // Set up the input device and stream with the default input config. - let device = if opt.device == "default" { - host.default_input_device() - } else { - host.input_devices()? - .find(|x| x.name().map(|y| y == opt.device).unwrap_or(false)) + let device = match opt.device.as_str() { + "default" => host.default_input_device(), + "default-output" => host.default_output_device(), + name => host + .input_devices()? + .find(|x| x.name().map(|y| y == name).unwrap_or(false)), } .expect("failed to find input device"); println!("Input device: {}", device.name()?); - let config = device - .default_input_config() - .expect("Failed to get default input config"); - println!("Default input config: {config:?}"); + let config = if device.supports_input() { + device.default_input_config() + } else { + device.default_output_config() + } + .expect("Failed to get default input/output config"); + println!("Default input/output config: {config:?}"); // The WAV file we're recording to. const PATH: &str = concat!(env!("CARGO_MANIFEST_DIR"), "/recorded.wav"); @@ -135,7 +145,7 @@ fn main() -> Result<(), anyhow::Error> { stream.play()?; // Let recording go for roughly three seconds. - std::thread::sleep(std::time::Duration::from_secs(3)); + std::thread::sleep(std::time::Duration::from_secs(opt.duration)); drop(stream); writer.lock().unwrap().take().unwrap().finalize()?; println!("Recording {PATH} complete!"); diff --git a/src/host/coreaudio/macos/device.rs b/src/host/coreaudio/macos/device.rs index 5d2329f0d..5f0999dde 100644 --- a/src/host/coreaudio/macos/device.rs +++ b/src/host/coreaudio/macos/device.rs @@ -2,42 +2,33 @@ use super::OSStatus; use super::Stream; use super::{asbd_from_config, check_os_status, frames_to_duration, host_time_to_stream_instant}; +use crate::host::coreaudio::macos::loopback::LoopbackDevice; use crate::host::coreaudio::macos::StreamInner; -use crate::traits::{DeviceTrait, HostTrait, StreamTrait}; +use crate::traits::DeviceTrait; use crate::{ BackendSpecificError, BufferSize, BuildStreamError, ChannelCount, Data, - DefaultStreamConfigError, DeviceNameError, DevicesError, InputCallbackInfo, OutputCallbackInfo, - PauseStreamError, PlayStreamError, SampleFormat, SampleRate, StreamConfig, StreamError, - SupportedBufferSize, SupportedStreamConfig, SupportedStreamConfigRange, - SupportedStreamConfigsError, + DefaultStreamConfigError, DeviceNameError, InputCallbackInfo, OutputCallbackInfo, SampleFormat, + SampleRate, StreamConfig, StreamError, SupportedBufferSize, SupportedStreamConfig, + SupportedStreamConfigRange, SupportedStreamConfigsError, }; use coreaudio::audio_unit::render_callback::{self, data}; use coreaudio::audio_unit::{AudioUnit, Element, Scope}; -use coreaudio::error::audio_unit; -use objc2::rc::{autoreleasepool, Retained}; -use objc2::AnyThread; +use objc2::rc::Retained; use objc2_audio_toolbox::{ kAudioOutputUnitProperty_CurrentDevice, kAudioOutputUnitProperty_EnableIO, kAudioUnitProperty_StreamFormat, }; -use objc2_core_audio::kAudioEndPointDeviceIsPrivateKey; -use objc2_core_audio::AudioDeviceCreateIOProcID; -use objc2_core_audio::AudioDeviceIOProcID; -use objc2_core_audio::AudioHardwareCreateAggregateDevice; use objc2_core_audio::{ - kAudioAggregateDeviceIsPrivateKey, kAudioAggregateDeviceNameKey, - kAudioAggregateDeviceTapAutoStartKey, kAudioAggregateDeviceTapListKey, - kAudioAggregateDeviceUIDKey, kAudioDevicePropertyAvailableNominalSampleRates, - kAudioDevicePropertyBufferFrameSize, kAudioDevicePropertyBufferFrameSizeRange, - kAudioDevicePropertyDeviceIsAlive, kAudioDevicePropertyDeviceUID, - kAudioDevicePropertyNominalSampleRate, kAudioDevicePropertyStreamConfiguration, - kAudioDevicePropertyStreamFormat, kAudioObjectPropertyElementMain, - kAudioObjectPropertyElementMaster, kAudioObjectPropertyScopeGlobal, - kAudioObjectPropertyScopeInput, kAudioObjectPropertyScopeOutput, - kAudioSubTapDriftCompensationKey, kAudioSubTapUIDKey, AudioDeviceID, - AudioHardwareCreateProcessTap, AudioObjectGetPropertyData, AudioObjectGetPropertyDataSize, - AudioObjectID, AudioObjectPropertyAddress, AudioObjectPropertyScope, - AudioObjectSetPropertyData, CATapDescription, CATapMuteBehavior, + kAudioDevicePropertyAvailableNominalSampleRates, kAudioDevicePropertyBufferFrameSize, + kAudioDevicePropertyBufferFrameSizeRange, kAudioDevicePropertyDeviceIsAlive, + kAudioDevicePropertyDeviceUID, kAudioDevicePropertyNominalSampleRate, + kAudioDevicePropertyStreamConfiguration, kAudioDevicePropertyStreamFormat, + kAudioObjectPropertyElementMain, kAudioObjectPropertyElementMaster, + kAudioObjectPropertyScopeGlobal, kAudioObjectPropertyScopeInput, + kAudioObjectPropertyScopeOutput, AudioDeviceID, AudioHardwareCreateProcessTap, + AudioObjectGetPropertyData, AudioObjectGetPropertyDataSize, AudioObjectID, + AudioObjectPropertyAddress, AudioObjectPropertyScope, AudioObjectSetPropertyData, + CATapDescription, CATapMuteBehavior, }; use objc2_core_audio_types::{ AudioBuffer, AudioBufferList, AudioStreamBasicDescription, AudioValueRange, @@ -72,8 +63,6 @@ use std::time::{Duration, Instant}; use super::property_listener::AudioObjectPropertyListener; use coreaudio::audio_unit::macos_helpers::get_device_name; -type CFStringRef = *mut std::os::raw::c_void; - /// Attempt to set the device sample rate to the provided rate. /// Return an error if the requested sample rate is not supported by the device. fn set_sample_rate( @@ -393,7 +382,7 @@ impl DeviceTrait for Device { } } -#[derive(Clone, PartialEq, Eq)] +#[derive(Clone, Eq, PartialEq)] pub struct Device { pub(crate) audio_device_id: AudioDeviceID, } @@ -422,42 +411,6 @@ impl Device { }) } - fn uid(&self) -> Result, BackendSpecificError> { - let mut cfstring: CFStringRef = std::ptr::null_mut(); - let mut size = std::mem::size_of::() as u32; - - let property = AudioObjectPropertyAddress { - mSelector: kAudioDevicePropertyDeviceUID, - mScope: kAudioObjectPropertyScopeGlobal, - mElement: kAudioObjectPropertyElementMain, - }; - - let status = unsafe { - AudioObjectGetPropertyData( - self.audio_device_id, - NonNull::from(&property), - 0, - std::ptr::null(), - NonNull::from(&mut size), - NonNull::from(&mut cfstring).cast(), - ) - }; - check_os_status(status)?; - - if cfstring.is_null() { - return Err(BackendSpecificError { - description: "Device uid is null".to_string(), - }); - } - - let ns_string: Retained = unsafe { - // unwrap cuz cfstring!=null as checked before - Retained::retain(cfstring as *mut NSString).unwrap() - }; - - Ok(ns_string) - } - // Logic re-used between `supported_input_configs` and `supported_output_configs`. #[allow(clippy::cast_ptr_alignment)] fn supported_configs( @@ -752,11 +705,13 @@ impl Device { // Potentially change the device sample rate to match the config. set_sample_rate(self.audio_device_id, config.sample_rate)?; + let mut loopback_aggregate: Option = None; let mut audio_unit = if self.supports_input() { audio_unit_from_device(self, true)? } else { - let loopback_aggregate = self.get_loopback_record_device()?; - audio_unit_from_device(&loopback_aggregate, true)? + loopback_aggregate.replace(self.create_loopback_record_device()?); + // audio_unit_from_device(&loopback_aggregate.clone().unwrap().aggregate_device, true)? + audio_unit_from_device(&loopback_aggregate.as_ref().unwrap().aggregate_device, true)? }; // Set the stream in interleaved mode. @@ -835,6 +790,7 @@ impl Device { _disconnect_listener: None, audio_unit, device_id: self.audio_device_id, + _loopback_device: loopback_aggregate, }); // If we didn't request the default device, stop the stream if the @@ -848,50 +804,6 @@ impl Device { Ok(stream) } - fn get_loopback_record_device(&self) -> Result { - // 1 - Create tap - - // Empty list of processes as we want to record all processes - let processes = NSArray::new(); - let device_uid = self.uid()?; - let tap_desc = unsafe { - CATapDescription::initWithProcesses_andDeviceUID_withStream( - CATapDescription::alloc(), - &*processes, - device_uid.as_ref(), - 0, - ) - }; - unsafe { - tap_desc.setMuteBehavior(CATapMuteBehavior::Unmuted); // captured audio still goes to speakers - tap_desc.setName(ns_string!("cpal output recorder")); - tap_desc.setPrivate(true); // the Aggregate Device would be private - tap_desc.setExclusive(true); // the process list means exclude them - }; - - let mut _tap_obj_id: MaybeUninit = MaybeUninit::uninit(); - let _tap_obj_id = unsafe { - AudioHardwareCreateProcessTap(Some(tap_desc.as_ref()), _tap_obj_id.as_mut_ptr()); - _tap_obj_id.assume_init() - }; - let tap_uid = unsafe { tap_desc.UUID().UUIDString() }; - - // 2 - Create aggregate device - let aggregate_deivce_properties = create_audio_aggregate_device_properties(tap_uid); - let aggregate_device_id: AudioObjectID = 0; - let status = unsafe { - AudioHardwareCreateAggregateDevice( - aggregate_deivce_properties.as_ref(), - NonNull::from(&aggregate_device_id), - ) - }; - check_os_status(status)?; - - // TODO: impl Drop for Device and destroy the aggregate device - let aggregate_device = Device::new(aggregate_device_id); - Ok(aggregate_device) - } - fn build_output_stream_raw( &self, config: &StreamConfig, @@ -984,6 +896,7 @@ impl Device { _disconnect_listener: None, audio_unit, device_id: self.audio_device_id, + _loopback_device: None, }); // If we didn't request the default device, stop the stream if the @@ -997,113 +910,3 @@ impl Device { Ok(stream) } } - -fn to_cfstring(cstr: &'static CStr) -> CFRetained { - unsafe { - CFStringCreateWithCString( - kCFAllocatorDefault, - cstr.as_ptr(), - 0x08000100, /* UTF8 */ - ) - } - .unwrap() -} - -/// Rust reimplementation of the following: -/// ```c -/// tap_uid = [[tap_description UUID] UUIDString]; -/// taps = @[ -/// @{ -/// @kAudioSubTapUIDKey : (NSString*)tap_uid, -/// @kAudioSubTapDriftCompensationKey : @YES, -/// }, -/// ]; -/// -/// aggregate_device_properties = @{ -/// @kAudioAggregateDeviceNameKey : @"MiniMetersAggregateDevice", -/// @kAudioAggregateDeviceUIDKey : -/// @"com.josephlyncheski.MiniMetersAggregateDevice", -/// @kAudioAggregateDeviceTapListKey : taps, -/// @kAudioAggregateDeviceTapAutoStartKey : @NO, -/// // If we set this to NO then I believe we need to make the Tap public as -/// // well. -/// @kAudioAggregateDeviceIsPrivateKey : @YES, -/// }; -/// ``` -pub fn create_audio_aggregate_device_properties( - tap_uid: Retained, -) -> CFRetained { - let tap_inner = unsafe { - let dict = CFMutableDictionary::new( - kCFAllocatorDefault, - 2, - &kCFTypeDictionaryKeyCallBacks, - &kCFTypeDictionaryValueCallBacks, - ) - .unwrap(); - - CFMutableDictionary::set_value( - Some(dict.as_ref()), - &*to_cfstring(kAudioSubTapUIDKey) as *const _ as *const c_void, - &*tap_uid as *const _ as *const c_void, - ); - CFMutableDictionary::set_value( - Some(dict.as_ref()), - &*to_cfstring(kAudioSubTapDriftCompensationKey) as *const _ as *const c_void, - &*NSNumber::initWithBool(NSNumber::alloc(), true) as *const _ as *const c_void, - ); - - dict - }; - let _taps_list = [tap_inner]; - let taps = unsafe { - CFArray::new( - kCFAllocatorDefault, - _taps_list.as_ptr() as *mut *const c_void, - _taps_list.len() as _, - &kCFTypeArrayCallBacks, - ) - .unwrap() - }; - let aggregate_dev_properties = unsafe { - let dict = CFMutableDictionary::new( - kCFAllocatorDefault, - 5, - &kCFTypeDictionaryKeyCallBacks, - &kCFTypeDictionaryValueCallBacks, - ) - .unwrap(); - - CFMutableDictionary::set_value( - Some(dict.as_ref()), - &*to_cfstring(kAudioAggregateDeviceNameKey) as *const _ as *const c_void, - &*CFString::from_str("Cpal loopback record aggregate device") as *const _ - as *const c_void, - ); - CFMutableDictionary::set_value( - Some(dict.as_ref()), - &*to_cfstring(kAudioAggregateDeviceUIDKey) as *const _ as *const c_void, - &*CFString::from_str("com.cpal.LoopbackRecordAggregateDevice") as *const _ - as *const c_void, - ); - CFMutableDictionary::set_value( - Some(dict.as_ref()), - &*to_cfstring(kAudioAggregateDeviceTapListKey) as *const _ as *const c_void, - &*taps as *const _ as *const c_void, - ); - CFMutableDictionary::set_value( - Some(dict.as_ref()), - &*to_cfstring(kAudioAggregateDeviceTapAutoStartKey) as *const _ as *const c_void, - &*NSNumber::initWithBool(NSNumber::alloc(), false) as *const _ as *const c_void, - ); - CFMutableDictionary::set_value( - Some(dict.as_ref()), - &*to_cfstring(kAudioEndPointDeviceIsPrivateKey) as *const _ as *const c_void, - &*NSNumber::initWithBool(NSNumber::alloc(), true) as *const _ as *const c_void, - ); - - CFRetained::cast_unchecked::(dict) - }; - - aggregate_dev_properties -} diff --git a/src/host/coreaudio/macos/loopback.rs b/src/host/coreaudio/macos/loopback.rs new file mode 100644 index 000000000..c827783b1 --- /dev/null +++ b/src/host/coreaudio/macos/loopback.rs @@ -0,0 +1,244 @@ +//! Manages loopback recording (recording system audio output) + +use super::device::Device; +use crate::{host::coreaudio::check_os_status, BackendSpecificError, BuildStreamError}; +use objc2::{rc::Retained, AnyThread}; +use objc2_core_audio::{ + kAudioAggregateDeviceNameKey, kAudioAggregateDeviceTapAutoStartKey, + kAudioAggregateDeviceTapListKey, kAudioAggregateDeviceUIDKey, kAudioDevicePropertyDeviceUID, + kAudioEndPointDeviceIsPrivateKey, kAudioObjectPropertyElementMain, + kAudioObjectPropertyScopeGlobal, kAudioSubTapDriftCompensationKey, kAudioSubTapUIDKey, + AudioHardwareCreateAggregateDevice, AudioHardwareCreateProcessTap, + AudioHardwareDestroyAggregateDevice, AudioHardwareDestroyProcessTap, + AudioObjectGetPropertyData, AudioObjectID, AudioObjectPropertyAddress, CATapDescription, + CATapMuteBehavior, +}; +use objc2_core_foundation::{ + kCFAllocatorDefault, kCFTypeArrayCallBacks, kCFTypeDictionaryKeyCallBacks, + kCFTypeDictionaryValueCallBacks, CFArray, CFDictionary, CFMutableDictionary, CFRetained, + CFString, CFStringCreateWithCString, +}; +use objc2_foundation::{ns_string, NSArray, NSNumber, NSString}; +use std::{ + ffi::{c_void, CStr}, + mem::MaybeUninit, + ptr::NonNull, +}; +type CFStringRef = *mut std::os::raw::c_void; + +impl Device { + pub fn create_loopback_record_device(&self) -> Result { + // 1 - Create tap + + // Empty list of processes as we want to record all processes + let processes = NSArray::new(); + let device_uid = self.uid()?; + let tap_desc = unsafe { + CATapDescription::initWithProcesses_andDeviceUID_withStream( + CATapDescription::alloc(), + &*processes, + device_uid.as_ref(), + 0, + ) + }; + unsafe { + tap_desc.setMuteBehavior(CATapMuteBehavior::Unmuted); // captured audio still goes to speakers + tap_desc.setName(ns_string!("cpal output recorder")); + tap_desc.setPrivate(true); // the Aggregate Device would be private + tap_desc.setExclusive(true); // the process list means exclude them + }; + + let mut tap_obj_id: MaybeUninit = MaybeUninit::uninit(); + let tap_obj_id = unsafe { + AudioHardwareCreateProcessTap(Some(tap_desc.as_ref()), tap_obj_id.as_mut_ptr()); + tap_obj_id.assume_init() + }; + let tap_uid = unsafe { tap_desc.UUID().UUIDString() }; + + // 2 - Create aggregate device + let aggregate_deivce_properties = create_audio_aggregate_device_properties(tap_uid); + let aggregate_device_id: AudioObjectID = 0; + let status = unsafe { + AudioHardwareCreateAggregateDevice( + aggregate_deivce_properties.as_ref(), + NonNull::from(&aggregate_device_id), + ) + }; + check_os_status(status)?; + + Ok(LoopbackDevice { + tap_id: tap_obj_id, + aggregate_device: Device::new(aggregate_device_id), + }) + } + + fn uid(&self) -> Result, BackendSpecificError> { + let mut cfstring: CFStringRef = std::ptr::null_mut(); + let mut size = std::mem::size_of::() as u32; + + let property = AudioObjectPropertyAddress { + mSelector: kAudioDevicePropertyDeviceUID, + mScope: kAudioObjectPropertyScopeGlobal, + mElement: kAudioObjectPropertyElementMain, + }; + + let status = unsafe { + AudioObjectGetPropertyData( + self.audio_device_id, + NonNull::from(&property), + 0, + std::ptr::null(), + NonNull::from(&mut size), + NonNull::from(&mut cfstring).cast(), + ) + }; + check_os_status(status)?; + + if cfstring.is_null() { + return Err(BackendSpecificError { + description: "Device uid is null".to_string(), + }); + } + + let ns_string: Retained = unsafe { + // unwrap cuz cfstring!=null as checked before + Retained::retain(cfstring as *mut NSString).unwrap() + }; + + Ok(ns_string) + } +} + +fn to_cfstring(cstr: &'static CStr) -> CFRetained { + unsafe { + CFStringCreateWithCString( + kCFAllocatorDefault, + cstr.as_ptr(), + 0x08000100, /* UTF8 */ + ) + } + .unwrap() +} + +/// Rust reimplementation of the following: +/// ```c +/// tap_uid = [[tap_description UUID] UUIDString]; +/// taps = @[ +/// @{ +/// @kAudioSubTapUIDKey : (NSString*)tap_uid, +/// @kAudioSubTapDriftCompensationKey : @YES, +/// }, +/// ]; +/// +/// aggregate_device_properties = @{ +/// @kAudioAggregateDeviceNameKey : @"MiniMetersAggregateDevice", +/// @kAudioAggregateDeviceUIDKey : +/// @"com.josephlyncheski.MiniMetersAggregateDevice", +/// @kAudioAggregateDeviceTapListKey : taps, +/// @kAudioAggregateDeviceTapAutoStartKey : @NO, +/// // If we set this to NO then I believe we need to make the Tap public as +/// // well. +/// @kAudioAggregateDeviceIsPrivateKey : @YES, +/// }; +/// ``` +pub fn create_audio_aggregate_device_properties( + tap_uid: Retained, +) -> CFRetained { + let tap_inner = unsafe { + let dict = CFMutableDictionary::new( + kCFAllocatorDefault, + 2, + &kCFTypeDictionaryKeyCallBacks, + &kCFTypeDictionaryValueCallBacks, + ) + .unwrap(); + + CFMutableDictionary::set_value( + Some(dict.as_ref()), + &*to_cfstring(kAudioSubTapUIDKey) as *const _ as *const c_void, + &*tap_uid as *const _ as *const c_void, + ); + CFMutableDictionary::set_value( + Some(dict.as_ref()), + &*to_cfstring(kAudioSubTapDriftCompensationKey) as *const _ as *const c_void, + &*NSNumber::initWithBool(NSNumber::alloc(), true) as *const _ as *const c_void, + ); + + dict + }; + let _taps_list = [tap_inner]; + let taps = unsafe { + CFArray::new( + kCFAllocatorDefault, + _taps_list.as_ptr() as *mut *const c_void, + _taps_list.len() as _, + &kCFTypeArrayCallBacks, + ) + .unwrap() + }; + let aggregate_dev_properties = unsafe { + let dict = CFMutableDictionary::new( + kCFAllocatorDefault, + 5, + &kCFTypeDictionaryKeyCallBacks, + &kCFTypeDictionaryValueCallBacks, + ) + .unwrap(); + + CFMutableDictionary::set_value( + Some(dict.as_ref()), + &*to_cfstring(kAudioAggregateDeviceNameKey) as *const _ as *const c_void, + &*CFString::from_str("Cpal loopback record aggregate device") as *const _ + as *const c_void, + ); + CFMutableDictionary::set_value( + Some(dict.as_ref()), + &*to_cfstring(kAudioAggregateDeviceUIDKey) as *const _ as *const c_void, + &*CFString::from_str("com.cpal.LoopbackRecordAggregateDevice") as *const _ + as *const c_void, + ); + CFMutableDictionary::set_value( + Some(dict.as_ref()), + &*to_cfstring(kAudioAggregateDeviceTapListKey) as *const _ as *const c_void, + &*taps as *const _ as *const c_void, + ); + CFMutableDictionary::set_value( + Some(dict.as_ref()), + &*to_cfstring(kAudioAggregateDeviceTapAutoStartKey) as *const _ as *const c_void, + &*NSNumber::initWithBool(NSNumber::alloc(), false) as *const _ as *const c_void, + ); + CFMutableDictionary::set_value( + Some(dict.as_ref()), + &*to_cfstring(kAudioEndPointDeviceIsPrivateKey) as *const _ as *const c_void, + &*NSNumber::initWithBool(NSNumber::alloc(), true) as *const _ as *const c_void, + ); + + CFRetained::cast_unchecked::(dict) + }; + + aggregate_dev_properties +} + +/// An aggregate device with tap for recording system output. +/// +/// Its main difference with [`Device`] is that it's destroyed when dropped. +/// +/// It also doesn't implement the [`DeviceTrait`] as users shouldn't be using it. Its +/// main purpose is to destroy the created aggregate device when loopback recording +/// is done. +#[derive(Clone, PartialEq, Eq)] +pub struct LoopbackDevice { + pub tap_id: AudioObjectID, + pub aggregate_device: Device, +} + +impl Drop for LoopbackDevice { + fn drop(&mut self) { + unsafe { + let status = AudioHardwareDestroyAggregateDevice(self.aggregate_device.audio_device_id); + check_os_status(status).unwrap(); + let status = AudioHardwareDestroyProcessTap(self.tap_id); + check_os_status(status).unwrap(); + } + } +} diff --git a/src/host/coreaudio/macos/mod.rs b/src/host/coreaudio/macos/mod.rs index 816c12509..57d81aa41 100644 --- a/src/host/coreaudio/macos/mod.rs +++ b/src/host/coreaudio/macos/mod.rs @@ -2,6 +2,7 @@ use super::{asbd_from_config, check_os_status, frames_to_duration, host_time_to_stream_instant}; use super::OSStatus; +use crate::host::coreaudio::macos::loopback::LoopbackDevice; use crate::traits::{DeviceTrait, HostTrait, StreamTrait}; use crate::{ BackendSpecificError, BufferSize, BuildStreamError, ChannelCount, Data, @@ -49,6 +50,7 @@ use property_listener::AudioObjectPropertyListener; mod device; pub mod enumerate; +mod loopback; mod property_listener; use device::is_default_device; pub use device::Device; @@ -96,6 +98,9 @@ struct StreamInner { // a stream associated with the device. #[allow(dead_code)] device_id: AudioDeviceID, + /// Manage the lifetime of the aggregate device used + /// for loopback recording + _loopback_device: Option, } impl StreamInner { @@ -205,7 +210,7 @@ mod test { &config, move |data: &[f32], _: &crate::InputCallbackInfo| { // react to stream events and read or write stream data here. - println!("Got data"); + println!("Got data: {:?}", &data[..25]); }, move |err| println!("Error: {err}"), None, // None=blocking, Some(Duration)=timeout @@ -227,12 +232,13 @@ mod test { .with_max_sample_rate(); let config = supported_config.config(); + println!("Building input stream"); let stream = device .build_input_stream( &config, move |data: &[f32], _: &crate::InputCallbackInfo| { // react to stream events and read or write stream data here. - println!("Got data"); + println!("Got data: {:?}", &data[..25]); }, move |err| println!("Error: {err}"), None, // None=blocking, Some(Duration)=timeout From 86d7ec61f3fdec9964326b2ed5f4bc0cd993066f Mon Sep 17 00:00:00 2001 From: ken <1610057945@qq.com> Date: Sat, 16 Aug 2025 21:18:43 +0800 Subject: [PATCH 03/10] Remove nix env files --- .envrc | 1 - flake.lock | 58 ------------------------------------------------------ flake.nix | 30 ---------------------------- 3 files changed, 89 deletions(-) delete mode 100644 .envrc delete mode 100644 flake.lock delete mode 100644 flake.nix diff --git a/.envrc b/.envrc deleted file mode 100644 index 3550a30f2..000000000 --- a/.envrc +++ /dev/null @@ -1 +0,0 @@ -use flake diff --git a/flake.lock b/flake.lock deleted file mode 100644 index 4ba5481e3..000000000 --- a/flake.lock +++ /dev/null @@ -1,58 +0,0 @@ -{ - "nodes": { - "flake-utils": { - "inputs": { - "systems": "systems" - }, - "locked": { - "lastModified": 1731533236, - "narHash": "sha256-l0KFg5HjrsfsO/JpG+r7fRrqm12kzFHyUHqHCVpMMbI=", - "owner": "numtide", - "repo": "flake-utils", - "rev": "11707dc2f618dd54ca8739b309ec4fc024de578b", - "type": "github" - }, - "original": { - "id": "flake-utils", - "type": "indirect" - } - }, - "nixpkgs": { - "locked": { - "lastModified": 1755082269, - "narHash": "sha256-Ix7ALeaxv9tW4uBKWeJnaKpYZtZiX4H4Q/MhEmj4XYA=", - "owner": "NixOS", - "repo": "nixpkgs", - "rev": "d74de548348c46cf25cb1fcc4b74f38103a4590d", - "type": "github" - }, - "original": { - "id": "nixpkgs", - "type": "indirect" - } - }, - "root": { - "inputs": { - "flake-utils": "flake-utils", - "nixpkgs": "nixpkgs" - } - }, - "systems": { - "locked": { - "lastModified": 1681028828, - "narHash": "sha256-Vy1rq5AaRuLzOxct8nz4T6wlgyUR7zLU309k9mBC768=", - "owner": "nix-systems", - "repo": "default", - "rev": "da67096a3b9bf56a91d16901293e51ba5b49a27e", - "type": "github" - }, - "original": { - "owner": "nix-systems", - "repo": "default", - "type": "github" - } - } - }, - "root": "root", - "version": 7 -} diff --git a/flake.nix b/flake.nix deleted file mode 100644 index 14353a763..000000000 --- a/flake.nix +++ /dev/null @@ -1,30 +0,0 @@ -# SPDX-License-Identifier: Unlicense -{ - inputs = { - # nixpkgs.url = "github:NixOS/nixpkgs/nixpkgs-unstable"; - # systems.url = "github:nix-systems/default"; - }; - - outputs = - { - self, - nixpkgs, - flake-utils, - }: - flake-utils.lib.eachSystem nixpkgs.lib.systems.flakeExposed ( - system: - let - pkgs = import nixpkgs { inherit system; }; - in - { - devShells.default = pkgs.mkShellNoCC { - buildInputs = with pkgs; [ - cargo - rustc - rust-analyzer - rustfmt - ]; - }; - } - ); -} From 064f0d10708ccb3cafd5232c853a5a63fdcd4918 Mon Sep 17 00:00:00 2001 From: ken <1610057945@qq.com> Date: Sat, 16 Aug 2025 21:33:25 +0800 Subject: [PATCH 04/10] Resolve warnings --- src/host/coreaudio/macos/device.rs | 32 +++++---------------- src/host/coreaudio/macos/mod.rs | 45 ++++-------------------------- src/host/coreaudio/mod.rs | 7 ----- 3 files changed, 12 insertions(+), 72 deletions(-) diff --git a/src/host/coreaudio/macos/device.rs b/src/host/coreaudio/macos/device.rs index 5f0999dde..060ebdbec 100644 --- a/src/host/coreaudio/macos/device.rs +++ b/src/host/coreaudio/macos/device.rs @@ -13,7 +13,6 @@ use crate::{ }; use coreaudio::audio_unit::render_callback::{self, data}; use coreaudio::audio_unit::{AudioUnit, Element, Scope}; -use objc2::rc::Retained; use objc2_audio_toolbox::{ kAudioOutputUnitProperty_CurrentDevice, kAudioOutputUnitProperty_EnableIO, kAudioUnitProperty_StreamFormat, @@ -21,39 +20,22 @@ use objc2_audio_toolbox::{ use objc2_core_audio::{ kAudioDevicePropertyAvailableNominalSampleRates, kAudioDevicePropertyBufferFrameSize, kAudioDevicePropertyBufferFrameSizeRange, kAudioDevicePropertyDeviceIsAlive, - kAudioDevicePropertyDeviceUID, kAudioDevicePropertyNominalSampleRate, - kAudioDevicePropertyStreamConfiguration, kAudioDevicePropertyStreamFormat, - kAudioObjectPropertyElementMain, kAudioObjectPropertyElementMaster, + kAudioDevicePropertyNominalSampleRate, kAudioDevicePropertyStreamConfiguration, + kAudioDevicePropertyStreamFormat, kAudioObjectPropertyElementMaster, kAudioObjectPropertyScopeGlobal, kAudioObjectPropertyScopeInput, - kAudioObjectPropertyScopeOutput, AudioDeviceID, AudioHardwareCreateProcessTap, - AudioObjectGetPropertyData, AudioObjectGetPropertyDataSize, AudioObjectID, - AudioObjectPropertyAddress, AudioObjectPropertyScope, AudioObjectSetPropertyData, - CATapDescription, CATapMuteBehavior, + kAudioObjectPropertyScopeOutput, AudioDeviceID, AudioObjectGetPropertyData, + AudioObjectGetPropertyDataSize, AudioObjectID, AudioObjectPropertyAddress, + AudioObjectPropertyScope, AudioObjectSetPropertyData, }; use objc2_core_audio_types::{ AudioBuffer, AudioBufferList, AudioStreamBasicDescription, AudioValueRange, }; -use objc2_core_foundation::kCFTypeArrayCallBacks; -use objc2_core_foundation::kCFTypeDictionaryKeyCallBacks; -use objc2_core_foundation::kCFTypeDictionaryValueCallBacks; -use objc2_core_foundation::CFMutableDictionary; -use objc2_core_foundation::CFType; -use objc2_core_foundation::Type; -use objc2_core_foundation::{ - kCFAllocatorDefault, CFArray, CFDictionary, CFDictionaryCreate, CFRetained, CFString, - CFStringCreateWithCString, -}; -use objc2_foundation::{ns_string, NSArray, NSDictionary, NSMutableDictionary, NSNumber, NSString}; -use std::ffi::c_void; -use std::ffi::CStr; pub use super::enumerate::{ - default_input_device, default_output_device, Devices, SupportedInputConfigs, - SupportedOutputConfigs, + default_input_device, default_output_device, SupportedInputConfigs, SupportedOutputConfigs, }; -use std::cell::RefCell; use std::fmt; -use std::mem::{self, MaybeUninit}; +use std::mem::{self}; use std::ptr::{null, NonNull}; use std::rc::Rc; use std::slice; diff --git a/src/host/coreaudio/macos/mod.rs b/src/host/coreaudio/macos/mod.rs index 57d81aa41..329add419 100644 --- a/src/host/coreaudio/macos/mod.rs +++ b/src/host/coreaudio/macos/mod.rs @@ -3,56 +3,21 @@ use super::{asbd_from_config, check_os_status, frames_to_duration, host_time_to_ use super::OSStatus; use crate::host::coreaudio::macos::loopback::LoopbackDevice; -use crate::traits::{DeviceTrait, HostTrait, StreamTrait}; -use crate::{ - BackendSpecificError, BufferSize, BuildStreamError, ChannelCount, Data, - DefaultStreamConfigError, DeviceNameError, DevicesError, InputCallbackInfo, OutputCallbackInfo, - PauseStreamError, PlayStreamError, SampleFormat, SampleRate, StreamConfig, StreamError, - SupportedBufferSize, SupportedStreamConfig, SupportedStreamConfigRange, - SupportedStreamConfigsError, -}; -use coreaudio::audio_unit::render_callback::{self, data}; -use coreaudio::audio_unit::{AudioUnit, Element, Scope}; -use objc2_audio_toolbox::{ - kAudioOutputUnitProperty_CurrentDevice, kAudioOutputUnitProperty_EnableIO, - kAudioUnitProperty_StreamFormat, -}; -use objc2_core_audio::{ - kAudioDevicePropertyAvailableNominalSampleRates, kAudioDevicePropertyBufferFrameSize, - kAudioDevicePropertyBufferFrameSizeRange, kAudioDevicePropertyDeviceIsAlive, - kAudioDevicePropertyNominalSampleRate, kAudioDevicePropertyStreamConfiguration, - kAudioDevicePropertyStreamFormat, kAudioObjectPropertyElementMaster, - kAudioObjectPropertyScopeGlobal, kAudioObjectPropertyScopeInput, - kAudioObjectPropertyScopeOutput, AudioDeviceID, AudioObjectGetPropertyData, - AudioObjectGetPropertyDataSize, AudioObjectID, AudioObjectPropertyAddress, - AudioObjectPropertyScope, AudioObjectSetPropertyData, -}; -use objc2_core_audio_types::{ - AudioBuffer, AudioBufferList, AudioStreamBasicDescription, AudioValueRange, -}; +use crate::traits::{HostTrait, StreamTrait}; +use crate::{BackendSpecificError, DevicesError, PauseStreamError, PlayStreamError}; +use coreaudio::audio_unit::AudioUnit; +use objc2_core_audio::AudioDeviceID; use std::cell::RefCell; -use std::fmt; -use std::mem; -use std::ptr::{null, NonNull}; use std::rc::Rc; -use std::slice; -use std::sync::mpsc::{channel, RecvTimeoutError}; -use std::sync::{Arc, Mutex}; -use std::time::{Duration, Instant}; -pub use self::enumerate::{ - default_input_device, default_output_device, Devices, SupportedInputConfigs, - SupportedOutputConfigs, -}; +pub use self::enumerate::{default_input_device, default_output_device, Devices}; -use coreaudio::audio_unit::macos_helpers::get_device_name; use property_listener::AudioObjectPropertyListener; mod device; pub mod enumerate; mod loopback; mod property_listener; -use device::is_default_device; pub use device::Device; /// Coreaudio host, the default host on macOS. diff --git a/src/host/coreaudio/mod.rs b/src/host/coreaudio/mod.rs index 3c9511d34..4310b6940 100644 --- a/src/host/coreaudio/mod.rs +++ b/src/host/coreaudio/mod.rs @@ -1,10 +1,3 @@ -use std::mem::{self, MaybeUninit}; -use std::ptr::{null, NonNull}; - -use objc2_core_audio::{ - kAudioHardwareNoError, AudioDeviceID, AudioObjectGetPropertyData, - AudioObjectGetPropertyDataSize, AudioObjectID, AudioObjectPropertyAddress, -}; use objc2_core_audio_types::{ kAudioFormatFlagIsFloat, kAudioFormatFlagIsPacked, kAudioFormatFlagIsSignedInteger, kAudioFormatLinearPCM, AudioStreamBasicDescription, From b74bb9ee8934751bd029d47c9f37c1554512def9 Mon Sep 17 00:00:00 2001 From: ken <1610057945@qq.com> Date: Mon, 18 Aug 2025 19:13:20 +0800 Subject: [PATCH 05/10] Apply some copilot suggestions --- src/host/coreaudio/macos/device.rs | 2 -- src/host/coreaudio/macos/loopback.rs | 8 ++++---- 2 files changed, 4 insertions(+), 6 deletions(-) diff --git a/src/host/coreaudio/macos/device.rs b/src/host/coreaudio/macos/device.rs index 060ebdbec..09a9eaab3 100644 --- a/src/host/coreaudio/macos/device.rs +++ b/src/host/coreaudio/macos/device.rs @@ -1,4 +1,3 @@ -#![allow(deprecated)] use super::OSStatus; use super::Stream; use super::{asbd_from_config, check_os_status, frames_to_duration, host_time_to_stream_instant}; @@ -692,7 +691,6 @@ impl Device { audio_unit_from_device(self, true)? } else { loopback_aggregate.replace(self.create_loopback_record_device()?); - // audio_unit_from_device(&loopback_aggregate.clone().unwrap().aggregate_device, true)? audio_unit_from_device(&loopback_aggregate.as_ref().unwrap().aggregate_device, true)? }; diff --git a/src/host/coreaudio/macos/loopback.rs b/src/host/coreaudio/macos/loopback.rs index c827783b1..8743881bd 100644 --- a/src/host/coreaudio/macos/loopback.rs +++ b/src/host/coreaudio/macos/loopback.rs @@ -56,11 +56,11 @@ impl Device { let tap_uid = unsafe { tap_desc.UUID().UUIDString() }; // 2 - Create aggregate device - let aggregate_deivce_properties = create_audio_aggregate_device_properties(tap_uid); + let aggregate_device_properties = create_audio_aggregate_device_properties(tap_uid); let aggregate_device_id: AudioObjectID = 0; let status = unsafe { AudioHardwareCreateAggregateDevice( - aggregate_deivce_properties.as_ref(), + aggregate_device_properties.as_ref(), NonNull::from(&aggregate_device_id), ) }; @@ -101,7 +101,7 @@ impl Device { } let ns_string: Retained = unsafe { - // unwrap cuz cfstring!=null as checked before + // unwrap cause cfstring!=null as checked before Retained::retain(cfstring as *mut NSString).unwrap() }; @@ -226,7 +226,7 @@ pub fn create_audio_aggregate_device_properties( /// It also doesn't implement the [`DeviceTrait`] as users shouldn't be using it. Its /// main purpose is to destroy the created aggregate device when loopback recording /// is done. -#[derive(Clone, PartialEq, Eq)] +#[derive(PartialEq, Eq)] pub struct LoopbackDevice { pub tap_id: AudioObjectID, pub aggregate_device: Device, From 6b1db5a53bc6e6491013e6c40f786757bc82e8da Mon Sep 17 00:00:00 2001 From: ken <1610057945@qq.com> Date: Mon, 18 Aug 2025 19:23:10 +0800 Subject: [PATCH 06/10] Move some logic from device.rs to loopback.rs --- src/host/coreaudio/macos/device.rs | 2 +- src/host/coreaudio/macos/loopback.rs | 128 ++++++++++++++------------- 2 files changed, 68 insertions(+), 62 deletions(-) diff --git a/src/host/coreaudio/macos/device.rs b/src/host/coreaudio/macos/device.rs index 09a9eaab3..5dad9ba8d 100644 --- a/src/host/coreaudio/macos/device.rs +++ b/src/host/coreaudio/macos/device.rs @@ -690,7 +690,7 @@ impl Device { let mut audio_unit = if self.supports_input() { audio_unit_from_device(self, true)? } else { - loopback_aggregate.replace(self.create_loopback_record_device()?); + loopback_aggregate.replace(LoopbackDevice::from_device(self)?); audio_unit_from_device(&loopback_aggregate.as_ref().unwrap().aggregate_device, true)? }; diff --git a/src/host/coreaudio/macos/loopback.rs b/src/host/coreaudio/macos/loopback.rs index 8743881bd..b17a7f13b 100644 --- a/src/host/coreaudio/macos/loopback.rs +++ b/src/host/coreaudio/macos/loopback.rs @@ -1,7 +1,9 @@ //! Manages loopback recording (recording system audio output) use super::device::Device; -use crate::{host::coreaudio::check_os_status, BackendSpecificError, BuildStreamError}; +use crate::{ + host::coreaudio::check_os_status, traits::DeviceTrait, BackendSpecificError, BuildStreamError, +}; use objc2::{rc::Retained, AnyThread}; use objc2_core_audio::{ kAudioAggregateDeviceNameKey, kAudioAggregateDeviceTapAutoStartKey, @@ -27,12 +29,65 @@ use std::{ type CFStringRef = *mut std::os::raw::c_void; impl Device { - pub fn create_loopback_record_device(&self) -> Result { + fn uid(&self) -> Result, BackendSpecificError> { + let mut cfstring: CFStringRef = std::ptr::null_mut(); + let mut size = std::mem::size_of::() as u32; + + let property = AudioObjectPropertyAddress { + mSelector: kAudioDevicePropertyDeviceUID, + mScope: kAudioObjectPropertyScopeGlobal, + mElement: kAudioObjectPropertyElementMain, + }; + + let status = unsafe { + AudioObjectGetPropertyData( + self.audio_device_id, + NonNull::from(&property), + 0, + std::ptr::null(), + NonNull::from(&mut size), + NonNull::from(&mut cfstring).cast(), + ) + }; + check_os_status(status)?; + + if cfstring.is_null() { + return Err(BackendSpecificError { + description: "Device uid is null".to_string(), + }); + } + + let ns_string: Retained = unsafe { + // unwrap cause cfstring!=null as checked before + Retained::retain(cfstring as *mut NSString).unwrap() + }; + + Ok(ns_string) + } +} + +/// An aggregate device with tap for recording system output. +/// +/// Its main difference with [`Device`] is that it's destroyed when dropped. +/// +/// It also doesn't implement the [`DeviceTrait`] as users shouldn't be using it. Its +/// main purpose is to destroy the created aggregate device when loopback recording +/// is done. +#[derive(PartialEq, Eq)] +pub struct LoopbackDevice { + pub tap_id: AudioObjectID, + pub aggregate_device: Device, +} + +impl LoopbackDevice { + /// Create a [`LoopbackDevice`] that records the sound + /// output of `device`. + pub fn from_device(device: &Device) -> Result { // 1 - Create tap // Empty list of processes as we want to record all processes let processes = NSArray::new(); - let device_uid = self.uid()?; + let device_uid = device.uid()?; let tap_desc = unsafe { CATapDescription::initWithProcesses_andDeviceUID_withStream( CATapDescription::alloc(), @@ -66,46 +121,21 @@ impl Device { }; check_os_status(status)?; - Ok(LoopbackDevice { + Ok(Self { tap_id: tap_obj_id, aggregate_device: Device::new(aggregate_device_id), }) } +} - fn uid(&self) -> Result, BackendSpecificError> { - let mut cfstring: CFStringRef = std::ptr::null_mut(); - let mut size = std::mem::size_of::() as u32; - - let property = AudioObjectPropertyAddress { - mSelector: kAudioDevicePropertyDeviceUID, - mScope: kAudioObjectPropertyScopeGlobal, - mElement: kAudioObjectPropertyElementMain, - }; - - let status = unsafe { - AudioObjectGetPropertyData( - self.audio_device_id, - NonNull::from(&property), - 0, - std::ptr::null(), - NonNull::from(&mut size), - NonNull::from(&mut cfstring).cast(), - ) - }; - check_os_status(status)?; - - if cfstring.is_null() { - return Err(BackendSpecificError { - description: "Device uid is null".to_string(), - }); +impl Drop for LoopbackDevice { + fn drop(&mut self) { + unsafe { + let status = AudioHardwareDestroyAggregateDevice(self.aggregate_device.audio_device_id); + check_os_status(status).unwrap(); + let status = AudioHardwareDestroyProcessTap(self.tap_id); + check_os_status(status).unwrap(); } - - let ns_string: Retained = unsafe { - // unwrap cause cfstring!=null as checked before - Retained::retain(cfstring as *mut NSString).unwrap() - }; - - Ok(ns_string) } } @@ -218,27 +248,3 @@ pub fn create_audio_aggregate_device_properties( aggregate_dev_properties } - -/// An aggregate device with tap for recording system output. -/// -/// Its main difference with [`Device`] is that it's destroyed when dropped. -/// -/// It also doesn't implement the [`DeviceTrait`] as users shouldn't be using it. Its -/// main purpose is to destroy the created aggregate device when loopback recording -/// is done. -#[derive(PartialEq, Eq)] -pub struct LoopbackDevice { - pub tap_id: AudioObjectID, - pub aggregate_device: Device, -} - -impl Drop for LoopbackDevice { - fn drop(&mut self) { - unsafe { - let status = AudioHardwareDestroyAggregateDevice(self.aggregate_device.audio_device_id); - check_os_status(status).unwrap(); - let status = AudioHardwareDestroyProcessTap(self.tap_id); - check_os_status(status).unwrap(); - } - } -} From c161aa87bbe62ac4cfe8a6c9de4aac880b51c5f0 Mon Sep 17 00:00:00 2001 From: ken <1610057945@qq.com> Date: Mon, 1 Sep 2025 16:02:55 +0800 Subject: [PATCH 07/10] Skip loopback recording test on CI --- src/host/coreaudio/macos/mod.rs | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/src/host/coreaudio/macos/mod.rs b/src/host/coreaudio/macos/mod.rs index 329add419..e29d05768 100644 --- a/src/host/coreaudio/macos/mod.rs +++ b/src/host/coreaudio/macos/mod.rs @@ -187,6 +187,11 @@ mod test { #[test] fn test_record_output() { + if std::env::var("CI").is_ok() { + println!("Skipping test_record_output in CI environment due to permissions"); + return; + } + let host = default_host(); let device = host.default_output_device().unwrap(); From a314d4d0c57c3dc7ffa82630a3a49c9a78fe9f1b Mon Sep 17 00:00:00 2001 From: ken <1610057945@qq.com> Date: Fri, 5 Sep 2025 08:56:27 +0800 Subject: [PATCH 08/10] Prevent panic in drop --- src/host/coreaudio/macos/loopback.rs | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/src/host/coreaudio/macos/loopback.rs b/src/host/coreaudio/macos/loopback.rs index b17a7f13b..34f6ca6ca 100644 --- a/src/host/coreaudio/macos/loopback.rs +++ b/src/host/coreaudio/macos/loopback.rs @@ -131,10 +131,10 @@ impl LoopbackDevice { impl Drop for LoopbackDevice { fn drop(&mut self) { unsafe { - let status = AudioHardwareDestroyAggregateDevice(self.aggregate_device.audio_device_id); - check_os_status(status).unwrap(); - let status = AudioHardwareDestroyProcessTap(self.tap_id); - check_os_status(status).unwrap(); + // We don't check status to avoid panic during `drop` + let _status = + AudioHardwareDestroyAggregateDevice(self.aggregate_device.audio_device_id); + let _status = AudioHardwareDestroyProcessTap(self.tap_id); } } } From 3c121d79bfcfde8df863674679ca48ba3dc692ec Mon Sep 17 00:00:00 2001 From: ken <1610057945@qq.com> Date: Tue, 16 Sep 2025 11:51:40 +0800 Subject: [PATCH 09/10] Remove unnecessary 'static --- src/host/coreaudio/macos/device.rs | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/host/coreaudio/macos/device.rs b/src/host/coreaudio/macos/device.rs index 5dad9ba8d..177f82914 100644 --- a/src/host/coreaudio/macos/device.rs +++ b/src/host/coreaudio/macos/device.rs @@ -100,7 +100,7 @@ fn set_sample_rate( }; coreaudio::Error::from_os_status(status)?; let ranges: *mut AudioValueRange = ranges.as_mut_ptr() as *mut _; - let ranges: &'static [AudioValueRange] = unsafe { slice::from_raw_parts(ranges, n_ranges) }; + let ranges: &[AudioValueRange] = unsafe { slice::from_raw_parts(ranges, n_ranges) }; // Now that we have the available ranges, pick the one matching the desired rate. let sample_rate = target_sample_rate.0; @@ -438,7 +438,7 @@ impl Device { // Count the number of channels as the sum of all channels in all output buffers. let n_buffers = (*audio_buffer_list).mNumberBuffers as usize; let first: *const AudioBuffer = (*audio_buffer_list).mBuffers.as_ptr(); - let buffers: &'static [AudioBuffer] = slice::from_raw_parts(first, n_buffers); + let buffers: &[AudioBuffer] = slice::from_raw_parts(first, n_buffers); let mut n_channels = 0; for buffer in buffers { n_channels += buffer.mNumberChannels as usize; @@ -481,7 +481,7 @@ impl Device { check_os_status(status)?; let ranges: *mut AudioValueRange = ranges.as_mut_ptr() as *mut _; - let ranges: &'static [AudioValueRange] = slice::from_raw_parts(ranges, n_ranges); + let ranges: &[AudioValueRange] = slice::from_raw_parts(ranges, n_ranges); #[allow(non_upper_case_globals)] let input = match scope { From ba78ecd13799fffae0f67f65b4e7b88969221898 Mon Sep 17 00:00:00 2001 From: Roderick van Domburg Date: Wed, 24 Sep 2025 23:16:06 +0200 Subject: [PATCH 10/10] docs: Add CoreAudio loopback recording support to changelog --- CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 0fe4cb9bd..41c78be07 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,7 @@ - CoreAudio: Change `Device::supported_configs` to return a single element containing the available sample rate range when all elements have the same `mMinimum` and `mMaximum` values. - CoreAudio: Change default audio device detection to be lazy when building a stream, instead of during device enumeration. - CoreAudio: Add `i8`, `i32` and `I24` sample format support (24-bit samples stored in 4 bytes). +- CoreAudio: Add support for loopback recording (recording system audio output) on macOS. - iOS: Fix example by properly activating audio session. - WASAPI: Expose `IMMDevice` from WASAPI host Device. - WASAPI: Add `I24` and `U24` sample format support (24-bit samples stored in 4 bytes).