Audio

Our game engine is missing an audio system. In this tutorial we will add basic music and sound effects functionality to our games using SDL’ mixer library. This is a straightforward and simple library that lets us play a single track of music and multiple tracks of audio effects. Compared to a modern game engine with 3D audio rendering, this is very basic, but for 2D games its just what we need.

Setup

Like with other SDL libraries, we need to initialize SDL before we play audio. When we create a window with our platform package we initialize all SDL subsystems and that includes audio. So when we call platform.InitializeWindow we also have the audio subsystem ready. That alone will let us play individual audio files but we would need to mix overlapping music and sound effects ourselves1. Fortunately, SDL provides a mixer library that saves us the work. We need to initialize it and we do that like this:

import "github.com/veandco/go-sdl2/mix"

func Init() error {
    if err := mix.OpenAudio(44100, mix.DEFAULT_FORMAT, 2, 4096); err != nil {
        return err
    }
    return nil
}

The OpenAudio function will initialize audio playing on the default audio output device. The parameters control the playback frequency, audio format, channel count (2 is stereo) and sample rate. We provide reasonable defaults for these but if the user needs to pass specific values the can call mix.OpenAudio directly. It is also possible to call mix.OpenAudioDevice if we need to output to a specific audio device if, for example, we have speakers and headphones connected at the same time.

Audio Types

Mixer can play multiple sounds at once but only one music track. SDL provides separate types for music and sound effects. In our code we wrap these types in our own classes for convenience.

// Music (mp3, flac, ogg, wav). Only one music track can play at a time.
type Music struct {
    *mix.Music
}

func LoadMusic(filename string) (Music, error) {
    m, err := mix.LoadMUS(filename)
    return Music{Music: m}, err
}

func (m *Music) IsLoaded() bool {
    return m.Music != nil
}

The Music type simply wraps SDL’s mix.Music type. We provide a constructor function that lets us load the music file from storage. Once loaded, it can be played by calling Music.Play() which is a mix.Music method. We pass how many times we want the music to loop so 0 means play once and don’t loop (3 would play the music 4 times).

music, _ := audio.LoadMusic("song.mp3")
defer music.Free()
music.Play(0) 

Note that we have to explicitly destroy the music object once done. Sound effects are very similar:

type Sound struct {
    *mix.Chunk
}

func LoadSound(filename string) (Sound, error) {
    c, err := mix.LoadWAV(filename)
    return Sound{Chunk: c}, err
}

func (s *Sound) Play(loops int) {
    s.Chunk.Play(-1, loops)
}

func (s *Sound) IsLoaded() bool {
    return s.Chunk != nil
}

The only difference is that for sound we overwrite the default Play() implementation. For sound effects, since we can play multiple at once, we need to tell Play which track to use for playback. Passing -1 uses the next available track and we make this the default behavior.

For each audio type we provide a volume control function. The values passed are percentiles, so 0 is no sound, 0.5 is half volume and 1 is max. We can also set the volume for individual Sound clips by calling Sound.Volume.

func SetMusicVolume(volume float32) {
    mix.VolumeMusic(int(volume * float32(mix.MAX_VOLUME)))
}

func SetSoundVolume(volume float32) {
    mix.Volume(-1, int(volume*float32(mix.MAX_VOLUME)))
}

Adding Audio to Knights vs Trolls

Our goal for this update to Knights vs Trolls is to add sound effects and some background music. We start by calling audio.Init() somewhere in our main function to initialize the mixer. For simplicity, we will have our background music start as soon as the game starts in the main function. Of course, in a full game the background music would be tied to something like the current level.

func main() {
    err := platform.InitializeWindow(500, 500, "Knight vs Trolls", true, false)
    panicOnError(err)

    audio.Init()
    music, err := audio.LoadMusic("data/eerie.mp3")
    panicOnError(err)
    defer music.Free()
    music.Play(0)
    //...
}

Our sound effects will be tied to the object that they represent. We want our coin to make a satisfying rattle when we pick it up. When we create a coin using NewCoin we also load a sound effect of rattling coin. We make sure that the clip is only loaded once and not for every new coin. We also setup Coin so it plays a sound when it is destroyed (picked up).

var coinEffect audio.Sound

func NewCoin(position math.Vector2[float32]) *Coin {
    if coinEffect.Chunk == nil {
        coinEffect, err = audio.LoadSound("data/coin.wav")
    }
    
    c.RunOnDestroy(c.PlayPickupSound)
    //.. 
}
func (c *Coin) PlayPickupSound() {
    coinEffect.Play(0)
}

And that’s it! When a coin is picked up it will now play the coin.wav sound effect. If we manage to pickup more than one coin in quick succession, the sound effects overlap. We can do the exact same thing for the scull and we omit the code for this.

We could have added the sound effect logic to Knight’s Update method, i.e make the sound when the Knight picks up the coin or steps on a scull. It would have looked something like this.

func (k *Knight) Update(dt time.Duration) {
    //...
        collisions := k.bbox.CheckForCollisions()
    for i := range collisions {
        if game.HasTag(collisions[i], TagDebuff) {
            k.coins--
            scullEffect.Play(0) //NEW
        } else {
            k.coins++
            coinEffect.Play(0) //NEW
        }
        ScoreText.SetText(fmt.Sprint("Score:", k.coins))
        collisions[i].Destroy()
    }

This is probably easier to code at this point but is probably messier in the long run where we might have multiple sounds effects. Pairing a sound effect with the object that produces it makes it easier to track and change sounds when multiple effects are in the game.