Files
Jander_Semester2/Semesterprojekt/docs/superpowers/specs/2026-06-01-music-design.md
Jean-Luc Makiola a0e21a9baa docs: spec for GUI background music
OGG Vorbis (vorbisspi+jorbis) per-room music via rooms.yaml, external gitignored
music/ folder. Testable MusicController (idempotent track switch, null->silence,
OFF disables) over an isolated OggMusicBackend; GameIO.setMusic/setMusicLevel
no-op on console, played by SwingIO. Settings gains a Music level Off/Low/Med/High
(default Medium). Console stays silent.

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

227 lines
11 KiB
Markdown

# 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).