Files
Jander_Semester2/Semesterprojekt/docs/superpowers/plans/2026-06-01-music.md
Jean-Luc Makiola 36f60863ad docs: implementation plan for GUI background music
7 TDD tasks: MusicLevel, MusicController+MusicBackend, Room music field,
GameIO seams + loop/app wiring, Settings music level + SettingsMenu->GameIO
refactor, OggMusicBackend + deps + SwingIO wiring + docs, backlog note.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-01 22:46:15 +02:00

31 KiB

GUI Background Music Implementation Plan

For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (- [ ]) syntax for tracking.

Goal: Add per-room background music in the GUI — OGG tracks streamed from an external music/ folder, fading on room change, with an Off/Low/Med/High Settings control. Console stays silent.

Architecture: A testable MusicController holds the track/level decision logic (idempotent same-track, null→silence, OFF disables) and delegates raw operations to a MusicBackend. OggMusicBackend (the only audio/thread code, manually verified) decodes OGG via vorbisspi/jorbis and streams PCM through a SourceDataLine with gain fades. GameIO.setMusic/setMusicLevel are console no-ops; SwingIO owns the controller. Game.publishHud pushes the current room's track each turn; Settings carries a persisted MusicLevel.

Tech Stack: Java 25, Maven, vorbisspi+jorbis (OGG), Jackson (YAML), JUnit 5 + AssertJ, Lombok.


File Structure

Create: io/MusicLevel.java, io/MusicBackend.java, io/MusicController.java, io/OggMusicBackend.java; tests MusicLevelTest, MusicControllerTest; docs/music.md. Modify: io/GameIO.java (seams), io/SwingIO.java (wire controller), io/ConsoleIO.java (none — inherits no-op), game/Game.java (publishHud), App.java (setMusic(null) + settings apply), model/Room.java + loader/dto/RoomDto.java + loader/RoomFactory.java (music field), save/Settings.java + save/SettingsStore-tests + menu/SettingsMenu.java + test/.../menu/MenuTest.java, src/main/resources/world/rooms.yaml, pom.xml, .gitignore, docs/enhancement-ideas.md.


Task 1: MusicLevel enum

Files: Create src/main/java/thb/jeanluc/adventure/io/MusicLevel.java; Test src/test/java/thb/jeanluc/adventure/io/MusicLevelTest.java

  • Step 1: Write the failing test

src/test/java/thb/jeanluc/adventure/io/MusicLevelTest.java:

package thb.jeanluc.adventure.io;

import org.junit.jupiter.api.Test;

import static org.assertj.core.api.Assertions.assertThat;

class MusicLevelTest {

    @Test
    void gainIncreasesWithLevel() {
        assertThat(MusicLevel.LOW.gainDb()).isLessThan(MusicLevel.MEDIUM.gainDb());
        assertThat(MusicLevel.MEDIUM.gainDb()).isLessThan(MusicLevel.HIGH.gainDb());
    }

    @Test
    void isOnOnlyWhenNotOff() {
        assertThat(MusicLevel.OFF.isOn()).isFalse();
        assertThat(MusicLevel.LOW.isOn()).isTrue();
        assertThat(MusicLevel.HIGH.isOn()).isTrue();
    }

    @Test
    void nextCyclesThroughAllAndWraps() {
        assertThat(MusicLevel.OFF.next()).isEqualTo(MusicLevel.LOW);
        assertThat(MusicLevel.LOW.next()).isEqualTo(MusicLevel.MEDIUM);
        assertThat(MusicLevel.MEDIUM.next()).isEqualTo(MusicLevel.HIGH);
        assertThat(MusicLevel.HIGH.next()).isEqualTo(MusicLevel.OFF);
    }
}
  • Step 2: Run — expect FAIL (mvn -q test -Dtest=MusicLevelTest → MusicLevel missing).

  • Step 3: Create MusicLevel

src/main/java/thb/jeanluc/adventure/io/MusicLevel.java:

package thb.jeanluc.adventure.io;

/** Background-music volume level, cycled in the Settings menu. */
public enum MusicLevel {
    OFF, LOW, MEDIUM, HIGH;

    /** Target MASTER_GAIN in decibels. Irrelevant for {@link #OFF} (playback disabled). */
    public float gainDb() {
        return switch (this) {
            case OFF -> Float.NEGATIVE_INFINITY;
            case LOW -> -20f;
            case MEDIUM -> -10f;
            case HIGH -> 0f;
        };
    }

    /** @return true when music should play. */
    public boolean isOn() {
        return this != OFF;
    }

    /** Next level in the cycle (wraps HIGH → OFF). */
    public MusicLevel next() {
        return values()[(ordinal() + 1) % values().length];
    }
}
  • Step 4: Run — PASS (mvn -q test -Dtest=MusicLevelTest).
  • Step 5: Commitgit add the two files; git commit -m "feat(io): MusicLevel enum (Off/Low/Med/High)".

Task 2: MusicBackend + MusicController

Files: Create io/MusicBackend.java, io/MusicController.java; Test io/MusicControllerTest.java

  • Step 1: Write the failing test

src/test/java/thb/jeanluc/adventure/io/MusicControllerTest.java:

package thb.jeanluc.adventure.io;

import org.junit.jupiter.api.Test;

import java.util.ArrayList;
import java.util.List;

import static org.assertj.core.api.Assertions.assertThat;

class MusicControllerTest {

    /** Records backend calls for assertions. */
    private static final class FakeBackend implements MusicBackend {
        final List<String> calls = new ArrayList<>();
        public void fadeTo(String track, float gainDb) { calls.add("fadeTo:" + track); }
        public void fadeOut() { calls.add("fadeOut"); }
        public void setGainDb(float gainDb) { calls.add("setGain:" + gainDb); }
        public void shutdown() { calls.add("shutdown"); }
    }

    @Test
    void firstRoomWithTrackPlaysAtDefaultLevel() {
        FakeBackend b = new FakeBackend();
        new MusicController(b).room("a");
        assertThat(b.calls).containsExactly("fadeTo:a");
    }

    @Test
    void sameTrackDoesNotRestart() {
        FakeBackend b = new FakeBackend();
        MusicController c = new MusicController(b);
        c.room("a");
        b.calls.clear();
        c.room("a");
        assertThat(b.calls).isEmpty();
    }

    @Test
    void differentTrackSwitches() {
        FakeBackend b = new FakeBackend();
        MusicController c = new MusicController(b);
        c.room("a");
        c.room("b");
        assertThat(b.calls).containsExactly("fadeTo:a", "fadeTo:b");
    }

    @Test
    void nullOrBlankRoomFadesToSilence() {
        FakeBackend b = new FakeBackend();
        MusicController c = new MusicController(b);
        c.room("a");
        c.room(null);
        c.room("  ");
        assertThat(b.calls).containsExactly("fadeTo:a", "fadeOut");
    }

    @Test
    void offStopsAndIgnoresRooms() {
        FakeBackend b = new FakeBackend();
        MusicController c = new MusicController(b);
        c.room("a");
        c.level(MusicLevel.OFF);
        b.calls.clear();
        c.room("b");
        assertThat(b.calls).isEmpty();
    }

    @Test
    void levelChangeSetsGain() {
        FakeBackend b = new FakeBackend();
        MusicController c = new MusicController(b); // default MEDIUM
        c.level(MusicLevel.HIGH);
        assertThat(b.calls).containsExactly("setGain:" + MusicLevel.HIGH.gainDb());
    }

    @Test
    void turningOffThenOnReplaysOnNextRoom() {
        FakeBackend b = new FakeBackend();
        MusicController c = new MusicController(b);
        c.room("a");
        c.level(MusicLevel.OFF);   // fadeOut
        c.level(MusicLevel.MEDIUM); // setGain
        b.calls.clear();
        c.room("a");               // current was reset → plays again
        assertThat(b.calls).containsExactly("fadeTo:a");
    }
}
  • Step 2: Run — expect FAIL.

  • Step 3: Create MusicBackend

src/main/java/thb/jeanluc/adventure/io/MusicBackend.java:

package thb.jeanluc.adventure.io;

/** Raw audio operations — the only part that touches sound hardware. */
public interface MusicBackend {

    /** Fade the current track out, then start {@code track} (looping) and fade in to {@code gainDb}. */
    void fadeTo(String track, float gainDb);

    /** Fade the current track out and stop. */
    void fadeOut();

    /** Set the playback volume immediately. */
    void setGainDb(float gainDb);

    /** Stop playback and release resources. */
    void shutdown();
}
  • Step 4: Create MusicController

src/main/java/thb/jeanluc/adventure/io/MusicController.java:

package thb.jeanluc.adventure.io;

import java.util.Objects;

/**
 * Frontend-agnostic music decision logic. Idempotent per track (never restarts
 * the same track), fades to silence on a null/blank track, and honours the
 * {@link MusicLevel} (OFF disables playback). Delegates raw operations to a
 * {@link MusicBackend}.
 */
public final class MusicController {

    private final MusicBackend backend;
    private MusicLevel level = MusicLevel.MEDIUM;
    private String current; // currently selected track, or null = silence

    public MusicController(MusicBackend backend) {
        this.backend = backend;
    }

    /** Called per turn with the current room's music field (may be null/blank). */
    public void room(String track) {
        if (!level.isOn()) {
            return;
        }
        String next = (track == null || track.isBlank()) ? null : track;
        if (Objects.equals(next, current)) {
            return;
        }
        current = next;
        if (next == null) {
            backend.fadeOut();
        } else {
            backend.fadeTo(next, level.gainDb());
        }
    }

    /** Applies a new music level (volume / off). */
    public void level(MusicLevel newLevel) {
        this.level = newLevel;
        if (!newLevel.isOn()) {
            backend.fadeOut();
            current = null; // turning back on replays on the next room() call
        } else {
            backend.setGainDb(newLevel.gainDb());
        }
    }

    public void shutdown() {
        backend.shutdown();
    }
}
  • Step 5: Run — PASS (mvn -q test -Dtest=MusicControllerTest, 7 tests).
  • Step 6: Commitgit commit -m "feat(io): MusicController + MusicBackend (testable music logic)".

Task 3: Room.music field + loader

Files: Modify model/Room.java, loader/dto/RoomDto.java, loader/RoomFactory.java, src/main/resources/world/rooms.yaml; Test loader/RoomMusicTest.java

  • Step 1: Write the failing test

src/test/java/thb/jeanluc/adventure/loader/RoomMusicTest.java:

package thb.jeanluc.adventure.loader;

import org.junit.jupiter.api.Test;
import thb.jeanluc.adventure.loader.dto.RoomDto;
import thb.jeanluc.adventure.model.Room;

import java.util.List;
import java.util.Map;

import static org.assertj.core.api.Assertions.assertThat;

class RoomMusicTest {

    @Test
    void musicFieldIsCarriedOntoRoom() {
        RoomDto dto = new RoomDto("r", "Room", "d",
                Map.of(), List.of(), List.of(), null, null, null, "theme.ogg");
        Room room = RoomFactory.shellFromDto(dto);
        assertThat(room.getMusic()).isEqualTo("theme.ogg");
    }

    @Test
    void absentMusicIsNull() {
        RoomDto dto = new RoomDto("r", "Room", "d", Map.of(), List.of(), List.of());
        Room room = RoomFactory.shellFromDto(dto);
        assertThat(room.getMusic()).isNull();
    }
}
  • Step 2: Run — expect FAIL (no getMusic, RoomDto has no music arg).

  • Step 3: Add music to Room

In src/main/java/thb/jeanluc/adventure/model/Room.java, after the dark field (which uses @Setter), add:

    /** Optional GUI background-music track filename for this room; null = none. */
    @Setter
    private String music;

(@Getter on the class provides getMusic(); @Setter provides setMusic(String), mirroring dark.)

  • Step 4: Add music to RoomDto

In src/main/java/thb/jeanluc/adventure/loader/dto/RoomDto.java, add String music as the final record component and update the backward-compatible constructor:

public record RoomDto(
        String id,
        String name,
        String description,
        Map<String, String> exits,
        List<String> items,
        List<String> npcs,
        List<ExitLockDto> exitLocks,
        List<DescriptionStateDto> descriptionStates,
        Boolean dark,
        String music
) {
    /** Backward-compatible constructor without the optional state fields. */
    public RoomDto(String id, String name, String description,
                   Map<String, String> exits, List<String> items, List<String> npcs) {
        this(id, name, description, exits, items, npcs, null, null, null, null);
    }
}
  • Step 5: Carry it in RoomFactory

In src/main/java/thb/jeanluc/adventure/loader/RoomFactory.java, in shellFromDto, after room.setDark(...):

        room.setMusic(dto.music());
  • Step 6: Add demo music: fields

In src/main/resources/world/rooms.yaml, add a music: line to two rooms (files supplied externally; absent → silent). For the kitchen room add music: manor-theme.ogg and for the dungeon room add music: dungeon-drone.ogg (place the line among the room's other top-level keys, e.g. after its description).

  • Step 7: Run — PASS (mvn -q test -Dtest=RoomMusicTest), then mvn -q clean test (full suite green; WorldLoaderTest uses its own fixture base, unaffected by the production rooms.yaml music: additions).

  • Step 8: Commitgit commit -m "feat(loader): per-room music field + demo rooms.yaml entries".


Task 4: GameIO music seams + game-loop / app wiring

Files: Modify io/GameIO.java, game/Game.java, App.java; Test io/MusicSeamDefaultTest.java

  • Step 1: Write the failing test

src/test/java/thb/jeanluc/adventure/io/MusicSeamDefaultTest.java:

package thb.jeanluc.adventure.io;

import org.junit.jupiter.api.Test;

import static org.assertj.core.api.Assertions.assertThat;

class MusicSeamDefaultTest {

    @Test
    void defaultMusicSeamsAreSilentNoOps() {
        TestIO io = new TestIO();
        io.setMusic("anything.ogg");
        io.setMusic(null);
        io.setMusicLevel(MusicLevel.HIGH);
        assertThat(io.outputs()).isEmpty();
    }
}
  • Step 2: Run — expect FAIL (no setMusic/setMusicLevel).

  • Step 3: Add the default seams to GameIO

In src/main/java/thb/jeanluc/adventure/io/GameIO.java, add (MusicLevel is in the same package — no import needed):

    /** Per-turn current-room music track; null/blank = silence. Console no-op; GUI plays. */
    default void setMusic(String track) {
        // no audio in text mode
    }

    /** Applies the music volume/level. Console no-op; GUI sets it. */
    default void setMusicLevel(MusicLevel level) {
        // no audio in text mode
    }
  • Step 4: Push the current track each turn in Game

In src/main/java/thb/jeanluc/adventure/game/Game.java, at the END of publishHud() (after setQuests(...)):

        ctx.getIo().setMusic(ctx.getPlayer().getCurrentRoom().getMusic());
  • Step 5: Silence music on return to menu in App

In src/main/java/thb/jeanluc/adventure/App.java, in play(...), immediately after game.run();:

        io.setMusic(null); // fade music out when returning to the menu
  • Step 6: Run — PASS (mvn -q test -Dtest=MusicSeamDefaultTest), then mvn -q clean test (full suite green; console/loop tests unaffected — seams are no-ops).

  • Step 7: Commitgit commit -m "feat(io): GameIO music seams; push room track per turn, silence at menu".


Task 5: Settings MusicLevel + SettingsMenu refactor to GameIO

Files: Modify save/Settings.java, menu/SettingsMenu.java, App.java, test/.../menu/MenuTest.java, test/.../save/SettingsStoreTest.java

  • Step 1: Write the failing tests

Add to src/test/java/thb/jeanluc/adventure/save/SettingsStoreTest.java (keep existing tests; add imports thb.jeanluc.adventure.io.MusicLevel):

    @Test
    void musicLevelRoundTrips(@TempDir Path dir) {
        SettingsStore store = new SettingsStore(dir.resolve("settings.json"));
        store.save(new Settings(ConsoleIO.ColorMode.AUTO, ConsoleIO.GlyphMode.UNICODE, MusicLevel.HIGH));
        assertThat(store.load().musicLevel()).isEqualTo(MusicLevel.HIGH);
    }

    @Test
    void oldSettingsWithoutMusicDefaultToMedium(@TempDir Path dir) throws Exception {
        Path file = dir.resolve("settings.json");
        Files.writeString(file, "{\"colorMode\":\"ON\",\"glyphMode\":\"ASCII\"}");
        assertThat(new SettingsStore(file).load().musicLevel()).isEqualTo(MusicLevel.MEDIUM);
    }

(Add import java.nio.file.Files; / Path / @TempDir if not already present — the existing tests already use @TempDir.)

Update the music-toggle expectation in src/test/java/thb/jeanluc/adventure/menu/MenuTest.java — the existing settingsMenuTogglesGlyphAndPersists test calls SettingsMenu.show(io, Settings.defaults(), store, new ConsoleIO()). Change it to the new signature and account for the new "Music" row (options become Colour=1, Glyphs=2, Music=3, Back=4):

    @Test
    void settingsMenuTogglesGlyphAndPersists(@TempDir Path dir) {
        SettingsStore store = new SettingsStore(dir.resolve("settings.json"));
        TestIO io = new TestIO().enqueue("2").enqueue("4"); // toggle Glyphs, then Back
        Settings result = SettingsMenu.show(io, Settings.defaults(), store);
        assertThat(result.glyphMode()).isEqualTo(ConsoleIO.GlyphMode.ASCII);
        assertThat(store.load().glyphMode()).isEqualTo(ConsoleIO.GlyphMode.ASCII);
    }

Add a music-cycle test:

    @Test
    void settingsMenuCyclesMusicLevelAndPersists(@TempDir Path dir) {
        SettingsStore store = new SettingsStore(dir.resolve("settings.json"));
        TestIO io = new TestIO().enqueue("3").enqueue("4"); // cycle Music once, then Back
        Settings result = SettingsMenu.show(io, Settings.defaults(), store);
        assertThat(result.musicLevel()).isEqualTo(MusicLevel.MEDIUM.next()); // HIGH
        assertThat(store.load().musicLevel()).isEqualTo(MusicLevel.MEDIUM.next());
    }

(Add import thb.jeanluc.adventure.io.MusicLevel; to MenuTest.)

  • Step 2: Run — expect FAIL (Settings has no musicLevel; show signature differs).

  • Step 3: Extend Settings

Replace src/main/java/thb/jeanluc/adventure/save/Settings.java:

package thb.jeanluc.adventure.save;

import thb.jeanluc.adventure.io.ConsoleIO;
import thb.jeanluc.adventure.io.MusicLevel;

/** Persisted user preferences (colour + glyph fidelity + music level). */
public record Settings(ConsoleIO.ColorMode colorMode, ConsoleIO.GlyphMode glyphMode, MusicLevel musicLevel) {

    public Settings {
        if (musicLevel == null) {
            musicLevel = MusicLevel.MEDIUM; // backward-compat: old saves lack this field
        }
    }

    /** Backward-compatible constructor for callers that don't set music. */
    public Settings(ConsoleIO.ColorMode colorMode, ConsoleIO.GlyphMode glyphMode) {
        this(colorMode, glyphMode, MusicLevel.MEDIUM);
    }

    public static Settings defaults() {
        return new Settings(ConsoleIO.ColorMode.AUTO, ConsoleIO.GlyphMode.UNICODE, MusicLevel.MEDIUM);
    }
}
  • Step 4: Refactor SettingsMenu to GameIO + add the Music row

Replace src/main/java/thb/jeanluc/adventure/menu/SettingsMenu.java:

package thb.jeanluc.adventure.menu;

import thb.jeanluc.adventure.io.ConsoleIO;
import thb.jeanluc.adventure.io.GameIO;
import thb.jeanluc.adventure.io.MusicLevel;
import thb.jeanluc.adventure.save.Settings;
import thb.jeanluc.adventure.save.SettingsStore;

import java.util.List;

/** Settings screen: cycle colour, glyph, and music level; persists + applies. */
public final class SettingsMenu {

    private SettingsMenu() {
    }

    /** Loops the settings screen until "Back", persisting + applying each change. */
    public static Settings show(GameIO io, Settings current, SettingsStore store) {
        Settings s = current;
        while (true) {
            int i = io.choose("SETTINGS", List.of(
                    "Colour: " + s.colorMode(),
                    "Glyphs: " + s.glyphMode(),
                    "Music: " + s.musicLevel(),
                    "Back"));
            if (i == 0) {
                s = new Settings(nextColor(s.colorMode()), s.glyphMode(), s.musicLevel());
            } else if (i == 1) {
                s = new Settings(s.colorMode(), nextGlyph(s.glyphMode()), s.musicLevel());
            } else if (i == 2) {
                s = new Settings(s.colorMode(), s.glyphMode(), s.musicLevel().next());
            } else {
                return s;
            }
            store.save(s);
            apply(s, io);
        }
    }

    /** Applies settings to the IO: music level always; colour/glyph only on the console. */
    public static void apply(Settings s, GameIO io) {
        io.setMusicLevel(s.musicLevel());
        if (io instanceof ConsoleIO c) {
            c.setColorMode(s.colorMode());
            c.setGlyphMode(s.glyphMode());
        }
    }

    private static ConsoleIO.ColorMode nextColor(ConsoleIO.ColorMode m) {
        return switch (m) {
            case AUTO -> ConsoleIO.ColorMode.ON;
            case ON -> ConsoleIO.ColorMode.OFF;
            case OFF -> ConsoleIO.ColorMode.AUTO;
        };
    }

    private static ConsoleIO.GlyphMode nextGlyph(ConsoleIO.GlyphMode m) {
        return switch (m) {
            case UNICODE -> ConsoleIO.GlyphMode.ASCII;
            case ASCII -> ConsoleIO.GlyphMode.GLYPH;
            case GLYPH -> ConsoleIO.GlyphMode.UNICODE;
        };
    }
}
  • Step 5: Update App call sites

In src/main/java/thb/jeanluc/adventure/App.java, in run():

  • Remove the now-unused ConsoleIO consoleIo = io instanceof ConsoleIO c ? c : null; line (if it becomes unused after the changes below).
  • Change the startup apply to SettingsMenu.apply(settings, io);.
  • Change the SETTINGS case to case SETTINGS -> settings = SettingsMenu.show(io, settings, settingsStore);.

(If ConsoleIO import becomes unused in App.java, remove it. io is the GameIO.)

  • Step 6: Run — PASS (mvn -q test -Dtest=SettingsStoreTest,MenuTest,ConsoleIORenderTest), then mvn -q clean test (full suite green).

  • Step 7: Commitgit commit -m "feat(settings): persisted Music level; SettingsMenu applies via GameIO".


Task 6: OGG dependency + OggMusicBackend + SwingIO wiring

Files: Modify pom.xml, io/SwingIO.java, .gitignore; Create io/OggMusicBackend.java, docs/music.md. (No unit test — audio is manually verified per the no-GUI-test convention.)

  • Step 1: Add the OGG dependencies to pom.xml

In the <dependencies> block:

        <!-- OGG Vorbis decoding (GUI background music) -->
        <dependency>
            <groupId>com.googlecode.soundlibs</groupId>
            <artifactId>vorbisspi</artifactId>
            <version>1.0.3.3</version>
        </dependency>
        <dependency>
            <groupId>com.googlecode.soundlibs</groupId>
            <artifactId>jorbis</artifactId>
            <version>0.0.17.4</version>
        </dependency>

Run mvn -q -DskipTests compile to confirm the dependencies resolve.

  • Step 2: Create OggMusicBackend

src/main/java/thb/jeanluc/adventure/io/OggMusicBackend.java:

package thb.jeanluc.adventure.io;

import lombok.extern.slf4j.Slf4j;

import javax.sound.sampled.AudioFormat;
import javax.sound.sampled.AudioInputStream;
import javax.sound.sampled.AudioSystem;
import javax.sound.sampled.FloatControl;
import javax.sound.sampled.SourceDataLine;
import java.nio.file.Files;
import java.nio.file.Path;

/**
 * Streams OGG Vorbis tracks from the external {@code music/} folder through a
 * {@link SourceDataLine}, looping, with ~400 ms gain fades. Decoding uses the
 * vorbisspi/jorbis {@code javax.sound} SPI. All playback runs on a daemon
 * thread; missing/undecodable files are logged and stay silent. GUI-only —
 * verified manually.
 */
@Slf4j
public final class OggMusicBackend implements MusicBackend {

    private static final String MUSIC_DIR = "music";
    private static final int FADE_MS = 400;
    private static final int FADE_STEPS = 20;
    private static final int BUFFER = 4096;
    private static final float FLOOR_DB = -40f; // near-silence for fades

    private volatile Thread playThread;
    private volatile boolean stopping;
    private volatile SourceDataLine line;
    private volatile float targetGainDb = MusicLevel.MEDIUM.gainDb();

    @Override
    public synchronized void fadeTo(String track, float gainDb) {
        stopCurrent();
        targetGainDb = gainDb;
        Path file = Path.of(MUSIC_DIR, track);
        if (!Files.isReadable(file)) {
            log.warn("Music file not found, staying silent: {}", file);
            return;
        }
        stopping = false;
        playThread = new Thread(() -> playLoop(file, gainDb), "music");
        playThread.setDaemon(true);
        playThread.start();
    }

    @Override
    public synchronized void fadeOut() {
        stopCurrent();
    }

    @Override
    public synchronized void setGainDb(float gainDb) {
        targetGainDb = gainDb;
        SourceDataLine l = line;
        if (l != null) {
            applyGain(l, gainDb);
        }
    }

    @Override
    public synchronized void shutdown() {
        stopCurrent();
    }

    /** Signals the play thread to stop and waits for its fade-out + cleanup. */
    private void stopCurrent() {
        Thread t = playThread;
        if (t == null) {
            return;
        }
        stopping = true;
        try {
            t.join(FADE_MS + 600L);
        } catch (InterruptedException e) {
            Thread.currentThread().interrupt();
        }
        playThread = null;
    }

    private void playLoop(Path file, float gainDb) {
        SourceDataLine l = null;
        try {
            while (!stopping) {
                try (AudioInputStream in = AudioSystem.getAudioInputStream(file.toFile())) {
                    AudioFormat base = in.getFormat();
                    AudioFormat pcm = new AudioFormat(AudioFormat.Encoding.PCM_SIGNED,
                            base.getSampleRate(), 16, base.getChannels(),
                            base.getChannels() * 2, base.getSampleRate(), false);
                    try (AudioInputStream din = AudioSystem.getAudioInputStream(pcm, in)) {
                        if (l == null) {
                            l = AudioSystem.getSourceDataLine(pcm);
                            l.open(pcm);
                            l.start();
                            line = l;
                            ramp(l, FLOOR_DB, gainDb); // fade in once
                        }
                        byte[] buf = new byte[BUFFER];
                        int n;
                        while (!stopping && (n = din.read(buf, 0, buf.length)) != -1) {
                            l.write(buf, 0, n);
                        }
                    }
                }
            }
        } catch (Exception e) {
            log.warn("Music playback failed for {}: {}", file, e.getMessage());
        } finally {
            if (l != null) {
                try {
                    ramp(l, currentGain(l), FLOOR_DB); // fade out
                } catch (RuntimeException ignored) {
                    // best-effort fade
                }
                l.stop();
                l.close();
            }
            line = null;
        }
    }

    private void ramp(SourceDataLine l, float fromDb, float toDb) {
        for (int i = 0; i <= FADE_STEPS; i++) {
            applyGain(l, fromDb + (toDb - fromDb) * i / FADE_STEPS);
            sleep(FADE_MS / FADE_STEPS);
        }
    }

    private float currentGain(SourceDataLine l) {
        try {
            return ((FloatControl) l.getControl(FloatControl.Type.MASTER_GAIN)).getValue();
        } catch (IllegalArgumentException e) {
            return targetGainDb;
        }
    }

    private void applyGain(SourceDataLine l, float db) {
        try {
            FloatControl gain = (FloatControl) l.getControl(FloatControl.Type.MASTER_GAIN);
            gain.setValue(Math.max(gain.getMinimum(), Math.min(gain.getMaximum(), db)));
        } catch (IllegalArgumentException ignored) {
            // line has no master-gain control on this platform
        }
    }

    private void sleep(long ms) {
        try {
            Thread.sleep(ms);
        } catch (InterruptedException e) {
            Thread.currentThread().interrupt();
        }
    }
}

If any call doesn't compile against the installed vorbisspi/javax.sound API, adjust minimally to compile while preserving the behaviour (decode OGG→PCM, stream with looping, MASTER_GAIN fades, graceful on missing file). This class is manually verified.

  • Step 3: Wire the controller into SwingIO

In src/main/java/thb/jeanluc/adventure/io/SwingIO.java:

  • Add a field (with the other final fields): private final MusicController music = new MusicController(new OggMusicBackend());
  • Add the overrides (near setMap):
    @Override
    public void setMusic(String track) {
        music.room(track);
    }

    @Override
    public void setMusicLevel(MusicLevel level) {
        music.level(level);
    }

(MusicController/MusicLevel/OggMusicBackend are in the same io package — no imports needed.)

  • Step 4: Ignore the music folder

Append to .gitignore:

# External background-music files (not bundled)
music/
  • Step 5: Document the music folder

Create docs/music.md:

# Background music (GUI)

Background music plays only in the Swing GUI. It is **data-driven and external**:

- Put `.ogg` (OGG Vorbis) files in a `music/` folder next to where you run the game.
  This folder is git-ignored and never bundled in the JAR.
- A room plays a track via an optional `music:` field in `rooms.yaml`, e.g.
  `music: manor-theme.ogg`. Entering a room with a different track cross-fades to it;
  a room with no `music:` field fades to silence; the same track keeps playing
  seamlessly across rooms.
- Volume is the **Music** setting (Off / Low / Medium / High) in the Settings menu,
  persisted to `saves/settings.json`. Off disables playback.
- Missing files are ignored (silent) — the game never crashes over absent audio.
- The console version is always silent.

Decoding uses the `vorbisspi` + `jorbis` libraries via the `javax.sound` SPI.
  • Step 6: Build + full suite + manual check

Run: mvn -q clean test → BUILD SUCCESS (all prior + new tests; the audio backend has no unit test). Manual (optional, needs audio): drop an .ogg as music/manor-theme.ogg, launch the GUI, New Game → the kitchen should play it; move to the dungeon → it cross-fades to dungeon-drone.ogg (or silence if absent); Settings → Music cycles volume/off.

  • Step 7: Commitgit add pom.xml, OggMusicBackend, SwingIO, .gitignore, docs/music.md; git commit -m "feat(io): OggMusicBackend + SwingIO wiring; music/ folder + docs".

Task 7: Backlog doc

Files: Modify docs/enhancement-ideas.md

  • Step 1: Under section 8. Musik (GUI), add a > ✅ umgesetzt (Branch feature/music) note mirroring the existing style: OGG Vorbis (vorbisspi/jorbis), per-room music: in rooms.yaml, external gitignored music/ folder, idempotent track switch with ~400 ms fades, fade-to-silence in unscored rooms, Music level Off/Low/Med/High in Settings (default Medium), console silent; testable MusicController over an isolated OggMusicBackend.

  • Step 2: Commitgit commit -m "docs: mark GUI background music implemented".


Final Verification

  • mvn -q clean test — all tests pass (211 prior + MusicLevel/MusicController/RoomMusic/MusicSeam + extended Settings/Menu suites).
  • OGG dependencies resolve; project compiles.
  • Manual GUI smoke (optional, with an .ogg): room music plays/fades; Settings Music Off silences it; console run is silent.
  • Load a saved game from before this feature → defaults to Medium, no crash.
  • git status clean (no music/ tracked).