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.
A fun endeavor no doubt, but not a priority for AGL.↩︎