# 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`: ```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`: ```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: Commit** — `git 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`: ```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 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`: ```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`: ```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: Commit** — `git 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`: ```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: ```java /** 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: ```java public record RoomDto( String id, String name, String description, Map exits, List items, List npcs, List exitLocks, List descriptionStates, Boolean dark, String music ) { /** Backward-compatible constructor without the optional state fields. */ public RoomDto(String id, String name, String description, Map exits, List items, List 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(...)`: ```java 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: Commit** — `git 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`: ```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): ```java /** 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(...)`): ```java 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();`: ```java 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: Commit** — `git 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`): ```java @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): ```java @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: ```java @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`: ```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`: ```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: Commit** — `git 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 `` block: ```xml com.googlecode.soundlibs vorbisspi 1.0.3.3 com.googlecode.soundlibs jorbis 0.0.17.4 ``` Run `mvn -q -DskipTests compile` to confirm the dependencies resolve. - [ ] **Step 2: Create `OggMusicBackend`** `src/main/java/thb/jeanluc/adventure/io/OggMusicBackend.java`: ```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`): ```java @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`: ```markdown # 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: Commit** — `git 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: Commit** — `git 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). ```