Handmade Penguin Chapter 12: Platform-independent Sound Output

This chapter covers roughly the content in the Platform-independent Sound Output part of the Handmade Hero course, under the Linux operating system.

<-- Chapter 11 | Back to Index | Chapter 13 -->

Be warned! This chapter is not yet finished. Maybe new stuff will appear if you keep hitting refresh!

The Story So Far

Yesterday, we split our code up into cross-platform (handmade.cpp) and platform-specific (sdl_handmade.cpp) components, which talk to each other through a defined interface (handmade.h). We then moved our graphics rendering code from sdl_handmade.cpp to the common handmade.cpp file. Today, we want to extend this to make our sound code cross-platform.

APIs and Buffers

Unlike with graphics, where we're always trying to render one frame at a time, with sound we want to render several samples. So, basically, we want our GameUpdateAndRender() function to call a GameOutputSound() function, which will look something like:

internal void
GameOutputSound(game_sound_output_buffer *SoundBuffer)

We can then move our sine code into that function. Some of the variables we used in our SoundOutput struct will become local variables (or local_persist variables), and some (in this case SamplesPerSecond) will need to go into our new game_sound_output_buffer. We'll also want to pass in the number of samples in the buffer that we want to fill.

So what does our game_sound_output_buffer structure look like? It's actually very simple:

struct game_sound_output_buffer
{
    int SamplesPerSecond;
    int SampleCount;
    int16 *Samples;
};
You'll note that we only have one buffer: we're not going to handle case in the ring buffer where our buffer needs to be split in two. As the amount of sound data we have is actually pretty small, we can have our game write into a linear buffer and then just copy it into the ring buffer later. We'll pass this buffer through the GameUpdateAndRender() function as a new parameter, too. This will let our platform layer create a buffer, have the game fill it with data, and then actually send it to the soundcard once the game code has returned.

We then need to set up a buffer. We'll allocate a buffer to put our audio in, and pass that to the game in a game_sound_output_buffer:

int16 Samples[48000 * 2];
game_sound_output_buffer SoundBuffer = {};
SoundBuffer.SamplesPerSecond = SoundOutput.SamplesPerSecond;
SoundBuffer.SampleCount = BytesToWrite / SoundOutput.BytesPerSample;
SoundBuffer.Samples = Samples;
Our Samples variable holds the maximum number of bytes we'll need to write. At the moment, we're allocating this once on the stack. We can move it out to when we initialise our sound device and use malloc() or mmap() instead.

Because we're going to be filling in our sound buffer at the same time as doing the graphics, we need to work out how many samples we need before calling GameUpdateAndRender(). This is actually quite simple: we're already calculating the size of the buffer we need for SDLFillSoundBuffer(). If we move that code above our game_sound_output_buffer initialisation and convert BytesToWrite from bytes to samples, that will be our sample count. We can then just pass &SoundBuffer to GameUpdateAndRender().

We'll also need to update SDLFillSoundBuffer() so that, instead of generating a sine wave, it just copies from our game_sound_output_buffer. To do this, we'll make it take a pointer to SoundBuffer as a parameter, get rid of all of the actual sine wave functions and instead copy each sample from the buffer:

int16_t *Samples = SoundBuffer->Samples;
// Calculate region sizes
for(int SampleIndex = 0;
        SampleIndex < Region1SampleCount;
        ++SampleIndex)
    {
        *SampleOut++ = *Samples++;
        *SampleOut++ = *Samples++;

        ++SoundOutput->RunningSampleIndex;
    }
We'll also want our ring buffer to start off cleared: containing silence. To do that, we'll replace the malloc() call in SDLInitAudio() with a calloc() call. calloc() is like malloc, but the memory you get back is cleared to 0 first. It also, instead of taking a single size in bytes, takes the number of elements in the array we're allocating and the size of each element in bytes. This is to avoid integer overflow with large arrays of large objects, but it isn't really important at the size we're dealing with, so we'll just have BufferSize elements, each 1 byte long.

We now have our sine wave being generated in our cross-platform code. The last thing we'll have to do is pass through the ToneHz so that we can change its pitch. Once we have cross-platform input working, this won't be necessary: the ToneHz variable and other game state will stay entirely in game code and our platform-specific code won't even know it exists. For now though, let's just add a ToneHz parameter to GameUpdateAndRender(), and pass ToneHz all the way through to our GameOutputSound() function.

SDL_QueueAudio() and the Sounds of the Future

You might ask why we're still using our ring buffer now that the game code is accepting linear buffers. Indeed, it is very easy to change our SDL_QueueAudio version to talk to our cross-platform audio. Our SDLFillSoundBuffer() function becomes trivial:

internal void
SDLFillSoundBuffer(sdl_sound_output *SoundOutput, int ByteToLock, int BytesToWrite, game_sound_output_buffer *SoundBuffer)
{
    SDL_QueueAudio(1, SoundBuffer->Samples, BytesToWrite);
}
Most of the rest of the changes are idential to our ring buffer method.

So why persist with the ring buffer if it just makes things more complicated? There are two reasons:

  1. The SDL_QueueAudio() function requires a very recent version of SDL, which is a bit of a pain.
  2. We might want to "optimistically" write an extra frame or two's worth of audio data into our buffer, and then overwrite it when we actually render the next frame. This would give us some leeway if the game ran slowly. While this can be done with SDL_QueueAudio() and its companion function SDL_ClearQueuedAudio(), it is much harder to get right.
So for future chapters, we will only be dealing with the ring buffer version of the code. If you want to use SDL_QueueAudio() yourself, though, it's just as good at the moment.

One other option would be to use the SDL audio callback directly. This isn't compatible with our current platform layer, which expects the audio and video to be rendered in the same function call, but it may later be prudent to split these up. This would decouple the audio update rate from the video frame rate, which can be a good or bad thing, and would move audio updates to another thread, meaning that the main thread slowing down would not affect the audio. It's much more complicated, though, as we have to synchonise different threads, so we'll be using the ring buffer for now. We'll have to keep our own buffer with a callback anyway if we want to support overwriting audio data in the future anyway.

End of Lesson!

And with that, our sound code is now cross-platform, too! Input is up next, and I'll see you tomorrow for some joystick-twiddling fun!

If you've bought Handmade Hero, the source for the Linux version can be downloaded here. Note that you will require the official source code for handmade.h and handmade.cpp. Note that this will probably be that last code release to include the SDL_QueueAudio() version: complain loudly if you want me to keep it.


<-- Chapter 11 | Back to Index | Chapter 13 -->