Handmade Hero Day 009 - Clarification needed for sine wave

Hi There,

Although this stream started in quite few years back, it's now only that I've discovered this gem and
started following along. I'm typing my own code following the instructions from Casey and I'm unable to completely
understand the circular buffer code for dsound.

So in the following code snippet from out WinMain function we're filling the complete buffer and playing it before calling the GetCurrentPosition(). My question is why do we have to calculate the play cursor/ write cursor and fill the buffer again if we filled full buffer previously and started playing?

Although if I comment the second win32FillSoundBuffer line inside the if condition, I notice the skip in the sound being played and I'm not able to understand why given that the buffer was filled completely before.

  1
  2
  3
  4
  5
  6
  7
  8
  9
 10
 11
 12
 13
 14
 15
 16
 17
 18
 19
 20
 21
 22
 23
 24
 25
 26
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
if(windowHandle)
{
	//NOTE: Get one DC and use it forever
	HDC DeviceContext = GetDC(windowHandle);

	//Note: Graphics test
	int XOffset = 0;
	int YOffset = 0;

	SoundOutput.SamplesPerSecond = 48000;
	SoundOutput.ToneHz = 256;
	SoundOutput.ToneVol = 3000; 
	SoundOutput.RunningSampleIndex=0;
	SoundOutput.WavePeriod = SoundOutput.SamplesPerSecond/SoundOutput.ToneHz;
	//SoundOutput.HalfWavePeriod = WavePeriod/2;
	SoundOutput.BytesPerSample = sizeof(int16)*2;
	SoundOutput.SecondaryBufferSize = SoundOutput.SamplesPerSecond*SoundOutput.BytesPerSample;			
	SoundOutput.LatencySampleCount = SoundOutput.SamplesPerSecond / 15; //Want to be head of play cursor by 1/60th of a sec

	//TODO Enable
	win32InitDSound(windowHandle, SoundOutput.SamplesPerSecond, SoundOutput.SecondaryBufferSize);
	win32FillSoundBuffer(&SoundOutput, 0, SoundOutput.LatencySampleCount*SoundOutput.BytesPerSample);
 	SecondaryBuffer->Play(0, 0, DSBPLAY_LOOPING);

	Running = true;
	while(Running)
	{
		MSG Message;
				
		while(PeekMessage(&Message, NULL, 0, 0, PM_REMOVE))
		{
			if(Message.message == WM_QUIT)
			{
				Running = false;
			}
			TranslateMessage(&Message);
			DispatchMessageA(&Message);
		}

		//TODO: should we poll this more frequently?
		for (DWORD ControllerIndex=0; ControllerIndex< XUSER_MAX_COUNT; ControllerIndex++ )
		{
			XINPUT_STATE ControllerState;
			if(XInputGetState(ControllerIndex, &ControllerState) == ERROR_SUCCESS)
			{
				//NOTE: this controller is plugged in
				XINPUT_GAMEPAD *Pad = &(ControllerState.Gamepad);
				bool Up = Pad->wButtons & XINPUT_GAMEPAD_DPAD_UP;
				bool Down = Pad->wButtons & XINPUT_GAMEPAD_DPAD_DOWN;
				bool Left = Pad->wButtons & XINPUT_GAMEPAD_DPAD_LEFT;
				bool Right = Pad->wButtons & XINPUT_GAMEPAD_DPAD_RIGHT;
				bool Start = Pad->wButtons & XINPUT_GAMEPAD_START;
				bool Back = Pad->wButtons & XINPUT_GAMEPAD_BACK;
				bool LeftShoulder = Pad->wButtons & XINPUT_GAMEPAD_LEFT_SHOULDER;
				bool RightShoulder = Pad->wButtons & XINPUT_GAMEPAD_RIGHT_SHOULDER;
				bool AButton = Pad->wButtons & XINPUT_GAMEPAD_A;
				bool BButton = Pad->wButtons & XINPUT_GAMEPAD_B;
				bool XButton = Pad->wButtons & XINPUT_GAMEPAD_X;
				bool YButton = Pad->wButtons & XINPUT_GAMEPAD_Y;

				int16 StickX = Pad->sThumbLX;
				int16 StickY = Pad->sThumbLY;

				if(AButton)
				{
					++YOffset;
				}
			}
			else
			{
						//The controller is not available.
			}
		}

		RenderWeirdGradient(&BackBuffer, XOffset,YOffset);

		DWORD PlayCursor;
		DWORD WriteCursor;
		if(SUCCEEDED(SecondaryBuffer->GetCurrentPosition( &PlayCursor, &WriteCursor)))				 
		{
			//TODO: Change this to lower latency offset from play cursor when we have sound effects. 
			DWORD ByteToLock = (SoundOutput.RunningSampleIndex*SoundOutput.BytesPerSample) % SoundOutput.SecondaryBufferSize;
			DWORD TargetCursor = ((PlayCursor + (SoundOutput.LatencySampleCount*SoundOutput.BytesPerSample)) % SoundOutput.SecondaryBufferSize);
			DWORD BytesToWrite;
			if(ByteToLock > TargetCursor)
			{
				BytesToWrite = SoundOutput.SecondaryBufferSize - ByteToLock;
				BytesToWrite += TargetCursor;

			}
			else
			{
				BytesToWrite = TargetCursor - ByteToLock;
			}

				win32FillSoundBuffer(&SoundOutput, ByteToLock, BytesToWrite);
		}
				
			win32_Window_Dimensions Dimensions = GetWindowDimension(windowHandle);
			Win32DisplayBufferInWindow (&BackBuffer, DeviceContext, Dimensions.Width, Dimensions.Height);
			++XOffset;
	}

			
}

Edited by Anurag Jain on
If the sound buffer played only one tone continuously, you could fill it once and let it play forever as long as the end of the buffer matches exactly the start of the buffer, otherwise you would hear a click when the buffer loops. If it helps you understand: if you visualize the sound wave in a sound editor, to avoid the click sound when you make a loop, the end of the sound wave must be at the same height and with the same slope as the start of the wave.

The first call to fill buffer doesn't fill the whole buffer, it fills 1/15th of a second (LatencySampleCount = SamplesPerSecond / 15). The application must continuously fill the buffer with new data to hear the next part of the sound.

Since on day 9 you want a changing tone, you need to continuously update the buffer so the sound change over time. There are several reason to that:

- The buffer only contains a limited amount of sound (for example 1 second). So if you want to have a pitch change over 2 seconds you can't write once and play, you need to write several time in the buffer. You could write the first second, and when the first second as played, you write the second part (this is not what you do in practice).

- Since the sound will be changing every frame, there is no need to write a full sound buffer every frame, only enough for the sound card to play. And you don't know what the user will do so you don't know what to add in the buffer in advance. At 60fps with a sound buffer at 48Khz that's 800 samples per frame. We write a little more (adding some latency) to be sure that the sound card as enough data in case we take too long during simulation/rendering (and I believe the minimum latency with direct sound is 30ms).

- There is a part in the buffer that you should not write to: the space between the play cursor and the write cursor. This part of the buffer is "used by the hardware" at the moment to play the sound.

- You want the latency, the amount of time passed between the moment you write to the sound buffer and the moment you ear the sound, to be as low as possible. For instance if you use the stick to change the sound pitch, you want it to happen directly when you move the stick, not one second after. So each frame you want to modify the content of the sound buffer with the new pitch.
Hi Simon,

Thanks for your reply. You certainly provided more useful insight about the working of dsound buffer. So I understand that we only fill the buffer enough like 1/15 of sec and then continuously calculate the play and write cursors and keep filling the buffer.

But in the day 9 stream before we introduce the latency variable we were filling the whole buffer like below.

1
win32FillSoundBuffer(&SoundOutput, 0, SoundOutput.SecondaryBufferSize);


Isn't that sufficient if I make sure if my wave loop around perfectly ?

I hear the sound skip every one sec if I remove the buffer fill inside the if condition where we call the GetCurrentPosition(). I assume it to be because of any of the following reasons; but not totally sure.

1: Does it fills the whole buffer; even the area between play cursor and write cursor ? Is the skip because it doesn't fill between play and write cursors ?

2: To play 256Hz tone the period count comes to 187.5 which is not a perfect integer, is the skip because the wave can't loop perfectly?

3: So I tried to change the toneHz to 240 and now the period count is 200 which is perfect integer and the wave should loop around smoothly, right? But I still hear the skip. So I guess it's because of the reason 1. Not sure.


Can you help me which of the above reasons might causing the skip?


Thanks
Anurag

1. Since SecondaryBuffer->Play(0, 0, DSBPLAY_LOOPING); is after the first fillSoundbuffer it should not matter.
2. I believe that would cause the problem.
3. This should fix the problem.

I took a look at the code of day 9 and I remembered that something like that is fixed later in the series.
The problem I believe is caused by the accumulation of error when adding and multiplying floating point numbers.

1
SoundOutput->tSine += 2.0f*Pi32*1.0f/(real32)SoundOutput->WavePeriod;

The computation for tSine (in Win32FillSoundBuffer) will cause an error after some time. In our case causing the end of the buffer not matching the start of the buffer resulting in the clicking sound.

Since tSine is use only as an input for the sin function, and that sine works between 0 and 2PI (0° and 360°) you can force tSine to stay in that range (2 places in the function, region 1 and region 2).

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
for(DWORD SampleIndex = 0; SampleIndex < Region1SampleCount; ++SampleIndex) {

    /* This is the only change. */
    if ( SoundOutput->tSine > 2.0f * Pi32 ) {
        SoundOutput->tSine -= 2.0f * Pi32;
    }
            
    real32 SineValue = sinf(SoundOutput->tSine);        
    int16 SampleValue = (int16)(SineValue * SoundOutput->ToneVolume);
    *SampleOut++ = SampleValue;
    *SampleOut++ = SampleValue;
            
    SoundOutput->tSine += 2.0f*Pi32*1.0f/(real32)SoundOutput->WavePeriod;
            
    ++SoundOutput->RunningSampleIndex;
    ...
}


Note that there still is an accumulation of error since we are still doing the same computation (we do more since we added a subtraction). But we are keeping the number in a smaller range: between 0 and 6.28... (2 PI) instead of 1500.46... And this helps keeping the error smaller (as far as I understand floating point errors) because a number like 1500.0f as less precision after the decimal point then 1.0f.