Audio Callback not triggered in CLI app – no sound output

Hi everyone,

I’m trying to create a simple command-line application with JUCE that plays white noise using AudioDeviceManager. However, while AudioDeviceManager::playTestSound() works and I hear the test sound, my custom AudioIODeviceCallback is never called and no audio is generated.

Here’s a minimal example of my setup:

#include <juce_core/juce_core.h>
#include <juce_audio_devices/juce_audio_devices.h>
#include <juce_events/juce_events.h>
#include <thread>
#include <chrono>

class WhiteNoiseCallback : public juce::AudioIODeviceCallback
{
public:
    void audioDeviceIOCallback(const float** /*inputChannelData*/,
                               int /*numInputChannels*/,
                               float** outputChannelData,
                               int numOutputChannels,
                               int numSamples)
    {
        for (int channel = 0; channel < numOutputChannels; ++channel)
        {
            float* buffer = outputChannelData[channel];
            if (buffer != nullptr)
            {
                for (int i = 0; i < numSamples; ++i)
                {
                    // White Noise: -0.25 .. +0.25
                    buffer[i] = juce::Random::getSystemRandom().nextFloat() * 0.5f - 0.25f;
                }
            }
        }
    }

    void audioDeviceAboutToStart(juce::AudioIODevice* device) override
    {
        juce::Logger::writeToLog("Audio device started: " + device->getName());
    }

    void audioDeviceStopped() override
    {
        juce::Logger::writeToLog("Audio device stopped.");
    }
};

int main (int argc, char* argv[])
{
    juce::ignoreUnused(argc, argv);
    juce::ScopedJuceInitialiser_GUI juceInit;
    juce::ConsoleApplication app;

    juce::AudioDeviceManager deviceManager;
    WhiteNoiseCallback noiseCallback;

    auto error = deviceManager.initialiseWithDefaultDevices(0, 2);
    if (error.isNotEmpty())
    {
        std::cerr << "Audio Init Error: " << error << std::endl;
        return 1;
    }

    deviceManager.playTestSound();

    if (auto* device = deviceManager.getCurrentAudioDevice())
    {
        std::cout << "Using device: " << device->getName() << std::endl;
        std::cout << "Sample Rate: " << device->getCurrentSampleRate() << std::endl;
        std::cout << "Buffer Size: " << device->getCurrentBufferSizeSamples() << " samples" << std::endl;
        std::cout << "Active outputs: " << device->getActiveOutputChannels().toInteger() << std::endl;
    }

    if (juce::MessageManager::getInstance()->isThisTheMessageThread())
        std::cout << "We are in the MessageThread" << std::endl;
    else
        std::cout << "We are NOT in the MessageThread" << std::endl;

    deviceManager.addAudioCallback(&noiseCallback);
    std::cout << "Playing white noise for 5 seconds..." << std::endl;
    std::this_thread::sleep_for(std::chrono::seconds(5));

    deviceManager.removeAudioCallback(&noiseCallback);
    std::cout << "Done." << std::endl;

    return 0;
}

What I’ve tried so far:

  • Using ScopedJuceInitialiser_GUI to initialize JUCE in a CLI app
  • Checking that the audio device is opened and has 2 output channels
  • Confirmed that playTestSound() works and produces sound

Issue:

Despite the device being initialized correctly, the audioDeviceIOCallback is never invoked and no white noise is heard.

Question:

Does anyone know why the callback is not called in this CLI setup and how I can make the AudioDeviceManager trigger my callback correctly?

Thanks in advance for any guidance!

first thing that came out to me is no include of juce_header that usually starts the operation.

It’s best to make your commandline app a JUCE app with no GUI, actually, for this kind of thing.
In Projucer, you can leave the project as a Console Application, but your main.h/cpp need to define a juce::JUCEApplication:

class ConsoleAudioProject : public juce::JUCEApplication
{
public:
    const juce::String getApplicationName() override { return ProjectInfo::projectName; }
    const juce::String getApplicationVersion() override { return ProjectInfo::versionString; }
    
    ~ConsoleAudioProject() override { }
    
    void initialise (const juce::String& commandLineParameters) override;
    
    void shutdown() override;
    void systemRequestedQuit() override { quit(); }
    bool moreThanOneInstanceAllowed() override { return false; }
    
    
private:
    juce::AudioDeviceManager audioDeviceManager;
    TestSignalAudioSource testSignalAudioSource;
    std::unique_ptr<SystemTrayIcon> systemTrayIcon;
#if JUCE_MAC
    std::unique_ptr<DummyMenuBarModel> model;
#endif
};

The important part is in your initialise function, as follows:

void ConsoleAudioProject::initialise(const juce::String& args)
{
    systemTrayIcon = std::make_unique<SystemTrayIcon>();
    juce::Image iconImage = juce::ImageFileFormat::loadFrom(BinaryData::tray_icon_jpg, BinaryData::tray_icon_jpgSize);
    if( iconImage.isNull() )
    {
        jassertfalse; //failed to load tray icon image!
    }
    else
    {
        systemTrayIcon->setIconImage(iconImage, iconImage);
    }

    auto audioSystemInitResult = audioDeviceManager.initialise(0, 2, nullptr, true);
    if( audioSystemInitResult.isEmpty() )
    {
        audioDeviceManager.addAudioCallback(&testSignalAudioSource);
    }
}

and in your shutdown function:

void ConsoleAudioProject::shutdown()
{
    audioDeviceManager.removeAudioCallback(&testSignalAudioSource);
}

the TestSignalAudioSource is just a class that inherits from juce::AudioIODeviceCallback, such as your white noise generator class.

At the end of main.cpp, be sure to put START_JUCE_APPLICATION(ConsoleAudioProject)


As for the SystemTrayIcon, when you make a JUCE App with no GUI, you have no way of closing it when debugging without force-killing in your IDE.

So, SystemTrayIcon is just a simple class that provides a system tray icon.
The DummyMenuBarModel is required on mac to provide the popup menu with the ‘quit’ option so you can quit the app when clicking on the system tray icon.

/*
  ==============================================================================

    SystemTrayIcon.h
    Created: 7 Jul 2025 10:29:27am
    Author:  Matkat Music LLC

  ==============================================================================
*/

#pragma once

#include <JuceHeader.h>

struct SystemTrayIcon : public juce::SystemTrayIconComponent
{
    void mouseDown(const juce::MouseEvent& e) override;
};

#if JUCE_MAC //https://forum.juce.com/t/macos-app-as-background-process-with-system-tray-icon-and-native-menu/39905/3?u=matkatmusic
class DummyMenuBarModel final : public juce::MenuBarModel
{
public:
    DummyMenuBarModel();
    ~DummyMenuBarModel() override;
    juce::StringArray getMenuBarNames() override;
    juce::PopupMenu getMenuForIndex (int, const juce::String&) override;
    void menuItemSelected (int, int) override;
};
#endif
/*
  ==============================================================================

    SystemTrayIcon.cpp
    Created: 7 Jul 2025 10:29:27am
    Author:  Matkat Music LLC

  ==============================================================================
*/

#include "SystemTrayIcon.h"

void SystemTrayIcon::mouseDown(const juce::MouseEvent& e)
{
    juce::PopupMenu menu;
    menu.addItem("Quit", true, false, [this]()
                 {
        juce::JUCEApplication::getInstance()->systemRequestedQuit();
    });
    
#if JUCE_MAC
    showDropdownMenu(menu);
#elif JUCE_WINDOWS
    //TODO: why doesn't this appear on windows?
    menu.showMenuAsync(juce::PopupMenu::Options().withParentComponent(this).withStandardItemHeight(18).withMinimumWidth(100));
#endif
}

#if JUCE_MAC
DummyMenuBarModel::DummyMenuBarModel()
{
    juce::MenuBarModel::setMacMainMenu(this);
}
DummyMenuBarModel::~DummyMenuBarModel()
{
    juce::MenuBarModel::setMacMainMenu (nullptr);
}

juce::StringArray DummyMenuBarModel::getMenuBarNames()  { return {""}; }
juce::PopupMenu DummyMenuBarModel::getMenuForIndex (int, const juce::String&)  { return juce::PopupMenu(); }
void DummyMenuBarModel::menuItemSelected (int, int)  {}
#endif

Ah, I thought JUCEHeader.h is deprecated (see link). Or did you mean something else?

Thanks a lot for the detailed answer, matkatmusic. I’ve now tried using JuceApplication with the
START_JUCE_APPLICATION macro, but I’m not hearing anything at all. As a test, I added logging inside the callback function, and it seems like it’s never being called. Do you happen to have a complete minimal example that works for you as a console app with sound output?

I second what @matkatmusic has suggested but there are two other things.

  1. Most importantly, at least for the current branch of JUCE, audioDeviceIOCallback is not an overridable function of AudioIODeviceCallback, instead you want to override audioDeviceIOCallbackWithContext

  2. I would suggest not doing a sleep but instead have a timer that you trigger to stop the application

So for example…

class WhiteNoiseCallback : public juce::AudioIODeviceCallback
{
public:
    void audioDeviceIOCallbackWithContext (const float* const* /*inputChannelData*/,
                                           int /*numInputChannels*/,
                                           float* const* outputChannelData,
                                           int numOutputChannels,
                                           int numSamples,
                                           const juce::AudioIODeviceCallbackContext& /*context*/) override
    {
        for (int channel = 0; channel < numOutputChannels; ++channel)
        {
            float* buffer = outputChannelData[channel];
            if (buffer != nullptr)
            {
                for (int i = 0; i < numSamples; ++i)
                {
                    // White Noise: -0.25 .. +0.25
                    buffer[i] = juce::Random::getSystemRandom().nextFloat() * 0.5f - 0.25f;
                }
            }
        }
    }

    void audioDeviceAboutToStart(juce::AudioIODevice* device) override
    {
        juce::Logger::writeToLog("Audio device started: " + device->getName());
    }

    void audioDeviceStopped() override
    {
        juce::Logger::writeToLog("Audio device stopped.");
    }
};
class MainComponent  : public juce::Component,
                       private juce::Timer
{
public:
    //==============================================================================
    MainComponent();
    ~MainComponent() override;

    //==============================================================================
    void paint (juce::Graphics&) override;

private:
    void timerCallback() override;
    
    //==============================================================================
    juce::AudioDeviceManager deviceManager;
    WhiteNoiseCallback noiseCallback;

    JUCE_DECLARE_NON_COPYABLE_WITH_LEAK_DETECTOR (MainComponent)
};
//==============================================================================
MainComponent::MainComponent()
{
    auto error = deviceManager.initialiseWithDefaultDevices(0, 2);
    jassert (error.isEmpty());

    if (auto* device = deviceManager.getCurrentAudioDevice())
    {
        std::cout << "Using device: " << device->getName() << std::endl;
        std::cout << "Sample Rate: " << device->getCurrentSampleRate() << std::endl;
        std::cout << "Buffer Size: " << device->getCurrentBufferSizeSamples() << " samples" << std::endl;
        std::cout << "Active outputs: " << device->getActiveOutputChannels().toInteger() << std::endl;
    }

    deviceManager.addAudioCallback (&noiseCallback);

    setSize (600, 400);

    startTimer (5000);
}

MainComponent::~MainComponent()
{
    deviceManager.removeAudioCallback (&noiseCallback);
}

void MainComponent::timerCallback()
{
    juce::JUCEApplication::getInstance()->quit();
}

//==============================================================================
void MainComponent::paint (juce::Graphics& g)
{
    g.fillAll (getLookAndFeel().findColour (juce::ResizableWindow::backgroundColourId));
}

so thanks for that info for that juce header :eyes: that went passed me.

But back to your problem. Something that sticks out; you have no AudioDeviceManager that will happily let you call playTestSound() without a running message thread, but custom AudioIODeviceCallback require the message loop to be alive so that audio callbacks can actually be pumped.

You can use 2 options:

  1. Add a message loop
    Replace the raw sleep_for(5s) with something that pumps JUCE’s event loop. Example
juce::MessageManager::getInstance()->runDispatchLoopUntil (5000); // runs for 5 seconds

That will keep JUCE’s machinery alive and callbacks firing.


  1. Use a non-GUI init and pump manually
    If you really want it to stay “pure CLI,” you can drop ScopedJuceInitialiser_GUI and just call:
juce::initialiseJuce_GUI();

and then again, run runDispatchLoopUntil().

If you’re checking MessageManager::isThisTheMessageThread() — in a console app without a proper loop, that’ll lie to you a bit. It is the message thread, but since you never ran the loop, nothing gets processed.

I took the time to write up this example and maybe it can work to your needs:
White noise CLI with tray icon:

#include <juce_gui_basics/juce_gui_basics.h>
#include <juce_audio_devices/juce_audio_devices.h>

class WhiteNoiseCallback : public juce::AudioIODeviceCallback
{
public:
    void audioDeviceIOCallback (const float**,
                                int,
                                float** outputChannelData,
                                int numOutputChannels,
                                int numSamples) override
    {
        for (int ch = 0; ch < numOutputChannels; ++ch)
        {
            float* buffer = outputChannelData[ch];
            if (buffer != nullptr)
            {
                for (int i = 0; i < numSamples; ++i)
                    buffer[i] = juce::Random::getSystemRandom().nextFloat() * 0.5f - 0.25f;
            }
        }
    }

    void audioDeviceAboutToStart (juce::AudioIODevice* device) override
    {
        DBG ("Audio started: " << device->getName());
    }

    void audioDeviceStopped() override
    {
        DBG ("Audio stopped.");
    }
};

class TrayApp  : public juce::JUCEApplication,
                 private juce::SystemTrayIconComponent
{
public:
    const juce::String getApplicationName() override       { return "WhiteNoiseCLI"; }
    const juce::String getApplicationVersion() override    { return "1.0"; }

    void initialise (const juce::String&) override
    {
        // setup tray icon (no GUI window)
        setIconImage (juce::ImageFileFormat::loadFrom (juce::File("/path/to/icon.png")));
        setIconTooltip ("White Noise CLI");
        setVisible (true);

        // setup audio
        auto err = deviceManager.initialiseWithDefaultDevices (0, 2);
        if (err.isNotEmpty())
        {
            DBG ("Audio init error: " << err);
            quit();
            return;
        }

        deviceManager.addAudioCallback (&noise);
    }

    void shutdown() override
    {
        deviceManager.removeAudioCallback (&noise);
    }

    void mouseDown (const juce::MouseEvent&) override
    {
        // click tray icon to quit
        quit();
    }

private:
    juce::AudioDeviceManager deviceManager;
    WhiteNoiseCallback noise;
};

// main entry
START_JUCE_APPLICATION (TrayApp)


If you are on Mac, make sure to request audio device permissions. Otherwise the audio callback is simply not called.

That is a projucer/cmake setting.

The RuntimePermissions might be handled by the AudioDeviceManager, but better check…

1 Like

Thanks a lot for the helpful tips! Thanks to audioDeviceIOCallbackWithContext and juce::MessageManager::getInstance()->runDispatchLoop(), my application is now working.

I can compile the application both with my own main() and using the START_JUCE_APPLICATION macro. Both work perfectly.

Question: is there any real advantage to using START_JUCE_APPLICATION for this purely CLI application? I actually prefer the purist approach with my own main(), as it gives me full control over what happens and when. What are the pros and cons?

I’m developing on macOS, but eventually the application will run on Linux. Thanks in advance!

1 Like