Tutoriel SDL: Mixage et effets sonores

Paru dans Linux Mag 73, juillet 2005

La dernière fois, nous avions évalué les fonctions audio mises à notre disposition par SDL. Force est de constater, qu'en s'y limitant strictement on ne parvient pas à des résultats très sophistiqués. Nous allons cette fois-ci construire un véritable moteur sonore à partir des fonctions de base offertes par SDL.

Il ne fallait de toute façon pas s'attendre à des miracles – tout comme pour le graphisme et la gestion des évènements, SDL nous démontre qu'elle est une bibliothèque de bas niveau. C'est au programmeur d'en faire ce qu'il veut – au moins n'est-il pas enfermé dans un carcan prédéfini par les développeurs de la bibliothèque. En ce qui nous concerne, réaliser nous même notre moteur sonore a un intérêt avant tout didactique.

Un moteur de son plus évolué

Notre dernier programme d'exemple se contentait de charger un son, de le convertir au format audio du périphérique, et de le lui envoyer progressivement au travers d'une fonction de rappel appelée par SDL lorsque nécessaire. Nous avions retenu trois paramètres (qui se sont traduits par trois variables globales) pour décrire le son :

 /* Les données du fichier son chargé */
Uint8 * sounddata;
/* La taille du fichier son chargé, en octets */
Uint32 soundlength;
/* Position courante de lecture dans le fichier son */
Uint32 soundpos;

Si nous faisons une analogie avec l'article sur les animations, nous pouvons dire que les variables sounddata et soundlength correspondent aux données du son, tandis que soundpos est un paramètre de lecture. Nous pouvons donc en déduire deux structures comparables à celles que nous avions mises en place pour gérer les animations. L'une, sound, contiendra les données sonores ainsi que la longueur du son. L'autre, soundplayer, pointera vers un son joué et contiendra la position courante du lecteur dans le son. Voilà pour les structures de base, nous pourrons leur associer les fonctions permettant de charger un son déjà codées le mois dernier. Les déclarations se feront dans audio.h :

 #ifndef _AUDIO_H__
#define _AUDIO_H__

#include "SDL.h"

/* Contient un son chargé */
typedef struct
{
/* Données sonores */
Uint8 * data;
/* Longueur du son */
Uint32 length;
} sound;

/* Chargement d'un son à partir d'un fichier .wav. Le son est converti au format
donné par la variable hw_spec, qui doit être le format utilisé par le matériel.
Le son chargé est retourné dans loaded_sound. La fonction retourne 0 en cas de
succès, une valeur négative en cas d'erreur. */
int sound_load(sound * loaded_sound, char * filename, SDL_AudioSpec * hw_spec);
/* Libère un son précédemment chargé par sound_load */
void sound_free(sound * loaded_sound);

/* Informations sur la lecture d'un son */
typedef struct
{
/* Le son en train d'être joué */
sound * played_sound;
/* Position du lecteur dans le son */
Uint32 pos;
} soundplayer;

/* Affecte un son à jouer à un soundplayer */
void soundplayer_play(soundplayer * player, sound * toplay);
/* Cesse la lecture du son */
void soundplayer_stop(soundplayer * player);
/* Mise à jour de l'état du player en fonction de sa position */
void soundplayer_update(soundplayer * player);
/* Retourne non-zéro si le lecteur est en train de jouer un son */
Uint8 soundplayer_isplaying(soundplayer * player);

#endif

Et les définitions dans audio.c :

#include <stdlib.h>
#include <string.h>
#include "audio.h"
#include "sound_effects.h"
int sound_load(sound * loaded_sound, char * filename, SDL_AudioSpec * hw_spec) {
SDL_AudioSpec filespec;
SDL_AudioCVT cvt;

Uint8 * data;
Uint32 length;

/* Chargement du fichier .wav */
if (SDL_LoadWAV(filename, &filespec, &data, &length) == NULL) {
fprintf(stderr, "Erreur lors du chargement de s\n", filename, SDL_GetError());
return -1;
}

/* Conversion vers le format supporté par le matériel */
if (SDL_BuildAudioCVT(&cvt, filespec.format, filespec.channels, filespec.freq,
hw_spec->format, hw_spec->channels, hw_spec->freq) < 0) {
fprintf(stderr, "Impossible de construire le convertisseur audio!\n");
SDL_FreeWAV(data);
return -1;
}

/* Création du tampon utilisé pour la conversion */
cvt.buf = malloc(length * cvt.len_mult);
cvt.len = length;
memcpy(cvt.buf, data, length);

/* Conversion... */
if (SDL_ConvertAudio(&cvt) != 0) {
fprintf(stderr, "Erreur lors de la conversion du fichier audio: %s\n", SDL_GetError());
SDL_FreeWAV(data);
return -1;
}

/* Libération de l'ancien tampon, création du nouveau,
copie des données converties, effacement du tampon de conversion */
SDL_FreeWAV(data);
loaded_sound->data = malloc(cvt.len_cvt);
memcpy(loaded_sound->data, cvt.buf, cvt.len_cvt);
free(cvt.buf);

loaded_sound->length = cvt.len_cvt;
return 0;
}

void sound_free(sound * loaded_sound) {
free(loaded_sound->data);
loaded_sound->data = NULL;
loaded_sound->length = 0;
}

void soundplayer_play(soundplayer * player, sound * toplay) {
player->played_sound = toplay;
player->pos = 0;
}

void soundplayer_stop(soundplayer * player) {
player->played_sound = NULL;
player->pos = 0;
}

void soundplayer_update(soundplayer * player) {
if (player->pos >= player->played_sound->length + (echo_delay * echo_repeat))
soundplayer_stop(player);
}

Uint8 soundplayer_isplaying(soundplayer * player) {
return (player->played_sound != NULL);
}

Vous remarquerez que nous faisons référence au fichier sound_effects.h ainsi qu'à deux de ses variables, echo_delay et echo_repeat. Nous les décrirons un peu plus loin – il faut juste savoir pour le moment que ces variables nous permettent de prendre en compte de temps de lecture ajouté par un éventuel effet d'écho pour savoir si le lecteur doit s'arrêter de jouer ou pas.

Somme toute, cette partie du programme ressemblait à un étrange mélange entre l'exemple du mois dernier et notre implémentation des animations. C'est dans le moteur sonore en lui-même que se trouvera l'innovation. Nous voulons être capable de jouer un son à l'aide d'un soundplayer arbitrairement, à n'importe quel moment. Si un autre son est déjà en train d'être joué, l'on doit pouvoir entendre les deux en même temps. Mais le matériel n'étant capable de jouer qu'un son simultanément (du moins au niveau de la vue que SDL nous en donne), il faudra mixer les deux sons afin qu'ils ne fassent plus qu'un, qui sera envoyé au matériel. Et tant que nous y sommes, il serait utile de pouvoir contrôler le volume sonore global, ainsi que d'appliquer des effets aux sons en train d'être joués. Nous allons étudier ces différentes fonctions dans les sections qui suivent au travers d'un programme d'exemple réagissant à l'appui de touches du clavier par l'émission d'un son préalablement chargé. Il supportera également les variations de volume ainsi que l'application d'effets en temps réel. Sa limitation: il ne sera capable de travailler que dans un format sonore bien défini, 16 bits signé stéréo à 44100Hz.

Commençons donc par étudier quelques effets de base utiles pour notre programme : le mixage, le changement de volume et l'écho.

Le mixage

Cette fonction est primordiale pour n'importe quelle application multimédia un tant soit peu évoluée (jeux bien évidemment, mais aussi logiciels de composition musicale, lecteurs audio permettant le cross-fading, etc.). Le principe est extrêmement simple: il suffit d'additioner, un à un, chacun des échantillons des sons à mixer pour obtenir le son correspondant à leur lecture simultanée. Il faudra cependant faire attention aux débordements de capacité: en effet, l'addition des deux échantillons peut dépasser la capacité de nos entiers sur 16 bits. Pour garder un résultat correct, l'opération se fera sur 32 bits et sera ramenée aux bornes codables sur 16 bits signés si le résultat venait à les dépasser. Cette erreur de précision, également appelée saturation, est illustrée par les figures 1 et 2 ; elle sera malheureusement audible et se traduira par un craquement si elle venait à se répéter trop fréquemment. Pour l'éviter, une précaution (que nous ne prendrons pas pour notre exemple): s'assurer que le volume des sons à mixer n'est pas trop élevé.

Fig. 1: Représentation graphique d'un son non-saturé (capture d'écran d'Audacity)
Fig. 1:
Représentation graphique d'un son non-saturé (capture d'écran d'Audacity).


Fig. 2: Le même son horriblement saturé après une augmentation de volume trop importante
Fig. 2:
Le même son horriblement saturé après une augmentation de volume trop importante.

Notre fichier sound_effects.c commencera donc par la définition de la fonction de base permettant de mixer deux échantillons :

#include "sound_effects.h"
Sint16 sample_mix(Sint16 src1, Sint16 src2)
{
Sint32 mix = src1 + src2;

/* Dépassement de la capacité de l'échantillon sur 16 bits? */
if (mix > 32767) mix = 32767;
if (mix < - 32768) mix = -32768;

return (Sint16)mix;
}

La variation de volume

De même que pour le mixage, cet effet est on ne peut plus classique. Changer le volume d'un son consiste simplement à augmenter ou baisser la valeur de ses échantillons de manière uniforme. Bien évidemment, les mêmes limitations que pour le mixage existent, et si l'on augmente trop le volume d'un son il risque de se produire le désagréable effet que tous ses échantillons soient à l'une des bornes codables dans son format (voir encore les figures 1 et 2). Et là, gare aux oreilles! Dans notre exemple, nous nous contenterons de baisser le volume. Nous pouvons donc compléter sound_effects.c avec la fonction règlant le volume d'un échantillon selon une échelle allant de 0 à 255, 0 rendant notre son totalement muet et 255 le laissant inchangé :

 Sint16 sample_volume(Sint16 sample, Uint8 volume)
{
return (sample * volume) / 255;
}

L'écho

Nous entrons ici dans une famille d'effets un peu plus sophistiqués. Les effets que nous avons étudié jusqu'à présent consistent à appliquer à tous les échantillons d'un son la même opération qui ne dépend pas de la valeur de ses autres échantillons. Pour pouvoir appliquer l'effet d'écho correctement, la fonction responsable devra disposer du son dans son intégralité.

Formalisons ce qu'est un écho : l'écho est la répétition, un certain nombre de fois, du son avec un décalage temporel. À chaque répétition, le volume du son sera plus faible que lors de la répétition précédente. Les paramètres de l'écho sont donc:

  • Le délai entre chaque répétition,
  • Le nombre de répétitions à effecter,
  • Le taux de diminution du volume à chaque répétition.

Cet effet se base donc sur les deux autres effets que nous connaissons déjà : diminuer le volume d'un échantillon, et mixer deux échantillons entre eux (pour mixer le son original avec son écho à un instant donné).

La procédure complète de l'effet d'écho pour un échantillon sera donc d'identifier les échantillons antérieurs qui devront être entendus simultanément, de diminuer leur volume en fonction de leur distance avec l'échantillon courant et de les mixer avec ce dernier. La figure 3 démontre visuellement ce principe. Notre implémentation utilisera des variables globales entières pour régler les paramètres de l'effet d'écho. Voici donc le code de sound_effects.c qui, étant donné un soundplayer, retourne l'échantillon correspondant à l'effet d'écho de l'échantillon à la position courante :

Fig. 3
Fig. 3

 Uint8 echo_volume_dec; /* Décrémentation du volume */
Uint8 echo_repeat; /* Nombre de répétitions */
Uint32 echo_delay; /* Délai entre chaque répétition */

Sint16 soundplayer_echo(soundplayer * player)
{
Uint8 k;
Sint16 res = 0;

for (k = 1; k <= echo_repeat; k++) {
Sint16 newvolume = global_volume - (echo_volume_dec * k);
/* Si le volume a atteint l'inaudible, inutile de continuer... */
if (newvolume <= 0) break;
/* Si la position de l'échantillon répété sort des bornes du son,
inutile de continuer... */
if (!(player->pos >= echo_delay * k &&
player->pos - (echo_delay * k) < player->played_sound->length))
break;
/* Sinon, alors on le mixe! */
res = sample_mix(res, sample_volume((*(Sint16 *)(player->played_sound->data +
player->pos - echo_delay * k)), newvolume));
}

return res;
}

Et voilà pour les effets de base. Ci-dessous, les déclarations de toutes nos variables et fonctions qui doivent se trouver dans sound_effects.h :

 #ifndef _SOUND_EFFECTS_H__
#define _SOUND_EFFECTS_H__

#include "audio.h"

/* Les paramètres d'echo */
Uint8 echo_volume_dec = 50; /* Décrémentation du volume */
Uint8 echo_repeat = 5; /* Nombre de répétitions */
Uint32 echo_delay = 35000; /* Délai entre chaque répétition */

/* Change le volume sur le sample passé en paramètre.
Le volume retourné est égal au paramètre volume / 255. */
Sint16 sample_volume(Sint16 sample, Uint8 volume);
/* Retourne le mixage des deux échantillons src1 et src2 */
Sint16 sample_mix(Sint16 src1, Sint16 src2);

/* Retourne l'échantillon correspondant à l'écho du player pour sa
position courante, en fonction des paramètres d'écho globaux */
Sint16 soundplayer_echo(soundplayer * player, Uint8 initial_volume);

#endif

Voyons maintenant comment orchestrer tout cela dans notre moteur de son.

Le moteur sonore

Dans notre exemple, les sons pourront être joués à n'importe quel moment, et dans n'importe quel ordre. N'ayant aucune information sur la manière dont ils vont être joués, nous devrons effectuer le mixage juste avant d'émettre le son sur la carte, c'est à dire dans la fonction de rappel appelée par SDL. Encore faut-il savoir quoi mixer. Nous allons utiliser une table globale de soundplayer qui contiendra les sons en train d'être joués avec leur position courante, et qui sera parcourue par notre fonction de rappel pour les mixer. Le nombre de sons jouables simultanément sera donc déterminé par la taille de cette table. Une fonction permettant de jouer un son sur cette « table de mixage » sera chargée de trouver un élément libre dans cette table et d'en initialiser la structure afin qu'il pointe vers le son à jouer. Sa lecture commencera ainsi dès le prochain passage dans la fonction de rappel.

Nous déclarerons toutes ces choses dans le fichier mixer.h :

 #ifndef _MIXER_H__
#define _MIXER_H__

#include "audio.h"

/* Nombre de sons pouvant être joués simultanément */
#define NBR_OF_AUDIO_SLOTS 8
/* "Table de mixage" de taille NBR_OF_SLOTS */
soundplayer audio_slots[NBR_OF_AUDIO_SLOTS];

/* Initialise le mixeur audio */
void audio_init();
/*Cherche un emplacement libre pour jouer le son donné en
paramètre. S'il est trouvé, il sera utilisé pour jouer le son.
Retourne 0 en cas de succès, -1 si aucun slot n'est libre. */
int audio_play(sound * toplay);

#endif

L'implémentation, relativement simple, se trouve dans mixer.c :

 #include "mixer.h"

void audio_init() {
int i;

for (i = 0; i < NBR_OF_AUDIO_SLOTS; i++) {
soundplayer_stop(&audio_slots[i]);
}
}

int audio_play(sound * toplay) {
int i;

/* Tout d'abord, chercher un slot de libre pour jouer le son */
for (i = 0; i < NBR_OF_AUDIO_SLOTS; i++) {
if (!soundplayer_isplaying(&audio_slots[i])) break;
}

/* Tous les slots sont utilisés? On retourne l'erreur */
if (i == NBR_OF_AUDIO_SLOTS) return -1;

/* Sinon on affecte le son à jouer au slot correspondant */
soundplayer_play(&audio_slots[i], toplay);

return 0;
}

La structure est en place, il reste maintenant à créer la fonction de rappel de SDL responsable de mixer tout cela. La procédure de base est de parcourir tous les éléments de la table audio_slots, de mixer les sons qui y sont présents pour la durée qu'on nous demande de fournir, et de mettre à jour les états des soundplayer (position et statut pour ceux qui sont arrivée à échéance).

Notre fonction de mixage ira plus loin, puisqu'elle contrôlera également un volume global, et un effet d'écho. Ces paramètres ainsi que la fonction de rappel pour SDL seront définis dans notre main.c :

 #include <stdio.h>
#include <unistd.h>
#include <string.h>
#include "SDL.h"

#include "mixer.h"
#include "sound_effects.h"

/* Le volume global */
static Uint8 global_volume = 255;

/* Variable de contrôle de l'écho */
static Uint8 echo_on = 0;

/* Fonction de rappel qui copie les données sonores dans le tampon audio */
void mixaudio(void * userdata, Uint8 * stream, int len)
{
int i, j;

/* Parcours de tous les slots audio, à la recherche de ceux qui sont utilisés */
for (i = 0; i < NBR_OF_AUDIO_SLOTS; i++) {
if (soundplayer_isplaying(&audio_slots[i])) {
soundplayer * player = &audio_slots[i];
sound * toplay = player->played_sound;

/* Le nombre de samples que nous allons produire */
Uint32 nbSamples = len / sizeof(Sint16);

for (j = 0; j < nbSamples; j++) {
Sint16 mix = ((Sint16 *)stream)[j];
/* Récupération de l'échantillon courant, ou de la valeur 0 si nous
avons fini de jouer le son */
Sint16 sample = (player->pos < toplay->length) ?
(*(Sint16 *)(toplay->data + player->pos)) : 0;

/* Application du volume global */
sample = sample_volume(sample, global_volume);

if (echo_on) {
/* On applique ensuite l'effet d'echo */
sample = sample_mix(sample, soundplayer_echo(player, global_volume));
}

/* L'échantillon est enfin additionné au buffer */
sample = sample_mix(sample, mix);

/* Ecriture du nouveau son mixé */
((Sint16 *)stream)[j] = sample;

player->pos += sizeof(Sint16);
}

/* Mise à jour du lecteur */
soundplayer_update(player);
}
}
}

Ensuite, la fonction de gestion des entrées se chargera de jouer les sons et d'activer les effets en fonction des entrées clavier :

 /* Gestion des entrées clavier */
Uint8 letsexit = 0;
/* Les sons jouables */
sound KDE_startup;
sound KDE_logout;
sound K3B_error;
sound KDE_dialog;
/* Pas de modification du volume par le clavier */
#define VOLUME_STEP 10
void input_update() {
SDL_Event event;
while (SDL_PollEvent(&event)) {
switch (event.type) {
case SDL_KEYDOWN:
switch (event.key.keysym.sym) {
/* Quitter */
case SDLK_ESCAPE:
letsexit = 1;
break;
case SDLK_TAB:
printf("Joue: KDE_Startup\n");
audio_play(&KDE_startup);
break;
case SDLK_a:
printf("Joue: KDE_Logout\n");
audio_play(&KDE_logout);
break;
case SDLK_z:
printf("Joue: K3B_Error\n");
audio_play(&K3B_error);
break;
case SDLK_e:
printf("Joue: KDE_Dialog\n");
audio_play(&KDE_dialog);
break;
/* Gestion du volume */
case SDLK_KP_PLUS:
if (global_volume < 255 - VOLUME_STEP) global_volume += VOLUME_STEP;
else global_volume = 255;
printf("Volume: %d\n", global_volume);
break;
case SDLK_KP_MINUS:
if (global_volume > 0 + VOLUME_STEP) global_volume -= VOLUME_STEP;
else global_volume = 0;
printf("Volume: %d\n", global_volume);
break;
/* Echo on/off */
case SDLK_RETURN:
if (echo_on) { echo_on = 0; printf("Echo desactivé\n"); }
else { echo_on = 1; printf("Echo activé\n"); }
break;
default:
break;
}
break;
default:
break;
}
}
}

Les touches TAB, A, Z et E déclenchent chacune la lecture d'un son différent, qui sera ajouté à la table de mixage pour être entendu avec les autres sons éventuellement en cours de lecture. Les touches + et – du pavé numérique contrôlent le volume global, tandis que la touche Entrée active ou désactive l'effet d'écho. Et enfin, la fonction principale! Elle ressemblera beaucoup à celle de la dernière fois, avec sa phase d'initialisation, le chargement des sons, le paramétrage de la fonction de rappel et la boucle d'attente.

 int main(int argc, char * argv[])
{
SDL_AudioSpec desired, obtained;
SDL_Surface * screen;

if (SDL_Init(SDL_INIT_VIDEO | SDL_INIT_AUDIO) == -1) {
printf("Erreur lors de l'initialisation de SDL!\n");
return 1;
}

/* Création d'une fenêtre pour récupérer les évènements clavier */
screen = SDL_SetVideoMode(640, 480, 24,
SDL_SWSURFACE);


/* Son 16 bits stéréo à 44100 Hz */
desired.freq = 44100;
desired.format = AUDIO_S16SYS;
desired.channels = 2;

/* Le tampon audio contiendra 512 échantillons */
desired.samples = 512;

/* Mise en place de la fonction de rappel et des données utilisateur */
desired.callback = &mixaudio;
desired.userdata = NULL;

if (SDL_OpenAudio(&desired, &obtained) != 0) {
printf("Erreur lors de l'ouverture du périphérique audio: %s\n", SDL_GetError());
return 1;
}
printf("Paramètres audio obtenus: d canaux, fréquence d échantillons.\n",
obtained.format & 0xff, obtained.channels, obtained.freq, obtained.samples);

audio_init();

/* Chargement de quelques fichiers son... */
if (sound_load(&KDE_startup, "KDE_Startup_new.wav", &obtained) ||
sound_load(&KDE_logout, "KDE_Logout_new.wav", &obtained) ||
sound_load(&K3B_error, "k3b_error1.wav", &obtained) ||
sound_load(&KDE_dialog, "KDE_Dialog_Appear.wav", &obtained)) {
fprintf(stderr, "Sons non chargés - arrêt\n");
SDL_CloseAudio();
SDL_Quit();
return 1;
}

/* La fonction de rappel commence à être appelée à partir de maintenant. */
SDL_PauseAudio(0);

while(!letsexit) {
input_update();
/* Attente d'1/10 de seconde */
SDL_Delay(100);
}

/* On cesse d'appeler la fonction de rappel */
SDL_PauseAudio(1);

sound_free(&KDE_dialog);
sound_free(&K3B_error);
sound_free(&KDE_logout);
sound_free(&KDE_startup);

/* Fermer le périphérique audio */
SDL_CloseAudio();

SDL_Quit();
return 0;
}

Le Makefile suivant vous permettra de compiler le tout aisément:

 CFLAGS = `sdl-config --cflags` -Wall -ansi -g
LIBS = `sdl-config --libs`

OBJECTS = audio.o mixer.o sound_effects.o main.o

all: $(OBJECTS)
$(CC) $(OBJECTS) -o audiodemo $(LIBS)

clean:
rm -f *.o audiodemo

Vous pouvez bien évidemment améliorer ce petit programme. Notamment en gérant d'autres touches du clavier pour changer, en temps réel, les paramètres de l'effet d'écho, ou en appliquant les effets de manière séparée pour chaque emplacement de la table de mixage.

Bilan

Nous venons de clôturer le tour d'horizon des fonctions sonores offertes par SDL. Vraiment? En fait, une fonction a volontairement été omise : SDL_MixAudio.

 void SDL_MixAudio(Uint8 *dst, Uint8 *src, Uint32 len, int volume);

Vous l'avez deviné, cette fonction ne fait rien d'autre que mixer deux sons, de manière très similaire à la fonction de mixage que nous avons écrite. Elle peut de plus faire un ajustement du volume. Mais il était plus intéressant d'écrire notre propre fonction, n'est-ce pas?

Comments

bon tutoriel SDL

je trouve que c'est un bon tutoriel SDL, avec des images et tt^^

moi qui vais commencer à utiliser les fonctionnalités SDL, jaurais pas de problème avec toute cette documentation ^^

a voir!