Partie 32: Le son

Téléchargements

Code source
Exécutable de l'éditeur de niveaux - exactement le même que la partie 31 (Windows 32bits)
Exécutable du jeu (Windows 32bits)

Avant d'essayer de compiler le code, allez dans l'onglet "Projets" dans le menu de gauche, séléctionnez l'onglet "Run" pour votre kit,
et mettez dans "Working directory" le chemin de "editor\data" pour l'éditeur ou "game\data" pour le jeu.

Le son avec Qt

Qt n'est pas du tout le meilleur moteur pour faire du son.
Vous pouvez soit jouer un fichier son sans aucun contrôle dessus avec QMediaPlayer ou utiliser QAudioOutput pour
avoir un contrôle bas niveau sur la lecture.
Mais même QAudioOutput est très limité.

D'abord définissons un peu ce que nous voulons.
Alors regardons comment on initialise ça.

		#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   // nombre de sons simultanés
		#define WAV_HEADER      44  // taille de l'entête d'un fichier ".wav"

		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);
		}
				
Vous pouvez voir qu'à la fin on initialise un timer qui sera utilisé pour alimenter régulièrement mOutput avec nos
samples.
Notez que les timers Qt ont besoin d'une "event loop" pour fonctionner. Alors on va appeler cette fonction d'init
dans main() pour utiliser la boucle d'évènement principale de notre programme.
Mais ça nous empêche de donner la période qu'on veut au timer. J'ai trouvé qu'il fallait lui mettre une valeur de
16 ou 17 ms parce qu'une valeur plus haute ou plus basse causait des craquements dans le son.

Vous devrez peut-être changer cette valeur si vous compilez sur une autre plateforme.
Quoi qu'il en soit, on n'est pas à l'abri de tous les problèmes parce que les timers ne sont pas très précis.
Si vous rencontrez des problèmes, il peut y avoir des façons de les contourner. Par exemple, on peut essayer de
jouer les sons dans un autre thread. Ou on peut envoyer plus de samples à l'output quand le timer se déclenche.

La liste de sons

Les sons qu'on joue vont être stockés dans une liste dans 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;
			[...]
		};
				
Les variables utilisées pour chaque son sont:
La fonction play() dans CSoundManger ajoute un son à la liste s'il n'est pas déjà en train de se jouer à la même
position

		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);
			}
		}
				
La fonction stop() sera utilisée pour retirer un son qui boucle de la liste.

		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++;
			}
		}
				

Envoyer les samples

A chaque fois que le timer se déclenche, il appelle la fonction pushTimerExpired().
C'est là qu'on va remplir un buffer avec les samples de nos sons.
D'abord on vide ce buffer.

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

				// efface le buffer son
				memset(mBuffer, 0, len);
				
Puis on trie les sons en fonction de leur distance au joueur.

				if (interface.isGamePaused() == false)
				{
					// trie les sons
					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;
							}
				
Maintenant que les sons sont triés, on va jouer seulement les 4 plus proches et on va sauter les samples des
autres.
Notez que la fonction getData() ajoute les samples du son directement dans le buffer final.
On va l'expliquer en détail plus tard.

					// joue les 4 sons les plus proches (on saute les autres samples)
					int leftVolume, rightVolume;

					for (size_t i = 0; i < sorted.size(); ++i)
					{
						if (i < MIXER_COEF)
						{
							sorted[i]->getVolumes(&leftVolume, &rightVolume);
							sorted[i]->getData(mBuffer, len, leftVolume, rightVolume);
						}
						else
						{
							sorted[i]->skipSamples(len);
						}
					}
				}
				
Maintenant que le buffer est prêt, on l'envoie à mOutput.

				mOutput->write((char *)mBuffer, len);
				
Et finalement, on retire de la liste tous les sons qui sont terminés.

				// efface les sons terminés
				std::vector<CSound>::iterator   it;
				for (it = mSounds.begin(); it != mSounds.end();)
				{
					if (it->isFinished() == true)
						it = mSounds.erase(it);
					else
						it++;
				}
			}
		}
				

Mixer les samples

La fonction getData() ajoute les samples d'un son au buffer courant.

		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++;
			}
		}
				
Les données source des samples viennent d'un fichier WAV 8 bits.
Quand les samples sont en 8 bits, le format WAV les stocke en valeurs non signées qui vont de 0 à 255.
Et on veut les convertir en valeurs 16 bits signées qui vont de -32768 à 32767.
Alors d'abord on soustrait 128 à notre sample pour le mettre dans l'intervalle [-128, 127].
Ensuite, on multiplie le sample par le volume du canal, qui est calculé dans une autre fonction, et qui va de 0 à
256.
Maintenant, notre sample couvre tout l'intervalle d'une valeur 16 bits signée.
Mais comme on veut jouer jusqu'à 4 sons en même temps, si ces sons sont à leur valeur maximum, ça va déborder.
Donc, on doit diviser nos samples par 4, qui est la valeur de MIXER_COEF.

La fonction getSample() renvoie simplement le sample suivant dans le fichier WAV.

		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;
		}
				

Spatialisation

Maintenant, regardons comment calculer les volumes.
D'abord, on va récupérer la position du son par rapport au joueur. En fait, c'est l'inverse du calcul qu'on a fait
quand on a dessiné les murs.

		void CSound::getVolumes(int* left, int* right)
		{
			CVec2   pos = player.getLocalFromPos(mPos);
				
Si le son est dans la même case que nous, on met le volume au maximum.

			if (pos == CVec2(0, 0))
			{
				*left = 256;
				*right = 256;
				return;
			}
				
Ensuite, on va mettre les volumes gauche et droit en fonction de la position du son.
La formule est un peu empirique. J'ai trouvé qu'elle sonnait mieux qu'une formule plus réaliste.

			// calcule la balance
			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;
			}
				
Ensuite, on veut réduire le son quand il est loin de nous

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

			l *= vol;
			r *= vol;
				
Et finalement, on renvoie les valeurs des volumes dans un intervalle de 0 à 256.

			// retourne les valeurs
			*left = (int)(l * 256.0f);
			*right = (int)(r * 256.0f);
		}
				
J'ai ajouté quelques sons dans le jeu (rentrer dans un mur, attaquer, presser un bouton, ouvrir une porte) pour que
vous puissiez entendre ce que ça donne. Ca devrait ajouter plus de profondeur au jeu.

Le bug de sauvegarde

J'ai corrigé le bug qui produisait un fichier de sauvegarde très gros dans le 2ième niveau.
Je n'avais pas remarqué que la moitié des objets de ce niveau disparaissaient quand on l'ouvrait dans l'éditeur.
Vous vous souvenez que la dernière fois on avait ajouté un paramètre int aux torches pour leurs charges ?
Je pensais que toutes les torches qu'on pouvait trouver dans ce niveau étaient sur les porte-torches, donc pas
vraiment dans la map.
Mais j'avais oublié qu'il y avait une torche posée sur le sol aussi.
Donc la map avait besoin d'une petite conversion pour ajouter ce paramètre.