Core Audio: when using a device more than once per process, closing takes 2 seconds

Problem
When using a certain Core Audio device more than once per process, closing the non-last instance takes 2 seconds.
In our case we rely on the destruction of the current device before creating an instance for the new device. This makes our application freeze for 2 seconds.

Cause
When closing a Core Audio device, the CoreAudioInternal::stop() method will wait for the kAudioDevicePropertyDeviceIsRunning property to become 0. However, this property will not become 0 as long as there are any other device clients active within the same process. After 2 seconds the state of kAudioDevicePropertyDeviceIsRunning is ignored and the method returns anyway.

// wait until it's definitely stopped calling back..
for (int i = 40; --i >= 0;) 
{
    Thread::sleep(50);

    UInt32 running = 0;
    UInt32 size = sizeof(running);

    AudioObjectPropertyAddress pa;
    pa.mSelector = kAudioDevicePropertyDeviceIsRunning;
    pa.mScope = kAudioObjectPropertyScopeWildcard;
    pa.mElement = juceAudioObjectPropertyElementMain;

    OK(AudioObjectGetPropertyData(deviceID, &pa, 0, nullptr, &size, &running));

    if (running == 0)
        break;
}

// From juce_mac_CoreAudio.cpp:721

Potential solution
There is a good reason for this mechanism to exist, a call to AudioDeviceStop() doesn’t guarantee any ioproc callback from being made after this call returns when called from a thread other than the io thread.

On the other hand, when AudioDeviceStop() is being called from the IO thread (from within the call to the AudioDeviceIOProc callback) there actually is a guarantee that the callback won’t happen anymore after AudioDeviceStop() returned.

To quote Jeff Moore from Apple’s Core Audio team:

When AudioDeviceStop() is called on the IO thread, things are different. The HAL will not call the IOProc that was stopped again after the AudioDeviceStop() call completes, but the buffer provided by that IOProc will get played (and nothing after that from that IOProc).

With this guarantee in mind the CoreAudioInternal class could be changed to stop the device on the IO thread instead of the message thread.

My proposal would be to add an std::atomic<bool> to the CoreAudioInternal class and use that to signal between the message thread and the IO thread.

The CoreAudioInternal::stop() method then becomes:

void stop (bool leaveInterruptRunning)
{
    {
        const ScopedLock sl (callbackLock);
        callback = nullptr;
    }

    if (started && (deviceID != 0) && ! leaveInterruptRunning)
    {
        shouldStopDeviceOnIOThread = true; // Set flag for audioCallback to stop the device
        started = false;

        { const ScopedLock sl (callbackLock); }

        // wait until it's definitely stopped calling back..
        for (int i = 40; --i >= 0;)
        {
            if (shouldStopDeviceOnIOThread == false)
                break;

            Thread::sleep (50);
        }

        const ScopedLock sl (callbackLock);

        OK (AudioDeviceDestroyIOProcID (deviceID, audioProcID));
        audioProcID = {};
    }
}

And stopping the device will then be done inside the audioCallback() method:

void audioCallback (const AudioBufferList* inInputData,
                    AudioBufferList* outOutputData)
{
    const ScopedLock sl (callbackLock);

    if (shouldStopDeviceOnIOThread)
    {
        if (OK (AudioDeviceStop (deviceID, audioProcID)))
            shouldStopDeviceOnIOThread = false;
        return;
    }
    
    // More irrelevant code not shown
}

Attached is a patch with the proposed changes.

juce_mac_CoreAudio.cpp.patch (2.5 KB)

@ed95 Could you please have a look at this?

Thanks for reporting. We’ve pushed some changes to develop which should address this:

Many thanks @ed95 , the change fixes our problem very well! Thanks!

1 Like