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:
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
+55
-2
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user