Fix TX mic DUC starvation: add 512-sample staging buffer

Opus decodes 960 samples per frame; the TX DSP thread consumes
512-sample blocks. 960 mod 512 = 448, so the ring always kept a
sub-threshold tail, causing TTXDSPThread to skip ticks and starve
the DUC hardware every ~20 ms.

FlushMicToCallback accumulates samples in FMicStageBuf and calls
FOnWebMic only when a full 512-sample block is ready. The remainder
stays in the buffer until the next Opus frame arrives. This ensures
FTXMicRing always receives data in multiples of 512, eliminating
the 0 < Avail < 512 dead-zone entirely.

FMicStageLen is reset together with FMicPreRollPos on new TX session
to prevent stale samples from a previous session mixing in.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-05-21 18:51:21 +03:00
co-authored by Claude Sonnet 4.6
parent cd9e504e8f
commit ebca3b4137
+57 -7
View File
@@ -187,6 +187,16 @@ type
FMicPreRollPos: Integer; FMicPreRollPos: Integer;
FMicPreRoll: array[0..MIC_PREROLL_SAMP-1] of Single; FMicPreRoll: array[0..MIC_PREROLL_SAMP-1] of Single;
// ── Staging buffer: выравнивание по границе DSP-блока ──────────────────
// Opus декодирует по 960 сэмплов, а TX DSP потребляет по 512.
// 960 mod 512 = 448 — в ring всегда остаётся хвост меньше порога,
// из-за чего TTXDSPThread пропускает тики и DUC hardware голодает.
// FlushMicToCallback накапливает сэмплы здесь и вызывает FOnWebMic
// ровно тогда, когда накопился полный блок 512 сэмплов.
// Гарантия: FTXMicRing всегда получает данные кратными 512.
FMicStageBuf: array[0..511] of Single; // буфер одного DSP-блока
FMicStageLen: Integer; // сколько сэмплов накоплено
// ── TCP ── // ── TCP ──
FListenSock: TSocket; FListenSock: TSocket;
FClients: array[0..MAX_WS_CLIENTS-1] of TWsClient; FClients: array[0..MAX_WS_CLIENTS-1] of TWsClient;
@@ -281,6 +291,7 @@ type
function LoadOpus: Boolean; function LoadOpus: Boolean;
procedure UnloadOpus; procedure UnloadOpus;
procedure PushMicSamples(P: PSingle; N: Integer); procedure PushMicSamples(P: PSingle; N: Integer);
procedure FlushMicToCallback(P: PSingle; N: Integer);
function InitListen: Boolean; function InitListen: Boolean;
procedure AcceptLoop; procedure AcceptLoop;
procedure PushLoop; procedure PushLoop;
@@ -544,11 +555,44 @@ begin
Result := True; Result := True;
end; end;
procedure TWebServer.FlushMicToCallback(P: PSingle; N: Integer);
// Аккумулирует сэмплы в FMicStageBuf и вызывает FOnWebMic ровно когда
// накопился полный DSP-блок (512 сэмплов). Неполный остаток хранится
// в буфере до следующего вызова.
//
// Почему 512: TX DSP поток читает из FTXMicRing блоками по FAudioBufSize=512.
// Если ring содержит 0 < Avail < 512, поток пропускает тик → DUC underflow.
// Staging гарантирует что ring всегда получает данные кратными 512.
var
Src: PSingle;
Fill: Integer;
begin
if not Assigned(FOnWebMic) or (N <= 0) then Exit;
Src := P;
while N > 0 do
begin
// Докладываем в stage сколько нужно до полного блока (или сколько есть)
Fill := Min(N, 512 - FMicStageLen);
Move(Src^, FMicStageBuf[FMicStageLen], Fill * SizeOf(Single));
Inc(FMicStageLen, Fill);
Inc(Src, Fill);
Dec(N, Fill);
// Полный блок готов — передаём в DSP-цепь и сбрасываем stage
if FMicStageLen = 512 then
begin
FOnWebMic(@FMicStageBuf[0], 512);
FMicStageLen := 0;
end;
end;
end;
procedure TWebServer.PushMicSamples(P: PSingle; N: Integer); procedure TWebServer.PushMicSamples(P: PSingle; N: Integer);
// Прокладка между Opus-декодером и FOnWebMic с pre-roll cushion. // Прокладка между Opus-декодером и FlushMicToCallback с pre-roll cushion.
// Первые MIC_PREROLL_SAMP сэмплов TX-сессии копим в FMicPreRoll и сливаем // Первые MIC_PREROLL_SAMP сэмплов TX-сессии копим в FMicPreRoll и сливаем
// одним блоком — это даёт FTXMicRing запас глубины ~40мс на джиттер. // одним блоком — это даёт FTXMicRing запас глубины ~40мс на джиттер.
// Дальше всё пушим напрямую. // После pre-roll все сэмплы уходят через FlushMicToCallback, которая
// выравнивает поток по границе 512 сэмплов.
var var
Want, Remainder: Integer; Want, Remainder: Integer;
P2: PSingle; P2: PSingle;
@@ -559,22 +603,24 @@ begin
Want := MIC_PREROLL_SAMP - FMicPreRollPos; Want := MIC_PREROLL_SAMP - FMicPreRollPos;
if N <= Want then if N <= Want then
begin begin
// Pre-roll ещё не набран: просто копим
Move(P^, FMicPreRoll[FMicPreRollPos], N * SizeOf(Single)); Move(P^, FMicPreRoll[FMicPreRollPos], N * SizeOf(Single));
Inc(FMicPreRollPos, N); Inc(FMicPreRollPos, N);
end end
else else
begin begin
// Pre-roll заполнен: сливаем накопленный буфер + остаток пакета
Move(P^, FMicPreRoll[FMicPreRollPos], Want * SizeOf(Single)); Move(P^, FMicPreRoll[FMicPreRollPos], Want * SizeOf(Single));
FMicPreRollPos := MIC_PREROLL_SAMP; FMicPreRollPos := MIC_PREROLL_SAMP;
FOnWebMic(@FMicPreRoll[0], MIC_PREROLL_SAMP); FlushMicToCallback(@FMicPreRoll[0], MIC_PREROLL_SAMP);
Remainder := N - Want; Remainder := N - Want;
P2 := P; P2 := P;
Inc(P2, Want); Inc(P2, Want);
FOnWebMic(P2, Remainder); FlushMicToCallback(P2, Remainder);
end; end;
end end
else else
FOnWebMic(P, N); FlushMicToCallback(P, N);
end; end;
procedure TWebServer.UnloadOpus; procedure TWebServer.UnloadOpus;
@@ -1006,9 +1052,13 @@ begin
begin begin
NowTick := GetTickCount64; NowTick := GetTickCount64;
if (FMicLastTick = 0) or ((NowTick - FMicLastTick) > MIC_IDLE_MS) then if (FMicLastTick = 0) or ((NowTick - FMicLastTick) > MIC_IDLE_MS) then
begin
// Новая TX-сессия (первый пакет или длинный простой): // Новая TX-сессия (первый пакет или длинный простой):
// сбрасываем pre-roll, PLC не применяем — нет состояния для предсказания. // сбрасываем pre-roll и staging, PLC не применяем —
FMicPreRollPos := 0 // нет состояния для предсказания.
FMicPreRollPos := 0;
FMicStageLen := 0;
end
else else
begin begin
// PLC для опоздавших/потерянных кадров по wall-clock. // PLC для опоздавших/потерянных кадров по wall-clock.