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

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

View File

@@ -0,0 +1,239 @@
package com.ts3client.sound;
import com.ts3client.audio.AudioIo;
import com.ts3client.audio.AudioPlayback;
import com.ts3client.audio.VoiceFormat;
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;
/**
* Plays sound-pack wave files on the configured playback device.
*
* <p>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
* again after a long enough quiet spell. Files are decoded once, resampled to the
* device's 48&nbsp;kHz and cached, so repeat events cost nothing but the mix.
*/
public final class WavSoundPlayer implements SoundPlayer {
/**
* How long the playback line is held after the last sound. Opening it costs a
* device round-trip, which is audible as a lag before a sound the user just
* triggered, so it is kept around for a spell of quiet rather than per sound.
*/
private static final long IDLE_KEEP_OPEN_NANOS = 30_000_000_000L;
private static final long FRAME_NANOS = VoiceFormat.FRAME_SIZE * 1_000_000_000L / VoiceFormat.SAMPLE_RATE;
/**
* How far ahead of real time the mixer renders. The line would happily take a
* few hundred milliseconds at once, but anything written is fixed: a sound that
* fires while another plays can only join the mix past what is already queued,
* so the lead is kept to a couple of frames, enough to ride out scheduling jitter.
*/
private static final long LEAD_NANOS = 3 * FRAME_NANOS;
/** Sound files are small; this caps the decoded cache anyway. */
private static final int MAX_CACHED_FILES = 64;
/** A decoded, 48 kHz mono sound being rendered. */
private static final class Voice {
final float[] pcm;
final double volume;
int position;
Voice(float[] pcm, double volume) {
this.pcm = pcm;
this.volume = volume;
}
}
private final Map<String, float[]> cache = new LinkedHashMap<>(16, 0.75f, true) {
@Override
protected boolean removeEldestEntry(Map.Entry<String, float[]> eldest) {
return size() > MAX_CACHED_FILES;
}
};
private final List<Voice> voices = new ArrayList<>();
private final Object lock = new Object();
private final AudioIo io;
private volatile String outputDevice;
private volatile boolean running = true;
private Thread mixer;
public WavSoundPlayer(AudioIo io, String outputDevice) {
this.io = io;
this.outputDevice = outputDevice;
}
@Override
public void setOutputDevice(String device) {
this.outputDevice = device == null ? "" : device;
}
@Override
public void play(File file, double volume) {
if (file == null || !file.isFile() || volume <= 0) return;
float[] pcm;
try {
pcm = decode(file);
} catch (Exception e) {
return; // unplayable file: stay silent rather than break the action
}
if (pcm.length == 0) return;
synchronized (lock) {
if (!running) return;
voices.add(new Voice(pcm, Math.min(1.0, volume)));
if (mixer == null) {
mixer = new Thread(this::mixLoop, "ts3j-sounds");
mixer.setDaemon(true);
mixer.start();
}
lock.notifyAll();
}
}
@Override
public void shutdown() {
synchronized (lock) {
running = false;
voices.clear();
lock.notifyAll();
}
}
// ---- mixing ----
/**
* Renders the active sounds onto one line, idling (rather than writing silence)
* between them and giving the device back after {@link #IDLE_KEEP_OPEN_NANOS}.
*
* <p>Latency matters here because a sound is heard against the click that caused
* it: the loop idles rather than writing silence, and while rendering it paces
* itself against the clock instead of filling the line, so at any moment no more
* than {@link #LEAD_NANOS} of audio is committed and a newly fired sound starts
* right behind it.
*/
private void mixLoop() {
AudioPlayback line = null;
try {
float[] mix = new float[VoiceFormat.FRAME_SIZE];
long due = 0; // when the next frame is due to leave the speaker
while (awaitVoices()) {
if (line == null) {
line = io.openPlayback(outputDevice, VoiceFormat.MAX_CHANNELS);
line.start();
}
long now = System.nanoTime();
if (due < now) {
due = now; // fresh start or fell behind: resync
} else if (due - now > LEAD_NANOS) {
Thread.sleep((due - now - LEAD_NANOS) / 1_000_000L);
}
due += FRAME_NANOS;
renderFrame(mix);
line.write(toBytes(mix, line.channels()), 0, mix.length * 2 * line.channels());
}
} catch (Exception e) {
synchronized (lock) {
voices.clear();
mixer = null;
}
} finally {
if (line != null) line.close();
}
}
/**
* Blocks until there is something to render.
*
* @return false when the idle timeout expired or the player was shut down, in
* which case this thread is done and {@link #play} will start a fresh one
*/
private boolean awaitVoices() throws InterruptedException {
synchronized (lock) {
long deadline = System.nanoTime() + IDLE_KEEP_OPEN_NANOS;
while (running && voices.isEmpty()) {
long remaining = deadline - System.nanoTime();
if (remaining <= 0) break;
lock.wait(Math.max(1, remaining / 1_000_000L));
}
if (running && !voices.isEmpty()) return true;
mixer = null;
return false;
}
}
/** Sums the active sounds into {@code mix}, dropping the ones that ran out. */
private void renderFrame(float[] mix) {
Arrays.fill(mix, 0f);
synchronized (lock) {
for (Iterator<Voice> 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++) {
mix[i] += v.pcm[v.position + i] * v.volume;
}
v.position += n;
if (v.position >= v.pcm.length) it.remove();
}
}
}
/** Interleaves the mono mix across the line's channels as signed 16-bit LE. */
private static byte[] toBytes(float[] mix, int channels) {
byte[] out = new byte[mix.length * 2 * channels];
for (int i = 0, k = 0; i < mix.length; i++) {
float v = Math.max(-1f, Math.min(1f, mix[i]));
short s = (short) Math.round(v * 32767.0);
for (int c = 0; c < channels; c++, k += 2) {
out[k] = (byte) (s & 0xFF);
out[k + 1] = (byte) ((s >> 8) & 0xFF);
}
}
return out;
}
// ---- decoding ----
private float[] decode(File file) throws Exception {
String key = file.getAbsolutePath() + '@' + file.lastModified();
synchronized (cache) {
float[] cached = cache.get(key);
if (cached != null) return cached;
}
float[] pcm = readMono48k(file);
synchronized (cache) {
cache.put(key, pcm);
}
return pcm;
}
/** Decodes a sound file to mono 48 kHz float samples. */
private static float[] readMono48k(File file) throws Exception {
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) {
if (fromRate <= 0 || Math.abs(fromRate - toRate) < 0.5f || input.length == 0) return input;
double ratio = toRate / (double) fromRate;
int length = (int) (input.length * ratio);
float[] out = new float[length];
for (int i = 0; i < length; i++) {
double position = i / ratio;
int index = (int) position;
double fraction = position - index;
float a = input[Math.min(index, input.length - 1)];
float b = input[Math.min(index + 1, input.length - 1)];
out[i] = (float) (a + (b - a) * fraction);
}
return out;
}
}

View File

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

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