Introduction
Welcome to the final lesson in our module on audio integration! In the previous lesson, we built a powerful 3D positional audio system using OpenAL. You learned how to represent sounds in a 3D world with listeners and sources, and how to make them realistically fade with distance. We concluded with an example that created and cleaned up a single sound source using audio_source_unload and audio_shutdown.
While that works for one sound, a real game involves hundreds of assets. Loading them on an ad-hoc basis is inefficient and, more importantly in C, a recipe for resource leaks. Forgetting to free even one resource can lead to bugs and crashes. This lesson directly addresses the learning outcome: Manage and clean up audio resources on exit.
Today, we will elevate our audio module from a simple player to a robust Resource Manager. We will refactor our code to:
- Understand the fundamental C principle of pairing every resource allocation with a deallocation.
- Implement a system to prevent loading the same sound file multiple times.
- Centralize all cleanup logic into a single, reliable shutdown function.
This will make our audio engine more efficient, safer, and ready to scale for a full game project.
1. The Core Principle: Symmetrical Resource Management
In languages like C# or Python, a garbage collector automatically cleans up memory for objects that are no longer in use. In C, you are the garbage collector. Every piece of memory you malloc, every file you fopen, and every library object you create must be explicitly released by you.
A well-designed C library makes this clear by providing symmetrical init and uninit (or create/destroy, alloc/free) functions for every type of resource it manages. Let's look at the documentation for miniaudio, the library we started with, to see this pattern in action.
The miniaudio documentation provides an excellent overview of this symmetrical design pattern. As you read, notice how nearly every object type that can be initialized has a corresponding uninitialization function.
Skim through the documentation, specifically looking for function pairs. You don't need to read in detail, just spot the pattern for these different object types: ma_device_init() and ma_device_uninit() ma_engine_init() and ma_engine_uninit() ma_sound_init_...() and ma_sound_uninit() ma_resource_manager_data_source_init() and ma_resource_manager_data_source_uninit() ma_encoder_init_...() and ma_encoder_uninit() ma_data_converter_init() and ma_data_converter_uninit()
This pattern is universal. The resources we create with OpenAL are no different. We must be diligent about destroying them, and doing so in the correct order. Generally, you deallocate resources in the reverse order of allocation:
- Delete Sources (
alDeleteSources) - Delete Buffers (
alDeleteBuffers) - Destroy the Context (
alcDestroyContext) - Close the Device (
alcCloseDevice)
Let's watch a short clip that shows this exact cleanup sequence.
How to setup and use OpenAL for game audio in C++ (Using Visual Studio) tutorial
This clip from enigma tutorials demonstrates the correct procedure for cleaning up all OpenAL resources at the end of a program.
Watch the segment from 13:43 to 14:26. Observe the four key function calls and the order in which they are executed to shut down OpenAL.
Our goal is to create a system that guarantees this cleanup happens correctly for all audio assets, no matter how many we load.
2. Designing a Sound Manager
Right now, our audio_source_load function creates both a buffer and a source. If we call it twice with the same filepath, we load the same audio data into two separate OpenAL buffers, wasting memory.

We'll solve this by creating a Sound Manager. Its job is to manage the ALuint buffer resources. It will be responsible for:
- Loading Buffers: When asked for a sound, it first checks if it has already been loaded. If so, it returns the existing buffer ID. If not, it loads the file, creates a new buffer, and stores it for future requests.
- Tracking Buffers: It will maintain a list of all buffer IDs it has created.
- Centralized Cleanup: On shutdown, it will iterate through its list and call
alDeleteBufferson every single one.
A classic C data structure for a manager like this is a linked list. Each node in the list will store the file path of a sound and its corresponding OpenAL buffer ID.
Here is the design we'll implement inside audio.c:
// A node for our linked list of loaded sounds (buffers)
typedef struct sound_node {
char* filepath;
ALuint buffer;
struct sound_node* next;
} sound_node_t;
// The head of our list. It's static, so it's private to audio.c
static sound_node_t* sound_list_head = NULL;
This approach is a direct C-style translation of the SoundManager concept you can see in C++ game engine code. For a more in-depth look at how a manager like this is structured, you can refer to the following resource.
This document, written for C++, describes a SoundManager class that serves the exact purpose we're aiming for. It uses a std::map, which is a C++ data structure, but the core logic of preventing duplicates and centralizing deletion is identical.
Read the section 'The SoundManager class' to understand its responsibilities. Pay special attention to the AddSound function's logic for checking for duplicates and the DeleteSounds function, which iterates and deletes all sounds. Notice the final note: 'don’t forget to also call DeleteSounds...!' This highlights the importance of this cleanup step.
3. Implementing the Resource Manager
Now, let's refactor our audio module to implement this manager. We'll separate the concept of a sound buffer (managed data) from a sound source (an instance of a sound in the world).
3.1. New audio.h
Our public interface will be simplified. The main application will no longer manage audio_source_t structs. Instead, it will just get back an ALuint handle for a source and use that to control the sound. The complexities of buffer management will be hidden inside audio.c.
// src/audio.h
#ifndef AUDIO_H
#define AUDIO_H
#include <AL/al.h>
#include <AL/alc.h>
// Initializes the OpenAL audio system and our sound manager.
int audio_init(void);
// Shuts down the audio system, cleaning up ALL sources and buffers.
void audio_shutdown(void);
// Creates a new source and attaches a sound buffer to it.
// The sound is loaded (once) if it hasn't been already.
// Returns 0 on failure.
ALuint audio_create_source(const char* filepath);
// Deletes a single source. Buffers are managed separately.
void audio_delete_source(ALuint source);
// --- Functions to control a source using its handle ---
void audio_source_set_position(ALuint source, float x, float y, float z);
void audio_source_play(ALuint source);
void audio_source_set_looping(ALuint source, int should_loop);
void audio_source_set_gain(ALuint source, float gain);
// (Add other control functions like pitch, etc., as needed)
#endif // AUDIO_H
3.2. Updated audio.c
This is where our manager logic lives. We'll add the linked list and the functions to manage it.
First, add the necessary includes and our data structures at the top of audio.c.
// src/audio.c
#include "audio.h"
#include <stdio.h>
#include <stdlib.h> // For malloc, free
#include <string.h> // For strcmp, strcpy
#define DR_WAV_IMPLEMENTATION
#include "dr_wav.h"
// --- Sound Manager Data Structures ---
typedef struct sound_node {
char filepath[256]; // Use a fixed-size buffer for simplicity
ALuint buffer;
struct sound_node* next;
} sound_node_t;
// A list of ALL sources created, so we can clean them up.
typedef struct source_node {
ALuint source;
struct source_node* next;
} source_node_t;
static ALCdevice* device;
static ALCcontext* context;
static sound_node_t* sound_list_head = NULL;
static source_node_t* source_list_head = NULL;
Note we are now also tracking all created sources in a separate list. This ensures that even if the main application forgets to delete a source, our audio_shutdown function will clean it up, preventing leaks.
Now, let's implement the core logic.
// Add these new private helper functions to audio.c
// Finds a sound buffer by filepath, or loads it if not found.
static ALuint get_or_load_buffer(const char* filepath) {
// 1. Search the list for an existing sound
sound_node_t* current = sound_list_head;
while (current != NULL) {
if (strcmp(current->filepath, filepath) == 0) {
return current->buffer; // Found it!
}
current = current->next;
}
// 2. Not found, so load the WAV file
unsigned int channels;
unsigned int sample_rate;
drwav_uint64 total_pcm_frame_count;
short* pcm_data = drwav_open_file_and_read_pcm_frames_s16(filepath, &channels, &sample_rate, &total_pcm_frame_count, NULL);
if (pcm_data == NULL) {
printf("Failed to load WAV file: %s\n", filepath);
return 0;
}
// Determine format (we should use mono for positional audio)
ALenum format = (channels == 1) ? AL_FORMAT_MONO16 : AL_FORMAT_STEREO16;
if (channels > 1) {
printf("Warning: Positional audio works best with MONO sounds. %s is stereo.\n", filepath);
}
// 3. Create OpenAL buffer and fill it with data
ALuint buffer = 0;
alGenBuffers(1, &buffer);
alBufferData(buffer, format, pcm_data, total_pcm_frame_count * channels * sizeof(short), sample_rate);
drwav_free(pcm_data, NULL);
// 4. Create a new node and add it to the head of our list
sound_node_t* new_node = malloc(sizeof(sound_node_t));
strncpy(new_node->filepath, filepath, 255);
new_node->filepath[255] = '\0';
new_node->buffer = buffer;
new_node->next = sound_list_head;
sound_list_head = new_node;
printf("Loaded sound '%s' into new buffer.\n", filepath);
return buffer;
}
Now, update audio_shutdown to use our new lists for a complete cleanup.
// In audio.c, replace the old audio_shutdown
void audio_shutdown(void) {
printf("Shutting down audio system...\n");
// 1. Delete all sources
source_node_t* src_current = source_list_head;
while (src_current != NULL) {
alDeleteSources(1, &src_current->source);
source_node_t* next = src_current->next;
free(src_current);
src_current = next;
}
source_list_head = NULL;
printf("All sources deleted.\n");
// 2. Delete all buffers
sound_node_t* snd_current = sound_list_head;
while (snd_current != NULL) {
alDeleteBuffers(1, &snd_current->buffer);
sound_node_t* next = snd_current->next;
free(snd_current);
snd_current = next;
}
sound_list_head = NULL;
printf("All buffers deleted.\n");
// 3. Destroy context and device
alcMakeContextCurrent(NULL);
if (context) alcDestroyContext(context);
if (device) alcCloseDevice(device);
printf("OpenAL context and device shut down.\n");
}
Finally, implement the public functions from audio.h. audio_create_source will use our manager to get a buffer, and track the new source handle.
// In audio.c, add the new public functions
ALuint audio_create_source(const char* filepath) {
ALuint buffer = get_or_load_buffer(filepath);
if (buffer == 0) {
return 0; // Failed to get buffer
}
ALuint source = 0;
alGenSources(1, &source);
// Set default properties
alSourcef(source, AL_PITCH, 1.0f);
alSourcef(source, AL_GAIN, 1.0f);
alSource3f(source, AL_POSITION, 0.0f, 0.0f, 0.0f);
alSource3f(source, AL_VELOCITY, 0.0f, 0.0f, 0.0f);
alSourcei(source, AL_LOOPING, AL_FALSE);
// Set attenuation properties from last lesson
alSourcef(source, AL_ROLLOFF_FACTOR, 1.0f);
alSourcef(source, AL_REFERENCE_DISTANCE, 6.0f);
alSourcef(source, AL_MAX_DISTANCE, 50.0f);
alSourcei(source, AL_BUFFER, buffer);
// Add to our tracked sources list
source_node_t* new_node = malloc(sizeof(source_node_t));
new_node->source = source;
new_node->next = source_list_head;
source_list_head = new_node;
return source;
}
// NOTE: We no longer need to delete sources manually if we let audio_shutdown handle it.
// This function is for dynamic objects that are created and destroyed during gameplay.
void audio_delete_source(ALuint source) {
// For now, we'll let shutdown handle everything for simplicity.
// A full implementation would find and remove the source from the linked list.
// alDeleteSources(1, &source);
}
// Implement the control functions (they are simple wrappers now)
void audio_source_set_position(ALuint source, float x, float y, float z) {
alSource3f(source, AL_POSITION, x, y, z);
}
void audio_source_play(ALuint source) {
alSourcePlay(source);
}
void audio_source_set_looping(ALuint source, int should_loop) {
alSourcei(source, AL_LOOPING, should_loop ? AL_TRUE : AL_FALSE);
}
//... etc.
4. Testing the Manager in main.c
Let's update main.c to test our new manager. We'll create two sources that use the same sound file. When you run the program, check the console output. You should see that the .wav file is only loaded once, even though two sources are created from it.
// src/main.c
#include <stdio.h>
#include <math.h>
#include <GLFW/glfw3.h>
#include "audio.h"
int main(void) {
glfwInit();
GLFWwindow* window = glfwCreateWindow(640, 480, "Audio Manager Test", NULL, NULL);
glfwMakeContextCurrent(window);
if (!audio_init()) {
glfwTerminate();
return -1;
}
// You need a mono WAV file in your assets folder
const char* sound_file = "assets/your_mono_sound.wav";
// Create the first sound source
printf("Creating source 1...\n");
ALuint source1 = audio_create_source(sound_file);
if (source1 == 0) {
audio_shutdown();
glfwTerminate();
return -1;
}
audio_source_set_looping(source1, 1);
audio_source_play(source1);
// Create a SECOND sound source using the SAME file
// Our manager should NOT load the file again.
printf("\nCreating source 2...\n");
ALuint source2 = audio_create_source(sound_file);
if (source2 == 0) {
audio_shutdown();
glfwTerminate();
return -1;
}
audio_source_set_looping(source2, 1);
// We'll play this one later
printf("\nTest started. One source moving, one stationary.\n");
double start_time = glfwGetTime();
int played_source2 = 0;
while (!glfwWindowShouldClose(window)) {
glfwPollEvents();
double current_time = glfwGetTime();
// Animate source 1
float x_pos = 15.0f * sin((float)current_time * 0.5f);
audio_source_set_position(source1, x_pos, 0.0f, -5.0f);
// Position source 2 at a fixed spot and play it after 3 seconds
audio_source_set_position(source2, 0.0f, 0.0f, -10.0f);
if (!played_source2 && (current_time - start_time > 3.0)) {
printf("\nPlaying stationary source 2!\n");
audio_source_play(source2);
played_source2 = 1;
}
glClear(GL_COLOR_BUFFER_BIT);
glfwSwapBuffers(window);
}
// --- Cleanup ---
// The ONLY cleanup call we need now!
audio_shutdown();
glfwDestroyWindow(window);
glfwTerminate();
return 0;
}
Compile and run. Observe the console output and listen. You'll see the message "Loaded sound..." only once. You will hear the first sound panning left and right, and after three seconds, the second sound will start playing from a fixed central position. When you close the window, audio_shutdown will be called, and the console will confirm that all sources and buffers have been deleted.
Conclusion
Congratulations! You have successfully implemented a crucial piece of engine architecture. By creating a sound resource manager, you have made your audio system more robust, efficient, and safe.
Key Takeaways:
- Symmetrical Cleanup is Key: In C, for every
init, there must be anuninit. This is a core principle for preventing resource leaks. - Resource Managers Prevent Waste: By checking if an asset is already loaded, a manager avoids redundant memory usage and disk access.
- Centralized Cleanup is Safe: Having a single
shutdownfunction that cleans up everything is far more reliable than manually cleaning up individual objects. Our manager now tracks all allocated buffers and sources, guaranteeing their release. - Separation of Concerns: The manager handles data (buffers), while the game logic uses instances (sources). This is a clean and scalable design.
Preview of the Next Module:
You've built a solid foundation with C programming, 3D math, and core systems for graphics and audio. In our next module, "Core Game Architecture," we will take a step back from specific systems like audio and look at the big picture. You will learn how to structure the entire application, starting with a frame-rate independent game loop (delta time), and begin designing the fundamental Entity and Component structures that will form the backbone of your game world.