Handmade Penguin Chapter 9: Variable-Pitch Sine Wave Output

This chapter covers roughly the content in the Variable-Pitch Sine Wave Output part of the Handmade Hero course, under the Linux operating system.

<-- Chapter 8 | Back to Index | Chapter 10 -->

A Good Sine

This is going to be a very short chapter: and that's good. The reason for this is that almost everything we were doing in the stream does not need any significant change to work with our SDL code. In fact, the vast majority of what we need to do is copying and pasting from the windows version and doing a few quick renames.

The first thing done in the stream was changing the square wave to a sine wave. The good news is that every single line of the windows changes applies exactly to our SDL version. The setup for floating-point and maths operations is the same:

// TODO: Implement sine ourselves
#include <math.h>

#define Pi32 3.14159265358979f

typedef float real32;
typedef double real64;
And we need to change our square wave code to generate sine waves:
real32 t = 2,0f*Pi32*RunningSampleIndex/(real32)WavePeriod;
real32 SineValue = sinf(t);
int16 SampleValue = (int16)(SineValue * ToneVolume);
*SampleOut++ = SampleValue;
*SampleOut++ = SampleValue;

VoilĂ , we have a sine wave.

Bugfixes and Refactoring

If you're using the ring buffer version, you may hear some pops or other artefacts. This is the same bug as in the windows version: the play cursor isn't always advancing enough. It probably sounds better than the windows version, since we set the SDL buffer size to be small. Basically, we're handling the case where the PlayCursor hasn't changed incorrectly: we're assuming it's because exactly one whole buffer has been played (and we've therefore probably not written more data quickly enough), rather than the much more common possibility that no sound (or, technically, less than a whole system-level buffer of sound) has been played. This also explains why the SDL_QueueAudio() version isn't affected: it doesn't use a ringbuffer at all, so the ambiguity doesn't exist.

When we fixed this in the stream, we split most of the code out into a separate function and structure. Let's do that here:

struct sdl_sound_output
    int SamplesPerSecond;
    int ToneHz;
    int16 ToneVolume;
    uint32 RunningSampleIndex;
    int WavePeriod;
    int BytesPerSample;
    int SecondaryBufferSize;
Our function will be similarly simple, though its exact contents will depend on whether we're using the ring buffer or the SDL_QueueAudio() technique.

For the most part, the function is a simple move of the code in our main() function. The first thing to note is our function signature:

internal void
SDLFillSoundBuffer(sdl_sound_output *SoundOutput, int ByteToLock, int BytesToWrite)
There are two interesting things here. Firstly, the DWORD values have become ints. This is because DWORD (and indeed pretty much every capitalised type here) are windows specific defines. We change these to ints because we really only care that they're integers, but int32 might be a more accurate translation. Secondly, in the SDL_QueueAudio() version, we don't actually need a ByteToLock value: we're not using a ringbuffer, so we always add data to the end.

Tales from the Q&A

Let's control the pitch of our wave using the controller. The first thing we'll do is simply change SoundOutput.ToneHz to be dependent on our controller's position:

SoundOutput.ToneHz = 512 + (int)(256.0f*((real32)StickY / 30000.0f));
We'll also need to update our WavePeriod:
SoundOutput.WavePeriod = SoundOutput.SamplesPerSecond/SoundOutput.ToneHz;

This works, but we hear a "pop" when changing frequency. This is because the wave isn't continuous: we're calculating the t parameter assuming that the frequency has been constant for the duration of our program. We will fix this by adding a tSine variable to represent the t parameter for the sine function to our sdl_sound_output struct:

real32 tSine;
and adding the required amount, depending on the current frequency:
tSine += 2.0f*Pi32*1.0f/(real32)WavePeriod;

Be warned!Unfortunately, the precision of a 32-bit floating point number is not infinite, and hence, as tSine grows, it loses precision. This means that we eventually end up with the pitch changing and then silence. To fix this, we'll need to "clamp" the tSine value to a smaller range, probably between 0 and 2*Pi.

The other thing we notice is that there is significant latency between moving the controller and the sound actually changing. This is because we are filling the entire sound buffer each frame, and the sound buffer is one second long. What we need is to fill in a smaller amount of our buffer. We'll create another member for our sdl_sound_output structure:

int LatencySampleCount;
and initialise it to 1/15th of a second: 4 frames as 60FPS.
SoundOutput.LatencySampleCount = SoundOutput.SamplesPerSecond / 15;
This is a much more tolerable latency.

Where we go from here depends a bit on whether we're using a ring buffer or SDL_QueueAudio(). If we're using SDL_QueueAudio(), this is easy: we already have a TargetQueueBytes variable: we just need to have it be:

int TargetQueueBytes = SoundOutput.LatencySampleCount * SoundOutput.BytesPerSample;
Recompile, and we have low latency. Trivial, eh?

With the ring buffer, things are a teensy bit more complicated: we don't have a nice TargetQueueBytes variable. What we want to do is, instead of filling all the way back around to where the play cursor is, we just want to fill LatencySampleCount samples ahead. This works exactly the same way as the DirectSound version of the code. We create a variable, TargetCursor, which stores the byte we want to fill up to:

int TargetCursor = ((AudioRingBuffer.PlayCursor +
                     (SoundOutput.LatencySampleCount*SoundOutput.BytesPerSample)) %
We just take our play cursor, add the number of bytes we want to fill, and mod by the size of our buffer so that it wraps around.

We can now just replace the next instances of AudioRingBuffer.PlayCursor with TargetCursor and we're done! Easy as that.

End of Lesson!

The end is in sight: pretty much everything we're doing at this point is the same between Windows and Linux. We're basically done with sound, now. We'll be needing to clean things up so that other code can play sound without even knowing what operating system is running. Tomorrow, however, we'll be looking at timing: and it's really easy — SDL does things almost exactly the same way as window.!

If you've bought Handmade Hero, the source for the Linux version can be downloaded here. A special, extra-compatible copy of SDL which includes SDL_QueueAudio() is included: you can compile the SDL_QueueAudio() version with ./build-queueaudio.sh.

<-- Chapter 8 | Back to Index | Chapter 10 -->