Sound
Playing a sound
This chapter plays a WAV file when Space is pressed. It also plays a second sound on a timer, once every sixty ticks.
Ebitengine's audio package provides the context and players. A program opens one context, decodes each sound once, makes one player for that sound and plays it by rewinding the player and starting it. The worked failure shows what Ebitengine reports when the program tries to make a new player from the same stream on each press.
Loading one player
Download the two sounds into assets/, beside the sheet. The first
program plays one of them, on Space, and lights a square for ten ticks so that the
tick it played on can be seen as well as heard.
// cmd/beep/main.go — create
package main
import (
"bytes"
"image/color"
"log"
"math"
"os"
"github.com/hajimehoshi/ebiten/v2"
"github.com/hajimehoshi/ebiten/v2/audio"
"github.com/hajimehoshi/ebiten/v2/audio/wav"
"github.com/hajimehoshi/ebiten/v2/inpututil"
"github.com/hajimehoshi/ebiten/v2/text/v2"
"github.com/hajimehoshi/ebiten/v2/vector"
"golang.org/x/image/font/gofont/goregular"
)
const (
screenW = 320
screenH = 180
sampleRate = 22050 // samples a second: the rate the sounds were written at
litFor = 10 // ticks a light stays lit after its sound plays
)
var (
courtColor = color.RGBA{R: 16, G: 20, B: 28, A: 255}
lineColor = color.RGBA{R: 232, G: 232, B: 232, A: 255}
dimColor = color.RGBA{R: 60, G: 66, B: 80, A: 255}
)
func newFace(size float64) *text.GoTextFace {
src, err := text.NewGoTextFaceSource(bytes.NewReader(goregular.TTF))
if err != nil {
log.Fatal(err)
}
return &text.GoTextFace{Source: src, Size: size}
}
func drawCentred(dst *ebiten.Image, s string, face *text.GoTextFace, x, y float64) {
w, _ := text.Measure(s, face, 0)
op := &text.DrawOptions{}
op.GeoM.Translate(math.Floor(x-w/2), y)
op.ColorScale.ScaleWithColor(lineColor)
text.Draw(dst, s, face, op)
}
// keys is what the player is doing this tick.
type keys struct {
hit bool // Space, on the tick it went down
}
func readKeys() keys {
return keys{hit: inpututil.IsKeyJustPressed(ebiten.KeySpace)}
}
type Game struct {
face *text.GoTextFace
hit *audio.Player // the hit sound, decoded once
hitLit int // ticks the light has left
}
// step advances the game one tick: a hit on Space.
func (g *Game) step(k keys) {
if k.hit {
g.hit.Rewind()
g.hit.Play()
g.hitLit = litFor
}
if g.hitLit > 0 {
g.hitLit--
}
}
func (g *Game) Update() error {
g.step(readKeys())
return nil
}
// drawLight draws a square that is lit while its sound is fresh.
func drawLight(screen *ebiten.Image, x float64, lit int, label string, face *text.GoTextFace) {
c := dimColor
if lit > 0 {
c = lineColor
}
vector.FillRect(screen, float32(x)-16, 60, 32, 32, c, false)
drawCentred(screen, label, face, x, 100)
}
func (g *Game) Draw(screen *ebiten.Image) {
screen.Fill(courtColor)
drawLight(screen, 100, g.hitLit, "SPACE", g.face)
}
func (g *Game) Layout(outsideWidth, outsideHeight int) (int, int) {
return screenW, screenH
}
func main() {
ebiten.SetWindowSize(960, 540)
ebiten.SetWindowTitle("Beep")
ebiten.SetTPS(60)
ctx := audio.NewContext(sampleRate)
data, err := os.ReadFile("assets/hit.wav")
if err != nil {
log.Fatal(err)
}
stream, err := wav.DecodeWithSampleRate(sampleRate, bytes.NewReader(data))
if err != nil {
log.Fatal(err)
}
hit, err := ctx.NewPlayer(stream)
if err != nil {
log.Fatal(err)
}
g := &Game{face: newFace(12), hit: hit}
if err := ebiten.RunGame(g); err != nil {
log.Fatal(err)
}
}
go mod tidy
go mod vendor
cat go.mod
go vet ./...
go run ./cmd/beep
$ go mod tidy $ go mod vendor $ cat go.mod module gez go 1.26 require ( github.com/hajimehoshi/ebiten/v2 v2.9.11 golang.org/x/image v0.43.0 ) require ( github.com/ebitengine/gomobile v0.0.0-20250923094054-ea854a63cce1 // indirect github.com/ebitengine/hideconsole v1.0.0 // indirect github.com/ebitengine/oto/v3 v3.4.1 // indirect github.com/ebitengine/purego v0.9.0 // indirect github.com/go-text/typesetting v0.3.0 // indirect github.com/jezek/xgb v1.1.1 // indirect github.com/rivo/uniseg v0.4.7 // indirect golang.org/x/sync v0.21.0 // indirect golang.org/x/sys v0.44.0 // indirect golang.org/x/text v0.38.0 // indirect ) $ go vet ./...
Tap Space and the beep sounds. The square lights for a sixth of a second so you can see the tick that played it.
Tap Space twice quickly and the sound starts twice. The second tap cuts the first
beep short because Rewind moves the player back to the first sample
before Play starts it. A player at the end of its samples must be
rewound before it can play again.
The audio packages bring in oto, the module Ebitengine uses to talk to
the sound device. go mod tidy and go mod vendor add and
vendor it.
main opens the context at 22,050 samples a second, the rate of these
sounds. It reads the WAV file into memory. wav.DecodeWithSampleRate
turns the bytes into samples the context can read, and ctx.NewPlayer
makes the player kept in the game.
audio.NewContext(rate) opens the machine's sound device at a sample
rate; there is one per program, and the library reports a device it cannot open
as the error RunGame returns. wav.DecodeWithSampleRate(rate,
r) reads a WAV from a reader and returns a stream in the context's format.
ctx.NewPlayer(stream) makes a player; Rewind puts it back
to the start, Play starts it, and a player that is already playing
when Play is called carries on. The reference is
pkg.go.dev/github.com/hajimehoshi/ebiten/v2/audio.
The player is used in step. Making it there from the decoded stream
looks tidy, but it makes a second player from the same stream:
type Game struct {
face *text.GoTextFace
ctx *audio.Context
stream *wav.Stream // the hit sound, decoded once
hitLit int // ticks the light has left
}
// step advances the game one tick: a hit on Space.
func (g *Game) step(k keys) {
if k.hit {
p, err := g.ctx.NewPlayer(g.stream)
if err != nil {
log.Fatal(err)
}
p.Play()
g.hitLit = litFor
}
if g.hitLit > 0 {
g.hitLit--
}
}
with main keeping the context and the stream in the game instead of
a player. The first tap beeps. The second tap, a quarter of a second later, ends
the program:
$ go run ./cmd/beep
audio: audio error: audio: the same source must not be used by multiple Player objects
That line follows the date and time printed by log.Fatal.
go run then reports exit status 1.
The message names the problem. Two players were made from one stream. A stream is a position in the decoded samples, so two players would fight over where that position is. Ebitengine refuses the second player. Keep one player and rewind it.
Playing on a timer
The second sound plays once a second. At sixty ticks a second, that means every
sixtieth tick. The counter that decides it lives in Update.
Move the loading code into internal/sound. The package opens the context
when the first sound loads and hides the rewind inside Play.
// internal/sound/sound.go — create
// Package sound plays short WAV files: one audio context for the whole
// program, one player for each sound, made once, and started from the
// beginning on the tick a game says. Every game in this volume plays its
// sounds with it.
package sound
import (
"bytes"
"os"
"github.com/hajimehoshi/ebiten/v2/audio"
"github.com/hajimehoshi/ebiten/v2/audio/wav"
)
// SampleRate is the rate the volume's sounds were written at, and the rate
// the audio context is opened at.
const SampleRate = 22050
// context is the program's one audio context, opened by the first Load.
// A program can open only one, and every player lives in it.
var context *audio.Context
// Sound is one sound a game can play.
type Sound struct {
player *audio.Player
}
// Load reads the WAV file at path, decodes it, and makes a player for it
// in the program's audio context, opening the context if this is the first
// sound loaded.
func Load(path string) (*Sound, error) {
if context == nil {
context = audio.NewContext(SampleRate)
}
data, err := os.ReadFile(path)
if err != nil {
return nil, err
}
stream, err := wav.DecodeWithSampleRate(SampleRate, bytes.NewReader(data))
if err != nil {
return nil, err
}
player, err := context.NewPlayer(stream)
if err != nil {
return nil, err
}
return &Sound{player: player}, nil
}
// Play plays the sound from its start. A sound still playing starts again.
func (s *Sound) Play() {
s.player.Rewind()
s.player.Play()
}
// cmd/beep/main.go — extend
import (
"bytes"
"image/color"
"log"
"math"
"github.com/hajimehoshi/ebiten/v2"
"github.com/hajimehoshi/ebiten/v2/inpututil"
"github.com/hajimehoshi/ebiten/v2/text/v2"
"github.com/hajimehoshi/ebiten/v2/vector"
"golang.org/x/image/font/gofont/goregular"
"gez/internal/sound"
)
const (
screenW = 320
screenH = 180
)
type Game struct {
face *text.GoTextFace
hit, point *sound.Sound
tick int
hitLit int // ticks the hit light has left
pointLit int
}
// step advances the game one tick: a hit on Space, a point every second.
func (g *Game) step(k keys) {
g.tick++
if k.hit {
g.hit.Play()
g.hitLit = litFor
}
if g.tick%pointEvery == 0 {
g.point.Play()
g.pointLit = litFor
}
if g.hitLit > 0 {
g.hitLit--
}
if g.pointLit > 0 {
g.pointLit--
}
}
func (g *Game) Draw(screen *ebiten.Image) {
screen.Fill(courtColor)
drawLight(screen, 100, g.hitLit, "SPACE", g.face)
drawLight(screen, 220, g.pointLit, "EVERY SECOND", g.face)
}
func main() {
ebiten.SetWindowSize(960, 540)
ebiten.SetWindowTitle("Beep")
ebiten.SetTPS(60)
hit, err := sound.Load("assets/hit.wav")
if err != nil {
log.Fatal(err)
}
point, err := sound.Load("assets/point.wav")
if err != nil {
log.Fatal(err)
}
g := &Game{face: newFace(12), hit: hit, point: point}
if err := ebiten.RunGame(g); err != nil {
log.Fatal(err)
}
}
// A light stays lit for ten ticks after its sound plays; a point sounds
// every sixty ticks.
const (
litFor = 10
pointEvery = 60
)
go vet ./...
go run ./cmd/beep
The lower tone sounds once a second and the right light blinks with it. Space still plays the hit sound on the left. If a tap lands near a second boundary, both sounds play because the context mixes every player that is playing.
The extend listing removes os, audio and
wav from cmd/beep, because internal/sound
now does that work. It also moves the sample rate into the package and keeps the
light and timer constants beside the code that uses them.
sound.Load reads a file, decodes it and makes one player. The context
lives in a package variable, so the program loads a sound in one call and never
handles the context directly.
Mixing players
The sound device receives a steady stream of samples. The context supplies that stream and adds together the samples from every player that is playing. Silence is zeros.
A player is a position in one decoded sound. Playing advances the position, reaching the end stops it, and rewinding moves it back to the start. Decoding once and rewinding keeps sound playback tied to the tick that caused it.
Checkpoint
- Open the program's one audio context at the sounds' sample rate and say what
a second
NewContextdoes. - Read a WAV file, decode it into a stream, and make one player from it, in the order those three happen.
- Play a sound on the tick a key goes down and on every sixtieth tick, with the counter where chapter 1 put it.
- Say why a new player per press ends the program, from the error's own words, and why rewinding the one player is the fix.
- Load a sound through
internal/soundin two lines, and know what the package keeps that the game never sees.
Exercise 1 — a faster beat. Change
pointEvery to 20, run it, and count the tones in ten seconds.
Thirty: three a second, because sixty ticks a second divided by twenty ticks a tone is three. The tone is 180 milliseconds long and the gap between starts is 333, so each finishes before the next begins and none is cut short.
Exercise 2 — a quieter point. Give Sound a
SetVolume(v float64) method that passes v to the
player, and play the point at a fifth of its volume.
func (s *Sound) SetVolume(v float64) { s.player.SetVolume(v) },
and point.SetVolume(0.2) in main after the load. The
volume is a scale from 0 to 1 on every sample the player contributes to the
mix, and it stays set across rewinds, so the tone is quiet every second.
Exercise 3 — the two-key chord. Play the point on S as well as on the timer, and tap S and Space together.
A second field in keys read with IsKeyJustPressed(ebiten.KeyS),
and g.point.Play() with the point light in step when it
is set. Both tones sound at once, the higher one ending after sixty milliseconds
and the lower one carrying on, which is the mixer adding two players' samples
in the same stream.