Skip to main content

Lesson 10: Sound & Music

  • Module 5: Collisions & Sound
  • Lesson 10 of 14
  • ⏱️ About 1 h 30 min (instruction + lab)

Turn the sound off in your favorite game and notice how much flatter it feels. In this lesson you give your games a voice: snappy sound effects, looping music, and the volume and mute controls players expect.

🎯 Learning Objectives

By the end of this lesson, you will be able to:

  • Start pygame-ce's mixer safely, so a game still runs on a computer with no sound device.
  • Explain the difference between pygame.mixer.Sound (short effects, loaded into memory) and pygame.mixer.music (one streamed track).
  • Load sounds once and play them on events, with volumes between 0.0 and 1.0.
  • Predict what Sound.play() does when every mixer channel is busy, and change the number of channels.
  • Build a small SoundManager class with effects volume, music volume and a mute that restores the right volumes.

Project: a Sound Board that plays three effects, loops music, and has volume, pause and mute keys, plus an on-screen view of the busy channels.

In This Lesson

🔊 Effects and Music

Think of a movie theater. The soundtrack plays underneath the whole film and sets the mood. On top of it, short sounds (a door slam, footsteps, a laser) happen exactly when something happens on screen. Games split their audio the same way, and pygame-ce's pygame.mixer module gives you one tool for each:

  • Sound effects are short clips such as a jump, a coin or a hit. Each is a pygame.mixer.Sound, loaded completely into memory so it can start instantly, many times, even overlapping itself.
  • Music is one long track, played through pygame.mixer.music. It is streamed: read from the file bit by bit while it plays, instead of loaded all at once. Only one music track plays at a time.

The mixer blends every playing sound into the single stream your speakers play, each at its own volume.

Four sound sources on the left, each with a volume bar: Music (loops on its own channel) plus three short effects, Jump, Coin and Hit, grouped as effects that each take a free channel from a shared pool. Arrows lead from every source into a central Mixer box that combines them at their set volumes, and one arrow leaves for a speaker.
Music streams on its own path; each sound effect takes a free channel from a small pool while it plays. The mixer adds everything together at each source's volume.

💡 Why this matters

Sound is feedback. A coin "ding" confirms the pickup before your eyes find the score, and a thud tells you that you got hit even while you are looking somewhere else. Adding a sound to an action is often the cheapest way to make it feel better.

🧰 Make Some Test Sounds

You need a few sound files to practice with. You can download free ones later (see the FAQ), but this program makes four for you in a second, so every example in this lesson works right away. Save it as make_sounds.py in the folder where you keep this lesson's programs and run it once:

"""Make the test sounds for Intro Lesson 10 (Sound & Music).

Run it once:  python make_sounds.py   (python3 on macOS/Linux)
It writes four small WAV files into an assets folder next to this script:
jump.wav, coin.wav, hit.wav and music_loop.wav. No downloads needed, and
you are free to use them in anything you make.
"""
import math
import random
import wave
from array import array
from pathlib import Path

RATE = 22050                       # samples per second
ASSETS = Path(__file__).parent / "assets"


def save_wav(name, samples):
    """Save a list of floats between -1 and 1 as a 16-bit mono WAV file."""
    data = array("h", [int(max(-1.0, min(1.0, s)) * 32767) for s in samples])
    with wave.open(str(ASSETS / name), "wb") as wav:
        wav.setnchannels(1)        # mono
        wav.setsampwidth(2)        # 2 bytes = 16 bits per sample
        wav.setframerate(RATE)
        wav.writeframes(data.tobytes())
    print("wrote", ASSETS / name)


def tone(freq_start, freq_end, seconds, volume=0.5):
    """A square-ish beep that slides from freq_start to freq_end and fades out."""
    samples = []
    phase = 0.0
    count = int(RATE * seconds)
    for i in range(count):
        t = i / count
        freq = freq_start + (freq_end - freq_start) * t
        phase += freq / RATE
        wave_value = 1.0 if math.sin(2 * math.pi * phase) >= 0 else -1.0
        samples.append(wave_value * volume * (1 - t))
    return samples


def noise(seconds, volume=0.5):
    """A burst of static that fades out, like a hit or an explosion."""
    rng = random.Random(1)
    count = int(RATE * seconds)
    return [rng.uniform(-1, 1) * volume * (1 - i / count) ** 2 for i in range(count)]


def music_loop():
    """Four bars of a gentle arpeggio, 4 seconds long, that loops cleanly."""
    notes = [262, 330, 392, 523, 220, 262, 330, 440, 175, 220, 262, 349, 196, 247, 294, 392]
    samples = []
    note_len = int(RATE * 0.25)
    for freq in notes:
        for i in range(note_len):
            t = i / note_len
            envelope = min(1.0, t * 20) * (1 - t) * 0.3
            samples.append(math.sin(2 * math.pi * freq * i / RATE) * envelope)
    return samples


if __name__ == "__main__":
    ASSETS.mkdir(exist_ok=True)
    save_wav("jump.wav", tone(300, 900, 0.18, 0.35))
    save_wav("coin.wav", tone(988, 988, 0.08, 0.3) + tone(1319, 1319, 0.25, 0.3))
    save_wav("hit.wav", noise(0.3, 0.6))
    save_wav("music_loop.wav", music_loop())

You don't need to understand every line. It builds each sound as a long list of numbers between −1 and 1 (the shape of the sound wave, 22050 numbers per second), and the wave module from Python's standard library saves them as WAV files in an assets folder. Every program in this lesson loads its sounds from Path(__file__).parent / "assets", the folder next to the program, so it works no matter which folder you run it from.

FormatWhat it isA good fit for
WAV (.wav)Uncompressed: large files, nothing to decodeShort effects
OGG Vorbis (.ogg)Compressed: much smaller filesMusic and longer effects
MP3 (.mp3)Compressed: small filesMusic you already have as MP3
FLAC (.flac)Compressed without losing qualityHigh-quality music

We checked all four with pygame-ce 2.5.8 installed from pip: both Sound and music loaded WAV, OGG, MP3 and FLAC files. Older tutorials say Sound can't load MP3; that was true of older pygame versions. WAV for effects and OGG for music is still a safe, common choice.

🎚️ Start the Mixer Safely

pygame.init() starts the mixer for you. Two details are worth knowing.

Settings go in before pygame.init(). If you want to choose the mixer's settings, call pygame.mixer.pre_init(...) first. Once the mixer is running, calling pygame.mixer.init() with different settings does not change them.

pygame.mixer.pre_init(44100, -16, 2, 512)   # rate, 16-bit, stereo, buffer size
pygame.init()
  • 44100 samples per second, 16-bit signed samples (-16), 2 channels (stereo). On our test machine that is also what the mixer used without pre_init.
  • 512 is the buffer size: how many samples the mixer prepares at a time. The pygame-ce docs explain the trade-off: a smaller buffer means less delay between play() and hearing the sound, but can cause crackles or dropouts; a larger buffer plays smoothly but adds delay. If your sounds lag behind the action, lower it; if they crackle, raise it.

Some computers have no sound device. Classroom machines, servers and some virtual machines have none. Then pygame.init() quietly skips the mixer, and the first pygame.mixer.Sound(...) raises pygame.error. Ask for the mixer explicitly and catch the error, so your game still runs, just silently:

try:
    pygame.mixer.init()       # already running? Then this does nothing.
    sound_ok = True
except pygame.error as error:
    print("No sound device, running silently:", error)
    sound_ok = False

💥 Sound Effects: Load Once, Play Often

Here is a complete program: press Space and it plays jump.wav and flashes the window. It needs the assets folder from make_sounds.py next to it.

from pathlib import Path

import pygame

ASSETS = Path(__file__).parent / "assets"

pygame.mixer.pre_init(44100, -16, 2, 512)   # sound settings: must come BEFORE pygame.init()
pygame.init()
try:
    pygame.mixer.init()                     # already running? Then this does nothing.
    sound_ok = True
except pygame.error as error:
    print("No sound device, running silently:", error)
    sound_ok = False

screen = pygame.display.set_mode((480, 240))
pygame.display.set_caption("Press SPACE to jump")
clock = pygame.time.Clock()

jump_sound = None
if sound_ok:
    jump_sound = pygame.mixer.Sound(ASSETS / "jump.wav")   # load ONCE, before the loop
    jump_sound.set_volume(0.6)                             # 0.0 (silent) to 1.0 (full)

flash = 0.0                        # seconds left of the "jump!" flash
running = True
while running:
    dt = clock.tick(60) / 1000
    for event in pygame.event.get():
        if event.type == pygame.QUIT:
            running = False
        elif event.type == pygame.KEYDOWN and event.key == pygame.K_SPACE:
            if jump_sound is not None:
                jump_sound.play()          # plays on a free channel, then returns
            flash = 0.15

    flash = max(0.0, flash - dt)
    screen.fill((255, 220, 90) if flash > 0 else (25, 28, 40))
    pygame.display.flip()

pygame.quit()

The key line is where the Sound is created: before the loop. Creating a Sound reads the file from disk and decodes it, which is work you want to do once, at startup. After that, play() just starts the ready-made sound, so it can happen the instant the player presses a key. Creating the Sound inside the event handler would repeat all that work on every key press.

A few more things a Sound can do:

coin_sound = pygame.mixer.Sound(ASSETS / "coin.wav")
hum_sound = pygame.mixer.Sound(ASSETS / "music_loop.wav")   # any Sound can loop

coin_sound.set_volume(0.5)      # 0.0 = silent, 1.0 = full volume
coin_sound.play()               # play once
hum_sound.play(loops=-1)        # loop until stopped (loops=2 plays 3 times in total)
hum_sound.stop()                # stop it now, on every channel it is playing on
hum_sound.fadeout(500)          # fade out, then stop. pygame counts fades in MILLISECONDS

Note the units in the last line: in this course dt and timers are in seconds, but pygame's fade functions take milliseconds, so 500 is half a second. When your own code stores a fade time in seconds, convert it at the call: fadeout(int(fade_seconds * 1000)).

✅ Growth Mindset: Silence Is a Clue, Not a Dead End

Sound bugs are sneaky because there is nothing to see. When you hear nothing, don't start changing code at random. Check the chain one link at a time: did the mixer start (print(pygame.mixer.get_init()))? Did the file load (print the Sound)? Did play() return a channel or None? Is the volume above 0? Is your computer's own volume up? Each check is a small win, and you will not need all of them.

🎵 Music: One Streamed Track

Background music has its own module with no object to create: you load a file, then play it.

pygame.mixer.music.load(ASSETS / "music_loop.wav")
pygame.mixer.music.set_volume(0.4)      # quieter than the effects
pygame.mixer.music.play(-1)             # -1 = loop forever

pygame.mixer.music.pause()              # freeze it where it is...
pygame.mixer.music.unpause()            # ...and carry on from the same spot
pygame.mixer.music.fadeout(1000)        # fade out over one second (milliseconds again)
pygame.mixer.music.play(-1, fade_ms=2000)   # start again, fading in over two seconds
print(pygame.mixer.music.get_busy())    # True while music is playing

Loading a new file replaces the current track, because there is only one music stream. That is also why music never competes with your sound effects for channels: it has its own path through the mixer.

A common starting point is music noticeably quieter than the effects, so the effects stay easy to hear. Treat that as a first guess, not a rule, and adjust it by ear, ideally with a friend playing.

🎛️ Channels: How Many Sounds at Once?

Each playing sound effect needs a channel, and the mixer has a fixed number of them; our test machine started with 8 (pygame.mixer.get_num_channels() tells you). So what happens when all of them are busy and you play one more? Try it: press 10 hits at once in the demo below.

Click a button to play a sound

Eight hits play; the other two don't play at all. In pygame-ce, Sound.play() returns the Channel it used, or None when no channel is free. It does not cut off an older sound to make room. You can see it for yourself (this program needs the assets folder too):

from pathlib import Path

import pygame

ASSETS = Path(__file__).parent / "assets"

pygame.mixer.init()
pygame.mixer.set_num_channels(2)            # only two sounds at once, to see what happens
hit = pygame.mixer.Sound(ASSETS / "hit.wav")

first = hit.play()
second = hit.play()
third = hit.play()                          # both channels are still busy
print(first is not None, second is not None, third)     # True True None

channel = pygame.mixer.find_channel(True)   # True = take the one playing longest
channel.play(hit)                           # this sound WILL play
print("Busy channels:", sum(pygame.mixer.Channel(i).get_busy() for i in range(2)))
pygame.quit()

You have two tools when channels run out:

  • More channels: pygame.mixer.set_num_channels(16) at startup lets more effects overlap.
  • Make room for an important sound: pygame.mixer.find_channel(True) returns the channel that has been playing longest when all are busy, and channel.play(sound) starts your sound on it, cutting off whatever it was playing. Save this for sounds the player must hear, like "game over".

🗂️ A Simple SoundManager

Once a game has more than a couple of sounds, scattered Sound variables and volume numbers get messy. A small class keeps them together, and it is the natural place for "no sound device" checks and a mute switch:

class SoundManager:
    """Loads sound effects once and controls effects and music volume."""

    def __init__(self, folder, enabled):
        self.folder = folder
        self.enabled = enabled        # False when the computer has no sound device
        self.sounds = {}
        self.sfx_volume = 0.7
        self.music_volume = 0.4
        self.muted = False

    def load(self, name, filename):
        """Load one effect from the folder and remember it under name."""
        if self.enabled:
            sound = pygame.mixer.Sound(self.folder / filename)
            sound.set_volume(self.sfx_volume)
            self.sounds[name] = sound

    def play(self, name):
        """Play an effect. Returns its Channel, or None if nothing played."""
        if self.muted or name not in self.sounds:
            return None
        return self.sounds[name].play()

    def set_sfx_volume(self, volume):
        self.sfx_volume = max(0.0, min(1.0, volume))
        for sound in self.sounds.values():
            sound.set_volume(self.sfx_volume)

    def play_music(self, filename):
        """Stream a music file from disk and loop it forever."""
        if self.enabled:
            pygame.mixer.music.load(self.folder / filename)
            pygame.mixer.music.set_volume(0.0 if self.muted else self.music_volume)
            pygame.mixer.music.play(-1)

    def set_music_volume(self, volume):
        self.music_volume = max(0.0, min(1.0, volume))
        if self.enabled and not self.muted:
            pygame.mixer.music.set_volume(self.music_volume)

    def toggle_mute(self):
        """Mute or unmute everything. Unmuting restores the saved volumes."""
        self.muted = not self.muted
        if self.enabled:
            if self.muted:
                pygame.mixer.stop()                    # cut effects already playing
                pygame.mixer.music.set_volume(0.0)
            else:
                pygame.mixer.music.set_volume(self.music_volume)


# Using it:
sounds = SoundManager(ASSETS, sound_ok)
sounds.load("coin", "coin.wav")
sounds.play_music("music_loop.wav")
sounds.play("coin")          # in the game, when a coin is collected

Look at how mute works. It does not throw away the volumes: self.music_volume keeps the player's choice while the music is set to 0.0, and unmuting puts that saved value back. A mute that sets everything to 0.0 and later back to a hard-coded number forgets whatever the player picked, which is a real bug in plenty of games.

The manager also clamps every volume with max(0.0, min(1.0, volume)), the same clamp you used for colors in the Drawing Shapes & Surfaces lesson, so pressing "volume up" too many times can't break anything. And when enabled is False, every method quietly does nothing.

🏋️ Practice Exercise: Sound Board

Objective: build a Sound Board that plays three effects, loops music, and lets the player change the effects and music volumes, pause the music and mute everything.

Time: about 30 minutes. Starter files: sound_board_starter.py and make_sounds.py (your instructor has them). The starter already draws the volume bars, the status line and the channel boxes and reads every key, but its SoundManager methods are empty. Its numbered comments match the steps below.

  1. Run make_sounds.py once, then run the starter. The window works but is silent. (≈ 3 min)
  2. Write load() (comment 1) and play() (comment 2). Keys 1, 2 and 3 should now play jump, coin and hit. (≈ 7 min)
  3. Write play_music() (comment 3): the music should start looping. P already pauses and resumes it. (≈ 4 min)
  4. Write toggle_mute() (comment 4) so M silences everything and brings back the volumes you had. (≈ 5 min)
  5. Write both volume setters (comments 5a and 5b), with clamping. Use ↑/↓ and ←/→. (≈ 6 min)
  6. Make key 4 play the hit ten times in a row and count how many calls returned a channel (comment 6). Watch the channel boxes fill up. (≈ 5 min)

You are done when:

  • keys 1, 2 and 3 play three different effects, and the music loops underneath;
  • the volume bars never go below 0% or above 100%, and you can hear each change;
  • M mutes everything, and pressing it again brings back the exact volumes you had set;
  • key 4 reports Burst: 8 played, 2 got no channel when nothing else is playing;
  • on a computer where the mixer can't start, the program prints one message and keeps running silently instead of crashing.
💡 Hint

Every method starts the same way: if self.enabled: (or return early when not). For mute, store nothing new: self.music_volume already remembers the player's choice, so muting sets the mixer's music volume to 0.0 and unmuting sets it back to self.music_volume. For the burst, a list comprehension works well: [sounds.play("hit") for _ in range(10)], then count the results that are not None.

✅ Example Solution

If your instructor hands you the lab file, you will see a few extra lines marked lab runtime near the top and and frame_budget() in the loop. They let the instructor's checker run the program automatically for a fixed number of frames; when you run it yourself they do nothing. You never need to write them.

"""Sound Board: Intro Lesson 10 practice exercise (solution).

Keys:  1 jump   2 coin   3 hit   4 ten hits at once (watch the channels run out)
       M mute everything   P pause the music
       UP / DOWN effects volume   LEFT / RIGHT music volume
Run make_sounds.py once first so the assets folder has its WAV files.
Close the window to quit.
"""
from pathlib import Path

import pygame


ASSETS = Path(__file__).parent / "assets"
WIDTH, HEIGHT = 640, 400
BG_COLOR = (20, 22, 34)
TEXT_COLOR = (235, 235, 235)
DIM_COLOR = (150, 155, 170)
BAR_BACK = (60, 64, 80)
BAR_COLOR = (90, 200, 140)
BUSY_COLOR = (255, 190, 70)
HELP = [
    "1 jump   2 coin   3 hit   4 ten hits at once",
    "M mute   P pause music",
    "UP/DOWN effects volume   LEFT/RIGHT music volume",
]


class SoundManager:
    """Loads sound effects once and controls effects and music volume."""

    def __init__(self, folder, enabled):
        self.folder = folder
        self.enabled = enabled        # False when the computer has no sound device
        self.sounds = {}
        self.sfx_volume = 0.7
        self.music_volume = 0.4
        self.muted = False

    def load(self, name, filename):
        """Load one effect from the folder and remember it under name."""
        if self.enabled:
            sound = pygame.mixer.Sound(self.folder / filename)
            sound.set_volume(self.sfx_volume)
            self.sounds[name] = sound

    def play(self, name):
        """Play an effect. Returns its Channel, or None if nothing played."""
        if self.muted or name not in self.sounds:
            return None
        return self.sounds[name].play()

    def set_sfx_volume(self, volume):
        self.sfx_volume = max(0.0, min(1.0, volume))
        for sound in self.sounds.values():
            sound.set_volume(self.sfx_volume)

    def play_music(self, filename):
        """Stream a music file from disk and loop it forever."""
        if self.enabled:
            pygame.mixer.music.load(self.folder / filename)
            pygame.mixer.music.set_volume(0.0 if self.muted else self.music_volume)
            pygame.mixer.music.play(-1)

    def set_music_volume(self, volume):
        self.music_volume = max(0.0, min(1.0, volume))
        if self.enabled and not self.muted:
            pygame.mixer.music.set_volume(self.music_volume)

    def toggle_mute(self):
        """Mute or unmute everything. Unmuting restores the saved volumes."""
        self.muted = not self.muted
        if self.enabled:
            if self.muted:
                pygame.mixer.stop()                    # cut effects already playing
                pygame.mixer.music.set_volume(0.0)
            else:
                pygame.mixer.music.set_volume(self.music_volume)


def start_audio():
    """Start pygame and its mixer. Returns True if this computer can play sound."""
    pygame.mixer.pre_init(44100, -16, 2, 512)          # must come before pygame.init()
    pygame.init()
    try:
        pygame.mixer.init()                           # does nothing if already running
    except pygame.error as error:
        print("No sound device, running silently:", error)
        return False
    return True


def busy_channels():
    """How many mixer channels are playing something right now."""
    if pygame.mixer.get_init() is None:
        return 0
    return sum(pygame.mixer.Channel(i).get_busy() for i in range(pygame.mixer.get_num_channels()))


def draw_bar(screen, font, label, value, y):
    screen.blit(font.render(label, True, TEXT_COLOR), (40, y))
    pygame.draw.rect(screen, BAR_BACK, (260, y + 2, 300, 18))
    pygame.draw.rect(screen, BAR_COLOR, (260, y + 2, 300 * value, 18))


def main():
    audio_ok = start_audio()
    screen = pygame.display.set_mode((WIDTH, HEIGHT))
    pygame.display.set_caption("Sound Board")
    clock = pygame.time.Clock()
    font = pygame.font.Font(None, 28)
    help_lines = [font.render(line, True, DIM_COLOR) for line in HELP]   # render once

    sounds = SoundManager(ASSETS, audio_ok)
    sounds.load("jump", "jump.wav")
    sounds.load("coin", "coin.wav")
    sounds.load("hit", "hit.wav")
    sounds.play_music("music_loop.wav")
    music_paused = False
    last_message = "Press 1, 2, 3 or 4"
    effect_keys = {pygame.K_1: "jump", pygame.K_2: "coin", pygame.K_3: "hit"}

    running = True
    while running:
        clock.tick(60)

        for event in pygame.event.get():
            if event.type == pygame.QUIT:
                running = False
            elif event.type == pygame.KEYDOWN:
                if event.key in effect_keys:
                    name = effect_keys[event.key]
                    channel = sounds.play(name)
                    last_message = f"{name}: " + ("playing" if channel is not None else "no sound")
                elif event.key == pygame.K_4:
                    results = [sounds.play("hit") for _ in range(10)]
                    played = sum(1 for channel in results if channel is not None)
                    last_message = f"Burst: {played} played, {10 - played} got no channel"
                    print(last_message)
                elif event.key == pygame.K_m:
                    sounds.toggle_mute()
                elif event.key == pygame.K_p and audio_ok:
                    if music_paused:
                        pygame.mixer.music.unpause()
                    else:
                        pygame.mixer.music.pause()
                    music_paused = not music_paused
                elif event.key == pygame.K_UP:
                    sounds.set_sfx_volume(sounds.sfx_volume + 0.1)
                elif event.key == pygame.K_DOWN:
                    sounds.set_sfx_volume(sounds.sfx_volume - 0.1)
                elif event.key == pygame.K_RIGHT:
                    sounds.set_music_volume(sounds.music_volume + 0.1)
                elif event.key == pygame.K_LEFT:
                    sounds.set_music_volume(sounds.music_volume - 0.1)

        screen.fill(BG_COLOR)
        draw_bar(screen, font, f"Effects volume {sounds.sfx_volume:.0%}", sounds.sfx_volume, 40)
        draw_bar(screen, font, f"Music volume {sounds.music_volume:.0%}", sounds.music_volume, 80)
        status = "MUTED" if sounds.muted else ("music paused" if music_paused else "music playing")
        if not audio_ok:
            status = "no sound device"
        screen.blit(font.render(f"Status: {status}", True, TEXT_COLOR), (40, 130))
        busy = busy_channels()
        total = pygame.mixer.get_num_channels() if audio_ok else 0
        screen.blit(font.render(f"Channels busy: {busy}/{total}", True, TEXT_COLOR), (40, 170))
        for i in range(total):
            color = BUSY_COLOR if pygame.mixer.Channel(i).get_busy() else BAR_BACK
            pygame.draw.rect(screen, color, (40 + i * 34, 205, 28, 28))
        screen.blit(font.render(last_message, True, TEXT_COLOR), (40, 255))
        for i, line in enumerate(help_lines):
            screen.blit(line, (40, 300 + i * 28))
        pygame.display.flip()

    pygame.quit()
    print(f"Effects {sounds.sfx_volume:.0%}, music {sounds.music_volume:.0%}, muted {sounds.muted}")


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:

  1. Play a game you like for two minutes with the sound on, then two with it off. Which actions felt weaker without sound? Which sound would you add to Coin Dash first?
  2. Explain to a friend why a Sound is created before the loop but played inside it.
  3. What surprised you about channels running out? Where in a game could that actually happen?

📝 Summary

You split game audio into short effects and streamed music, started the mixer so a game without a sound device still runs, and made your own test sounds. You loaded each Sound once and played it on events, looped and paused music with pygame.mixer.music, and discovered that when every channel is busy, Sound.play() plays nothing and returns None. Finally you wrapped it all in a SoundManager whose mute remembers the player's volumes.

🎓 Key Takeaways

  • pygame.mixer.Sound is for short effects, loaded into memory; pygame.mixer.music streams one long track at a time.
  • Choose mixer settings with pre_init() before pygame.init(); a smaller buffer means less delay but a higher risk of crackles.
  • Wrap pygame.mixer.init() in try/except pygame.error so your game runs on computers without sound.
  • Create every Sound once, before the loop, and call play() when things happen.
  • When all channels are busy, Sound.play() returns None; use set_num_channels() or find_channel(True) when that matters.
  • Volumes run from 0.0 to 1.0: clamp them, and let mute restore the saved values instead of hard-coded ones.

🔭 Looking Ahead

Your games now move, collide and make noise, but your sprites are still frozen pictures. In the next lesson, Sprite Animation, you flip through frames from a sprite sheet with dt-based timing, so characters walk, idle and swing a sword.

❓ Common Questions

Where can I get free sounds for my games?

kenney.nl offers free game sound packs under the CC0 license, which lets you use them in anything without asking. Freesound.org has a huge library, but each sound has its own license, so read it before you use one. Browser tools such as jsfxr generate retro effects like the ones make_sounds.py makes. Keep a note of where every file came from.

I get pygame.error: mixer not initialized. What does that mean?

The mixer never started, usually because the computer has no working sound device (common in virtual machines and on some school computers). Use the try/except start-up from this lesson and skip audio when it fails. If the computer does have sound, check that your system volume is up and that another program is not holding the audio device.

I get FileNotFoundError when loading a sound.

The path is relative to the folder you ran Python from, not the folder your program is in. Build paths from the program's own folder, Path(__file__).parent / "assets" / "jump.wav", as every example here does, and run make_sounds.py first.

Can two different songs play at the same time?

Not through pygame.mixer.music, which plays one track at a time. You can load a second song as a Sound and play it on a channel, but it will be loaded completely into memory, so keep that for short loops such as ambient wind or rain.

Why does my sound play late, a moment after the key press?

Usually the buffer is large. Set a smaller one with pygame.mixer.pre_init(44100, -16, 2, 512) before pygame.init(). Also check that you are not creating the Sound inside the event handler, and that the file itself has no silence at the start.

Should every action have a sound?

Every important action benefits from one. Too many overlapping sounds turn into noise, so listen to your game as a player would and remove any sound you stop noticing. Varying a repeated sound a little (two or three versions of a footstep) can keep it from getting tiring.

🎯 Quick Quiz

Question 1: What is the best way to play a three-minute background track that loops forever?

Question 2: Why create each pygame.mixer.Sound before the game loop instead of in the event handler?

Question 3: All 8 mixer channels are busy and your code calls hit_sound.play(). What happens?

Question 4: Players say your sounds start a moment after the action. Which mixer change is most likely to help?

Question 5: The player sets the music to 30%, mutes, then unmutes. What should unmuting do?

🌟 Going Further

  • Add sound to Coin Dash: copy the SoundManager into your Coin Dash game and play "coin" when a coin is collected and "hit" when a drone bumps you. Play a sound only when something changes, not on every frame of an overlap.
  • Footsteps: while the player moves, play a short sound every 0.3 seconds using a timer that counts down with dt.
  • Your own sounds: change the frequencies and lengths in make_sounds.py, or record yourself saying "boing" and save it as WAV or OGG.
  • Read the docs: skim the pygame-ce pages for pygame.mixer and pygame.mixer.music. Find music.queue() and Channel.set_volume().
  • Coming up in Game Dev II: Intermediate: Audio Mixing & Spatial Sound covers sounds that get quieter with distance, stereo panning and smooth crossfades between music tracks.