Repository navigation
Chapter 2 ‐ Mixing Audio
In this chapter we are going to implement a very simple audio mixer for our client.
As SDL2 is only able to load WAV files we have to be very careful with file sizes, as WAV is usually uncompressed audio data. There is a small trick tough where WAV can use ADPCM encoding to limit file sizes. This can be thought of an very early attempt of lossy audio compression (compare to MP3), but it still used a lot of space and is not as good as modern compression algorithms. Perhaps we will use such in a later stage.
So here are the limitations for now:
- 8KHz audio data for sound effects / music
- 8KHz audio rendering (which makes it super easy to mix the loaded sample data)
- 8 concurrent audio playing (including the music, which is always on channel 0)
- 32 sound effects will be loaded on startup (if available)
- music is loaded on demand
SDL2 does not offer a high-level audio API (just with extensions like SDL_mixer). So we have to write our own here. We start by defining two structures we need for the audio rendering. One structure is holding the actual (decompressed) audio data, and the other is representing a mixer channel (I called it voice).
// sound effect / music asset
typedef struct sound_t {
int16_t *data; // 16-bit PCM data
int length; // length in PCM samples
} sound_t;
// sound / music playback channel
typedef struct voice_t {
sound_t *sound; // which sound is currently played
int position; // current playback position
bool loop; // shall we loop?
} voice_t;The hearth of the audio mixing is a pair functions which simply add all PCM values together, multiply it with the global audio gain (volume control) and clips it to the 16-bit range of -32768, 32767. The render_voice function takes a voice and returns its PCM value (0 if it is not playing). It will also reset the voice if the sound effect was played completely or in case of music, just starts from the beginning.
// render a single audio voice
static int render_voice(voice_t *voice) {
if (voice->sound == NULL)
return 0;
if (voice->position >= voice->sound->length) {
if (voice->loop) {
voice->position = 0;
} else {
voice->sound = NULL;
return 0;
}
}
return voice->sound->data[voice->position++];
}
// render all audio voices from SDL callback
static void render_audio(void *userdata, Uint8 *stream8, int len8) {
(void)userdata;
int16_t *stream = (int16_t*)stream8;
const int len = len8 / 2;
for (int i = 0; i < len; ++i) {
int total = 0;
for (int j = 0; j < AUDIO_VOICES; ++j)
total += render_voice(&state.audio.voices[j]);
*stream++ = (int16_t)clampi(total * state.audio.gain, -32768, 32767);
}
}As you might read through the source, you will often find a pair of function calls like this.
SDL_LockAudioDevice(state.audio.device);
// some code here
SDL_UnlockAudioDevice(state.audio.device);This is needed whenever you want to change values in the audio mixer. Why? Simply because the audio mixer is run in a separate thread by SDL2 and therefore you cannot change values from the main thread without the risk of a race condition. This pair of function calls make sure, that everything you do inside those calls will take place when the mixer thread is currently sleeping. So this is important!
You can browse through the source here.