BUG: Audio device starts when properties change even if was not running


#1

I’m using a JUCE CoreAudioDevice in my code and some users reported that under certain circumstances audio starts playing although my app isn’t running.

I was now able to reproduce the issue.
When my app launches it opens a CoreAudioDevice at 48000 Hz. In my case Logic was opened already forcing the audio device at 44100 Hz.

Hence, deviceDetailsChanged() in juce_Mac_Core_Audio.cpp is called.

void deviceDetailsChanged()
{
    if (callbacksAllowed.get() == 1)
        startTimer (100);
}

The timerCallback in turn calls the device’s restart method even if the device is not running:

void timerCallback() override
{
    JUCE_COREAUDIOLOG ("Device changed");

    stopTimer();
    const double oldSampleRate = sampleRate;
    const int oldBufferSize = bufferSize;

    if (! updateDetailsFromDevice())
        owner.stop();
    else if (oldBufferSize != bufferSize || oldSampleRate != sampleRate)
        owner.restart();
}

An easy fix was to just add another if statement to only restart the device if it was running before:

else if ((oldBufferSize != bufferSize || oldSampleRate != sampleRate) && started)
        owner.restart();

#2

I don’t think that fix will work, started will always be false as the call to stop() will happen before the timer callback is called from the device change callback. This means that restart() will never be called and the audio device won’t restart correctly after a sample rate or buffer size change.

Can you post some code for how you’re opening the audio device and adding your audio callback? AFAICT the audio callback shouldn’t be called until you actually call start() on the device and pass it your AudioIODeviceCallback object.


#3

Hmm, that may be true.

Here’s some code bits I’m using for setting up the audio device:

AudioDeviceManager deviceManager;
deviceManager.createAudioDeviceTypes(_audioIODeviceTypes);

for (int i = 0; i < _audioIODeviceTypes.size(); ++i)
{
    String typeName (_audioIODeviceTypes[i]->getTypeName());
    _audioIODeviceTypes[i]->scanForDevices();  // This must be called before getting the list of devices
    StringArray deviceNames(_audioIODeviceTypes[i]->getDeviceNames());  // This will now return a list of available devices of this type
    for (int j = 0; j < deviceNames.size(); ++j)
    {
        AudioIODevice* device = _audioIODeviceTypes[i]->createDevice(deviceNames[j], String());
        AudioDevice::Ptr oneDevice = std::make_shared<AudioDevice>(device, 48000.0, 512);
        _audioDevices.push_back(oneDevice);
    }
}

In the constructor for AudioDevice is a call to open the device like so:
_juceAudioDevice-&gt;open(inputChannels, outputChannels, sampleRate, bufferSize);

And to start the device when playback is started:
_juceAudioDevice-&gt;start(this);

This is the code path being executed when my app is launched while Logic is already running at 44100 Hz and the audio device is NOT running:

juce_mac_CoreAudio.cpp, line 869ff:

static OSStatus deviceListenerProc (AudioDeviceID /*inDevice*/, UInt32 /*inLine*/, const AudioObjectPropertyAddress* pa, void* inClientData)

case kAudioStreamPropertyPhysicalFormat:
    intern->deviceDetailsChanged();
    break;

deviceDetailsChanged() starts the timer. And the timerCallback() in turn restarts the device no matter if it was running running before or not (as the sample rate changed).

What would be the right way to fix this then?
Add something like wasRunning as a member and checking that instead to make sure to not restart a device which was not previously running?


#4

I’m not sure I understand the issue correctly. I can see that restart() is called whenever the device details have changed and that this will cause the device to be started, but I can’t see how it would cause your app to output audio until you’ve passed an AudioIODeviceCallback object in by explicitly calling AudioIODevice::start() in which case this is the expected behaviour as you’ve started the audio device.


#5

The audio device seems to remember the previousCallback.

void timerCallback() override
{
    stopTimer();

    stop();

    internal->updateDetailsFromDevice();

    open (inputChannelsRequested, outputChannelsRequested,
          getCurrentSampleRate(), getCurrentBufferSizeSamples());
    start (previousCallback);
}

Code path: deviceDetailsChanged -> startTimer -> timerCallback calls owner.restart -> startTimer -> timerCallback starts device with previous callback.

Does that make sense?


#6

OK, I managed to reproduce the issue and have pushed a fix to the develop branch here. Please test it out and let me know if it fixes your issue.


#7

Thanks a lot!
I think restartDevice must be initialized to false. Then it works.


#8

That defeats the purpose of the flag as the device will never be restarted even if it is required.

Here is the code that I was using to reproduce the issue:

/*******************************************************************************
 The block below describes the properties of this PIP. A PIP is a short snippet
 of code that can be read by the Projucer and used to generate a JUCE project.

 BEGIN_JUCE_PIP_METADATA

  name:             CoreAudioDeviceTest

  dependencies:     juce_audio_basics, juce_audio_devices, juce_core, juce_data_structures, juce_events, juce_graphics, 
                    juce_gui_basics
  exporters:        xcode_mac

  moduleFlags:      JUCE_STRICT_REFCOUNTEDPOINTER=1

  type:             Component
  mainClass:        MainComponent

 END_JUCE_PIP_METADATA

*******************************************************************************/

#pragma once

//==============================================================================
class MainComponent   : public Component,
                        public AudioIODeviceCallback
{
public:
    //==============================================================================
    MainComponent()
    {
        if (auto* coreAudioDevice = AudioIODeviceType::createAudioIODeviceType_CoreAudio())
        {
            coreAudioDevice->scanForDevices();
            
            device.reset (coreAudioDevice->createDevice ("Built-in Output", {}));
            jassert (device != nullptr);
        }
        
        auto err = device->open (0, 2, 48000.0, 512);
        jassert (err.isEmpty());
        
        device->start (this);
        device->stop();
        
        setSize (600, 400);
    }
    
    ~MainComponent()
    {
    }
    
    //==============================================================================
    void paint (Graphics& g) override
    {
        g.fillAll (getLookAndFeel().findColour (ResizableWindow::backgroundColourId));
    }
    
    void audioDeviceIOCallback (const float** inputChannelData, int numInputChannels,
                                float** outputChannelData, int numOutputChannels, int numSamples) override
    {
        jassertfalse;
    }
    
    void audioDeviceAboutToStart (AudioIODevice* device) override  {}
    void audioDeviceStopped() override                             {}
    
private:
    //==============================================================================
    std::unique_ptr<AudioIODevice> device = nullptr;
    
    JUCE_DECLARE_NON_COPYABLE_WITH_LEAK_DETECTOR (MainComponent)
};

Before the fix that I posted above, if you started the program whilst something like Logic was running in the background then the audio callback would be started even though we have explicitly called stop() in the MainComponent constructor. The purpose of the restartDevice flag is to avoid this by only allowing restarts if the user hasn’t called stop().

Can you put together a simple test app like the one above to demonstrate the issue you’re having?


#9

Ok, thanks for your effort in fixing this and sorry for the delay. Got a lot on my plate right now…

Your changes fix the issue if the device has been started and stopped at least once after the deviceDetailsChanged callback appears. Then it does what it should. Thanks for that!

Your fix however doesn’t work if the device hasn’t been started yet.
If it hasn’t, restart will be called (as restartDevice is initialized to true) which will cause the Core Audio device to start without a callback. Not sure what happens then.

It doesn’t call any of my code but it does call AudioDeviceCreateIOProcID(deviceID, audioIOProc, this, &audioProcID) and AudioDeviceStart (deviceID, audioIOProc).

You can try what happens by commenting out the start/stop calls in your code sample.


#10

I don’t think that should be an issue, and we need the device open for querying sample rates, buffer sizes etc. which can happen without passing an AudioIODeviceCallback to the device.