Handmade Penguin Chapter 19: Improving Audio Synchronisation

This chapter covers roughly the content in the Improving Audio Synchronization part of the Handmade Hero course, under the Linux operating system.

<-- Chapter 18 | Back to Index | Chapter 20 -->

Thinking about Sync-ing

We've already spent a little bit of time looking at (listening to?) audio latency in Chapter 8. Let's see how low we can get that latency to be!

In DirectSound, we have the GetCurrentPosition() function to tell us where the "Play Cursor" is — letting us work out where in the buffer the sound card is currently playing. Unfortunately, as SDL's sound API is different, we can't copy the windows code exactly.

If we're using SDL_QueueAudio(), then there's no simple correlation: the equivalent of the "Play Cursor" is the front of the audio queue, effectively always zero. This is because SDL_QueueAudio() is not a ring buffer. (It's possible to implement an API like SDL_QueueAudio() on top of a ring buffer — though SDL uses a linked list of linear buffers instead — but not expose the "Play cursor" to the user.) We can work out how much latency we have using the SDL_GetQueuedAudioSize() function, which returns the number of bytes of queued audio. Divide it by the sample rate, sample size and number of channels, and you can get the current latency.

Of course, we're not using SDL_QueueAudio() — we've implemented our own ring buffer, which has its own "Play cursor". We can check it just by reading RingBuffer->PlayCursor. This obviously doesn't match exactly where the sound card is playing: it's the next position in the buffer the SDL audio callback will read. This means that it's always after the actual sound card position (assuming everything goes well). In fact, in most systems there is likely another ring buffer (or several) inside the sound card and the operating system's mixer, so even on Windows, the "Play Cursor" is likely the same thing: the next point to be read out of the ring buffer.

Debug Visualisations

So let's look at where we thought our play cursor was by drawing it on the scene. We'll add an array of the play cursors for the last second: DebugLastPlayCursor, and update it after every frame being displayed.

#ifdef HANDMADE_INTERNAL
{
    DebugLastPlayCursor[DebugLastPlayCursorIndex++] = AudioRingBuffer.PlayCursor;
    if (DebugLastPlayCursorIndex > ArraySize(DebugLastPlayCursor))
    {
        DebugLastPlayCursorIndex = 0;
    }
}
#endif

We can then "draw" this cursor on the screen each frame. Let's add a call to a new SDLDebugSyncDisplay() function.

#ifdef HANDMADE_INTERNAL
SDLDebugSyncDisplay(&GlobalBackbuffer,
                    ArrayCount(DebugLastPlayCursor), DebugLastPlayCursor
                    &SoundOutput, TargetSecondsPerFrame);
#endif

This function will loop over our play cursor positions, and display them on the screen as a series of vertical lines (one per play cursor). The body of the function is pretty simple: we simply have to loop over the play cursors we are rendering, calculate a position on the screen to display them, and draw lines.

To calculate a position on-screen for a play cursor, we need to take a value between 0 and the largest possible play cursor, SecondaryBufferSize, and convert it to an X-coordinate onscreen. Because it would be too difficult to see a line which was right next to the edge of the window, we'll have a bit of "padding", for example 16 pixels. So, we want to convert a number in the range [0, SecondaryBufferSize) to one in the range [16, GlobalBackbuffer->Width - 16). We do that by dividing by SecondaryBufferSize and multiplying by GlobalBackbuffer->Width - 2 * 16. To make this easier, we pre-compute a conversion constant, C.

real32 C = (real32)(Backbuffer->Width - 2*PadX) / (real32)SoundOutput->SecondaryBufferSize;
Check it out! The bracket notation [a,b) used above is a way of defining which ends of intervals contain their endpoints. A square bracket means "inclusive", and a round bracket, "exclusive". So "[a,b)" refers to the interval which includes everything which is greater than or equal to a and strictly less than b.

Once we've worked out what the X-coordinates are, we just need to draw our lines.

Drawing Vertical Lines

Fortunately, there's not much difference between drawing vertical lines on Windows and drawing vertical lines on Linux, particularly since we've already set up our own surface structure. All we need to do is fill in the correct pixels.

Drawing a line horizontally is easy: you find the location in memory of the first (left-most) pixel in the line, then fill all of the pixels in memory until you get to the last one. All of the pixels in the line are contiguous in memory. Drawing a vertical line is a little bit harder, but still rather easy. First, we need to find the first pixel in memory — the top-most.

uint8 *Pixel = ((uint8 *)GlobalBackbuffer->Memory +
                     X*GlobalBackbuffer->BytesPerPixel +
                     Top*GlobalBackbuffer->Pitch);

Note that we'll need to plumb BytesPerPixel through: let's add it as a member of sdl_offscreen_buffer.

Once we've got the first pixel, we need to loop through all of the pixels and set their colours. Fortunately, this is quite easy. We know how many pixels we want to fill in (Bottom - Top), and that they're all exactly one pixel below the previous one, i.e. on the next line of the buffer. This means that they're all GlobalBackbuffer->Pitch bytes after the previous. Easy!

for(int Y = Top;
    Y < Bottom;
    ++Y)
{
    *(uint32 *)Pixel = Color;
    Pixel += GlobalBackbuffer->Pitch;
}

Write Cursors

It'd be nice to see where our write cursor is as well as the play cursor, so we can see where we're going to write new audio data.

It's quite simple: instead of passing just the play cursor, we'll create a struct to pass around instead:

struct sdl_debug_time_marker
{
    int PlayCursor;
    int WriteCursor;
};

We then can, in our SDLDebugSyncDisplay() function, draw two lines, each in different colours. If we want, we can even split out the code for converting the coordinates and drawing the line into a different function.

Looking at the Play and Write cursors for the last half a second.

...and we're done! Doesn't that look great?

A quick bug-fix!

While writing this, I found that the game was sometimes mysteriously crashing with invalid memory accesses when run in the debugger. After digging around in gdb, I found that sometimes, random structures seemed to have weird, invalid memory addresses:

Program received signal SIGSEGV, Segmentation fault.
0x00000000004018ff in GameUpdateAndRender (Memory=0x7fffffffd880, Input=0x7fffffffd8f0, 
                                           Buffer=0x7fffffffd860, 
                                           SoundBuffer=0x7fffffffd830) at ../handmade/code/handmade.cpp:78
78              GameState->ToneHz = 256;
(gdb) print GameState
$1 = (game_state *) 0xffffffffffffffff
(gdb) print *Memory 
$2 = {IsInitialized = 0, PermanentStorageSize = 67108864, PermanentStorage = 0xffffffffffffffff,
      TransientStorageSize = 4294967296, TransientStorage = 0x3ffffff}

It turns out, our mmap() call was failing, and returning MAP_INVALID. As our assertion only checked if the pointer we got was 0, and MAP_INVALID is actually -1, the game was continuing to run until it actually tried to access memory. Ouch!

End of Lesson!

Sorry for the wait! I hope this chapter has been (at least a little bit) worth it! Tomorrow, we'll be interpreting these lines a bit more carefully, making our code a bit more robust, and discussing latency trade-offs.

If you've bought Handmade Hero, the source code to the Handmade Penguin port is available in the day019 tag of the Handmade Penguin GitHub repository.

<-- Chapter 18 | Back to Index | Chapter 20 -->