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 <noreply@anthropic.com>
This commit is contained in:
2026-10-06 20:17:13 +00:00
co-authored by Claude Opus 5.5
parent 4cc27db0e8
commit 90bf6e030c
2 changed files with 69 additions and 2 deletions
@@ -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;
}
}
@@ -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<VoiceCopy> voiceCopies =
new PriorityBlockingQueue<>(64, Comparator.comparingLong((VoiceCopy copy) -> copy.due));
private volatile long voiceCopyDelayNanos;
private volatile int voiceCopyCount;
private List<TS3Listener> 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