From eb8d08f41873eac7079a7edfca41bba0e90341b4 Mon Sep 17 00:00:00 2001 From: extio-de Date: Wed, 24 Dec 2025 21:31:25 +0100 Subject: [PATCH] feat(audio): Implement audio management system with multi-stream and single-stream players - Added AudioData class to encapsulate audio name and input stream. - Introduced AudioLoader for loading audio files with caching mechanism. - Created AudioLoaderRequest for handling audio loading requests. - Developed AudioOptions class to manage audio settings for sound effects and music. - Implemented AudioPlayer interface and its concrete classes: AudioPlayerMultiStream and AudioPlayerSingleStream. - Established AudioStrategy interface with implementations for Java Mixer and Software Mixing strategies. - Enhanced StaticResource class with a custom toString method for better resource path representation. - Updated Spring configuration to include AudioAutoConfiguration for audio management. --- .../audio/AudioAutoConfiguration.java | 20 + .../extio/game_engine/audio/AudioControl.java | 45 ++ .../game_engine/audio/AudioController.java | 184 +++++++ .../de/extio/game_engine/audio/AudioData.java | 7 + .../extio/game_engine/audio/AudioLoader.java | 110 ++++ .../game_engine/audio/AudioLoaderRequest.java | 46 ++ .../extio/game_engine/audio/AudioOptions.java | 83 ++++ .../extio/game_engine/audio/AudioPlayer.java | 19 + .../audio/AudioPlayerMultiStream.java | 468 ++++++++++++++++++ .../audio/AudioPlayerSingleStream.java | 186 +++++++ .../game_engine/audio/AudioStrategy.java | 23 + .../audio/AudioStrategyJavaMixer.java | 243 +++++++++ .../audio/AudioStrategySoftwareMixing.java | 174 +++++++ .../game_engine/resource/StaticResource.java | 9 + ...ot.autoconfigure.AutoConfiguration.imports | 1 + 15 files changed, 1618 insertions(+) create mode 100644 src/main/java/de/extio/game_engine/audio/AudioAutoConfiguration.java create mode 100644 src/main/java/de/extio/game_engine/audio/AudioControl.java create mode 100644 src/main/java/de/extio/game_engine/audio/AudioController.java create mode 100644 src/main/java/de/extio/game_engine/audio/AudioData.java create mode 100644 src/main/java/de/extio/game_engine/audio/AudioLoader.java create mode 100644 src/main/java/de/extio/game_engine/audio/AudioLoaderRequest.java create mode 100644 src/main/java/de/extio/game_engine/audio/AudioOptions.java create mode 100644 src/main/java/de/extio/game_engine/audio/AudioPlayer.java create mode 100644 src/main/java/de/extio/game_engine/audio/AudioPlayerMultiStream.java create mode 100644 src/main/java/de/extio/game_engine/audio/AudioPlayerSingleStream.java create mode 100644 src/main/java/de/extio/game_engine/audio/AudioStrategy.java create mode 100644 src/main/java/de/extio/game_engine/audio/AudioStrategyJavaMixer.java create mode 100644 src/main/java/de/extio/game_engine/audio/AudioStrategySoftwareMixing.java diff --git a/src/main/java/de/extio/game_engine/audio/AudioAutoConfiguration.java b/src/main/java/de/extio/game_engine/audio/AudioAutoConfiguration.java new file mode 100644 index 0000000..24605a4 --- /dev/null +++ b/src/main/java/de/extio/game_engine/audio/AudioAutoConfiguration.java @@ -0,0 +1,20 @@ +package de.extio.game_engine.audio; + +import org.springframework.boot.autoconfigure.AutoConfiguration; +import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean; +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; +import org.springframework.context.annotation.Bean; + +import de.extio.game_engine.resource.StaticResourceService; +import de.extio.game_engine.storage.StorageService; + +@AutoConfiguration +@ConditionalOnProperty(name = "game-engine.audio.enabled", havingValue = "true", matchIfMissing = true) +public class AudioAutoConfiguration { + + @Bean + @ConditionalOnMissingBean + AudioController audioController(final StaticResourceService resourceService, final StorageService storageService) { + return new AudioController(resourceService, storageService); + } +} diff --git a/src/main/java/de/extio/game_engine/audio/AudioControl.java b/src/main/java/de/extio/game_engine/audio/AudioControl.java new file mode 100644 index 0000000..9b08900 --- /dev/null +++ b/src/main/java/de/extio/game_engine/audio/AudioControl.java @@ -0,0 +1,45 @@ +package de.extio.game_engine.audio; + +import java.util.List; + +import de.extio.game_engine.resource.StaticResource; + +/** + * Controls the audio system + */ +public interface AudioControl { + + /** + * Plays a collection of music tracks, music playback will be shuffled and repeat + * @param opener Optional opener track to be played first, will not repeat, can be null + * @param audioResources Collection of music tracks to be played + * @param standard Defines the given collection of music tracks as standard collection, see playStandardMusic() + */ + void playMusic(StaticResource opener, List audioResources, boolean standard); + + /** + * Plays the standard collection of music tracks, music playback will be shuffled and repeat + */ + void playStandardMusic(); + + /** + * Stops playing music + */ + void stopMusic(); + + /** + * Plays a sound effect once + */ + void play(StaticResource audioResource); + + /** + * @return Copy of audio options to configure the audio system + */ + AudioOptions getAudioOptions(); + + /** + * Applies the given audio options to the audio system + */ + void applyAudioOptions(AudioOptions audioOptions); + +} diff --git a/src/main/java/de/extio/game_engine/audio/AudioController.java b/src/main/java/de/extio/game_engine/audio/AudioController.java new file mode 100644 index 0000000..a558dab --- /dev/null +++ b/src/main/java/de/extio/game_engine/audio/AudioController.java @@ -0,0 +1,184 @@ +package de.extio.game_engine.audio; + +import java.util.ArrayList; +import java.util.List; + +import de.extio.game_engine.resource.StaticResource; +import de.extio.game_engine.resource.StaticResourceService; +import de.extio.game_engine.storage.StorageService; + +public final class AudioController implements AudioControl { + + private final StorageService storageService; + + private AudioOptions audioOptions; + + private AudioStrategy audioStrategy; + + private final AudioLoader audioLoader; + + private Thread audioLoaderThread; + + private final Thread audioControllerThread; + + private final List standardMusic = new ArrayList<>(); + + private List lastMusicQueued = null; + + private boolean started; + + private boolean useSoftwareMixing; + + public AudioController(final StaticResourceService resourceService, final StorageService storageService) { + this.storageService = storageService; + storageService.loadByPath(AudioOptions.class, List.of("audio"), "audioOptions").ifPresentOrElse( + loadedOptions -> { + this.audioOptions = loadedOptions; + }, + () -> { + this.audioOptions = new AudioOptions(); + this.storeAudioOptions(); + }); + + this.audioLoader = new AudioLoader(resourceService); + + this.startStrategy(); + + this.audioControllerThread = Thread.ofPlatform() + .name("Audio Controller") + .daemon(true) + .start(() -> { + while (!Thread.currentThread().isInterrupted()) { + try { + Thread.sleep(100); + } + catch (final InterruptedException e) { + Thread.currentThread().interrupt(); + break; + } + + this.run(); + } + }); + } + + private void storeAudioOptions() { + this.storageService.store(List.of("audio"), "audioOptions", this.audioOptions); + } + + public void run() { + if (!this.started || this.audioOptions.getSfxOptions().isSoftwareMixing() != this.useSoftwareMixing) { + this.stopStrategy(); + this.startStrategy(); + } + + if (this.audioStrategy != null) { + this.audioStrategy.run(); + } + } + + public void shutdown() { + this.audioControllerThread.interrupt(); + this.stopStrategy(); + } + + private void startStrategy() { + if (!this.started) { + this.audioLoader.getQueue().clear(); + this.audioLoaderThread = Thread.ofPlatform() + .name("Audio Loader") + .daemon(true) + .start(this.audioLoader); + + if (this.audioOptions.getSfxOptions().isSoftwareMixing()) { + this.audioStrategy = new AudioStrategySoftwareMixing(this.audioOptions, this.audioLoader); + this.useSoftwareMixing = true; + } + else { + this.audioStrategy = new AudioStrategyJavaMixer(this.audioOptions, this.audioLoader); + this.useSoftwareMixing = false; + } + + this.audioStrategy.start(); + if (this.lastMusicQueued != null) { + this.audioStrategy.queueMusic(this.lastMusicQueued); + } + + this.started = true; + } + } + + private void stopStrategy() { + if (this.started) { + if (this.audioStrategy != null) { + this.audioStrategy.stop(); + this.audioStrategy = null; + } + if (this.audioLoaderThread != null) { + this.audioLoaderThread.interrupt(); + this.audioLoaderThread = null; + } + this.audioLoader.getQueue().clear(); + this.started = false; + } + } + + @Override + public void playMusic(final StaticResource opener, final List audioResources, final boolean standard) { + if (audioResources == null) { + return; + } + + if (standard) { + this.standardMusic.clear(); + this.standardMusic.addAll(audioResources); + } + + if (this.audioStrategy != null) { + this.lastMusicQueued = this.wrapOpener(opener, audioResources); + this.audioStrategy.queueMusic(this.lastMusicQueued); + } + } + + @Override + public void playStandardMusic() { + if (this.audioStrategy != null) { + this.lastMusicQueued = this.wrapOpener(null, this.standardMusic); + this.audioStrategy.queueMusic(this.lastMusicQueued); + } + } + + @Override + public void stopMusic() { + if (this.audioStrategy != null) { + this.audioStrategy.clearMusic(); + } + this.lastMusicQueued = null; + } + + @Override + public void play(final StaticResource audioResource) { + if (this.audioStrategy != null) { + this.audioStrategy.queueSfx(List.of(audioResource)); + } + } + + @Override + public AudioOptions getAudioOptions() { + return new AudioOptions(this.audioOptions); + } + + @Override + public void applyAudioOptions(final AudioOptions audioOptions) { + this.audioOptions.apply(audioOptions); + this.storeAudioOptions(); + } + + private List wrapOpener(final StaticResource opener, final List audioFileNames) { + final List result = new ArrayList<>(audioFileNames.size() + 1); + result.add(opener); + result.addAll(audioFileNames); + return result; + } + +} diff --git a/src/main/java/de/extio/game_engine/audio/AudioData.java b/src/main/java/de/extio/game_engine/audio/AudioData.java new file mode 100644 index 0000000..7ee651a --- /dev/null +++ b/src/main/java/de/extio/game_engine/audio/AudioData.java @@ -0,0 +1,7 @@ +package de.extio.game_engine.audio; + +import java.io.InputStream; + +public record AudioData(String name, InputStream inputStream) { + +} diff --git a/src/main/java/de/extio/game_engine/audio/AudioLoader.java b/src/main/java/de/extio/game_engine/audio/AudioLoader.java new file mode 100644 index 0000000..406ecda --- /dev/null +++ b/src/main/java/de/extio/game_engine/audio/AudioLoader.java @@ -0,0 +1,110 @@ +package de.extio.game_engine.audio; + +import java.io.ByteArrayInputStream; +import java.io.IOException; +import java.io.InputStream; +import java.util.LinkedHashMap; +import java.util.Map; +import java.util.concurrent.BlockingQueue; +import java.util.concurrent.LinkedBlockingQueue; +import java.util.concurrent.atomic.AtomicLong; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import de.extio.game_engine.resource.StaticResourceService; + +final class AudioLoader implements Runnable { + + private final static int CACHE_SIZE_LIMIT = 128; + + private final static int CACHE_ELEMENT_BYTES_LIMIT = 262144; + + private final static Logger LOGGER = LoggerFactory.getLogger(AudioLoader.class); + + private final BlockingQueue queue = new LinkedBlockingQueue<>(50); + + private final Map lruCache = new LRUCache<>(); + + private final StaticResourceService resourceService; + + public AudioLoader(final StaticResourceService resourceService) { + this.resourceService = resourceService; + } + + @Override + public void run() { + LOGGER.debug("start"); + + while (!Thread.currentThread().isInterrupted()) { + try { + final AudioLoaderRequest request = this.queue.take(); + + if (request.isUseCache()) { + final String cacheKey = request.getStaticResource().toString(); + + final CacheEntry cacheEntry = this.lruCache.get(cacheKey); + if (cacheEntry != null) { + LOGGER.debug("Loading audio file from cache: {}", cacheKey); + + cacheEntry.lastAccessTime().set(System.currentTimeMillis()); + request.getConsumer().accept(new ByteArrayInputStream(cacheEntry.data())); + } + else { + LOGGER.debug("Loading audio file from disk: {}", cacheKey); + + this.resourceService.loadStreamByPath(request.getStaticResource()).ifPresent(resourceStream -> { + try (InputStream stream = resourceStream) { + final byte[] bytes = stream.readAllBytes(); + LOGGER.debug("Loaded {} bytes", bytes.length); + + if (bytes.length <= CACHE_ELEMENT_BYTES_LIMIT) { + this.lruCache.put(cacheKey, new CacheEntry(bytes, new AtomicLong(System.currentTimeMillis()))); + LOGGER.debug("Added to cache: {}", cacheKey); + } + + request.getConsumer().accept(new ByteArrayInputStream(bytes)); + } + catch (final IOException e) { + LOGGER.error(e.getMessage(), e); + } + }); + } + } + else { + LOGGER.debug("Loading audio file from disk (no cache): {}", request.getStaticResource()); + + // Stream will be closed by audio player async, don't close it here + this.resourceService.loadStreamByPath(request.getStaticResource()).ifPresent(request.getConsumer()::accept); + } + } + catch (final Exception e) { + if (e instanceof InterruptedException) { + LOGGER.info(e.getClass().getName()); + break; + } + LOGGER.error(e.getMessage(), e); + } + } + + LOGGER.debug("end"); + } + + public BlockingQueue getQueue() { + return this.queue; + } + + private static class LRUCache extends LinkedHashMap { + + public LRUCache() { + super((int) (CACHE_SIZE_LIMIT * 1.5), 0.75f, true); + } + + @Override + protected boolean removeEldestEntry(final Map.Entry eldest) { + return this.size() >= CACHE_SIZE_LIMIT; + } + } + + private static record CacheEntry(byte[] data, AtomicLong lastAccessTime) {} +} diff --git a/src/main/java/de/extio/game_engine/audio/AudioLoaderRequest.java b/src/main/java/de/extio/game_engine/audio/AudioLoaderRequest.java new file mode 100644 index 0000000..261f3ec --- /dev/null +++ b/src/main/java/de/extio/game_engine/audio/AudioLoaderRequest.java @@ -0,0 +1,46 @@ +package de.extio.game_engine.audio; + +import java.io.InputStream; +import java.util.function.Consumer; + +import de.extio.game_engine.resource.StaticResource; + +final class AudioLoaderRequest { + + private StaticResource StaticResource; + + private Consumer consumer; + + private boolean useCache; + + public AudioLoaderRequest(final StaticResource StaticResource, final boolean useCache, final Consumer consumer) { + this.StaticResource = StaticResource; + this.useCache = useCache; + this.consumer = consumer; + } + + public Consumer getConsumer() { + return this.consumer; + } + + public void setConsumer(final Consumer consumer) { + this.consumer = consumer; + } + + public StaticResource getStaticResource() { + return this.StaticResource; + } + + public void setStaticResource(final StaticResource StaticResource) { + this.StaticResource = StaticResource; + } + + public boolean isUseCache() { + return this.useCache; + } + + public void setUseCache(final boolean useCache) { + this.useCache = useCache; + } + +} diff --git a/src/main/java/de/extio/game_engine/audio/AudioOptions.java b/src/main/java/de/extio/game_engine/audio/AudioOptions.java new file mode 100644 index 0000000..543b1c8 --- /dev/null +++ b/src/main/java/de/extio/game_engine/audio/AudioOptions.java @@ -0,0 +1,83 @@ +package de.extio.game_engine.audio; + +public class AudioOptions { + + private AudioStreamOptions sfxOptions; + + private AudioStreamOptions musicOptions; + + public AudioOptions() { + this.sfxOptions = new AudioStreamOptions(true, 1.0, true); + this.musicOptions = new AudioStreamOptions(true, 0.8, false); + } + + public AudioOptions(final AudioOptions other) { + this.sfxOptions = new AudioStreamOptions(other.sfxOptions.enable, other.sfxOptions.volume, other.sfxOptions.softwareMixing); + this.musicOptions = new AudioStreamOptions(other.musicOptions.enable, other.musicOptions.volume, other.musicOptions.softwareMixing); + } + + public void apply(final AudioOptions other) { + this.sfxOptions = new AudioStreamOptions(other.sfxOptions.enable, other.sfxOptions.volume, other.sfxOptions.softwareMixing); + this.musicOptions = new AudioStreamOptions(other.musicOptions.enable, other.musicOptions.volume, other.musicOptions.softwareMixing); + } + + public AudioStreamOptions getSfxOptions() { + return sfxOptions; + } + + public void setSfxOptions(final AudioStreamOptions sfxOptions) { + this.sfxOptions = sfxOptions; + } + + public AudioStreamOptions getMusicOptions() { + return musicOptions; + } + + public void setMusicOptions(final AudioStreamOptions musicOptions) { + this.musicOptions = musicOptions; + } + + public class AudioStreamOptions { + + boolean enable; + + double volume; + + private boolean softwareMixing; + + public AudioStreamOptions() { + + } + + public AudioStreamOptions(final boolean enable, final double volume, final boolean softwareMixing) { + this.enable = enable; + this.volume = volume; + this.softwareMixing = softwareMixing; + } + + public boolean isEnable() { + return this.enable; + } + + public void setEnable(final boolean enable) { + this.enable = enable; + } + + public double getVolume() { + return this.volume; + } + + public void setVolume(final double volume) { + this.volume = volume; + } + + public boolean isSoftwareMixing() { + return this.softwareMixing; + } + + public void setSoftwareMixing(final boolean softwareMixing) { + this.softwareMixing = softwareMixing; + } + } + +} diff --git a/src/main/java/de/extio/game_engine/audio/AudioPlayer.java b/src/main/java/de/extio/game_engine/audio/AudioPlayer.java new file mode 100644 index 0000000..75dce21 --- /dev/null +++ b/src/main/java/de/extio/game_engine/audio/AudioPlayer.java @@ -0,0 +1,19 @@ +package de.extio.game_engine.audio; + +import java.util.concurrent.BlockingQueue; + +import de.extio.game_engine.audio.AudioOptions.AudioStreamOptions; + +interface AudioPlayer extends Runnable { + + void exit(); + + void stopAudio(); + + void updateAudioOptions(AudioStreamOptions options); + + boolean isPlaying(); + + BlockingQueue getQueue(); + +} diff --git a/src/main/java/de/extio/game_engine/audio/AudioPlayerMultiStream.java b/src/main/java/de/extio/game_engine/audio/AudioPlayerMultiStream.java new file mode 100644 index 0000000..8954c75 --- /dev/null +++ b/src/main/java/de/extio/game_engine/audio/AudioPlayerMultiStream.java @@ -0,0 +1,468 @@ +package de.extio.game_engine.audio; + +import java.io.InputStream; +import java.util.concurrent.BlockingQueue; +import java.util.concurrent.LinkedBlockingQueue; + +import javax.sound.sampled.AudioFormat; +import javax.sound.sampled.AudioInputStream; +import javax.sound.sampled.AudioSystem; +import javax.sound.sampled.BooleanControl; +import javax.sound.sampled.DataLine.Info; +import javax.sound.sampled.FloatControl; +import javax.sound.sampled.SourceDataLine; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import de.extio.game_engine.audio.AudioOptions.AudioStreamOptions; + +final class AudioPlayerMultiStream implements AudioPlayer { + + private static final Logger LOGGER = LoggerFactory.getLogger(AudioPlayerMultiStream.class); + + private static final int STREAMS = 4; + + private static final int MUSIC_FADING_MS = 1000; + + private final BlockingQueue queue = new LinkedBlockingQueue<>(20); + + private volatile AudioData musicQueue; + + private volatile boolean musicPlaying; + + private volatile boolean stopAudio; + + private volatile boolean stopMusic; + + private volatile boolean playing; + + private volatile double sfxVolume; + + private volatile boolean sfxMute; + + private volatile double musicVolume; + + private volatile boolean musicMute; + + private volatile boolean exit; + + private boolean curMute; + + private long notStartedAudioCnt; + + public AudioPlayerMultiStream() { + } + + @Override + public void run() { + LOGGER.debug("start"); + + final int[] mix = new int[STREAMS]; + final double[] vols = new double[STREAMS]; + double musicFadeVol = 1.0; + long musicFading = 0; + final AudioBuffer[] buffers = new AudioBuffer[STREAMS]; + final AudioPlayerStream[] streams = new AudioPlayerStream[STREAMS]; + for (int i = 0; i < STREAMS; i++) { + buffers[i] = new AudioBuffer(); + streams[i] = new AudioPlayerStream(); + } + final AudioBuffer mixBuffer = new AudioBuffer(); + + while (!Thread.currentThread().isInterrupted() && !this.exit) { + SourceDataLine line = null; + try { + while (!Thread.currentThread().isInterrupted() && !this.exit) { + if (this.stopAudio) { + for (int i = 0; i < STREAMS; i++) { + try { + streams[i].close(); + } + catch (final Exception e) { + LOGGER.error(e.getMessage(), e); + } + } + this.musicPlaying = false; + this.stopAudio = false; + } + if (this.stopMusic) { + if (this.musicPlaying && musicFading == 0) { + musicFading = System.currentTimeMillis(); + } + this.stopMusic = false; + } + if (musicFading > 0) { + final int ms = (int) (System.currentTimeMillis() - musicFading); + if (ms >= MUSIC_FADING_MS) { + streams[0].close(); + this.musicPlaying = false; + musicFadeVol = 1.0; + musicFading = 0; + } + else { + musicFadeVol = 1.0 - ((double) ms / MUSIC_FADING_MS); + } + } + else { + musicFadeVol = 1.0; + } + + // Load music + { + if (streams[0].inStream == null) { + final AudioData musicQueued = this.musicQueue; + if (musicQueued != null) { + if (this.setupStream(musicQueued, streams, 0)) { + this.musicPlaying = true; + } + this.musicQueue = null; + } + } + } + + // Load new sfx streams if available + for (int i = 1; i < STREAMS; i++) { + if (streams[i].inStream == null) { + final AudioData data = this.queue.poll(); + if (data == null) { + break; + } + else { + this.setupStream(data, streams, i); + } + } + } + + // Manage streams + + int streamsActive = 0; + for (int i = 0; i < STREAMS; i++) { + if (streams[i].inStream != null) { + streamsActive++; + } + buffers[i].size = 0; + } + + this.playing = streamsActive > 0; + if (!this.playing) { + // No stream active + + if (line != null) { + // Close line + line.drain(); + line.stop(); + line.close(); + line = null; + + LOGGER.debug("Line closed"); + } + + Thread.sleep(5); + continue; + } + + if (line == null) { + // Init line + for (int i = 0; i < STREAMS; i++) { + if (streams[i].info != null) { + line = (SourceDataLine) AudioSystem.getLine(streams[i].info); + if (line != null) { + line.open(streams[i].info.getFormats()[0]); + line.start(); + LOGGER.debug("Line opened"); + this.applyOptions(line, true); + } + break; + } + } + } + if (line == null) { + Thread.sleep(5); + continue; + } + + this.applyOptions(line, false); + + // Read stream buffers + + boolean readMore; + do { + readMore = false; + for (int i = 0; i < STREAMS; i++) { + if (streams[i].convertedAudio != null && buffers[i].size < buffers[i].bytes.length) { + final int read = streams[i].convertedAudio.read(buffers[i].bytes, buffers[i].size, buffers[i].bytes.length - buffers[i].size); + if (read == -1) { + streams[i].close(); + if (i == 0) { + this.musicPlaying = false; + } + + if (LOGGER.isDebugEnabled()) { + LOGGER.debug("End of stream " + i); + } + } + else { + buffers[i].size += read; + readMore |= buffers[i].size < buffers[i].bytes.length; + + //if (LOGGER.isDebugEnabled()) { + // LOGGER.debug("Read buffer " + i + " " + buffers[i].size); + //} + } + } + } + } while (readMore && !this.exit); + + for (int i = 0; i < STREAMS; i++) { + if (i == 0) { + vols[i] = this.musicVolume * musicFadeVol; + } + else { + vols[i] = this.sfxVolume; + } + } + + int val; + int samplesAvailable; + mixBuffer.size = mixBuffer.bytes.length; + for (int n = 0; n < mixBuffer.bytes.length; n += 2) { + // Collect samples, factor in volume at this point + samplesAvailable = 0; + for (int i = 0; i < STREAMS; i++) { + if (buffers[i].size > n + 1) { + mix[samplesAvailable++] = (int) (((buffers[i].bytes[n] & 0xFF) | (short) (buffers[i].bytes[n + 1] << 8)) * vols[i]); + } + } + + // Mix samples + val = 0; + if (samplesAvailable == 1) { + val = mix[0]; + } + else if (samplesAvailable > 1) { + val = mix[0] + 32768; + for (int i = 1; i < samplesAvailable; i++) { + mix[i] += 32768; + + if ((val < 32768) || (mix[i] < 32768)) { + val = val * mix[i] / 32768; + } + else { + val = 2 * (val + mix[i]) - (val * mix[i]) / 32768 - 65536; + } + + if (val == 65536) { + val = 65535; + } + } + val -= 32768; + } + + // Write sample + if (samplesAvailable > 0) { + mixBuffer.bytes[n] = (byte) (val & 0xFF); + mixBuffer.bytes[n + 1] = (byte) (val >> 8); + } + else { + mixBuffer.size = n; + break; + } + } + + // Write mix buffer + if (mixBuffer.size > 0) { + //LOGGER.debug("Write " + mixBuffer.size); + line.write(mixBuffer.bytes, 0, mixBuffer.size); + } + + this.notStartedAudioCnt = 0; + } + } + catch (final InterruptedException exc) { + LOGGER.info(exc.getClass().getName()); + break; + } + catch (final Exception exc) { + LOGGER.error(exc.getMessage(), exc); + + if (++this.notStartedAudioCnt > 5) { + this.notStartedAudioCnt = 0; + this.exit = true; + LOGGER.error("Too many audio playback errors, shutting down audio player."); + } + } + finally { + if (line != null) { + line.close(); + line = null; + LOGGER.debug("Line closed"); + } + for (int i = 0; i < STREAMS; i++) { + try { + streams[i].close(); + } + catch (final Exception e) { + LOGGER.error(e.getMessage(), e); + } + } + this.musicPlaying = false; + } + } + + this.playing = false; + LOGGER.debug("end"); + } + + private boolean setupStream(final AudioData data, final AudioPlayerStream[] streams, final int idx) throws Exception { + try { + streams[idx].inStream = data.inputStream(); + streams[idx].inAudio = AudioSystem.getAudioInputStream(streams[idx].inStream); + + final AudioFormat inFormat = streams[idx].inAudio.getFormat(); + final int ch = inFormat.getChannels(); + final float rate = inFormat.getSampleRate(); + final AudioFormat outFormat = new AudioFormat(AudioFormat.Encoding.PCM_SIGNED, rate, 16, ch, ch * 2, rate, false); + streams[idx].info = new Info(SourceDataLine.class, outFormat); + streams[idx].convertedAudio = AudioSystem.getAudioInputStream(outFormat, streams[idx].inAudio); + + if (LOGGER.isDebugEnabled()) { + LOGGER.debug("Start stream " + idx + " " + data.name() + ": in " + inFormat + "; out " + outFormat); + } + + return true; + } + catch (final Exception ex) { + LOGGER.error(ex.getMessage(), ex); + streams[idx].close(); + return false; + } + } + + @Override + public void stopAudio() { + try { + this.queue.clear(); + this.stopAudio = true; + } + catch (final Exception exc) { + // + } + } + + @Override + public void updateAudioOptions(final AudioStreamOptions options) { + this.sfxMute = !options.isEnable(); + this.sfxVolume = this.sfxMute ? 0.0 : options.getVolume(); + } + + public void updateMusicAudioOptions(final AudioStreamOptions options) { + this.musicMute = !options.isEnable(); + if (this.musicMute) { + this.musicVolume = 0.0; + } + else { + this.musicVolume = options.getVolume(); + } + } + + private void applyOptions(final SourceDataLine line, final boolean force) { + if (force) { + final FloatControl control = (FloatControl) line.getControl(FloatControl.Type.MASTER_GAIN); + control.setValue((float) Math.log10(1.0) * 20.0f); + } + if (force || this.curMute != (this.sfxMute && this.musicMute)) { + this.curMute = (this.sfxMute && this.musicMute); + + final BooleanControl control = (BooleanControl) line.getControl(BooleanControl.Type.MUTE); + control.setValue(this.curMute); + } + } + + @Override + public boolean isPlaying() { + return this.playing; + } + + @Override + public BlockingQueue getQueue() { + return this.queue; + } + + public AudioData getMusicQueue() { + return this.musicQueue; + } + + public void setMusicQueue(final AudioData musicQueue) { + this.musicQueue = musicQueue; + } + + public boolean isMusicPlaying() { + return this.musicPlaying; + } + + public boolean isStopMusic() { + return this.stopMusic; + } + + public void setStopMusic(final boolean stopMusic) { + this.stopMusic = stopMusic; + } + + @Override + public void exit() { + this.exit = true; + } + + private static class AudioPlayerStream implements AutoCloseable { + + private InputStream inStream; + + private AudioInputStream inAudio; + + private AudioInputStream convertedAudio; + + private Info info; + + @Override + public void close() throws Exception { + if (this.convertedAudio != null) { + try { + this.convertedAudio.close(); + } + catch (final Exception exc) { + LOGGER.error(exc.getMessage(), exc); + } + this.convertedAudio = null; + } + if (this.inAudio != null) { + try { + this.inAudio.close(); + } + catch (final Exception exc) { + LOGGER.error(exc.getMessage(), exc); + } + this.inAudio = null; + } + if (this.inStream != null) { + try { + this.inStream.close(); + } + catch (final Exception exc) { + LOGGER.error(exc.getMessage(), exc); + } + this.inStream = null; + } + this.info = null; + } + } + + private static class AudioBuffer { + + byte[] bytes = new byte[2048]; + + int size; + + } + +} diff --git a/src/main/java/de/extio/game_engine/audio/AudioPlayerSingleStream.java b/src/main/java/de/extio/game_engine/audio/AudioPlayerSingleStream.java new file mode 100644 index 0000000..09265d4 --- /dev/null +++ b/src/main/java/de/extio/game_engine/audio/AudioPlayerSingleStream.java @@ -0,0 +1,186 @@ +package de.extio.game_engine.audio; + +import java.io.InputStream; +import java.util.concurrent.BlockingQueue; +import java.util.concurrent.LinkedBlockingQueue; + +import javax.sound.sampled.AudioFormat; +import javax.sound.sampled.AudioInputStream; +import javax.sound.sampled.AudioSystem; +import javax.sound.sampled.BooleanControl; +import javax.sound.sampled.DataLine.Info; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import javax.sound.sampled.FloatControl; +import javax.sound.sampled.SourceDataLine; + +import de.extio.game_engine.audio.AudioOptions.AudioStreamOptions; + +final class AudioPlayerSingleStream implements AudioPlayer { + + private static final Logger LOGGER = LoggerFactory.getLogger(AudioPlayerSingleStream.class); + + private final BlockingQueue queue = new LinkedBlockingQueue<>(5); + + private volatile boolean stopAudio; + + private volatile boolean playing; + + private volatile double volume; + + private volatile boolean mute; + + private volatile boolean exit; + + private double curVolume; + + private boolean curMute; + + private int notStartedAudioCnt; + + public AudioPlayerSingleStream() { + } + + @Override + public void run() { + LOGGER.debug("start"); + + while (!Thread.currentThread().isInterrupted() && !this.exit) { + try { + final AudioData data = this.queue.peek(); + if (data == null) { + this.playing = false; + Thread.sleep(5); + continue; + } + + this.playing = true; + this.queue.remove(); + + this.stopAudio = false; + try (final InputStream stream = data.inputStream(); final AudioInputStream in = AudioSystem.getAudioInputStream(stream)) { + final AudioFormat inFormat = in.getFormat(); + final int ch = inFormat.getChannels(); + final float rate = inFormat.getSampleRate(); + final AudioFormat outFormat = new AudioFormat(AudioFormat.Encoding.PCM_SIGNED, rate, 16, ch, ch * 2, rate, false); + final Info info = new Info(SourceDataLine.class, outFormat); + LOGGER.debug("Start {}: in {}; out {}", data.name(), inFormat, outFormat); + + try (final SourceDataLine line = (SourceDataLine) AudioSystem.getLine(info); + final AudioInputStream inConverted = AudioSystem.getAudioInputStream(outFormat, in)) { + + if (line != null) { + line.open(outFormat); + line.start(); + + boolean fading = false; + double fadingVol = 0.0; + this.notStartedAudioCnt = 0; + this.applyOptions(line, true); + + final byte[] buffer = new byte[4096]; + for (int n = 0; n != -1; n = inConverted.read(buffer, 0, buffer.length)) { + line.write(buffer, 0, n); + + if (Thread.currentThread().isInterrupted() || this.exit) { + break; + } + else if (this.stopAudio) { + if (!fading) { + fading = true; + fadingVol = this.volume; + } + + fadingVol -= 0.01; + if (fadingVol <= 0.0) { + break; + } + + final FloatControl control = (FloatControl) line.getControl(FloatControl.Type.MASTER_GAIN); + control.setValue((float) Math.log10(fadingVol) * 20.0f); + } + else { + this.applyOptions(line, false); + } + } + + line.drain(); + line.stop(); + } + } + finally { + LOGGER.debug("Stop {}", data.name()); + } + } + } + catch (final Exception exc) { + if (exc instanceof InterruptedException) { + LOGGER.info(exc.getClass().getName()); + break; + } + LOGGER.error(exc.getMessage(), exc); + + if (++this.notStartedAudioCnt > 5) { + this.notStartedAudioCnt = 0; + this.exit = true; + LOGGER.error("Too many audio playback errors, shutting down audio player."); + } + } + } + + this.playing = false; + LOGGER.debug("end"); + } + + @Override + public void stopAudio() { + try { + this.queue.clear(); + this.stopAudio = true; + } + catch (final Exception exc) { + // + } + } + + @Override + public void updateAudioOptions(final AudioStreamOptions options) { + this.volume = options.getVolume(); + this.mute = !options.isEnable(); + } + + private void applyOptions(final SourceDataLine line, final boolean force) { + if (force || this.volume != this.curVolume) { + this.curVolume = this.volume; + + if (this.curVolume >= 0.0 && this.curVolume <= 1.0) { + final FloatControl control = (FloatControl) line.getControl(FloatControl.Type.MASTER_GAIN); + control.setValue((float) Math.log10(this.curVolume) * 20.0f); + } + } + if (force || this.curMute != this.mute) { + this.curMute = this.mute; + + final BooleanControl control = (BooleanControl) line.getControl(BooleanControl.Type.MUTE); + control.setValue(this.curMute); + } + } + + @Override + public boolean isPlaying() { + return this.playing; + } + + @Override + public BlockingQueue getQueue() { + return this.queue; + } + + @Override + public void exit() { + this.exit = true; + } + +} diff --git a/src/main/java/de/extio/game_engine/audio/AudioStrategy.java b/src/main/java/de/extio/game_engine/audio/AudioStrategy.java new file mode 100644 index 0000000..b45fdbe --- /dev/null +++ b/src/main/java/de/extio/game_engine/audio/AudioStrategy.java @@ -0,0 +1,23 @@ +package de.extio.game_engine.audio; + +import java.util.Collection; + +import de.extio.game_engine.resource.StaticResource; + +interface AudioStrategy { + + void start(); + + void stop(); + + void run(); + + void queueSfx(Collection audioFiles); + + void clearSfx(); + + void queueMusic(Collection audioFiles); + + void clearMusic(); + +} diff --git a/src/main/java/de/extio/game_engine/audio/AudioStrategyJavaMixer.java b/src/main/java/de/extio/game_engine/audio/AudioStrategyJavaMixer.java new file mode 100644 index 0000000..c0b0430 --- /dev/null +++ b/src/main/java/de/extio/game_engine/audio/AudioStrategyJavaMixer.java @@ -0,0 +1,243 @@ +package de.extio.game_engine.audio; + +import java.util.ArrayDeque; +import java.util.ArrayList; +import java.util.Collection; +import java.util.Collections; +import java.util.Deque; +import java.util.Iterator; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Random; +import java.util.Set; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import de.extio.game_engine.resource.StaticResource; +import de.extio.game_engine.util.rng.XorShift128Random; + +final class AudioStrategyJavaMixer implements AudioStrategy { + + private final static Logger LOGGER = LoggerFactory.getLogger(AudioStrategyJavaMixer.class); + + private final static int SFX_PLAYERS = 3; + + private final static int MUSIC_INITIAL_DELAY_MS = 1000; + + private final AudioOptions clientEngineData; + + private final AudioLoader audioLoader; + + private final Random random = new XorShift128Random(); + + private final List sfxAudioPlayers = new ArrayList<>(SFX_PLAYERS); + + private final Set sfxQueue = new LinkedHashSet<>(); + + private final List sfxPlayersBySize = new ArrayList<>(SFX_PLAYERS); + + private AudioThread musicAudioPlayer; + + private final List musicFiles = new ArrayList<>(); + + private StaticResource musicPlayOpener = null; + + private final Deque musicPlayQueue = new ArrayDeque<>(); + + private long musicFirstQueued; + + public AudioStrategyJavaMixer(final AudioOptions clientEngineData, final AudioLoader audioLoader) { + this.clientEngineData = clientEngineData; + this.audioLoader = audioLoader; + } + + @Override + public void start() { + { + final var musicAudioPlayer = new AudioPlayerSingleStream(); + final var musicThread = Thread.ofPlatform() + .name("Audio Music") + .daemon(true) + .start(musicAudioPlayer); + musicThread.start(); + this.musicAudioPlayer = new AudioThread(musicThread, musicAudioPlayer); + } + + for (int i = 0; i < SFX_PLAYERS; i++) { + final AudioPlayer audioPlayer = new AudioPlayerSingleStream(); + final var audioThread = Thread.ofPlatform() + .name("Audio SFX " + i) + .daemon(true) + .start(audioPlayer); + + this.sfxAudioPlayers.add(new AudioThread(audioThread, audioPlayer)); + this.sfxPlayersBySize.add(new AudioPlayerReference(audioPlayer)); + } + } + + @Override + public void stop() { + this.musicAudioPlayer.audioPlayer().exit(); + this.musicAudioPlayer.thread().interrupt(); + + for (int i = 0; i < SFX_PLAYERS; i++) { + this.sfxAudioPlayers.get(i).audioPlayer.exit(); + this.sfxAudioPlayers.get(i).thread.interrupt(); + } + } + + @Override + public void run() { + // Music + + if (!this.clientEngineData.getMusicOptions().isEnable()) { + if (this.musicAudioPlayer.audioPlayer().isPlaying()) { + this.musicAudioPlayer.audioPlayer().stopAudio(); + } + } + else { + if (this.musicPlayQueue.isEmpty() && (this.musicPlayOpener != null || !this.musicFiles.isEmpty())) { + if (this.musicPlayOpener != null) { + this.musicPlayQueue.add(this.musicPlayOpener); + this.musicPlayOpener = null; + } + + final List music = new ArrayList<>(this.musicFiles); + Collections.shuffle(music, this.random); + this.musicPlayQueue.addAll(music); + + if (LOGGER.isDebugEnabled()) { + LOGGER.debug("Music play queue: " + this.musicPlayQueue); + } + } + + if (this.musicAudioPlayer.audioPlayer().isPlaying() || !this.musicPlayQueue.isEmpty()) { + this.musicAudioPlayer.audioPlayer().updateAudioOptions(this.clientEngineData.getMusicOptions()); + } + + if (!this.musicAudioPlayer.audioPlayer().isPlaying() && + !this.musicPlayQueue.isEmpty() && + System.currentTimeMillis() - this.musicFirstQueued >= MUSIC_INITIAL_DELAY_MS) { + final StaticResource nextFile = this.musicPlayQueue.pop(); + + this.audioLoader.getQueue().offer(new AudioLoaderRequest(nextFile, false, stream -> this.musicAudioPlayer.audioPlayer().getQueue().offer(new AudioData(nextFile.resourceName(), stream)))); + } + } + + // SFX + + if (!this.sfxQueue.isEmpty()) { + for (int i = 0; i < SFX_PLAYERS; i++) { + this.sfxAudioPlayers.get(i).audioPlayer().updateAudioOptions(this.clientEngineData.getSfxOptions()); + this.sfxPlayersBySize.get(i).queueSize = this.sfxPlayersBySize.get(i).audioPlayer.getQueue().size(); + } + Collections.sort(this.sfxPlayersBySize); + + while (!this.sfxQueue.isEmpty()) { + int i = 0; + final Iterator it = this.sfxQueue.iterator(); + do { + this.sfxPlayersBySize.get(i).queueSize++; + + final AudioPlayer player = this.sfxPlayersBySize.get(i).audioPlayer; + final StaticResource nextFile = it.next(); + it.remove(); + + this.audioLoader.getQueue().offer(new AudioLoaderRequest(nextFile, true, stream -> player.getQueue().offer(new AudioData(nextFile.resourceName(), stream)))); + } while (it.hasNext() && ++i < this.sfxPlayersBySize.size() && this.sfxPlayersBySize.get(i - 1).queueSize > this.sfxPlayersBySize.get(i).queueSize); + } + } + } + + @Override + public void queueSfx(final Collection audioFile) { + if (!this.clientEngineData.getSfxOptions().isEnable()) { + if (LOGGER.isDebugEnabled()) { + LOGGER.debug("Not queued audio file (audio disabled): " + audioFile); + } + } + else if (this.sfxQueue.addAll(audioFile)) { + if (LOGGER.isDebugEnabled()) { + LOGGER.debug("Queued audio file: " + audioFile); + } + } + else { + if (LOGGER.isDebugEnabled()) { + LOGGER.debug("Not queued audio file (already queued): " + audioFile); + } + } + } + + @Override + public void clearSfx() { + this.sfxQueue.clear(); + } + + @Override + public void queueMusic(final Collection audioFiles) { + StaticResource opener = null; + final List musicAudioFiles = new ArrayList<>(audioFiles.size() - 1); + boolean first = true; + for (final StaticResource audioFile : audioFiles) { + if (first) { + opener = audioFile; + first = false; + } + else { + musicAudioFiles.add(audioFile); + } + } + + if (musicAudioFiles.equals(this.musicFiles)) { + LOGGER.debug("The selected tracks are already running"); + return; + } + + if (this.musicAudioPlayer.audioPlayer().isPlaying()) { + this.musicFirstQueued = System.currentTimeMillis(); + } + + this.clearMusic(); + this.musicPlayOpener = opener; + this.musicFiles.addAll(musicAudioFiles); + + if (LOGGER.isDebugEnabled()) { + LOGGER.debug("Queued music: " + this.musicFiles); + } + } + + @Override + public void clearMusic() { + this.musicFiles.clear(); + this.musicPlayQueue.clear(); + this.musicPlayOpener = null; + + this.musicAudioPlayer.audioPlayer().stopAudio(); + + if (LOGGER.isDebugEnabled()) { + LOGGER.debug("Stopped and cleared music player"); + } + } + + static class AudioPlayerReference implements Comparable { + + private int queueSize; + + private final AudioPlayer audioPlayer; + + AudioPlayerReference(final AudioPlayer audioPlayer) { + this.audioPlayer = audioPlayer; + } + + @Override + public int compareTo(final AudioPlayerReference o) { + return Integer.compare(this.queueSize, o.queueSize); + } + + } + + private static record AudioThread(Thread thread, AudioPlayer audioPlayer) { + } + +} diff --git a/src/main/java/de/extio/game_engine/audio/AudioStrategySoftwareMixing.java b/src/main/java/de/extio/game_engine/audio/AudioStrategySoftwareMixing.java new file mode 100644 index 0000000..76fdc1f --- /dev/null +++ b/src/main/java/de/extio/game_engine/audio/AudioStrategySoftwareMixing.java @@ -0,0 +1,174 @@ +package de.extio.game_engine.audio; + +import java.util.ArrayDeque; +import java.util.ArrayList; +import java.util.Collection; +import java.util.Collections; +import java.util.Deque; +import java.util.Iterator; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Optional; +import java.util.Random; +import java.util.Set; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import de.extio.game_engine.resource.StaticResource; +import de.extio.game_engine.util.rng.XorShift128Random; + +final class AudioStrategySoftwareMixing implements AudioStrategy { + + private final static Logger LOGGER = LoggerFactory.getLogger(AudioStrategySoftwareMixing.class); + + private final static int MUSIC_INITIAL_DELAY_MS = 1000; + + private final AudioOptions clientEngineData; + + private final AudioLoader audioLoader; + + private final Random random = new XorShift128Random(); + + private AudioThread audioPlayer; + + private final Set sfxQueue = new LinkedHashSet<>(); + + private final List musicFiles = new ArrayList<>(); + + private final Deque musicPlayQueue = new ArrayDeque<>(); + + private long musicFirstQueued; + + public AudioStrategySoftwareMixing(final AudioOptions clientEngineData, final AudioLoader audioLoader) { + this.clientEngineData = clientEngineData; + this.audioLoader = audioLoader; + } + + @Override + public void start() { + final var multiStreamPlayer = new AudioPlayerMultiStream(); + final var multiStreamThread = Thread.ofPlatform() + .name("Audio MultiStream") + .daemon(true) + .start(multiStreamPlayer); + this.audioPlayer = new AudioThread(multiStreamThread, multiStreamPlayer); + } + + @Override + public void stop() { + this.audioPlayer.audioPlayer().exit(); + this.audioPlayer.thread().interrupt(); + } + + @Override + public void run() { + if (this.audioPlayer.audioPlayer().isPlaying() || !this.sfxQueue.isEmpty() || !this.musicPlayQueue.isEmpty() || !this.musicFiles.isEmpty()) { + this.audioPlayer.audioPlayer().updateAudioOptions(this.clientEngineData.getSfxOptions()); + this.audioPlayer.audioPlayer().updateMusicAudioOptions(this.clientEngineData.getMusicOptions()); + } + + // Music + + if (!this.clientEngineData.getMusicOptions().isEnable()) { + if (this.audioPlayer.audioPlayer().isMusicPlaying()) { + this.audioPlayer.audioPlayer().setStopMusic(true); + } + } + else { + if (this.musicPlayQueue.isEmpty() && !this.musicFiles.isEmpty()) { + final List music = new ArrayList<>(this.musicFiles); + Collections.shuffle(music, this.random); + this.musicPlayQueue.addAll(music); + + if (LOGGER.isDebugEnabled()) { + LOGGER.debug("Music play queue: " + this.musicPlayQueue); + } + } + if (!this.musicPlayQueue.isEmpty() && this.audioPlayer.audioPlayer().getMusicQueue() == null && !this.audioPlayer.audioPlayer().isMusicPlaying() && (System.currentTimeMillis() - this.musicFirstQueued) >= MUSIC_INITIAL_DELAY_MS) { + final StaticResource nextFile = this.musicPlayQueue.pop(); + this.musicFirstQueued = System.currentTimeMillis(); + this.audioLoader.getQueue().offer(new AudioLoaderRequest(nextFile, false, stream -> this.audioPlayer.audioPlayer().setMusicQueue(new AudioData(nextFile.resourceName(), stream)))); + } + } + + // Sfx + + if (!this.sfxQueue.isEmpty()) { + final Iterator it = this.sfxQueue.iterator(); + while (it.hasNext()) { + final StaticResource nextFile = it.next(); + this.audioLoader.getQueue().offer(new AudioLoaderRequest(nextFile, true, stream -> this.audioPlayer.audioPlayer().getQueue().offer(new AudioData(nextFile.resourceName(), stream)))); + } + this.sfxQueue.clear(); + } + } + + @Override + public void clearSfx() { + this.sfxQueue.clear(); + } + + @Override + public void queueSfx(final Collection audioFiles) { + if (!this.clientEngineData.getSfxOptions().isEnable()) { + if (LOGGER.isDebugEnabled()) { + LOGGER.debug("Not queued audio file (audio disabled): " + audioFiles); + } + } + else if (this.sfxQueue.addAll(audioFiles)) { + if (LOGGER.isDebugEnabled()) { + LOGGER.debug("Queued audio file: " + audioFiles); + } + } + else { + if (LOGGER.isDebugEnabled()) { + LOGGER.debug("Not queued audio file (already queued): " + audioFiles); + } + } + } + + @Override + public void queueMusic(final Collection audioFiles) { + Optional opener = null; + final List musicAudioFiles = new ArrayList<>(Math.min(1, audioFiles.size() - 1)); + for (final StaticResource audioFile : audioFiles) { + if (opener == null) { + opener = Optional.ofNullable(audioFile); + } + else { + musicAudioFiles.add(audioFile); + } + } + + if (musicAudioFiles.equals(this.musicFiles)) { + this.musicFirstQueued = System.currentTimeMillis(); + } + + this.clearMusic(); + if (opener != null && opener.isPresent()) { + this.musicPlayQueue.offer(opener.get()); + } + this.musicFiles.addAll(musicAudioFiles); + + if (LOGGER.isDebugEnabled()) { + LOGGER.debug("Queued music: Opener: " + (opener != null ? opener.orElse(null) : null) + " Files: " + this.musicFiles); + } + } + + @Override + public void clearMusic() { + this.musicFiles.clear(); + this.musicPlayQueue.clear(); + + this.audioPlayer.audioPlayer().setStopMusic(true); + + if (LOGGER.isDebugEnabled()) { + LOGGER.debug("Stopped and cleared music"); + } + } + + private static record AudioThread(Thread thread, AudioPlayerMultiStream audioPlayer) { + } + +} diff --git a/src/main/java/de/extio/game_engine/resource/StaticResource.java b/src/main/java/de/extio/game_engine/resource/StaticResource.java index 8bf9ee2..bf5509c 100644 --- a/src/main/java/de/extio/game_engine/resource/StaticResource.java +++ b/src/main/java/de/extio/game_engine/resource/StaticResource.java @@ -4,4 +4,13 @@ import java.util.List; public record StaticResource(List pathSegments, String resourceName) { + @Override + public final String toString() { + if (this.pathSegments == null || this.pathSegments.isEmpty()) { + return this.resourceName; + } else { + return String.join("/", this.pathSegments) + "/" + this.resourceName; + } + } + } diff --git a/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports b/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports index 8de1e28..eca9fd4 100644 --- a/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports +++ b/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports @@ -6,3 +6,4 @@ de.extio.game_engine.storage.StorageServiceAutoConfiguration de.extio.game_engine.module.ModuleServiceAutoConfiguration de.extio.game_engine.i18n.LocalizationAutoConfiguration de.extio.game_engine.resource.StaticResourceAutoConfiguration +de.extio.game_engine.audio.AudioAutoConfiguration