Bunyip a game engine in Go GitHub

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
}