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();
|
CodecType getCodec();
|
||||||
|
|
||||||
byte[] provide();
|
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.header.PacketHeader;
|
||||||
import com.github.manevolent.ts3j.protocol.packet.PacketBody0Voice;
|
import com.github.manevolent.ts3j.protocol.packet.PacketBody0Voice;
|
||||||
import com.github.manevolent.ts3j.protocol.packet.PacketBody2Command;
|
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.PacketHandler;
|
||||||
import com.github.manevolent.ts3j.protocol.packet.handler.client.LocalClientHandlerConnected;
|
import com.github.manevolent.ts3j.protocol.packet.handler.client.LocalClientHandlerConnected;
|
||||||
import com.github.manevolent.ts3j.protocol.packet.handler.client.LocalClientHandlerConnecting;
|
import com.github.manevolent.ts3j.protocol.packet.handler.client.LocalClientHandlerConnecting;
|
||||||
@@ -54,6 +55,11 @@ public class LocalTeamspeakClientSocket
|
|||||||
|
|
||||||
private Microphone microphone;
|
private Microphone microphone;
|
||||||
private HighPrecisionTimer microphoneThread;
|
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<>();
|
private List<TS3Listener> listeners = new LinkedList<>();
|
||||||
|
|
||||||
@@ -278,6 +284,39 @@ public class LocalTeamspeakClientSocket
|
|||||||
packet.getDatagram().setSocketAddress(remote);
|
packet.getDatagram().setSocketAddress(remote);
|
||||||
|
|
||||||
socket.send(packet.getDatagram());
|
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) {
|
if (microphone != null && microphoneThread == null) {
|
||||||
microphoneThread = new HighPrecisionTimer(
|
microphoneThread = new HighPrecisionTimer(
|
||||||
20,
|
TICK_MS,
|
||||||
5F, // up to 100ms of drift/catchup
|
5F, // up to 100ms of drift/catchup
|
||||||
new MicrophoneTask()
|
new MicrophoneTask()
|
||||||
);
|
);
|
||||||
@@ -620,6 +659,8 @@ public class LocalTeamspeakClientSocket
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private static final int TICK_MS = 20;
|
||||||
|
|
||||||
private class MicrophoneTask implements Runnable {
|
private class MicrophoneTask implements Runnable {
|
||||||
private boolean wasSendingAudio = false;
|
private boolean wasSendingAudio = false;
|
||||||
private byte sessionId = 1;
|
private byte sessionId = 1;
|
||||||
@@ -627,7 +668,13 @@ public class LocalTeamspeakClientSocket
|
|||||||
|
|
||||||
@Override
|
@Override
|
||||||
public void run() {
|
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;
|
Microphone microphone;
|
||||||
|
|
||||||
@@ -636,6 +683,12 @@ public class LocalTeamspeakClientSocket
|
|||||||
if ((microphone = LocalTeamspeakClientSocket.this.microphone) == null)
|
if ((microphone = LocalTeamspeakClientSocket.this.microphone) == null)
|
||||||
return;
|
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()) {
|
if (microphone.isMuted()) {
|
||||||
// TODO
|
// TODO
|
||||||
// Set a state and send a muted event
|
// Set a state and send a muted event
|
||||||
|
|||||||
Reference in New Issue
Block a user