# 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/<track>` ö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/<track>` = `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: <datei>.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: <level>"; 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` (`<dependencies>`):
  - `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).
