Part 32: Sound

Downloads

Source code
Executable for the map editor - exactly the same as in part 31 (Windows 32bits)
Executable for the game (Windows 32bits)

Before you try to compile the code go to the "Projects" tab on the left menu, select the "Run" settings for your kit,
and set the "Working directory" to the path of "editor\data" for the editor or "game\data" for the game.

The sound with Qt

Qt is not the best engine for playing sounds at all.
You can either play a file witout any control on it with QMediaPlayer, or use QAudioOutput to have a low level
control on how the sound is played.
But even AudioOutput is very limited.

First let's define a little bit what we want.
So let's see how we initialize that.

		#define TIMER_PERIOD    17
		#define SAMPLE_RATE     6000
		#define CHANNELS        2
		#define SAMPLE_SIZE     16
		#define SAMPLE_BYTES    (SAMPLE_SIZE / 8)
		#define MIXER_COEF      4   // number of simultaneous sounds

		CSoundManager::CSoundManager()
		{
			memset(mBuffer, 0, SND_BUFFER_SIZE);

			QAudioFormat    format;
			format.setSampleRate(SAMPLE_RATE);
			format.setChannelCount(CHANNELS);
			format.setSampleSize(SAMPLE_SIZE);
			format.setCodec("audio/pcm");
			format.setByteOrder(QAudioFormat::LittleEndian);
			format.setSampleType(QAudioFormat::SignedInt);

			QAudioDeviceInfo    device = QAudioDeviceInfo::defaultOutputDevice();
			QAudioDeviceInfo info(device);

			if (info.isFormatSupported(format) == false)
				format = info.nearestFormat(format);

			mAudioOutput = new QAudioOutput(device, format);
			mPushTimer = new QTimer();
			connect(mPushTimer, SIGNAL(timeout()), SLOT(pushTimerExpired()));

			mOutput = mAudioOutput->start();
			mPushTimer->start(TIMER_PERIOD);
		}
				
You can see at the end that we initialize a timer that will be used to feed regularly the AudioOutput with
our samples.
Note that the timers need an event loop to work. So we call this init function in main() to use the main event
loop of our program.
But that prevent us from giving the period we want for the timer. I found that I had to set a value around 16
or 17 ms because a value higher or lower caused crackling in the sound.

You may have to change this value if you compile on another platform.
Anyways, we are not safe from every problem because the timers are not very accurate.
If you encounter problems there can be workarounds. I.e. we could try to play the sounds in another thread.
Or we can send more samples to the audio output every time the timer fires.

The sound list

The sounds we play will be stored in a list in CSoundManager.

		class CSound
		{
		public:
			CSound(CVec2 pos, const char* fileName, int loops);

			[...]
			CVec2       mPos;
			std::string mFileName;

		private:
			[...]
			uint32_t    mCurrentPos;
			int         mLoops;
		};

		class CSoundManager : public QObject
		{
			Q_OBJECT

		public:
			[...]
			void    play(CVec2 pos, const char *fileName, int loops = 1);
			void    stop(CVec2 pos, const char *fileName);

		private:
			[...]
			std::vector<CSound> mSounds;
			[...]
		};
				
The variables used for each sound are:
The play function in CSoundManger will add a sound to the list if it isn't already playing at the same place.

		void CSoundManager::play(CVec2 pos, const char* fileName, int loops)
		{
			std::vector<CSound>::iterator   it;

			for (it = mSounds.begin(); it !=mSounds.end(); ++it)
				if (it->mPos == pos && it->mFileName == fileName)
					break;

			if (it == mSounds.end())
			{
				CSound  snd(pos, fileName, loops);
				mSounds.push_back(snd);
			}
		}
				
The stop function will be used to remove a looping sound from the list.

		void CSoundManager::stop(CVec2 pos, const char *fileName)
		{
			std::vector<CSound>::iterator   it;

			for (it = mSounds.begin(); it !=mSounds.end();)
			{
				if (it->mPos == pos && it->mFileName == fileName)
					it = mSounds.erase(it);
				else
					it++;
			}
		}
				

Sending the samples

Each time our timer fire, it calls the pushTimerExpired() function.
That's where we will fill a buffer with the samples of our sounds.
First we clear this buffer.

		void CSoundManager::pushTimerExpired()
		{
			if (mAudioOutput && mAudioOutput->state() != QAudio::StoppedState)
			{
				int len = SAMPLE_BYTES * CHANNELS * SAMPLE_RATE * TIMER_PERIOD / 1000;

				// clear the sound buffer
				memset(mBuffer, 0, len);
				
Then we sort the sounds by their distance from the player.

				if (interface.isGamePaused() == false)
				{
					// sort the sounds
					std::vector<CSound*>    sorted;
					for (size_t i = 0; i < mSounds.size(); ++i)
						sorted.push_back(&mSounds[i]);

					for (int i = 0; i < (int)mSounds.size() - 1; ++i)
						for (int j = i + 1; j < (int)mSounds.size(); ++j)
							if (sorted[j]->getDistance() < sorted[i]->getDistance())
							{
								CSound* temp = sorted[i];
								sorted[i] = sorted[j];
								sorted[j] = temp;
							}
				
Now that the sounds are sorted, we will play only the 4 nearest ones and we
skip the samples of the others.
Note that the getData() function add the samples of the sound directly to the
final buffer. We will look at it in details later.

					// play the 4 nearest sounds (the others' samples are skipped)
					int leftVolume, rightVolume;

					for (size_t i = 0; i < mSounds.size(); ++i)
					{
						if (i < MIXER_COEF)
						{
							mSounds[i].getVolumes(&leftVolume, &rightVolume);
							mSounds[i].getData(mBuffer, len, leftVolume, rightVolume);
						}
						else
						{
							mSounds[i].skipSamples(len);
						}
					}
				}
				
Now the buffer is ready, we send it to the output.

				mOutput->write((char *)mBuffer, len);
				
And finally, we remove from the list all the sounds that are finished.

				// delete finished sounds
				std::vector<CSound>::iterator   it;
				for (it = mSounds.begin(); it != mSounds.end();)
				{
					if (it->isFinished() == true)
						it = mSounds.erase(it);
					else
						it++;
				}
			}
		}
				

Mixing the samples

The getData() add the samples of a sound to the current buffer.

		void CSound::getData(int16_t* dest, int len, int leftVolume, int rightVolume)
		{
			for (int i = 0; i < len / (SAMPLE_BYTES * CHANNELS); ++i)
			{
				int sample = (int)getSample() - 128;
				*dest++ += sample * leftVolume / MIXER_COEF;
				*dest++ += sample * rightVolume / MIXER_COEF;
				mCurrentPos++;
			}
		}
				
The source datas of the samples comes from an 8 bits WAV file.
When the samples are 8 bits, the WAV format store them as unsigned values that go from 0 to 255.
And we want to convert them to 16 bits signed values that go from −32768 to 32767.
So first we substract 128 to our sample to get it in the [-128, 127] range.
Then we multiply the sample by the channel volume which is computed in another function and that can go from 0 to 256.
Now our sample covers the full range of a signed 16 bits value.
But as we want to play up to 4 sound at the same time, if these sounds are at their maximum value, it will overflow.
So we have to divide our samples by 4, that's the value of MIXER_COEF.

The getSample function simply returns the next sample in the WAV file.

		uint8_t CSound::getSample()
		{
			uint8_t*    mem = (uint8_t*)fileCache.getFile(mFileName).mem;
			uint32_t    size = *(uint32_t*)&mem[WAV_HEADER - 4];

			if (isFinished() == false)
				return mem[WAV_HEADER + (mCurrentPos % size)];
			else
				return 128;
		}
				

Spatialization

Now let's have a look at how we compute the volumes.
First we will get the position of the sound relatively to the player. In fact it's the opposite of the calculation
we did when we draw the walls.

		void CSound::getVolumes(int* left, int* right)
		{
			CVec2   pos = player.getLocalFromPos(mPos);
				
If the sound is on the same tile as us, we set the volume to the maximum.

			if (pos == CVec2(0, 0))
			{
				*left = 256;
				*right = 256;
				return;
			}
				
Now we will set the the left and the right volumes according to the sound position.
The formla is a bit empirical. I found that it sounded better than a more realistic one.

			// calc panning
			float   angle = atan2(-pos.y, pos.x);

			float l, r;

			if (pos.x < 0)
			{
				l = 1.0f;
				r = fabs(sin(angle));
			}
			else
			{
				l = fabs(sin(angle));
				r = 1.0f;
			}
				
Then we want to lower the sound when it is far from us

			// calc distance
			float vol = (5.0f - pos.length()) / 5.0f;
			if (vol < 0.0f)
				vol = 0.0f;

			l *= vol;
			r *= vol;
				
And finally we return the values of the volumes in a range from 0 to 256.

			// return values
			*left = (int)(l * 256.0f);
			*right = (int)(r * 256.0f);
		}
				
I added a few sounds in the game - hitting a wall, attacking, pressing a switch, opening a door - so that you can
see how it sounds. It should add more depth to the game.

The save bug

I fixed the bug that made the save file very big in the 2nd level.
I didn't notice that half of the objects of this level disappeared when it was opened in the map editor.
Do you remember that last time we added an int parameter to the torches for their charge?
I thought that all the torches we could get in this level were in torch holders, so not really in the map.
But I forgot that there was one torch laying on the ground too.
So the map needed only a small conversion to add this parameter.