From 94748e27ee517996e87284e8e790a665839024af Mon Sep 17 00:00:00 2001 From: ericek111 Date: Fri, 25 Sep 2026 08:03:08 +0000 Subject: [PATCH] Play notification sounds from core The sound player was a desktop class only because it decoded wave files through Java Sound. A small RIFF reader replaces that, so the player moves to core and every platform gets it from AudioBackend by default. The reader takes integer PCM up to 32 bits and 32-bit float, in plain or extensible headers. It decodes all 151 files of the TS3 sound packs to the same samples Java Sound does. Co-Authored-By: Claude Opus 5.5 --- .../com/ts3client/audio/AudioBackend.java | 11 ++- .../com/ts3client/sound}/WavSoundPlayer.java | 43 ++------- .../java/com/ts3client/sound/WaveFile.java | 89 +++++++++++++++++++ .../com/ts3client/sound/WaveFileTest.java | 74 +++++++++++++++ .../audio/desktop/DesktopAudioBackend.java | 7 -- 5 files changed, 179 insertions(+), 45 deletions(-) rename ts3-client/{desktop/src/main/java/com/ts3client/audio/desktop => core/src/main/java/com/ts3client/sound}/WavSoundPlayer.java (82%) create mode 100644 ts3-client/core/src/main/java/com/ts3client/sound/WaveFile.java create mode 100644 ts3-client/core/src/test/java/com/ts3client/sound/WaveFileTest.java 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();