feat(io): OggMusicBackend + SwingIO wiring; music/ folder + docs
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -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();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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<String> 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);
|
||||
|
||||
Reference in New Issue
Block a user