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.
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.
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) {
, err := mix.LoadMUS(filename)
mreturn 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).
, _ := audio.LoadMusic("song.mp3")
musicdefer music.Free()
.Play(0) music
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) {
, err := mix.LoadWAV(filename)
creturn Sound{Chunk: c}, err
}
func (s *Sound) Play(loops int) {
.Chunk.Play(-1, loops)
s}
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) {
.VolumeMusic(int(volume * float32(mix.MAX_VOLUME)))
mix}
func SetSoundVolume(volume float32) {
.Volume(-1, int(volume*float32(mix.MAX_VOLUME)))
mix}
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() {
:= platform.InitializeWindow(500, 500, "Knight vs Trolls", true, false)
err (err)
panicOnError
.Init()
audio, err := audio.LoadMusic("data/eerie.mp3")
music(err)
panicOnErrordefer music.Free()
.Play(0)
music//...
}
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 {
, err = audio.LoadSound("data/coin.wav")
coinEffect}
.RunOnDestroy(c.PlayPickupSound)
c//..
}
func (c *Coin) PlayPickupSound() {
.Play(0)
coinEffect}
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) {
//...
:= k.bbox.CheckForCollisions()
collisions for i := range collisions {
if game.HasTag(collisions[i], TagDebuff) {
.coins--
k.Play(0) //NEW
scullEffect} else {
.coins++
k.Play(0) //NEW
coinEffect}
.SetText(fmt.Sprint("Score:", k.coins))
ScoreText[i].Destroy()
collisions}
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.