Cleaned up the sound system.

This commit is contained in:
Andrew Lalis 2021-07-06 20:34:15 +02:00
parent 6a6d367054
commit b3483ade7e
5 changed files with 207 additions and 118 deletions

View File

@ -1,5 +1,6 @@
package nl.andrewlalis.aos_client; 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.ChatMessage;
import nl.andrewlalis.aos_core.net.chat.ChatType; import nl.andrewlalis.aos_core.net.chat.ChatType;
import nl.andrewlalis.aos_core.net.chat.PlayerChatMessage; import nl.andrewlalis.aos_core.net.chat.PlayerChatMessage;

View File

@ -1,5 +1,6 @@
package nl.andrewlalis.aos_client; 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.GameFrame;
import nl.andrewlalis.aos_client.view.GamePanel; import nl.andrewlalis.aos_client.view.GamePanel;
import nl.andrewlalis.aos_core.model.Player; import nl.andrewlalis.aos_core.model.Player;

View File

@ -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<String, List<Clip>> soundData = new HashMap<>();
private final Map<String, Integer> clipIndexes = new HashMap<>();
public void play(List<Sound> sounds, Player player) {
for (Sound sound : sounds) {
this.play(sound, player);
}
}
public void play(List<Sound> 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();
}
}
}
}

View File

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

View File

@ -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<String, AudioClip> 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<Sound> 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.
* <p>
* This method is blocking, and should ideally be called in a separate
* thread or submitted as a lambda expression to a thread pool.
* </p>
* @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();
}
}