mirror of
https://git.vladimir.cc/vladimir/ewsdr.git
synced 2026-08-25 20:37:33 +00:00
fix(tci): всплески на передаче — TX-аудио просилось общим тиком 20 мс
Посреди передачи из MSHV на водопаде появлялись всплески своего сигнала. Цепочка: очередь DUC пустеет дольше подушки отправителя (DUC_FIFO_THROTTLE = 2000 отсчётов = 10.4 мс) → FIFO радио сохнет → модуляция обрывается → в эфире остаётся голая несущая на частоте гетеродина DUC, в стороне от тона ровно на звуковой сдвиг. Доказано pcap-съёмом: шесть всплесков в дампе — ровно столько, сколько видел оператор, и каждый стоит за паузой 10.3-23.5 мс, а паузы 8 мс и короче не дали ни одного. Виноват не клиент и не блокировка UI, а зернистость НАШЕГО запроса. Слой первый: PushTxChrono жил на общем тике сервера 20 мс, а просил блок клиента целиком (2048 отсчётов = 42.7 мс) — маркер выходил через два или три тика, то есть через 40 или 60 мс. Слой второй: MSHV отвечает пачками по 4-5 блоков раз в ~44 мс (STREAM_C = 4096 при 96 кГц), и мелкий квант этого не лечит — нужен запас не меньше пачки. Сделано: * квант запроса = один блок TXA (512 отсчётов движка), а не блок клиента; * свой поток-планировщик TxTickLoop с АБСОЛЮТНЫМИ дедлайнами (опоздание одного пробуждения не сдвигает сетку); общий тик маркеров больше не шлёт; * бухгалтерия Owed/InFlight в кадрах на канал, гасится по k до интерполятора; потолок долга обязан быть выше окна в полёте (TCI_TX_OWED_HEADROOM_Q), иначе связывающим становится он и подача падает до 58% реального времени при полностью исправном клиенте; * SendBinNow: маркеры пишутся в сокет напрямую под FWriteLock, минуя очередь (та выпускается лишь на пробуждении потока клиента, TCI_POLL_MS = 20 мс — вдвое больше кванта, и подача снова рвалась); * FReapLock: планировщик TX — новый поток, а правило «клиента освобождает только тик-поток» держалось на том, что им же он и пользуется; * аванс под зернистость клиента: измеряется по ПЕРИОДУ между пачками (размер пачки зависит от того, сколько мы запросили ⇒ положительная обратная связь), переживает конец передачи, умеет уменьшаться по выдержке TCI_TX_LEAD_DOWN_MS, потолок TCI_TX_LEAD_MAX_MS; * старт передачи: KickTxTick будит планировщика на фронте PTT, TxPreWarm шлёт один маркер ДО SetMOX (41 мс раздумий клиента накладываются на нашу же подготовку тракта) под гейтом «передатчик свободен и чужого источника нет», PrimeDUCIQ для источника TCI растянут до Max(6096, аванс×4) — путь микрофона радио, CW и web не затронут; * посев аванса TCI_TX_LEAD_DEF_MS = 50 мс, пока про клиента ничего не известно: обучение к первому осушению физически не успевает. Монотонные часы одного источника для всех потоков — PlatformUtils.MonotonicUs (абсолютные дедлайны не терпят часов, способных прыгнуть от NTP). На железе: опасных осушений посреди передачи НОЛЬ (было 12 за 11 с), четыре передачи из пяти вообще без единого, включая старт; прогон 15:21 чист везде, в том числе на первой передаче после подключения. Всплесков оператор больше не видит. Приборы: TxTrace.pas (EWSDR_TXTRACE=1) и стенд test/hpsdr — кольцевой tcpdump capture.sh, разбор дампа pcap_tx_scan.py, разбор трассы txtrace_scan.py. Стенд test/tci: часть F «Пейсинг TX», 260 проверок, провалов нет; живой клиент с рампой, RTT и потерями — test/tci/tx_chrono_bench.py. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_014gmVQnna1i4EbSZ2VGm6KD
This commit is contained in:
+15
-1
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
|
||||
+18
-1
@@ -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,6 +6339,14 @@ begin
|
||||
// на bfo-глитч тона (устранён коммитом f2d0519). Снижено до 2 блоков ради
|
||||
// латентности старта TX (~10мс). Если под нагрузкой DUP / быстром пере-PTT
|
||||
// вернётся щелчок РОВНО на старте — это реальный underrun, вернуть 8144.
|
||||
// ★При модуляции из 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
|
||||
@@ -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);
|
||||
|
||||
+524
-31
@@ -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;
|
||||
|
||||
procedure TTCIAdapter.PushTxChrono;
|
||||
// Тик-поток. Маркер TX_CHRONO говорит клиенту «пришли столько-то отсчётов»
|
||||
// (§3.4). Пейсинг по часам: сколько времени прошло — столько и просим, плюс
|
||||
// разовая подушка TX_STREAM_AUDIO_BUFFERING на старте передачи. Ответа не
|
||||
// ждём: не успел клиент — в эфир уйдёт тишина, это его забота.
|
||||
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
|
||||
C: TTCIClient;
|
||||
Now_: QWord;
|
||||
Rate, Chans, Block, Rx: Integer;
|
||||
Rate, Chans, Q, Seed: Integer;
|
||||
ST: TTCISampleType;
|
||||
H: TTCIStreamHeader;
|
||||
Active: Boolean;
|
||||
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 (свой поток сервера, абсолютные дедлайны).
|
||||
//
|
||||
// Долг растёт по часам, гасится ТОЛЬКО фактически принятым звуком. Маркер
|
||||
// уходит, пока непокрытая часть долга больше кванта:
|
||||
//
|
||||
// Owed − InFlight >= Q
|
||||
//
|
||||
// ★Сторож. Потерянный ответ навсегда занимает слот в InFlight: сам по себе один
|
||||
// такой слот темпа не ломает (долг и окно просто стоят выше), но накопившись до
|
||||
// потолка окна они дают ЗАЩЁЛКУ — условие выдачи перестаёт выполняться, маркеры
|
||||
// прекращаются, и в эфир идёт тишина до конца посылки. Признак — долгое
|
||||
// ОДНОВРЕМЕННОЕ насыщение долга и окна; частично замороженный конвейер при этом
|
||||
// продолжает отдавать звук (медленнее реального времени), поэтому сторожа по
|
||||
// одному лишь молчанию клиента недостаточно.
|
||||
var
|
||||
C: TTCIClient;
|
||||
NowUs, PeriodUs, StallUs: Int64;
|
||||
Rate, Chans, Q, Rx, Sent, LeadWant: Integer;
|
||||
ST: TTCISampleType;
|
||||
H: TTCIStreamHeader;
|
||||
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;
|
||||
NowUs := MonotonicUs;
|
||||
Rate := C.AudioRate;
|
||||
if not TCIValidAudioRate(Rate) then Rate := TCI_AUDIO_RATE_DEF;
|
||||
Chans := EnsureRange(C.AudioChannels, 1, 2);
|
||||
Block := EnsureRange(C.AudioSamples, TCI_AUDIO_SAMPLES_MIN,
|
||||
TCI_AUDIO_SAMPLES_MAX);
|
||||
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;
|
||||
while FTxOwed >= Block do
|
||||
TxUpdateWindow(Rate);
|
||||
if FTxOwed > TxOwedCap then FTxOwed := TxOwedCap;
|
||||
end;
|
||||
|
||||
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);
|
||||
|
||||
+170
-1
@@ -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
|
||||
|
||||
+139
@@ -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.
|
||||
@@ -17,9 +17,9 @@
|
||||
<UseVersionInfo Value="True"/>
|
||||
<AutoIncrementBuild Value="True"/>
|
||||
<MinorVersionNr Value="9"/>
|
||||
<BuildNr Value="300"/>
|
||||
<BuildNr Value="302"/>
|
||||
</VersionInfo>
|
||||
<MacroValues Count="148">
|
||||
<MacroValues Count="160">
|
||||
<Macro1 Name="LCLWidgetType" Value="qt6"/>
|
||||
<Macro2 Name="LCLWidgetType" Value="qt6"/>
|
||||
<Macro3 Name="LCLWidgetType" Value="qt6"/>
|
||||
@@ -168,6 +168,18 @@
|
||||
<Macro146 Name="LCLWidgetType" Value="qt6"/>
|
||||
<Macro147 Name="LCLWidgetType" Value="qt6"/>
|
||||
<Macro148 Name="LCLWidgetType" Value="qt6"/>
|
||||
<Macro149 Name="LCLWidgetType" Value="qt6"/>
|
||||
<Macro150 Name="LCLWidgetType" Value="qt6"/>
|
||||
<Macro151 Name="LCLWidgetType" Value="qt6"/>
|
||||
<Macro152 Name="LCLWidgetType" Value="qt6"/>
|
||||
<Macro153 Name="LCLWidgetType" Value="qt6"/>
|
||||
<Macro154 Name="LCLWidgetType" Value="qt6"/>
|
||||
<Macro155 Name="LCLWidgetType" Value="qt6"/>
|
||||
<Macro156 Name="LCLWidgetType" Value="qt6"/>
|
||||
<Macro157 Name="LCLWidgetType" Value="qt6"/>
|
||||
<Macro158 Name="LCLWidgetType" Value="qt6"/>
|
||||
<Macro159 Name="LCLWidgetType" Value="qt6"/>
|
||||
<Macro160 Name="LCLWidgetType" Value="qt6"/>
|
||||
</MacroValues>
|
||||
<BuildModes>
|
||||
<Item Name="Debug" Default="True"/>
|
||||
|
||||
@@ -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';
|
||||
|
||||
+2
-1
@@ -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;
|
||||
|
||||
@@ -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`
|
||||
обязан быть вдвое больше моно-отсчётов до интерполяции.
|
||||
Executable
+66
@@ -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"
|
||||
Executable
+843
@@ -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()
|
||||
Executable
+219
@@ -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())
|
||||
@@ -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);
|
||||
|
||||
Executable
+329
@@ -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())
|
||||
Reference in New Issue
Block a user