doc 3 of 6

Part 02 — Music and loops

You will build: a score that loops without a seam, a crossfade that changes tracks without a click, the two-bus layout (Music / SFX), and the pause behaviour that keeps the music alive while the world stops. You'll learn: loop points · the two-player crossfade · buses as the mixing board · process_mode as a musical decision.

Why this exists#

Part 01's sounds are one-shots — they start, they finish, they're done. Music is a system in the sense the event bus shelf uses the word: it has a state (which track, what volume, is it fading), it has transitions (the crossfade), and it has to survive things the one-shots don't — a scene reload, a pause, a track change at the wrong moment. The first hour of this part is one loop property; the rest is the state machine you didn't know the score needed.

Build it#

A — The loop, and the seam#

A score on an AudioStreamPlayer with the Loop checkbox ticked (import settings, OGG's loop points) plays forever — if the recording loops. A 90 s track whose first second and last second don't agree produces a seam: a click, a gap, or a harmonic thud every 90 s, on schedule, forever. The seam is the music bug with the worst signal-to-effort ratio — ten seconds to hear, an hour to find, because it's not in your code at all.

Two cures, in order of preference:

  1. Loop points that actually agree. In the OGG import dock, set the loop start/end so the track's arrangement repeats — most game scores are written as [intro | loop | outro] precisely so the loop section is seam-free. The points go around the loop section, not the whole file.
  2. The crossfade hides it. If the source only loops badly, a 1–2 s crossfade over the seam (below) makes the seam unfindable — which is what a fix is.

The listening test: loop the track and sit through three repetitions with your eyes closed. The seam announces itself as a rhythm — a bump on a downbeat, every 90 s. If you can find the bump, the player can too, even if they can't name it.

B — The crossfade: two players, one job#

One player can't fade a track out and fade the next one in — it's playing one stream. The standard answer is two players and a swap:

class_name Music
extends Node

@export var fade_seconds := 1.5

var _a: AudioStreamPlayer
var _b: AudioStreamPlayer
var _active: AudioStreamPlayer

func _ready() -> void:
	_a = $TrackA
	_b = $TrackB
	_active = _a

func play_track(stream: AudioStream) -> void:
	var incoming := _b if _active == _a else _a
	var outgoing := _active

	incoming.stream = stream
	incoming.volume_db = -40.0
	incoming.play()
	_active = incoming

	var tween := create_tween().set_parallel(true)
	tween.tween_property(incoming, "volume_db", 0.0, fade_seconds)
	tween.tween_property(outgoing, "volume_db", -40.0, fade_seconds)
	tween.chain().tween_callback(outgoing.stop)

The mechanics worth naming:

  • -40.0 is "silent for practical purposes." Decibels are logarithmic: −40 dB is 1/100th the amplitude, inaudible under any mix. Fading to 0.0 dB as the "off" position would be fading to full volume — the part-01 unit trap, now load-bearing.
  • set_parallel(true) then chain(). Both volume moves run together (that's the crossfade — overlap, not sequence); the stop() waits for both to finish (a stop mid- fade is the click you're trying to remove).
  • The swap is a variable, not a branch. _active points at whoever's loud; the next fade uses the other. Two players, any number of tracks, no state to track but the pointer.
  • create_tween() bound to Music (a Node) — the tween dies with the music system, and a scene reload that frees Music mid-fade frees the tween too. The containment rule from the pool part, applied to a fade.

The when is design: the survivors game crossfades at the minute-ten wall (stage 09's stretch — the score shifts when the spawn rate doubles), the 3D farm on harvest milestones. A crossfade you trigger on a game event is the score participating in the design; one you trigger on a timer is a jukebox.

C — The buses: the mixing board#

Project Settings → Audio tab: the Buses button opens the mixing view. The default is one bus — Master — and every player routes there. Add two:

Master
├── Music      ← the score's players
└── SFX        ← every one-shot player

Set each AudioStreamPlayer's Bus property (it's in the inspector, defaults to Master). Now the board does three jobs the single bus couldn't:

  • Balance by layer, not by file. The score and the hits are in permanent negotiation — thirty thocks a second will drown a −6 dB score. One turn of the Music bus's fader rebalances the whole layer; per-file faders would be thirty turns.
  • Solo is the debugging tool. Solo the SFX bus and the score goes silent: you can now hear whether the hits are landing on the frame (part 04's test) without the music masking the answer. Solo is how you find out the seam is in the SFX, not the score.
  • The duck lives on a bus. The level-up sting (the survivors stage 07) should drop the score for its two seconds — the weather changing. One line, on the bus, not the player:
func duck_music(amount_db := -8.0, seconds := 2.0) -> void:
	var idx := AudioServer.get_bus_index("Music")
	var base := AudioServer.get_bus_volume_db(idx)
	var tween := create_tween()
	tween.tween_method(AudioServer.set_bus_volume_db, idx, base + amount_db, 0.1)
	tween.tween_interval(seconds)
	tween.tween_method(AudioServer.set_bus_volume_db, idx, base, 0.5)

AudioServer.set_bus_volume_db moves the layer; the sting plays on SFX at full level while the score bows. The duck is a tween on a method, not a property — tween_method because the fader's position is server state, not node state. (The bus layout — names, order, effects — is saved in the project, so the board you tune is the board everyone gets. The settings-that-persist pattern applies: the user's master volume, when you add one, is a setting; the designer's bus balance is a project file.)

D — The pause, as a musical decision#

The survivors game's level-up menu pauses the tree (stage 07). Watch what happens to the music: it stopsAudioStreamPlayer obeys process_mode, and the default is Inherit, which inherits the pause. The world freezes, the drops hang mid-arc, and the score cuts out — three things that all read as "time stopped," and the third is the one players feel as a glitch, because a record that stops mid-bar is not "paused," it's broken.

The fix is one property, on the music players: Process Mode → When Paused. The score keeps playing under the frozen world — the "time stopped for you" feeling the stage-07 menu was built on, now with the weather intact. And the sting (part C's duck) plays over the continuing score, which is exactly the arrangement: the world stops, the music doesn't, and the new bar announces the choice.

(The opposite is also a design: a menu that silences the score reads as "outside the game" — the title screen. Both are honest; choose per menu. The rule is only that the choice is madeprocess_mode left at Inherit is the pause deciding for you.)

E — Commit#

git add . && git commit -m "audio 02: looping score, crossfade, Music/SFX buses, pause behaviour"

Checkpoint — definition of done#

  • The score loops through three repetitions with no findable seam — eyes closed, the listening test from part A
  • The crossfade changes tracks with no click: the incoming track is audible under the outgoing one for the full fade (that overlap is the crossfade; a sequence is a gap)
  • The Music and SFX buses exist and are routed — solo SFX and name what the mix lost
  • The duck drops the score for the sting's length and recovers — and the sting is unaffected (it's on SFX)
  • The level-up pause (or your menu's) keeps the music alive: the world freezes, the bar doesn't break — and you can say which property did it
  • Zero warnings; committed

Stretch (no instructions)#

A music state — the score shifts on the same trigger as the survivors minute-ten wall, and the transition is the design: a 3 s crossfade on a spawn-rate change is a different sentence than a 0.5 s one. Find the fade length that makes the player feel the wall arrive instead of being told it arrived.

If you get stuck#

  • The loop clicks at the seam but the track should loop → the OGG import's loop points are at the file's ends, not around the loop section. Reimport with the points moved; the seam is a recording-position problem, and the import dock is where the position lives.
  • The crossfade is just two tracks, one after the other → the set_parallel(true) is missing (the two tween_property calls sequenced), or the incoming play() is after the outgoing fade starts with a delay. The overlap is the whole figure; check the two volume curves in the debugger's tween view or add a temporary print at each fade's midpoint.
  • The duck never comes back → the tween_interval and the recovery tween_method are on separate tweens (two create_tween() calls) and the second one's base was read after the first duck applied. One tween, read base once, at the top.
  • Music is silent in the menu but you set When Paused → the property is on the AudioStreamPlayer, not on the Music node that holds it — and it's per-player. Both crossfade players need it, or the inactive one (the one that will start playing during the pause) inherits the pause and the crossfade into it is silent.