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

11 KiB

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 musicFade 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)

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)

/** 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
}
/** 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

/** 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: "; 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).