I made a game with JUCE! Help me add sound

Hi all. Please brace yourself, this will be a long post.

I made a little arcade game for fun on my spare time. I have actually already added sound: a handful of short samples that are played frequently. The sounds work without glitches and there seem to be no memory leaks, but I am still unsure if my architecture is good.

Main questions:
1) I want to play audio from a separate thread. Does my AudioThread mechanism make sense?
2) Am I using the JUCE Audio classes efficiently? Most examples I’ve seen try to reimplement MixerAudioSource or AudioSourcePlayer, whereas I have merely instantiated them as class members.

The parts of my Controller class which are sound-related look like this:

class Controller
{
public:
	enum SoundID
	{
		SOUND_NONE = 0,
		SOUND_CLICK,
		SOUND_EXPLODE,
		// ... More sounds
	};

	void QueueSound(SoundID soundID);

private:
	class AudioThread : public juce::Thread
	{
	public:
		AudioThread();

		void run() override;

		void QueueSound(SoundID soundID);

	private:
		std::atomic<SoundID> m_soundID = { SoundID::SOUND_NONE };
	};

	AudioThread m_audioThread;

	void InitAudio();

	void ShutdownAudio();

	void PlaySound(SoundID soundID);

	juce::AudioDeviceManager m_audioDeviceManager;

	juce::AudioSourcePlayer m_audioPlayer;

	juce::MixerAudioSource m_audioMixer;

	typedef std::unique_ptr<juce::AudioFormatReaderSource> SoundSource;

	std::map<SoundID, SoundSource> m_soundSources;
};

The Controller::InitAudio() and Controller::ShutdownAudio() methds are called in the constructor and destructor respectively, and is where the JUCE Audio classes are initialized and configured. This is where I am unsure if this is being done in the most efficient way, since I haven’t fully understood how these different classes relate and interact with each other. Some kind of class or interaction diagram would be cool to have in the docs.

void Controller::InitAudio()
{
	// Enable support for WAV files and other commom formats.
	juce::AudioFormatManager audioFormatManager;
	audioFormatManager.registerBasicFormats();

	// Stereo output.
	m_audioDeviceManager.initialiseWithDefaultDevices(0, 2);

	// Registers audio callback to be used.
	m_audioDeviceManager.addAudioCallback(&m_audioPlayer);

	juce::AudioIODevice* audioDevice = m_audioDeviceManager.getCurrentAudioDevice();
	if (audioDevice)
	{
		for (int i = SOUND_CLICK; i < SoundID::SOUND_MAX; i++)
		{
			std::unique_ptr<juce::InputStream> inputStream;

			SoundID sId = static_cast<SoundID>(i);
			switch (sId)
			{
				case SOUND_CLICK:
					inputStream = std::make_unique<juce::MemoryInputStream>(BinaryData::tap_wav, BinaryData::tap_wavSize, true);
					break;
					
				// Other cases....
				
				default:
					break;
			}

			// Create a new AudioSource which gets its data from the binary stream above.
			SoundSource source(new juce::AudioFormatReaderSource(audioFormatManager.createReaderFor(std::move(inputStream)), true));

			// Store this source in a map to be easily found later, in Controller::PlaySound().
			m_soundSources.insert(std::make_pair(sId, std::move(source)));
		}

		// Audio is to be mixed by m_audioMixer and passed on to 
		// the AudioSourcePlayer, which then streams it to the AudioIODevice.
		m_audioPlayer.setSource(&m_audioMixer);

		// Start the AudioThread, which will lay dormant 
		// until woken up by calls to QueueSound().
		m_audioThread.startThread();
	}
}

void Controller::ShutdownAudio()
{
	// -- TODO: are these really needed?
	for (std::map<SoundID, SoundSource>::iterator iter = m_soundSources.begin(); iter != m_soundSources.end(); ++iter)
	{
		iter->second->releaseResources();
	}
	m_audioMixer.releaseResources();
	m_audioMixer.removeAllInputs();
	// --


	// Exception in CriticalSection::enter() without this.
	m_audioPlayer.setSource(nullptr);

	// Attempts to stop the thread running. The threadShouldExit() method will return true,
	// and notify() will be called in case the thread is currently waiting.
	m_audioThread.stopThread(2000);
}

The Controller::QueueSound method will simply pass on to AudioThread, which will be then woken up. Once awake, AudioThread::run() will call Controller::PlaySound and the sound will be played from that thread.

void Controller::QueueSound(SoundID soundID)
{
	m_audioThread.QueueSound(soundID);
}

void Controller::PlaySound(SoundID soundID)
{
	juce::AudioIODevice* audioDevice = m_audioDeviceManager.getCurrentAudioDevice();
	if (audioDevice && (m_soundSources.count(soundID) != 0))
	{
		// Set playhead back to the start of the sample, 
		// and add it to the mixer if not already added before.
		// TODO: adding immediately plays it, so not added in InitAudio().
		m_soundSources.at(soundID)->setNextReadPosition(0);
		m_audioMixer.addInputSource(m_soundSources.at(soundID).get(), false);
	}
}

void Controller::AudioThread::run()
{
	while (!threadShouldExit())
	{
		// Just a safety net to avoid constantly triggering the sound,
		// should the wait() call not have immediate effect. 
		// TODO: need this?
		if (m_soundID != SOUND_NONE)
		{
			Controller::GetInstance()->PlaySound(m_soundID);

			m_soundID = SOUND_NONE;
		}

		// Stop the AudioThread until another thread calls notify().
		wait(-1); 
	}
}

void Controller::AudioThread::QueueSound(SoundID soundID)
{
	// Wake up the AudioThread, and let it know 
	// which sound is to be triggered once run() is called.
	m_soundID = soundID;

	notify();
}

Finally, to trigger a sound from the GUI, I can call:

void MainComponent::mouseDown(const juce::MouseEvent& event)
{
    Controller::GetInstance()->QueueSound(Controller::SOUND_CLICK);
}

And you will immediately hear the click sound effect. There is no perceptible delay between the mouse click and the sound, so it appears that waking up the AudioThread with notify(), and then triggering the sound from the AudioThread()::run() method is fast enough.

Thank you for reading this far, hope to hear your comments :slight_smile:
Cheers!

1 Like

I don’t want to write a wall of text here, there is a lot to say, so here are a few short thoughts.

From a “code reuse” perspective I recommend removing everything sound specific, like SOUND_EXPLODE, from your audio thread and sound controller code. You can implement the sound id hashmap, loading and other high level management classes in another place.

Your sound controller should only play chunks of sound (AudioBuffer/AudioBlock) and shouldn’t care about what it actually plays. Put game related stuff like resource identifiers into your game code. Everything low level (thread, sound driver, audio format) related can be abstracted away here. Decouple it from your audio resource format (FLAC/OGG/WAV). This is also your danger zone. Make it real-time safe and avoid allocations here. Make it bullet proof and thread safe.

On top of that you add audio processors. juce::dsp is your friend and offers a lot. Add some kind of mixer, so you can play many sounds (of the same kind) at once. Set panning, volume, and perhaps the playback speed. Optionally after that → build an effects chain (reverb, filter) for cool effects like caves or underwater ambience.

Now… on top of that at a higher abstraction level. Introduce the concept of a “Sound Instance”. Which uses some “Interface” of your sound controller. If done cleverly you can probably hide the thread safety stuff behind your interface. So your game can use your sound objects however it wants without worrying about threading issues.

The actual loading of your sounds into memory can be a totally separate concern. Ideally use a thread pool with jobs, so you can evenly spread the resource loading.

It’s a layered approach and your game is on top of everything. Don’t drag down a game design object like “sound” into a hardware related layer “like audio thread + controller”. It probably helps to draw a diagram and reduce coupling between the layers. A clean interface will make your audio controller usable in many future projects.

1 Like