Skip to content

Web Audio API pour les jeux

Un bon rendu sonore transforme un jeu. Un jeu silencieux semble sans vie. La Web Audio API permet de produire des effets sonores à faible latence, de lire de la musique et même de créer un son spatial 3D, le tout dans le navigateur.

Essayez-la (cliquez sur un bouton pour écouter des sons synthétisés) :

Le problème de la lecture automatique

Avant toute chose, vous devez comprendre les restrictions liées à la lecture automatique. Les navigateurs bloquent le son jusqu’à ce que l’utilisateur interagisse avec votre page. C’est une bonne chose — personne ne veut qu’un site diffuse soudainement du son au chargement — mais cela signifie que vous devez initialiser l’audio en réponse à un clic, un appui ou une pression sur une touche.

js
let audioCtx = null

function initAudio() {
  if (!audioCtx) {
    audioCtx = new AudioContext()
  }
  if (audioCtx.state === 'suspended') {
    audioCtx.resume()
  }
  return audioCtx
}

// Initialiser lors de la première interaction
document.addEventListener('click', () => initAudio(), { once: true })
document.addEventListener('keydown', () => initAudio(), { once: true })

Si vous essayez de lire du son avant cela, l’opération échoue silencieusement ou l’AudioContext reste suspendu. Ce comportement prend de nombreux développeurs au dépourvu.

Chargement des sons

Les fichiers audio doivent être décodés avant leur lecture. Récupérez le fichier, décodez-le et conservez le tampon pour le réutiliser :

js
async function loadSound(url) {
  const response = await fetch(url)
  const arrayBuffer = await response.arrayBuffer()
  const audioBuffer = await audioCtx.decodeAudioData(arrayBuffer)
  return audioBuffer
}

Le décodage prend du temps : chargez donc tous vos sons pendant un écran de chargement, et non au moment où vous devez les lire.

Lecture des sons

Pour lire un son, créez une source de tampon, connectez-la à un nœud de gain pour contrôler le volume, puis lancez-la :

js
function playSound(buffer, volume = 1) {
  const source = audioCtx.createBufferSource()
  source.buffer = buffer
  
  const gainNode = audioCtx.createGain()
  gainNode.gain.value = volume
  
  source.connect(gainNode)
  gainNode.connect(audioCtx.destination)
  
  source.start(0)
  return source
}

Les sources de tampon sont à usage unique. Une fois la lecture d’une source terminée, vous ne pouvez pas la redémarrer. Créez-en une nouvelle à chaque lecture. Cela peut sembler inefficace, mais c’est ainsi que fonctionne l’API, et les navigateurs sont optimisés pour ce modèle.

Un gestionnaire de sons

Pour un véritable jeu, regroupez toutes ces fonctionnalités dans une classe de gestion :

js
class SoundManager {
  constructor() {
    this.ctx = null
    this.sounds = new Map()
    this.masterGain = null
  }
  
  init() {
    this.ctx = new AudioContext()
    this.masterGain = this.ctx.createGain()
    this.masterGain.connect(this.ctx.destination)
  }
  
  async load(name, url) {
    const response = await fetch(url)
    const buffer = await this.ctx.decodeAudioData(await response.arrayBuffer())
    this.sounds.set(name, buffer)
  }
  
  play(name, volume = 1) {
    const buffer = this.sounds.get(name)
    if (!buffer) return
    
    const source = this.ctx.createBufferSource()
    source.buffer = buffer
    
    const gain = this.ctx.createGain()
    gain.gain.value = volume
    
    source.connect(gain)
    gain.connect(this.masterGain)
    source.start(0)
    
    return source
  }
  
  setMasterVolume(v) {
    this.masterGain.gain.value = v
  }
}

Le nœud de gain principal vous permet d’implémenter un curseur de volume global. Tous les sons passent par ce nœud.

Musique de fond

La musique doit pouvoir être lue en boucle, arrêtée ou atténuée progressivement :

js
class MusicPlayer {
  constructor(ctx, masterGain) {
    this.ctx = ctx
    this.masterGain = masterGain
    this.currentTrack = null
    this.gain = ctx.createGain()
    this.gain.connect(masterGain)
  }
  
  play(buffer, volume = 0.5) {
    this.stop()
    
    this.currentTrack = this.ctx.createBufferSource()
    this.currentTrack.buffer = buffer
    this.currentTrack.loop = true
    this.currentTrack.connect(this.gain)
    this.gain.gain.value = volume
    this.currentTrack.start(0)
  }
  
  stop() {
    if (this.currentTrack) {
      this.currentTrack.stop()
      this.currentTrack = null
    }
  }
  
  fadeOut(duration = 1) {
    this.gain.gain.linearRampToValueAtTime(0, this.ctx.currentTime + duration)
  }
}

La méthode linearRampToValueAtTime permet d’obtenir des fondus fluides plutôt que des coupures brusques.

Audio spatial

Pour les jeux en 3D, vous pouvez positionner les sons dans l’espace. Leur volume diminue à mesure qu’ils s’éloignent de l’auditeur :

js
function playSpatialSound(buffer, x, y, z) {
  const source = audioCtx.createBufferSource()
  source.buffer = buffer
  
  const panner = audioCtx.createPanner()
  panner.panningModel = 'HRTF'
  panner.distanceModel = 'inverse'
  panner.refDistance = 1
  panner.maxDistance = 100
  panner.positionX.value = x
  panner.positionY.value = y
  panner.positionZ.value = z
  
  source.connect(panner)
  panner.connect(audioCtx.destination)
  source.start(0)
}

function updateListener(x, y, z, fx, fy, fz) {
  const listener = audioCtx.listener
  listener.positionX.value = x
  listener.positionY.value = y
  listener.positionZ.value = z
  listener.forwardX.value = fx
  listener.forwardY.value = fy
  listener.forwardZ.value = fz
  listener.upX.value = 0
  listener.upY.value = 1
  listener.upZ.value = 0
}

Appelez updateListener à chaque image avec la position de la caméra ou du joueur. Le panoramique ajuste automatiquement le volume et la répartition stéréo en fonction de la distance et de la direction.

Sons joués en rafale

Pour les sons joués plusieurs fois par seconde, comme les tirs ou les bruits de pas, il faut éviter que les instances qui se chevauchent s’accumulent. Un simple système de pool peut vous y aider :

js
class SoundPool {
  constructor(ctx, buffer, size = 8) {
    this.sources = []
    this.index = 0
    this.ctx = ctx
    this.buffer = buffer
    this.size = size
  }
  
  play(volume = 1) {
    const source = this.ctx.createBufferSource()
    source.buffer = this.buffer
    
    const gain = this.ctx.createGain()
    gain.gain.value = volume
    
    source.connect(gain)
    gain.connect(this.ctx.destination)
    source.start(0)
    
    this.index = (this.index + 1) % this.size
  }
}

Le pool limite le nombre d’instances d’un même son pouvant être lues simultanément. Lorsque la limite est atteinte, l’instance la plus ancienne est remplacée.

Formats audio

Certains formats sont plus adaptés que d’autres selon la situation :

Le MP3 fonctionne partout et convient bien à la musique. OGG Vorbis offre une meilleure qualité à taille de fichier égale. Safari a longtemps été le dernier navigateur à ne pas le prendre en charge, mais la lecture native d’Ogg Vorbis a été ajoutée dans Safari 18.4 : tous les principaux navigateurs actuels le prennent donc désormais en charge. Prévoyez tout de même une solution de repli en MP3 si vous souhaitez prendre en charge les anciennes versions de Safari. AAC/M4A fonctionne bien sur les appareils Apple. WAV est non compressé et volumineux, mais son décodage est instantané, ce qui convient aux effets sonores courts lorsque la taille du fichier n’est pas un problème. WebM/Opus offre le meilleur rapport qualité-taille, mais ne fonctionne que dans les navigateurs modernes.

Pour une compatibilité maximale, prévoyez des formats de repli :

js
const audioUrl = canPlayOgg() ? 'sound.ogg' : 'sound.mp3'

Astuces utiles

La variation de hauteur rend les sons répétés moins robotiques :

js
function playWithPitchVariation(buffer, variance = 0.1) {
  const source = audioCtx.createBufferSource()
  source.buffer = buffer
  source.playbackRate.value = 1 + (Math.random() - 0.5) * variance
  source.connect(audioCtx.destination)
  source.start(0)
}

Chaque coup de feu ou bruit de pas produit ainsi un son légèrement différent.

Le ducking réduit le volume de la musique pendant les dialogues ou les sons importants :

js
function duckMusic(musicGain, duration = 0.3) {
  musicGain.gain.linearRampToValueAtTime(0.2, audioCtx.currentTime + duration)
}

function unduckMusic(musicGain, duration = 0.3) {
  musicGain.gain.linearRampToValueAtTime(1, audioCtx.currentTime + duration)
}

Pièges sur iOS

iOS impose des restrictions audio plus strictes que les autres plateformes. L’AudioContext doit être créé et réactivé pendant un geste de l’utilisateur. Certaines versions d’iOS exigent également que vous lisiez un son, même silencieux, pendant ce geste, plutôt que de simplement créer le contexte. Si l’audio fonctionne sur ordinateur, mais pas sur iOS, c’est probablement la raison.

Ressources complémentaires

Publier un jeu web qui se charge rapidement aborde la compression des ressources, y compris des fichiers audio.

Jeux web adaptés aux appareils mobiles présente davantage de détails sur les problèmes propres à iOS.

PWA pour les jeux hors ligne explique comment mettre en cache les fichiers audio pour jouer hors ligne.

Tone.js pour l’audio de jeu traite de la génération procédurale de sons et des systèmes musicaux à l’aide d’une bibliothèque de plus haut niveau.

Où trouver des ressources de jeu gratuites répertorie des sources gratuites d’effets sonores et de musique, comme Freesound et Poly Haven.

Ressources externes