| Lift freeq's AV media plane out of sleek 90f8b89 nandi 19d ago | 1 | //! Manages loopback recording (recording system audio output) |
| 2 | |
| 3 | use super::device::Device; |
| 4 | use crate::{host::coreaudio::check_os_status, BackendSpecificError, BuildStreamError}; |
| 5 | use objc2::{rc::Retained, AnyThread}; |
| 6 | use objc2_core_audio::{ |
| 7 | kAudioAggregateDeviceNameKey, kAudioAggregateDeviceTapAutoStartKey, |
| 8 | kAudioAggregateDeviceTapListKey, kAudioAggregateDeviceUIDKey, kAudioDevicePropertyDeviceUID, |
| 9 | kAudioEndPointDeviceIsPrivateKey, kAudioObjectPropertyElementMain, |
| 10 | kAudioObjectPropertyScopeGlobal, kAudioSubTapDriftCompensationKey, kAudioSubTapUIDKey, |
| 11 | AudioHardwareCreateAggregateDevice, AudioHardwareCreateProcessTap, |
| 12 | AudioHardwareDestroyAggregateDevice, AudioHardwareDestroyProcessTap, |
| 13 | AudioObjectGetPropertyData, AudioObjectID, AudioObjectPropertyAddress, CATapDescription, |
| 14 | CATapMuteBehavior, |
| 15 | }; |
| 16 | use objc2_core_foundation::{ |
| 17 | kCFAllocatorDefault, kCFTypeArrayCallBacks, kCFTypeDictionaryKeyCallBacks, |
| 18 | kCFTypeDictionaryValueCallBacks, CFArray, CFDictionary, CFMutableDictionary, CFRetained, |
| 19 | CFString, CFStringCreateWithCString, |
| 20 | }; |
| 21 | use objc2_foundation::{ns_string, NSArray, NSNumber, NSString}; |
| 22 | use std::{ |
| 23 | ffi::{c_void, CStr}, |
| 24 | mem::MaybeUninit, |
| 25 | ptr::NonNull, |
| 26 | }; |
| 27 | type CFStringRef = *mut std::os::raw::c_void; |
| 28 | |
| 29 | impl Device { |
| 30 | fn uid(&self) -> Result<Retained<NSString>, BackendSpecificError> { |
| 31 | let mut cfstring: CFStringRef = std::ptr::null_mut(); |
| 32 | let mut size = std::mem::size_of::<CFStringRef>() as u32; |
| 33 | |
| 34 | let property = AudioObjectPropertyAddress { |
| 35 | mSelector: kAudioDevicePropertyDeviceUID, |
| 36 | mScope: kAudioObjectPropertyScopeGlobal, |
| 37 | mElement: kAudioObjectPropertyElementMain, |
| 38 | }; |
| 39 | |
| 40 | let status = unsafe { |
| 41 | AudioObjectGetPropertyData( |
| 42 | self.audio_device_id, |
| 43 | NonNull::from(&property), |
| 44 | 0, |
| 45 | std::ptr::null(), |
| 46 | NonNull::from(&mut size), |
| 47 | NonNull::from(&mut cfstring).cast(), |
| 48 | ) |
| 49 | }; |
| 50 | check_os_status(status)?; |
| 51 | |
| 52 | if cfstring.is_null() { |
| 53 | return Err(BackendSpecificError { |
| 54 | description: "Device uid is null".to_string(), |
| 55 | }); |
| 56 | } |
| 57 | |
| 58 | let ns_string: Retained<NSString> = unsafe { |
| 59 | // unwrap cause cfstring!=null as checked before |
| 60 | Retained::retain(cfstring as *mut NSString).unwrap() |
| 61 | }; |
| 62 | |
| 63 | Ok(ns_string) |
| 64 | } |
| 65 | } |
| 66 | |
| 67 | /// An aggregate device with tap for recording system output. |
| 68 | /// |
| 69 | /// Its main difference with [`Device`] is that it's destroyed when dropped. |
| 70 | /// |
| 71 | /// It also doesn't implement the [`DeviceTrait`] as users shouldn't be using it. Its |
| 72 | /// main purpose is to destroy the created aggregate device when loopback recording |
| 73 | /// is done. |
| 74 | #[derive(PartialEq, Eq)] |
| 75 | pub struct LoopbackDevice { |
| 76 | pub tap_id: AudioObjectID, |
| 77 | pub aggregate_device: Device, |
| 78 | } |
| 79 | |
| 80 | impl LoopbackDevice { |
| 81 | /// Create a [`LoopbackDevice`] that records the sound |
| 82 | /// output of `device`. |
| 83 | pub fn from_device(device: &Device) -> Result<Self, BuildStreamError> { |
| 84 | // 1 - Create tap |
| 85 | |
| 86 | // Empty list of processes as we want to record all processes |
| 87 | let processes = NSArray::new(); |
| 88 | let device_uid = device.uid()?; |
| 89 | let tap_desc = unsafe { |
| 90 | CATapDescription::initWithProcesses_andDeviceUID_withStream( |
| 91 | CATapDescription::alloc(), |
| 92 | &processes, |
| 93 | device_uid.as_ref(), |
| 94 | 0, |
| 95 | ) |
| 96 | }; |
| 97 | unsafe { |
| 98 | tap_desc.setMuteBehavior(CATapMuteBehavior::Unmuted); // captured audio still goes to speakers |
| 99 | tap_desc.setName(ns_string!("cpal output recorder")); |
| 100 | tap_desc.setPrivate(true); // the Aggregate Device would be private |
| 101 | tap_desc.setExclusive(true); // the process list means exclude them |
| 102 | }; |
| 103 | |
| 104 | let mut tap_obj_id: MaybeUninit<AudioObjectID> = MaybeUninit::uninit(); |
| 105 | let tap_obj_id = unsafe { |
| 106 | let status = |
| 107 | AudioHardwareCreateProcessTap(Some(tap_desc.as_ref()), tap_obj_id.as_mut_ptr()); |
| 108 | check_os_status(status)?; |
| 109 | tap_obj_id.assume_init() |
| 110 | }; |
| 111 | let tap_uid = unsafe { tap_desc.UUID().UUIDString() }; |
| 112 | |
| 113 | // 2 - Create aggregate device |
| 114 | let aggregate_device_properties = create_audio_aggregate_device_properties(tap_uid); |
| 115 | let mut aggregate_device_id: AudioObjectID = 0; |
| 116 | let status = unsafe { |
| 117 | AudioHardwareCreateAggregateDevice( |
| 118 | aggregate_device_properties.as_ref(), |
| 119 | NonNull::from(&mut aggregate_device_id), |
| 120 | ) |
| 121 | }; |
| 122 | check_os_status(status)?; |
| 123 | |
| 124 | Ok(Self { |
| 125 | tap_id: tap_obj_id, |
| 126 | aggregate_device: Device::new(aggregate_device_id), |
| 127 | }) |
| 128 | } |
| 129 | } |
| 130 | |
| 131 | impl Drop for LoopbackDevice { |
| 132 | fn drop(&mut self) { |
| 133 | unsafe { |
| 134 | // We don't check status to avoid panic during `drop` |
| 135 | let _status = |
| 136 | AudioHardwareDestroyAggregateDevice(self.aggregate_device.audio_device_id); |
| 137 | let _status = AudioHardwareDestroyProcessTap(self.tap_id); |
| 138 | } |
| 139 | } |
| 140 | } |
| 141 | |
| 142 | fn to_cfstring(cstr: &'static CStr) -> CFRetained<CFString> { |
| 143 | unsafe { |
| 144 | CFStringCreateWithCString( |
| 145 | kCFAllocatorDefault, |
| 146 | cstr.as_ptr(), |
| 147 | 0x08000100, /* UTF8 */ |
| 148 | ) |
| 149 | } |
| 150 | .unwrap() |
| 151 | } |
| 152 | |
| 153 | /// Rust reimplementation of the following: |
| 154 | /// ```c |
| 155 | /// tap_uid = [[tap_description UUID] UUIDString]; |
| 156 | /// taps = @[ |
| 157 | /// @{ |
| 158 | /// @kAudioSubTapUIDKey : (NSString*)tap_uid, |
| 159 | /// @kAudioSubTapDriftCompensationKey : @YES, |
| 160 | /// }, |
| 161 | /// ]; |
| 162 | /// |
| 163 | /// aggregate_device_properties = @{ |
| 164 | /// @kAudioAggregateDeviceNameKey : @"MiniMetersAggregateDevice", |
| 165 | /// @kAudioAggregateDeviceUIDKey : |
| 166 | /// @"com.josephlyncheski.MiniMetersAggregateDevice", |
| 167 | /// @kAudioAggregateDeviceTapListKey : taps, |
| 168 | /// @kAudioAggregateDeviceTapAutoStartKey : @NO, |
| 169 | /// // If we set this to NO then I believe we need to make the Tap public as |
| 170 | /// // well. |
| 171 | /// @kAudioAggregateDeviceIsPrivateKey : @YES, |
| 172 | /// }; |
| 173 | /// ``` |
| 174 | pub fn create_audio_aggregate_device_properties( |
| 175 | tap_uid: Retained<NSString>, |
| 176 | ) -> CFRetained<CFDictionary> { |
| 177 | let tap_inner = unsafe { |
| 178 | let dict = CFMutableDictionary::new( |
| 179 | kCFAllocatorDefault, |
| 180 | 2, |
| 181 | &kCFTypeDictionaryKeyCallBacks, |
| 182 | &kCFTypeDictionaryValueCallBacks, |
| 183 | ) |
| 184 | .unwrap(); |
| 185 | |
| 186 | CFMutableDictionary::set_value( |
| 187 | Some(dict.as_ref()), |
| 188 | &*to_cfstring(kAudioSubTapUIDKey) as *const _ as *const c_void, |
| 189 | &*tap_uid as *const _ as *const c_void, |
| 190 | ); |
| 191 | CFMutableDictionary::set_value( |
| 192 | Some(dict.as_ref()), |
| 193 | &*to_cfstring(kAudioSubTapDriftCompensationKey) as *const _ as *const c_void, |
| 194 | &*NSNumber::initWithBool(NSNumber::alloc(), true) as *const _ as *const c_void, |
| 195 | ); |
| 196 | |
| 197 | dict |
| 198 | }; |
| 199 | let _taps_list = [tap_inner]; |
| 200 | let taps = unsafe { |
| 201 | CFArray::new( |
| 202 | kCFAllocatorDefault, |
| 203 | _taps_list.as_ptr() as *mut *const c_void, |
| 204 | _taps_list.len() as _, |
| 205 | &kCFTypeArrayCallBacks, |
| 206 | ) |
| 207 | .unwrap() |
| 208 | }; |
| 209 | let aggregate_dev_properties = unsafe { |
| 210 | let dict = CFMutableDictionary::new( |
| 211 | kCFAllocatorDefault, |
| 212 | 5, |
| 213 | &kCFTypeDictionaryKeyCallBacks, |
| 214 | &kCFTypeDictionaryValueCallBacks, |
| 215 | ) |
| 216 | .unwrap(); |
| 217 | |
| 218 | CFMutableDictionary::set_value( |
| 219 | Some(dict.as_ref()), |
| 220 | &*to_cfstring(kAudioAggregateDeviceNameKey) as *const _ as *const c_void, |
| 221 | &*CFString::from_str("Cpal loopback record aggregate device") as *const _ |
| 222 | as *const c_void, |
| 223 | ); |
| 224 | CFMutableDictionary::set_value( |
| 225 | Some(dict.as_ref()), |
| 226 | &*to_cfstring(kAudioAggregateDeviceUIDKey) as *const _ as *const c_void, |
| 227 | &*CFString::from_str("com.cpal.LoopbackRecordAggregateDevice") as *const _ |
| 228 | as *const c_void, |
| 229 | ); |
| 230 | CFMutableDictionary::set_value( |
| 231 | Some(dict.as_ref()), |
| 232 | &*to_cfstring(kAudioAggregateDeviceTapListKey) as *const _ as *const c_void, |
| 233 | &*taps as *const _ as *const c_void, |
| 234 | ); |
| 235 | CFMutableDictionary::set_value( |
| 236 | Some(dict.as_ref()), |
| 237 | &*to_cfstring(kAudioAggregateDeviceTapAutoStartKey) as *const _ as *const c_void, |
| 238 | &*NSNumber::initWithBool(NSNumber::alloc(), false) as *const _ as *const c_void, |
| 239 | ); |
| 240 | CFMutableDictionary::set_value( |
| 241 | Some(dict.as_ref()), |
| 242 | &*to_cfstring(kAudioEndPointDeviceIsPrivateKey) as *const _ as *const c_void, |
| 243 | &*NSNumber::initWithBool(NSNumber::alloc(), true) as *const _ as *const c_void, |
| 244 | ); |
| 245 | |
| 246 | CFRetained::cast_unchecked::<CFDictionary>(dict) |
| 247 | }; |
| 248 | |
| 249 | aggregate_dev_properties |
| 250 | } |