From 70c9c5bfdb99a1acef56d778acb18bc4e40ddb7c Mon Sep 17 00:00:00 2001 From: attila Date: Tue, 1 Oct 2024 16:50:50 +0200 Subject: [PATCH] iOS Audio: Add waiting mechanism after AVAudioSession setActive: to ensure correct buffer size detection on iOS 18 The waiting is only added on iOS 18 as this is the only platform where seems to be an asynchronous relationship between (1) AVAudioSession setActive: (2) AVAudioSession setPreferredIOBufferDuration: and (3) AVAudioSession.IOBufferDuration. The issue is not observable in the iOS 18 emulator. --- .../native/juce_Audio_ios.cpp | 107 +++++++++++++++++- 1 file changed, 101 insertions(+), 6 deletions(-) diff --git a/modules/juce_audio_devices/native/juce_Audio_ios.cpp b/modules/juce_audio_devices/native/juce_Audio_ios.cpp index fa0ba27793..dac1d9b706 100644 --- a/modules/juce_audio_devices/native/juce_Audio_ios.cpp +++ b/modules/juce_audio_devices/native/juce_Audio_ios.cpp @@ -257,6 +257,81 @@ private: JUCE_DECLARE_NON_COPYABLE_WITH_LEAK_DETECTOR (iOSAudioIODeviceType) }; +class SubstituteAudioUnit +{ +public: + /* Returns true if the audio callback was called. False if a timeout occurred. */ + bool waitForAudioCallback() + { + if (audioUnit != nullptr) + { + AudioComponentInstanceDispose (audioUnit); + audioUnit = nullptr; + } + + AudioComponentDescription desc; + desc.componentType = kAudioUnitType_Output; + desc.componentSubType = kAudioUnitSubType_RemoteIO; + desc.componentManufacturer = kAudioUnitManufacturer_Apple; + desc.componentFlags = 0; + desc.componentFlagsMask = 0; + + AudioComponent comp = AudioComponentFindNext (nullptr, &desc); + AudioComponentInstanceNew (comp, &audioUnit); + + if (audioUnit == nullptr) + return false; + + { + AURenderCallbackStruct inputProc; + inputProc.inputProc = audioUnitCallback; + inputProc.inputProcRefCon = this; + AudioUnitSetProperty (audioUnit, kAudioUnitProperty_SetRenderCallback, kAudioUnitScope_Input, 0, &inputProc, sizeof (inputProc)); + } + + { + AudioStreamBasicDescription format; + zerostruct (format); + format.mSampleRate = [AVAudioSession sharedInstance].sampleRate; + format.mFormatID = kAudioFormatLinearPCM; + format.mFormatFlags = kAudioFormatFlagIsFloat | kAudioFormatFlagIsNonInterleaved | kAudioFormatFlagsNativeEndian | kLinearPCMFormatFlagIsPacked; + format.mBitsPerChannel = 8 * sizeof (float); + format.mFramesPerPacket = 1; + format.mChannelsPerFrame = 2; + format.mBytesPerFrame = format.mBytesPerPacket = sizeof (float); + + AudioUnitSetProperty (audioUnit, kAudioUnitProperty_StreamFormat, kAudioUnitScope_Input, 0, &format, sizeof (format)); + AudioUnitSetProperty (audioUnit, kAudioUnitProperty_StreamFormat, kAudioUnitScope_Output, 1, &format, sizeof (format)); + } + + AudioUnitInitialize (audioUnit); + AudioOutputUnitStart (audioUnit); + + const auto result = audioCallbackOccurred.wait (1000.0); + + AudioComponentInstanceDispose (audioUnit); + audioUnit = nullptr; + + return result; + } + +private: + static OSStatus audioUnitCallback (void* object, + AudioUnitRenderActionFlags*, + const AudioTimeStamp*, + UInt32, + UInt32, + AudioBufferList*) + { + static_cast (object)->audioCallbackOccurred.signal(); + + return noErr; + } + + AudioUnit audioUnit{}; + WaitableEvent audioCallbackOccurred; +}; + //============================================================================== struct iOSAudioIODevice::Pimpl final : public AsyncUpdater { @@ -311,6 +386,16 @@ struct iOSAudioIODevice::Pimpl final : public AsyncUpdater { JUCE_NSERROR_CHECK ([[AVAudioSession sharedInstance] setActive: enabled error: &error]); + + if (@available (ios 18, *)) + { + if (enabled) + { + SubstituteAudioUnit au; + [[maybe_unused]] const auto success = au.waitForAudioCallback(); + jassert (success); + } + } } int getBufferSize (const double currentSampleRate) @@ -334,8 +419,20 @@ struct iOSAudioIODevice::Pimpl final : public AsyncUpdater NSTimeInterval bufferDuration = currentSampleRate > 0 ? (NSTimeInterval) (newBufferSize + extraOffset) / currentSampleRate : 0.0; auto session = [AVAudioSession sharedInstance]; - JUCE_NSERROR_CHECK ([session setPreferredIOBufferDuration: bufferDuration - error: &error]); + + // According to the apple docs, it's best to set preferred sample rates and block sizes + // while the device is inactive, and then to query the real values after activation. + // Unfortunately, on iOS 18.0, the real block size isn't immediately available after + // a call to setActive, so we also need to wait for the first audio callback. + // This will be slow! + // https://developer.apple.com/library/archive/qa/qa1631/_index.html + if (@available (ios 18, *)) + setAudioSessionActive (false); + + JUCE_NSERROR_CHECK ([session setPreferredIOBufferDuration: bufferDuration error: &error]); + + if (@available (ios 18, *)) + setAudioSessionActive (true); return getBufferSize (currentSampleRate); } @@ -347,7 +444,7 @@ struct iOSAudioIODevice::Pimpl final : public AsyncUpdater auto newBufferSize = tryBufferSize (sampleRate, 64); jassert (newBufferSize > 0); - const auto longestBufferSize = tryBufferSize (sampleRate, 4096); + const auto longestBufferSize = tryBufferSize (sampleRate, 4096); while (newBufferSize <= longestBufferSize) { @@ -377,9 +474,7 @@ struct iOSAudioIODevice::Pimpl final : public AsyncUpdater double trySampleRate (double rate) { auto session = [AVAudioSession sharedInstance]; - JUCE_NSERROR_CHECK ([session setPreferredSampleRate: rate - error: &error]); - + JUCE_NSERROR_CHECK ([session setPreferredSampleRate: rate error: &error]); return session.sampleRate; }