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