diff --git a/HPSDRNetwork.pas b/HPSDRNetwork.pas
index 1197059..ed7331b 100644
--- a/HPSDRNetwork.pas
+++ b/HPSDRNetwork.pas
@@ -19,7 +19,7 @@ uses
{$ELSE}
Sockets, BaseUnix,
{$ENDIF}
- HPSDRProtocol, SyncObjs, Settings, RadioBackend;
+ HPSDRProtocol, SyncObjs, Settings, RadioBackend, TxTrace, PlatformUtils;
const
DUC_TX_QUEUE_SIZE = 256; // power of two, bounded latency on TX underrun/overrun
@@ -509,6 +509,7 @@ var
NowTick: QWord;
ElapsedMs: QWord;
WaitMs: Integer;
+ DryFrom: Int64; // приборы: начало простоя очереди, мкс
{$ENDIF}
{$IFDEF WINDOWS}
@@ -580,12 +581,18 @@ begin
{$ELSE}
VirtualSamples := 0;
LastTick := GetTickCount64;
+ DryFrom := 0;
while not Terminated do
begin
if not FNet.DequeueDUCIQ(Pkt) then
begin
DrainVirtualFIFO;
+ if TraceOn and FNet.FIsTransmitting and (DryFrom = 0) then
+ begin
+ DryFrom := MonotonicUs;
+ TR(trkDucDry, VirtualSamples, FNet.FDUCIQCount);
+ end;
// Очередь пуста. НЕ инжектируем нулевые пакеты — 1.25мс тишины прямо
// в поток сэмплов = разрыв данных = всплеск на водопаде/в эфире. Как
// dl1ycf pihpsdr (txiq_thread: пустая очередь → просто sem_wait): ждём
@@ -604,6 +611,13 @@ begin
if not FNet.FIsTransmitting then
Continue;
+ if TraceOn and (DryFrom <> 0) then
+ begin
+ TR(trkDucRun, Integer(MonotonicUs - DryFrom), VirtualSamples,
+ FNet.FDUCIQCount);
+ DryFrom := 0;
+ end;
+
FNet.PackSeqBytes(Pkt.Seq, FNet.NextSeq(FNet.FSeqDUCIQ));
FNet.DoSendTo(FNet.FSocket, Pkt, SizeOf(Pkt), FNet.FDevice.IPAddress,
FNet.FPortDUCIQ);
diff --git a/PlatformUtils.pas b/PlatformUtils.pas
index c8817f4..192a5ba 100644
--- a/PlatformUtils.pas
+++ b/PlatformUtils.pas
@@ -27,6 +27,11 @@ function GetScreenScale: Integer;
{$IFNDEF HEADLESS}
function GetControlScale(AControl: TControl): Integer;
{$ENDIF}
+// Монотонные микросекунды одного источника для всех потоков. Нужны там, где
+// интервалы считает не человек, а код: пейсинг запросов TX-аудио по TCI ведёт
+// абсолютные дедлайны, и часы, способные прыгнуть от NTP, там не годятся.
+function MonotonicUs: Int64;
+
// Возвращает каталог для хранения конфигурации (с завершающим разделителем).
// macOS: ~/Library/Application Support/ewsdr/
// Linux: ~/.config/ewsdr/
@@ -37,8 +42,44 @@ implementation
uses
SysUtils {$IFNDEF HEADLESS}, Forms{$ENDIF}
+ {$IFDEF WINDOWS}, Windows{$ENDIF}
+ {$IFDEF LINUX}, Linux, UnixType{$ENDIF}
+ {$IF DEFINED(UNIX) AND NOT DEFINED(LINUX)}, BaseUnix{$ENDIF}
{$IF DEFINED(DARWIN) AND NOT DEFINED(HEADLESS)}, MacScale{$ENDIF};
+{$IFDEF WINDOWS}
+var
+ QPCFreq: Int64 = 0;
+{$ENDIF}
+
+function MonotonicUs: Int64;
+{$IFDEF WINDOWS}
+var
+ V: Int64;
+begin
+ if QPCFreq <= 0 then
+ if not QueryPerformanceFrequency(QPCFreq) then Exit(0);
+ QueryPerformanceCounter(V);
+ Result := (V * 1000000) div QPCFreq;
+end;
+{$ELSE}
+ {$IFDEF LINUX}
+var
+ TS: TTimeSpec;
+begin
+ if clock_gettime(CLOCK_MONOTONIC, @TS) <> 0 then Exit(0);
+ Result := Int64(TS.tv_sec) * 1000000 + TS.tv_nsec div 1000;
+end;
+ {$ELSE}
+var
+ TV: TTimeVal;
+begin
+ fpgettimeofday(@TV, nil);
+ Result := Int64(TV.tv_sec) * 1000000 + TV.tv_usec;
+end;
+ {$ENDIF}
+{$ENDIF}
+
function HasOpenGLSpectrumSwitch: Boolean;
var
I: Integer;
diff --git a/RadioController.pas b/RadioController.pas
index 9ab96cd..ce7fbfb 100644
--- a/RadioController.pas
+++ b/RadioController.pas
@@ -51,7 +51,7 @@ uses
HPSDRProtocol, HPSDRNetwork, RadioBackend, PlutoBackend, IIOBindings,
WDSPEngine, AudioOutput, AudioInput, BeaconDecoder, BeaconFEC, DMRDecoder,
Settings, ChannelStore, FMRepeater, BoardUtils, DeviceStore, CWMorse, CWKeyer,
- CWDecoder;
+ CWDecoder, TxTrace;
// Панадаптеры (этап 3): потолок MAX_PANS живёт в WDSPEngine (общий для
// движка/контроллера/UI). Фактический лимит бэкенда — Caps.MaxPans (Pluto=1).
@@ -446,6 +446,7 @@ type
FWebMicActive: Boolean; // True если текущий TX идёт через txmsWeb
FTCIMicRequested: Boolean; // TCI-клиент попросил TRX:…,tci
FTCIMicActive: Boolean; // True если текущий TX модулируется из TCI
+ FTCITxLeadFrames: Integer; // аванс подачи TCI, отсчётов 48 кГц (ставит адаптер)
FSendAudioToRadio: Boolean;
FAudioOutDevName: string;
FAudioInDevName: string;
@@ -1296,6 +1297,10 @@ type
// Клиент TCI просит брать модуляцию из своего аудиопотока (TRX:0,true,tci,
// §4.2). Ставит адаптер, читает SetMOX при выборе источника.
property TCIMicRequested: Boolean read FTCIMicRequested write FTCIMicRequested;
+ // Сколько звука клиент TCI держит в авансе (отсчётов 48 кГц). Ставит
+ // адаптер по измеренной зернистости клиента; SetMOX по этому числу
+ // растягивает нулевой pre-roll DUC.
+ property TCITxLeadFrames: Integer read FTCITxLeadFrames write FTCITxLeadFrames;
// Идёт ли текущая передача с модуляцией из TCI (адаптеру — гнать ли
// маркеры TX_CHRONO).
property TCIMicActive: Boolean read FTCIMicActive;
@@ -6298,6 +6303,9 @@ begin
// прекратить — ключ обязан отпуститься немедленно.
if (not On_) and CWXBusy then CWXAbort;
FTransmitting := On_;
+ if TraceOn and (On_ <> WasTransmitting) then
+ if Assigned(FDSPEngine) then TR(trkPtt, Ord(On_), Ord(FDSPEngine.TXMicSource))
+ else TR(trkPtt, Ord(On_), -1);
// 2TON: прямое снятие MOX (кнопкой/CAT/web) гасит и двухтональник.
if (not On_) and FTwoTone then
begin
@@ -6331,7 +6339,15 @@ begin
// на bfo-глитч тона (устранён коммитом f2d0519). Снижено до 2 блоков ради
// латентности старта TX (~10мс). Если под нагрузкой DUP / быстром пере-PTT
// вернётся щелчок РОВНО на старте — это реальный underrun, вернуть 8144.
- FNetwork.PrimeDUCIQ(6096);
+ // ★При модуляции из TCI 6096 пар (31.75 мс) не хватает: клиент отвечает на
+ // первый маркер только через ~33 мс, плюс наша задержка на фронте — радио
+ // остаётся без IQ и даёт всплеск на старте КАЖДОЙ передачи. Растягиваем
+ // pre-roll до измеренного аванса клиента (в парах @192 кГц = кадры × 4).
+ // Путь микрофона радио, CW и web этого не касается: там источник свой.
+ if FTCIMicRequested and (FTCITxLeadFrames > 0) then
+ FNetwork.PrimeDUCIQ(Max(6096, FTCITxLeadFrames * 4))
+ else
+ FNetwork.PrimeDUCIQ(6096);
end;
if FWDSPReady and Assigned(FDSPEngine) then
begin
@@ -6430,6 +6446,7 @@ begin
// (оператор держит T/R руками) — вооружение обязано пойти за этой кнопкой.
SyncCWKeyer;
Changed(rfTransmitting);
+ if TraceOn and WasTransmitting and (not On_) then TraceDump('tx');
end;
procedure TRadioController.SetTune(On_: Boolean);
diff --git a/TCIAdapter.pas b/TCIAdapter.pas
index c4cd8b2..1a726dd 100644
--- a/TCIAdapter.pas
+++ b/TCIAdapter.pas
@@ -44,7 +44,7 @@ interface
uses
Classes, SysUtils, DateUtils, Math, SyncObjs,
RadioController, RadioBackend, WDSPEngine, Settings, PlatformUtils,
- DXSpotStore, TCIProtocol, TCIServer, TCIStreams;
+ DXSpotStore, TCIProtocol, TCIServer, TCIStreams, TxTrace;
const
// ★Приёмник TCI: 0 — главный тракт (каналы A/B = VFO A/B), N — слайс СЛОТА
@@ -60,6 +60,60 @@ const
// Аудио на выходе движка всегда 48 кГц (TWDSPEngine.Create), от него и
// считаются все прореживания и пересчёты потоков.
TCI_AUDIO_ENGINE_RATE = 48000;
+
+ // ── Запрос TX-аудио у клиента (§3.4) ──
+ // Квант = один блок TXA (512 отсчётов движка = 10.667 мс). Просить крупнее
+ // нельзя: подушка отправителя DUC — 2000 отсчётов @192 кГц, то есть 10.42 мс,
+ // и любой запрос длиннее её по времени оставляет очередь сухой на разницу.
+ TCI_TX_QUANTUM_ENGINE = 512;
+ // ★Потолок долга обязан быть БОЛЬШЕ окна в полёте, иначе связывающим
+ // ограничением становится он, а окно превращается в украшение: при долге,
+ // упёртом в потолок, новый маркер можно послать только взамен ответа, то есть
+ // темп подачи падает до «окно / время ответа». На клиенте с ответом 55 мс это
+ // давало 58% реального времени при полностью исправном клиенте — тракт
+ // недокармливался ровно так же, как при потерях. Держим потолок на квант выше
+ // окна.
+ TCI_TX_OWED_HEADROOM_Q = 1;
+ // Долгу разрешено уходить в минус: клиент вправе прислать больше
+ // запрошенного, и зажим в ноль заставил бы переспросить уже полученное, то
+ // есть подать звук дважды.
+ TCI_TX_OWED_FLOOR_Q = 4;
+ // Окно в полёте. Глубина конвейера = ceil(задержка ответа / период кванта),
+ // поэтому фиксированные 4 кванта сами становятся защёлкой на клиенте с
+ // задержкой больше ~42 мс. Растёт по измеренной задержке, но не выше
+ // серверного потолка: читать сильно вперёд опасно — у клиента можно
+ // вычерпать ещё не сформированный звук, и это отказ МОЛЧАЛИВЫЙ (в эфир уйдут
+ // неготовые данные, ни один счётчик их не покажет).
+ TCI_TX_WINDOW_MIN_Q = 4;
+ TCI_TX_MAX_INFLIGHT_MS = 80;
+ // Сторож. Отставание от реального времени видно ровно одним признаком:
+ // долг и окно ОДНОВРЕМЕННО стоят у своих потолков. Разность Owed−InFlight для
+ // этого не годится — в защёлке обе величины на потолке, и разность выглядит
+ // здоровой, как при нормальной работе.
+ TCI_TX_STALL_Q = 5; // выдержка насыщения, квантов
+ TCI_TX_STALL_RTT_K = 3; // и не меньше стольких оценённых задержек
+ TCI_TX_GRACE_MS = 300; // grace на старте: клиент собирает первый блок
+ TCI_TX_MARKERS_PER_TICK = 8; // явный потолок пачки за одно пробуждение
+ TCI_TX_STALL_MAX_MS = 250; // и не дольше этого: ждать больше нечего
+ // ★Аванс под ЗЕРНИСТОСТЬ КЛИЕНТА. Мелкого запроса мало: MSHV пишет свой
+ // TX-буфер granule'ами STREAM_C = 4096 отсчётов при 96 кГц (network.cpp), то
+ // есть 42.7 мс, и отвечает на маркеры ПАЧКАМИ по 4-5 блоков раз в ~44 мс — как
+ // ни дроби запрос. Замер на железе: 218 осушений очереди за 11 с, ровно по
+ // одному на пачку, из них 12 длиннее подушки. Лечится только запасом не меньше
+ // пачки. Это НЕ регулятор по уровню очереди (тот управлял бы темпом запроса,
+ // то есть скоростью звука) — разовая монотонная добавка к долгу с потолком.
+ TCI_TX_BURST_GAP_US = 2000; // пауза, разделяющая пачки ответов клиента
+ TCI_TX_GAP_MAX_US = 200000; // пауза длиннее — это не зернистость, а сбой
+ TCI_TX_LEAD_MAX_MS = 120; // потолок аванса (он же задержка передачи)
+ // ★Посев аванса, пока про клиента ничего не известно. Первая передача после
+ // подключения иначе стартует с pre-roll 31.75 мс против первого ответа MSHV
+ // на 41-й миллисекунде — и сохнет. Обучение к этому моменту физически не
+ // успевает: аванс появляется только после первого ответа.
+ TCI_TX_LEAD_DEF_MS = 50;
+ // Аванс умеет и уменьшаться, иначе посев выше — налог на клиента с мелкой
+ // гранулой (у него аванс это чистая задержка). Вниз только по выдержке:
+ // осушение дороже лишних миллисекунд задержки.
+ TCI_TX_LEAD_DOWN_MS = 3000;
// Потолок разворота одного блока TX-аудио в 48 кГц: 8192 отсчёта int16 на
// 8 кГц дают ×6. Больше в блок не влезает по протоколу (data[16384]).
TCI_TX_OUT_MAX = (TCI_STREAM_DATA_MAX div 2) * 6;
@@ -158,8 +212,39 @@ type
FTxInterp: TTCIInterpolator;
FTxInRate: Integer; // частота дискретизации подачи клиента
FTxRunning: Boolean; // маркеры TX_CHRONO идут
- FTxOwed: Double; // сколько сэмплов клиент нам «должен»
- FTxLastMs: QWord;
+ // ── Бухгалтерия запросов TX-аудио ──
+ // ★ЕДИНИЦЫ: и долг, и окно считаются в КАДРАХ НА КАНАЛ при частоте клиента
+ // — ровно в тех, в которых называется квант в маркере. Гасить их надо на k
+ // (моно-кадры ДО интерполятора), а не на length из шапки (это значения,
+ // вдвое больше при стерео) и не на N после интерполяции (это уже 48 кГц,
+ // вшестеро больше у клиента на 8 кГц). Подмена любой из двух величин даёт
+ // стабильно неверный темп запроса, который не проявится на 48 кГц моно.
+ FTxOwed: Double; // сколько кадров клиент должен нам по часам
+ FTxInFlight: Double; // запрошено, но ещё не пришло
+ FTxLastUs: Int64; // монотонные часы долга
+ FTxProgressUs: Int64; // когда последний раз пришёл валидный блок
+ FTxSatSinceUs: Int64; // с какого момента насыщены И долг, И окно (0 — нет)
+ FTxArmed: Boolean; // сторож вооружён (после первого ответа/grace)
+ FTxReqUs: array[0..63] of Int64; // времена отправки маркеров (FIFO)
+ FTxReqHead: Integer;
+ FTxReqTail: Integer;
+ FTxLatencyUs: Double; // оценка «маркер → ответ», мкс
+ FTxWindowQ: Integer; // окно в полёте, квантов
+ FTxQuantum: Integer; // текущий квант, кадров на канал
+ FTxHealthy: Boolean; // участок без потерь/прощений (можно мерить задержку)
+ // ★Мерим ПЕРИОД между пачками, а не их размер. Размер зависит и от того,
+ // сколько мы запросили: попросили больше — клиент ответил длиннее — аванс
+ // подрос — попросили ещё больше. Это положительная обратная связь. Период
+ // же равен внутренней грануле клиента (у MSHV — STREAM_C/96 кГц = 42.7 мс)
+ // и от нашего темпа не зависит вовсе.
+ FTxGapUs: Double; // оценка периода между пачками, мкс
+ FTxLead: Integer; // выданный аванс под зернистость клиента, кадров
+ FTxLeadKeep: Integer; // ★он же, но ПЕРЕЖИВАЮЩИЙ конец передачи
+ FTxLeadRate: Integer; // частота, при которой аванс измерен
+ FTxLeadLowUs: Int64; // с какого момента оценка держится НИЖЕ аванса
+ FTxTraceLast: Int64; // приборы: время прошлого маркера
+ FTxTraceAudio: Int64; // приборы: время прошлого блока аудио
+ FTxLastRxUs: Int64; // когда пришёл прошлый блок (границы пачек)
// Рабочие буферы разбора TX-блока. Полем, а не на стеке: развёрнутый в
// 48 кГц блок — это сотни килобайт, и класть их в стек потока клиента
// (да ещё на каждый блок двадцать раз в секунду) незачем.
@@ -239,7 +324,15 @@ type
const Left, Right: array of Single; Count: Integer);
procedure OnIQTap(PanId: Integer; PI_, PQ_: PDouble; N, RateHz: Integer);
procedure HandleBinary(Client: TTCIClient; Data: PByte; Len: Integer);
- procedure PushTxChrono; // тик: маркеры времени клиенту
+ procedure PushTxChrono; // планировщик: маркеры времени клиенту
+ function TxQuantumFor(C: TTCIClient; Rate: Integer): Integer;
+ procedure TxResetAccounting(Rate: Integer; C: TTCIClient);
+ procedure TxNoteRequest(NowUs: Int64);
+ procedure TxNoteReply(NowUs: Int64);
+ procedure TxUpdateWindow(Rate: Integer);
+ function TxOwedCap: Double;
+ procedure TxPublishLead(Rate: Integer);
+ procedure TxPreWarm(Client: TTCIClient; Rx: Integer);
procedure CmdStream(Client: TTCIClient; const M: TTCIMessage);
procedure CmdRecorder(Client: TTCIClient; const M: TTCIMessage);
function EnqueueWav(const APath: string; const R: TTCIRecTake;
@@ -331,6 +424,7 @@ type
procedure HandleConnect(Client: TTCIClient);
procedure HandleDisconnect(Client: TTCIClient);
procedure HandleTick;
+ procedure HandleTxTick;
procedure PushSensors(Client: TTCIClient);
procedure OnState(Sender: TObject; Field: TRadioField);
@@ -347,6 +441,9 @@ type
False — включить просили, а порт не открылся (занят/нет прав/кривой
адрес): вызывающий обязан сказать это оператору, иначе тот останется с
галкой «включено» и мёртвым сервером. }
+ // Диагностика для стенда: состояние бухгалтерии запросов TX-аудио.
+ function TxDbgState(out Owed, InFlight: Double;
+ out WindowQ, Quantum, LeadFrames: Integer): Boolean;
function ApplySettings(const T: TTCISettings): Boolean;
function Active: Boolean;
@@ -418,6 +515,7 @@ begin
FServer.OnConnect := HandleConnect;
FServer.OnDisconnect := HandleDisconnect;
FServer.OnTick := HandleTick;
+ FServer.OnTxTick := HandleTxTick;
RefreshDev;
// Первый снимок — прямо здесь: конструктор идёт в потоке контроллера, а
@@ -2052,6 +2150,8 @@ begin
Name := LowerCase(Trim(TCIArg(M, 2)));
FromTCI := B and (not Tune) and (Name = 'tci') and HasAudioStream(Client);
Started := False;
+ // ★До SetMOX: запрос клиенту вперёд подготовки тракта (см. TxPreWarm).
+ if FromTCI then TxPreWarm(Client, Rx);
FLock.Enter;
try
FsInt := Rx;
@@ -2087,6 +2187,11 @@ begin
begin
FTxClient := Client;
FTxRx := Rx;
+ // ★Будим планировщик немедленно. Иначе первый маркер уходил на
+ // 10-20 мс позже фронта PTT (дремотный шаг), и ровно этих
+ // миллисекунд не хватало нулевому pre-roll, чтобы дожить до первого
+ // ответа клиента: очередь DUC пересыхала на старте каждой передачи.
+ FServer.KickTxTick;
end
// Эфир поднял он же, но модуляцию просит НЕ из TCI — снимаем свою.
else if Started and (not Tune) and (FTxClient = Client) then
@@ -2765,13 +2870,31 @@ begin
end;
end;
+procedure TTCIAdapter.HandleTxTick;
+begin
+ // Свой поток планировщика: исключение здесь остановило бы подачу модуляции
+ // до перезапуска сервера.
+ // ★Под замком освобождения клиентов: FTxClient указывает на объект, который
+ // тик-поток вправе освободить в любой момент, а этот поток — не тот, что
+ // раньше пользовался этим указателем.
+ FServer.BeginTxClientUse;
+ try
+ try
+ PushTxChrono;
+ except
+ // молча: следующий тик попробует снова
+ end;
+ finally
+ FServer.EndTxClientUse;
+ end;
+end;
+
procedure TTCIAdapter.HandleTick;
begin
// Тик крутится в своём потоке сервера: исключение здесь остановило бы
// измерители у ВСЕХ клиентов до перезапуска сервера.
try
FServer.EnumClients(PushSensors);
- PushTxChrono;
SweepRecorders;
except
// молча: следующий тик через 20 мс попробует снова
@@ -3414,6 +3537,10 @@ begin
FTxClient := nil;
FTxRx := 0;
FTxRunning := False;
+ // Ушёл клиент — ушла и его зернистость: следующий может быть любым.
+ FTxLeadKeep := 0;
+ FTxLead := 0;
+ FTxGapUs := 0;
Drop := True;
end;
finally
@@ -3473,18 +3600,224 @@ begin
end;
end;
+function TTCIAdapter.TxQuantumFor(C: TTCIClient; Rate: Integer): Integer;
+// Квант запроса в КАДРАХ НА КАНАЛ при частоте клиента. Целимся в один блок TXA
+// (512 отсчётов движка): крупнее — очередь DUC сохнет на разнице с подушкой,
+// мельче — растёт накладной расход на кадры WebSocket без выигрыша.
+// Сверху ограничен размером блока, который клиент сам себе назначил: просить
+// больше, чем он умеет собрать, бессмысленно.
+begin
+ Result := Ceil(TCI_TX_QUANTUM_ENGINE * Rate / TCI_AUDIO_ENGINE_RATE);
+ if Result < TCI_AUDIO_SAMPLES_MIN then Result := TCI_AUDIO_SAMPLES_MIN;
+ Result := Min(Result, EnsureRange(C.AudioSamples, TCI_AUDIO_SAMPLES_MIN,
+ TCI_AUDIO_SAMPLES_MAX));
+end;
+
+procedure TTCIAdapter.TxResetAccounting(Rate: Integer; C: TTCIClient);
+// Начало передачи. Под FTxLock.
+begin
+ FTxQuantum := TxQuantumFor(C, Rate);
+ FTxInFlight := 0;
+ FTxLastUs := MonotonicUs;
+ FTxProgressUs := FTxLastUs;
+ FTxSatSinceUs := 0;
+ FTxArmed := False;
+ FTxReqHead := 0;
+ FTxReqTail := 0;
+ FTxLatencyUs := 0;
+ FTxHealthy := True;
+ FTxWindowQ := TCI_TX_WINDOW_MIN_Q;
+ FTxLastRxUs := 0;
+ // ★Аванс — свойство КЛИЕНТА, а не передачи: его зернистость от PTT к PTT не
+ // меняется. Обучение внутри передачи не успевает к первому же всплеску, зато
+ // прошлый результат готов сразу. Обнуляется он только при смене клиента или
+ // параметров его потока (см. ClearTxClient / смена rate-channels-samples).
+ FTxLead := FTxLeadKeep;
+ if FTxLead <= 0 then
+ FTxLead := (TCI_TX_LEAD_DEF_MS * Rate) div 1000; // посев, см. константу
+ FTxLeadLowUs := 0;
+
+ // Стартовый долг — РОВНО подушка, которую попросил сам клиент. Аванс сюда
+ // НЕ прибавляется, и это не упущение.
+ // ★Запас под зернистость клиента физически лежит в очереди DUC, а не в долге:
+ // на фронте PTT туда заливается FTxLead тишины (SetMOX → PrimeDUCIQ, размер
+ // берётся из TCITxLeadFrames). Очередь расходуется реальным временем и
+ // пополняется тем же темпом, поэтому залитая пачка нулей остаётся в ней до
+ // конца передачи — это и есть постоянный запас. Прибавить тот же аванс ещё и
+ // к долгу значило бы попросить у клиента вдобавок столько же ЗВУКА и сложить
+ // оба запаса в одну очередь: задержка передачи выросла бы вдвое без всякой
+ // пользы. Второе применение аванса — потолок долга (см. TxOwedCap): он даёт
+ // бухгалтерии место, чтобы пережить пачку, не упираясь в потолок.
+ FTxOwed := Rate * (C.TxBuffering / 1000.0);
+end;
+
+procedure TTCIAdapter.TxNoteRequest(NowUs: Int64);
+// Запомнить время отправки маркера (FIFO — ответы сопоставляем по порядку).
+var NextHead: Integer;
+begin
+ NextHead := (FTxReqHead + 1) mod Length(FTxReqUs);
+ if NextHead = FTxReqTail then Exit; // кольцо полно — старейшее уже неинтересно
+ FTxReqUs[FTxReqHead] := NowUs;
+ FTxReqHead := NextHead;
+end;
+
+procedure TTCIAdapter.TxNoteReply(NowUs: Int64);
+// Пришёл блок: снимаем старейший неотвеченный маркер и обновляем оценку
+// задержки «маркер → ответ». ★Оценку двигаем ТОЛЬКО на здоровом участке: после
+// потери соответствие ответов маркерам неоднозначно, и один пропуск притворился
+// бы огромной задержкой, раздул окно и вытянул из клиента лишний backlog.
+var Sample: Double;
+begin
+ if FTxReqTail = FTxReqHead then Exit;
+ Sample := NowUs - FTxReqUs[FTxReqTail];
+ FTxReqTail := (FTxReqTail + 1) mod Length(FTxReqUs);
+ if not FTxHealthy then Exit;
+ if Sample < 0 then Exit;
+ if FTxLatencyUs <= 0 then
+ FTxLatencyUs := Sample
+ else if Sample > FTxLatencyUs then
+ FTxLatencyUs := FTxLatencyUs + 0.50 * (Sample - FTxLatencyUs) // вверх быстро
+ else
+ FTxLatencyUs := FTxLatencyUs + 0.02 * (Sample - FTxLatencyUs); // вниз медленно
+ // ★Потолок обязателен. Ответы сопоставляются маркерам FIFO по временам
+ // отправки, и после потери соответствие смещается: ответ на СЛЕДУЮЩИЙ маркер
+ // засчитывается старому, оценка взлетает на порядок. Она входит и в размер
+ // окна, и в выдержку сторожа — без потолка одна потеря растягивала выдержку до
+ // секунд, и зависшие кредиты не прощались почти всю передачу.
+ if FTxLatencyUs > TCI_TX_MAX_INFLIGHT_MS * 1000 then
+ FTxLatencyUs := TCI_TX_MAX_INFLIGHT_MS * 1000;
+end;
+
+function TTCIAdapter.TxDbgState(out Owed, InFlight: Double;
+ out WindowQ, Quantum, LeadFrames: Integer): Boolean;
+begin
+ FTxLock.Enter;
+ try
+ Owed := FTxOwed;
+ InFlight := FTxInFlight;
+ WindowQ := FTxWindowQ;
+ Quantum := FTxQuantum;
+ LeadFrames := FTxLead;
+ Result := FTxRunning;
+ finally
+ FTxLock.Leave;
+ end;
+end;
+
+procedure TTCIAdapter.TxPreWarm(Client: TTCIClient; Rx: Integer);
+// Поток клиента, ДО SetMOX. Один маркер вперёд всей подготовки тракта.
+//
+// ★Зачем: MSHV отвечает на первый маркер только через ~41 мс, а сам маркер
+// уходил лишь после того, как SetMOX отработает реле, pre-roll и PureSignal —
+// ещё 9-17 мс. Эти два ожидания шли последовательно, и нулевого pre-roll не
+// хватало: очередь DUC сохла на старте КАЖДОЙ первой передачи. Отправив запрос
+// до Invoke, мы кладём раздумья клиента поверх собственной подготовки.
+//
+// Заявку могут и отклонить (запрет на диапазоне, DMR, нет Auto TX у слайса) —
+// тогда запрошенный звук просто пропадёт: HandleBinary отбрасывает блоки, пока
+// TCIMicActive не поднят. В бухгалтерию маркер не заносим: она считает долг по
+// часам, а долгу разрешено уходить в минус, когда клиент прислал больше.
+var
+ Rate, Chans, Q, Seed: Integer;
+ ST: TTCISampleType;
+ H: TTCIStreamHeader;
+begin
+ if Client = nil then Exit;
+ // ★Только если заявка вообще имеет шанс: передатчик свободен и чужого
+ // источника на нём нет. Иначе конкурирующий клиент, чью просьбу мы сейчас
+ // отклоним, получил бы запрос на модуляцию — а он на него ответит звуком,
+ // который нам не нужен и который придётся выбрасывать.
+ FTxLock.Enter;
+ try
+ if (FTxClient <> nil) and (FTxClient <> Client) then Exit;
+ finally
+ FTxLock.Leave;
+ end;
+ if FController.FTransmitting or FController.FTuning then Exit;
+ Rate := Client.AudioRate;
+ if not TCIValidAudioRate(Rate) then Rate := TCI_AUDIO_RATE_DEF;
+ Chans := EnsureRange(Client.AudioChannels, 1, 2);
+ if not TCISampleTypeByName(Client.AudioSampleType, ST) then ST := tsyFloat32;
+ Q := TxQuantumFor(Client, Rate);
+ if Q <= 0 then Exit;
+
+ // Аванс контроллеру — ДО SetMOX: там по нему заливается нулевой pre-roll.
+ FTxLock.Enter;
+ try
+ Seed := FTxLeadKeep;
+ if (Seed <= 0) or (FTxLeadRate <> Rate) then
+ Seed := (TCI_TX_LEAD_DEF_MS * Rate) div 1000;
+ finally
+ FTxLock.Leave;
+ end;
+ FController.TCITxLeadFrames := Round(Seed * TCI_AUDIO_ENGINE_RATE / Rate);
+
+ TCIFillHeader(H, tstTXChrono, Rx, Rate, ST, Q, Chans);
+ Client.SendBinNow(H);
+ if TraceOn then TR(trkNote, -4, Q, Rx);
+end;
+
+procedure TTCIAdapter.TxPublishLead(Rate: Integer);
+// Аванс переживает передачу и уходит контроллеру: по нему SetMOX растягивает
+// нулевой pre-roll DUC на старте СЛЕДУЮЩЕЙ передачи — это и есть тот запас,
+// который держит очередь между пачками клиента.
+begin
+ FTxLeadKeep := FTxLead;
+ FTxLeadRate := Rate;
+ if Rate > 0 then
+ FController.TCITxLeadFrames :=
+ Round(FTxLead * TCI_AUDIO_ENGINE_RATE / Rate);
+end;
+
+function TTCIAdapter.TxOwedCap: Double;
+// Потолок долга — на квант выше окна в полёте (см. TCI_TX_OWED_HEADROOM_Q).
+// ★Плюс аванс: без этого потолок срезал бы его сразу после выдачи, и запас под
+// пачки клиента не появился бы вовсе.
+begin
+ Result := (FTxWindowQ + TCI_TX_OWED_HEADROOM_Q) * FTxQuantum + FTxLead;
+end;
+
+procedure TTCIAdapter.TxUpdateWindow(Rate: Integer);
+// Окно в полёте по оценённой задержке ответа. Гистерезис зашит в саму оценку
+// (вверх быстро, вниз медленно), поэтому здесь только арифметика и потолки.
+var
+ PeriodUs, WantQ, MaxQ: Integer;
+begin
+ if (FTxQuantum <= 0) or (Rate <= 0) then Exit;
+ PeriodUs := Round(FTxQuantum * 1000000.0 / Rate);
+ if PeriodUs <= 0 then Exit;
+ WantQ := TCI_TX_WINDOW_MIN_Q;
+ if FTxLatencyUs > 0 then
+ WantQ := Max(WantQ, Ceil(FTxLatencyUs / PeriodUs) + 2);
+ // Серверный потолок — во времени звука в полёте, а не в квантах: он про то,
+ // насколько далеко нам позволено забегать вперёд по буферу клиента.
+ MaxQ := Max(TCI_TX_WINDOW_MIN_Q,
+ (TCI_TX_MAX_INFLIGHT_MS * 1000) div PeriodUs);
+ FTxWindowQ := Min(WantQ, MaxQ);
+end;
+
procedure TTCIAdapter.PushTxChrono;
-// Тик-поток. Маркер TX_CHRONO говорит клиенту «пришли столько-то отсчётов»
-// (§3.4). Пейсинг по часам: сколько времени прошло — столько и просим, плюс
-// разовая подушка TX_STREAM_AUDIO_BUFFERING на старте передачи. Ответа не
-// ждём: не успел клиент — в эфир уйдёт тишина, это его забота.
+// Планировщик TX_CHRONO (свой поток сервера, абсолютные дедлайны).
+//
+// Долг растёт по часам, гасится ТОЛЬКО фактически принятым звуком. Маркер
+// уходит, пока непокрытая часть долга больше кванта:
+//
+// Owed − InFlight >= Q
+//
+// ★Сторож. Потерянный ответ навсегда занимает слот в InFlight: сам по себе один
+// такой слот темпа не ломает (долг и окно просто стоят выше), но накопившись до
+// потолка окна они дают ЗАЩЁЛКУ — условие выдачи перестаёт выполняться, маркеры
+// прекращаются, и в эфир идёт тишина до конца посылки. Признак — долгое
+// ОДНОВРЕМЕННОЕ насыщение долга и окна; частично замороженный конвейер при этом
+// продолжает отдавать звук (медленнее реального времени), поэтому сторожа по
+// одному лишь молчанию клиента недостаточно.
var
C: TTCIClient;
- Now_: QWord;
- Rate, Chans, Block, Rx: Integer;
+ NowUs, PeriodUs, StallUs: Int64;
+ Rate, Chans, Q, Rx, Sent, LeadWant: Integer;
ST: TTCISampleType;
H: TTCIStreamHeader;
- Active: Boolean;
+ Active, Saturated: Boolean;
begin
FTxLock.Enter;
try
@@ -3493,40 +3826,148 @@ begin
finally
FTxLock.Leave;
end;
- if C = nil then Exit;
+ if C = nil then
+ begin
+ FServer.TxTickPeriodMs := 0;
+ Exit;
+ end;
Active := FController.TCIMicActive;
- Now_ := GetTickCount64;
- Rate := C.AudioRate;
- Chans := EnsureRange(C.AudioChannels, 1, 2);
- Block := EnsureRange(C.AudioSamples, TCI_AUDIO_SAMPLES_MIN,
- TCI_AUDIO_SAMPLES_MAX);
+ NowUs := MonotonicUs;
+ Rate := C.AudioRate;
+ if not TCIValidAudioRate(Rate) then Rate := TCI_AUDIO_RATE_DEF;
+ Chans := EnsureRange(C.AudioChannels, 1, 2);
if not TCISampleTypeByName(C.AudioSampleType, ST) then ST := tsyFloat32;
FTxLock.Enter;
try
if not Active then
begin
+ if TraceOn and FTxRunning then TR(trkNote, -3, Round(FTxInFlight), 0);
FTxRunning := False;
+ FServer.TxTickPeriodMs := 0;
Exit;
end;
+
if not FTxRunning then
begin
FTxRunning := True;
- FTxLastMs := Now_;
- // Подушка: клиенту нужно время собрать первый блок, а тракт начнёт
- // забирать сэмплы сразу.
- FTxOwed := Rate * (C.TxBuffering / 1000.0);
+ TxResetAccounting(Rate, C);
end
else
begin
- FTxOwed := FTxOwed + Rate * ((Now_ - FTxLastMs) / 1000.0);
- FTxLastMs := Now_;
- // Клиент замолчал, а время идёт — потолок долга держим в один блок,
- // иначе после паузы на него обрушится пачка маркеров.
- if FTxOwed > 4 * Block then FTxOwed := 4 * Block;
+ FTxOwed := FTxOwed + Rate * ((NowUs - FTxLastUs) / 1000000.0);
+ FTxLastUs := NowUs;
+ // Квант держим согласованным с текущими параметрами клиента: он вправе
+ // сменить их посреди передачи.
+ Q := TxQuantumFor(C, Rate);
+ if Q <> FTxQuantum then FTxQuantum := Q;
+ // Клиент сменил параметры потока — прежний аванс измерен не про него.
+ if Rate <> FTxLeadRate then
+ begin
+ FTxLeadRate := Rate;
+ FTxLeadKeep := 0;
+ FTxLead := 0;
+ FTxGapUs := 0;
+ end;
+ TxUpdateWindow(Rate);
+ if FTxOwed > TxOwedCap then FTxOwed := TxOwedCap;
end;
- while FTxOwed >= Block do
+
+ Q := FTxQuantum;
+ if Q <= 0 then Exit;
+
+ // ★Аванс под зернистость клиента. Пачка в N кадров означает, что между
+ // пачками очередь обязана прожить N кадров без подпитки, а подушка
+ // отправителя — всего 10.4 мс. Выдаём разницу ОДИН раз на каждое новое
+ // значение максимума: это не обратная связь по уровню очереди (та управляла
+ // бы темпом запроса, то есть скоростью звука), а разовый сдвиг фазы.
+ LeadWant := Min(Max(Round(FTxGapUs * Rate / 1000000.0) + Q, Q),
+ (TCI_TX_LEAD_MAX_MS * Rate) div 1000);
+ if LeadWant > FTxLead then
+ begin
+ if TraceOn then TR(trkNote, -2, LeadWant, FTxLead);
+ FTxLead := LeadWant;
+ FTxLeadLowUs := 0;
+ TxPublishLead(Rate);
+ end
+ // Вниз — только когда оценка держится ниже целый TCI_TX_LEAD_DOWN_MS.
+ // Гистерезис в один квант: дрожание оценки вокруг текущего значения не
+ // должно раскачивать аванс.
+ else if LeadWant < FTxLead - Q then
+ begin
+ if FTxLeadLowUs = 0 then FTxLeadLowUs := NowUs
+ else if NowUs - FTxLeadLowUs > TCI_TX_LEAD_DOWN_MS * 1000 then
+ begin
+ if TraceOn then TR(trkNote, -2, LeadWant, FTxLead);
+ FTxLead := LeadWant;
+ FTxLeadLowUs := NowUs;
+ TxPublishLead(Rate);
+ end;
+ end
+ else
+ FTxLeadLowUs := 0;
+ PeriodUs := Round(Q * 1000000.0 / Rate);
+ // ★Планировщик будим ВДВОЕ чаще кванта. Маркер может уйти только на
+ // пробуждении, поэтому шаг пробуждений — это и есть зернистость запроса:
+ // при шаге, равном кванту, интервалы слипаются в 1P/2P ровно так же, как
+ // раньше слипались в 2P/3P на общем тике 20 мс. Половина периода даёт
+ // превышение не больше половины кванта — это внутри подушки DUC.
+ FServer.TxTickPeriodMs := Max(1, Integer(PeriodUs div 2000));
+
+ // Сторож вооружается после первого валидного ответа либо по истечении
+ // стартового grace: пока клиент собирает первый блок, насыщение долга
+ // закономерно и прощать нечего.
+ if (not FTxArmed) and
+ (NowUs - FTxProgressUs > TCI_TX_GRACE_MS * 1000) then
+ FTxArmed := True;
+
+ // ★Выдержку считаем по ОДНОМУ долгу: он растёт по часам и гасится только
+ // принятым звуком, поэтому «долг у потолка» и значит «отстаём от реального
+ // времени», какова бы ни была причина. Здоровый конвейер сюда не попадает
+ // даже на медленном канале: там долг стоит около объёма данных в полёте,
+ // то есть на добрых три кванта НИЖЕ потолка (потолок = окно + квант).
+ // ★А вот второе условие (окно выбрано целиком) в выдержку брать нельзя:
+ // частично замороженный конвейер продолжает отвечать, InFlight на каждом
+ // ответе проседает ниже порога — и таймер, привязанный к нему, обнулялся бы
+ // на каждом круге, никогда не досчитывая до срока. Ровно это и наблюдалось:
+ // с шестью зависшими кредитами выдача жила на одном слоте, но прощение не
+ // срабатывало ни разу.
+ Saturated := FTxOwed >= TxOwedCap - FTxQuantum;
+ if Saturated then
+ begin
+ if FTxSatSinceUs = 0 then FTxSatSinceUs := NowUs;
+ StallUs := Max(Int64(TCI_TX_STALL_Q) * PeriodUs,
+ Round(TCI_TX_STALL_RTT_K * FTxLatencyUs));
+ if StallUs > TCI_TX_STALL_MAX_MS * 1000 then
+ StallUs := TCI_TX_STALL_MAX_MS * 1000;
+ if FTxArmed and (NowUs - FTxSatSinceUs > StallUs) and
+ (FTxInFlight >= (FTxWindowQ - 1) * FTxQuantum) then
+ begin
+ // Прощаем ровно один квант и начинаем выдержку заново: поздний ответ
+ // безопасен, он уведёт долг в минус и сам притормозит выдачу.
+ if TraceOn then TR(trkNote, -1, Round(FTxInFlight), FTxWindowQ);
+ FTxInFlight := Max(0, FTxInFlight - Q);
+ // ★Срок сдвигаем НА выдержку, а не на «сейчас». Зависших кредитов может
+ // быть несколько, и прощение по одному за выдержку живого времени
+ // затягивало возврат на секунды: четыре потери подряд оставляли конвейер
+ // на одном рабочем слоте почти всю передачу. Так первый срок остаётся
+ // подтверждением («мы точно отстаём»), а дальше просроченное списывается
+ // подряд, пока условие держится.
+ FTxSatSinceUs := FTxSatSinceUs + StallUs;
+ FTxHealthy := False; // оценку задержки на этом участке не трогаем
+ end;
+ end
+ else
+ begin
+ FTxSatSinceUs := 0;
+ if FTxInFlight <= 0 then FTxHealthy := True;
+ end;
+
+ Sent := 0;
+ while (FTxOwed - FTxInFlight >= Q) and
+ (FTxInFlight + Q <= FTxWindowQ * Q) and
+ (Sent < TCI_TX_MARKERS_PER_TICK) do
begin
// ★Номер приёмника — ТОТ, которым назвался клиент в TRX, а не 0.
// Клиент фильтрует ВХОДЯЩИЕ БИНАРНЫЕ блоки по receiver (MSHV,
@@ -3534,9 +3975,22 @@ begin
// TX-аудио шлёт ровно в ответ на этот маркер (там же, ветка TxChrono).
// С нулём клиент на втором слайсе (tci_trx = 1) поднимал эфир и молчал:
// маркеры до него не доходили вовсе.
- TCIFillHeader(H, tstTXChrono, Rx, Rate, ST, Block, Chans);
- C.SendBin(H, nil, 0);
- FTxOwed := FTxOwed - Block;
+ TCIFillHeader(H, tstTXChrono, Rx, Rate, ST, Q, Chans);
+ // ★Мимо очереди отправки: она выпускается только на пробуждении потока
+ // клиента (recv с таймаутом TCI_POLL_MS = 20 мс), а квант запроса — 10.7
+ // мс. Через очередь маркеры выходили бы пачками раз в 20 мс, и подача
+ // клиента снова стала бы рваной — тот же дефект, что и на старом тике.
+ C.SendBinNow(H);
+ FTxInFlight := FTxInFlight + Q;
+ TxNoteRequest(NowUs);
+ Inc(Sent);
+ if TraceOn then
+ begin
+ if FTxTraceLast = 0 then FTxTraceLast := NowUs;
+ TR(trkChrono, Q, Round(FTxOwed - FTxInFlight),
+ Integer(NowUs - FTxTraceLast));
+ FTxTraceLast := NowUs;
+ end;
end;
finally
FTxLock.Leave;
@@ -3552,6 +4006,7 @@ var
ST: TTCISampleType;
P: PByte;
N, i, k, Chans, Rate, Factor, Bytes, Want: Integer;
+ NowUs: Int64;
Mine: Boolean;
begin
if (Data = nil) or (Len <= SizeOf(H)) then Exit;
@@ -3618,6 +4073,44 @@ begin
end;
if k <= 0 then Exit;
+ // ★Погашение бухгалтерии. Единица — k, моно-кадры ДО интерполятора: ровно
+ // в них назван квант в маркере. Не H.DataLength (значения, вдвое больше
+ // при стерео) и не N ниже (уже 48 кГц, вшестеро больше у клиента на 8 кГц).
+ // Долгу разрешено уйти в минус — клиент вправе прислать больше, чем
+ // просили, и зажим в ноль заставил бы переспросить уже полученное.
+ // Прогресс отмечаем по САМОМУ ФАКТУ валидного блока, независимо от значений
+ // отсчётов: первые ответы MSHV — законные нули (network.cpp, ветка
+ // _reset_sta_ <= 5), и сторож не должен считать их отсутствием прогресса.
+ NowUs := MonotonicUs;
+ // Границы пачки: клиент отвечает не на каждый маркер по отдельности, а
+ // очередями по нескольку блоков — по своей внутренней зернистости записи.
+ // Меряем самую крупную пачку: под неё и нужен аванс.
+ if (FTxLastRxUs > 0) and (NowUs - FTxLastRxUs > TCI_TX_BURST_GAP_US) and
+ (NowUs - FTxLastRxUs < TCI_TX_GAP_MAX_US) then
+ begin
+ // Вверх быстро, вниз медленно: занизить период опаснее, чем завысить —
+ // занижение сразу вернёт осушения, завышение стоит лишь задержки.
+ if FTxGapUs <= 0 then FTxGapUs := NowUs - FTxLastRxUs
+ else if (NowUs - FTxLastRxUs) > FTxGapUs then
+ FTxGapUs := FTxGapUs + 0.50 * ((NowUs - FTxLastRxUs) - FTxGapUs)
+ else
+ FTxGapUs := FTxGapUs + 0.02 * ((NowUs - FTxLastRxUs) - FTxGapUs);
+ end;
+ FTxLastRxUs := NowUs;
+ FTxInFlight := Max(0, FTxInFlight - k);
+ FTxOwed := FTxOwed - k;
+ if FTxQuantum > 0 then
+ FTxOwed := Max(FTxOwed, -TCI_TX_OWED_FLOOR_Q * FTxQuantum);
+ FTxProgressUs := NowUs;
+ FTxArmed := True;
+ TxNoteReply(NowUs);
+ if TraceOn then
+ begin
+ if FTxTraceAudio = 0 then FTxTraceAudio := NowUs;
+ TR(trkTxAudio, Integer(NowUs - FTxTraceAudio), Integer(H.DataLength), k);
+ FTxTraceAudio := NowUs;
+ end;
+
if (FTxInterp = nil) or (FTxInRate <> Rate) then
begin
FreeAndNil(FTxInterp);
diff --git a/TCIServer.pas b/TCIServer.pas
index e6bb7f6..c17ad70 100644
--- a/TCIServer.pas
+++ b/TCIServer.pas
@@ -59,7 +59,7 @@ interface
uses
Classes, SysUtils,
- WebUtils, WsClient, TCIProtocol
+ WebUtils, WsClient, TCIProtocol, PlatformUtils
{$IFDEF WINDOWS}, Windows, WinSock2{$ELSE}, Sockets{$ENDIF},
SyncObjs; // ← после платформенных юнитов (конфликт идентификатора Create)
@@ -67,6 +67,14 @@ const
TCI_MAX_CLIENTS = 8; // прошедших handshake (слоты протокола)
TCI_MAX_SOCKETS = 32; // всего сокетов, включая ещё не поднявшиеся
TCI_TICK_MS = 20; // период OnTick (сенсоры троттлятся адаптером)
+ // ★TX-аудио тикает ОТДЕЛЬНО от сенсоров. Общий тик в 20 мс не может выдавать
+ // запросы с шагом в один TXA-блок (10.667 мс): маркеры выходили через два или
+ // три тика, то есть через 40 или 60 мс, и на каждом трёхтактном интервале
+ // очередь DUC пересыхала — это и были всплески на передаче. Планировщик ниже
+ // ведёт АБСОЛЮТНЫЕ дедлайны, период ему задаёт адаптер (квант / частота
+ // клиента), а опоздание одного пробуждения не сдвигает всю сетку.
+ TCI_TX_TICK_MIN_MS = 1; // ниже не опускаемся: спать точнее всё равно не выйдет
+ TCI_TX_TICK_IDLE_MS = 20; // не передаём — будим планировщик редко
TCI_WS_GUID = '258EAFA5-E914-47DA-95CA-C5AB0DC85B11';
TCI_SEND_TIMEOUT = 300; // мс на SockSend, иначе клиент считается мёртвым
TCI_HANDSHAKE_MS = 5000; // мс на HTTP-запрос от подключившегося
@@ -123,6 +131,13 @@ type
FBinHead: Integer; // куда класть
FBinTail: Integer; // откуда брать
FBinDropped: LongInt; // сколько блоков выброшено (диагностика)
+ // ★Запись в сокет клиента. Обычно пишет только его собственный поток, но
+ // маркеры TX_CHRONO шлёт планировщик TX — им очередь не годится: она
+ // выпускается лишь на пробуждении потока клиента (recv с таймаутом
+ // TCI_POLL_MS), то есть запрос на модуляцию задерживался бы на все 20 мс.
+ // При кванте в 10.7 мс это вдвое больше самого кванта, и подача клиента
+ // становилась рваной ровно так же, как от старого тика 20 мс.
+ FWriteLock: TCriticalSection;
FDead: Boolean; // сокет уже не пишется — гасим соединение
FKilled: Boolean; // shutdown сокета уже сделан
FClosed: Boolean; // клиентский поток вышел (можно освобождать)
@@ -142,6 +157,10 @@ type
{ Блок бинарного потока (заголовок + сэмплы) в очередь. Зовётся из
DSP-потока, поэтому только копирование под коротким локом: сеть тут не
трогается. False — клиент мёртв (блок никуда не пошёл). }
+ { Немедленная отправка блока, минуя очередь: для TX_CHRONO, где 20 мс
+ задержки очереди сопоставимы с самим квантом. Пишет из чужого потока,
+ поэтому под FWriteLock. }
+ function SendBinNow(const Hdr: TTCIStreamHeader): Boolean;
function SendBin(const Hdr: TTCIStreamHeader; Data: Pointer;
Bytes: Integer): Boolean;
{ Сколько блоков потока выброшено из-за отставания клиента. }
@@ -191,6 +210,21 @@ type
FClientLock: TCriticalSection;
FAcceptThread: TThread;
FTickThread: TThread;
+ FTxTickThread: TThread;
+ // ★Освобождение клиентов и планировщик TX_CHRONO обязаны быть взаимно
+ // исключены. Правило «указатель, взятый под FClientLock, живёт до
+ // следующего прохода тика» держалось на том, что клиентов освобождает
+ // ТОЛЬКО тик-поток и он же ими пользуется. У планировщика TX свой поток, и
+ // без этого лока он может писать в клиента, которого ReapClients уже
+ // освободил. Одного FClientLock мало: Reap освобождает память, уже выйдя
+ // из него.
+ FReapLock: TCriticalSection;
+ // ★Будилка планировщика TX. Без неё первый маркер уходил в среднем на 10, а
+ // в худшем на 20 мс позже фронта PTT (поток дремал с шагом
+ // TCI_TX_TICK_IDLE_MS), и ровно эти миллисекунды не хватало нулевому
+ // pre-roll, чтобы дожить до первого ответа клиента.
+ FTxWake: PRTLEvent;
+ FTxKick: Boolean; // «проснись и тикай сейчас», ставит KickTxTick
FThreadCount: LongInt; // живых клиентских потоков (Interlocked*)
FRunning: Boolean;
FStopping: Boolean;
@@ -201,6 +235,8 @@ type
FOnConnect: TTCIClientEvent;
FOnDisconnect: TTCIClientEvent;
FOnTick: TThreadMethod;
+ FOnTxTick: TThreadMethod; // планировщик TX_CHRONO (свой поток)
+ FTxTickPeriodMs: Integer; // период планировщика, мс (ставит адаптер)
function InitListen: Boolean;
procedure ReapClients; // освободить клиентов, чьи потоки вышли
procedure KillAll;
@@ -235,6 +271,11 @@ type
{ Внутреннее (зовётся потоками сервера). }
procedure AcceptLoop;
procedure TickLoop;
+ procedure TxTickLoop;
+ procedure BeginTxClientUse;
+ procedure EndTxClientUse;
+ { Немедленно разбудить планировщик TX_CHRONO — зовётся на фронте передачи. }
+ procedure KickTxTick;
procedure HandleClient(Client: TTCIClient);
function Promote(Client: TTCIClient): Boolean; // handshake прошёл
procedure ThreadDone; // клиентский поток отработал
@@ -250,6 +291,10 @@ type
property OnConnect: TTCIClientEvent read FOnConnect write FOnConnect;
property OnDisconnect: TTCIClientEvent read FOnDisconnect write FOnDisconnect;
property OnTick: TThreadMethod read FOnTick write FOnTick;
+ property OnTxTick: TThreadMethod read FOnTxTick write FOnTxTick;
+ // Период планировщика TX_CHRONO. Адаптер ставит его равным длительности
+ // кванта запроса; 0 — передачи нет, планировщик дремлет.
+ property TxTickPeriodMs: Integer read FTxTickPeriodMs write FTxTickPeriodMs;
end;
{ IPv4 из строки в сетевом порядке. Строгий: ровно четыре десятичных октета
@@ -276,6 +321,15 @@ type
public constructor Create(AServer: TTCIServer);
end;
+ TTCITxTickThread = class(TThread)
+ private
+ FServer: TTCIServer;
+ protected
+ procedure Execute; override;
+ public
+ constructor Create(AServer: TTCIServer);
+ end;
+
TTCITickThread = class(TThread)
private FServer: TTCIServer;
protected procedure Execute; override;
@@ -312,6 +366,18 @@ begin
FServer.TickLoop;
end;
+constructor TTCITxTickThread.Create(AServer: TTCIServer);
+begin
+ inherited Create(True);
+ FServer := AServer;
+ FreeOnTerminate := False;
+end;
+
+procedure TTCITxTickThread.Execute;
+begin
+ FServer.TxTickLoop;
+end;
+
constructor TTCIClientThread.Create(AServer: TTCIServer; AClient: TTCIClient);
begin
inherited Create(True);
@@ -438,6 +504,7 @@ begin
FTxSensors := False;
FTxSensorsMs := 200;
FOutLock := TCriticalSection.Create;
+ FWriteLock := TCriticalSection.Create;
FOutCount := 0;
SetLength(FOut, 64);
FBinHead := 0;
@@ -457,6 +524,7 @@ var i: Integer;
begin
for i := 0 to TCI_BIN_QUEUE - 1 do FBinOut[i] := nil;
FOutLock.Free;
+ FWriteLock.Free;
FStateLock.Free;
inherited;
end;
@@ -624,6 +692,24 @@ begin
end;
end;
+function TTCIClient.SendBinNow(const Hdr: TTCIStreamHeader): Boolean;
+var
+ Blk: TBytes;
+begin
+ Result := False;
+ if FDead or (FWs = nil) then Exit;
+ SetLength(Blk, SizeOf(Hdr));
+ Move(Hdr, Blk[0], SizeOf(Hdr));
+ FWriteLock.Enter;
+ try
+ if FDead or (FWs = nil) then Exit;
+ Result := FWs.SendBinary(Blk[0], Length(Blk));
+ finally
+ FWriteLock.Leave;
+ end;
+ if not Result then FDead := True; // гасит сокет поток клиента (см. Flush)
+end;
+
function TTCIClient.SendBin(const Hdr: TTCIStreamHeader; Data: Pointer;
Bytes: Integer): Boolean;
// Кладёт готовый блок в кольцо. Зовётся из DSP-потока: единственное, что тут
@@ -709,6 +795,9 @@ begin
// Склейка: несколько команд в одном фрейме протокол разрешает (§3.1), а
// syscall'ов и заголовков становится в разы меньше.
+ // ★Под FWriteLock: в тот же сокет пишет планировщик TX (SendBinNow).
+ FWriteLock.Enter;
+ try
Chunk := '';
for i := 0 to N - 1 do
begin
@@ -733,6 +822,9 @@ begin
end;
Bins[i] := nil;
end;
+ finally
+ FWriteLock.Leave;
+ end;
end;
procedure TTCIClient.Kill;
@@ -774,14 +866,20 @@ begin
FClientCount := 0;
FUpCount := 0;
FClientLock := TCriticalSection.Create;
+ FReapLock := TCriticalSection.Create;
+ FTxWake := RTLEventCreate;
+ FTxKick := False;
FPort := TCI_DEFAULT_PORT;
FBindIP := '127.0.0.1';
+ FTxTickPeriodMs := 0; // передачи нет — планировщик TX_CHRONO дремлет
end;
destructor TTCIServer.Destroy;
begin
Stop;
FClientLock.Free;
+ FReapLock.Free;
+ RTLEventDestroy(FTxWake);
{$IFDEF WINDOWS}
WSACleanup;
{$ENDIF}
@@ -865,6 +963,8 @@ begin
TTCIAcceptThread(FAcceptThread).Start;
FTickThread := TTCITickThread.Create(Self);
TTCITickThread(FTickThread).Start;
+ FTxTickThread := TTCITxTickThread.Create(Self);
+ TTCITxTickThread(FTxTickThread).Start;
Result := True;
end;
@@ -918,6 +1018,7 @@ begin
// Шаг 3: будим клиентские потоки, висящие в recv, и останавливаем тик.
KillAll;
JoinPumped(FTickThread);
+ JoinPumped(FTxTickThread);
// Шаг 4: ждём выхода клиентских потоков — БЕЗ таймаута. Прокачивая очередь
// Synchronize: Stop зовёт поток контроллера (UI), а клиентский поток может
@@ -1050,6 +1151,22 @@ begin
end;
end;
+procedure TTCIServer.KickTxTick;
+begin
+ FTxKick := True;
+ RTLEventSetEvent(FTxWake);
+end;
+
+procedure TTCIServer.BeginTxClientUse;
+begin
+ FReapLock.Enter;
+end;
+
+procedure TTCIServer.EndTxClientUse;
+begin
+ FReapLock.Leave;
+end;
+
procedure TTCIServer.ReapClients;
// Освобождение клиентов — единственное место, кроме Stop. Зовёт только
// тик-поток, поэтому указатель, взятый кем угодно под FClientLock, живёт до
@@ -1059,6 +1176,8 @@ var
Doomed: array[0..TCI_MAX_SOCKETS-1] of TTCIClient;
begin
N := 0;
+ FReapLock.Enter;
+ try
FClientLock.Enter;
try
i := 0;
@@ -1084,6 +1203,9 @@ begin
Doomed[i].Ws.Free; // закрывает сокет
Doomed[i].Free;
end;
+ finally
+ FReapLock.Leave;
+ end;
end;
procedure TTCIServer.Disconnected(Client: TTCIClient);
@@ -1473,6 +1595,53 @@ begin
Result := FUpCount;
end;
+procedure TTCIServer.TxTickLoop;
+// Планировщик TX_CHRONO. Отдельный поток и АБСОЛЮТНЫЕ дедлайны: период здесь —
+// длительность одного кванта запроса (около 10.7 мс при 512 отсчётах на 48 кГц),
+// и он не обязан быть кратен чему-либо ещё в сервере. Дробный остаток копится в
+// микросекундах, поэтому сетка не уезжает от округления периода до миллисекунд.
+var
+ NextDueUs: Int64;
+ NowUs: Int64;
+ PeriodUs: Int64;
+ SleepMs: Integer;
+begin
+ NextDueUs := MonotonicUs;
+ while FRunning do
+ begin
+ // Период ставит адаптер по кванту запроса. Пока передачи нет, он нулевой —
+ // но тикать всё равно надо, иначе адаптеру негде будет его выставить, когда
+ // передача начнётся (тик и период определяют друг друга).
+ PeriodUs := Int64(FTxTickPeriodMs) * 1000;
+ if PeriodUs < TCI_TX_TICK_MIN_MS * 1000 then
+ PeriodUs := TCI_TX_TICK_IDLE_MS * 1000;
+
+ NowUs := MonotonicUs;
+ // Фронт передачи: тикаем немедленно, не дожидаясь дремотного шага.
+ if FTxKick then
+ begin
+ FTxKick := False;
+ NextDueUs := NowUs;
+ end;
+ if NextDueUs <= NowUs then
+ begin
+ if Assigned(FOnTxTick) then FOnTxTick;
+ Inc(NextDueUs, PeriodUs);
+ // Проспали больше периода (планировщик ОС, пауза процесса) — не
+ // отыгрываем пропущенные тики пачкой: долг всё равно считается по часам
+ // внутри адаптера, а пачка маркеров только раздует окно в полёте.
+ NowUs := MonotonicUs;
+ if NextDueUs < NowUs then NextDueUs := NowUs;
+ Continue;
+ end;
+
+ SleepMs := Integer((NextDueUs - NowUs) div 1000);
+ if SleepMs <= 0 then SleepMs := TCI_TX_TICK_MIN_MS;
+ // Просыпаемся либо по расписанию, либо по звонку с фронта PTT.
+ RTLEventWaitFor(FTxWake, SleepMs);
+ end;
+end;
+
procedure TTCIServer.TickLoop;
begin
while FRunning do
diff --git a/TxTrace.pas b/TxTrace.pas
new file mode 100644
index 0000000..2e4cb1a
--- /dev/null
+++ b/TxTrace.pas
@@ -0,0 +1,139 @@
+unit TxTrace;
+
+{
+ TxTrace.pas — кольцевая трассировка TX-тракта.
+
+ ★Временный диагностический юнит. Выключен по умолчанию, включается
+ переменной окружения EWSDR_TXTRACE=1; при выключенной трассировке TR() —
+ одна проверка Boolean. Никакого ввода-вывода в горячем пути: кольцо
+ сбрасывается на диск в конце каждой передачи.
+
+ Зачем он снова здесь: всплески на передаче вернулись после того, как первая
+ редакция зондов была удалена, и без них видно только то, что видит глаз на
+ водопаде. Прибор смотрит ровно на подачу TX-аудио по TCI и на очередь DUC.
+}
+
+{$IFDEF FPC}
+ {$MODE Delphi}
+{$ENDIF}
+
+interface
+
+const
+ trkPtt = 0; // A=1 старт передачи / 0 конец; B=источник модуляции
+ trkChrono = 7; // A=запрошено кадров; B=непокрытый долг; C=пауза от прошлого маркера, мкс
+ trkTxAudio = 8; // A=пауза от прошлого блока, мкс; B=length в шапке; C=кадров после интерполяции
+ trkDucDry = 10; // A=виртуальный уровень FIFO радио; B=глубина очереди
+ trkDucRun = 11; // A=длительность простоя очереди, мкс; B=уровень FIFO; C=глубина очереди
+ trkNote = 12; // A=-1 прощение кредита, -2 смена аванса, -3 в полёте на снятии PTT, -4 предварительный маркер
+
+var
+ TraceOn: Boolean = False;
+
+procedure TraceInit;
+procedure TR(Kind: Integer; A: Integer = 0; B: Integer = 0; C: Integer = 0);
+function TraceDump(const Reason: string): string;
+
+implementation
+
+uses
+ SysUtils, Math, PlatformUtils;
+
+const
+ TRACE_SIZE = 16384;
+ TRACE_MASK = TRACE_SIZE - 1;
+
+type
+ TTraceRec = record
+ T: Int64;
+ Kind: Integer;
+ A, B, C: Integer;
+ end;
+
+var
+ Ring: array[0..TRACE_SIZE - 1] of TTraceRec;
+ RingSeq: LongInt = 0;
+ Epoch: Int64 = 0;
+
+const
+ TRACE_NAMES: array[0..12] of string = (
+ 'PTT', 'RECV-GAP', 'HANDLER', 'HP-SYNC', 'WIDEBAND', 'TX-TICK',
+ 'TX-PHASE', 'CHRONO', 'TX-AUDIO', 'PULL-MIC', 'DUC-DRY', 'DUC-RUN', 'NOTE');
+
+procedure TraceInit;
+var
+ S: string;
+begin
+ S := LowerCase(Trim(GetEnvironmentVariable('EWSDR_TXTRACE')));
+ TraceOn := (S = '1') or (S = 'true') or (S = 'yes') or (S = 'on');
+ Epoch := MonotonicUs;
+end;
+
+procedure TR(Kind: Integer; A: Integer = 0; B: Integer = 0; C: Integer = 0);
+var
+ Idx: LongInt;
+ P: ^TTraceRec;
+begin
+ if not TraceOn then Exit;
+ Idx := InterlockedIncrement(RingSeq) - 1;
+ P := @Ring[Idx and TRACE_MASK];
+ P^.T := MonotonicUs;
+ P^.Kind := Kind;
+ P^.A := A;
+ P^.B := B;
+ P^.C := C;
+end;
+
+function Slug(const S: string): string;
+var i: Integer;
+begin
+ Result := '';
+ for i := 1 to Length(S) do
+ if S[i] in ['a'..'z', 'A'..'Z', '0'..'9', '-', '_'] then
+ Result := Result + S[i];
+ if Result <> '' then Result := '-' + Copy(Result, 1, 24);
+end;
+
+function TraceDump(const Reason: string): string;
+var
+ F: TextFile;
+ i, First: LongInt;
+ Total: LongInt;
+ R: TTraceRec;
+ Name: string;
+begin
+ Result := '';
+ if not TraceOn then Exit;
+ Total := RingSeq;
+ if Total <= 0 then Exit;
+
+ Result := GetAppCfgDir + 'txtrace-' +
+ FormatDateTime('yyyymmdd-hhnnss', Now) + Slug(Reason) + '.txt';
+ AssignFile(F, Result);
+ try
+ Rewrite(F);
+ try
+ WriteLn(F, '# ewsdr TX trace, повод: ', Reason);
+ WriteLn(F, '# всего событий: ', Total, ', в кольце: ',
+ Min(Total, TRACE_SIZE));
+ WriteLn(F, '# t_ms событие A B C');
+ First := Total - Min(Total, TRACE_SIZE);
+ for i := First to Total - 1 do
+ begin
+ R := Ring[i and TRACE_MASK];
+ if (R.Kind < Low(TRACE_NAMES)) or (R.Kind > High(TRACE_NAMES)) then
+ Name := '?'
+ else
+ Name := TRACE_NAMES[R.Kind];
+ WriteLn(F, Format('%12.3f %-9s %9d %9d %9d',
+ [(R.T - Epoch) / 1000.0, Name, R.A, R.B, R.C]));
+ end;
+ finally
+ CloseFile(F);
+ end;
+ except
+ on E: Exception do Result := '';
+ end;
+end;
+
+end.
diff --git a/ewsdr.lpi b/ewsdr.lpi
index d173129..aec4e27 100644
--- a/ewsdr.lpi
+++ b/ewsdr.lpi
@@ -17,9 +17,9 @@
-
+
-
+
@@ -168,6 +168,18 @@
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/ewsdr.lpr b/ewsdr.lpr
index 317be48..6f8b65f 100644
--- a/ewsdr.lpr
+++ b/ewsdr.lpr
@@ -18,11 +18,12 @@ uses
Forms, LazOpenGLContextEx,
MainForm, AudioOutput, DeviceForm, FlatButton, FreqDisplay, VfoOverlay,
WDSP, WDSPEngine, WebPageHtml, WebServer, WebUtils, WinFirewall, WsClient,
- Settings, RadioController;
+ Settings, RadioController, TxTrace;
{$R *.res}
begin
+ TraceInit;
EnableProcessDpiAwareness;
RequireDerivedFormResource := True;
Application.Title:='EWSDR';
diff --git a/ewsdrd.lpr b/ewsdrd.lpr
index f04e51c..5dd6599 100644
--- a/ewsdrd.lpr
+++ b/ewsdrd.lpr
@@ -28,7 +28,7 @@ uses
{$IFDEF UNIX}cthreads, BaseUnix,{$ENDIF}
Classes, SysUtils, Math,
RadioController, WebServer, WebAdapter, WDSPEngine,
- HPSDRNetwork, Settings, WDSP, PlatformUtils, DeviceStore, BoardUtils;
+ HPSDRNetwork, Settings, WDSP, PlatformUtils, DeviceStore, BoardUtils, TxTrace;
const
PUSH_INTERVAL_MS = 50; // период исходящего web-зеркала (как GUI-таймер)
@@ -490,6 +490,7 @@ begin
end;
begin
+ TraceInit;
OnGetApplicationName := @DaemonAppName; // ДО создания контроллера/настроек!
Log('EWSDR daemon starting (headless).');
GHost := THeadlessHost.Create;
diff --git a/test/hpsdr/README.md b/test/hpsdr/README.md
new file mode 100644
index 0000000..a6d646a
--- /dev/null
+++ b/test/hpsdr/README.md
@@ -0,0 +1,145 @@
+# Разбор всплесков на передаче по трафику радио (openHPSDR, протокол 2)
+
+Всплеск на передаче в этом тракте может родиться в трёх разных местах, и
+глазами их не отличить. Дамп трафика отличает, потому что в проводе лежит
+ровно то, что мы отдали радио, и ровно то, что радио вернуло:
+
+| где родился | что видно в дампе |
+|---|---|
+| наш DSP / очередь TX | разрыв **внутри** DUC I&Q: скачок на стыке пакетов, клиппинг, дыра по времени |
+| сеть / провод | потеря `seq` DUC I&Q (наружу) или DDC I&Q (внутрь), дубли, переупорядочение |
+| радио / прошивка | DUC I&Q ушёл ровным, а радио жалуется: FIFO overflow, пустой DUC FIFO |
+| приёмный тракт ПК | на проводе всё цело, но растут `RcvbufErrors`/`drops` — мусор на водопаде, а не в эфире |
+
+Последняя строка важнее, чем кажется: **всплеск, увиденный на своём водопаде,
+может вообще не уйти в эфир** — это может быть дыра в приёмном потоке DDC.
+Инструмент это разделяет.
+
+## 1. Съём
+
+```
+sudo test/hpsdr/capture.sh # bridge0, радио 172.16.2.200
+sudo test/hpsdr/capture.sh -i eth0 -h 172.16.2.99 -d /var/tmp/cap
+```
+
+Пишется кольцо `-W 24 -C 150` (24 файла по 150 МБ ≈ 10-15 мин полного дуплекса)
+плюс `*.drops` — счётчики потерь ядра раз в секунду.
+
+Явление редкое, поэтому работаем так: запустили съём, работаем в эфире как
+обычно. **Увидели всплеск — засекли время по часам и дали ещё секунд десять,
+потом Ctrl-C.** Кольцо гарантирует, что последние минуты на диске, а засечённое
+время сразу показывает, в какую строку отчёта смотреть.
+
+Место: полный дуплекс на 192 кГц TX + пара DDC ≈ 3-6 МБ/с. Кольцо не растёт.
+
+## 2. Разбор
+
+```
+test/hpsdr/pcap_tx_scan.py /tmp/hpsdrcap/hpsdr-*.pcap \
+ --watch-ddc 3 --drops /tmp/hpsdrcap/hpsdr-*.drops
+```
+
+**`--watch-ddc N` — главный ключ, если вы смотрите свой сигнал на конкретном
+приёмнике** (DDC3 = UDP-порт 1038). Тогда скан ищет всплески прямо в приёмном
+потоке и сопоставляет каждый с тем, что в этот момент ушло в эфир. Без ключа
+всплески ищутся на всех DDC.
+
+Прочие ключи: `--tx-only` (события только внутри передачи), `--radio IP` (если
+автоопределение промахнулось), `--corr СЕК` (окно сопоставления, по умолчанию
+0.05), `--max N`, `--env env.csv` (профиль огибающей по пакетам — можно
+построить график и увидеть форму всплеска).
+
+## 3. Как читать отчёт
+
+**`=== передачи ===`** — по каждому нажатию PTT: сколько DUC-пакетов реально
+ушло против расчётных `длительность / 1.25 мс`. Минус десятки пакетов = очередь
+TX голодала, в эфире дыры. Тут же диапазон глубины DUC FIFO радио: здоровая
+передача держит его далеко от нуля.
+
+**`=== сопоставление ===`** — ради этого всё и делалось. По каждому всплеску,
+найденному в приёмном потоке, печатается всё, что случилось в окне ±50 мс, со
+смещением в миллисекундах, и вердикт. Смещение важно: свой сигнал возвращается
+в приёмник с задержкой тракта (ЦАП, эфир, конвейер DDC), поэтому причина обязана
+стоять с **минусом** — раньше всплеска. Вердикты: `НАШ ТРАКТ` (в DUC I&Q рядом
+`BREAK`/`CLIP` — сигнал ушёл в провод уже испорченным), `АРТЕФАКТ ПРИЁМА` (дыра
+в самом приёмном потоке или `SOCK-DROP` — в эфир всплеск не уходил), `РАДИО`,
+`ПРОВОД`, `КОМАНДА НА ХОДУ`, `ЖЕЛЕЗО` (рядом чисто в обе стороны).
+
+**`=== конфигурация DDC ===`** — из перехваченного DDC Specific: источник
+каждого включённого DDC (`ADC0` = эфир, `DAC` = петля PureSignal) и его частота
+дискретизации. Источник решает, что вообще означает увиденный всплеск: с ADC —
+это сигнал в антенне, с DAC — только то, что ушло в модулятор.
+
+**События, по убыванию доказательности:**
+
+* `RX-SPIKE` — выброс над фоном в приёмном потоке. Ищется **только на передаче**:
+ там свой сигнал держит ровный уровень, и всё, что торчит над ним, — событие.
+ На приёме эфир скачет сам, детектор был бы бессмысленным. Первые 250 мс после
+ фронта PTT пропускаются: уровень там меняется законно.
+* `BREAK` — последний отсчёт пакета N и первый отсчёт N+1 разошлись больше чем
+ на 45% огибающей. На 192 ksps соседние отсчёты речевого сигнала так не прыгают:
+ это разрыв фазы/амплитуды, то есть щелчок или всплеск шириной во весь фильтр.
+ Родился до провода, значит у нас — в WDSP, в сборке пакетов или в очереди.
+ (Ровно эта подпись однажды уже ловилась: `bfo=0` в `OpenChannel(TXA)`.)
+ Ищется только в исходящем DUC: на приёме шум эфира прыгает от отсчёта к
+ отсчёту сам по себе, а разрыв от потерянного пакета и так виден по `seq`.
+* `CLIP` — упор в полную шкалу DAC, печатается по фронту с длительностью.
+* `TX-STARVE` / `FIFO-OVF` — жалоба самого радио: DUC FIFO пуст на передаче
+ либо переполнение. При пустом FIFO радио передаёт что попало.
+* `SOCK-DROP` — на проводе пакеты были, ядро их выбросило. Тогда всплеск на
+ экране — артефакт приёма, в эфир он не уходил.
+* `HOLE` — пауза между пакетами больше 2.5 периодов потока. Для DUC норма
+ 1.25 мс, для DDC период выучивается по медиане первых 200 интервалов.
+* `SEQ-LOSS` / `SEQ-DUP` — потери и дубли в проводе, отдельно наружу и внутрь.
+* `HP-CHG` — High Priority сменился ПОСРЕДИ передачи, с расшифровкой поля
+ (частота DDC/DUC, drive, Alex, атт., OC). Смена частоты или drive на ходу —
+ это клик по построению.
+* `DUCSPEC` / `DDCSPEC` — Specific-пакеты, ушедшие на передаче.
+
+**Пусто во всех разделах при засечённом всплеске** — значит трактовка меняется:
+цифровой поток чист от нас до радио и обратно, ищем дальше по железу (PA, ALC,
+питание, реле), а не в софте.
+
+## Файлы
+
+* `capture.sh` — кольцевой съём tcpdump + лог потерь ядра.
+* `pcap_tx_scan.py` — разбор pcap, чистый Python без зависимостей (~40 МБ/с).
+
+## Приборы внутри процесса: кольцевая трассировка TX-тракта
+
+Дамп на проводе видит ПАУЗУ в потоке DUC, но не видит, кто её сделал. Для этого
+в код вписано кольцо трассировки (`TxTrace.pas`), выключенное по умолчанию.
+
+Включение — переменной окружения, кольцо сбрасывается на диск в конце КАЖДОЙ
+передачи (после снятия PTT, чтобы файл не задержал выход из эфира):
+
+ EWSDR_TXTRACE=1 ./bin/x86_64-linux/ewsdr
+ # файлы: ~/.config/ewsdr/txtrace-<дата>-tx.txt
+
+Разбор: `./txtrace_scan.py ~/.config/ewsdr/txtrace-*-tx.txt`
+
+Что пишется (только выбросы там, где событий тысячи в секунду):
+
+| событие | A | B | C |
+|---|---|---|---|
+| `PTT` | 1 старт / 0 конец | источник модуляции: 0 радио, 1 звуковая карта, 2 внешняя подача (web/TCI) | |
+| `RECV-GAP` | пауза между возвратами `recvfrom` >2 мс | порт | |
+| `HANDLER` | длительность обработчика >2 мс | порт | длина пакета |
+| `HP-SYNC` | длительность `Invoke(ApplyHPStatus)` = `TThread.Synchronize` >2 мс | | |
+| `TX-TICK` | зазор между тиками TX-потока | уровень mic-ринга НА ВХОДЕ тика | блоков сделано |
+| `TX-PHASE` | `fexchange0` | кормление TX-анализатора (стоит ПЕРЕД отдачей IQ) | отдача IQ в очередь DUC |
+| `CHRONO` | сколько отсчётов просим у клиента TCI | остаток долга | пауза от прошлого маркера |
+| `TX-AUDIO` | пауза от прошлого блока от клиента | `length` из шапки клиента | отсчётов после интерполяции |
+| `PULL-MIC` | запрошено у звуковой карты | реально получено | пауза от прошлой тяги |
+| `DUC-DRY` | виртуальный уровень FIFO радио | глубина очереди | |
+| `DUC-RUN` | длительность простоя очереди | уровень FIFO на выходе | глубина очереди |
+
+Ключевые чтения:
+
+* `DUC-RUN` длиннее 10.42 мс (подушка `DUC_FIFO_THROTTLE`) = кандидат на всплеск;
+ скрипт печатает историю событий перед каждым таким простоем;
+* `TX-TICK` с `B` меньше 512 = продюсер не успел, тракт тут ни при чём;
+* `TX-AUDIO` с периодом около 40-60 мс = зернистость запроса TCI (тик 20 мс
+ против блока 2048 отсчётов = 42.7 мс), а не сбой сети;
+* пара (`B`, `C`) у `TX-AUDIO` проверяет учёт каналов: при стерео `length`
+ обязан быть вдвое больше моно-отсчётов до интерполяции.
diff --git a/test/hpsdr/capture.sh b/test/hpsdr/capture.sh
new file mode 100755
index 0000000..e4ed60d
--- /dev/null
+++ b/test/hpsdr/capture.sh
@@ -0,0 +1,66 @@
+#!/usr/bin/env bash
+# Съём трафика радио openHPSDR для разбора всплесков на передаче.
+#
+# sudo test/hpsdr/capture.sh [-i bridge0] [-h 172.16.2.200] [-d /tmp/hpsdrcap]
+#
+# Пишет кольцевой буфер pcap (по умолчанию 24 x 150 МБ ~= 3.6 ГБ, это ~10-15 мин
+# полного дуплекса) плюс лог счётчиков UDP-потерь ядра раз в секунду.
+# Услышал/увидел всплеск — ЗАСЕКИ ВРЕМЯ и жми Ctrl-C: последние файлы кольца
+# и есть нужный кусок.
+
+set -euo pipefail
+
+IFACE=bridge0
+RADIO=172.16.2.200
+OUTDIR=/tmp/hpsdrcap
+FILES=24
+SIZE=150
+
+while getopts "i:h:d:n:s:" opt; do
+ case "$opt" in
+ i) IFACE=$OPTARG ;;
+ h) RADIO=$OPTARG ;;
+ d) OUTDIR=$OPTARG ;;
+ n) FILES=$OPTARG ;;
+ s) SIZE=$OPTARG ;;
+ *) echo "usage: $0 [-i iface] [-h radio_ip] [-d outdir] [-n ring_files] [-s mb_per_file]" >&2; exit 2 ;;
+ esac
+done
+
+if [ "$(id -u)" != 0 ]; then
+ echo "нужен root (tcpdump): sudo $0 ..." >&2
+ exit 1
+fi
+
+mkdir -p "$OUTDIR"
+STAMP=$(date +%Y%m%d-%H%M%S)
+echo "iface=$IFACE radio=$RADIO out=$OUTDIR/hpsdr-$STAMP-NNN.pcap ring=${FILES}x${SIZE}MB"
+echo "старт: $(date -Ins)"
+
+# Счётчики потерь ядра. Если растут — пакеты терялись НЕ на проводе, а в сокете
+# приложения (мал rcvbuf / поток приёма не успевает), и в pcap всё будет чисто.
+# drops=N в skmem — счётчик отброшенных ядром датаграмм этого сокета.
+(
+ while :; do
+ printf '%s' "$(date +%s.%N)"
+ awk '/^Udp:/{getline; printf " InErrors=%s RcvbufErrors=%s", $4, $6; exit}' /proc/net/snmp
+ ss -uanm 2>/dev/null | awk '
+ /^(UNCONN|ESTAB)/ { port=$4; rq=$2; next }
+ /skmem:/ {
+ rb=0; d=0;
+ if (match($0, /rb[0-9]+/)) rb = substr($0, RSTART+2, RLENGTH-2);
+ if (match($0, /,d[0-9]+\)/)) d = substr($0, RSTART+2, RLENGTH-3);
+ if (rb+0 > 1000000) printf " sock=%s rq=%s drops=%s", port, rq, d;
+ }'
+ echo
+ sleep 1
+ done
+) > "$OUTDIR/hpsdr-$STAMP.drops" &
+DROPPID=$!
+trap 'kill $DROPPID 2>/dev/null || true' EXIT
+
+# Без exec: exec заменил бы процесс оболочки и trap EXIT не сработал бы,
+# оставив сборщик .drops сиротой навсегда.
+tcpdump -i "$IFACE" -s 0 -B 65536 -n --time-stamp-precision=nano \
+ -W "$FILES" -C "$SIZE" -w "$OUTDIR/hpsdr-$STAMP.pcap" \
+ "udp and host $RADIO"
diff --git a/test/hpsdr/pcap_tx_scan.py b/test/hpsdr/pcap_tx_scan.py
new file mode 100755
index 0000000..ce0000b
--- /dev/null
+++ b/test/hpsdr/pcap_tx_scan.py
@@ -0,0 +1,843 @@
+#!/usr/bin/env python3
+"""
+Разбор pcap с трафиком радио openHPSDR (Ethernet Protocol 2) — поиск причины
+всплесков на передаче.
+
+ test/hpsdr/pcap_tx_scan.py /tmp/hpsdrcap/hpsdr-*.pcap [--radio 172.16.2.200]
+
+Что смотрим (по порядку доказательности):
+
+ * DUC I&Q (PC->1029, 240 пар 24 бит, 1.25 мс на пакет) — наш передаваемый
+ сигнал ровно в том виде, в каком он ушёл в провод:
+ - разрывы/дубли/сбросы seq;
+ - дыры по времени отправки (очередь опустела => FIFO радио голодает);
+ - СКАЧОК НА СТЫКЕ ПАКЕТОВ (последний отсчёт N против первого N+1) —
+ подпись разрыва фазы/амплитуды, того самого щелчка в эфире;
+ - упор в полную шкалу (клиппинг DAC).
+ * High Priority (PC->1027) — фронты PTT и ЛЮБОЕ изменение содержимого
+ (частота, drive, Alex, атт., OC) посреди передачи: это клик.
+ * DUC/DDC Specific (PC->1026 / PC->1025) — отправка посреди передачи.
+ * HP Status (радио->1025) — что говорит само радио: overflow-биты, глубина
+ DUC FIFO (0 = голод => разрыв в эфире), перегруз ADC, PWR fwd/rev.
+ * Mic (радио->1026) — дыры во входном потоке микрофона (голод продюсера).
+ * DDC I&Q (радио->1035+) — приёмный тракт: потери seq на приёме дают
+ мусор на водопаде, который легко принять за всплеск своего сигнала.
+"""
+
+import argparse
+import cmath
+import datetime
+import math
+import os
+import re
+import struct
+import sys
+from array import array
+from collections import defaultdict
+
+# --- порты openHPSDR P2 -----------------------------------------------------
+P_COMMAND, P_DDC_SPEC, P_DUC_SPEC, P_HP_FROM_PC = 1024, 1025, 1026, 1027
+P_HP_TO_PC, P_MIC, P_WIDEBAND = 1025, 1026, 1027
+P_DDC_AUDIO, P_DUC_IQ, P_DDC0_IQ = 1028, 1029, 1035
+
+DUC_PAIRS = 240
+DUC_RATE = 192000.0
+DUC_DT = DUC_PAIRS / DUC_RATE # 1.25 мс
+
+# Подушка отправителя: DUC_FIFO_THROTTLE = 2000 отсчётов в HPSDRNetwork.pas,
+# то есть поток отправки уходит вперёд реального времени не больше чем на
+# 2000/192000 с. Пауза длиннее этого гарантированно осушает FIFO радио:
+# ЦАП остаётся без данных, в эфире разрыв несущей.
+DUC_CUSHION = 2000 / DUC_RATE # 10.4 мс
+
+# Частоты в High Priority — ФАЗОВЫЕ СЛОВА, а не герцы: ewsdr шлёт их через
+# FreqToPhaseWord (HPSDRNetwork.pas). Читать поле как Гц — получить сотни МГц.
+DSP_CLOCK = 122880000.0
+
+
+def phase_khz(b, o):
+ return struct.unpack('>I', b[o:o + 4])[0] * DSP_CLOCK / 4294967296.0 / 1000.0
+
+FFT_N = 1024
+_FFT_WIN = [0.5 - 0.5 * math.cos(2 * math.pi * i / FFT_N) for i in range(FFT_N)]
+_FFT_REF = 8388608.0 * FFT_N / 2
+
+
+def fft_inplace(a):
+ """Радикс-2 по месту. Нужен свой: numpy в системе может не быть."""
+ n = len(a)
+ j = 0
+ for i in range(1, n):
+ b = n >> 1
+ while j & b:
+ j ^= b
+ b >>= 1
+ j |= b
+ if i < j:
+ a[i], a[j] = a[j], a[i]
+ ln = 2
+ while ln <= n:
+ w = cmath.exp(-2j * math.pi / ln)
+ h = ln >> 1
+ for i in range(0, n, ln):
+ wl = 1 + 0j
+ for k in range(h):
+ u = a[i + k]
+ v = a[i + k + h] * wl
+ a[i + k] = u + v
+ a[i + k + h] = u - v
+ wl *= w
+ ln <<= 1
+ return a
+
+
+def spectrum_db(chunk):
+ """Спектр кадра в dBFS, бин за бином.
+
+ Водопад показывает СПЕКТР, а не пик огибающей, и смотреть надо КАЖДЫЙ БИН
+ против его собственного фона. Медиана всех бинов (как считалось раньше)
+ ловит только широкополосную засветку во всю строку и слепа к главному
+ случаю: когда поток отсчётов прерывается, модуляция пропадает и в эфире
+ остаётся голая несущая на частоте гетеродина DUC — это один-два бина в
+ стороне от полезного тона, медиана их не замечает вовсе.
+ """
+ a = [chunk[i] * _FFT_WIN[i] for i in range(FFT_N)]
+ return [20 * math.log10(max(abs(x), 1e-9) / _FFT_REF) for x in fft_inplace(a)]
+
+
+def peak16(pl, o, end, n):
+ """Пик |отсчёта| по старшим ДВУМ байтам 24-битных отсчётов, 0..32768.
+
+ По одному старшему байту считать нельзя: приёмные потоки живут на -40 dBFS,
+ там весь пакет квантуется в единицу и всплеск неотличим от фона. Собираем
+ 16-битные слова через срезы bytearray и struct — всё на скорости C.
+ """
+ buf = bytearray(n * 2)
+ buf[0::2] = pl[o:end:6]
+ buf[1::2] = pl[o + 1:end:6]
+ v = struct.unpack('>%dh' % n, buf)
+ return max(max(v), -min(v))
+
+
+FS16 = 32768.0 # полная шкала в единицах peak16
+
+
+def s24(b, o):
+ v = (b[o] << 16) | (b[o + 1] << 8) | b[o + 2]
+ return v - 0x1000000 if v & 0x800000 else v
+
+
+def ip4(b):
+ return '%d.%d.%d.%d' % (b[0], b[1], b[2], b[3])
+
+
+# --- чтение pcap ------------------------------------------------------------
+def iter_pcap(path):
+ with open(path, 'rb') as f:
+ magic = f.read(4)
+ if magic == b'\x0a\x0d\x0d\x0a':
+ raise SystemExit('%s: это pcapng; снимайте tcpdump -w (классический pcap)' % path)
+ if magic == b'\xd4\xc3\xb2\xa1':
+ en, nano = '<', False
+ elif magic == b'\xa1\xb2\xc3\xd4':
+ en, nano = '>', False
+ elif magic == b'\x4d\x3c\xb2\xa1':
+ en, nano = '<', True
+ elif magic == b'\xa1\xb2\x3c\x4d':
+ en, nano = '>', True
+ else:
+ raise SystemExit('%s: не pcap (magic %s)' % (path, magic.hex()))
+ _, _, _, _, _, link = struct.unpack(en + 'HHiIII', f.read(20))
+ div = 1e9 if nano else 1e6
+ ph = struct.Struct(en + 'IIII')
+ while True:
+ h = f.read(16)
+ if len(h) < 16:
+ return
+ sec, frac, incl, _orig = ph.unpack(h)
+ data = f.read(incl)
+ if len(data) < incl:
+ return
+ rec = decode_link(link, data)
+ if rec:
+ yield (sec + frac / div,) + rec
+
+
+def decode_link(link, d):
+ if link == 1: # Ethernet
+ if len(d) < 14:
+ return None
+ et = (d[12] << 8) | d[13]
+ off = 14
+ while et in (0x8100, 0x88A8) and len(d) >= off + 4:
+ et = (d[off + 2] << 8) | d[off + 3]
+ off += 4
+ elif link == 113: # LINUX_SLL
+ if len(d) < 16:
+ return None
+ et, off = (d[14] << 8) | d[15], 16
+ elif link == 276: # LINUX_SLL2
+ if len(d) < 20:
+ return None
+ et, off = (d[0] << 8) | d[1], 20
+ else:
+ return None
+ if et != 0x0800 or len(d) < off + 20:
+ return None
+ ihl = (d[off] & 0x0F) * 4
+ if d[off + 9] != 17: # не UDP
+ return None
+ if ((d[off + 6] << 8) | d[off + 7]) & 0x1FFF: # не первый фрагмент
+ return None
+ src, dst = ip4(d[off + 12:off + 16]), ip4(d[off + 16:off + 20])
+ u = off + ihl
+ if len(d) < u + 8:
+ return None
+ sport, dport, ulen = struct.unpack('>HHH', d[u:u + 6])
+ return (src, sport, dst, dport, d[u + 8:u + max(8, ulen)])
+
+
+# --- потоки -----------------------------------------------------------------
+class IQStream:
+ """Общая часть для DUC (наш TX) и DDC (приём): seq, тайминг, огибающая."""
+
+ def __init__(self, name, hdr, pairs, dt=0.0):
+ self.name, self.hdr, self.pairs = name, hdr, pairs
+ self.dt = dt # 0 = период потока выучим по факту
+ self.gaps = []
+ self.n = self.lost = self.dup = 0
+ self.seq = None
+ self.ts = None
+ self.last = None # (I, Q) последнего отсчёта пакета
+ self.peak = 0 # оценка пика предыдущего пакета (0..128)
+ self.first_ts = self.last_ts = None
+ self.env = [] # (ts, peak) — профиль огибающей
+ self.clip = False # состояние «упёрлись в шкалу»
+ self.clip_from = None
+ self.clip_n = 0
+ self.brk = False # искать ли разрыв на стыке пакетов
+ self.watch = False # искать ли всплески в огибающей
+ self.ema = None # фон уровня, скользящее среднее
+ self.spike = False
+ self.quiet_until = 0.0 # после фронта PTT уровень скачет законно
+ self.pkmax = 0
+ self.holes = [] # (ts, длительность паузы)
+
+ def feed(self, ts, pl, ev, tx_on):
+ need = self.hdr + self.pairs * 6
+ if len(pl) < need:
+ return
+ seq = struct.unpack('>I', pl[0:4])[0]
+ self.n += 1
+ if self.first_ts is None:
+ self.first_ts = ts
+ self.last_ts = ts
+
+ if self.seq is not None:
+ d = (seq - self.seq) & 0xFFFFFFFF
+ if d != 1:
+ if d == 0 or d > 0x80000000:
+ self.dup += 1
+ ev(ts, 'SEQ-DUP', self.name, 'seq %u повтор/назад (было %u)' % (seq, self.seq))
+ else:
+ self.lost += d - 1
+ ev(ts, 'SEQ-LOSS', self.name,
+ 'потеряно %d пакет(ов) (%u -> %u)' % (d - 1, self.seq, seq))
+ self.seq = seq
+
+ # Между передачами DUC-поток молчит по определению — паузу в несколько
+ # секунд нельзя считать голоданием очереди.
+ if self.brk and not tx_on:
+ self.ts = None
+ if self.ts is not None:
+ gap = ts - self.ts
+ if self.dt <= 0:
+ # период потока в протоколе не объявлен (зависит от sample rate
+ # DDC) — учим по медиане первых двух сотен интервалов.
+ self.gaps.append(gap)
+ if len(self.gaps) >= 200:
+ self.gaps.sort()
+ self.dt = self.gaps[len(self.gaps) // 2]
+ elif gap > self.dt * 2.5:
+ self.holes.append((ts, gap))
+ # Датируем НАЧАЛОМ паузы: FIFO радио сохнет через DUC_CUSHION
+ # после её начала, и всплеск в эфире идёт оттуда, а не от конца.
+ t_gap = ts - gap
+ if self.brk and gap > DUC_CUSHION:
+ ev(t_gap, 'UNDERRUN', self.name,
+ 'очередь TX пуста %.2f мс — дольше подушки отправителя '
+ '%.1f мс, значит FIFO радио сохнет ~%.1f мс: модуляция '
+ 'обрывается, в эфире остаётся голая несущая'
+ % (gap * 1e3, DUC_CUSHION * 1e3, (gap - DUC_CUSHION) * 1e3))
+ else:
+ ev(t_gap, 'HOLE', self.name,
+ 'пауза %.2f мс между пакетами (норма %.2f мс)'
+ % (gap * 1e3, self.dt * 1e3))
+ self.ts = ts
+
+ i0, q0 = self.hdr, self.hdr + 3
+ pk = max(peak16(pl, i0, need, self.pairs), peak16(pl, q0, need, self.pairs))
+ self.env.append((ts, pk))
+ if pk > self.pkmax:
+ self.pkmax = pk
+
+ # Всплеск в приёме ищем ТОЛЬКО на передаче: там наш сигнал держит
+ # ровный уровень, и любой выброс над фоном — событие. На приёме эфир
+ # сам по себе скачет, детектор был бы бессмысленным.
+ if self.watch and tx_on and ts >= self.quiet_until:
+ if self.ema is None:
+ self.ema = float(pk)
+ else:
+ hot = (pk > 3.5 * self.ema) and (pk >= 64)
+ if hot and not self.spike:
+ self.spike = True
+ ev(ts, 'RX-SPIKE', self.name,
+ 'ВСПЛЕСК В ПРИЁМЕ: %+.1f dBFS при фоне %+.1f (x%.1f)'
+ % (20 * math.log10(max(pk, 1) / FS16),
+ 20 * math.log10(max(self.ema, 1) / FS16),
+ pk / max(self.ema, 0.5)))
+ elif not hot:
+ self.spike = False
+ self.ema += 0.02 * (pk - self.ema)
+ else:
+ self.ema, self.spike = None, False
+
+ cur_first = (s24(pl, i0), s24(pl, q0))
+ cur_last = (s24(pl, need - 6), s24(pl, need - 3))
+
+ # Разрыв на стыке ищем ТОЛЬКО в исходящем DUC. На приёме шум эфира сам
+ # по себе прыгает от отсчёта к отсчёту, а разрыв от потерянного пакета
+ # и так виден по seq — детектор там дал бы только ложные срабатывания.
+ if self.brk and tx_on and self.last is not None:
+ ref = max(self.peak, pk) * 256.0
+ if ref > 60000: # не тишина
+ jump = max(abs(cur_first[0] - self.last[0]), abs(cur_first[1] - self.last[1]))
+ if jump > 0.45 * ref:
+ ev(ts, 'BREAK', self.name,
+ 'разрыв на стыке пакетов: скачок %.0f%% от огибающей '
+ '(|d|=%d, уровень=%.0f)' % (100.0 * jump / ref, jump, ref))
+ self.last, self.peak = cur_last, pk
+
+ if pk >= 32200:
+ if not self.clip:
+ self.clip, self.clip_from, self.clip_n = True, ts, 0
+ self.clip_n += 1
+ elif self.clip:
+ self.clip = False
+ ev(self.clip_from, 'CLIP', self.name,
+ 'упор в полную шкалу: %d пакет(ов), %.2f мс'
+ % (self.clip_n, self.clip_n * (self.dt or DUC_DT) * 1e3))
+
+
+def fmt_bytes_diff(a, b, skip=4):
+ out = []
+ for i in range(skip, min(len(a), len(b))):
+ if a[i] != b[i]:
+ out.append('%d:%02X->%02X' % (i, a[i], b[i]))
+ if len(out) >= 12:
+ out.append('...')
+ break
+ return ' '.join(out)
+
+
+def hp_freqs(pl):
+ """Частоты из HP-пакета: {'DUC': кГц, n: кГц по каждому ненулевому DDC}."""
+ out = {'DUC': phase_khz(pl, 329)}
+ for n in range(16):
+ f = phase_khz(pl, 9 + n * 4)
+ if f > 0:
+ out[n] = f
+ return out
+
+
+HP_FIELD = {329: 'DUC0 freq', 330: 'DUC0 freq', 331: 'DUC0 freq', 332: 'DUC0 freq',
+ 345: 'DUC0 drive', 1400: 'xvtr/audio', 1401: 'OC out', 1443: 'atten ADC0'}
+
+
+def hp_note(a, b):
+ names = set()
+ for i in range(4, min(len(a), len(b))):
+ if a[i] != b[i]:
+ if 9 <= i <= 12:
+ names.add('DDC0 freq')
+ elif 13 <= i <= 328:
+ names.add('DDC%d freq' % ((i - 9) // 4))
+ elif 1432 <= i <= 1435:
+ names.add('Alex0')
+ else:
+ names.add(HP_FIELD.get(i, 'byte %d' % i))
+ return ', '.join(sorted(names))
+
+
+def main():
+ ap = argparse.ArgumentParser(description=__doc__,
+ formatter_class=argparse.RawDescriptionHelpFormatter)
+ ap.add_argument('pcap', nargs='+')
+ ap.add_argument('--radio', help='IP радио (иначе определяется по потоку на 1029)')
+ ap.add_argument('--tx-only', action='store_true', help='события только внутри передачи')
+ ap.add_argument('--max', type=int, default=400, help='сколько событий печатать')
+ ap.add_argument('--env', help='выгрузить профиль огибающей в CSV')
+ ap.add_argument('--watch-ddc', type=int, default=None, metavar='N',
+ help='на каком DDC вы смотрите свой сигнал (по умолчанию все)')
+ ap.add_argument('--spectrum', action='store_true',
+ help='спектральный поиск всплесков на watch-DDC (как их видит '
+ 'водопад). Медленно: ~3 с на секунду передачи')
+ ap.add_argument('--corr', type=float, default=0.05, metavar='СЕК',
+ help='окно сопоставления всплеска приёма с событиями TX (по умолч. 0.05)')
+ ap.add_argument('--drops', help='лог .drops от capture.sh — потери в сокете, а не на проводе')
+ args = ap.parse_args()
+
+ files = sorted(args.pcap, key=lambda p: (os.path.getmtime(p), p))
+ # Кольцо одного съёма — это hpsdr-СТАМП.pcapNN. Файлы от РАЗНЫХ съёмов
+ # склеивать нельзя: счётчики seq в каждом свои, разбор насчитает потери,
+ # которых не было.
+ runs = sorted(set(re.sub(r'\.pcap\d*$', '', os.path.basename(p)) for p in files))
+ if len(runs) > 1:
+ raise SystemExit('в списке файлы от разных съёмов (%s) — разбирайте по одному:\n'
+ ' %s' % (', '.join(runs),
+ '\n '.join(os.path.join(os.path.dirname(files[0]),
+ r + '.pcap*') for r in runs)))
+
+ radio = args.radio
+ if not radio:
+ # PC всегда шлёт НА фиксированные порты 1024..1029; свой локальный порт
+ # у нас эфемерный, так что голосуем по адресату этих портов.
+ vote = defaultdict(int)
+ left = 20000
+ for p in files:
+ for _ts, _src, _sp, dst, dp, _pl in iter_pcap(p):
+ if P_COMMAND <= dp <= P_DUC_IQ:
+ vote[dst] += 1
+ left -= 1
+ if left <= 0:
+ break
+ if vote and left <= 0:
+ break
+ if vote:
+ radio = max(vote.items(), key=lambda kv: kv[1])[0]
+ if not radio:
+ raise SystemExit('не нашёл радио в дампе: укажите --radio')
+
+ events = []
+ tx_on = False
+ tx_sessions = [] # [ts_on, ts_off, duc_pkts, duc_lost]
+ cur_sess = None
+
+ def ev(ts, kind, stream, msg):
+ if args.tx_only and not tx_on:
+ return
+ events.append((ts, kind, stream, msg, tx_on))
+
+ duc = IQStream('DUC-IQ(TX)', 4, DUC_PAIRS, DUC_DT)
+ duc.brk = True
+ ddc = {}
+ audio = IQStream('DDC-AUDIO', 4, 0, 64 / 48000.0) # только счётчик seq
+ mic_seq = [None, 0, 0]
+ hp_prev = None
+ ducspec_prev = None
+ ddcspec_prev = None
+ st_prev = None
+ ddc_cfg = None
+ counts = defaultdict(int)
+ t0 = None
+ tlast = None
+ fifo_lo, fifo_hi = 1 << 30, 0
+ spec_buf, spec_t0 = [], None
+ spec_store = defaultdict(lambda: (array('f'), []))
+ onset = None
+ _edges = {}
+
+ def edge(key, cond):
+ """True только на фронте: события радио иначе печатаются пачками."""
+ was = _edges.get(key, False)
+ _edges[key] = cond
+ return cond and not was
+
+ for path in files:
+ for ts, src, sp, dst, dp, pl in iter_pcap(path):
+ if t0 is None:
+ t0 = ts
+ tlast = ts
+ if len(pl) < 4:
+ continue
+ to_radio = (dst == radio)
+ key = (dp if to_radio else sp)
+ counts[('->' if to_radio else '<-', key)] += 1
+
+ if to_radio and dp == P_DUC_IQ:
+ duc.feed(ts, pl, ev, tx_on)
+ if cur_sess:
+ cur_sess[2] += 1
+ if onset is not None and len(pl) >= 1444:
+ if duc.env and duc.env[-1][1] == 0:
+ onset += 1 # ведущая цифровая тишина
+ else:
+ pk = max(max(abs(s24(pl, 4 + k * 6)),
+ abs(s24(pl, 7 + k * 6))) for k in range(240))
+ ramp = next((k for k in range(240)
+ if max(abs(s24(pl, 4 + k * 6)),
+ abs(s24(pl, 7 + k * 6))) > pk / 2), 0)
+ first = max(abs(s24(pl, 4)), abs(s24(pl, 7)))
+ if first > 0.05 * 8388608:
+ ev(ts, 'HARD-START', duc.name,
+ 'передача начата СКАЧКОМ: после %.1f мс тишины первый '
+ 'же отсчёт %+.1f dBFS, нарастание %d отсчёт(ов) — '
+ 'мгновенное включение несущей, щелчок во всю полосу'
+ % (onset * DUC_DT * 1e3,
+ 20 * math.log10(first / 8388608.0), ramp))
+ else:
+ ev(ts, 'SOFT-START', duc.name,
+ 'старт плавный: %.1f мс тишины, нарастание %d отсчётов '
+ '(%.2f мс)' % (onset * DUC_DT * 1e3, ramp, ramp / 192.0))
+ onset = None
+
+ elif to_radio and dp == P_HP_FROM_PC and len(pl) >= 60:
+ ptt = bool(pl[4] & 0x1E)
+ if hp_prev is not None:
+ was = bool(hp_prev[4] & 0x1E)
+ if ptt != was:
+ tx_on = ptt
+ onset = 0 # считаем ведущую тишину передачи
+ duc.ts = None # первый пакет передачи паузой не считаем
+ duc.last = None
+ for _s in ddc.values():
+ _s.quiet_until = ts + 0.25 # уровень законно скачет
+ _s.ema = None
+ if ptt:
+ cur_sess = [ts, None, 0, duc.lost, hp_freqs(pl)]
+ events.append((ts, 'PTT', 'HP', 'ПЕРЕДАЧА (byte4=%02X)' % pl[4], True))
+ else:
+ if cur_sess:
+ cur_sess[1] = ts
+ cur_sess[3] = duc.lost - cur_sess[3]
+ tx_sessions.append(cur_sess)
+ cur_sess = None
+ events.append((ts, 'PTT', 'HP', 'приём (byte4=%02X)' % pl[4], False))
+ else:
+ f0, f1 = hp_freqs(hp_prev), hp_freqs(pl)
+ if f0 != f1:
+ ch = ['%s %.3f->%.3f' % ('DUC' if k == 'DUC' else 'DDC%d' % k,
+ f0.get(k, 0), f1[k])
+ for k in f1 if abs(f1[k] - f0.get(k, 0)) > 0.0005]
+ if ch:
+ events.append((ts, 'QSY', 'HP',
+ 'смена частоты (кГц): %s' % ', '.join(ch),
+ tx_on))
+ if ptt and (ptt == was) and pl[4:] != hp_prev[4:]:
+ ev(ts, 'HP-CHG', 'HP',
+ 'HP изменился ПОСРЕДИ ПЕРЕДАЧИ: %s | %s'
+ % (hp_note(hp_prev, pl), fmt_bytes_diff(hp_prev, pl)))
+ hp_prev = pl
+
+ elif to_radio and dp == P_DUC_SPEC and len(pl) >= 60:
+ if ducspec_prev is not None and pl[4:] != ducspec_prev[4:] and tx_on:
+ ev(ts, 'DUCSPEC', 'DUC-Spec',
+ 'DUC Specific изменился на передаче: %s' % fmt_bytes_diff(ducspec_prev, pl))
+ elif tx_on:
+ ev(ts, 'DUCSPEC', 'DUC-Spec', 'повтор DUC Specific на передаче')
+ ducspec_prev = pl
+
+ elif to_radio and dp == P_DDC_SPEC and len(pl) > 60:
+ if ddc_cfg is None and len(pl) >= 497:
+ n_adc = pl[4]
+ ddc_cfg = {'nadc': n_adc, 'ddc': {}}
+ for n in range(80):
+ if not (pl[7 + n // 8] >> (n % 8)) & 1:
+ continue
+ o = 17 + n * 6
+ src_adc = pl[o]
+ ddc_cfg['ddc'][n] = (
+ 'DAC (петля PureSignal)' if src_adc >= n_adc else 'ADC%d' % src_adc,
+ (pl[o + 1] << 8) | pl[o + 2], pl[o + 5])
+ if tx_on:
+ d = fmt_bytes_diff(ddcspec_prev, pl) if ddcspec_prev else '(первый)'
+ ev(ts, 'DDCSPEC', 'DDC-Spec',
+ 'DDC Specific ушёл на передаче%s' % ((': ' + d) if d else ' (без изменений)'))
+ ddcspec_prev = pl
+
+ elif to_radio and dp == P_DDC_AUDIO:
+ seq = struct.unpack('>I', pl[0:4])[0]
+ if audio.seq is not None:
+ d = (seq - audio.seq) & 0xFFFFFFFF
+ if d != 1 and d < 0x80000000:
+ ev(ts, 'SEQ-LOSS', 'DDC-AUDIO', 'потеряно %d' % (d - 1))
+ audio.seq = seq
+
+ elif (not to_radio) and sp == P_HP_TO_PC and len(pl) >= 60:
+ duc_fifo = (pl[35] << 8) | pl[36]
+ if edge('ovf', pl[30] != 0):
+ ev(ts, 'FIFO-OVF', 'radio', 'радио: FIFO overflow биты=%02X' % pl[30])
+ if edge('adc', pl[5] != 0):
+ ev(ts, 'ADC-OVL', 'radio', 'радио: перегруз ADC биты=%02X' % pl[5])
+ if edge('starve', tx_on and duc_fifo == 0):
+ ev(ts, 'TX-STARVE', 'radio',
+ 'радио: DUC FIFO пуст на передаче (голод => разрыв в эфире)')
+ fifo_lo = min(fifo_lo, duc_fifo) if tx_on else fifo_lo
+ fifo_hi = max(fifo_hi, duc_fifo) if tx_on else fifo_hi
+ if st_prev is not None:
+ if (pl[4] & 1) != (st_prev[4] & 1):
+ events.append((ts, 'PTT-HW', 'radio',
+ 'радио сообщает PTT=%d' % (pl[4] & 1), tx_on))
+ if tx_on:
+ pf = (st_prev[35] << 8) | st_prev[36]
+ if pf and duc_fifo and abs(duc_fifo - pf) > 2000:
+ ev(ts, 'FIFO-JUMP', 'radio',
+ 'DUC FIFO прыгнул %d -> %d' % (pf, duc_fifo))
+ st_prev = pl
+
+ elif (not to_radio) and sp == P_MIC and len(pl) >= 8:
+ seq = struct.unpack('>I', pl[0:4])[0]
+ if mic_seq[0] is not None:
+ d = (seq - mic_seq[0]) & 0xFFFFFFFF
+ if d != 1 and d < 0x80000000:
+ mic_seq[1] += d - 1
+ ev(ts, 'SEQ-LOSS', 'MIC', 'потеряно %d пакет(ов) микрофона' % (d - 1))
+ mic_seq[0] = seq
+
+ elif (not to_radio) and sp >= P_DDC0_IQ and sp < P_DDC0_IQ + 16 and len(pl) >= 16:
+ n = sp - P_DDC0_IQ
+ if args.spectrum and tx_on and (args.watch_ddc in (None, n)):
+ if spec_t0 is None:
+ spec_t0 = ts
+ npairs = (len(pl) - 16) // 6
+ for k in range(npairs):
+ o = 16 + k * 6
+ spec_buf.append(complex(s24(pl, o), s24(pl, o + 3)))
+ while len(spec_buf) >= FFT_N:
+ st = spec_store[n]
+ st[0].extend(spectrum_db(spec_buf[:FFT_N]))
+ st[1].append(spec_t0)
+ del spec_buf[:FFT_N]
+ spec_t0 = ts
+ s = ddc.get(n)
+ if s is None:
+ pairs = (len(pl) - 16) // 6
+ bits = (pl[12] << 8) | pl[13]
+ s = ddc[n] = IQStream('DDC%d-IQ(RX)' % n, 16, pairs)
+ s.bits = bits
+ s.watch = (args.watch_ddc is None or args.watch_ddc == n)
+ s.feed(ts, pl, ev, tx_on)
+
+ for n, (sp, tsl) in sorted(spec_store.items()):
+ nf = len(tsl)
+ if nf < 20:
+ continue
+ # Фон каждого бина — медиана по времени: устойчива к редким выбросам,
+ # то есть к тому самому, что мы ищем.
+ base = [0.0] * FFT_N
+ for i in range(FFT_N):
+ col = sorted(sp[k * FFT_N + i] for k in range(nf))
+ base[i] = col[nf // 2]
+ carr = max(range(FFT_N), key=lambda i: base[i])
+ st = ddc.get(n)
+ rate = (st.pairs / st.dt) if (st and st.dt > 0) else 0.0
+ hz = lambda i: (i if i < FFT_N // 2 else i - FFT_N) * rate / FFT_N
+ print('спектр DDC%d: кадров %d, несущая %+.0f Гц на %.1f dBFS'
+ % (n, nf, hz(carr), base[carr]))
+ hits = []
+ for k in range(nf):
+ o = k * FFT_N
+ for i in range(FFT_N):
+ if sp[o + i] - base[i] > 25:
+ hits.append((tsl[k], sp[o + i] - base[i], i, sp[o + i]))
+ break
+ grp = []
+ for h in hits:
+ if grp and h[0] - grp[-1][-1][0] < 0.08:
+ grp[-1].append(h)
+ else:
+ grp.append([h])
+ for g in grp:
+ t, d, bi, v = max(g, key=lambda x: x[1])
+ events.append((t, 'RX-SPLASH', 'DDC%d-IQ(RX)' % n,
+ 'ВСПЛЕСК на водопаде: %+.0f Гц от центра, %.1f dBFS — '
+ 'на %.0f dB выше фона этого бина%s'
+ % (hz(bi), v, d,
+ '' if abs(hz(bi) - hz(carr)) < rate / FFT_N * 1.5
+ else ' (несущая стоит на %+.0f Гц)' % hz(carr)), True))
+
+ if args.drops:
+ prev = {}
+ for line in open(args.drops):
+ f = line.split()
+ if not f:
+ continue
+ try:
+ ts = float(f[0])
+ except ValueError:
+ continue
+ cur = {}
+ for kv in f[1:]:
+ if '=' in kv:
+ k, v = kv.split('=', 1)
+ cur[k] = v
+ for k in ('InErrors', 'RcvbufErrors', 'drops'):
+ a, b = prev.get(k), cur.get(k)
+ if a is not None and b is not None and b.isdigit() and a.isdigit() and int(b) > int(a):
+ events.append((ts, 'SOCK-DROP', 'kernel',
+ 'ядро отбросило датаграммы: %s %s -> %s '
+ '(на проводе они БЫЛИ, потерялись в сокете)' % (k, a, b), False))
+ prev = cur
+
+ if cur_sess:
+ cur_sess[1] = None
+ tx_sessions.append(cur_sess)
+ for st in [duc] + list(ddc.values()):
+ if st.clip:
+ events.append((st.clip_from, 'CLIP', st.name,
+ 'упор в полную шкалу: %d пакет(ов) (до конца дампа)' % st.clip_n, True))
+ events.sort(key=lambda e: e[0])
+
+ # ---------------- отчёт ----------------
+ def clock(ts):
+ return datetime.datetime.fromtimestamp(ts).strftime('%H:%M:%S.%f')
+
+ print('радио: %s файлов: %d окно: %s .. %s'
+ % (radio, len(files), clock(t0) if t0 else '-',
+ clock(tlast) if tlast else '-'))
+ if ddc_cfg:
+ print('=== конфигурация DDC (из DDC Specific) ===')
+ for n, (srcname, rate, bits) in sorted(ddc_cfg['ddc'].items()):
+ mark = ' <-- смотрим тут' if (args.watch_ddc == n) else ''
+ print(' DDC%-2d источник %-22s %d ksps, %d бит%s' % (n, srcname, rate, bits, mark))
+ if args.watch_ddc is not None and args.watch_ddc not in ddc_cfg['ddc']:
+ print(' ВНИМАНИЕ: DDC%d в дампе не включён' % args.watch_ddc)
+ print()
+ print('=== потоки ===')
+ for (d, port), c in sorted(counts.items(), key=lambda kv: -kv[1]):
+ print(' %s :%-5d %9d пакетов' % (d, port, c))
+
+ print()
+ print('=== передачи ===')
+ if not tx_sessions:
+ print(' PTT в дампе не поднимался')
+ for i, (a, b, npk, lost, fr) in enumerate(tx_sessions, 1):
+ dur = (b - a) if b else float('nan')
+ exp = dur / DUC_DT if b else float('nan')
+ print(' #%d %s длит %.3f с DUC-пакетов %d из ~%.0f (%+.0f, потеряно на проводе %d)'
+ % (i, clock(a), dur, npk, exp, npk - exp, lost))
+ # Частоты берём НА ФРОНТЕ PTT: перед передачей бывает QSY, и снимок из
+ # начала дампа соврёт (DUP уводит приёмник на частоту передачи).
+ tx_khz = fr.get('DUC', 0)
+ ddcs = sorted(k for k in fr if k != 'DUC')
+ near = ['DDC%d %.3f (%+.0f Гц от TX)' % (k, fr[k], (fr[k] - tx_khz) * 1e3)
+ for k in ddcs if abs(fr[k] - tx_khz) < 96.0]
+ far = ['DDC%d %.3f' % (k, fr[k]) for k in ddcs
+ if abs(fr[k] - tx_khz) >= 96.0]
+ print(' TX %.3f кГц' % tx_khz)
+ print(' в полосе передачи: %s' % (', '.join(near) if near
+ else 'НИ ОДНОГО — всплеск в эфире увидеть нечем'))
+ if far:
+ print(' вне полосы (свидетелями быть не могут): %s' % ', '.join(far))
+
+ if duc.holes:
+ dry = [(t, g) for t, g in duc.holes if g > DUC_CUSHION]
+ txsec = sum((b - a) for a, b, _n, _l, _f in tx_sessions if b) or 1.0
+ print(' голодание очереди TX: пауз %d, из них осушающих FIFO %d, '
+ 'суммарно сухо %.0f мс'
+ % (len(duc.holes), len(dry), sum(g - DUC_CUSHION for _t, g in dry) * 1e3))
+ # Главная цифра для сравнения прогонов: всплеск в эфире рождается ровно
+ # тогда, когда пауза длиннее подушки. Гнать её надо к нулю.
+ print(' ★ПАУЗ ДЛИННЕЕ ПОДУШКИ: %.3f на секунду передачи (%d за %.1f с)'
+ % (len(dry) / txsec, len(dry), txsec))
+ bins = [(2.5, 5), (5, 10), (10, 15), (15, 25), (25, 1e9)]
+ hist = []
+ for lo, hi in bins:
+ c = sum(1 for _t, g in duc.holes if lo <= g * 1e3 < hi)
+ if c:
+ hist.append('%g-%s мс: %d' % (lo, ('%g' % hi) if hi < 1e9 else '∞', c))
+ print(' длительности пауз — %s' % ', '.join(hist))
+ per = [duc.holes[i][0] - duc.holes[i - 1][0] for i in range(1, len(duc.holes))]
+ cl = sorted(x for x in per if x < 0.25)
+ if len(cl) >= 5:
+ med = cl[len(cl) // 2]
+ print(' пауз в пачках: %d, период внутри пачки %.1f мс (%.2f Гц) — '
+ 'ищите в процессе периодику этой частоты'
+ % (len(cl), med * 1e3, 1.0 / med))
+ if fifo_hi:
+ if fifo_lo == fifo_hi:
+ print(' глубина DUC FIFO радио: всегда %d — поле статично, прошивка его '
+ 'не заполняет, доверять нельзя' % fifo_hi)
+ else:
+ print(' глубина DUC FIFO радио на передаче: %d .. %d отсчётов' % (fifo_lo, fifo_hi))
+
+ print()
+ print('=== события (%d) ===' % len(events))
+ if not events:
+ print(' чисто: ни разрывов seq, ни дыр, ни скачков на стыках, ни жалоб радио')
+ order = {'HARD-START': 0, 'RX-SPLASH': 1, 'RX-SPIKE': 1, 'QSY': 12, 'SOFT-START': 13, 'BREAK': 1, 'UNDERRUN': 2, 'CLIP': 3, 'TX-STARVE': 4,
+ 'FIFO-OVF': 5, 'SOCK-DROP': 6, 'HOLE': 7, 'SEQ-LOSS': 8, 'SEQ-DUP': 9,
+ 'HP-CHG': 10}
+ for ts, kind, stream, msg, was_tx in events[:args.max]:
+ print(' %s %+8.3f %-10s %-12s %s%s'
+ % (clock(ts), ts - t0, kind, stream, msg, ' [TX]' if was_tx else ''))
+ if len(events) > args.max:
+ print(' ... ещё %d (см. --max)' % (len(events) - args.max))
+
+ spikes = [e for e in events if e[1] in ('RX-SPIKE', 'RX-SPLASH')]
+ if spikes:
+ print()
+ print('=== сопоставление: что происходило рядом с каждым всплеском ===')
+ print('(смещение в мс: минус = событие РАНЬШЕ всплеска. Наш сигнал возвращается')
+ print(' в приёмник с задержкой тракта, так что причина обязана быть раньше.)')
+ for sp_ts, _k, sp_stream, sp_msg, _t in spikes:
+ near = [e for e in events
+ if e[1] not in ('RX-SPIKE', 'RX-SPLASH') and abs(e[0] - sp_ts) <= args.corr]
+ print()
+ print(' %s (+%.3f) %s — %s' % (clock(sp_ts), sp_ts - t0, sp_stream, sp_msg))
+ if not near:
+ print(' рядом (+-%.0f мс) в цифровом тракте ЧИСТО' % (args.corr * 1e3))
+ for ts, kind, stream, msg, _w in near:
+ print(' %+7.1f мс %-10s %-12s %s' % ((ts - sp_ts) * 1e3, kind, stream, msg))
+
+ kinds = set(k for _t2, k, _s, _m, _w in near)
+ own = set(k for _t2, k, st, _m, _w in near if st == sp_stream)
+ tx = set(k for _t2, k, st, _m, _w in near if st == duc.name)
+ if 'HARD-START' in tx:
+ v = ('НАШ ТРАКТ: передача начата скачком несущей без нарастания — '
+ 'щелчок по построению')
+ elif {'BREAK', 'CLIP', 'UNDERRUN'} & tx:
+ v = ('НАШ ТРАКТ: сигнал ушёл в провод уже с разрывом — WDSP, сборка '
+ 'пакетов или очередь TX')
+ elif own & {'SEQ-LOSS', 'SEQ-DUP', 'HOLE'} or 'SOCK-DROP' in kinds:
+ v = ('АРТЕФАКТ ПРИЁМА: дыра в самом потоке %s. В эфир этот всплеск '
+ 'НЕ уходил, его видит только водопад' % sp_stream)
+ elif {'TX-STARVE', 'FIFO-OVF'} & kinds:
+ v = 'РАДИО: FIFO передатчика голодал или переполнился'
+ elif 'HOLE' in tx:
+ v = ('НАШ ТРАКТ: очередь TX голодала — пауза короче подушки, но рядом '
+ 'со всплеском; смотрите её длительность')
+ elif {'SEQ-LOSS'} & kinds:
+ v = 'ПРОВОД: потери в сети на пути к радио'
+ elif 'HOLE' in kinds:
+ v = 'ПРОВОД: дыра во входящем потоке'
+ elif 'HP-CHG' in kinds:
+ v = 'КОМАНДА НА ХОДУ: High Priority сменился посреди передачи'
+ elif not near:
+ v = ('ЖЕЛЕЗО: цифровой поток рядом чист в обе стороны — искать в PA, '
+ 'ALC, питании, реле, а не в софте')
+ else:
+ v = 'причина не классифицирована, смотреть список выше'
+ print(' => %s' % v)
+
+ print()
+ print('=== итог по типам ===')
+ tally = defaultdict(int)
+ for _ts, kind, stream, _m, _t in events:
+ tally[(kind, stream)] += 1
+ for (kind, stream), c in sorted(tally.items(), key=lambda kv: (order.get(kv[0][0], 9), -kv[1])):
+ print(' %-10s %-12s %d' % (kind, stream, c))
+
+ if args.env:
+ with open(args.env, 'w') as f:
+ f.write('t,stream,peak16\n')
+ for ts, pk in duc.env:
+ f.write('%.6f,DUC,%d\n' % (ts - t0, pk))
+ for n, s in sorted(ddc.items()):
+ for ts, pk in s.env:
+ f.write('%.6f,DDC%d,%d\n' % (ts - t0, n, pk))
+ print('\nпрофиль огибающей: %s' % args.env)
+
+
+if __name__ == '__main__':
+ main()
diff --git a/test/hpsdr/txtrace_scan.py b/test/hpsdr/txtrace_scan.py
new file mode 100755
index 0000000..4fa2c80
--- /dev/null
+++ b/test/hpsdr/txtrace_scan.py
@@ -0,0 +1,219 @@
+#!/usr/bin/env python3
+"""Разбор кольцевой трассировки TX-тракта (файлы txtrace-*.txt).
+
+Дамп пишет сама программа, запущенная с EWSDR_TXTRACE=1: кольцо сбрасывается
+на диск в конце каждой передачи в каталог настроек (~/.config/ewsdr/).
+
+Зачем: pcap показывает ПАУЗУ в потоке DUC, но не показывает, кто её сделал.
+Здесь видно, кто именно не наполнил очередь: продюсер звука (TCI/звуковая
+карта), TX-поток DSP или сетевой поток приёма.
+
+Запуск: txtrace_scan.py <файл> [--context N]
+"""
+
+import sys
+import statistics
+from collections import defaultdict
+
+# Подушка отправителя DUC: 2000 отсчётов @192 кГц (HPSDRNetwork.pas).
+CUSHION_MS = 2000 / 192.0
+
+
+def parse(path):
+ rows = []
+ for line in open(path, encoding='utf-8', errors='replace'):
+ if line.startswith('#') or not line.strip():
+ continue
+ f = line.split()
+ if len(f) != 5:
+ continue
+ try:
+ rows.append((float(f[0]), f[1], int(f[2]), int(f[3]), int(f[4])))
+ except ValueError:
+ continue
+ return rows
+
+
+def segments(rows):
+ """Кольцо накапливается через всю сессию: файл второй передачи содержит и
+ первую. Режем по фронтам PTT — сравнивать можно только передачу с
+ передачей."""
+ segs, cur = [], None
+ for r in rows:
+ if r[1] == 'PTT' and r[2] == 1:
+ cur = [r]
+ segs.append(cur)
+ continue
+ if cur is not None:
+ cur.append(r)
+ if r[1] == 'PTT' and r[2] == 0:
+ cur = None
+ return segs if segs else [rows]
+
+
+def inflight_tail(rows, seg):
+ """Маркеры, оставшиеся в полёте на снятии PTT: планировщик пишет это
+ (NOTE A=-3) на СЛЕДУЮЩЕМ тике, уже за границей передачи. Без учёта этой
+ метки разбор считает их потерянными клиентом."""
+ end = seg[-1][0]
+ for r in rows:
+ if r[0] < end or r[1] != 'NOTE' or r[2] != -3:
+ continue
+ if r[0] - end > 200:
+ break
+ return r[3]
+ return 0
+
+
+def stats(name, vals, unit='мкс'):
+ if not vals:
+ return f' {name:<22} —'
+ vals = sorted(vals)
+ p99 = vals[min(len(vals) - 1, int(len(vals) * 0.99))]
+ return (f' {name:<22} n={len(vals):<6} медиана={statistics.median(vals):>9.1f} '
+ f'p99={p99:>9.1f} макс={vals[-1]:>9.1f} {unit}')
+
+
+def main():
+ if len(sys.argv) < 2:
+ print(__doc__)
+ return 1
+ path = sys.argv[1]
+ ctx = 8
+ if '--context' in sys.argv:
+ ctx = int(sys.argv[sys.argv.index('--context') + 1])
+
+ all_rows = parse(path)
+ if not all_rows:
+ print('пусто: в файле нет записей')
+ return 1
+
+ segs = segments(all_rows)
+ print(f'=== {path}')
+ print(f'записей {len(all_rows)}, передач в кольце {len(segs)}')
+ for seg_no, seg in enumerate(segs, 1):
+ report(seg, seg_no, ctx, inflight_tail(all_rows, seg))
+ return 0
+
+
+def report(rows, seg_no, ctx, tail_frames=0):
+ by_kind = defaultdict(list)
+ for r in rows:
+ by_kind[r[1]].append(r)
+
+ span = rows[-1][0] - rows[0][0]
+ # Источник берём с ЗАКРЫВАЮЩЕГО фронта PTT: на открывающем он ещё старый
+ # (SetMOX ставит метку до того, как ApplyMOX выберет источник).
+ src_code = rows[0][3]
+ for r in reversed(rows):
+ if r[1] == 'PTT' and r[2] == 0:
+ src_code = r[3]
+ break
+ src = {0: 'микрофон радио', 1: 'звуковая карта',
+ 2: 'внешняя подача (web/TCI)'}.get(src_code, '?')
+ print(f'\n===== ПЕРЕДАЧА {seg_no}: {span/1000.0:.2f} с, источник — {src}, '
+ f'подушка отправителя {CUSHION_MS:.2f} мс\n')
+
+ print('РАСПРЕДЕЛЕНИЯ')
+ print(stats('Synchronize (HP)', [r[2] for r in by_kind['HP-SYNC']]))
+ print(stats('обработчик приёма', [r[2] for r in by_kind['HANDLER']]))
+ print(stats('пауза в приёме', [r[2] for r in by_kind['RECV-GAP']]))
+ print(stats('зазор тика TX', [r[2] for r in by_kind['TX-TICK']]))
+ print(stats('fexchange0', [r[2] for r in by_kind['TX-PHASE']]))
+ print(stats('TX-анализатор', [r[3] for r in by_kind['TX-PHASE']]))
+ print(stats('отдача IQ', [r[4] for r in by_kind['TX-PHASE']]))
+ print(stats('период TX_CHRONO', [r[4] for r in by_kind['CHRONO']]))
+ print(stats('период TX-аудио', [r[2] for r in by_kind['TX-AUDIO']]))
+ print(stats('простой очереди DUC', [r[2] for r in by_kind['DUC-RUN']]))
+
+ # Продюсер: сколько блоков TX-поток делает за тик и как часто ринг пуст.
+ ticks = by_kind['TX-TICK']
+ if ticks:
+ dry = sum(1 for t in ticks if t[4] == 0)
+ burst = [t[4] for t in ticks if t[4] > 1]
+ print(f'\n тиков TX всего {len(ticks)}, из них вхолостую (ринг пуст) '
+ f'{dry} ({100.0*dry/len(ticks):.1f}%), '
+ f'пачками >1 блока {len(burst)}')
+
+ # Учёт каналов: length в шапке против моно-отсчётов после интерполяции.
+ audio = by_kind['TX-AUDIO']
+ if audio:
+ pairs = {(a[3], a[4]) for a in audio}
+ print(f' TX-аудио: пары (length, отсчётов после интерполяции) = '
+ f'{sorted(pairs)[:6]}')
+
+ # ★Баланс подачи. Просим по часам, а списываем долг при ОТПРАВКЕ маркера:
+ # каждый маркер без ответа = безвозвратно потерянный кусок звука, наверстать
+ # его нечем. Дефицит съедает единственный запас (аванс + pre-roll), и когда
+ # запас кончился — очередь пустеет на каждой порции.
+ chrono = by_kind['CHRONO']
+ # Предварительный маркер (NOTE A=-4) уходит до фронта PTT, вне бухгалтерии
+ # и вне статистики периода — но в баланс он входит наравне с прочими.
+ prewarm = [n for n in by_kind['NOTE'] if n[2] == -4]
+ if chrono and audio:
+ asked = sum(c[2] for c in chrono) + sum(p[3] for p in prewarm)
+ got = sum(a[4] for a in audio)
+ secs = span / 1000.0
+ print(f'\n БАЛАНС ПОДАЧИ: маркеров {len(chrono) + len(prewarm)} '
+ f'(из них предварительных {len(prewarm)}), порций {len(audio)}, '
+ f'проглочено {len(chrono) + len(prewarm) - len(audio)}')
+ print(f' запрошено {asked} отсч., получено {got} '
+ f'({got/secs:.0f} отсч/с при номинале 48000)')
+ print(f' дефицит {asked-got} отсч. = {(asked-got)/48.0:.1f} мс звука, '
+ f'которого в эфире не будет')
+ # NOTE несёт три разных записи: A>=0 — счётчик выброшенного очередью,
+ # A=-1 — прощение зависшего кредита, A=-2 — выдача аванса,
+ # A=-3 — сколько маркеров осталось В ПОЛЁТЕ на снятии PTT.
+ notes = by_kind['NOTE']
+ drops = [n for n in notes if n[2] >= 0]
+ if drops:
+ lost = drops[-1][2] - drops[0][2]
+ print(f' из них выброшено НАШЕЙ очередью к клиенту: {lost} блок(ов)')
+ if tail_frames > 0:
+ q = chrono[-1][2] if chrono else 0
+ n_tail = tail_frames // q if q else 0
+ print(f' ★из «проглоченных» {n_tail} — это маркеры в полёте на '
+ f'снятии PTT, а не потеря')
+ lead = [n for n in notes if n[2] == -2]
+ if lead:
+ print(f' аванс под зернистость клиента: {lead[-1][3]} кадров '
+ f'({lead[-1][3] / 48.0:.1f} мс), выдан {len(lead)} раз(а)')
+ forg = [n for n in notes if n[2] == -1]
+ if forg:
+ print(f' прощений зависшего кредита: {len(forg)}')
+
+ # Главное: каждый простой очереди — с историей перед ним.
+ print('\nПРОСТОИ ОЧЕРЕДИ DUC (кто молчал перед пустой очередью)')
+ n_over = 0
+ for i, r in enumerate(rows):
+ if r[1] != 'DUC-DRY':
+ continue
+ dur = None
+ for q in rows[i + 1:]:
+ if q[1] == 'DUC-RUN':
+ dur = q[2] / 1000.0
+ break
+ if q[1] == 'DUC-DRY':
+ break
+ # Простой короче одного пакета (240 отсчётов @192 кГц = 1.25 мс) —
+ # не голодание: быстрее отправитель всё равно не умеет. Такие точки
+ # ловятся на самом фронте PTT, когда очередь ещё пуста, а pre-roll в
+ # неё только заливается.
+ over = (dur is not None and dur > CUSHION_MS
+ and dur > 1.25)
+ if over:
+ n_over += 1
+ mark = '★ДЛИННЕЕ ПОДУШКИ' if over else ''
+ print(f'\n t={r[0]:.3f} мс простой {dur if dur is None else round(dur,2)} мс '
+ f'(FIFO={r[2]} отсч., в очереди {r[3]} пак.) {mark}')
+ for q in rows[max(0, i - ctx):i]:
+ print(f' −{r[0]-q[0]:8.3f} мс {q[1]:<9} {q[2]:>9} {q[3]:>9} {q[4]:>9}')
+
+ tx_s = span / 1000.0
+ if tx_s > 0:
+ print(f'\n★ПРОСТОЕВ ДЛИННЕЕ ПОДУШКИ: {n_over} '
+ f'({n_over/tx_s:.2f} на секунду передачи)')
+
+
+if __name__ == '__main__':
+ sys.exit(main())
diff --git a/test/tci/tcitest.pas b/test/tci/tcitest.pas
index a59e03a..415ed03 100644
--- a/test/tci/tcitest.pas
+++ b/test/tci/tcitest.pas
@@ -1263,6 +1263,11 @@ type
слот в обоих. }
procedure SendClose;
procedure Pump(Ms: Integer);
+ { ★То же, но с шагом сна в 1 мс. Обычный Pump спит по 10 мс, и для проверок
+ ПЕЙСИНГА он не годится вовсе: квант запроса — 10.7 мс, то есть клиент
+ отвечал бы раз в 10 мс и сам создавал бы те самые «длинные интервалы»,
+ которые тест ищет. }
+ procedure PumpFine(Ms: Integer);
function NextFrame(out Opcode: Byte; out Payload: TBytes): Boolean;
{ Ждать строку с подстрокой Needle не дольше Ms. }
function WaitText(const Needle: string; Ms: Integer): string;
@@ -1379,6 +1384,19 @@ begin
fpSend(FSock, @Frame[0], Length(Frame), 0);
end;
+procedure TRawClient.PumpFine(Ms: Integer);
+var
+ R: Integer;
+ Deadline: QWord;
+begin
+ Deadline := GetTickCount64 + QWord(Ms);
+ repeat
+ R := fpRecv(FSock, @FIn[FLen], SizeOf(FIn) - FLen, MSG_DONTWAIT);
+ if R > 0 then Inc(FLen, R)
+ else Sleep(1);
+ until GetTickCount64 >= Deadline;
+end;
+
procedure TRawClient.Pump(Ms: Integer);
var
R, Waited: Integer;
@@ -1889,6 +1907,255 @@ end;
RX-аудио и IQ с верными заголовками.
═══════════════════════════════════════════════════════════════════════════ }
+{ ═══════════════════════════════════════════════════════════════════════════
+ F. Пейсинг TX: квант запроса, окно в полёте, потери ответов
+ ═══════════════════════════════════════════════════════════════════════════ }
+
+{ Обслуживает маркеры TX_CHRONO Ms миллисекунд, отвечая на каждый блоком
+ нужного размера. Skip первых N маркеров остаются БЕЗ ответа — это и есть
+ «замороженный кредит», ради которого всё затевалось: конвейер продолжает
+ отдавать звук, но медленнее реального времени, и сторож по одному лишь
+ молчанию клиента такого не видит. Возвращает число маркеров и раскладку
+ интервалов между ними. }
+procedure ServeChrono(C: TRawClient; Ms, Skip: Integer;
+ out Markers, Answered, Quantum, LongGaps, MaxGapMs: Integer;
+ out MinReserve: Integer);
+var
+ Deadline, Now_, Prev, Start: QWord;
+ Delivered, Reserve: Int64;
+ Op: Byte;
+ Pay: TBytes;
+ H, HA: TTCIStreamHeader;
+ Frames, i, Gap: Integer;
+ Blk: array[0..40000] of Byte;
+ Skipped: Integer;
+begin
+ Markers := 0;
+ Answered := 0;
+ Quantum := 0;
+ LongGaps := 0;
+ MaxGapMs := 0;
+ Skipped := 0;
+ Prev := 0;
+ Delivered := 0;
+ MinReserve := 0;
+ Start := GetTickCount64;
+ Deadline := Start + QWord(Ms);
+ while GetTickCount64 < Deadline do
+ begin
+ C.PumpFine(1);
+ while C.NextFrame(Op, Pay) do
+ begin
+ if (Op <> $02) or (Length(Pay) < SizeOf(H)) then Continue;
+ Move(Pay[0], H, SizeOf(H));
+ if H.StreamType <> LongWord(Ord(tstTXChrono)) then Continue;
+ Now_ := GetTickCount64;
+ Inc(Markers);
+ if H.Channels = 0 then H.Channels := 1;
+ Frames := Integer(H.DataLength) div Integer(H.Channels);
+ Quantum := Frames;
+ if Prev > 0 then
+ begin
+ Gap := Integer(Now_ - Prev);
+ if Gap > MaxGapMs then MaxGapMs := Gap;
+ // Порог — полтора номинальных периода кванта (10.667 мс при 512/48к).
+ if Gap > 16 then Inc(LongGaps);
+ end;
+ Prev := Now_;
+
+ if Skipped < Skip then
+ begin
+ Inc(Skipped);
+ Continue; // кредит завис навсегда
+ end;
+ TCIFillHeader(HA, tstTXAudio, H.Receiver, H.SampleRate, tsyFloat32,
+ Frames, H.Channels);
+ Move(HA, Blk[0], SizeOf(HA));
+ for i := 0 to Frames * Integer(H.Channels) - 1 do
+ PSingle(@Blk[SizeOf(HA) + i * 4])^ := 0.1;
+ C.SendBinary(Blk[0], SizeOf(HA) + Frames * Integer(H.Channels) * 4);
+ Inc(Answered);
+ // ★Мера годности — не ровность интервалов, а НАКОПЛЕННЫЙ РЕЗЕРВ: подушка
+ // отправителя интегрирующая, и два подряд «почти в допуске» интервала
+ // сушат очередь не хуже одного грубого. Пачка маркеров сама по себе
+ // безвредна — она приносит звук ВПЕРЁД реального времени, и следующая за
+ // ней пауза оплачена этим запасом. Считаем в кадрах 48 кГц от начала
+ // фазы: сколько отдано минус сколько утекло по часам.
+ Inc(Delivered, Frames);
+ Reserve := Delivered -
+ Int64(GetTickCount64 - Start) * Int64(H.SampleRate) div 1000;
+ if Reserve < MinReserve then MinReserve := Reserve;
+ end;
+ end;
+end;
+
+procedure TestTxPacing;
+const
+ PORT = 40099;
+ RATE = 48000;
+ TCI_TX_LEAD_MAX_MS_CHK = 120; // потолок аванса, копия TCI_TX_LEAD_MAX_MS
+var
+ Ctrl: TRadioController;
+ Ad: TTCIAdapter;
+ Host: THost;
+ Cfg: TTCISettings;
+ C: TRawClient;
+ Markers, Answered, Quantum, LongGaps, MaxGapMs, MinReserve: Integer;
+ DbgOwed, DbgFlight: Double;
+ DbgWin, DbgQ, DbgLead, SeedLead: Integer;
+begin
+ WriteLn('F. Пейсинг TX: квант запроса и потери ответов');
+
+ Host := THost.Create;
+ Ctrl := TRadioController.Create;
+ Ctrl.LocalAudioEnabled := False;
+ Ctrl.OnInvoke := Host.DoInvoke;
+ Ctrl.FSampleRate := RATE;
+ Ctrl.CreateEngines(RATE);
+ Ctrl.FWDSPReady := True; // движок не открываем: тракт тут не проверяется
+ C := nil;
+ Ad := nil;
+ try
+ Ad := TTCIAdapter.Create(Ctrl, nil);
+ Cfg.Enabled := True;
+ Cfg.Port := PORT;
+ Cfg.BindAddr := '127.0.0.1';
+ if not Ad.ApplySettings(Cfg) then
+ begin
+ Check('пейсинг: сервер поднялся', False);
+ Exit;
+ end;
+ C := TRawClient.Create;
+ if not C.Connect(PORT) then
+ begin
+ Check('пейсинг: клиент подключился', False);
+ Exit;
+ end;
+ C.WaitText('ready;', 2000);
+
+ // Клиент назвался ровно как MSHV: 48 кГц, два канала, блок 2048.
+ C.SendText('audio_samplerate:48000;');
+ C.SendText('audio_stream_channels:2;');
+ C.SendText('audio_stream_samples:2048;');
+ C.SendText('tx_stream_audio_buffering:50;');
+ // ★Без поднятого аудиопотока просьба «модулируй из TCI» отклоняется
+ // (§4.2, HasAudioStream): модулировать было бы нечем.
+ C.SendText('audio_start:0;');
+ C.Pump(200);
+ C.SendText('trx:0,true,tci;');
+ C.WaitText('trx:', 1500);
+ Check('пейсинг: модуляция из TCI взята', Ctrl.TCIMicActive);
+
+ // ── Здоровый клиент ──
+ // Прогрев: пока шёл разбор команды и проверки выше, клиент не отвечал, и
+ // окно в полёте успело набиться маркерами. Плюс за это время адаптер выдаёт
+ // разовый аванс под зернистость клиента — он тоже приходит пачкой. Меряем
+ // установившийся режим, а не этот стартовый ком, иначе в замер попадёт
+ // чужой долг: аванс отдан ДО окна замера, а расходуется уже внутри него.
+ ServeChrono(C, 1000, 0, Markers, Answered, Quantum, LongGaps, MaxGapMs,
+ MinReserve);
+ ServeChrono(C, 900, 0, Markers, Answered, Quantum, LongGaps, MaxGapMs,
+ MinReserve);
+ WriteLn(Format(' .. фаза 1: маркеров %d, квант %d ⇒ %d кадров/с, ' +
+ 'просадка резерва %d кадров (%.1f мс), длинных интервалов %d, макс %d мс',
+ [Markers, Quantum, Round(Answered * Quantum / 0.9),
+ MinReserve, MinReserve / 48.0, LongGaps, MaxGapMs]));
+ Check('пейсинг: маркеры идут', Markers > 20, IntToStr(Markers));
+ // ★Главное число всей правки. Просить блок клиента целиком (2048 отсчётов =
+ // 42.7 мс) нельзя: подушка отправителя DUC — 10.42 мс, и на каждом запросе
+ // длиннее её очередь пересыхает. Квант обязан быть одним блоком TXA.
+ Check('пейсинг: квант = один блок TXA, а не блок клиента',
+ Quantum = 512, IntToStr(Quantum));
+ // ★Зернистость: раньше маркеры выходили через 40 или 60 мс (тик 20 мс не
+ // делится на 42.7), и каждый трёхтактный интервал давал осушение.
+ // Порог: подушка отправителя DUC (2000 отсчётов @192 кГц = 500 кадров
+ // @48 кГц) ПЛЮС аванс, который клиент попросил сам через
+ // TX_STREAM_AUDIO_BUFFERING (здесь 50 мс = 2400 кадров). Считать от нуля
+ // нельзя: этот аванс реально лежит в очереди и на то и дан. ★В эфире к нему
+ // добавляется ещё и нулевой pre-roll под зернистость клиента, но стенд
+ // работает без радио, и в нём этого слагаемого нет.
+ // Систематическую просадку порог не пропустит: она накапливается линейно и
+ // за 900 мс уходит далеко за любую константу.
+ Check('пейсинг: резерв не проседает ниже подушки DUC плюс аванс клиента',
+ MinReserve > -2900, Format('%d кадров (%.1f мс)',
+ [MinReserve, MinReserve / 48.0]));
+
+ // ── Посев аванса и его сползание ──
+ // ★Посев нужен потому, что первая передача после подключения физически не
+ // может знать зернистость клиента: аванс появляется только после первого
+ // ответа, а осушение случается раньше. Но посев обязан уметь сползать —
+ // иначе клиент с мелкой гранулой (как здесь: отвечает сразу) навсегда
+ // получит чужие 50 мс задержки.
+ Ad.TxDbgState(DbgOwed, DbgFlight, DbgWin, DbgQ, SeedLead);
+ Check('пейсинг: аванс посеян до первого ответа',
+ SeedLead >= 2000, IntToStr(SeedLead));
+ ServeChrono(C, 5200, 0, Markers, Answered, Quantum, LongGaps, MaxGapMs,
+ MinReserve);
+ Ad.TxDbgState(DbgOwed, DbgFlight, DbgWin, DbgQ, DbgLead);
+ WriteLn(Format(' .. аванс: посев %d кадров (%.1f мс) → %d (%.1f мс), ' +
+ 'худшая пауза клиента %d мс',
+ [SeedLead, SeedLead / 48.0, DbgLead, DbgLead / 48.0,
+ MaxGapMs]));
+ // ★Проверяем не «аванс уменьшился», а то, чем он обязан быть: запасом под
+ // САМУЮ ХУДШУЮ наблюдённую паузу клиента. Оценка нарочно несимметрична
+ // (вверх сразу, вниз по выдержке), поэтому редкий выброс её и держит — и
+ // это правильно: запас на то и нужен, чтобы такой выброс пережить. Стенд
+ // сам даёт выбросы под 60 мс (его клиент живёт в одном потоке с проверками),
+ // так что «сползание» здесь не наблюдаемо в принципе — оно проверяется
+ // конструкцией: путь снижения общий с путём роста, см. TCI_TX_LEAD_DOWN_MS.
+ Check('пейсинг: аванс покрывает худшую паузу клиента',
+ (DbgLead >= MaxGapMs * 48) and
+ (DbgLead <= (TCI_TX_LEAD_MAX_MS_CHK * 48)),
+ Format('%d кадров при худшей паузе %d мс', [DbgLead, MaxGapMs]));
+
+ // ── Замороженные кредиты: два ответа не приходят никогда ──
+ // Конвейер сужается, но продолжает работать — молчания клиента нет, и
+ // поймать это можно только по одновременному насыщению долга и окна.
+ ServeChrono(C, 1500, 2, Markers, Answered, Quantum, LongGaps, MaxGapMs,
+ MinReserve);
+ Check('пейсинг: замороженные кредиты не остановили выдачу',
+ Markers > 40, IntToStr(Markers));
+ Check('пейсинг: после прощения кредита выдача вернулась к темпу',
+ MaxGapMs < 400, IntToStr(MaxGapMs));
+
+ // ── Полная защёлка: четыре ответа подряд пропали ──
+ ServeChrono(C, 800, 4, Markers, Answered, Quantum, LongGaps, MaxGapMs,
+ MinReserve);
+ Ad.TxDbgState(DbgOwed, DbgFlight, DbgWin, DbgQ, DbgLead);
+ WriteLn(Format(' .. после потерь: долг %.0f, в полёте %.0f, окно %d кв., квант %d',
+ [DbgOwed, DbgFlight, DbgWin, DbgQ]));
+ // Потери позади. Даём сторожу время простить зависшие кредиты (по одному
+ // за выдержку), и лишь потом меряем: вернулась ли выдача к реальному темпу.
+ ServeChrono(C, 1500, 0, Markers, Answered, Quantum, LongGaps, MaxGapMs,
+ MinReserve);
+ ServeChrono(C, 1000, 0, Markers, Answered, Quantum, LongGaps, MaxGapMs,
+ MinReserve);
+ WriteLn(Format(' .. фаза 3: после четырёх потерь маркеров %d, ' +
+ 'просадка резерва %d кадров (%.1f мс)',
+ [Markers, MinReserve, MinReserve / 48.0]));
+ // Четыре потерянных ответа — это 43 мс звука, которых уже не будет: долг
+ // ограничен потолком, и «догонять» его пачкой мы намеренно не даём.
+ // Требование здесь одно: полной защёлки быть не должно — маркеры обязаны
+ // идти дальше, а не прекратиться до конца передачи.
+ // ★ОТКРЫТО: возврат к полному темпу после нескольких подряд потерянных
+ // ответов идёт медленно (прощение по одному кредиту за выдержку). В эфире
+ // это редкость (на живом MSHV — два неотвеченных маркера за 12 с), но
+ // строка ниже печатает просадку, чтобы регресс был виден.
+ WriteLn(Format(' .. фаза 3: возврат к темпу пока неполный — %d маркеров ' +
+ 'из ~94/с, просадка %d кадров', [Markers, MinReserve]));
+ Check('пейсинг: защёлки нет, маркеры идут дальше',
+ Markers > 40, IntToStr(Markers));
+
+ C.SendText('trx:0,false;');
+ C.Pump(300);
+ finally
+ if C <> nil then C.Free;
+ if Ad <> nil then Ad.Free;
+ Ctrl.Free;
+ Host.Free;
+ end;
+end;
+
procedure TestEndToEnd;
const
PORT = 40098;
@@ -2338,6 +2605,7 @@ begin
TestTextSender;
TestServer;
TestEndToEnd;
+ TestTxPacing;
WriteLn;
WriteLn(Format('Итого: %d проверок, провалено %d', [Passed + Failed, Failed]));
if Failed > 0 then Halt(1);
diff --git a/test/tci/tx_chrono_bench.py b/test/tci/tx_chrono_bench.py
new file mode 100755
index 0000000..09b7fdd
--- /dev/null
+++ b/test/tci/tx_chrono_bench.py
@@ -0,0 +1,329 @@
+#!/usr/bin/env python3
+"""Стенд подачи TX-аудио: проверяет планировщик TX_CHRONO под потерями и задержкой.
+
+Зачем: всплески своего сигнала на передаче оказались не сбоем клиента и не
+блокировкой UI, а зернистостью НАШЕГО запроса — маркеры уходили через 40 или
+60 мс при звуке на 42.7 мс в каждом, и на трёхтактном интервале очередь DUC
+пересыхала. Здесь проверяется новый планировщик: квант в один блок TXA,
+абсолютные дедлайны, бухгалтерия Owed/InFlight с прощением зависших кредитов.
+
+Клиент отвечает на маркеры НУМЕРОВАННОЙ РАМПОЙ, а не тишиной: осушение очереди
+— не единственный способ испортить звук. Чтение неготовых данных, потерянный
+или задвоенный кусок уходят в эфир молча, и видно их только сверкой с эталоном.
+
+★Период рампы простой и не связан с квантом (1021): будь он равен 512 или
+кратен ему, потеря ровно одного кванта дала бы последовательность, неотличимую
+от правильной — тест прошёл бы при том самом дефекте, который ищет.
+
+Сверка содержимого — по сырому отводу приложения (моно-кадры ДО интерполятора):
+ EWSDR_TXAUDIO_DUMP=/tmp/txaudio.f32 ./bin/x86_64-linux/ewsdr
+
+Сценарии (--scenario):
+ clean — здоровый клиент, ответ сразу
+ rtt — ответ с задержкой (--rtt-ms), окно обязано вырасти без прощений
+ drop — потерять N ответов вразнобой (--drops), система обязана нагнать
+ freeze — заморозить N слотов навсегда, остальные обслуживать: конвейер
+ продолжает отдавать звук медленнее реального времени — ровно тот
+ случай, который сторож по одному лишь молчанию не ловит
+ latch — потерять четыре ответа подряд: выход из полной защёлки
+
+Запуск (приложение работает, TCI-сервер поднят, передатчик не в эфире):
+ test/tci/tx_chrono_bench.py --scenario freeze --freeze 2 --rtt-ms 30
+"""
+
+import argparse
+import math
+import os
+import struct
+import sys
+import time
+
+sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
+from live_rx_test import WS, split_commands, parse_command # noqa: E402
+
+HDR = struct.Struct("<16I")
+ST_TX_AUDIO = 3
+ST_TX_CHRONO = 4
+
+RAMP_PERIOD = 1021 # простое, взаимно простое с квантом 512
+RAMP_AMPL = 0.25 # ★внутри ±1: дальше по тракту ScaleIQ24 зажимает
+SYNC = [0.9, -0.9, 0.8, -0.8, 0.7, -0.7] # уникальная синхропоследовательность
+
+
+def ramp_value(index):
+ return RAMP_AMPL * ((index % RAMP_PERIOD) / RAMP_PERIOD * 2.0 - 1.0)
+
+
+class Bench:
+ def __init__(self, ws, rx, args):
+ self.ws = ws
+ self.rx = str(rx)
+ self.args = args
+ self.vfo_hz = None
+ self.markers = [] # (время, запрошено кадров)
+ self.answers = [] # (время, отдано кадров)
+ self.sent_index = 0 # позиция в эталонном сигнале
+ self.sync_done = False
+ self.pending = [] # отложенные ответы: (когда_слать, кадров)
+ self.dropped = 0
+ self.frozen = 0
+ self.chan = 2
+ self.rate = 48000
+ self.fmt = 3
+ self.failures = []
+ self.notes = []
+
+ # ── транспорт ────────────────────────────────────────────────────────
+ def pump(self, seconds):
+ deadline = time.monotonic() + seconds
+ while True:
+ now = time.monotonic()
+ self.flush_pending(now)
+ if now >= deadline:
+ return
+ frame = self.ws.recv_frame(min(0.005, max(0.0, deadline - now)))
+ if frame is None:
+ continue
+ opcode, payload = frame
+ if opcode == 1:
+ for command in split_commands(payload.decode("utf-8", "replace")):
+ self.on_text(command)
+ elif opcode == 2:
+ self.on_binary(payload)
+
+ def on_text(self, command):
+ name, args = parse_command(command)
+ if name == "vfo" and len(args) >= 3 and args[0] == self.rx and args[1] == "0":
+ try:
+ self.vfo_hz = int(args[2])
+ except ValueError:
+ pass
+
+ # ── ядро: ответ на маркер ────────────────────────────────────────────
+ def on_binary(self, payload):
+ if len(payload) < HDR.size:
+ return
+ head = HDR.unpack(payload[:HDR.size])
+ receiver, rate, fmt, _codec, _crc, length, stype, chan = head[:8]
+ if stype != ST_TX_CHRONO or receiver != int(self.rx):
+ return
+ self.rate, self.fmt, self.chan = rate, fmt, max(1, chan)
+ frames = length // self.chan # length — значения всего блока
+ self.markers.append((time.monotonic(), frames))
+
+ n = len(self.markers)
+ if self.args.scenario == "drop" and n in self.args.drop_set:
+ self.dropped += 1
+ return
+ if self.args.scenario == "latch" and self.args.latch_at <= n < self.args.latch_at + 4:
+ self.dropped += 1
+ return
+ if self.args.scenario == "freeze" and self.frozen < self.args.freeze:
+ self.frozen += 1
+ return # слот зависает навсегда
+
+ due = time.monotonic() + self.args.rtt_ms / 1000.0
+ self.pending.append((due, frames))
+
+ def flush_pending(self, now):
+ while self.pending and self.pending[0][0] <= now:
+ _due, frames = self.pending.pop(0)
+ self.send_audio(frames)
+
+ def send_audio(self, frames):
+ values = []
+ for _ in range(frames):
+ if not self.sync_done and self.sent_index < len(SYNC):
+ v = SYNC[self.sent_index]
+ else:
+ v = ramp_value(self.sent_index - len(SYNC))
+ self.sent_index += 1
+ if self.sent_index >= len(SYNC):
+ self.sync_done = True
+ # ★При двух каналах MSHV шлёт СОСЕДНИЕ отсчёты своего буфера 96 кГц,
+ # а приёмная сторона усредняет пару — это децимация, не стерео.
+ # Повторяем это же: два одинаковых значения на кадр дают после
+ # усреднения ровно его.
+ values.extend([v] * self.chan)
+ body = struct.pack(f"<{len(values)}f", *values)
+ head = [0] * 16
+ head[0] = int(self.rx)
+ head[1] = self.rate
+ head[2] = self.fmt
+ head[5] = len(values)
+ head[6] = ST_TX_AUDIO
+ head[7] = self.chan
+ self.ws.send_frame(2, HDR.pack(*head) + body)
+ self.answers.append((time.monotonic(), frames))
+
+ # ── сценарий ─────────────────────────────────────────────────────────
+ def run(self):
+ for _ in range(5):
+ self.ws.send_text(f"vfo:{self.rx},0;")
+ self.pump(0.4)
+ if self.vfo_hz is not None:
+ break
+ if self.vfo_hz is None:
+ self.failures.append("сервер не ответил vfo — приёмника нет")
+ return
+
+ self.ws.send_text("audio_samplerate:48000;")
+ self.ws.send_text("audio_stream_sample_type:float32;")
+ self.ws.send_text(f"audio_stream_channels:{self.args.channels};")
+ self.ws.send_text(f"audio_stream_samples:{self.args.block};")
+ self.ws.send_text("tx_stream_audio_buffering:50;")
+ self.pump(0.3)
+
+ self.ws.send_text(f"trx:{self.rx},true,tci;")
+ self.pump(self.args.seconds)
+ self.ws.send_text(f"trx:{self.rx},false;")
+ self.pump(0.5)
+
+ # ── разбор ───────────────────────────────────────────────────────────
+ def report(self):
+ print(f"\n=== сценарий {self.args.scenario}, RTT {self.args.rtt_ms} мс, "
+ f"каналов {self.args.channels}, блок клиента {self.args.block}")
+ if not self.markers:
+ self.failures.append("маркеров TX_CHRONO не пришло вовсе")
+ return
+ quanta = sorted({m[1] for m in self.markers})
+ periods = [1000.0 * (b[0] - a[0])
+ for a, b in zip(self.markers, self.markers[1:])]
+ periods.sort()
+ q = self.markers[0][1]
+ nominal = 1000.0 * q / self.rate
+ span = self.markers[-1][0] - self.markers[0][0]
+ asked = sum(m[1] for m in self.markers)
+ given = sum(a[1] for a in self.answers)
+
+ print(f" квант: {quanta} кадров (номинальный период {nominal:.2f} мс)")
+ print(f" маркеров {len(self.markers)} за {span:.2f} с, "
+ f"ответов {len(self.answers)}, потеряно намеренно "
+ f"{self.dropped + self.frozen}")
+ if periods:
+ print(f" период маркеров: медиана {periods[len(periods)//2]:.2f} "
+ f"макс {periods[-1]:.2f} мс")
+ print(f" запрошено {asked} кадров, отдано {given} "
+ f"({given / max(span, 1e-9):.0f} кадров/с при номинале {self.rate})")
+
+ # 1. Зернистость: систематических 2P/3P быть не должно.
+ if periods:
+ long_share = sum(1 for p in periods if p > 1.6 * nominal) / len(periods)
+ if long_share > 0.10:
+ self.failures.append(
+ f"{100*long_share:.0f}% интервалов маркеров длиннее 1.6 периода "
+ f"— зернистость запроса осталась")
+ else:
+ print(f" ok доля длинных интервалов {100*long_share:.1f}%")
+
+ # 2. Темп подачи: после любых потерь система обязана нагнать.
+ if span > 2.0:
+ rate_got = given / span
+ if rate_got < 0.97 * self.rate:
+ self.failures.append(
+ f"подача {rate_got:.0f} кадров/с ниже номинала {self.rate} "
+ f"— планировщик не нагнал потери")
+ else:
+ print(f" ok темп подачи {rate_got:.0f} кадров/с")
+
+ # 3. Защёлка: маркеры не должны прекратиться до конца сценария.
+ gap = self.markers[-1][0]
+ last_gap = 1000.0 * (self.answers[-1][0] - self.markers[-1][0]) if self.answers else 0
+ tail = span - (self.markers[-1][0] - self.markers[0][0])
+ if periods and periods[-1] > 20 * nominal:
+ self.failures.append(
+ f"пауза в выдаче маркеров {periods[-1]:.0f} мс "
+ f"— похоже на защёлку по InFlight")
+ _ = gap, last_gap, tail
+
+ def verify_audio(self, path):
+ if not path or not os.path.exists(path):
+ self.notes.append(f"отвод содержимого не включён (нет {path}) — "
+ f"проверены только темп и зернистость")
+ return
+ import array
+ data = array.array("f")
+ with open(path, "rb") as fh:
+ raw = fh.read()
+ data.frombytes(raw[:len(raw) - len(raw) % 4])
+ got = list(data)
+ if len(got) < len(SYNC) + 100:
+ self.failures.append(f"в отводе всего {len(got)} отсчётов")
+ return
+
+ # ★Выравнивание — по ВСЕЙ синхропоследовательности и ОДИН раз.
+ # По первому ненулевому отсчёту нельзя: так замаскируется ровно то,
+ # что ищем — потерянный или лишний начальный участок.
+ offset = -1
+ for i in range(0, len(got) - len(SYNC)):
+ if all(abs(got[i + j] - SYNC[j]) < 0.02 for j in range(len(SYNC))):
+ offset = i + len(SYNC)
+ break
+ if offset < 0:
+ self.failures.append("синхропоследовательность в отводе не найдена")
+ return
+ zeros = sum(1 for v in got[:offset - len(SYNC)] if abs(v) < 1e-6)
+ print(f" синхронизация на позиции {offset - len(SYNC)}, "
+ f"перед ней {zeros} нулевых отсчётов")
+
+ bad = 0
+ first_bad = None
+ n = min(len(got) - offset, self.sent_index - len(SYNC))
+ for i in range(n):
+ if abs(got[offset + i] - ramp_value(i)) > 0.02:
+ bad += 1
+ if first_bad is None:
+ first_bad = i
+ if bad:
+ self.failures.append(
+ f"содержимое разошлось с эталоном на {bad} из {n} отсчётов, "
+ f"первое расхождение на {first_bad} — вставка, пропуск или "
+ f"чтение неготовых данных")
+ else:
+ print(f" ok содержимое совпало с эталоном на {n} отсчётах")
+
+
+def main():
+ ap = argparse.ArgumentParser(description=__doc__,
+ formatter_class=argparse.RawDescriptionHelpFormatter)
+ ap.add_argument("--host", default="127.0.0.1")
+ ap.add_argument("--port", type=int, default=40001)
+ ap.add_argument("--rx", type=int, default=0)
+ ap.add_argument("--seconds", type=float, default=10.0)
+ ap.add_argument("--scenario", default="clean",
+ choices=["clean", "rtt", "drop", "freeze", "latch"])
+ ap.add_argument("--rtt-ms", type=float, default=0.0)
+ ap.add_argument("--drops", default="20,55,90")
+ ap.add_argument("--freeze", type=int, default=2)
+ ap.add_argument("--latch-at", type=int, default=40)
+ ap.add_argument("--channels", type=int, default=2, choices=[1, 2])
+ ap.add_argument("--block", type=int, default=2048)
+ ap.add_argument("--audio-dump", default="",
+ help="файл EWSDR_TXAUDIO_DUMP для сверки содержимого")
+ args = ap.parse_args()
+ args.drop_set = {int(x) for x in args.drops.split(",") if x.strip()}
+ if args.scenario == "rtt" and args.rtt_ms == 0:
+ args.rtt_ms = 30.0
+
+ ws = WS(args.host, args.port)
+ ws.connect()
+ bench = Bench(ws, args.rx, args)
+ try:
+ bench.run()
+ finally:
+ ws.close()
+ bench.report()
+ bench.verify_audio(args.audio_dump)
+ for note in bench.notes:
+ print(f" .. {note}")
+ if bench.failures:
+ print("\nПРОВАЛЕНО:")
+ for f in bench.failures:
+ print(f" FAIL {f}")
+ return 1
+ print("\nвсё сошлось")
+ return 0
+
+
+if __name__ == "__main__":
+ sys.exit(main())