diff --git a/modules/audio_device/audio_engine_device.h b/modules/audio_device/audio_engine_device.h index e9977d62bb..2f2efaaab2 100644 --- a/modules/audio_device/audio_engine_device.h +++ b/modules/audio_device/audio_engine_device.h @@ -510,6 +510,11 @@ class AudioEngineDevice : public AudioDeviceModule, public AudioSessionObserver std::vector output_device_ids_; std::vector output_device_labels_; std::vector input_device_labels_; + + // Private aggregate device used when voice processing is disabled and the + // engine's shared I/O unit must address different input and output devices. + AudioObjectID engine_aggregate_device_id_ = kAudioObjectUnknown; + void DestroyAggregateDeviceIfNeeded(); #endif bool IsMicrophonePermissionGranted(); diff --git a/modules/audio_device/audio_engine_device.mm b/modules/audio_device/audio_engine_device.mm index ef249643dc..29ccbd13a7 100644 --- a/modules/audio_device/audio_engine_device.mm +++ b/modules/audio_device/audio_engine_device.mm @@ -2233,6 +2233,9 @@ AVAudioVoiceProcessingOtherAudioDuckingLevel ToAVDuckingLevel( } engine_device_ = nil; +#if TARGET_OS_OSX + DestroyAggregateDeviceIfNeeded(); +#endif } // -------------------------------------------------------------------------------------------- @@ -2368,6 +2371,111 @@ AVAudioVoiceProcessingOtherAudioDuckingLevel ToAVDuckingLevel( } } + // -------------------------------------------------------------------------------------------- + // Step: Configure device for the shared I/O unit (macOS, voice processing disabled) + // + // Without voice processing the engine's input and output nodes share a single + // HAL I/O unit, so per-direction device selection is impossible: setting + // kAudioOutputUnitProperty_CurrentDevice re-routes both directions. When the + // requested input and output devices differ, a private aggregate device + // combining them is created and set instead. This must happen before the + // enable steps below: the HAL unit rejects device changes once the graph is + // wired, and the node formats read during enable must reflect the device. +#if TARGET_OS_OSX + if (!state.next.voice_processing_enabled && state.next.IsAnyEnabled() && + (!state.prev.IsAnyEnabled() || state.IsEngineRecreateRequired())) { + bool input_needed = state.next.IsInputEnabled(); + bool output_needed = state.next.IsOutputEnabled(); + AudioObjectID requested_input = state.next.input_device_id; + AudioObjectID requested_output = state.next.output_device_id; + + // When both directions follow the system default there is nothing to do, + // the engine already tracks the default route. + if ((input_needed && requested_input != kAudioObjectUnknown) || + (output_needed && requested_output != kAudioObjectUnknown)) { + AudioObjectID input_device = requested_input; + if (input_needed && input_device == kAudioObjectUnknown) { + input_device = + mac_audio_utils::GetDefaultInputDeviceID().value_or(kAudioObjectUnknown); + } + AudioObjectID output_device = requested_output; + if (output_needed && output_device == kAudioObjectUnknown) { + output_device = + mac_audio_utils::GetDefaultOutputDeviceID().value_or(kAudioObjectUnknown); + } + + AudioObjectID target_device = kAudioObjectUnknown; + if (input_needed && output_needed && input_device != kAudioObjectUnknown && + output_device != kAudioObjectUnknown && input_device != output_device) { + DestroyAggregateDeviceIfNeeded(); + auto aggregate = + mac_audio_utils::CreatePrivateAggregateDevice(output_device, input_device); + if (!aggregate.has_value()) { + LOGE() << "Failed to create aggregate device, output=" << output_device + << " input=" << input_device; + return rollback(kAudioEngineRecordingDeviceNotAvailableError); + } + engine_aggregate_device_id_ = *aggregate; + target_device = *aggregate; + LOGI() << "Created aggregate device " << target_device + << " (output=" << output_device << ", input=" << input_device << ")"; + rollback_actions.push_back([this]() { + RTC_DCHECK_RUN_ON(thread_); + DestroyAggregateDeviceIfNeeded(); + }); + } else if (input_needed && input_device != kAudioObjectUnknown && + (!output_needed || input_device == output_device)) { + target_device = input_device; + } else if (output_needed && output_device != kAudioObjectUnknown && + !input_needed) { + target_device = output_device; + } + + if (target_device != kAudioObjectUnknown) { + auto device_name = mac_audio_utils::GetDeviceName(target_device); + LOGI() << "Setting shared I/O unit device: " + << device_name.value_or("Unknown") << " (" << target_device << ")"; + AudioUnit io_unit = + output_needed ? outputNode().audioUnit : inputNode().audioUnit; + OSStatus err = AudioUnitSetProperty( + io_unit, kAudioOutputUnitProperty_CurrentDevice, kAudioUnitScope_Global, + 0, &target_device, sizeof(target_device)); + if (err != noErr) { + LOGE() << "Failed to set shared I/O unit device: requested=" + << target_device << ", error: " << err; + return rollback(input_needed ? kAudioEngineRecordingDeviceNotAvailableError + : kAudioEnginePlayoutDeviceNotAvailableError); + } + + // The unit renegotiates its formats from the new device asynchronously, + // aggregates in particular can take a moment. Wait until the node + // formats are usable so the enable steps below read valid channel + // counts. + constexpr int kMaxFormatAttempts = 100; + constexpr int64_t kFormatPollIntervalMs = 10; + bool format_ready = false; + for (int attempt = 0; attempt < kMaxFormatAttempts; ++attempt) { + bool output_ready = + !output_needed || [outputNode() outputFormatForBus:0].channelCount > 0; + bool input_ready = + !input_needed || [inputNode() outputFormatForBus:0].channelCount > 0; + if (output_ready && input_ready) { + format_ready = true; + break; + } + webrtc::Thread::SleepMs(kFormatPollIntervalMs); + } + if (!format_ready) { + LOGE() << "Shared I/O unit formats did not become ready for device " + << target_device; + return rollback(input_needed ? kAudioEngineRecordingDeviceNotAvailableError + : kAudioEnginePlayoutDeviceNotAvailableError); + } + } + } + } +#endif + // -------------------------------------------------------------------------------------------- // Step: Enable output // @@ -2824,10 +2932,14 @@ AVAudioVoiceProcessingOtherAudioDuckingLevel ToAVDuckingLevel( } // -------------------------------------------------------------------------------------------- - // Step: Configure device (macOS only) + // Step: Configure device (macOS, voice processing enabled) // + // With voice processing the input and output nodes use separate I/O units + // that accept per-direction device selection at this point. The non voice + // processing path configures its shared unit earlier, before the graph is + // wired (see "Configure device for the shared I/O unit" above). #if TARGET_OS_OSX - if (state.next.IsAnyEnabled() && + if (state.next.voice_processing_enabled && state.next.IsAnyEnabled() && (!state.prev.IsAnyEnabled() || state.IsEngineRecreateRequired())) { if (state.next.IsInputEnabled()) { uint32_t requested_input_device_id = state.next.input_device_id; @@ -3060,6 +3172,9 @@ AVAudioVoiceProcessingOtherAudioDuckingLevel ToAVDuckingLevel( LOGI() << "Releasing AVAudioEngine..."; engine_device_ = nil; +#if TARGET_OS_OSX + DestroyAggregateDeviceIfNeeded(); +#endif } // --- Diagnostic: final state after apply --- @@ -3145,6 +3260,16 @@ AVAudioVoiceProcessingOtherAudioDuckingLevel ToAVDuckingLevel( #if TARGET_OS_OSX +void AudioEngineDevice::DestroyAggregateDeviceIfNeeded() { + RTC_DCHECK_RUN_ON(thread_); + if (engine_aggregate_device_id_ == kAudioObjectUnknown) { + return; + } + LOGI() << "Destroying aggregate device " << engine_aggregate_device_id_; + mac_audio_utils::DestroyAggregateDevice(engine_aggregate_device_id_); + engine_aggregate_device_id_ = kAudioObjectUnknown; +} + void AudioEngineDevice::UpdateAllDeviceIDs() { using namespace webrtc::mac_audio_utils; diff --git a/modules/audio_device/mac/audio_device_utils_mac.cc b/modules/audio_device/mac/audio_device_utils_mac.cc index 736aca4928..e9df582e4b 100644 --- a/modules/audio_device/mac/audio_device_utils_mac.cc +++ b/modules/audio_device/mac/audio_device_utils_mac.cc @@ -17,7 +17,10 @@ #include "audio_device_utils_mac.h" #include +#include +#include +#include #include #include #include @@ -381,5 +384,116 @@ bool IsOutputDevice(AudioObjectID device_id) { (num_unknown_output_streams > 0 && num_input_streams == 0); } +std::optional CreatePrivateAggregateDevice( + AudioObjectID output_device_id, AudioObjectID input_device_id) { + std::optional output_uid = GetDeviceUniqueID(output_device_id); + std::optional input_uid = GetDeviceUniqueID(input_device_id); + if (!output_uid.has_value() || !input_uid.has_value()) { + RTC_LOG(LS_ERROR) << "CreatePrivateAggregateDevice: missing device UID" + << " (output=" << output_device_id + << ", input=" << input_device_id << ")"; + return std::nullopt; + } + + // The aggregate UID must be unique. Multiple instances may exist briefly + // during engine recreation, so include a counter. + static std::atomic counter{0}; + const std::string aggregate_uid = "org.webrtc.audioengine.aggregate." + + std::to_string(getpid()) + "." + + std::to_string(counter.fetch_add(1)); + + CFStringRef aggregate_uid_cf = CFStringCreateWithCString( + kCFAllocatorDefault, aggregate_uid.c_str(), kNarrowStringEncoding); + CFStringRef output_uid_cf = CFStringCreateWithCString( + kCFAllocatorDefault, output_uid->c_str(), kNarrowStringEncoding); + CFStringRef input_uid_cf = CFStringCreateWithCString( + kCFAllocatorDefault, input_uid->c_str(), kNarrowStringEncoding); + + CFMutableDictionaryRef output_sub_device = CFDictionaryCreateMutable( + kCFAllocatorDefault, 0, &kCFTypeDictionaryKeyCallBacks, + &kCFTypeDictionaryValueCallBacks); + CFDictionarySetValue(output_sub_device, CFSTR(kAudioSubDeviceUIDKey), + output_uid_cf); + + // The output device drives the clock, so the input sub device needs drift + // compensation. + int32_t drift_compensation = 1; + CFNumberRef drift_compensation_cf = CFNumberCreate( + kCFAllocatorDefault, kCFNumberSInt32Type, &drift_compensation); + CFMutableDictionaryRef input_sub_device = CFDictionaryCreateMutable( + kCFAllocatorDefault, 0, &kCFTypeDictionaryKeyCallBacks, + &kCFTypeDictionaryValueCallBacks); + CFDictionarySetValue(input_sub_device, CFSTR(kAudioSubDeviceUIDKey), + input_uid_cf); + CFDictionarySetValue(input_sub_device, + CFSTR(kAudioSubDeviceDriftCompensationKey), + drift_compensation_cf); + + const void* sub_devices[] = {output_sub_device, input_sub_device}; + CFArrayRef sub_device_list = + CFArrayCreate(kCFAllocatorDefault, sub_devices, 2, &kCFTypeArrayCallBacks); + + int32_t is_private = 1; + CFNumberRef is_private_cf = + CFNumberCreate(kCFAllocatorDefault, kCFNumberSInt32Type, &is_private); + + CFMutableDictionaryRef description = CFDictionaryCreateMutable( + kCFAllocatorDefault, 0, &kCFTypeDictionaryKeyCallBacks, + &kCFTypeDictionaryValueCallBacks); + CFDictionarySetValue(description, CFSTR(kAudioAggregateDeviceUIDKey), + aggregate_uid_cf); + CFDictionarySetValue(description, CFSTR(kAudioAggregateDeviceNameKey), + CFSTR("WebRTC AudioEngine I/O")); + CFDictionarySetValue(description, CFSTR(kAudioAggregateDeviceSubDeviceListKey), + sub_device_list); + CFDictionarySetValue(description, + CFSTR(kAudioAggregateDeviceMainSubDeviceKey), + output_uid_cf); + CFDictionarySetValue(description, CFSTR(kAudioAggregateDeviceIsPrivateKey), + is_private_cf); + + AudioObjectID aggregate_device_id = kAudioObjectUnknown; + OSStatus status = + AudioHardwareCreateAggregateDevice(description, &aggregate_device_id); + + CFRelease(description); + CFRelease(is_private_cf); + CFRelease(sub_device_list); + CFRelease(input_sub_device); + CFRelease(drift_compensation_cf); + CFRelease(output_sub_device); + CFRelease(input_uid_cf); + CFRelease(output_uid_cf); + CFRelease(aggregate_uid_cf); + + if (status != noErr || aggregate_device_id == kAudioObjectUnknown) { + RTC_LOG(LS_ERROR) << "AudioHardwareCreateAggregateDevice failed: " + << status; + return std::nullopt; + } + + // The HAL may still be composing the sub devices at this point. Consumers + // must not rely on the aggregate's streams being visible yet, an I/O unit + // pointed at the aggregate renegotiates its formats asynchronously and that + // is where readiness has to be awaited. Logged here for error attribution. + RTC_LOG(LS_INFO) << "Created aggregate device " << aggregate_device_id + << " (streams at creation: input=" + << GetNumStreams(aggregate_device_id, true) + << ", output=" << GetNumStreams(aggregate_device_id, false) + << ")"; + + return aggregate_device_id; +} + +bool DestroyAggregateDevice(AudioObjectID aggregate_device_id) { + OSStatus status = AudioHardwareDestroyAggregateDevice(aggregate_device_id); + if (status != noErr) { + RTC_LOG(LS_WARNING) << "AudioHardwareDestroyAggregateDevice failed: " + << status; + return false; + } + return true; +} + } // namespace mac_audio_utils } // namespace webrtc diff --git a/modules/audio_device/mac/audio_device_utils_mac.h b/modules/audio_device/mac/audio_device_utils_mac.h index 8e3ad21e6a..f2f8298ede 100644 --- a/modules/audio_device/mac/audio_device_utils_mac.h +++ b/modules/audio_device/mac/audio_device_utils_mac.h @@ -52,6 +52,17 @@ bool IsInputDevice(AudioObjectID device_id); bool IsOutputDevice(AudioObjectID device_id); +// Creates a private (process local) aggregate device combining the given +// output and input devices, so a single HAL I/O unit can address both. The +// output device is the clock master and drift compensation is enabled for +// the input sub device. Returns the aggregate AudioObjectID on success. +std::optional CreatePrivateAggregateDevice( + AudioObjectID output_device_id, + AudioObjectID input_device_id); + +// Destroys an aggregate device created by CreatePrivateAggregateDevice. +bool DestroyAggregateDevice(AudioObjectID aggregate_device_id); + } // namespace mac_audio_utils } // namespace webrtc