diff --git a/ts3-client/core/src/main/java/com/ts3client/audio/AudioBackend.java b/ts3-client/core/src/main/java/com/ts3client/audio/AudioBackend.java
index 04f4a27..ca10c8d 100644
--- a/ts3-client/core/src/main/java/com/ts3client/audio/AudioBackend.java
+++ b/ts3-client/core/src/main/java/com/ts3client/audio/AudioBackend.java
@@ -3,11 +3,12 @@ package com.ts3client.audio;
import com.ts3client.audio.opus.OpusCodec;
import com.ts3client.config.Settings;
import com.ts3client.sound.SoundPlayer;
+import com.ts3client.sound.WavSoundPlayer;
/**
- * A platform's audio stack: its devices, its libopus and its sound player. Injected into
- * the connection layer so the core stays independent of any concrete audio stack; the
- * voice pipelines on top are the same everywhere.
+ * A platform's audio stack: its devices and its libopus. Injected into the connection
+ * layer so the core stays independent of any concrete audio stack; the voice pipelines
+ * and the sound player on top are the same everywhere.
*/
public interface AudioBackend {
@@ -27,7 +28,9 @@ public interface AudioBackend {
}
/** Player for notification sounds (sound packs); shared by all connections. */
- SoundPlayer createSoundPlayer(Settings settings);
+ default SoundPlayer createSoundPlayer(Settings settings) {
+ return new WavSoundPlayer(io(), settings.outputDevice);
+ }
/** Human-readable codec/backend description, e.g. for an "about" line. */
String description();
diff --git a/ts3-client/desktop/src/main/java/com/ts3client/audio/desktop/WavSoundPlayer.java b/ts3-client/core/src/main/java/com/ts3client/sound/WavSoundPlayer.java
similarity index 82%
rename from ts3-client/desktop/src/main/java/com/ts3client/audio/desktop/WavSoundPlayer.java
rename to ts3-client/core/src/main/java/com/ts3client/sound/WavSoundPlayer.java
index 8ca742b..ac58df3 100644
--- a/ts3-client/desktop/src/main/java/com/ts3client/audio/desktop/WavSoundPlayer.java
+++ b/ts3-client/core/src/main/java/com/ts3client/sound/WavSoundPlayer.java
@@ -1,22 +1,19 @@
-package com.ts3client.audio.desktop;
+package com.ts3client.sound;
import com.ts3client.audio.AudioIo;
import com.ts3client.audio.AudioPlayback;
import com.ts3client.audio.VoiceFormat;
-import com.ts3client.sound.SoundPlayer;
-import javax.sound.sampled.AudioFormat;
-import javax.sound.sampled.AudioInputStream;
-import javax.sound.sampled.AudioSystem;
import java.io.File;
import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.Iterator;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
/**
- * Desktop {@link SoundPlayer}: plays sound-pack wave files on the configured
- * playback device.
+ * Plays sound-pack wave files on the configured playback device.
*
*
All notification sounds share one playback line and are mixed together, so
* two events firing at once never fight over the device, and the line is dropped
@@ -175,9 +172,9 @@ public final class WavSoundPlayer implements SoundPlayer {
/** Sums the active sounds into {@code mix}, dropping the ones that ran out. */
private void renderFrame(float[] mix) {
- java.util.Arrays.fill(mix, 0f);
+ Arrays.fill(mix, 0f);
synchronized (lock) {
- for (java.util.Iterator it = voices.iterator(); it.hasNext(); ) {
+ for (Iterator it = voices.iterator(); it.hasNext(); ) {
Voice v = it.next();
int n = Math.min(mix.length, v.pcm.length - v.position);
for (int i = 0; i < n; i++) {
@@ -218,32 +215,10 @@ public final class WavSoundPlayer implements SoundPlayer {
return pcm;
}
- /**
- * Decodes a sound file to mono 48 kHz float samples. Packs ship 44.1 kHz mono
- * waves, but nothing stops them from using another rate, depth or encoding, so
- * the conversion goes through Java Sound and a linear resample.
- */
+ /** Decodes a sound file to mono 48 kHz float samples. */
private static float[] readMono48k(File file) throws Exception {
- try (AudioInputStream in = AudioSystem.getAudioInputStream(file)) {
- AudioFormat source = in.getFormat();
- int channels = Math.max(1, source.getChannels());
- AudioFormat pcmFormat = new AudioFormat(AudioFormat.Encoding.PCM_SIGNED,
- source.getSampleRate(), 16, channels, channels * 2, source.getSampleRate(), false);
- try (AudioInputStream pcm = AudioSystem.getAudioInputStream(pcmFormat, in)) {
- byte[] data = pcm.readAllBytes();
- int frames = data.length / (2 * channels);
- float[] mono = new float[frames];
- for (int i = 0; i < frames; i++) {
- float sum = 0;
- for (int c = 0; c < channels; c++) {
- int k = 2 * (i * channels + c);
- sum += (short) ((data[k + 1] << 8) | (data[k] & 0xFF)) / 32768f;
- }
- mono[i] = sum / channels;
- }
- return resample(mono, source.getSampleRate(), VoiceFormat.SAMPLE_RATE);
- }
- }
+ WaveFile.Audio audio = WaveFile.readMono(file);
+ return resample(audio.samples(), audio.sampleRate(), VoiceFormat.SAMPLE_RATE);
}
private static float[] resample(float[] input, float fromRate, int toRate) {
diff --git a/ts3-client/core/src/main/java/com/ts3client/sound/WaveFile.java b/ts3-client/core/src/main/java/com/ts3client/sound/WaveFile.java
new file mode 100644
index 0000000..379ab15
--- /dev/null
+++ b/ts3-client/core/src/main/java/com/ts3client/sound/WaveFile.java
@@ -0,0 +1,89 @@
+package com.ts3client.sound;
+
+import java.io.File;
+import java.io.IOException;
+import java.nio.ByteBuffer;
+import java.nio.ByteOrder;
+import java.nio.file.Files;
+
+/**
+ * Reads RIFF wave files into mono float samples. Sound packs ship 16-bit PCM, but any
+ * integer depth up to 32 bits and 32-bit float are accepted too, in plain or
+ * {@code WAVE_FORMAT_EXTENSIBLE} headers.
+ */
+final class WaveFile {
+
+ private static final int FORMAT_PCM = 1;
+ private static final int FORMAT_FLOAT = 3;
+ private static final int FORMAT_EXTENSIBLE = 0xFFFE;
+
+ /** Decoded audio: mono samples in [-1, 1] at {@code sampleRate}. */
+ record Audio(float[] samples, int sampleRate) {
+ }
+
+ private WaveFile() {
+ }
+
+ static Audio readMono(File file) throws IOException {
+ ByteBuffer in = ByteBuffer.wrap(Files.readAllBytes(file.toPath())).order(ByteOrder.LITTLE_ENDIAN);
+ if (in.remaining() < 12 || in.getInt() != fourCc("RIFF")) throw new IOException("not a RIFF file");
+ in.getInt(); // RIFF size
+ if (in.getInt() != fourCc("WAVE")) throw new IOException("not a wave file");
+
+ int format = -1, channels = 0, sampleRate = 0, bits = 0;
+ while (in.remaining() >= 8) {
+ int id = in.getInt();
+ int size = in.getInt();
+ if (size < 0 || size > in.remaining()) size = in.remaining(); // truncated files still play
+ ByteBuffer chunk = in.slice(in.position(), size).order(ByteOrder.LITTLE_ENDIAN);
+ if (id == fourCc("fmt ") && size >= 16) {
+ format = chunk.getShort() & 0xFFFF;
+ channels = chunk.getShort() & 0xFFFF;
+ sampleRate = chunk.getInt();
+ chunk.getInt(); // byte rate
+ chunk.getShort(); // block align
+ bits = chunk.getShort() & 0xFFFF;
+ if (format == FORMAT_EXTENSIBLE && size >= 26) {
+ format = chunk.getShort(24) & 0xFFFF; // the sub-format GUID starts with the tag
+ }
+ } else if (id == fourCc("data")) {
+ if (format < 0) throw new IOException("data before fmt chunk");
+ return new Audio(decode(chunk, format, channels, bits), sampleRate);
+ }
+ in.position(in.position() + size + (size & 1)); // chunks are word-aligned
+ }
+ throw new IOException("no audio data");
+ }
+
+ private static float[] decode(ByteBuffer data, int format, int channels, int bits) throws IOException {
+ int bytes = bits / 8;
+ boolean supported = (format == FORMAT_PCM && bits % 8 == 0 && bytes >= 1 && bytes <= 4)
+ || (format == FORMAT_FLOAT && bits == 32);
+ if (!supported || channels < 1) {
+ throw new IOException("unsupported wave encoding " + format + "/" + bits + " bit");
+ }
+ int frames = data.remaining() / (bytes * channels);
+ float[] mono = new float[frames];
+ for (int i = 0; i < frames; i++) {
+ float sum = 0;
+ for (int c = 0; c < channels; c++) {
+ sum += format == FORMAT_FLOAT ? data.getFloat() : readInt(data, bytes);
+ }
+ mono[i] = sum / channels;
+ }
+ return mono;
+ }
+
+ /** One integer sample scaled to [-1, 1]; 8-bit wave data is unsigned, wider data signed. */
+ private static float readInt(ByteBuffer data, int bytes) {
+ if (bytes == 1) return ((data.get() & 0xFF) - 128) / 128f;
+ int value = 0;
+ for (int b = 0; b < bytes; b++) value |= (data.get() & 0xFF) << (8 * b);
+ int shift = 32 - 8 * bytes;
+ return (value << shift) / 2147483648f; // sign-extend via the top bits
+ }
+
+ private static int fourCc(String id) {
+ return id.charAt(0) | id.charAt(1) << 8 | id.charAt(2) << 16 | id.charAt(3) << 24;
+ }
+}
diff --git a/ts3-client/core/src/test/java/com/ts3client/sound/WaveFileTest.java b/ts3-client/core/src/test/java/com/ts3client/sound/WaveFileTest.java
new file mode 100644
index 0000000..9e018d6
--- /dev/null
+++ b/ts3-client/core/src/test/java/com/ts3client/sound/WaveFileTest.java
@@ -0,0 +1,74 @@
+package com.ts3client.sound;
+
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.io.TempDir;
+
+import java.io.File;
+import java.io.IOException;
+import java.nio.ByteBuffer;
+import java.nio.ByteOrder;
+import java.nio.file.Files;
+import java.nio.file.Path;
+
+import static org.junit.jupiter.api.Assertions.assertArrayEquals;
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertThrows;
+
+class WaveFileTest {
+
+ @TempDir
+ Path dir;
+
+ /** Builds a wave file around raw sample bytes. */
+ private File wave(int format, int channels, int bits, boolean extensible, byte[] data) throws IOException {
+ int fmtSize = extensible ? 40 : 16;
+ ByteBuffer b = ByteBuffer.allocate(12 + 8 + fmtSize + 8 + data.length).order(ByteOrder.LITTLE_ENDIAN);
+ b.put("RIFF".getBytes()).putInt(b.capacity() - 8).put("WAVE".getBytes());
+ b.put("fmt ".getBytes()).putInt(fmtSize);
+ b.putShort((short) (extensible ? 0xFFFE : format)).putShort((short) channels).putInt(44100)
+ .putInt(44100 * channels * bits / 8).putShort((short) (channels * bits / 8)).putShort((short) bits);
+ if (extensible) {
+ b.putShort((short) 22).putShort((short) bits).putInt(0).putShort((short) format).put(new byte[14]);
+ }
+ b.put("data".getBytes()).putInt(data.length).put(data);
+ File file = dir.resolve("test.wav").toFile();
+ Files.write(file.toPath(), b.array());
+ return file;
+ }
+
+ @Test
+ void readsSixteenBitMono() throws IOException {
+ WaveFile.Audio audio = WaveFile.readMono(wave(1, 1, 16, false, new byte[]{0, 0x40, 0, (byte) 0xC0}));
+ assertEquals(44100, audio.sampleRate());
+ assertArrayEquals(new float[]{0.5f, -0.5f}, audio.samples());
+ }
+
+ @Test
+ void averagesStereoDown() throws IOException {
+ WaveFile.Audio audio = WaveFile.readMono(wave(1, 2, 16, false, new byte[]{0, 0x40, 0, 0}));
+ assertArrayEquals(new float[]{0.25f}, audio.samples());
+ }
+
+ @Test
+ void readsUnsignedEightBit() throws IOException {
+ WaveFile.Audio audio = WaveFile.readMono(wave(1, 1, 8, false, new byte[]{(byte) 192, 64, (byte) 128}));
+ assertArrayEquals(new float[]{0.5f, -0.5f, 0f}, audio.samples());
+ }
+
+ @Test
+ void readsTwentyFourBitThroughAnExtensibleHeader() throws IOException {
+ WaveFile.Audio audio = WaveFile.readMono(wave(1, 1, 24, true, new byte[]{0, 0, 0x40, 0, 0, (byte) 0xC0}));
+ assertArrayEquals(new float[]{0.5f, -0.5f}, audio.samples());
+ }
+
+ @Test
+ void readsFloat() throws IOException {
+ byte[] data = ByteBuffer.allocate(8).order(ByteOrder.LITTLE_ENDIAN).putFloat(0.25f).putFloat(-1f).array();
+ assertArrayEquals(new float[]{0.25f, -1f}, WaveFile.readMono(wave(3, 1, 32, false, data)).samples());
+ }
+
+ @Test
+ void rejectsCompressedAudio() {
+ assertThrows(IOException.class, () -> WaveFile.readMono(wave(2, 1, 4, false, new byte[4])));
+ }
+}
diff --git a/ts3-client/desktop/src/main/java/com/ts3client/audio/desktop/DesktopAudioBackend.java b/ts3-client/desktop/src/main/java/com/ts3client/audio/desktop/DesktopAudioBackend.java
index 71e6f8f..94c123b 100644
--- a/ts3-client/desktop/src/main/java/com/ts3client/audio/desktop/DesktopAudioBackend.java
+++ b/ts3-client/desktop/src/main/java/com/ts3client/audio/desktop/DesktopAudioBackend.java
@@ -5,8 +5,6 @@ import com.ts3client.audio.AudioIo;
import com.ts3client.audio.StreamingVoiceOutput;
import com.ts3client.audio.desktop.pipewire.PipeWire;
import com.ts3client.audio.opus.OpusCodec;
-import com.ts3client.config.Settings;
-import com.ts3client.sound.SoundPlayer;
/**
* Desktop audio backend: PipeWire for capture/playback where it is running, Java Sound
@@ -32,11 +30,6 @@ public final class DesktopAudioBackend implements AudioBackend {
return StreamingVoiceOutput.Lines.PER_SPEAKER;
}
- @Override
- public SoundPlayer createSoundPlayer(Settings settings) {
- return new WavSoundPlayer(io(), settings.outputDevice);
- }
-
@Override
public String description() {
return codec() + ", " + audioSystem();