Lesson 22: Audio Mixing & Spatial Sound
Close your eyes in a good game and you can still tell where things are: the campfire crackles on your right, a bell rings far off to the left, and the music swells as danger arrives. In this lesson you build the audio system behind that: volume buses players can adjust, a pool of channels, sounds panned by where they happen, and a smooth music crossfade that never freezes the game.
๐ฏ Learning Objectives
By the end of this lesson, you will be able to:
- Build a mixer with master, SFX, ambient and music buses, where a sound's final volume is source ร bus ร master.
- Manage a pool of channels, including what to do when every channel is busy.
- Pan a sound from its world position with a constant-power pan law, and explain why the sign of the pan matters.
- Compare hand-made panning with pygame-ce's built-in
Channel.set_source_location(). - Crossfade between two music tracks over a few seconds without blocking the game loop.
Project: a Spatial Sound Sandbox where you walk around a crackling campfire and a bell, adjust four buses, and crossfade the music.
In This Lesson
๐๏ธ Buses: Mixing Like a Sound Engineer
A sound engineer at a concert doesn't adjust every microphone when the band gets too loud. The drums go into one group fader, the vocals into another, and one master fader controls everything. Games organize sound the same way. Each group is called a bus: music, ambient sounds (wind, a campfire), sound effects, and often UI and voice.
Every sound's final volume is a chain of multiplications: its own volume, times its bus, times the master:
final_volume = source_volume * bus_volume * master_volume
Multiplying keeps the stages independent. Muting the music bus for a cutscene leaves the sound effects alone; halving the master makes everything quieter while keeping the balance between buses. Any extra factor, such as distance fading for a sound in the world, is one more multiplication in the same chain. It must never replace the bus: if spatial volume overwrote the result, a player who muted SFX would still hear every positioned explosion.
class Mixer:
def __init__(self, audio_ok):
self.audio_ok = audio_ok
self.bus = {"master": 1.0, "sfx": 1.0, "ambient": 1.0, "music": 1.0}
# (the channel pool is added in the next section)
def gain(self, bus):
return self.bus[bus] * self.bus["master"]
๐ก Why this matters
Many games offer separate volume sliders for music, effects and voice in their settings menu. Some players turn music off to play their own; others need dialogue louder than explosions. A bus system makes all of that one multiplication.
๐๏ธ Channels: A Pool of Voices
You met channels in the Sound & Music lesson of Game Dev I: pygame's mixer can play a fixed number of sounds at once, one per Channel, and pygame.mixer.set_num_channels(n) changes how many. When you need a channel for a new sound:
pygame.mixer.find_channel()returns an idle channel, orNonewhen every channel is busy. Always check forNone.pygame.mixer.find_channel(True)never returnsNonewhen channels exist: if they are all busy it hands you the one that has been playing longest, so the oldest sound is cut off. Use it for sounds that must be heard.
Long-running sounds such as a looping campfire or music should not be cut off by a burst of gunshots, so you set a few channels aside with pygame.mixer.set_reserved(n), which reserves channels 0 to n โ 1. Sound.play() skips reserved channels. One surprise, which we measured with pygame-ce 2.5.8: find_channel() did not skip them and returned channel 0 while it was reserved. The simplest safe design is to keep your own list of the unreserved channels and search only that:
NUM_CHANNELS = 16
RESERVED = 3 # 0 = campfire loop, 1 and 2 = music crossfade
# in Mixer.__init__, when audio works:
pygame.mixer.set_num_channels(NUM_CHANNELS)
pygame.mixer.set_reserved(RESERVED)
self.pool = [pygame.mixer.Channel(i) for i in range(RESERVED, NUM_CHANNELS)]
self.started = {} # channel -> time it started, for stealing
def free_channel(self, force):
"""Like find_channel(force), but only from our pool, never a reserved channel."""
for channel in self.pool:
if not channel.get_busy():
return channel
if force and self.pool:
return min(self.pool, key=lambda ch: self.started.get(ch, 0.0)) # oldest sound
return None
def play(self, sound, bus, left=1.0, right=1.0, force=False):
if not self.audio_ok:
return None
channel = self.free_channel(force)
if channel is None:
return None # every voice busy: skip this one
channel.play(sound) # play() resets the channel volume...
g = self.gain(bus)
channel.set_volume(left * g, right * g) # ...so set it afterwards
self.started[channel] = self.clock
return channel
Notice the order inside play(). The pygame-ce docs say a channel's volume is reset when it starts to play, so you call channel.play() first and set_volume() after. With two arguments, set_volume(left, right) sets each speaker separately; that's the hook for panning in the next section.
Audio must also fail gracefully. Some computers (and every headless test machine) have no sound device, and pygame.mixer.init() raises pygame.error there. Catch it and carry on silently:
def init_audio():
pygame.mixer.pre_init(SAMPLE_RATE, -16, 2, 512, allowedchanges=0)
pygame.init()
try:
pygame.mixer.init()
return True
except pygame.error:
print("No audio device: running silently.")
return False
allowedchanges=0 asks SDL for exactly 16-bit stereo, converting if the hardware prefers something else. That matters because the exercise builds its sounds as raw 16-bit stereo samples in code, so it needs no sound files at all.
๐ง Panning and Distance
Your ears locate sound mostly by comparing the two sides: a sound on your right reaches the right ear louder. In a 2D game you fake that with two numbers per sound: a pan from โ1 (hard left) through 0 (center) to +1 (hard right), and an attenuation from 1 (right next to you) to 0 (out of range).
offset = source - listener # Vector2 from you to the sound
attenuation = max(0.0, 1.0 - offset.length() / hearing_range)
pan = offset.x / hearing_range # keep the sign: negative = left
The sign carries the direction, so never throw it away with abs(): abs(pan) treats a sound on your left exactly like one on your right. Next, the pan must become a left and a right volume, each between 0 and 1. That rule is called a pan law. The obvious one is linear, but it has a flaw that this short program makes visible:
import math
def linear_pan(pan):
return (1 - pan) / 2, (1 + pan) / 2
def constant_power_pan(pan):
angle = (pan + 1) * math.pi / 4
return math.cos(angle), math.sin(angle)
print(" pan | linear L R power | constant L R power")
for pan in (-1.0, -0.5, 0.0, 0.5, 1.0):
ll, lr = linear_pan(pan)
cl, cr = constant_power_pan(pan)
print(f"{pan:5.1f} | {ll:8.2f} {lr:4.2f} {ll * ll + lr * lr:6.2f} |"
f" {cl:8.2f} {cr:4.2f} {cl * cl + cr * cr:6.2f}")
It prints:
pan | linear L R power | constant L R power
-1.0 | 1.00 0.00 1.00 | 1.00 0.00 1.00
-0.5 | 0.75 0.25 0.62 | 0.92 0.38 1.00
0.0 | 0.50 0.50 0.50 | 0.71 0.71 1.00
0.5 | 0.25 0.75 0.62 | 0.38 0.92 1.00
1.0 | 0.00 1.00 1.00 | 0.00 1.00 1.00
Loudness follows power, which is the square of the level, added across both speakers. With the linear law the power halves at the center, so a sound seems to dip in volume as it passes in front of you. The constant-power (or equal-power) law uses a quarter circle: cos for the left and sin for the right, and since cosยฒ + sinยฒ = 1, the total power stays the same at every pan. Neither side ever goes above 1.0.
Put together, a positioned sound gets its two volumes like this:
def constant_power_pan(pan):
"""pan -1 (hard left) .. 0 (center) .. +1 (hard right) -> (left, right) gains."""
pan = max(-1.0, min(1.0, pan))
angle = (pan + 1) * math.pi / 4 # 0 .. pi/2
return math.cos(angle), math.sin(angle)
def spatialize(listener, source, hearing_range=HEARING_RANGE):
"""Left and right gains (each 0..1) for a sound at `source` heard from `listener`."""
offset = pygame.Vector2(source) - pygame.Vector2(listener)
attenuation = max(0.0, 1.0 - offset.length() / hearing_range)
pan = offset.x / hearing_range # keep the sign: negative means "to the left"
left, right = constant_power_pan(pan)
return left * attenuation, right * attenuation
# A bell in the world: spatialized, then through the SFX bus.
left, right = spatialize(listener, bell_pos)
mixer.play(sounds["bell"], "sfx", left, right, force=True)
# A menu click is not "somewhere": no spatialize() call, just the bus.
mixer.play(sounds["click"], "sfx")
Two more rules keep spatial audio convincing:
- Only world sounds are spatialized. UI clicks, the player's own footsteps and the music play centered. Panning a menu click would just sound broken.
- Looping sounds are re-spatialized every frame. A campfire's volumes were right when it started, but the listener keeps moving. Update its channel each frame:
fire_channel.set_volume(fire_l * g, fire_r * g).
โ Growth Mindset: When You Can't Hear the Bug
Audio bugs are hard because you can't see them, and sometimes you can't hear them either (a laptop speaker, a noisy room, a machine with no sound device). That's not a dead end; it's a reason to make the sound visible. Draw the left and right volumes as bars on screen, as the exercise does, and print them when something seems off. Once you can see the numbers, you can debug audio the same way you debug movement.
๐งญ The Built-In Option: set_source_location()
pygame-ce 2.3 added Channel.set_source_location(angle, distance), which hands the work to SDL_mixer's positional audio:
angleis in degrees: 0 is straight ahead, and the angle grows clockwise, so 90 is to the right and 270 to the left.distanceruns from 0 (right on top of you) to 255 (as far as it goes); anything outside that range raisesValueError. Even 255 isn't guaranteed to be silent, so skip sounds that are completely out of range.
For a top-down game where "ahead" is up the screen, atan2(dx, -dy) gives that angle:
offset = pygame.Vector2(source) - listener
if offset.length() < HEARING_RANGE:
channel = mixer.free_channel(force=True)
if channel is not None:
channel.play(sounds["bell"])
channel.set_volume(mixer.gain("sfx")) # bus volume
angle = math.degrees(math.atan2(offset.x, -offset.y)) % 360
distance = int(min(255.0, 255 * offset.length() / HEARING_RANGE))
channel.set_source_location(angle, distance)
It is less code, and it also handles surround setups. The hand-made version gives you full control of the curve and lets you test the math without an audio device, which is why the exercise uses it. Pick one approach per channel: both control how the channel is split between the speakers, and mixing them on one channel makes it hard to know which setting wins. The pygame-ce docs also warn that set_source_location() can raise an error with 7.1 surround output, and suggest forcing stereo with pygame.mixer.pre_init(channels=2, ...), which the exercise already does.
๐ผ Crossfading Music
When the calm exploration music gives way to battle music, a hard cut is jarring. A crossfade fades the old track out while the new one fades in, overlapping for a second or two. pygame.mixer.music streams only one track at a time, so it can't overlap two tracks with itself. The overlap needs two channels, which is why the exercise reserves channels 1 and 2 for music and plays each track as a looping Sound.
The one thing you must never do is wait for the fade: pygame.time.wait() or time.sleep() inside the game loop freezes input, drawing and everything else for the whole fade. Instead, a fade is a timer that advances a little every frame:
def crossfade_levels(progress):
"""Equal-power crossfade. progress 0..1 -> (incoming, outgoing) volumes."""
progress = max(0.0, min(1.0, progress))
return math.sin(progress * math.pi / 2), math.cos(progress * math.pi / 2)
class Crossfade:
def __init__(self, audio_ok, duration=2.0):
self.channels = [pygame.mixer.Channel(1), pygame.mixer.Channel(2)] if audio_ok else []
self.active = 0 # index of the channel fading in (or playing)
self.progress = 1.0 # 1.0 = no fade running
self.duration = duration
self.track = None
def start(self, name, sound):
if self.track is None: # first track: no fade needed
self.progress = 1.0
else:
self.active = 1 - self.active
self.progress = 0.0
self.track = name
if self.channels:
self.channels[self.active].play(sound, loops=-1)
def update(self, dt, music_gain):
if self.progress < 1.0:
self.progress = min(1.0, self.progress + dt / self.duration)
incoming, outgoing = crossfade_levels(self.progress)
if self.channels:
self.channels[self.active].set_volume(incoming * music_gain)
old = self.channels[1 - self.active]
if self.progress >= 1.0:
old.stop()
else:
old.set_volume(outgoing * music_gain)
progress is clamped with min(1.0, ...), so the fade lands on exactly full volume for the new track and exactly silence for the old one, which is then stopped. The same sine and cosine curves as the pan law keep the loudness steady through the middle of the fade. Because update() multiplies by the music bus every frame, moving the music slider mid-fade just works.
If you don't need that control, pygame has a simpler built-in version: new_channel.play(sound, loops=-1, fade_ms=2000) fades the new track in and old_channel.fadeout(2000) fades the old one out. Both return immediately, and the mixer does the fading in the background.
๐๏ธ Practice Exercise: Spatial Sound Sandbox
Objective: walk around a campfire and a bell and hear them move from speaker to speaker, with four volume buses and a music crossfade that never pauses the game.
Time: about 30 minutes. Starter file: spatial_sound_starter.py (your instructor has it). It generates all the sounds, draws the scene and the left/right meters, and already plays everything, but dead center and at full volume. Headphones make the panning much easier to hear.
- Run the starter. Walk around with the arrow keys and press SPACE, U and M. Notice that the meters never change and the music switches instantly. (โ 3 min)
- Write
constant_power_pan(): clamp the pan, turn it into an angle from 0 to ฯ/2, return the cosine and sine. (โ 5 min) - Finish
spatialize(): attenuation from the distance, pan fromoffset.xwith its sign kept. The meters now move as you walk. (โ 5 min) - In
Mixer.play(), multiply left and right by the bus gain and callset_volume()afterplay(). Press 2 to cycle the SFX bus and check that the bell obeys it. (โ 5 min) - Write
crossfade_levels()and advanceprogressinCrossfade.update(). Press M and watch "fade" count up to 100% while you keep walking. (โ 8 min) - Stand to the left of the bell, then to its right, and ring it each time. Confirm the louder bar swaps sides. (โ 4 min)
You are done when:
- the fire and bell meters show the louder side facing the source, and fall to zero outside the rings;
- the UI click (U) always plays centered;
- setting a bus to 0% silences exactly that group, and the master silences everything;
- pressing M fades the music over two seconds while you can still move.
๐ก Hint
If the source to your left is louder on the right, your offset is backward: it is source - listener. If the crossfade never finishes, check that progress grows by dt / self.duration (a fraction per frame), not by dt alone or by 1.
โ Example Solution
If your instructor hands you the lab file, you will see a few extra lines marked lab runtime near the top, plus an extra and frame_budget() condition on the main loop. They let the instructor's checker run the program automatically; when you run it yourself they do nothing.
"""Spatial Sound Sandbox: Intermediate Lesson 22 practice exercise (solution).
Walk the listener (arrow keys or WASD) around a crackling campfire and a bell.
SPACE rings the bell, U plays a UI click (never panned), M crossfades the music,
1/2/3/4 cycle the master/sfx/ambient/music bus volumes (100% -> 50% -> 0%).
All sounds are generated in code, so no audio files are needed.
"""
import array
import math
import random
import pygame
WIDTH, HEIGHT = 800, 500
SAMPLE_RATE = 44100
HEARING_RANGE = 420.0 # pixels: beyond this a source is silent
NUM_CHANNELS = 16
RESERVED = 3 # 0 = campfire loop, 1 and 2 = music crossfade
SPEED = 220.0 # listener speed, px/s
TEXT_COLOR = (230, 230, 230)
MOVE_KEYS = {pygame.K_LEFT: (-1, 0), pygame.K_RIGHT: (1, 0), pygame.K_UP: (0, -1),
pygame.K_DOWN: (0, 1), pygame.K_a: (-1, 0), pygame.K_d: (1, 0),
pygame.K_w: (0, -1), pygame.K_s: (0, 1)}
# ---------- pure math: no audio device needed, easy to test ----------
def constant_power_pan(pan):
"""pan -1 (hard left) .. 0 (center) .. +1 (hard right) -> (left, right) gains.
leftยฒ + rightยฒ is always 1, so the sound keeps the same loudness as it moves.
"""
pan = max(-1.0, min(1.0, pan))
angle = (pan + 1) * math.pi / 4 # 0 .. pi/2
return math.cos(angle), math.sin(angle)
def spatialize(listener, source, hearing_range=HEARING_RANGE):
"""Left and right gains (each 0..1) for a sound at `source` heard from `listener`."""
offset = pygame.Vector2(source) - pygame.Vector2(listener)
attenuation = max(0.0, 1.0 - offset.length() / hearing_range)
pan = offset.x / hearing_range # keep the sign: negative means "to the left"
left, right = constant_power_pan(pan)
return left * attenuation, right * attenuation
def crossfade_levels(progress):
"""Equal-power crossfade. progress 0..1 -> (incoming, outgoing) volumes."""
progress = max(0.0, min(1.0, progress))
return math.sin(progress * math.pi / 2), math.cos(progress * math.pi / 2)
# ---------- sound generation (plain Python, 16-bit stereo) ----------
def make_sound(seconds, sample):
"""Build a Sound from sample(t) -> -1..1, called once per sample."""
data = array.array("h")
for i in range(int(SAMPLE_RATE * seconds)):
value = int(max(-1.0, min(1.0, sample(i / SAMPLE_RATE))) * 32767)
data.append(value) # left
data.append(value) # right
return pygame.mixer.Sound(buffer=data)
def make_sounds():
rng = random.Random(7)
return {
"bell": make_sound(0.8, lambda t: 0.5 * math.sin(2 * math.pi * 880 * t) * math.exp(-5 * t)),
"click": make_sound(0.05, lambda t: 0.4 * math.sin(2 * math.pi * 1500 * t) * math.exp(-60 * t)),
"fire": make_sound(1.0, lambda t: 0.25 * rng.uniform(-1, 1) * (0.3 + 0.7 * abs(math.sin(9 * t)))),
"calm": make_sound(2.0, lambda t: 0.2 * (math.sin(2 * math.pi * 220 * t)
+ math.sin(2 * math.pi * 330 * t))),
"tense": make_sound(2.0, lambda t: 0.2 * (math.sin(2 * math.pi * 233 * t)
+ math.sin(2 * math.pi * 247 * t))),
}
# ---------- the mixer: buses, a channel pool, a crossfade ----------
class Mixer:
def __init__(self, audio_ok):
self.audio_ok = audio_ok
self.bus = {"master": 1.0, "sfx": 1.0, "ambient": 1.0, "music": 1.0}
self.pool = []
self.started = {} # channel -> time it started, for stealing
self.clock = 0.0
if audio_ok:
pygame.mixer.set_num_channels(NUM_CHANNELS)
pygame.mixer.set_reserved(RESERVED)
self.pool = [pygame.mixer.Channel(i) for i in range(RESERVED, NUM_CHANNELS)]
def gain(self, bus):
return self.bus[bus] * self.bus["master"]
def cycle(self, bus):
self.bus[bus] = {1.0: 0.5, 0.5: 0.0}.get(self.bus[bus], 1.0)
def free_channel(self, force):
"""Like find_channel(force), but only from our pool, never a reserved channel."""
for channel in self.pool:
if not channel.get_busy():
return channel
if force and self.pool:
return min(self.pool, key=lambda ch: self.started.get(ch, 0.0)) # oldest sound
return None
def play(self, sound, bus, left=1.0, right=1.0, force=False):
if not self.audio_ok:
return None
channel = self.free_channel(force)
if channel is None:
return None # every voice busy: skip this one
channel.play(sound) # play() resets the channel volume...
g = self.gain(bus)
channel.set_volume(left * g, right * g) # ...so set it afterwards
self.started[channel] = self.clock
return channel
def busy_voices(self):
return sum(1 for channel in self.pool if channel.get_busy())
class Crossfade:
def __init__(self, audio_ok, duration=2.0):
self.channels = [pygame.mixer.Channel(1), pygame.mixer.Channel(2)] if audio_ok else []
self.active = 0 # index of the channel fading in (or playing)
self.progress = 1.0 # 1.0 = no fade running
self.duration = duration
self.track = None
def start(self, name, sound):
if self.track is None: # first track: no fade needed
self.progress = 1.0
else:
self.active = 1 - self.active
self.progress = 0.0
self.track = name
if self.channels:
self.channels[self.active].play(sound, loops=-1)
def update(self, dt, music_gain):
if self.progress < 1.0:
self.progress = min(1.0, self.progress + dt / self.duration)
incoming, outgoing = crossfade_levels(self.progress)
if self.channels:
self.channels[self.active].set_volume(incoming * music_gain)
old = self.channels[1 - self.active]
if self.progress >= 1.0:
old.stop()
else:
old.set_volume(outgoing * music_gain)
def init_audio():
pygame.mixer.pre_init(SAMPLE_RATE, -16, 2, 512, allowedchanges=0)
pygame.init()
try:
pygame.mixer.init()
return True
except pygame.error:
print("No audio device: running silently.")
return False
def draw_meter(screen, font, label, x, y, left, right):
screen.blit(font.render(label, True, TEXT_COLOR), (x, y))
for i, value in enumerate((left, right)):
pygame.draw.rect(screen, (50, 56, 80), (x + 70, y + 2 + i * 12, 150, 9))
pygame.draw.rect(screen, (110, 220, 140), (x + 70, y + 2 + i * 12, 150 * value, 9))
def main():
audio_ok = init_audio()
screen = pygame.display.set_mode((WIDTH, HEIGHT))
pygame.display.set_caption("Spatial Sound Sandbox")
clock = pygame.time.Clock()
font = pygame.font.Font(None, 24)
sounds = make_sounds() if audio_ok else {}
mixer = Mixer(audio_ok)
music = Crossfade(audio_ok)
fire_pos = pygame.Vector2(560, 300)
bell_pos = pygame.Vector2(180, 160)
listener = pygame.Vector2(WIDTH / 2, HEIGHT / 2)
fire_channel = pygame.mixer.Channel(0) if audio_ok else None
if fire_channel is not None:
fire_channel.play(sounds["fire"], loops=-1)
music.start("calm", sounds.get("calm"))
held = set()
rings = crossfades = 0
running = True
while running:
dt = clock.tick(60) / 1000
mixer.clock += dt
for event in pygame.event.get():
if event.type == pygame.QUIT:
running = False
elif event.type == pygame.KEYDOWN:
held.add(event.key)
if event.key == pygame.K_SPACE:
rings += 1
left, right = spatialize(listener, bell_pos)
mixer.play(sounds.get("bell"), "sfx", left, right, force=True)
elif event.key == pygame.K_u:
mixer.play(sounds.get("click"), "sfx") # UI sounds are never panned
elif event.key == pygame.K_m:
crossfades += 1
name = "tense" if music.track == "calm" else "calm"
music.start(name, sounds.get(name))
elif event.key in (pygame.K_1, pygame.K_2, pygame.K_3, pygame.K_4):
mixer.cycle(("master", "sfx", "ambient", "music")[event.key - pygame.K_1])
elif event.type == pygame.KEYUP:
held.discard(event.key)
move = pygame.Vector2()
for key in held:
if key in MOVE_KEYS:
move += MOVE_KEYS[key]
if move.length_squared() > 0:
listener += move.normalize() * SPEED * dt
listener.x = max(0, min(WIDTH, listener.x))
listener.y = max(0, min(HEIGHT, listener.y))
# A looping positional source is re-spatialized every frame as the listener moves.
fire_l, fire_r = spatialize(listener, fire_pos)
g = mixer.gain("ambient")
if fire_channel is not None:
fire_channel.set_volume(fire_l * g, fire_r * g)
music.update(dt, mixer.gain("music"))
screen.fill((16, 20, 32))
for pos, color in ((fire_pos, (255, 140, 60)), (bell_pos, (250, 220, 90))):
pygame.draw.circle(screen, (45, 52, 78), pos, HEARING_RANGE, 1)
pygame.draw.circle(screen, color, pos, 14)
pygame.draw.circle(screen, (120, 190, 255), listener, 12)
bell_l, bell_r = spatialize(listener, bell_pos)
draw_meter(screen, font, "fire L/R", 12, HEIGHT - 70, fire_l, fire_r)
draw_meter(screen, font, "bell L/R", 12, HEIGHT - 40, bell_l, bell_r)
buses = " ".join(f"{name} {vol:.0%}" for name, vol in mixer.bus.items())
info = [buses,
f"music: {music.track} fade {music.progress:.0%} voices busy {mixer.busy_voices()}",
"arrows/WASD move SPACE bell U click M music 1-4 buses"]
for i, text in enumerate(info):
screen.blit(font.render(text, True, TEXT_COLOR), (12, 10 + i * 22))
pygame.display.flip()
pygame.quit()
print(f"Bell rung {rings} times. Crossfades: {crossfades}. Music now: {music.track}.")
if __name__ == "__main__":
main()
๐ Learning Journal
Take five minutes to write in your learning journal (a notebook or a plain text file works). Jot down:
- Key concepts you learned today
- Techniques that clicked (and the ones that haven't, yet)
- Questions or confusion to bring to the next session
- Ideas to try in your own game
- Progress and feelings: how did this lesson go for you?
โ๏ธ This lesson's prompts:
- Which buses would your capstone game need, and which sounds go on each? Is any sound hard to place?
- Explain in your own words why
abs(pan)breaks spatial audio. - Think of a game where sound told you where an enemy was before you saw it. How could you build that moment with what you learned today?
๐ Summary
You built a small but complete game audio system. Buses turned "make it quieter" into one multiplication per group, with spatial volume as one more factor rather than a replacement. A pool of unreserved channels gave sound effects room while the campfire and music kept their reserved channels. Panning from the signed horizontal offset with a constant-power law placed sounds in the world without a loudness dip in the middle, and set_source_location() showed the built-in alternative. Finally, a crossfade driven by dt blended two music tracks while the game kept running.
๐ Key Takeaways
- Final volume = source ร bus ร master (ร attenuation for world sounds); every stage multiplies, none overrides.
find_channel()can returnNone;find_channel(True)steals the longest-playing channel. Keep your own pool if you reserve channels.- Call
channel.play()first, thenset_volume(left, right): starting a sound resets the channel volume. - Pan keeps its sign; the constant-power law (
cos,sin) keeps loudness steady and never exceeds 1. - Crossfade on two channels with a timer advanced by
dt; never wait or sleep in the game loop.
๐ญ Looking Ahead
That completes the Juice & Audio module. Next you start the Genre Studio with Puzzle Games, where you build a match-3 game with cascading combos and a sliding-tile puzzle that is always solvable.
โ Common Questions
I can't hear any sound at all. What should I check?
First, whether init_audio() printed "No audio device". Some setups (remote desktops, some Linux subsystems on Windows, CI machines) have no audio output, and the program correctly runs silently. If audio is available, check that no bus is at 0% and that your system volume is up. The on-screen meters tell you whether the program thinks it is playing sound.
Why generate sounds in code instead of loading .wav files?
So the exercise needs no files and runs anywhere. In a real game you would load recorded sounds once at startup with pygame.mixer.Sound(path), exactly as in the Sound & Music lesson, and the mixer code stays the same.
Do I need 3D audio for a 2D game?
No. Left/right panning plus distance fading covers most 2D games. Top-down games sometimes also make sounds above or below the listener a little quieter, but true front/back placement needs surround speakers or special headphone processing that pygame doesn't do by itself.
Why is a centered sound quieter with the constant-power law?
Each speaker plays at about 0.71 instead of 1.0, but two speakers together deliver the same total power as one speaker at 1.0. If you want a centered positional sound to match a non-positional one exactly, you can scale the positional gains up slightly and clamp them at 1.0, but most games leave it as it is.
How many channels should I use?
Enough that important sounds rarely get cut off, but not so many that a big fight becomes a wall of noise. 16 to 32 is a common range for small 2D games. If the voices busy counter often hits the pool size, raise it, or use force=False for minor sounds so they are simply skipped.
๐ฏ Quick Quiz
Question 1: A sound's volume is 0.8, its SFX bus is 0.5 and the master is 0.5. What is its final volume?
Question 2: What does pygame.mixer.find_channel(True) do when every channel is busy?
Question 3: A panning function uses abs(dx) to compute the pan. What goes wrong?
Question 4: With the constant-power pan law, what are the left and right volumes at pan 0?
Question 5: Why does the crossfade advance progress a little every frame instead of calling pygame.time.wait()?
๐ Going Further
- Pitch-free variety: give the bell three slightly different generated tones and pick one at random each time, so repeated rings don't sound robotic.
- A better falloff: try
attenuation = (1 - d / range) ** 2and compare it with the straight line. Which feels more natural in your sandbox? - Built-in crossfade: rewrite
Crossfade.start()withplay(sound, loops=-1, fade_ms=2000)andfadeout(2000), then compare what happens when you change the music bus mid-fade. - Remember the settings: save the bus volumes with the JSON save code from the Saving & Loading lesson and load them at startup.
- Read the docs: pygame.mixer and Channel (look for
set_reservedandset_source_location). - Coming up in Game Dev III: Advanced: Adaptive Audio & DSP builds music that reacts to the game in layers, plus echo and filter effects.