From 90bf6e030c718abf2010e851960bbaaf35483296 Mon Sep 17 00:00:00 2001 From: ericek111 Date: Tue, 6 Oct 2026 20:17:13 +0000 Subject: [PATCH] Resend voice packets for loss resilience A microphone can ask for every voice packet to be sent again, a number of times at a fixed spacing. The copies are the same encrypted packet, so listeners keep whichever arrives first. A TS3 server forwards them, and a jitter buffer that sees them arrive late grows to wait for them: the sender buys its listeners a deeper buffer, trading latency for riding out loss, with native clients as much as with others. Copies go out from the 20 ms microphone timer, so the spacing is best a whole number of ticks. Copies of a connection that is gone are dropped. Co-Authored-By: Claude Opus 5.5 --- .../manevolent/ts3j/audio/Microphone.java | 14 +++++ .../client/LocalTeamspeakClientSocket.java | 57 ++++++++++++++++++- 2 files changed, 69 insertions(+), 2 deletions(-) diff --git a/src/main/java/com/github/manevolent/ts3j/audio/Microphone.java b/src/main/java/com/github/manevolent/ts3j/audio/Microphone.java index 58bb6c9..fe4e0e3 100644 --- a/src/main/java/com/github/manevolent/ts3j/audio/Microphone.java +++ b/src/main/java/com/github/manevolent/ts3j/audio/Microphone.java @@ -12,4 +12,18 @@ public interface Microphone { CodecType getCodec(); byte[] provide(); + + /** + * Milliseconds between a voice packet and each copy of it sent afterwards, or 0 to send it + * once. A copy is the same packet, so receivers keep whichever arrives first; a jitter buffer + * that sees copies arrive late grows to wait for them, trading latency for riding out loss. + */ + default int getRedundancyDelay() { + return 0; + } + + /** How many copies follow each voice packet, {@link #getRedundancyDelay} apart. */ + default int getRedundancyCopies() { + return 1; + } } 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 f6bbd86..b8e0fca 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 @@ -18,6 +18,7 @@ import com.github.manevolent.ts3j.protocol.client.ClientConnectionState; import com.github.manevolent.ts3j.protocol.header.PacketHeader; import com.github.manevolent.ts3j.protocol.packet.PacketBody0Voice; 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; import com.github.manevolent.ts3j.protocol.packet.handler.client.LocalClientHandlerConnected; import com.github.manevolent.ts3j.protocol.packet.handler.client.LocalClientHandlerConnecting; @@ -54,6 +55,11 @@ public class LocalTeamspeakClientSocket private Microphone microphone; private HighPrecisionTimer microphoneThread; + /** Voice packets still to be sent again, soonest first; see {@link Microphone#getRedundancyDelay}. */ + private final Queue voiceCopies = + new PriorityBlockingQueue<>(64, Comparator.comparingLong((VoiceCopy copy) -> copy.due)); + private volatile long voiceCopyDelayNanos; + private volatile int voiceCopyCount; private List listeners = new LinkedList<>(); @@ -278,6 +284,39 @@ public class LocalTeamspeakClientSocket packet.getDatagram().setSocketAddress(remote); socket.send(packet.getDatagram()); + + long copyDelay = voiceCopyDelayNanos; + if (copyDelay > 0 && packet.getHeader().getType() == PacketBodyType.VOICE) { + // 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++) + voiceCopies.add(new VoiceCopy(sent + i * copyDelay, packet.getDatagram())); + } + } + + /** Sends the voice copies that are due; runs on the microphone timer. */ + private void sendDueVoiceCopies() { + long now = System.nanoTime(); + VoiceCopy copy; + while ((copy = voiceCopies.peek()) != null && now - copy.due >= 0) { + // Whatever poll() hands out is due no later than the one peeked at. + copy = voiceCopies.poll(); + try { + socket.send(copy.datagram); + } catch (IOException e) { + getExceptionHandler().accept(e); + } + } + } + + private static final class VoiceCopy { + final long due; + final DatagramPacket datagram; + + VoiceCopy(long due, DatagramPacket datagram) { + this.due = due; + this.datagram = datagram; + } } @@ -552,7 +591,7 @@ public class LocalTeamspeakClientSocket if (microphone != null && microphoneThread == null) { microphoneThread = new HighPrecisionTimer( - 20, + TICK_MS, 5F, // up to 100ms of drift/catchup new MicrophoneTask() ); @@ -620,6 +659,8 @@ public class LocalTeamspeakClientSocket } } + private static final int TICK_MS = 20; + private class MicrophoneTask implements Runnable { private boolean wasSendingAudio = false; private byte sessionId = 1; @@ -627,7 +668,13 @@ public class LocalTeamspeakClientSocket @Override public void run() { - if (!isConnected()) return; + if (!isConnected()) { + // Copies from a connection that is gone must not reach the next one. + voiceCopies.clear(); + return; + } + + sendDueVoiceCopies(); Microphone microphone; @@ -636,6 +683,12 @@ public class LocalTeamspeakClientSocket if ((microphone = LocalTeamspeakClientSocket.this.microphone) == null) return; + // Copies are sent from this tick, so the delay is best a whole number of ticks. + int copyDelay = microphone.getRedundancyDelay(); + int copies = microphone.getRedundancyCopies(); + voiceCopyCount = copies; + voiceCopyDelayNanos = copyDelay > 0 && copies > 0 ? copyDelay * 1_000_000L : 0; + if (microphone.isMuted()) { // TODO // Set a state and send a muted event