fix(tx): убраны инъекции данных в DUC IQ-поток — всплески на водопаде при TX

Два источника фабрикации данных в TX IQ-поток рисовали всплески на водопаде
при передаче (openHPSDR, особенно TUN/DUP):

1. HPSDRNetwork (sender): анти-старв zero-fill при пустой очереди впрыскивал
   нулевые пакеты = 1.25мс тишины прямо в поток = разрыв данных = всплеск.
   Теперь пустая очередь → просто ждём следующий блок (модель dl1ycf pihpsdr
   txiq_thread: данные не инжектируются никогда; короткий разрыв покрывает
   pre-roll в FPGA-FIFO радио). Убраны DUC_FIFO_LOW/REANCHOR/ZeroPkt.

2. WDSPEngine (продюсер TTXDSPThread): silence-инъекция при стойле mic
   впрыскивала лишний блок сверх mic-темпа (для TUN он нёс PostGen-тон →
   боковые/разрыв темпа). Продюсер теперь строго mic-гейтед.

Замерено на эфире: всплески при TUN 5-6/сессию → ~1. Остаточный редкий клик
по прямому замеру НЕ из TX-пейсинга (sender ровно на радиоклоке 800 пак/с
через supply-limiting, дропов/underrun нет) — источник отдельный (дисплей/RX),
разбор позже.

Windows-ветка sender'а не тронута. Pluto — свой TX-тракт (TPlutoTXThread +
libiio DMA), задета только общая silence-removal (push-поток сам добивает
нулями). Обе не перепроверены на железе.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-07-06 20:43:17 +03:00
co-authored by Claude Opus 4.8
parent 577b8add9d
commit 69717c2eda
2 changed files with 32 additions and 75 deletions
+20 -37
View File
@@ -420,22 +420,20 @@ procedure TDUCIQSenderThread.Execute;
const const
DUC_IQ_PAIRS_PER_PACKET = 240; DUC_IQ_PAIRS_PER_PACKET = 240;
DUC_SAMPLE_RATE = 192000; // IQ pairs per second DUC_SAMPLE_RATE = 192000; // IQ pairs per second
// Пейсинг DUC-FIFO радио (виртуальная оценка уровня, Linux-ветка). // Пейсинг DUC-FIFO радио — модель dl1ycf pihpsdr (new_protocol_txiq_thread).
// Режим «подушка в очереди»: pre-roll на старте TX (~THROTTLE+блоки нулей) // Продюсер (TTXDSPThread) mic-клочит очередь: mic-пакеты радио идут на кварце
// наполняет FIFO радио до THROTTLE, а остаток (~3 WDSP-блока) остаётся лежать // радио, а mic-ADC и DUC-DAC на одном кристалле → темп прихода mic = темп
// в FDUCIQQueue. Дальше очередь на каждом приходе блока непуста → sender // расхода DAC. Значит очередь пополняется ровно радиоклоком, и sender должен
// непрерывно метрит пакеты у порога THROTTLE, уровень FIFO радио прижат к // просто ОТПРАВЛЯТЬ пакеты по мере готовности. THROTTLE — не «держатель
// THROTTLE..THROTTLE+240 (а не ходит пилой до дна). Опоздание WDSP-блока // уровня», а BURST-ЛИМИТЕР: один WDSP-блок = ~8.5 пакетов приходят пачкой;
// (замерено до 25мс на старте под нагрузкой DUP) съедает подушку очереди, // без ограничения пачка мгновенно вливается в FPGA-FIFO радио и может его
// но FIFO радио не проседает — нулевые вставки (LOW) не нужны. // переполнить. Пока виртуальная оценка уровня > THROTTLE — притормаживаем
// THROTTLE=2000: FIFO радио ~2000..2400 — запас и от пустоты (2000), и от // (только ЗАДЕРЖКА отправки, НИКОГДА не инжектируем данные). Дрейф PC↔радио
// переполнения (4096−2400) под дрейф клоков радио/ПК. // тут даёт лишь ±1мс джиттера отправки (сглаживает FPGA-FIFO), а НЕ разрыв
// сэмплов. Пустая очередь → просто ждём следующий блок (как dl1ycf sem_wait):
// подушку на короткий разрыв держит pre-roll в FPGA-FIFO радио. Нулевые
// вставки УБРАНЫ — именно они (1.25мс тишины в потоке) рисовали всплески.
DUC_FIFO_THROTTLE = 2000; DUC_FIFO_THROTTLE = 2000;
DUC_FIFO_LOW = 1000; // анти-старв (аварийный): ниже — шлём нули
// Доливка при срабатывании анти-старва: не один пакет (якорь у LOW+240,
// запас 1.25мс — следующее опоздание блока снова даст вставку-щелчок),
// а до REANCHOR. Излишек осядет подушкой в очереди через троттл THROTTLE.
DUC_FIFO_REANCHOR = 1700;
DUC_PACKET_INTERVAL_US = 1250; // 240 / 192000 seconds DUC_PACKET_INTERVAL_US = 1250; // 240 / 192000 seconds
DUC_WIN_SPIN_US = 350; // final part of 1.25 ms interval: avoid Sleep jitter DUC_WIN_SPIN_US = 350; // final part of 1.25 ms interval: avoid Sleep jitter
var var
@@ -451,7 +449,6 @@ var
LastTick: QWord; LastTick: QWord;
NowTick: QWord; NowTick: QWord;
ElapsedMs: QWord; ElapsedMs: QWord;
ZeroPkt: TDUCIQPacket; // анти-старв филлер (нулевые IQ)
WaitMs: Integer; WaitMs: Integer;
{$ENDIF} {$ENDIF}
@@ -524,33 +521,19 @@ begin
{$ELSE} {$ELSE}
VirtualSamples := 0; VirtualSamples := 0;
LastTick := GetTickCount64; LastTick := GetTickCount64;
FillChar(ZeroPkt, SizeOf(ZeroPkt), 0);
while not Terminated do while not Terminated do
begin begin
if not FNet.DequeueDUCIQ(Pkt) then if not FNet.DequeueDUCIQ(Pkt) then
begin begin
DrainVirtualFIFO; DrainVirtualFIFO;
// Анти-старв: на передаче данных нет, а уровень FIFO радио проседает — // Очередь пуста. НЕ инжектируем нулевые пакеты — 1.25мс тишины прямо
// шлём нулевые пакеты (тишина вместо underrun-мусора DAC). В устоявшемся // в поток сэмплов = разрыв данных = всплеск на водопаде/в эфире. Как
// режиме сюда не заходим (подушка очереди держит уровень у THROTTLE); // dl1ycf pihpsdr (txiq_thread: пустая очередь → просто sem_wait): ждём
// срабатывает на старте TX (первый WDSP-блок через 10-25мс), в окне // следующий WDSP-блок продюсера. Короткий разрыв покрывает подушка
// WDSP-фейда после PTT-off (продюсер уже молчит, PTT ещё поднят) и // pre-roll в FPGA-FIFO радио; mic-клок продюсера держит очередь полной.
// после стайлов планировщика. Доливаем сразу до REANCHOR: излишек // На передаче ждём коротко (быстро подхватить пришедший блок), на приёме
// осядет обратно подушкой в очереди, и якорь не остаётся у LOW. // дольше (энергосбережение, всё равно ничего не шлём).
if FNet.FIsTransmitting and (VirtualSamples < DUC_FIFO_LOW) then
begin
while (not Terminated) and (VirtualSamples < DUC_FIFO_REANCHOR) do
begin
FNet.PackSeqBytes(ZeroPkt.Seq, FNet.NextSeq(FNet.FSeqDUCIQ));
FNet.DoSendTo(FNet.FSocket, ZeroPkt, SizeOf(ZeroPkt),
FNet.FDevice.IPAddress, FNet.FPortDUCIQ);
Inc(VirtualSamples, DUC_IQ_PAIRS_PER_PACKET);
end;
Continue; // сразу пробуем данные — вдруг уже пришли
end;
// На передаче ждём коротко: за 20мс FIFO радио сливает 3840 сэмплов —
// анти-старв выше должен успевать подхватывать просадку.
if FNet.FIsTransmitting then WaitMs := 1 else WaitMs := 20; if FNet.FIsTransmitting then WaitMs := 1 else WaitMs := 20;
RTLEventWaitFor(FNet.FDUCIQSem, WaitMs); RTLEventWaitFor(FNet.FDUCIQSem, WaitMs);
DrainVirtualFIFO; DrainVirtualFIFO;
+12 -38
View File
@@ -669,15 +669,12 @@ var
PeriodMs: Integer; PeriodMs: Integer;
PeriodUs: Integer; PeriodUs: Integer;
AccUs: Integer; AccUs: Integer;
DidBlock: Boolean;
StallCount: Integer;
begin begin
// 512 samples / 48000 Hz = 10.666 ms. Do not floor this to 10 ms: // 512 samples / 48000 Hz = 10.666 ms. Do not floor this to 10 ms:
// that over-produces TX IQ by ~6.7%, eventually forcing DUC queue // that over-produces TX IQ by ~6.7%, eventually forcing DUC queue
// corrections/drops and producing periodic sidebands on a pure TUN tone. // corrections/drops and producing periodic sidebands on a pure TUN tone.
PeriodUs := Max(1000, FEngine.FAudioBufSize * 1000000 div FEngine.FAudioRate); PeriodUs := Max(1000, FEngine.FAudioBufSize * 1000000 div FEngine.FAudioRate);
AccUs := 0; AccUs := 0;
StallCount := 0;
while not Terminated do while not Terminated do
begin begin
@@ -698,51 +695,28 @@ begin
Assigned(FEngine.FOnPullMic) then Assigned(FEngine.FOnPullMic) then
FEngine.FOnPullMic(FEngine.FAudioBufSize * 2); FEngine.FOnPullMic(FEngine.FAudioBufSize * 2);
// Обрабатываем накопившиеся блоки с реальными mic-данными // Обрабатываем накопившиеся блоки с реальными mic-данными (строго
DidBlock := False; // mic-гейтед: сколько полных mic-блоков в ring — столько TX-блоков).
repeat repeat
Avail := (FEngine.FTXMicHead - FEngine.FTXMicTail + TX_MIC_RING) Avail := (FEngine.FTXMicHead - FEngine.FTXMicTail + TX_MIC_RING)
and (TX_MIC_RING - 1); and (TX_MIC_RING - 1);
if (Avail >= FEngine.FAudioBufSize) and not Terminated then if (Avail >= FEngine.FAudioBufSize) and not Terminated then
begin FEngine.ProcessTXBlock
FEngine.ProcessTXBlock;
DidBlock := True;
end
else else
Break; Break;
until False; until False;
if Terminated then Break; if Terminated then Break;
// Если на этом тике не было ни одного полного mic-блока и mic-данных // Silence-инъекция УБРАНА (модель dl1ycf pihpsdr). Раньше при пустом ring
// вообще нет — обрабатываем один блок тишины, чтобы DUC IQ поток не // (>6 тиков) сюда впрыскивался блок тишины «чтобы DUC IQ не прерывался».
// прерывался и спектр/водопад обновлялись. // Но это фабрикация данных не на радиоклоке: для TUN такой «silence» несёт
// Важно: не добавлять silence-block сразу после реального блока. Для TUN // PostGen-тон, и лишний блок ломает темп DUC IQ (боковые/тр-р-р); плюс он
// этот "silence" тоже содержит PostGen tone, поэтому лишний блок ломает // над-производит блоки сверх mic-темпа. Продюсер теперь строго mic-гейтед:
// темп DUC IQ и даёт периодические боковые/тр-р-р. // блок создаётся ТОЛЬКО когда в ring накопился полный mic-блок (радиокварц
// Если же в ring уже есть неполный блок, нельзя подменять его тишиной: // шлёт mic-пакеты непрерывно и на TX/TUN → очередь пополняется радиоклоком).
// radio mic приходит пакетами по 64 сэмпла, sound-card часто по 128. // Реальный обрыв mic (сеть) = короткий разрыв, покрываемый подушкой
// Иначе каждый mic packet будит поток раньше срока, а мы генерируем // FPGA-FIFO радио, без фабрикации тишины в поток.
// лишний TX-блок тишины вместо ожидания полного 512-sample блока.
Avail := (FEngine.FTXMicHead - FEngine.FTXMicTail + TX_MIC_RING)
and (TX_MIC_RING - 1);
// Тишину генерируем только после 6 подряд пустых тиков (~64 ms).
// Порог 64 ms даёт 44 ms запаса относительно периода Opus-фрейма (20 ms),
// что покрывает WiFi-джиттер до ~40 ms без ложного инжекта тишины.
// При реальной потере пакета тишина всё равно инжектируется.
if not DidBlock then
begin
if Avail = 0 then
begin
Inc(StallCount);
if StallCount >= 6 then
FEngine.ProcessTXBlock;
end
else
StallCount := 0;
end
else
StallCount := 0;
end; end;
end; end;