Play sound-pack notifications for client actions

Adds TeamSpeak-format sound packs: a folder of waves plus a settings.ini
mapping actions to play()/say() entries, with ${clientType} and friends
resolved per event. Packs are found in the client's own sound folder, an
installed TS3 client and a folder of the user's choosing, so the official
packs work unchanged.

Each action can be switched off or marked important; important actions are
the only ones still played while the speakers are muted, as in TS3. The new
Notifications options page lists them by category, greys out what the active
pack has no sound for, and previews on double-click.

Sounds are decoded, resampled and mixed onto a single playback line that is
only open while something plays, so overlapping events never fight over the
device.

Fires the events from the protocol layer, following TeamSpeak's own
distinctions: reason ids separate switched/moved/kicked/banned/timed out,
and visibility decides appears/disappears/stays.

Also fixes a ts3j trap in the process: a field an event never carried reads
back as an empty string, so the existing "e.get(x) != null" checks were
always true. That made a partial clientupdate (someone muting) announce a
stopped recording, and it let a nickname-only update reset another client's
mute/away flags and talk power, or a channel edit blank the channel name.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-14 11:53:11 +00:00
parent 23896456ee
commit 2d7b82f9a3
23 changed files with 2103 additions and 32 deletions

View File

@@ -5,6 +5,7 @@ import com.ts3client.audio.VoiceInput;
import com.ts3client.audio.VoiceOutput;
import com.ts3client.audio.desktop.pipewire.PipeWire;
import com.ts3client.config.Settings;
import com.ts3client.sound.SoundPlayer;
/**
* Desktop audio backend: PipeWire for capture/playback where it is running, Java Sound
@@ -22,6 +23,11 @@ public final class DesktopAudioBackend implements AudioBackend {
return new DesktopVoiceOutput(settings.outputDevice);
}
@Override
public SoundPlayer createSoundPlayer(Settings settings) {
return new WavSoundPlayer(settings.outputDevice);
}
@Override
public String description() {
return codec() + ", " + audioSystem();

View File

@@ -49,6 +49,8 @@ public final class DesktopVoiceInput implements VoiceInput {
private volatile Consumer<Double> levelListener; // input level in dBFS
private volatile Consumer<Boolean> talkListener; // local talk-state changes
private volatile Runnable mutedTalkListener; // speech detected while muted
private boolean mutedTalking;
private final String deviceName;
@@ -193,6 +195,11 @@ public final class DesktopVoiceInput implements VoiceInput {
this.talkListener = l;
}
@Override
public void setMutedTalkListener(Runnable l) {
this.mutedTalkListener = l;
}
public void setPushToTalk(boolean down) {
this.pttDown.set(down);
}
@@ -329,8 +336,10 @@ public final class DesktopVoiceInput implements VoiceInput {
private boolean decideGate(double db, float[] pcm) {
if (muted.get()) {
hangover = 0;
detectMutedSpeech(db);
return false;
}
mutedTalking = false;
switch (mode) {
case CONTINUOUS:
return true;
@@ -373,6 +382,19 @@ public final class DesktopVoiceInput implements VoiceInput {
return false;
}
/**
* Reports the start of a talk burst that the mute is swallowing. The volume gate
* alone decides here: the speech detector's state is kept for real transmission.
*/
private void detectMutedSpeech(double db) {
boolean talking = db >= thresholdDb;
if (talking && !mutedTalking) {
Runnable listener = mutedTalkListener;
if (listener != null) listener.run();
}
mutedTalking = talking;
}
private void setTransmitting(boolean t) {
transmitting.set(t);
if (t != lastTransmitting) {

View File

@@ -0,0 +1,226 @@
package com.ts3client.audio.desktop;
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.LinkedHashMap;
import java.util.List;
import java.util.Map;
/**
* Desktop {@link SoundPlayer}: 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 only exists
* while something is actually playing. 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 kept open after the last sound, in mixer frames. */
private static final int IDLE_FRAMES = 150; // 3 s of 20 ms frames
/** 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 volatile String outputDevice;
private volatile boolean running = true;
private Thread mixer;
public WavSoundPlayer(String outputDevice) {
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 until everything has been quiet for
* {@link #IDLE_FRAMES}, then gives the device back.
*/
private void mixLoop() {
AudioPlayback line = null;
try {
int idle = 0;
float[] mix = new float[AudioDevices.FRAME_SIZE];
while (true) {
synchronized (lock) {
if (!running) return;
if (voices.isEmpty()) {
if (++idle > IDLE_FRAMES) {
mixer = null;
return;
}
} else {
idle = 0;
}
}
if (line == null) {
line = AudioDevices.openPlayback(outputDevice, AudioDevices.MAX_CHANNELS);
line.start();
}
// Silence keeps the line running (and paces this loop) until it is closed.
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();
}
}
/** Sums the active sounds into {@code mix}, dropping the ones that ran out. */
private void renderFrame(float[] mix) {
java.util.Arrays.fill(mix, 0f);
synchronized (lock) {
for (java.util.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. 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.
*/
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(), AudioDevices.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;
}
}