From ebca3b413710ada926b6fdbe13592559e2602b73 Mon Sep 17 00:00:00 2001 From: Uladzimir Karpenka Date: Thu, 21 May 2026 18:51:21 +0300 Subject: [PATCH] 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 --- WebServer.pas | 64 +++++++++++++++++++++++++++++++++++++++++++++------ 1 file changed, 57 insertions(+), 7 deletions(-) diff --git a/WebServer.pas b/WebServer.pas index 6d86252..312d776 100644 --- a/WebServer.pas +++ b/WebServer.pas @@ -187,6 +187,16 @@ type FMicPreRollPos: Integer; 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 ── FListenSock: TSocket; FClients: array[0..MAX_WS_CLIENTS-1] of TWsClient; @@ -281,6 +291,7 @@ type function LoadOpus: Boolean; procedure UnloadOpus; procedure PushMicSamples(P: PSingle; N: Integer); + procedure FlushMicToCallback(P: PSingle; N: Integer); function InitListen: Boolean; procedure AcceptLoop; procedure PushLoop; @@ -544,11 +555,44 @@ begin Result := True; 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); -// Прокладка между Opus-декодером и FOnWebMic с pre-roll cushion. +// Прокладка между Opus-декодером и FlushMicToCallback с pre-roll cushion. // Первые MIC_PREROLL_SAMP сэмплов TX-сессии копим в FMicPreRoll и сливаем // одним блоком — это даёт FTXMicRing запас глубины ~40мс на джиттер. -// Дальше всё пушим напрямую. +// После pre-roll все сэмплы уходят через FlushMicToCallback, которая +// выравнивает поток по границе 512 сэмплов. var Want, Remainder: Integer; P2: PSingle; @@ -559,22 +603,24 @@ begin Want := MIC_PREROLL_SAMP - FMicPreRollPos; if N <= Want then begin + // Pre-roll ещё не набран: просто копим Move(P^, FMicPreRoll[FMicPreRollPos], N * SizeOf(Single)); Inc(FMicPreRollPos, N); end else begin + // Pre-roll заполнен: сливаем накопленный буфер + остаток пакета Move(P^, FMicPreRoll[FMicPreRollPos], Want * SizeOf(Single)); FMicPreRollPos := MIC_PREROLL_SAMP; - FOnWebMic(@FMicPreRoll[0], MIC_PREROLL_SAMP); + FlushMicToCallback(@FMicPreRoll[0], MIC_PREROLL_SAMP); Remainder := N - Want; P2 := P; Inc(P2, Want); - FOnWebMic(P2, Remainder); + FlushMicToCallback(P2, Remainder); end; end else - FOnWebMic(P, N); + FlushMicToCallback(P, N); end; procedure TWebServer.UnloadOpus; @@ -1006,9 +1052,13 @@ begin begin NowTick := GetTickCount64; if (FMicLastTick = 0) or ((NowTick - FMicLastTick) > MIC_IDLE_MS) then + begin // Новая TX-сессия (первый пакет или длинный простой): - // сбрасываем pre-roll, PLC не применяем — нет состояния для предсказания. - FMicPreRollPos := 0 + // сбрасываем pre-roll и staging, PLC не применяем — + // нет состояния для предсказания. + FMicPreRollPos := 0; + FMicStageLen := 0; + end else begin // PLC для опоздавших/потерянных кадров по wall-clock.