diff --git a/client/src/main/java/nl/andrewlalis/aos_client/ChatManager.java b/client/src/main/java/nl/andrewlalis/aos_client/ChatManager.java index 031da39..9c2b105 100644 --- a/client/src/main/java/nl/andrewlalis/aos_client/ChatManager.java +++ b/client/src/main/java/nl/andrewlalis/aos_client/ChatManager.java @@ -1,5 +1,6 @@ package nl.andrewlalis.aos_client; +import nl.andrewlalis.aos_client.sound.SoundManager; import nl.andrewlalis.aos_core.net.chat.ChatMessage; import nl.andrewlalis.aos_core.net.chat.ChatType; import nl.andrewlalis.aos_core.net.chat.PlayerChatMessage; diff --git a/client/src/main/java/nl/andrewlalis/aos_client/Client.java b/client/src/main/java/nl/andrewlalis/aos_client/Client.java index 5363d44..af5ccb6 100644 --- a/client/src/main/java/nl/andrewlalis/aos_client/Client.java +++ b/client/src/main/java/nl/andrewlalis/aos_client/Client.java @@ -1,5 +1,6 @@ package nl.andrewlalis.aos_client; +import nl.andrewlalis.aos_client.sound.SoundManager; import nl.andrewlalis.aos_client.view.GameFrame; import nl.andrewlalis.aos_client.view.GamePanel; import nl.andrewlalis.aos_core.model.Player; diff --git a/client/src/main/java/nl/andrewlalis/aos_client/SoundManager.java b/client/src/main/java/nl/andrewlalis/aos_client/SoundManager.java deleted file mode 100644 index 67aa2e4..0000000 --- a/client/src/main/java/nl/andrewlalis/aos_client/SoundManager.java +++ /dev/null @@ -1,118 +0,0 @@ -package nl.andrewlalis.aos_client; - -import nl.andrewlalis.aos_core.geom.Vec2; -import nl.andrewlalis.aos_core.model.Player; -import nl.andrewlalis.aos_core.net.data.Sound; -import nl.andrewlalis.aos_core.net.data.SoundType; - -import javax.sound.sampled.*; -import java.io.ByteArrayInputStream; -import java.io.ByteArrayOutputStream; -import java.io.IOException; -import java.io.InputStream; -import java.util.ArrayList; -import java.util.HashMap; -import java.util.List; -import java.util.Map; - -public class SoundManager { - private static final float HEARING_RANGE = 50.0f; - private final Map> soundData = new HashMap<>(); - private final Map clipIndexes = new HashMap<>(); - - public void play(List sounds, Player player) { - for (Sound sound : sounds) { - this.play(sound, player); - } - } - public void play(List sounds) { - for (Sound sound : sounds) { - this.play(sound, null); - } - } - - public void play(Sound sound) { - this.play(sound, null); - } - - public void play(Sound sound, Player player) { - var clip = this.getClip(sound.getType()); - if (clip == null) { - return; - } - clip.setFramePosition(0); - float v = sound.getVolume(); - if (player != null && sound.getPosition() != null) { - float dist = player.getPosition().dist(sound.getPosition()); - v *= (Math.max(HEARING_RANGE - dist, 0) / HEARING_RANGE); - } - if (v <= 0.0f) return; - if (player != null && player.getTeam() != null && sound.getPosition() != null) { - setPan(clip, player.getPosition(), sound.getPosition(), player.getTeam().getOrientation()); - } - setVolume(clip, v); - clip.start(); - } - - private void setVolume(Clip clip, float volume) { - volume = Math.max(Math.min(volume, 1.0f), 0.0f); - FloatControl gainControl = (FloatControl) clip.getControl(FloatControl.Type.MASTER_GAIN); - gainControl.setValue(20f * (float) Math.log10(volume)); - } - - private void setPan(Clip clip, Vec2 playerPos, Vec2 soundPos, Vec2 playerOrientation) { - Vec2 soundDir = soundPos - .sub(playerPos) - .rotate(playerOrientation.perp().angle()) - .unit(); - float pan = Math.max(Math.min(soundDir.dot(Vec2.RIGHT), 1.0f), -1.0f); - if (Float.isNaN(pan)) pan = 0f; - FloatControl panControl = (FloatControl) clip.getControl(FloatControl.Type.PAN); - panControl.setValue(pan); - } - - private Clip getClip(SoundType soundType) { - String sound = soundType.getSoundName(); - var clips = this.soundData.get(sound); - if (clips == null) { - InputStream is = Client.class.getResourceAsStream("/nl/andrewlalis/aos_client/sound/" + sound); - if (is == null) { - System.err.println("Could not load sound: " + sound); - return null; - } - try { - ByteArrayOutputStream bos = new ByteArrayOutputStream(); - is.transferTo(bos); - byte[] data = bos.toByteArray(); - clips = new ArrayList<>(soundType.getClipBufferCount()); - for (int i = 0; i < soundType.getClipBufferCount(); i++) { - var ais = AudioSystem.getAudioInputStream(new ByteArrayInputStream(data)); - var clip = AudioSystem.getClip(); - clip.open(ais); - ais.close(); - clips.add(clip); - } - this.soundData.put(sound, clips); - this.clipIndexes.put(sound, 0); - } catch (UnsupportedAudioFileException | LineUnavailableException | IOException e) { - e.printStackTrace(); - return null; - } - } - int index = this.clipIndexes.get(sound); - if (index >= soundType.getClipBufferCount()) { - index = 0; - } - Clip clip = clips.get(index); - this.clipIndexes.put(sound, index + 1); - return clip; - } - - public void close() { - for (var c : this.soundData.values()) { - for (var clip : c) { - clip.close(); - } - } - } -} diff --git a/client/src/main/java/nl/andrewlalis/aos_client/sound/AudioClip.java b/client/src/main/java/nl/andrewlalis/aos_client/sound/AudioClip.java new file mode 100644 index 0000000..5d2b384 --- /dev/null +++ b/client/src/main/java/nl/andrewlalis/aos_client/sound/AudioClip.java @@ -0,0 +1,50 @@ +package nl.andrewlalis.aos_client.sound; + +import nl.andrewlalis.aos_core.model.Player; +import nl.andrewlalis.aos_core.net.data.Sound; + +import javax.sound.sampled.AudioFormat; +import javax.sound.sampled.AudioInputStream; +import javax.sound.sampled.AudioSystem; +import javax.sound.sampled.UnsupportedAudioFileException; +import java.io.BufferedInputStream; +import java.io.IOException; +import java.io.InputStream; + +/** + * Simple container object for an in-memory audio clip. The contents of this + * clip are played using a {@link javax.sound.sampled.SourceDataLine} during + * runtime. + * @see SoundManager#play(Sound, Player) + */ +public class AudioClip { + private final AudioFormat format; + private final byte[] samples; + + /** + * Constructs a new audio clip, using the given resource name to load audio + * data from a classpath resource. + * @param resource The name of the classpath resource to load. + * @throws IOException If the clip could not be loaded. + */ + public AudioClip(String resource) throws IOException { + try { + InputStream inputStream = AudioClip.class.getResourceAsStream(resource); + if (inputStream == null) throw new IOException("Could not get resource as stream: " + resource); + AudioInputStream in = AudioSystem.getAudioInputStream(new BufferedInputStream(inputStream)); + this.format = in.getFormat(); + this.samples = in.readAllBytes(); + } catch (UnsupportedAudioFileException e) { + e.printStackTrace(); + throw new IOException(e); + } + } + + public AudioFormat getFormat() { + return format; + } + + public byte[] getSamples() { + return samples; + } +} diff --git a/client/src/main/java/nl/andrewlalis/aos_client/sound/SoundManager.java b/client/src/main/java/nl/andrewlalis/aos_client/sound/SoundManager.java new file mode 100644 index 0000000..0f1f723 --- /dev/null +++ b/client/src/main/java/nl/andrewlalis/aos_client/sound/SoundManager.java @@ -0,0 +1,155 @@ +package nl.andrewlalis.aos_client.sound; + +import nl.andrewlalis.aos_core.geom.Vec2; +import nl.andrewlalis.aos_core.model.Player; +import nl.andrewlalis.aos_core.net.data.Sound; + +import javax.sound.sampled.AudioSystem; +import javax.sound.sampled.FloatControl; +import javax.sound.sampled.SourceDataLine; +import java.io.ByteArrayInputStream; +import java.io.IOException; +import java.io.InputStream; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; + +/** + * The sound manager is responsible for playing game sounds, using a cached set + * of audio clips that are loaded from sound resource files. Sounds are played + * using a thread pool. + */ +public class SoundManager { + /** + * The range in which a player can hear a sound. If it's further than this + * distance, the sound is not played. + */ + private static final float HEARING_RANGE = 50.0f; + + /** + * The size of the clip buffer used during audio playback, in seconds. + */ + private static final float CLIP_BUFFER_SIZE = 1.0f / 10.0f; + + private final ExecutorService soundPlayerThreadPool = Executors.newCachedThreadPool(); + private final Map audioClips = new HashMap<>(); + + /** + * Plays the given list of sounds from the player's perspective. + * @param sounds The list of sounds to play. + * @param player The player that's hearing the sounds. + */ + public void play(List sounds, Player player) { + for (Sound sound : sounds) { + this.play(sound, player); + } + } + + /** + * Plays a sound without any perspective, i.e. constant volume no matter + * where the player is. + * @param sound The sound to play. + */ + public void play(Sound sound) { + this.play(sound, null); + } + + /** + * Plays the given sound from the player's perspective. + * @param sound The sound to play. + * @param player The player that's hearing the sounds. + */ + public void play(Sound sound, Player player) { + final float volume = this.computeVolume(sound, player); + if (volume <= 0.0f) return; // Don't play the sound at all, if its volume is nothing. + final float pan = this.computePan(sound, player); + this.soundPlayerThreadPool.submit(() -> this.play(sound, pan, volume)); + } + + /** + * Plays the audio clip for a sound, using the given pan and volume settings. + *

+ * This method is blocking, and should ideally be called in a separate + * thread or submitted as a lambda expression to a thread pool. + *

+ * @param sound The sound to play. + * @param pan The pan setting, from -1.0 (left) to 1.0 (right). + * @param volume The volume, from 0.0 to 1.0. + */ + private void play(Sound sound, float pan, float volume) { + try { + AudioClip clip = this.getAudioClip(sound); + final int bufferSize = clip.getFormat().getFrameSize() * Math.round(clip.getFormat().getSampleRate() * CLIP_BUFFER_SIZE); + byte[] buffer = new byte[bufferSize]; + SourceDataLine line = AudioSystem.getSourceDataLine(clip.getFormat()); + line.open(clip.getFormat(), bufferSize); + line.start(); + + // Set pan. + FloatControl panControl = (FloatControl) line.getControl(FloatControl.Type.PAN); + panControl.setValue(pan); + // Set volume. + volume = Math.max(Math.min(volume, 1.0f), 0.0f); + FloatControl gainControl = (FloatControl) line.getControl(FloatControl.Type.MASTER_GAIN); + gainControl.setValue(20f * (float) Math.log10(volume)); + + InputStream source = new ByteArrayInputStream(clip.getSamples()); + int bytesRead = 0; + while (bytesRead != -1) { + bytesRead = source.read(buffer, 0, bufferSize); + if (bytesRead != -1) { + line.write(buffer, 0, bytesRead); + } + } + line.drain(); + line.close(); + } catch (Exception e) { + e.printStackTrace(); + } + } + + /** + * Computes the volume that a sound should be played at, from the given + * player's perspective. + * @param sound The sound to play. + * @param player The player that will be hearing the sound. + * @return The volume the sound should be played at, from 0.0 to 1.0. + */ + private float computeVolume(Sound sound, Player player) { + float v = sound.getVolume(); + if (player != null && sound.getPosition() != null) { + float dist = player.getPosition().dist(sound.getPosition()); + v *= (Math.max(HEARING_RANGE - dist, 0) / HEARING_RANGE); + } + return v; + } + + private float computePan(Sound sound, Player player) { + float pan = 0.0f; + if (player != null && player.getTeam() != null && sound.getPosition() != null) { + Vec2 soundDir = sound.getPosition() + .sub(player.getPosition()) + .rotate(player.getTeam().getOrientation().perp().angle()) + .unit(); + pan = Math.max(Math.min(soundDir.dot(Vec2.RIGHT), 1.0f), -1.0f); + if (Float.isNaN(pan)) pan = 0f; + } + return pan; + } + + private AudioClip getAudioClip(Sound sound) throws IOException { + String soundName = sound.getType().getSoundName(); + AudioClip clip = this.audioClips.get(soundName); + if (clip == null) { + clip = new AudioClip("/nl/andrewlalis/aos_client/sound/" + soundName); + this.audioClips.put(soundName, clip); + } + return clip; + } + + public void close() { + this.soundPlayerThreadPool.shutdown(); + } +}