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

@@ -0,0 +1,25 @@
package com.ts3client.net;
import com.github.manevolent.ts3j.event.ClientUpdatedEvent;
import org.junit.jupiter.api.Test;
import java.util.Map;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertNotNull;
/**
* Pins the ts3j behaviour {@code TeamspeakConnection.has(...)} exists for: a field
* the event never carried reads back as an empty string, so a null check would treat
* every partial update (someone muting, say) as if it reported every other field too.
*/
class EventFieldsTest {
@Test
void missingFieldsReadBackAsEmptyStringsRatherThanNull() {
ClientUpdatedEvent e = new ClientUpdatedEvent(Map.of("clid", "7", "client_input_muted", "1"));
assertNotNull(e.get("client_is_recording"));
assertEquals("", e.get("client_is_recording"));
assertEquals("", e.get("client_nickname"));
}
}

View File

@@ -0,0 +1,51 @@
package com.ts3client.sound;
import org.junit.jupiter.api.Test;
import java.util.Properties;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertTrue;
class NotificationSettingsTest {
@Test
void defaultsFollowTheEventCatalogue() {
NotificationSettings s = new NotificationSettings();
assertTrue(s.isImportant(SoundEvent.OTHER_RECEIVED_POKE));
assertFalse(s.isImportant(SoundEvent.YOU_SWITCHED_CHANNEL));
assertTrue(s.isEnabled(SoundEvent.YOU_SWITCHED_CHANNEL));
assertFalse(s.isEnabled(SoundEvent.SPECIAL_MIC_CLICK_SELF_ON));
}
@Test
void onlyDeviationsArePersisted() {
NotificationSettings s = new NotificationSettings();
s.setImportant(SoundEvent.YOU_SWITCHED_CHANNEL, true);
s.setEnabled(SoundEvent.CHAT_RECEIVED_MESSAGE_CHANNEL, false);
Properties props = new Properties();
s.store(props);
assertEquals(2, props.size());
NotificationSettings loaded = new NotificationSettings();
loaded.load(props);
assertTrue(loaded.isImportant(SoundEvent.YOU_SWITCHED_CHANNEL));
assertFalse(loaded.isEnabled(SoundEvent.CHAT_RECEIVED_MESSAGE_CHANNEL));
assertTrue(loaded.isImportant(SoundEvent.OTHER_RECEIVED_POKE));
}
@Test
void storeClearsStaleEntries() {
Properties props = new Properties();
NotificationSettings s = new NotificationSettings();
s.setEnabled(SoundEvent.SERVER_ERROR, false);
s.store(props);
assertEquals(1, props.size());
s.setEnabled(SoundEvent.SERVER_ERROR, true);
s.store(props);
assertTrue(props.isEmpty());
}
}

View File

@@ -0,0 +1,89 @@
package com.ts3client.sound;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.io.TempDir;
import java.io.File;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.Map;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertNotNull;
import static org.junit.jupiter.api.Assertions.assertNull;
class SoundPackTest {
private static final String INI = """
[info]
# a comment
name = Test Pack
version = 1.0
author = Somebody
[soundfiles]
CONNECTION_CONNECTED = play("connected.wav")
CONNECTION_DISCONNECTED =
CHANNEL_CREATED_BY_OTHER = say("Channel ${channelname} was created by ${clientname}")
CLIENT_MOVED_TO_CURRENT_CHANNEL_STAYS = play("${clientType}_moved_tocurrentchannel.wav")
NOT_AN_EVENT = play("nothing.wav")
""";
private SoundPack load(Path dir) throws Exception {
Files.writeString(dir.resolve("settings.ini"), INI, StandardCharsets.UTF_8);
Files.writeString(dir.resolve("connected.wav"), "not really audio");
Files.writeString(dir.resolve("Neutral_Moved_ToCurrentChannel.wav"), "not really audio");
SoundPack pack = SoundPack.load(dir.toFile());
assertNotNull(pack);
return pack;
}
@Test
void readsPackInfo(@TempDir Path dir) throws Exception {
SoundPack pack = load(dir);
assertEquals(dir.getFileName().toString(), pack.id());
assertEquals("Test Pack", pack.name());
assertEquals("Somebody", pack.author());
assertEquals("1.0", pack.version());
}
@Test
void mapsEventsAndSkipsEmptyOnes(@TempDir Path dir) throws Exception {
SoundPack pack = load(dir);
assertEquals(new SoundScript(SoundScript.Kind.PLAY, "connected.wav"),
pack.script(SoundEvent.CONNECTION_CONNECTED));
assertNull(pack.script(SoundEvent.CONNECTION_DISCONNECTED));
assertEquals(SoundScript.Kind.SAY, pack.script(SoundEvent.CHANNEL_CREATED_BY_OTHER).kind());
}
@Test
void resolvesVariables(@TempDir Path dir) throws Exception {
SoundPack pack = load(dir);
String spoken = pack.script(SoundEvent.CHANNEL_CREATED_BY_OTHER)
.resolve(Map.of("channelname", "Lobby", "clientname", "Bob"));
assertEquals("Channel Lobby was created by Bob", spoken);
}
@Test
void findsFilesRegardlessOfCase(@TempDir Path dir) throws Exception {
SoundPack pack = load(dir);
String name = pack.script(SoundEvent.CLIENT_MOVED_TO_CURRENT_CHANNEL_STAYS)
.resolve(Map.of("clientType", "neutral"));
assertEquals("neutral_moved_tocurrentchannel.wav", name);
File file = pack.file(name);
assertNotNull(file);
assertEquals("Neutral_Moved_ToCurrentChannel.wav", file.getName());
}
@Test
void unknownEntriesAreIgnored(@TempDir Path dir) throws Exception {
SoundPack pack = load(dir);
assertNull(pack.file("nothing.wav"));
}
@Test
void directoryWithoutIniIsNotAPack(@TempDir Path dir) {
assertNull(SoundPack.load(dir.toFile()));
}
}