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.
This commit is contained in:
extio-de
2025-12-24 21:31:25 +01:00
parent 1bc116374c
commit eb8d08f418
15 changed files with 1618 additions and 0 deletions

View File

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

View File

@@ -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<StaticResource> 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);
}

View File

@@ -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<StaticResource> standardMusic = new ArrayList<>();
private List<StaticResource> 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<StaticResource> 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<StaticResource> wrapOpener(final StaticResource opener, final List<StaticResource> audioFileNames) {
final List<StaticResource> result = new ArrayList<>(audioFileNames.size() + 1);
result.add(opener);
result.addAll(audioFileNames);
return result;
}
}

View File

@@ -0,0 +1,7 @@
package de.extio.game_engine.audio;
import java.io.InputStream;
public record AudioData(String name, InputStream inputStream) {
}

View File

@@ -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<AudioLoaderRequest> queue = new LinkedBlockingQueue<>(50);
private final Map<String, CacheEntry> 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<AudioLoaderRequest> getQueue() {
return this.queue;
}
private static class LRUCache<K, V> extends LinkedHashMap<K, V> {
public LRUCache() {
super((int) (CACHE_SIZE_LIMIT * 1.5), 0.75f, true);
}
@Override
protected boolean removeEldestEntry(final Map.Entry<K, V> eldest) {
return this.size() >= CACHE_SIZE_LIMIT;
}
}
private static record CacheEntry(byte[] data, AtomicLong lastAccessTime) {}
}

View File

@@ -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<InputStream> consumer;
private boolean useCache;
public AudioLoaderRequest(final StaticResource StaticResource, final boolean useCache, final Consumer<InputStream> consumer) {
this.StaticResource = StaticResource;
this.useCache = useCache;
this.consumer = consumer;
}
public Consumer<InputStream> getConsumer() {
return this.consumer;
}
public void setConsumer(final Consumer<InputStream> 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;
}
}

View File

@@ -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;
}
}
}

View File

@@ -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<AudioData> getQueue();
}

View File

@@ -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<AudioData> 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<AudioData> 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;
}
}

View File

@@ -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<AudioData> 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<AudioData> getQueue() {
return this.queue;
}
@Override
public void exit() {
this.exit = true;
}
}

View File

@@ -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<StaticResource> audioFiles);
void clearSfx();
void queueMusic(Collection<StaticResource> audioFiles);
void clearMusic();
}

View File

@@ -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<AudioThread> sfxAudioPlayers = new ArrayList<>(SFX_PLAYERS);
private final Set<StaticResource> sfxQueue = new LinkedHashSet<>();
private final List<AudioPlayerReference> sfxPlayersBySize = new ArrayList<>(SFX_PLAYERS);
private AudioThread musicAudioPlayer;
private final List<StaticResource> musicFiles = new ArrayList<>();
private StaticResource musicPlayOpener = null;
private final Deque<StaticResource> 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<StaticResource> 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<StaticResource> 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<StaticResource> 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<StaticResource> audioFiles) {
StaticResource opener = null;
final List<StaticResource> 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<AudioPlayerReference> {
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) {
}
}

View File

@@ -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<StaticResource> sfxQueue = new LinkedHashSet<>();
private final List<StaticResource> musicFiles = new ArrayList<>();
private final Deque<StaticResource> 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<StaticResource> 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<StaticResource> 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<StaticResource> 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<StaticResource> audioFiles) {
Optional<StaticResource> opener = null;
final List<StaticResource> 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) {
}
}

View File

@@ -4,4 +4,13 @@ import java.util.List;
public record StaticResource(List<String> 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;
}
}
}

View File

@@ -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