Callback on audio session interrupts and routing changes


#1

Sometimes iOS interrupts the current audio session. This happens for example when another app is put into the foreground that also plays audio.

I also had problems with routing changes when a phone call is coming in.When I accept the phone call audio routing changes from headphone to iPhone speakers while the phone call and audio playback are both still active. This is not optimal :wink:

I may be missing something but I see no way to get notified from JUCE about routing changes or audio session interruptions. Therefore there is no way to go to a paused state in my app when that happens.

Any pointers in the right direction would be very much appreciated.

Patrick


#2

I digged a little deeper for the case were another app is brought to the foreground that also plays audio and iOS interrupts the JUCE audio session.

It seems in this case, in juce_ios_Audio.cpp, the interruptionListener method is only called for kAudioSessionBeginInterruption, never for kAudioSessionEndInterruption.

I may be wrong, but I see no way to reflect this state in the ui or to recover from it without some kind of callback. The AudioDeviceManager also can’t recover from this. Audio is gone for good.

If there was a callback, the app could go into a Stopped or Paused state and show this in the ui. When the user presses play again the AudioDeviceManager could be recreated and audio playback could start again.

Patrick


#3

Yes, this is all iOS-specific stuff that didn’t fit into the pre-existing audio classes. It does need doing, but it’s one of those fiddly tasks that’d probably take me a whole day to research and test, and I don’t have any free days right now. (If anyone wants to contribute some code to do it, that might kick me into action on it, but any new callbacks would need to be designed in a generic, cross-platform way, and not just wrappers around whatever iOS does)


#4

I hacked around the issue for now by misusing the existing audioDeviceError callback. For this I changed the interruptionListener in juce_ios_Audio.cpp:

[code] void interruptionListener (const UInt32 interruptionType)
{
if (interruptionType == kAudioSessionBeginInterruption)
{
AudioIODeviceCallback* lastCallback;

        {
            const ScopedLock sl (callbackLock);
            lastCallback = callback;
        }
		
        if (lastCallback != nullptr)
            lastCallback->audioDeviceError("iOS audio session interruption");
    }

    if (interruptionType == kAudioSessionEndInterruption)
    {
        isRunning = true;
        AudioSessionSetActive (true);
        AudioOutputUnitStart (audioUnit);
    }
}[/code]

and made sure it gets forwarded through the AudioDeviceManager:

[code]void AudioDeviceManager::CallbackHandler::audioDeviceError (const String& errorMessage)
{
owner->audioDeviceErrorInt(errorMessage);
}

void AudioDeviceManager::audioDeviceErrorInt(const String& errorMessage)
{
const ScopedLock sl (audioCallbackLock);
for (int i = callbacks.size(); --i >= 0;)
callbacks.getUnchecked(i)->audioDeviceError(errorMessage);
}[/code]

This is probably not the general solution you are looking for. I am not sure how a more general solution should / could look like.

Patrick


#5

Ah, I’d forgotten that there was that error callback function. Ok… that’s not so bad.

Your attempt at locking the callback doesn’t make much sense though. Maybe something like this?

[code] void interruptionListener (const UInt32 interruptionType)
{
if (interruptionType == kAudioSessionBeginInterruption)
{
isRunning = false;
AudioOutputUnitStop (audioUnit);

        {
            const ScopedLock sl (callbackLock);
         
            if (callback != nullptr)
                callback->audioDeviceError ("iOS audio session interruption");
        }

        isRunning = true;
        routingChanged (nullptr);
    }

    if (interruptionType == kAudioSessionEndInterruption)
    {
        isRunning = true;
        AudioSessionSetActive (true);
        AudioOutputUnitStart (audioUnit);
    }

[/code]

?


#6

On a related note, how does the AudioDeviceManager cooperate with samplerate/buffersize changes when the app is re-brought to the foreground after being sent to the background by another app that uses different samplerate/buffersize?
Will it be restored to its previous state? If not, will this change fix the problem?


#7

Given a choice between fixing a show-stopper (audio completely stops) or dramatic improvements to IntroJucer I would pick the one that provides immediate benefits to users instead of developers, but that’s just me.


#8

Given a choice between fixing a show-stopper (audio completely stops) or dramatic improvements to IntroJucer I would pick the one that provides immediate benefits to users instead of developers, but that’s just me.[/quote]

Thanks Vinnie, I already heard you the last 3 or 4 times that you posted this same comment.


#9

Plus, what will happen when you launch a juce app while another app with different samplerate/buffersize is still playing?
I made a simple app that plays just a tone and I set its buffersize to 128 in order to test these issues (I am up-to-date with the tip and I applied the mods from this thread).
It crashes in this last case, but it doesn’t crash in the other cases and it looks like the buffersize is correctly restored.


#10

My comment was not really directed towards you, it was directed towards all the Projucer fanboys who might not realize the full cost of these bells and whistles.


#11

[quote=“jules”]Ah, I’d forgotten that there was that error callback function. Ok… that’s not so bad.

Your attempt at locking the callback doesn’t make much sense though. Maybe something like this? [/quote]

I copied the locking from the stop() method which seemed reasonable to me :slight_smile:
you probably wrote it like that because stop() also removes the callback.

I got a crash in processStatic with your original code though. I commented out the two lines below and the crash went away:

[code] void interruptionListener (const UInt32 interruptionType)
{
if (interruptionType == kAudioSessionBeginInterruption)
{
isRunning = false;
AudioOutputUnitStop (audioUnit);

        {
            const ScopedLock sl (callbackLock);
         
            if (callback != nullptr)
                callback->audioDeviceError ("iOS audio session interruption");
        }

// isRunning = true;
// routingChanged (nullptr);
}

    if (interruptionType == kAudioSessionEndInterruption)
    {
        isRunning = true;
        AudioSessionSetActive (true);
        AudioOutputUnitStart (audioUnit);
    }

[/code]

Maybe the crash has to do with me closing the device manager in audioDeviceError:

_DeviceManager->removeAudioCallback(this); _DeviceManager->closeAudioDevice(); _DeviceManager = 0;


#12

Ouch! Yes, it’s probably not very wise to delete the device manager in a method that the device manager is calling!


#13

[quote=“masshacker”]Plus, what will happen when you launch a juce app while another app with different samplerate/buffersize is still playing?
I made a simple app that plays just a tone and I set its buffersize to 128 in order to test these issues (I am up-to-date with the tip and I applied the mods from this thread).
It crashes in this last case, but it doesn’t crash in the other cases and it looks like the buffersize is correctly restored.[/quote]

I made a test with the new code, including the commented out lines. I put on a bluetooth headset and called my iPhone from another phone while my app was running (ah, the joys of mobile device development). udio was interrupted and was faded out nicely by iOS. After I hang up the call my ui showed paused state, I pressed play and audio started again.

Because my bluetooth headset switches to a different samplerate, the code changes fixed the issues I had with interruptions without crashes also with interruptions were samplerates change. Your details may vary of course depending on what your are doing in the device manager callbacks.


#14

Indeed. I postponed the deletion and everything works with your original code as expected now.


#15

Cool, thanks. I’ll check it in then.


#16

I found another problem. The routingChanged call reactivates the audio session:
AudioSessionSetActive (true);

This has the unfortunate side effect of stopping audio for the app that was responsible for the interruption in the first place.

This is bad. Consider the following use case:

  • JUCE app is playing
  • Switch to Music app on iPhone and press play
  • JUCE app gets interrupted, but instantly reclaims the audio session
  • Music app while in the foreground will go back to paused state without playing any audio because it thinks another app wants to play audio.

It seems the interruptionListener should only do the destructing part of routingChanged, not recreating the audio unit and session.


#17

Yes… good point. So just this then:

[code] void interruptionListener (const UInt32 interruptionType)
{
if (interruptionType == kAudioSessionBeginInterruption)
{
isRunning = false;
AudioOutputUnitStop (audioUnit);

        {
            const ScopedLock sl (callbackLock);

            if (callback != nullptr)
                callback->audioDeviceError ("iOS audio session interruption");
        }
    }

[/code]


#18

With isRunning set to false I am not sure close would continue to work correctly:

[code] void close()
{
if (isRunning)
{
isRunning = false;
AudioSessionRemovePropertyListenerWithUserData (kAudioSessionProperty_AudioRouteChange, routingChangedStatic, this);
AudioSessionSetActive (false);

        if (audioUnit != 0)
        {
            AudioComponentInstanceDispose (audioUnit);
            audioUnit = 0;
        }
    }
}

[/code]

Maybe we can just call close() instead?

void interruptionListener (const UInt32 interruptionType)
{
        if (interruptionType == kAudioSessionBeginInterruption)
        {
            {
                const ScopedLock sl (callbackLock);

                if (callback != nullptr)
                    callback->audioDeviceError ("iOS audio session interruption");
            }

            close();
       }
       
       ....
}

#19

Hmm… Maybe this?

[code] void interruptionListener (const UInt32 interruptionType)
{
if (interruptionType == kAudioSessionBeginInterruption)
{
AudioIODeviceCallback* lastCallback;

        {
            const ScopedLock sl (callbackLock);
            lastCallback = callback;
            callback = nullptr;
        }

        close();

        if (lastCallback != nullptr)
            lastCallback->audioDeviceError ("iOS audio session interruption");
    }

[/code]


#20

…no, sorry, that was nonsense.

But I do think it should stop the device before calling the callback, e.g.

[code] void interruptionListener (const UInt32 interruptionType)
{
if (interruptionType == kAudioSessionBeginInterruption)
{
close();

        {
            const ScopedLock sl (callbackLock);

            if (callback != nullptr)
                callback->audioDeviceError ("iOS audio session interruption");
        }
    }

[/code]