# Spec: Hintergrundmusik (GUI) Stand: 2026-06-01. Letzte der geplanten Mechaniken (nach `use X on Y`, Pathfinding, Tutorial). Setzt Backlog #8 um: **Hintergrundmusik nur in der GUI**, pro Raum via YAML, externer `music/`-Ordner, gestreamtes OGG. Baut auf der `GameIO`-Abstraktion, dem Per-Zug-Push (`Game.publishHud`), dem datengetriebenen Loader und der minimalen Settings auf. ## 1. Kontext & Ziel Bisher gibt es keinen Ton. Ziel: **Hintergrundmusik in der GUI**, pro Raum über ein optionales `music:`-Feld in `rooms.yaml`. Beim Raumwechsel sanfter Übergang; Lautstärke/Aus über die Settings. Konsole bleibt stumm (No-Op). Bestätigte Entscheidungen (Brainstorming 2026-06-01): - **Format/Lib**: **OGG Vorbis** via `vorbisspi` + `jorbis` (javax.sound-SPI), gestreamt über eine `SourceDataLine`. - **Wechsel**: gleicher Track → unverändert weiter (kein Neustart); anderer Track → **kurzes Fade-out, dann Fade-in** (~400 ms, sequenziell, eine Line); Raum **ohne** `music` → **Fade to silence**. - **Settings**: eine zyklische **Music**-Stufe `OFF/LOW/MEDIUM/HIGH`, persistiert; `OFF` deaktiviert Wiedergabe. Default **MEDIUM**. - **Dateien**: externer `music/`-Ordner (Arbeitsverzeichnis), **gitignored**, nicht im JAR; fehlende Datei → still + Log, kein Absturz. - **Demo**: einige Räume mit Beispiel-`music:`-Feldern + Doku zum `music/`-Ordner. ## 2. Scope **In Scope:** - `MusicLevel`-Enum (OFF/LOW/MEDIUM/HIGH + `gainDb()`). - `MusicController` (frontend-agnostisch, **testbar**): Track-/Level-Logik, delegiert an ein `MusicBackend`. - `MusicBackend`-Interface (Roh-Operationen) + `OggMusicBackend` (Audio, **manuell**). - `GameIO.setMusic(String)` + `setMusicLevel(MusicLevel)` (Default No-Op); `SwingIO`-Override mit `MusicController(new OggMusicBackend())`. - `Game.publishHud` ruft `setMusic(currentRoom.getMusic())`; nach `game.run()` zurück ins Menü → `setMusic(null)`. - `Room.music` + `RoomDto.music` + `RoomFactory`; optionales `music:` in `rooms.yaml`. - `Settings.musicLevel` + `SettingsStore` (fehlt → MEDIUM); `SettingsMenu`-Zeile „Music"; Anwendung via `io.setMusicLevel(...)` (Konsole No-Op). - `pom.xml`: `vorbisspi` + `jorbis`. - `music/` in `.gitignore` + kurze Doku. - Tests: `MusicController` (Fake-Backend), `MusicLevel`, Settings-Roundtrip, Room-`music`-Laden. **Out of Scope:** - Konsolen-Audio (bleibt stumm). - True Cross-Fade (überlappende Lines) — nur sequenzielles Fade. - Sound-Effekte / mehrere gleichzeitige Tracks. - Mitliefern von Audiodateien im Repo. - Automatisierte Tests der echten OGG-Dekodierung/-Wiedergabe (manuell, GUI-only). ## 3. `MusicLevel` (model oder io) ```java public enum MusicLevel { OFF, LOW, MEDIUM, HIGH; /** MASTER_GAIN in dB; für OFF irrelevant (Wiedergabe deaktiviert). */ public float gainDb() { return switch (this) { case OFF -> Float.NEGATIVE_INFINITY; case LOW -> -20f; case MEDIUM -> -10f; case HIGH -> 0f; }; } public boolean isOn() { return this != OFF; } /** Nächste Stufe im Zyklus (für den Settings-Toggle). */ public MusicLevel next() { return values()[(ordinal() + 1) % values().length]; } } ``` Platzierung: `io`-Paket (UI-nah, wie `ConsoleIO.ColorMode`). Settings referenziert es. ## 4. `MusicBackend` + `MusicController` (io) ```java /** Raw audio operations; the only part that touches sound hardware. */ public interface MusicBackend { void fadeTo(String track, float gainDb); // fade current out, start track (loop), fade in void fadeOut(); // fade current out + stop void setGainDb(float gainDb); // immediate volume change void shutdown(); // stop + release resources } ``` ```java /** Frontend-agnostic music decision logic. Idempotent per track; honours level. */ public final class MusicController { private final MusicBackend backend; private MusicLevel level = MusicLevel.MEDIUM; private String current; // currently selected track id, or null = silence public MusicController(MusicBackend backend) { this.backend = backend; } /** Called per turn with the current room's music field (may be null). */ public void room(String track) { if (!level.isOn()) { return; } // OFF: stay silent, ignore String t = (track == null || track.isBlank()) ? null : track; if (java.util.Objects.equals(t, current)) { return; } // same → keep playing current = t; if (t == null) { backend.fadeOut(); } else { backend.fadeTo(t, level.gainDb()); } } /** Settings change. */ public void level(MusicLevel newLevel) { this.level = newLevel; if (!newLevel.isOn()) { backend.fadeOut(); current = null; } else { backend.setGainDb(newLevel.gainDb()); } // turning ON again starts on the next room() call (current was reset to null) } public void shutdown() { backend.shutdown(); } } ``` Hinweis: Bei Wechsel OFF→ON beginnt die Musik beim nächsten `room(...)` (pro Zug ohnehin aufgerufen), da `current` auf null gesetzt wurde. Das vermeidet, dass der Controller den aktuellen Raum kennen muss. ## 5. `OggMusicBackend` (io, Audio, manuell) - Hält den aktuellen Wiedergabe-Thread (Daemon) + `SourceDataLine`. - `fadeTo(track, gainDb)`: laufenden Track per `MASTER_GAIN`-Rampe (~400 ms) ausblenden, Thread stoppen (`volatile boolean stopping`), `music/` öffnen (`AudioSystem.getAudioInputStream` → vorbisspi liefert dekodiertes PCM), in einer Schleife (Loop) in die Line schreiben, auf `gainDb` einblenden. - `fadeOut()`: Rampe runter, Thread stoppen, Line schließen. - `setGainDb(db)`: `MASTER_GAIN`-FloatControl sofort setzen. - `shutdown()`: stoppen + Ressourcen freigeben. - **Fehlende Datei / Decode-Fehler**: `log.warn`, still (kein Throw). `music/` fehlt ganz → ebenso still. - Threading: Wiedergabe auf Daemon-Thread; Fades blockierend auf dem aufrufenden Thread (Worker), kurz (~400 ms) — analog zur bestehenden `travelStep`-Pause. `music/` = `Path.of("music", track)` relativ zum Arbeitsverzeichnis. ## 6. `GameIO`-Anbindung ```java /** Per-turn current-room track; null = silence. Console no-op; GUI plays. */ default void setMusic(String track) { /* no-op */ } /** Apply the music volume/level. Console no-op; GUI sets it. */ default void setMusicLevel(MusicLevel level) { /* no-op */ } ``` - `SwingIO`: Feld `MusicController music = new MusicController(new OggMusicBackend());` `setMusic(t) → music.room(t)`; `setMusicLevel(l) → music.level(l)`. - `ConsoleIO`: erbt die No-Op-Defaults (kein Audio). - `Game.publishHud()`: zusätzlich `ctx.getIo().setMusic(ctx.getPlayer().getCurrentRoom().getMusic());` (idempotent → Wechsel nur bei echtem Raumwechsel). - `App.play(...)`: nach `game.run()` → `io.setMusic(null);` (Musik beim Rückkehr ins Menü ausblenden). ## 7. Room-Feld + Loader - `Room`: optionales `private final String music;` (+ Getter). Konstruktor/Factory erweitern; null = kein Track. - `RoomDto`: optionales `music`-Feld (nullable). - `RoomFactory.shellFromDto`: `music` durchreichen. - `rooms.yaml`: optionales `music: .ogg` pro Raum. - Demo: z.B. `kitchen: music: manor-theme.ogg`, `dungeon: music: dungeon-drone.ogg` (Dateien liefert der Nutzer; fehlend → still). ## 8. Settings-Erweiterung - `Settings` (record) bekommt `MusicLevel musicLevel`; `defaults()` → MEDIUM. - `SettingsStore`: Jackson serialisiert `MusicLevel` als Namen; alter Spielstand ohne Feld → `null` → auf MEDIUM normalisieren (im `load()` oder via Defaults). - `SettingsMenu`: dritte Zeile „Music: "; Auswahl zyklt `level.next()`; nach jeder Änderung speichern + `applySettings`. - **Refactor**: `SettingsMenu.apply/show` nehmen statt `ConsoleIO` das `GameIO` entgegen. `apply(Settings, GameIO io)`: `io.setMusicLevel(s.musicLevel());` **und** `if (io instanceof ConsoleIO c) { c.setColorMode(...); c.setGlyphMode(...); }`. Aufrufer (App, `MenuTest`) anpassen. - `App.run`: beim Start `SettingsMenu.apply(settings, io)` (setzt auch Music-Level). ## 9. Abhängigkeit + Dateien - `pom.xml` (``): - `com.googlecode.soundlibs:vorbisspi:1.0.3.3` - `com.googlecode.soundlibs:jorbis:0.0.17.4` (vorbisspi zieht `tritonus-share` transitiv; bei Bedarf explizit ergänzen.) - `.gitignore`: `music/`. - Kurze Doku (z.B. in `docs/` oder README-Abschnitt): „Lege `.ogg`-Dateien in `music/` ab; Räume referenzieren sie über `music:` in `rooms.yaml`. Ohne Dateien bleibt die GUI stumm." ## 10. Tests - **`MusicControllerTest`** (Fake-`MusicBackend`, das Aufrufe protokolliert): - `room("a")` → `fadeTo("a", MEDIUM.gainDb())`; erneut `room("a")` → kein Aufruf. - `room("b")` → `fadeTo("b", ...)`; `room(null)` → `fadeOut()`. - `level(OFF)` → `fadeOut()` + danach ignoriert `room("a")` (still). - `level(HIGH)` (von MEDIUM) → `setGainDb(HIGH)`; danach `room("a")` spielt. - leerer/blank Track == null. - **`MusicLevelTest`**: `gainDb()`-Mapping; `next()`-Zyklus; `isOn()`. - **`SettingsStoreTest`** (erweitern): musicLevel round-trip; fehlendes Feld → MEDIUM. - **`SettingsMenu`/`MenuTest`** (anpassen an `GameIO`-Signatur): Music-Zeile zyklt und persistiert. - **Room-`music`-Laden**: Loader-/Factory-Test, dass `music:` auf `Room.getMusic()` landet (und Abwesenheit → null). - `OggMusicBackend` + echte Wiedergabe: **manuell** (eine `.ogg` + Audio-Hardware), keine Unit-Tests (Konvention: kein GUI-/Hardware-abhängiger Test). ## 11. Architektur-Werte / Risiken - **Geteilter Loop bleibt**: nur zwei neue `GameIO`-No-Op-Defaults; Musik-Logik im testbaren `MusicController`, Audio isoliert im `OggMusicBackend`. Konsole stumm. - **Testbarkeit**: die wertvolle Entscheidungslogik (Idempotenz, null→Stille, OFF) ist gegen ein Fake-Backend voll getestet; Hardware-Audio bleibt manuell. - **Datengetrieben**: `music:` pro Raum in YAML; keine Hardcodierung. - **Externe Dateien**: `music/` gitignored, nicht im JAR — Repo bleibt klein; fehlende Dateien sind ein No-Op (graceful), kein Spielabbruch. - **Risiko Lib**: vorbisspi/jorbis sind alt, funktionieren aber mit `javax.sound`. Falls eine Datei nicht dekodierbar ist → graceful silence. Lizenz: LGPL/BSD-artig, für eine Erweiterung erlaubt (Backlog: Bibliotheken erlaubt). - **Threading**: Fades blockieren kurz den Worker-Thread (~400 ms, wie `travelStep`); Wiedergabe auf Daemon-Thread. Beim Spielende `setMusic(null)` → Stille im Menü. - **Settings-Refactor**: `SettingsMenu` von `ConsoleIO` auf `GameIO` umstellen ist die einzige Änderung an bestehendem, bereits reviewtem Code — minimal und nötig, damit der Music-Level beide Frontends erreicht (Konsole No-Op).