juce-framework / JUCE

JUCE is an open-source cross-platform C++ application framework for desktop and mobile applications, including VST, VST3, AU, AUv3, LV2 and AAX audio plug-ins.
https://juce.com
Other
6.68k stars 1.75k forks source link

[Bug]: Audio breaks up when using Bluetooth speaker on iOS #1456

Open hollance opened 1 month ago

hollance commented 1 month ago

Detailed steps on how to reproduce the bug

I have an AUv3 that's wrapped inside a standalone app running on iPad. When the app is active and I switch to a Bluetooth speaker, the sound becomes all garbled. This also happens if Bluetooth is active before starting the app. Without Bluetooth, it sounds fine. Other audio apps (not written with JUCE) work fine over Bluetooth. The audio processing code in my AUv3 can handle different sample rates and buffer sizes without problems.

I have traced it to the following: When Bluetooth is active, iOS for some reason first attempts to set the sample rate to 8000 Hz (and use a mono bus). With JUCE_IOS_AUDIO_LOGGING enabled, it prints the following:

Setting target sample rate: 8000
Actual sample rate: 44100

It's possible to reproduce this behavior even without Bluetooth. In juce_Audio_ios.cpp, in open(), change the line targetSampleRate = sampleRateWanted; to targetSampleRate = 8000.0;.

After some debugging, I was able to fix the issue by doing the following in juce_Audio_ios.cpp in setTargetSampleRateAndBufferSize():

    void setTargetSampleRateAndBufferSize()
    {
        JUCE_IOS_AUDIO_LOG ("Setting target sample rate: " << targetSampleRate);
        sampleRate = trySampleRate (targetSampleRate);
        if (sampleRate != targetSampleRate)
            sampleRate = trySampleRate (sampleRate);
        JUCE_IOS_AUDIO_LOG ("Actual sample rate: " << sampleRate);

        JUCE_IOS_AUDIO_LOG ("Setting target buffer size: " << targetBufferSize);
        bufferSize = tryBufferSize (sampleRate, targetBufferSize);
        if (bufferSize != targetBufferSize)
            bufferSize = tryBufferSize (sampleRate, bufferSize);
        JUCE_IOS_AUDIO_LOG ("Actual buffer size: " << bufferSize);
    }

trySampleRate sets the preferredSampleRate on the AVAudioSession, even if the audio hardware cannot handle that sample rate. My hypothesis is that at some point iOS may still attempt to use this preferred rate, while JUCE is using a different sample rate. My fix/workaround changes the preferredSampleRate to something that is actually supported. Same thing for the buffer size.

What is the expected behaviour?

The audio plays over the Bluetooth speaker.

Operating systems

iOS

What versions of the operating systems?

Tried on iPadOS 17.6.1 on iPad Pro (10.5-inch). Several beta testers have mentioned this Bluetooth problem on their devices as well. JUCE 8.0.3.

Architectures

ARM, 64-bit

Stacktrace

n/a

Plug-in formats (if applicable)

AUv3

Plug-in host applications (DAWs) (if applicable)

Standalone app

Testing on the develop branch

I have not tested against the develop branch

Code of Conduct

reuk commented 1 month ago

I have traced it to the following: When Bluetooth is active, iOS for some reason first attempts to set the sample rate to 8000 Hz (and use a mono bus). With JUCE_IOS_AUDIO_LOGGING enabled, it prints the following:

I'd be interested to see the stack trace that leads to setTargetSampleRateAndBufferSize being called with a target sample rate of 8 kHz.

I've tested this a bit, both on the develop branch, and on the 8.0.3 release. I'm using a 9th gen iPad running iOS 17.7. So far, I haven't been able reproduce the behaviour you describe when connecting and disconnecting a bluetooth headset while audio is playing via the AudioPlaybackDemo in the DemoRunner.

Another issue in this area was reported after the 8.0.3 release here. My potential fix for that issue is to add the following at line 523 in juce_Audio_ios.cpp, in updateAvailableSampleRates() before the call to AudioUnitAddPropertyListener:

sampleRate = trySampleRate (sampleRate);
bufferSize = getBufferSize (sampleRate);

The idea is to set the stored sampleRate and bufferSize back to whatever values they held before querying the available sample rates. This looks similar to the solution you're suggesting, but in a different location.

Please could you try making the change I outlined above and see whether it solves the problem for you?

hollance commented 1 month ago

Sure, I can try your solution tomorrow and set a breakpoint to capture the stack trace.

hollance commented 1 month ago

When I connect to Bluetooth (cheap AirPods imitation) while the app is running, the JUCE_IOS_AUDIO_LOGGING stuff prints the following:

handleRouteChange: New device available
Updating hardware info
Lowest supported sample rate: 44100
Highest supported sample rate: 44100
Available sample rates: 44100
Sample rate after detecting available sample rates: 44100
Available buffer sizes: 1024
Buffer size after detecting available buffer sizes: 1024
Setting target sample rate: 44100
Actual sample rate: 44100
Setting target buffer size: 256
Actual buffer size: 1024
Input channel configuration: {Number of hardware channels: 2, Hardware channel names: "Left" "Right", Are channels available: yes, Active channel indices:, Inactive channel indices: 0 1}
Output channel configuration: {Number of hardware channels: 2, Hardware channel names: "SL-001 Left" "SL-001 Right", Are channels available: yes, Active channel indices: 0 1, Inactive channel indices:}
Creating the audio unit
Internal buffer size: 4096

Note that here the sample rate is fine but there is a mismatch between the buffer sizes. This causes the sound to break up.

The stack trace for setTargetSampleRateAndBufferSize in this scenario is as follows:

Screenshot 2024-10-23 at 11 45 36

By the way, if I turn off the Bluetooth headset while the app is still running, the logging shows:

handleRouteChange: Old device unavailable
Updating hardware info
Lowest supported sample rate: 22050
Highest supported sample rate: 48000
Trying a sample rate of 23050, got 24000
Trying a sample rate of 25000, got 32000
Trying a sample rate of 33000, got 44100
Trying a sample rate of 45100, got 48000
Available sample rates: 22050 24000 32000 44100 48000
Sample rate after detecting available sample rates: 44100
Available buffer sizes: 59 118 236 472 944 1888 3763
Buffer size after detecting available buffer sizes: 235
Setting target sample rate: 8000
Actual sample rate: 22050
Setting target buffer size: 256
Actual buffer size: 256
Input channel configuration: {Number of hardware channels: 2, Hardware channel names: "Left" "Right", Are channels available: yes, Active channel indices:, Inactive channel indices: 0 1}
Output channel configuration: {Number of hardware channels: 2, Hardware channel names: "Headphones Left" "Headphones Right", Are channels available: yes, Active channel indices: 0 1, Inactive channel indices:}
Creating the audio unit
Internal buffer size: 4096

Note how it suddenly attempts to set the target sample rate to 8000 and chooses the closest, which is 22050, even though this device handles 44100 just fine.

The stacktrace for that is the same as above.

If I now connect the Bluetooth headset again, it might ask for 8000 Hz or for 44100 Hz. It all seems a bit arbitrary. I'm sure that's due to these headphones being a bit janky. ;-)

hollance commented 1 month ago

If I connect to Bluetooth before starting the app, the logging shows:

Creating iOS audio device
Updating hardware info
Lowest supported sample rate: 8000
Highest supported sample rate: 8000
Available sample rates: 8000
Sample rate after detecting available sample rates: 44100
Available buffer sizes: 44 88 176 352 704 1408 2816 2822
Buffer size after detecting available buffer sizes: 176
Input channel configuration: {Number of hardware channels: 1, Hardware channel names: "SL-001", Are channels available: yes, Active channel indices:, Inactive channel indices: 0}
Output channel configuration: {Number of hardware channels: 1, Hardware channel names: "SL-001", Are channels available: yes, Active channel indices:, Inactive channel indices: 0}
Opening audio device: inputChannelsWanted: 0, outputChannelsWanted: 11, targetSampleRate: 8000, targetBufferSize: 256
Input channel configuration: {Number of hardware channels: 2, Hardware channel names: "Left" "Right", Are channels available: yes, Active channel indices:, Inactive channel indices: 0 1}
Output channel configuration: {Number of hardware channels: 2, Hardware channel names: "SL-001 Left" "SL-001 Right", Are channels available: yes, Active channel indices: 0 1, Inactive channel indices:}
Updating hardware info
Lowest supported sample rate: 44100
Highest supported sample rate: 44100
Available sample rates: 44100
Sample rate after detecting available sample rates: 44100
Available buffer sizes: 64 128 256 512 1024
Buffer size after detecting available buffer sizes: 128
Setting target sample rate: 8000
Actual sample rate: 44100
Setting target buffer size: 256
Actual buffer size: 256
Creating the audio unit
Internal buffer size: 4096

Here the sample rate is the issue while the buffer size is OK. Note that it's weird that now it suddenly does support a 256 buffer while previously it only supported 1024.

The stack trace is:

Screenshot 2024-10-23 at 11 41 32

The App/Window/PluginHolder are essentially copies of the JUCE standalone stuff and it goes wrong with the default standalone as well.

hollance commented 1 month ago

My potential fix for that issue is to add the following at line 523 in juce_Audio_ios.cpp, in updateAvailableSampleRates() before the call to AudioUnitAddPropertyListener:

Unfortunately your suggestion does not work in this case.

reuk commented 1 month ago

I've spent a bit more time investigating this issue, but haven't made much progress so far.

When I connect to Bluetooth while the app is running, the JUCE_IOS_AUDIO_LOGGING stuff prints the following... Note that here the sample rate is fine but there is a mismatch between the buffer sizes. This causes the sound to break up.

I'm not convinced that a buffer-size mismatch here would cause garbled audio. The targetBufferSize is the buffer size that we would like to apply, while the bufferSize is the size that was actually applied after last attempting to change the configuration. When the iOS audio device is restarted, it calls AudioIODeviceCallback::audioDeviceAboutToStart, and interested listeners can call device->getCurrentSampleRate() and device->getCurrentBufferSizeSamples() to determine the current device configuration. These member functions return the sampleRate and bufferSize members respectively, which should reflect the real state of the audio device. That is, even if the target and actual values don't match, the audio processor will be initialised with the actual values picked by the device, so playback should work as expected.

The "Internal buffer size" value appears to be an upper limit on the size of any given buffer. If I stick a DBG in the actual process callback, the numFrames argument seems to match the bufferSize member.

Note how it suddenly attempts to set the target sample rate to 8000 and chooses the closest, which is 22050, even though this device handles 44100 just fine.

The target sample rate is only set when opening the device. It looks like the AudioDeviceManager will query the device for available sample rates, and then pick the best one of those rates before opening the device. From the output you posted, it looks like your device initially only reports a supported rate of 8kHz, so this is the value that ends up being set as the target sample rate.


The fix I mentioned earlier is now on the develop branch. I know you said that it didn't help for you, but maybe it's worth checking out the version on the develop branch just in case it differs in any way from the version you tested.

https://github.com/juce-framework/JUCE/commit/6f20de54349470aff16453052a82aa7e8e0aea26

Other than that, I'm starting to run out of ideas. The Apple docs suggest that it's best to call setPreferredIOBufferDuration while the device is inactive, so maybe you could try commenting out the if (@available (ios 18, *)) checks on line 429 and 434 of juce_Audio_ios.cpp, so that we unconditionally call setAudioSessionActive(false) and setAudioSessionActive(true) before/after the setPreferredIOBufferDuration call. We recently added the activate/deactivate because it seems to be necessary for iOS 18, but maybe it's needed in some situations on older iOS versions too.

reuk commented 3 weeks ago

Hi, just wanted to check whether you'd had a chance to test the latest changes on the develop branch.

hollance commented 3 weeks ago

Sorry, I have not had time to test this yet.