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