Audio
The audio package mixes in Go on the output device's
own thread. A game gets a Mixer from ctx.Audio; the engine opens and
owns the default output. Optional device enumeration and selection let
the game choose another endpoint through that mixer.
The engine still supplies ctx.Audio in headless mode, with NoAudio,
or when output initialization fails. Without output callbacks, sound
and stream playheads do not advance and natural-completion callbacks
do not run. Keep essential game progress independent of audio completion.
Every method is safe to call from the game loop. A setter copies its
value in under a short lock and the mixer picks it up at the start of the
next block, so setters do not wait for a whole block to finish.
The mixer takes the same lock twice a block. While it waits for the lock,
setters yield to it instead of queueing, so a game that calls setters
in a tight loop does not delay a block. To keep the lock short, call
setters once per frame for each voice that changed rather than
repeatedly for the same value.
Stream.Read runs without the settings lock, but retains the playback
lock. It may call setters or start voices, but must return promptly and
must not call Voice.Seek: seeking needs that same playback lock.
Voice.Seek waits for the block in flight when called from the game loop.
Sounds and voices
NewSound converts decoded PCM to the mixer's format; Decode reads
WAV, Ogg Vorbis, MP3 and native FLAC from bytes, and Sine synthesises a tone for
tests and placeholders. Play starts a Voice with options: volume,
pan, loop, pitch, a fade-in, a low-pass cutoff, a reverb send, occlusion
and a priority. The voice can be adjusted while it runs: SetVolume,
FadeTo, FadeOut, SetPitch, SetPaused, SetLowPass,
SetPosition, SetOcclusion, SetMute, SetSolo.
Gains ramp across each block, so changes never click. Stop, StopAll
and a voice that loses its slot ramp to silence over about a
millisecond, so cutting a sound off mid-cycle is inaudible; the voice
leaves Playing at once and the mixer spends that millisecond fading
what is left. Voice.Position and Seek read and move the playhead,
Sound.Duration is its length, and Voice.OnDone runs a callback when
the voice ends, for chaining clips.
Volume: 0 and Pitch: 0 in PlayOptions both mean 1. Use
SetVolume(0) after starting a voice to silence it. Playing includes
muted, paused and out-of-range voices; it does not measure audibility.
Use Voice.State() to distinguish PlaybackPlaying, PlaybackPaused
and PlaybackStopped. It includes pauses applied by the bus or mixer;
mute leaves the state playing. Stop reports stopped immediately during
its final gain ramp. States have readable String() values for a UI or log.
Pitch applies to sounds and streams, defaults to 1 and is clamped to
0.01 through 64. Zero and non-finite pitch values select 1.
An OnDone callback normally runs on the mixing thread after its locks
are released. StopAll and voice stealing run it on their caller before
the final ramp, and registration on an already finished voice calls it
immediately. Keep callbacks short and send game-state changes back to
the game loop.
pcm, err := audio.Decode(data) // WAV, Ogg Vorbis, MP3 or FLAC bytes
if err != nil {
return err
}
hit, err := ctx.Audio.NewSound(pcm)
if err != nil {
return err
}
v := ctx.Audio.Play(hit, audio.PlayOptions{Volume: 0.8, Pan: -0.3, Pitch: 1.1, FadeIn: 0.05})
v.OnDone(func() { ctx.Audio.Play(g.ricochet, audio.PlayOptions{}) })
// Later, while it plays.
v.SetVolume(0.4)
v.SetLowPass(1200)
v.FadeOut(0.5)
audio.Sine(440, 0.3, ctx.Audio.Rate()) makes a PCM without a file.
The examples and tests use it to get something to play.
Buses
Voices play through a Bus. A settings screen binds its sliders to
buses rather than to every voice. Music, Effects and Dialogue
already exist; NewBus makes more. A bus has its own volume, pause,
mute, solo and reverb, applied with the same ramps. To choose a bus, set
PlayOptions.Bus.
m := ctx.Audio
m.Music().SetVolume(0.4) // the settings screen's music slider
m.Effects().SetVolume(0.9)
steps := m.NewBus("footsteps")
m.Play(g.step, audio.PlayOptions{Bus: steps, Volume: 0.6})
m.Play(g.line, audio.PlayOptions{Bus: m.Dialogue(), Priority: 100})
steps.SetVolume(0.3) // the whole group at once, ramped
Mixer.Bus(name) finds a bus made earlier, so the settings screen does
not have to be handed one.
Pausing
To pause every voice at once, call Mixer.SetPaused. A pause menu calls
it, and the engine applies automatic pausing when every active window
requests it through its focus/minimized pause settings. In a single
window, Config.PauseUnfocused pauses audio when that window loses focus.
Bus.SetPaused pauses one bus and
Voice.SetPaused one voice. A pause fades out over the block it lands
in and the resume fades back in, so neither clicks. Each level is kept
separately, so resuming the mixer leaves a paused bus paused.
if ctx.Input.KeyPressed(input.KeyEscape) {
g.menu = !g.menu
ctx.Audio.SetPaused(g.menu) // everything holds where it is
}
ctx.Audio.Effects().SetPaused(true) // one bus, so effects stop and music plays on
g.engine.SetPaused(true) // one voice
Mute and solo
To mute a voice or bus, call Voice.SetMute or Bus.SetMute. A muted
voice or bus keeps playing silently, so unmuting resumes wherever the
sound has reached; to stop the sound advancing, use SetPaused instead.
To solo, call Voice.SetSolo or Bus.SetSolo. While any voice is
soloed, only soloed voices are heard. While any bus is soloed, only
soloed buses are heard, and a voice on no bus is silent. Clearing the
last solo brings everything back.
music := ctx.Audio.Music()
music.SetMute(!music.Muted()) // the mute button; the music keeps playing silently
// A mixing screen plays one bus at a time; g.audition is "" for none.
for _, b := range []*audio.Bus{ctx.Audio.Music(), ctx.Audio.Effects(), ctx.Audio.Dialogue()} {
b.SetSolo(b.Name() == g.audition)
}
Music
OpenMusicFile plays WAV, Ogg, MP3 or native FLAC through a two-second buffer filled by
a decoder goroutine. Ogg, MP3 and FLAC decode incrementally; WAV is decoded
fully at open and held in memory. PlayStream plays it, and Close
joins the decoder before closing the owned file.
Music.Duration and Music.Seek work for all four formats. FLAC duration
comes from STREAMINFO (0 when unknown). FLAC seeks scan forward, rewinding
when necessary, and retain at most one decoded FLAC frame. A distant seek
can take time, but opening does not decode the whole track or build an index.
FLAC decoding accepts 1..8 channels and 4..24-bit samples; playback accepts
mono/stereo. Frame CRCs are checked; whole-stream MD5 is not. 32-bit FLAC
and Ogg-encapsulated FLAC are not supported.
Looping retains resampling history across the track boundary, including
tracks as short as one source frame. An explicit seek resets that history;
nonlooping playback includes the final frame's interval.
Anything implementing Stream plays the same way. Read returns a
frame count, not a sample count: each frame has two interleaved samples.
A short return ends the voice. For a temporary underrun, fill with
silence and report a full buffer. Procedural music and the tracker
player use this contract.
Stream pitch conversion retains interpolation and lookahead across mixer
blocks without allocating during steady playback. A voice can read ahead
up to 513 source frames; Voice.Seek clears that lookahead. For streams,
Voice.Position reports source-rate elapsed time, including pitch,
Doppler and underrun silence, without wrapping at loop boundaries.
if g.music, err = ctx.Audio.OpenMusicFile("music/theme.ogg", true); err != nil { // true loops
return err
}
ctx.Cleanup(g.music.Close)
g.theme = ctx.Audio.PlayStream(g.music, audio.PlayOptions{Bus: ctx.Audio.Music(), Volume: 0.5})
...
g.theme.Seek(30) // move playback and the voice's reported position
Music.SetLooping(bool) changes repetition at runtime; Looping() reads
it. SetLoopRange(start, end time.Duration) selects an exclusive end
boundary and restarts at start. (0, 0) restores the whole track. A range
must contain at least one source frame and fit the duration when known.
Boundaries round down to source frames; LoopRange() returns the aligned
boundaries rounded up to nanoseconds so they can be passed back unchanged.
Seeking outside an enabled explicit range returns to its start. With
looping disabled, playback continues from start through the file's end.
Loop changes flush prefetched audio and can briefly produce silence while
the decoder refills. Toggling repetition resumes at the next source frame,
rounded down, accounting for unread samples but excluding underrun padding.
A mixer block already in progress can finish with its previous settings.
Changing a range intentionally restarts it. Enabling looping after EOF
restarts the decoder; an already stopped voice needs a new PlayStream
call. Direct music controls do not reset Voice.Position.
asset.Music(ctx.Audio, fs, "music/theme.ogg", true) does the same
through the asset sources, so a packed or embedded track opens the same
way as a loose one. It reads the encoded file into memory. Use
OpenMusicFile for incremental file I/O without separate file cleanup,
or OpenMusic for a borrowed io.ReadSeeker. A borrowed reader stays
open and can be closed safely after Music.Close returns. Close waits
for an in-flight read or seek; a custom reader must unblock those itself.
Never call Music.Close from that reader or its callbacks. Repeated and
concurrent Close calls are safe. Close every Music even after playback
ends, since its decoder parks for a future seek.
Positional audio
Set Positional and a Position on a voice, and put the listener where
the camera is each frame with SetListener (or SetListener2D for a 2D
game). Volume falls with distance between MinDistance and
MaxDistance, and the voice pans by direction.
// 2D: the listener goes where the camera is looking.
ctx.Audio.SetListener2D(g.camX, g.camY)
// 3D: position and orientation.
ctx.Audio.SetListener(audio.Listener{Position: g.eye, Forward: g.dir, Up: lin.V3(0, 1, 0)})
torch := ctx.Audio.Play(g.fire, audio.PlayOptions{
Loop: true, Positional: true,
Position: lin.V3(4, 1, -8), MinDistance: 2, MaxDistance: 40,
})
torch.SetPosition(lin.V3(6, 1, -8)) // when the source moves
RelativeToListener: true interprets position, direction and velocity in
the listener's local basis: +X right, +Y up, -Z forward. Listener motion
is added to local velocity, so a stationary attached source has no
relative Doppler shift. Set it later with SetRelativeToListener.
For a directional source, set Direction and Cone in PlayOptions,
or use SetDirection and SetCone. Cone angles are full apertures in
radians: gain is 1 inside InnerAngle, interpolates to OuterGain at
OuterAngle, and stays there outside. The zero cone is omnidirectional.
Angles must satisfy 0 <= inner <= outer <= 2*pi, and outer gain is 0..1.
Directions must be finite and nonzero; they are normalized.
Attenuation selects AttenuationDefault (the existing inverse-distance
curve multiplied by a linear cutoff), AttenuationLinear,
AttenuationInverse, AttenuationExponential, or AttenuationNone.
Rolloff defaults to 1; a larger value makes distance loss steeper.
All distance models reach silence at MaxDistance except None, which
ignores distance. SetAttenuation and SetDistanceRange validate finite
values; the distance range requires 0 < min < max. Direction, cone,
attenuation, distance-range and relative-mode setters enable positional
audio. Matching getters expose the current settings for editing interfaces.
Binaural rendering
By default a positional voice is panned between the two channels by a
constant-power law. SetSpatial(audio.SpatialSettings{Binaural: true})
renders it through a head model instead, which is worth having when the
player wears headphones. Each voice then gets:
- an interaural time difference, from Woodworth's formula for the path around a sphere, so the far ear hears it up to about 0.66 ms later;
- an interaural level difference, the far ear down by up to 6 dB;
- a head shadow, a one-pole low-pass on each ear whose cutoff falls from 22 kHz to 1.5 kHz as the source moves to the far side;
- an elevation cue, a shelf at 4 kHz that lifts the high band for a source above the listener and drops it for one below.
This is a parametric head model, not a measured head-related transfer
function: it has no ear shape, so it does not tell front from back, and
it is the same head for every player. HeadRadius sets that head's size
in metres; the default of 0.0875 is an average adult, and larger heads
give a wider time difference. Everything is interpolated across each
block, so moving sources and a moving listener glide. The zero
SpatialSettings restore panning, and voices that are not positional
are unaffected either way.
// A settings screen's headphones switch.
ctx.Audio.SetSpatial(audio.SpatialSettings{Binaural: g.headphones})
The cost is a delay line and three one-pole filters per positional voice, and the signal collapses to mono before the ears, so a stereo clip loses its own width when it is spatialised.
Doppler
To turn the Doppler effect on, call SetDoppler(1). A positional sound
closing on the listener then plays sharp, and one receding plays flat,
by how fast each moves along the line between them. The mixer does not
integrate motion, so the game gives it velocities: Listener.Velocity
and Voice.SetVelocity (or PlayOptions.Velocity), in world units per
second. They are measured against the speed of sound, 343 by default,
which suits metres; a game in pixels sets SetSpeedOfSound higher to
keep the effect subtle. The factor scales the shift, so 0.5 halves it and
0 (the default) is off. Doppler applies to both sound and stream voices,
combining with their configured pitch within the 0.01 through 64 rate range.
ctx.Audio.SetDoppler(1)
ctx.Audio.SetSpeedOfSound(3000) // pixels, not metres, so the shift stays subtle
l := ctx.Audio.Listener()
l.Position, l.Velocity = g.ship.Pos, g.ship.Vel
ctx.Audio.SetListener(l)
train.SetVelocity(lin.V3(0, 0, -40)) // world units per second
Occlusion
A sound behind a wall is quieter and duller than one in the open.
Occlusion applies that. PlayOptions.Occlusion and
Voice.SetOcclusion take 0 (clear) to 1 (fully blocked, 20 dB down and
low-passed to 400 Hz), with the amounts in between on a decibel scale.
The mixer has no scene, so the game supplies the amount. Cast a physics
ray from the listener to the source each frame and set the occlusion
from what it hits, or fade it as a door opens.
// g.wallBetween is the game's own ray against the level.
for _, s := range g.sources {
occ := float32(0)
if g.wallBetween(g.eye, s.pos) {
occ = 0.8
}
s.voice.SetOcclusion(occ)
}
Reverb
SetReverb configures the mixer's shared reverb, a Freeverb-style comb
and all-pass network; voices feed it through their Reverb send, and the
tail is mixed on top of the dry output. ReverbSettings has a room size,
damping, stereo width and wet level, and its zero value is no reverb.
Every field runs from 0 to 1; a larger room size is clamped, because
above about 1.07 the comb feedback reaches one and the tail grows
without end instead of dying away.
ctx.Audio.SetReverb(audio.ReverbSettings{RoomSize: 0.7, Damping: 0.4, Wet: 0.3})
// The send decides how much of each voice reaches it.
ctx.Audio.Play(g.shot, audio.PlayOptions{Reverb: 1})
ctx.Audio.Play(g.click, audio.PlayOptions{Bus: g.menu}) // no send: stays dry
g.pad.SetReverb(0.5) // change it while it plays
Reverb zones
To give an area its own reverb, so a cave does not sound like the field
outside it, call SetReverbZones. It takes a list of ReverbZones,
each a sphere with its own settings, and the mixer checks them whenever
the listener moves. Inside a zone its settings replace the shared
reverb, blended in over Fade units from the edge so walking through
the doorway never jumps. A zero Fade blends across the whole radius;
set it to a fraction of the radius for a room that sounds the same
everywhere but its threshold. Where zones overlap, the one the listener
is furthest inside wins. Mixer.Reverb reports what is in effect, for a
debug overlay.
ctx.Audio.SetReverbZones([]audio.ReverbZone{
{
Center: lin.V3(0, 0, -40), Radius: 20, Fade: 5, // the cave
Settings: audio.ReverbSettings{RoomSize: 0.95, Damping: 0.2, Wet: 0.8},
},
{
Center: lin.V3(30, 0, 0), Radius: 8, // the stairwell
Settings: audio.ReverbSettings{RoomSize: 0.6, Wet: 0.5},
},
})
here := ctx.Audio.Reverb() // what the listener is hearing now
Bus reverb
Bus.SetReverb gives a bus a reverb of its own, and voices on that bus
send to it instead of the shared one. That keeps the music dry while the
cave's effects ring, or gives dialogue a small room while the world has a
large one.
// The cave rings; the music stays dry because it is on another bus.
ctx.Audio.Effects().SetReverb(audio.ReverbSettings{RoomSize: 0.95, Damping: 0.2, Wet: 0.6})
ctx.Audio.Dialogue().SetReverb(audio.ReverbSettings{RoomSize: 0.3, Wet: 0.2})
ctx.Audio.Play(g.step, audio.PlayOptions{Bus: ctx.Audio.Effects(), Reverb: 1})
Voice limits
SetMaxVoices caps the number of voices playing at once. When the cap
is reached, a new voice chooses the lowest eligible priority, then the
quietest voice at that priority. Voices with higher priority are
ineligible, so a footstep never steals from the dialogue. If none is
eligible, Play returns an already finished voice. The default cap is 64.
ctx.Audio.SetMaxVoices(32)
// Dialogue outranks the world, which outranks incidental noise.
ctx.Audio.Play(g.line, audio.PlayOptions{Bus: ctx.Audio.Dialogue(), Priority: 100})
ctx.Audio.Play(g.explosion, audio.PlayOptions{Priority: 50})
ctx.Audio.Play(g.step, audio.PlayOptions{Priority: 0, Volume: 0.3})
n := ctx.Audio.Playing() // for the debug overlay
Microphone input
OpenCapture records from the default input or CaptureOptions.DeviceID.
It hands back a Capture whose Read copies
whatever is buffered without waiting for new samples, so the game loop
can call it every update. The read briefly shares a mutex with the
device's buffer writer. Level is the root mean square of the block that arrived most
recently, which is what a meter draws, and Close releases the device.
CaptureOptions take a rate and a channel count, defaulting to the
mixer's rate and mono, and Buffer sets how many seconds the ring holds
before the oldest samples are dropped; the default is half a second.
Dropped counts what was lost that way, so a rising count means the
game is not reading often enough.
if g.mic, err = ctx.Audio.OpenCapture(audio.CaptureOptions{}); err != nil {
return err // no device, no permission, or a headless run
}
...
// In Update, every frame.
for {
n := g.mic.Read(g.buf)
if n == 0 {
break
}
g.pushToVoiceChat(g.buf[:n])
}
g.meter = g.mic.Level()
Capture is separate from the mixer: nothing recorded is played back
unless the game plays it, which avoids a feedback loop by default. A
headless run and Config.NoAudio have no device at all, so OpenCapture
returns ErrNoDevice there rather than reaching the hardware behind the
game's back. On macOS the operating system asks the player for
microphone access the first time a game records, and a sandboxed
application needs the audio-input entitlement. go run ./examples/audio -mic records and draws a level meter.
Audio devices
Normal games open the system-default output automatically. NewMixer
alone opens no hardware. OutputDevices() and InputDevices() enumerate
endpoints without starting playback or recording. Each DeviceInfo has
an opaque local selection ID, a display Name, and a Default flag.
Names need not be unique; save and pass the ID when selecting an endpoint.
IDs can become unavailable after hardware, driver or configuration changes.
Linux lists configured ALSA PCMs, including virtual routing endpoints;
macOS uses device UIDs, and Windows uses WASAPI endpoint IDs.
outputs, err := ctx.Audio.OutputDevices()
if err != nil {
return err
}
// Present outputs[i].Name, retaining outputs[i].ID for the selected row.
if len(outputs) > 0 {
if err := ctx.Audio.SetOutputDevice(outputs[0].ID); err != nil {
return err // the previous output remains active
}
}
SetOutputDevice("") selects system-default routing. A replacement opens
with a silent callback before it takes over, so two devices never advance
the mixer together. Failure preserves the old output; success can briefly
produce silence while preserving voice positions. The old device is
closed after handoff. OutputDevice() reports the chosen endpoint and
whether the mixer owns an output. Empty ID with Default true represents
the default routing choice, not a physical destination or live health
query; the OS may change its destination. There is no device-change
notification or automatic recovery after an endpoint disconnects.
The engine closes its current output at shutdown. For a standalone mixer,
call CloseOutput() when finished; a later SetOutputDevice can reopen it.
Selection and close calls are safe from game goroutines and serialize with
one another. Do not invoke either from audio callbacks, including
Stream.Read and OnDone, because device shutdown waits for callbacks.
ErrDeviceUnavailable identifies unavailable endpoints and
ErrDeviceUnsupported identifies missing backends. A headless run or
Config.NoAudio returns ErrNoDevice for enumeration, selection and capture.
To select a microphone, pass an ID from InputDevices() in
CaptureOptions.DeviceID. Empty selects the system default. Capture
remains explicit and caller-owned: opening an output or enumerating inputs
does not record, persist or transmit audio. Close each Capture when done.
Recording and WAV export
Record owns a Capture and a worker that retains PCM. RecordOptions
contains CaptureOptions and MaxDuration; zero duration means 30 seconds.
The duration bounds both elapsed capture time and the number of complete
source frames. Negative durations, sub-frame limits and overflowing sample
counts are rejected. Reaching the limit is a normal stop.
rec, err := ctx.Audio.Record(audio.RecordOptions{MaxDuration: 10 * time.Second})
if err != nil {
return err
}
ctx.Cleanup(func() { rec.Close() })
...
pcm, err := rec.Stop() // stop input, drain accepted frames and join
if err != nil {
return err
}
clip, err := ctx.Audio.NewSound(pcm)
Recorder.Stop returns an independent PCM copy, including partial data on
error. Repeated calls return equivalent copies. Close joins and retains
the data for a later Stop. Done() closes after capture and worker cleanup;
Err() reports the terminal error after Done closes. Neither creating a
mixer nor enumerating devices starts a recording.
For longer recordings without retaining all samples, RecordPCM streams
raw interleaved signed 16-bit little-endian samples to a borrowed
io.Writer. RecordWAV writes PCM16 WAV to a borrowed io.WriteSeeker,
starting at its current offset and finalizing the header when stopped;
it leaves the writer positioned after the WAV and open. RecordWAVFile
creates or replaces a file and owns its closure. These return Recording,
whose Stop() and Close() return errors, plus the same Done and Err
methods. Capture and conversion buffers remain bounded independently of
recording length. A requested WAV maximum must fit RIFF's 32-bit file-size
limit; choose an explicit duration for recordings longer than 30 seconds.
Stop closes capture promptly even if output is blocked, then waits for
the writer to finish. A borrowed writer must unblock its own Write/Seek
calls. Do not call Stop or Close from those callbacks. Capture ring drops
stop the recording with ErrCaptureDropped; write, header-finalization
and owned-file close errors are returned together. Data written before an
error may be partial. Recording APIs perform no automatic transmission.
PCM.WriteWAV(w) and Sound.WriteWAV(w) export PCM16 WAV to a borrowed
writer; SaveWAV(path) owns the created file. Samples are rounded and
clamped to [-1,1]; non-finite samples, incomplete frames and invalid
rate/channel/RIFF sizes are rejected before writing. Sound exports stereo
at its mixer rate.
Normal tests use synthetic samples and never open microphones or outputs.
Hardware integration tests are explicit opt-ins, for example
BUNYIP_TEST_AUDIO_HARDWARE=1 CGO_ENABLED=0 go test ./audio -run '^TestCaptureDevice$'
records briefly from the default microphone. On macOS, the same environment
flag enables go test ./internal/audioout -run '^TestOpenPullsFrames$'.
Looking inside the mixer
Voices returns a snapshot of what is playing, one VoiceInfo per
voice with its bus, gain, pan, pitch, playhead and position, and Buses
returns every bus in the order it was made. Both are for a mixing panel
or a test rather than for the game's own logic; the debug
console shows them.
Tracker music
audio/tracker loads and plays MOD, S3M, XM
and IT modules with one engine: envelopes, new-note actions, loops, the
IT filter and ProTracker's quirks. The player is a Stream, so
PlayStream plays it. bunyip-play plays any supported file from the
command line and can dump what the device received for comparison.
mod, err := tracker.Load(data) // the format is sniffed from the bytes
if err != nil {
return err
}
g.player = tracker.NewPlayer(mod, ctx.Audio.Rate())
g.player.Loop = true
g.song = ctx.Audio.PlayStream(g.player, audio.PlayOptions{Bus: ctx.Audio.Music(), Volume: 0.6})
asset.Tracker(fs, "music/level1.xm") loads a module through the asset
sources. Module.Title, Channels and Patterns are there for a
player screen or for driving visuals from the pattern data.
Tracker control
Set the player's Loop, Cubic and AmigaFilter fields before playback,
and keep its Module and sample data unchanged while a player uses them.
The player can be driven while it plays; its methods take the same lock
as Read, so the game loop calls them freely. Position reports the
song position (an index into the order list) and row, Length counts the
positions and Rows the rows in the pattern at one, and Seek(order, row) jumps there, cutting whatever was sounding, so a level with several
sections in one module can seek between them. Mute(channel, true)
silences a pattern channel while the song plays on, Solo plays one
channel alone, and Channels says how many there are. To drop the drums
while the player hides, mute their channel and unmute it later; the song
does not break.
p := g.player
order, row := p.Position()
g.hud = fmt.Sprintf("%d/%d row %d of %d", order, p.Length(), row, p.Rows(order))
if g.enteredBossRoom {
p.Seek(3, 0) // the boss section starts at song position 3
}
p.Mute(g.drums, g.hiding) // the drums drop out while the player hides
for ch := range p.Channels() {
g.lit[ch] = !p.Muted(ch) // a channel strip for the overlay
}