Conceal a lost voice packet with 20 ms, not 120 ms

A lost packet was concealed by decoding "nothing" into a buffer sized
for the longest Opus frame, and Opus fills whatever it is given: every
lost packet played 120 ms of made-up audio in place of 20 ms, piling up
delay on that speaker's line.

Decoders now conceal through their own call that takes the length to
make up, and the stream asks for the length of the last packet it got.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-09-25 08:18:06 +00:00
parent cc0243a7c6
commit 75d9606231
5 changed files with 44 additions and 24 deletions

View File

@@ -51,6 +51,8 @@ final class VoiceStream {
private final float[] pcm = new float[MAX_FRAME * VoiceFormat.MAX_CHANNELS];
private OpusDecoder decoder;
private int decoderChannels;
/** Length of the last packet, which a lost one most likely had too. */
private int lastFrameSize = VoiceFormat.FRAME_SIZE;
private boolean closed;
private volatile boolean talking;
@@ -142,7 +144,13 @@ final class VoiceStream {
synchronized (decoderLock) {
if (closed) return 0;
try {
frames = decoderFor(channels).decode(data, pcm);
OpusDecoder d = decoderFor(channels);
if (data == null) {
frames = d.conceal(pcm, lastFrameSize);
} else {
frames = d.decode(data, pcm);
lastFrameSize = frames;
}
} catch (RuntimeException e) {
return 0;
}

View File

@@ -3,12 +3,15 @@ package com.ts3client.audio.opus;
/** One Opus decoder, used from one thread at a time. */
public interface OpusDecoder extends AutoCloseable {
/**
* Decodes {@code packet} into interleaved samples, or conceals a lost packet when it is
* {@code null}. Returns the samples decoded per channel.
*/
/** Decodes {@code packet} into interleaved samples; returns the samples decoded per channel. */
int decode(byte[] packet, float[] out);
/**
* Makes up {@code frameSize} samples per channel for a packet that never arrived. Opus
* conceals exactly as much as it is asked for, so this should be the lost packet's length.
*/
int conceal(float[] out, int frameSize);
/** Forgets the stream's state, as at the start of a new talk burst. */
void reset();

View File

@@ -4,17 +4,20 @@ import com.ts3client.audio.opus.OpusCodec;
import com.ts3client.audio.opus.OpusDecoder;
import com.ts3client.audio.opus.OpusEncoder;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.List;
/**
* Stands in for libopus: a "packet" is one byte, decoded to a 20 ms frame holding that byte
* divided by 100 in every sample. Concealment decodes to -1, so it is easy to spot.
* divided by 100 in every sample. Concealment yields -1, so it is easy to spot.
*/
final class FakeOpus implements OpusCodec {
static final float CONCEALED = -1f;
int resets;
final List<Integer> concealedFrameSizes = new ArrayList<>();
static byte[] packet(int value) {
return new byte[]{(byte) value};
@@ -30,11 +33,17 @@ final class FakeOpus implements OpusCodec {
return new OpusDecoder() {
@Override
public int decode(byte[] packet, float[] out) {
float v = packet == null ? CONCEALED : packet[0] / 100f;
Arrays.fill(out, 0, VoiceFormat.FRAME_SIZE * channels, v);
Arrays.fill(out, 0, VoiceFormat.FRAME_SIZE * channels, packet[0] / 100f);
return VoiceFormat.FRAME_SIZE;
}
@Override
public int conceal(float[] out, int frameSize) {
concealedFrameSizes.add(frameSize);
Arrays.fill(out, 0, frameSize * channels, CONCEALED);
return frameSize;
}
@Override
public void reset() {
resets++;

View File

@@ -50,6 +50,8 @@ class VoiceStreamTest {
assertEquals(0.01f, pull());
assertEquals(FakeOpus.CONCEALED, pull());
assertEquals(0.03f, pull());
assertEquals(List.of(VoiceFormat.FRAME_SIZE), opus.concealedFrameSizes,
"a lost packet is concealed at the length of the one before, not the longest Opus frame");
}
@Test