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();