From 1430782e2ae5443317af8076ca31e28507b5bf11 Mon Sep 17 00:00:00 2001 From: ericek111 Date: Fri, 9 Oct 2026 22:18:07 +0000 Subject: [PATCH] Send the microphone's voice as whispers to a target setWhisperTarget routes voice into VOICE_WHISPER packets to channels and clients until cleared; a voice session under way ends on its old route first. Whisper packets carry their own packet id and get the redundant copies voice does. WhisperTargetMultiple sized its two counts as two bytes each, leaving zero bytes after the voice data, and read the client ids by the channel count. Co-Authored-By: Claude Opus 5.5 --- .../packet/PacketBody1VoiceWhisper.java | 4 +- .../client/AbstractTeamspeakClientSocket.java | 2 + .../client/LocalTeamspeakClientSocket.java | 98 +++++++++++++------ .../manevolent/ts3j/WhisperPacketTest.java | 41 ++++++++ 4 files changed, 114 insertions(+), 31 deletions(-) create mode 100644 src/test/java/com/github/manevolent/ts3j/WhisperPacketTest.java diff --git a/src/main/java/com/github/manevolent/ts3j/protocol/packet/PacketBody1VoiceWhisper.java b/src/main/java/com/github/manevolent/ts3j/protocol/packet/PacketBody1VoiceWhisper.java index 5e70989..4c5a5d1 100644 --- a/src/main/java/com/github/manevolent/ts3j/protocol/packet/PacketBody1VoiceWhisper.java +++ b/src/main/java/com/github/manevolent/ts3j/protocol/packet/PacketBody1VoiceWhisper.java @@ -239,13 +239,13 @@ public class PacketBody1VoiceWhisper extends PacketBody { channelIds[n] = (int) (buffer.getLong()); clientIds = new int[clientIdCount]; - for (int n = 0; n < channelIdCount; n ++) + for (int n = 0; n < clientIdCount; n ++) clientIds[n] = (buffer.getShort() & 0xFFFF); } @Override public int getSize() { - return 2 + 2 + + return 1 + 1 + (channelIds == null ? 0 : channelIds.length * 8) + (clientIds == null ? 0 : clientIds.length * 2); } diff --git a/src/main/java/com/github/manevolent/ts3j/protocol/socket/client/AbstractTeamspeakClientSocket.java b/src/main/java/com/github/manevolent/ts3j/protocol/socket/client/AbstractTeamspeakClientSocket.java index 35d9fb7..68ff69d 100644 --- a/src/main/java/com/github/manevolent/ts3j/protocol/socket/client/AbstractTeamspeakClientSocket.java +++ b/src/main/java/com/github/manevolent/ts3j/protocol/socket/client/AbstractTeamspeakClientSocket.java @@ -478,6 +478,8 @@ public abstract class AbstractTeamspeakClientSocket // > X is a ushort in H2N order of an own audio packet counter // it seems it can be the same as the packet counter so we will let the packethandler do it. ((PacketBody0Voice)packet.getBody()).setPacketId(header.getPacketId()); + } else if (header.getType() == PacketBodyType.VOICE_WHISPER) { + ((PacketBody1VoiceWhisper)packet.getBody()).setPacketId(header.getPacketId()); } // Flush to a buffer diff --git a/src/main/java/com/github/manevolent/ts3j/protocol/socket/client/LocalTeamspeakClientSocket.java b/src/main/java/com/github/manevolent/ts3j/protocol/socket/client/LocalTeamspeakClientSocket.java index 00656c7..4de4811 100644 --- a/src/main/java/com/github/manevolent/ts3j/protocol/socket/client/LocalTeamspeakClientSocket.java +++ b/src/main/java/com/github/manevolent/ts3j/protocol/socket/client/LocalTeamspeakClientSocket.java @@ -16,7 +16,10 @@ import com.github.manevolent.ts3j.identity.Uid; import com.github.manevolent.ts3j.protocol.*; import com.github.manevolent.ts3j.protocol.client.ClientConnectionState; import com.github.manevolent.ts3j.protocol.header.PacketHeader; +import com.github.manevolent.ts3j.enums.CodecType; +import com.github.manevolent.ts3j.protocol.packet.PacketBody; import com.github.manevolent.ts3j.protocol.packet.PacketBody0Voice; +import com.github.manevolent.ts3j.protocol.packet.PacketBody1VoiceWhisper; import com.github.manevolent.ts3j.protocol.packet.PacketBody2Command; import com.github.manevolent.ts3j.protocol.packet.PacketBodyType; import com.github.manevolent.ts3j.protocol.packet.handler.PacketHandler; @@ -54,6 +57,7 @@ public class LocalTeamspeakClientSocket private volatile InetSocketAddress remote; private Microphone microphone; + private volatile PacketBody1VoiceWhisper.WhisperTarget whisperTarget; private HighPrecisionTimer microphoneThread; /** Voice packets still to be sent again, soonest first; see {@link Microphone#getRedundancyDelay}. */ private final Queue voiceCopies = @@ -286,7 +290,8 @@ public class LocalTeamspeakClientSocket socket.send(packet.getDatagram()); long copyDelay = voiceCopyDelayNanos; - if (copyDelay > 0 && packet.getHeader().getType() == PacketBodyType.VOICE) { + PacketBodyType type = packet.getHeader().getType(); + if (copyDelay > 0 && (type == PacketBodyType.VOICE || type == PacketBodyType.VOICE_WHISPER)) { // Aim half a tick early, so each copy goes out on the tick nearest its time. long sent = System.nanoTime() - TICK_MS * 1_000_000L / 2; for (int i = 1, n = voiceCopyCount; i <= n; i++) @@ -585,6 +590,19 @@ public class LocalTeamspeakClientSocket return microphone; } + /** Where the microphone's voice goes: null for our channel, else whisper packets to the target. */ + public PacketBody1VoiceWhisper.WhisperTarget getWhisperTarget() { + return whisperTarget; + } + + /** + * Sends the microphone's voice to {@code target} as whispers, or to our channel again with null. + * A voice session under way ends on its old route first. + */ + public void setWhisperTarget(PacketBody1VoiceWhisper.WhisperTarget target) { + this.whisperTarget = target; + } + public void setMicrophone(Microphone microphone) { if (this.microphone != microphone) { this.microphone = microphone; @@ -664,6 +682,8 @@ public class LocalTeamspeakClientSocket private class MicrophoneTask implements Runnable { private boolean wasSendingAudio = false; + /** Where the voice session under way goes; null for the channel. */ + private PacketBody1VoiceWhisper.WhisperTarget sendingTarget; private byte sessionId = 1; private int flaggedPackets = 5; @@ -690,6 +710,12 @@ public class LocalTeamspeakClientSocket voiceCopyCount = copies; voiceCopyDelayNanos = copyDelay > 0 && copies > 0 ? copyDelay * 1_000_000L : 0; + PacketBody1VoiceWhisper.WhisperTarget target = whisperTarget; + if (wasSendingAudio && target != sendingTarget) { + // The listeners of the old route hear the end of what they were hearing. + if (!sendEnd(microphone)) return; + } + if (microphone.isMuted()) { // TODO // Set a state and send a muted event @@ -700,17 +726,13 @@ public class LocalTeamspeakClientSocket data = microphone.provide(); if (data.length <= 0) break; - PacketBody0Voice voice = new PacketBody0Voice(getRole().getOut()); - - voice.setCodecType(microphone.getCodec()); - voice.setCodecData(data); - if (flaggedPackets > 0) voice.setServerFlag0(sessionId); - try { - writePacket(voice); + writePacket(voicePacket(target, microphone.getCodec(), data, + flaggedPackets > 0 && target == null ? sessionId : null)); flaggedPackets--; wasSendingAudio = true; + sendingTarget = target; } catch (Exception e) { // All we are really concerned about here is a disconnection event, in which case the loop // will exit. @@ -719,29 +741,47 @@ public class LocalTeamspeakClientSocket } } while (live && microphone.isReady()); } else if (wasSendingAudio) { - PacketBody0Voice voice = new PacketBody0Voice(getRole().getOut()); - - voice.setCodecType(microphone.getCodec()); - voice.setCodecData(new byte[0]); - - try { - writePacket(voice); - - // reset decoder flag - flaggedPackets = 5; - sessionId++; - - if (sessionId >= 8) - sessionId = 1; - - wasSendingAudio = false; - } catch (Exception e) { - // All we are really concerned about here is a disconnection event, in which case the loop - // will exit. - getExceptionHandler().accept(e); - } + sendEnd(microphone); } } + + /** Ends the voice session on the route it went, which an empty packet does; false once disconnected. */ + private boolean sendEnd(Microphone microphone) { + try { + writePacket(voicePacket(sendingTarget, microphone.getCodec(), new byte[0], null)); + + // reset decoder flag + flaggedPackets = 5; + sessionId++; + + if (sessionId >= 8) + sessionId = 1; + + wasSendingAudio = false; + return true; + } catch (Exception e) { + // All we are really concerned about here is a disconnection event, in which case the loop + // will exit. + getExceptionHandler().accept(e); + return false; + } + } + + private PacketBody voicePacket(PacketBody1VoiceWhisper.WhisperTarget target, CodecType codec, byte[] data, + Byte flag) { + if (target == null) { + PacketBody0Voice voice = new PacketBody0Voice(getRole().getOut()); + voice.setCodecType(codec); + voice.setCodecData(data); + if (flag != null) voice.setServerFlag0(flag); + return voice; + } + PacketBody1VoiceWhisper whisper = new PacketBody1VoiceWhisper(getRole().getOut()); + whisper.setCodecType(codec); + whisper.setTarget(target); + whisper.setCodecData(data); + return whisper; + } } private class ClientCommandResponse diff --git a/src/test/java/com/github/manevolent/ts3j/WhisperPacketTest.java b/src/test/java/com/github/manevolent/ts3j/WhisperPacketTest.java new file mode 100644 index 0000000..53f6e0e --- /dev/null +++ b/src/test/java/com/github/manevolent/ts3j/WhisperPacketTest.java @@ -0,0 +1,41 @@ +package com.github.manevolent.ts3j; + +import com.github.manevolent.ts3j.enums.CodecType; +import com.github.manevolent.ts3j.protocol.ProtocolRole; +import com.github.manevolent.ts3j.protocol.header.ClientPacketHeader; +import com.github.manevolent.ts3j.protocol.packet.PacketBody1VoiceWhisper; +import junit.framework.TestCase; + +import java.nio.ByteBuffer; +import java.util.Arrays; + +/** A whisper a client sends: codec, then the channel and client counts and ids, then the voice. */ +public class WhisperPacketTest extends TestCase { + + public void testClientWhisperRoundTrip() { + PacketBody1VoiceWhisper.WhisperTargetMultiple target = new PacketBody1VoiceWhisper.WhisperTargetMultiple(); + target.setChannelIds(new int[]{7, 9}); + target.setClientIds(new int[]{3}); + PacketBody1VoiceWhisper sent = new PacketBody1VoiceWhisper(ProtocolRole.CLIENT); + sent.setPacketId(42); + sent.setCodecType(CodecType.OPUS_VOICE); + sent.setTarget(target); + sent.setCodecData(new byte[]{1, 2, 3}); + + ByteBuffer buffer = ByteBuffer.allocate(sent.getSize()); + sent.write(buffer); + assertEquals("the size covers exactly what is written", buffer.capacity(), buffer.position()); + assertEquals(2 + 1 + 1 + 1 + 2 * 8 + 2 + 3, buffer.capacity()); + + buffer.flip(); + PacketBody1VoiceWhisper read = new PacketBody1VoiceWhisper(ProtocolRole.CLIENT); + read.read(new ClientPacketHeader(), buffer); + PacketBody1VoiceWhisper.WhisperTargetMultiple readTarget = + (PacketBody1VoiceWhisper.WhisperTargetMultiple) read.getTarget(); + assertEquals(42, read.getPacketId()); + assertEquals(CodecType.OPUS_VOICE, read.getCodecType()); + assertTrue(Arrays.equals(new int[]{7, 9}, readTarget.getChannelIds())); + assertTrue(Arrays.equals(new int[]{3}, readTarget.getClientIds())); + assertTrue(Arrays.equals(new byte[]{1, 2, 3}, read.getCodecData())); + } +}