Bug fix in iOSAudioIODevice::Pimpl::tryBufferSize for iOS 18

Sorry for the wait on this, we’ve pushed a fix for this on the develop branch

We’ve also reported this as a bug to Apple but the response currently is “Investigation complete — Works as currently designed”. I’ll keep pushing and add more details to my ticket.

Thank you @anthony-nicholls! I will try your workaround.
I hope that Apple will realise that this is not the desired behaviour.

Hi @anthony-nicholls,
I am happy to report the workaround fixed the sample rate issue I was having on iOS 18! However, I’m still noticing a problem related to the trySampleRate method.

When running JUCE 8 on iOS 18, I can hear a series of clicking noises during iOSAudioIODevice initialization. This problem is very similar to a bug @adamwilson reported years ago (Clicks / high pitched noise on audio device initialisation (iOS) - #2 by adamwilson).

The key difference is that previously, this only happened when using an external audio interface. But on iOS 18 with JUCE 8, I can now hear this noise on any wired earbuds directly connected to the device.

Would you mind taking a look into this? Many thanks!

Hi @anthony-nicholls,

I just spent the day looking into the issue I mentioned above, and I think I know what’s going on now.

In short, JUCE should not be calling setAudioSessionActive() all over the place like it currently does. (Ideally, audio session really should only be turned on once during initialization)

  1. Since commit 70c9c5b, each call to setAudioSessionActive(true) can take around half a second to complete. With the latest code on the dev branch, the audio session is being turned on and off 40+ times during the initialization of iOSAudioIODevice. This has increased the load time from under 1 second (measured on JUCE 7) to 20 seconds.

  2. On iOS 18, every time the audio session is activated, you can hear a very audible clicking noise when using a pair of wired earphones. With 40 calls to setAudioSessionActive(true), you end up with a symphony of clicking sounds that lasts for 20 seconds while the user waits for the app to load.
    (This issue also existed in earlier iOS versions, but it was only noticeable when listening through an external audio interface. My suspicion is that when you toggle the audio session, the underlying audio hardware also gets its power turned on and off. Prior to iOS18, Apple was probably not doing that on their own hardware; hence, you could only hear the popping on third-party interfaces, but now they are doing it on iOS18. This also explains why setPreferredSampleRate and setPreferredIOBufferDuration no longer run synchronously.)

Some possible solutions I can think of right now:

  1. I doubt Apple will ever give JUCE access to all the APIs it needs. Most apps don’t require full control over sample rate and buffer size, they just need a reasonable default. So, it might be worth having a version of iOSAudioIODevice that bypasses all the complicated logic for querying available sample rates and buffer sizes, and simply accepting whatever the OS picks as the only option. (For anyone facing the same issue, here are some modifications I ended up using: GitHub commit. This version reduces the number of times the audio session is switched on and off to just four times and decreases the load time to approximately two seconds.)

  2. Move the logic for querying available sample rates and buffer sizes out of the initial setup and allow developers to trigger it programmatically when needed. That way, at least we can show users a message or a visual indicator while they wait.

For us iOS developers, juce_Audio_ios is probably the most important file in the entire JUCE library, and right now, juce_Audio_ios is in dire need of major refactoring, and I really hope the JUCE team can give it the attention it deserves.

Yes we had the same issues with setting audio session active and inactive multiple times during the lifecycle of the app. We had to enable it at startup and only disable and enable when going background and coming foreground, but never multiple times. We collected the state to restore (for example audio is disabled by the user in the app) so we just call enable IF we need to play, but only once (and not calling it until the user enables the audio in the app IF he disabled it before). All sort of dropouts and bad audio/app experiences were heard all across different ios versions.

Also I think it would be useful to review the different AVAudioSessionCategory options which are used when calling setAudioSessionActive().

AVAudioSessionCategoryPlayAndRecord is currently set during startup immediately before the call to setAudioSessionActive(). The result is that background audio being played by other apps is always stopped if these do not have input channels enalbed (and this is the most common case, take Spotify, for example). Changing this to AVAudioSessionCategoryPlay solves this.

Also, including AVAudioSessionCategoryOptionAllowBluetooth within the categories means that a low-quality low-latency mode is per default set when a user connects bluetooth earphones if the app also uses audio input. This is often not desired or expected. This has come up a couple of times on the forum.

Additionally, there is no support for programatically switching between a SCO/HFP mode (AVAudioSessionModeVoiceChat) and the “normal” music mode. The former offers reduced latency at the cost of quality. Switching between these modes can be useful for those developing any sort of “instrument” app, specially since nowdays wired earphones are almost extinct.

1 Like

Thanks when I originally saw your message my gut reaction was that the constant deactivating/activating of the session would be the cause of the glitches.

I’ll add that the reason we do this is that the Audio Session Programming guide from Apple says…

Set preferred hardware values before you activate your audio session. If you’re already running an audio session, deactivate it. Changes to preferred values take effect after the audio session is activated, and you can verify the changes at that time.

This suggests that we must set this values with the session inactive but that we won’t be able to verify if this worked until the session is active. That being said when I originally done this work I didn’t have the constant deactivating/reactivating and all seemed well.

Are you sure about that? I was also worried about the time it takes but we did regression testing with multiple devices and iOS versions and found the version on develop before this change took just as long. from my debugging the time was always spent in the call to setPrefferredSampleRate.

I’m not so sure about this, a device could be stuck in any sample rate. It could also change whilst running from another application. I’m quite sure we would get other customers complaining if we suddenly done that. What some applications do is that they set the device to some constant sample rate (48kHz for example) then all the choices of sample rates they give just resample between the device sample rate and the chosen sample rate. I have been wondering If we do something like that, then we can present a constant set of sample rates, maybe we could try switching sample rates when it’s selected and only resample if the sample rate switch fails.

Thanks @kunitoki that’s useful to note.

@aamf some useful feedback here but I’m a bit concerned this a little off topic from the original problem reported and is likely to get lost in the noise of this conversation to be honest.

Yes, no need to discuss those points here. I just thought the information could be helpful for you or those modifying the codebase.

1 Like

Hi @anthony-nicholls thanks for the reply!

  1. Activate the audio session once to verify changes to sampleRate/bufferSize is perfectly fine. The issue is that JUCE currently does this 40 times on launch as a hack to populate availableSampleRates and availableBufferSizes, which is causing all the problems.

  2. I’m testing on an iPad Air (3rd gen) with iOS 18.3.1 using wired earbuds via the 3.5mm jack. In my experience, audio session’s behavior can vary depending on whether you’re using headphone jack, Lightning, or Bluetooth. So make sure to test all of them.

(I just tested it again, this time using the Lightning port with a dongle, and I’m getting the same issue on both the iPad and an iPhone 13 Pro Max.)

  1. To clarify about my suggestion, I’m not saying to use a fixed sampleRate, but rather populating availableSampleRates and availableBufferSizes with the default value Apple assigns the app at launch, so we can avoid doing the hack for querying availableSampleRates. For example, with this GitHub commit, the app will still adjust sampleRate dynamically if a route change happens later on. With the modification, JUCE’s behavior would remain the same for any apps that don’t require a specific sampleRate. For those that do, they will see only one available option from availableSampleRates, and if they need more, they should programmatically trigger a “discovery” process where they can use the slow and noisy hack. At that point, the program is no longer on any critical path so it would be less of a problem, and the developer can also show a message explaining what is happening if needed.

These are just rough ideas, I’m sure there are many factors JUCE has to consider that I am not aware of. Feel free to disregard the suggestions if they don’t make sense, but the issue is definitely there on iOS 18, and it’s currently a showstopper for us.

Any news here?

We need to release an update of our app but at the moment we are facing the same sample rate issue. The current version of our app, build with JUCE 804 in January, is working perfectly btw

Happy to drop the activate/deactivate calls as suggested.

I understand, but consider a 3rd party device such as a Scarlett 2i2 or whatever you decide to connect, that device could be set to anything. Whatever it is set to is now the only option for the app running.

Then there is also the case that another app could change the sample rate of the device while the JUCE app is running. In this instance the JUCE app will now be running at a sample rate that is not available from the drop down list.

One thing you could do now is use the preprocessor definition JUCE_IOS_AUDIO_EXPLICIT_SAMPLERATES to define a set of sample rates you want to support this would bypass the process of discovering the available sample rates.

If you set this to say 44100.0, 48000.0, 88200.0, 96000.0, 176400.0, 192000.0 I think you would be extremely well covered. It does mean these options would always be displayed even if the device doesn’t support them but if a user tries to switch to them it should just fail and stay on the current sample rate instead. I’ve not tested what happens if the device is in some sample rate not in the list though. My guess is that the device will stay at that sample rate until the user switches it to a sample rate in the list.

Could you please clarify because the experience I and others are having is that changing the sample rate on older versions is impossible to observe with the session sample rate which is what older versions of JUCE use. So despite the device changing sample rate the reported sample rate may be incorrect.

If I change the sample rate to 44.1/48 or 96 kHz the app sounds fine, no issue at all. I’ve tested with some external USB soundcard (EVO8, 404HD etc).

@anthony-nicholls

For us, this hasn’t been an issue. If you create an audio app following Apple’s WWDC tutorial using only AVAudioSession and AVAudioEngine without JUCE, this is the default behavior you will get. The app simply needs to react to AVAudioSession.routeChangeNotification when sample rate changes. (Once again, I don’t know all the internal workings of JUCE, so perhaps this behavior is not acceptable in the context of JUCE apps.)

Having a set of predefined settings for iOS might work, but it would require more manual effort to keep it updated when new devices are released. If JUCE decides to go that route, it will also need to do the same for buffer sizes. Currently, updateAvailableBufferSizes has the same problem as updateAvailableSampleRates. So even if we set JUCE_IOS_AUDIO_EXPLICIT_SAMPLERATES, it will only solve half of the problem.

Yes that’s true but most iOS apps probably don’t need or expect to be able to change the sample rate. I doubt most expect there is even more than one sample rate.

Just to be clear, if I understand you correctly, you’re suggesting that we only allow for the sample rate that the device is initially set to by iOS when we access the shared AVAudioSession?

As most JUCE apps are likely to be audio focused, pro audio apps in many cases, they are much more likely to need the ability to switch to different sample rates (and buffer sizes). I think if we effectively took that away on iOS we would have complaints from other users.

I played with this a little and found for example that when I used my Scarlett 2i2 4th gen connected to an iPhone running iOS 18.3 it always initialised to a sample rate of 44.1kHz (I had originally assumed it would use the sample rate the device was last configured to), and a buffer size of 256 samples. I can imagine this being quite frustrating for lots of developers making pro audio apps given the device is capable of sample rates up to 192kHz. They may also want to adjust the buffer size to either reduce latency or decrease the chance of buffer overruns if the processing being performed is particularly demanding.

JUCE already handles a AVAudioSessionRouteChangeNotification. However, right now the available sample rates are populated when it first sees a particular audio device. So it would respond to the sample rate change from another application just fine, it would just mean the device is now using a sample rate that is not in the available sample rates. I suspect it works but I’m not sure it would be a great user experience, most notably if you drop down a combo box containing the available sample rates. It is however an edge case that probably doesn’t need to be the main focus of our discussion.

By new devices are you referring to iOS or audio devices? I would be surprised if Apple are going to release any new iOS devices with sample rates outside of the list I’ve provided. Maybe there are some cases for lower sample rates for bluetooth devices. However, if the time taken to enumerate the sample rates is an issue this seems a reasonable compromise. I would have thought this is a better compromise than only having one sample rate. Updating the available sample rates if needed would also be very easy to do.

We could indeed provide a way to specify explicit buffer sizes but when we populate the buffer sizes it seems we really do have to toggle the session activity or we can’t observe the change, at least as far as we’ve been able to tell on iOS 18. That being said on iOS it does seem to “just work” with almost any buffer size we give it.

I’ve been testing myself, unfortunately I don’t have AirPods or an iPhone headphone adapter on me, but with my external device I get no audio glitches from the session activity changing so it’s difficult for me to confirm right now what is and isn’t problematic.

And you’re testing with iOS18? If so it seems odd that you’re not experiencing the original issue that was reported.

At the moment I’m inclined to suggest

  1. We remove the session activity toggling when changing sample rates
  2. We add the ability to explicitly declare the available iOS buffer sizes

As far as I can tell this resolves the primary issues being raised?

I can’t reproduce any of the audio glitches being described so it’s hard for me to say exactly what is right here.

Just to come back to this as I was in that area, I can see what you’re saying, but having spent a bit more time in this area I think this is likely because we need to probe both inputs and outputs to discover the device capabilities.

Yes, iOS18 on iPad with JUCE 804, the app is called PRIMO and is available for free on App Store. We have a custom UI for AudioDeviceSelectorComponent, but the logic behind is basically the original one from the JUCE repo.

Correct, but that doesn’t mean pro apps will give up control completely. Here’s how I envision it working:

  1. Use whatever the OS provides when the app is launched for the first time.
  2. The user goes into the app’s audio settings and clicks on the sample rate dropdown.
  3. The app shows a spinner while querying all available sample rates in the background. (This is when the session toggling occurs, similar to what JUCE currently does in updateAvailableSampleRates/BufferSizes.)
  4. The user selects a different sample rate, and the app switches to it while also persisting the choice to disk.
  5. The next time the user opens the app, instead of using the OS-provided sample rate, the app initializes the audio session with the sample rate stored on disk. (As long as the hardware remains unchanged, the audio session will take that sample rate without issue. If the hardware has changed, then there’s not much the app can do anyway, and the sample rate will reset to whatever the OS selects.)

Overall, the main goal is to move step 3 out of app launch.

The suggestion above won’t cause crashes for anyone, but it does break the current API contract. To handle this more gracefully, you could retain the existing behavior and introduce a flag that allows developers to move step 3 (sample rate and buffer size discovery) out of app launch. This way, any app that hasn’t been updated will continue to function exactly as before, albeit with the previously mentioned audio artifacts and degraded performance.

The tricky part about hardcoding sample rates is that they change constantly depending on the environment.

  1. Available sample rates will change when switching between headphone jack, Thunderbolt, Bluetooth and an audio interface. That’s why JUCE currently calls updateHardwareInfo whenever there is a route change.
  2. Different iOS devices may support different sample rates. For example, older devices like iPhone 6 do not support anything beyond 44.1k.

So at end of the day, programmatically enumerating the sample rates is probably what JUCE needs to do, but just not during app launch.

Thanks that helps make your idea clearer. I have also considered something like this. I think this will likely be more work than it first appears because some of the work appears to be well outside of the iOS device discovery, some of the work is tied up in more generic areas that could impact all platforms. I’m not immediately against the idea but it’s unlikely to be implemented quickly, and as far as I can tell the time taken to enumerate the sample rates hasn’t changed. Are you seeing differences using older versions of JUCE/iOS?

I understand but if the device fails to take on the selected sample rate the current implementation should already select the next best sample rate.

I only have devices running iOS 18 at the moment, but here’s what I have observed so far:

JUCE 7 on iOS 17 :white_check_mark: No problems

JUCE 7 on iOS 18 :warning: Partially working
The audio session occasionally initializes incorrectly, resulting in either muffled sound or complete chaos. This issue always occurs when listening through a headphone jack, and is less likely to happen with Thunderbolt.

JUCE 8 (dev branch) on iOS 18 :warning: Partially working
Everything initializes correctly, but with clicking audio artifacts at launch and slow load times.

Yeah, I can see this being a significant change. For now, we’ll just have to poke around in juce_Audio_ios and disable the problematic code temporarily.

@autumnrockdev thanks that helps.

This is the only thing that’s confusing me. It sounds like you’re saying slow load times are only with JUCE 8 and iOS18. As I’ve said I see slow load times using older versions of JUCE and older versions of iOS. I’ll take a look again to make sure there is not some regression here.

Correct, and this is caused specifically by the code below. Note that the “sleep” behavior is only triggered on iOS 18.

static void setAudioSessionActive (bool enabled)
{
    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);
        }
    }
}

If you simply comment out the if (@available (ios 18, *)) {...} block from the development branch, the code runs much faster. (Of course, doing so would reintroduce the same incorrect sample rate bug that existed in JUCE 7.)

Here are some rough measurements of launch times I recorded on my iPad running iOS 18 with different versions of JUCE:

With the most recent code on the development branch:

  • 10s when using the headphone jack
  • 5s when using headphones through Thunderbolt
  • 4s when using the onboard speaker

Development branch, but with the block at line 390 commented out:

  • 4s when using the headphone jack
  • 2s when using headphones through Thunderbolt
  • 2s when using the onboard speaker

With the modification from my previous post, where we set sampleRate and bufferSize only once:

  • 1s when using the headphone jack
  • 1s when using headphones through Thunderbolt
  • 1s when using the onboard speaker

It seems that most of the delay comes from [[maybe_unused]] const auto success = au.waitForAudioCallback();, with the effect being particularly noticeable when using headphones, though you should still see a difference with the onboard speaker. Also, the clicking noise is only audible through headphones and is not affected by the device’s volume.

(These numbers were measured on apps built with the profile configuration in Xcode. If you run the app in debug mode, the delays can be longer.)