From f1aedebfcbde054f4fb3334b30296e68dcf3b132 Mon Sep 17 00:00:00 2001 From: Jean-Luc Makiola Date: Mon, 1 Jun 2026 23:20:14 +0200 Subject: [PATCH] feat(io): OggMusicBackend + SwingIO wiring; music/ folder + docs Co-Authored-By: Claude Sonnet 4.6 --- Semesterprojekt/.gitignore | 3 + Semesterprojekt/docs/music.md | 16 ++ Semesterprojekt/pom.xml | 12 ++ .../jeanluc/adventure/io/OggMusicBackend.java | 155 ++++++++++++++++++ .../thb/jeanluc/adventure/io/SwingIO.java | 11 ++ 5 files changed, 197 insertions(+) create mode 100644 Semesterprojekt/docs/music.md create mode 100644 Semesterprojekt/src/main/java/thb/jeanluc/adventure/io/OggMusicBackend.java diff --git a/Semesterprojekt/.gitignore b/Semesterprojekt/.gitignore index 90926aa..b39385b 100644 --- a/Semesterprojekt/.gitignore +++ b/Semesterprojekt/.gitignore @@ -43,3 +43,6 @@ build/ # Local save games and settings saves/ + +# External background-music files (not bundled) +music/ diff --git a/Semesterprojekt/docs/music.md b/Semesterprojekt/docs/music.md new file mode 100644 index 0000000..6d91ba1 --- /dev/null +++ b/Semesterprojekt/docs/music.md @@ -0,0 +1,16 @@ +# 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. diff --git a/Semesterprojekt/pom.xml b/Semesterprojekt/pom.xml index 4e2dfd8..0fba00d 100644 --- a/Semesterprojekt/pom.xml +++ b/Semesterprojekt/pom.xml @@ -49,6 +49,18 @@ ${logback.version} + + + com.googlecode.soundlibs + vorbisspi + 1.0.3.3 + + + com.googlecode.soundlibs + jorbis + 0.0.17.4 + + org.junit.jupiter diff --git a/Semesterprojekt/src/main/java/thb/jeanluc/adventure/io/OggMusicBackend.java b/Semesterprojekt/src/main/java/thb/jeanluc/adventure/io/OggMusicBackend.java new file mode 100644 index 0000000..72bee18 --- /dev/null +++ b/Semesterprojekt/src/main/java/thb/jeanluc/adventure/io/OggMusicBackend.java @@ -0,0 +1,155 @@ +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(); + } + } +} diff --git a/Semesterprojekt/src/main/java/thb/jeanluc/adventure/io/SwingIO.java b/Semesterprojekt/src/main/java/thb/jeanluc/adventure/io/SwingIO.java index 8501488..77c6004 100644 --- a/Semesterprojekt/src/main/java/thb/jeanluc/adventure/io/SwingIO.java +++ b/Semesterprojekt/src/main/java/thb/jeanluc/adventure/io/SwingIO.java @@ -74,6 +74,7 @@ public class SwingIO implements GameIO { private static final int SIDE_MIN = 180; private static final int SIDE_MAX = 460; + private final MusicController music = new MusicController(new OggMusicBackend()); private final LinkedBlockingQueue inputs = new LinkedBlockingQueue<>(); private final JFrame frame; private final JTextPane output; @@ -333,6 +334,16 @@ public class SwingIO implements GameIO { SwingUtilities.invokeLater(() -> hud.setText(txt)); } + @Override + public void setMusic(String track) { + music.room(track); + } + + @Override + public void setMusicLevel(MusicLevel level) { + music.level(level); + } + @Override public void setMap(MapView view) { map.show(view);