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 <noreply@anthropic.com>
This commit is contained in:
2026-09-25 08:03:08 +00:00
parent 7396c4ca40
commit 94748e27ee
5 changed files with 179 additions and 45 deletions

View File

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