ajhahn.de
← the-way-out
Python 161 lines
"""Sound effects + background music.

Clone-safe and entirely optional, the same way the tileset is: the
mixer is initialised **lazily** on the first ``play``/``play_music``,
every lookup degrades to a silent no-op when there is no audio device,
no ``assets/audio`` tree or just a single missing file (mirrors the
``tileset.tile`` try/except → None pattern), and importing this module
touches nothing — so the headless smoke tests keep working.

Asset layout — drop files in, no code change needed:

    assets/audio/sfx/<name>.wav      # or .ogg
    assets/audio/music/<name>.ogg    # or .wav / .mp3

``play("shoot")`` looks up ``assets/audio/sfx/shoot.wav``; a level's
manifest ``"music"`` value is just such a ``<name>`` under music/.
Symmetry with ``tileset``: callers pass *names*, not paths, so the
audio asset convention lives only here. The names the game already
triggers are listed in ``assets/levels/LEGEND.md``.
"""

import os

import pygame

# Flipped by the Settings menu (persisted via save.py). Read live by
# every play call, so toggling it silences playback immediately.
enabled = True

# Music level (0.0..1.0). Independent of ``enabled``: muting flips
# enabled, the volume slider is the bed level when sound is on.
# Reapplied to ``pygame.mixer.music`` on every track change so a switch
# never resets it back to 1.0.
music_volume = 1.0

_SFX_DIR = os.path.join("assets", "audio", "sfx")
_MUSIC_DIR = os.path.join("assets", "audio", "music")
_SFX_EXTS = (".wav", ".ogg")
_MUSIC_EXTS = (".ogg", ".wav", ".mp3")

# One knob for every music transition (track switch, stop, mute). ~700
# ms reads as musical without dragging. ``pygame.mixer.music`` is a
# single stream, so there is no true crossfade — we fade the old track
# out and fade the new one in (the simple, fine-here option from the
# Style.md notes). Reused by stop_music so muting fades too, not clicks.
MUSIC_FADE_MS = 700

_mixer_ok = None          # None = not tried yet, then True / False
_sounds = {}              # name -> Sound | None  (None = file absent)
_current_music = None     # name of the looping track, or None


def _ensure_mixer():
    """Init the mixer once. Stays False forever if there is no audio
    device, which turns everything here into a no-op."""
    global _mixer_ok
    if _mixer_ok is None:
        try:
            pygame.mixer.init()
            _mixer_ok = True
        except pygame.error:
            _mixer_ok = False
    return _mixer_ok


def _find(directory, name, exts):
    for ext in exts:
        path = os.path.join(directory, name + ext)
        if os.path.isfile(path):
            return path
    return None


def play(name):
    """Fire-and-forget one-shot SFX. Silent when disabled, when there
    is no device, or when ``assets/audio/sfx/<name>.*`` is missing."""
    if not enabled or not _ensure_mixer():
        return
    if name not in _sounds:
        path = _find(_SFX_DIR, name, _SFX_EXTS)
        try:
            _sounds[name] = pygame.mixer.Sound(path) if path else None
        except pygame.error:
            _sounds[name] = None
    snd = _sounds[name]
    if snd is not None:
        snd.play()


def play_music(name):
    """Loop ``assets/audio/music/<name>.*`` as background music.

    ``name is None`` (the level declares no track) stops the music.
    Re-requesting the track that is already playing is a no-op, so a
    level reload / retry doesn't restart the loop."""
    global _current_music
    if name is None:
        stop_music()
        return
    if not enabled or not _ensure_mixer():
        return
    if name == _current_music:
        return
    path = _find(_MUSIC_DIR, name, _MUSIC_EXTS)
    if path is None:
        stop_music()
        # Remember the request even though the file is absent: main.py
        # calls play_music every frame, and without this _current_music
        # stays None so the unchanged-name guard above never engages —
        # a missing track would re-stat the FS and re-fade every frame.
        _current_music = name
        return
    try:
        # Fade the outgoing track, then bring the new one up. Single
        # stream → not a true crossfade; the incoming fade_ms is what
        # kills the hard cut on a start↔menu↔game switch.
        if pygame.mixer.music.get_busy():
            pygame.mixer.music.fadeout(MUSIC_FADE_MS)
        pygame.mixer.music.load(path)
        pygame.mixer.music.set_volume(music_volume)
        pygame.mixer.music.play(-1, fade_ms=MUSIC_FADE_MS)
        _current_music = name
    except pygame.error:
        _current_music = None


def stop_music():
    global _current_music
    _current_music = None
    # Only touch the mixer if it actually came up — never spin it up
    # just to stop silence (keeps cold paths side-effect-free).
    if _mixer_ok:
        try:
            # Fade rather than stop() so a mute / leaving a level goes
            # quiet smoothly instead of clicking.
            pygame.mixer.music.fadeout(MUSIC_FADE_MS)
        except pygame.error:
            pass


def set_enabled(flag):
    """Settings hook. Muting also kills the music so the world goes
    quiet at once; SFX are short, they just stop being requested."""
    global enabled
    enabled = bool(flag)
    if not enabled:
        stop_music()


def set_music_volume(level):
    """Settings hook for the bed volume (0.0..1.0). Applied live so a
    slider tweak audibly changes the current track without waiting for
    the next track switch."""
    global music_volume
    music_volume = max(0.0, min(1.0, float(level)))
    if _mixer_ok:
        try:
            pygame.mixer.music.set_volume(music_volume)
        except pygame.error:
            pass