fix(tci): всплески на передаче — TX-аудио просилось общим тиком 20 мс

Посреди передачи из MSHV на водопаде появлялись всплески своего сигнала.
Цепочка: очередь DUC пустеет дольше подушки отправителя (DUC_FIFO_THROTTLE
= 2000 отсчётов = 10.4 мс) → FIFO радио сохнет → модуляция обрывается → в
эфире остаётся голая несущая на частоте гетеродина DUC, в стороне от тона
ровно на звуковой сдвиг. Доказано pcap-съёмом: шесть всплесков в дампе —
ровно столько, сколько видел оператор, и каждый стоит за паузой 10.3-23.5 мс,
а паузы 8 мс и короче не дали ни одного.

Виноват не клиент и не блокировка UI, а зернистость НАШЕГО запроса. Слой
первый: PushTxChrono жил на общем тике сервера 20 мс, а просил блок клиента
целиком (2048 отсчётов = 42.7 мс) — маркер выходил через два или три тика,
то есть через 40 или 60 мс. Слой второй: MSHV отвечает пачками по 4-5 блоков
раз в ~44 мс (STREAM_C = 4096 при 96 кГц), и мелкий квант этого не лечит —
нужен запас не меньше пачки.

Сделано:
* квант запроса = один блок TXA (512 отсчётов движка), а не блок клиента;
* свой поток-планировщик TxTickLoop с АБСОЛЮТНЫМИ дедлайнами (опоздание
  одного пробуждения не сдвигает сетку); общий тик маркеров больше не шлёт;
* бухгалтерия Owed/InFlight в кадрах на канал, гасится по k до интерполятора;
  потолок долга обязан быть выше окна в полёте (TCI_TX_OWED_HEADROOM_Q),
  иначе связывающим становится он и подача падает до 58% реального времени
  при полностью исправном клиенте;
* SendBinNow: маркеры пишутся в сокет напрямую под FWriteLock, минуя очередь
  (та выпускается лишь на пробуждении потока клиента, TCI_POLL_MS = 20 мс —
  вдвое больше кванта, и подача снова рвалась);
* FReapLock: планировщик TX — новый поток, а правило «клиента освобождает
  только тик-поток» держалось на том, что им же он и пользуется;
* аванс под зернистость клиента: измеряется по ПЕРИОДУ между пачками (размер
  пачки зависит от того, сколько мы запросили ⇒ положительная обратная связь),
  переживает конец передачи, умеет уменьшаться по выдержке TCI_TX_LEAD_DOWN_MS,
  потолок TCI_TX_LEAD_MAX_MS;
* старт передачи: KickTxTick будит планировщика на фронте PTT, TxPreWarm шлёт
  один маркер ДО SetMOX (41 мс раздумий клиента накладываются на нашу же
  подготовку тракта) под гейтом «передатчик свободен и чужого источника нет»,
  PrimeDUCIQ для источника TCI растянут до Max(6096, аванс×4) — путь микрофона
  радио, CW и web не затронут;
* посев аванса TCI_TX_LEAD_DEF_MS = 50 мс, пока про клиента ничего не известно:
  обучение к первому осушению физически не успевает.

Монотонные часы одного источника для всех потоков — PlatformUtils.MonotonicUs
(абсолютные дедлайны не терпят часов, способных прыгнуть от NTP).

На железе: опасных осушений посреди передачи НОЛЬ (было 12 за 11 с), четыре
передачи из пяти вообще без единого, включая старт; прогон 15:21 чист везде,
в том числе на первой передаче после подключения. Всплесков оператор больше
не видит.

Приборы: TxTrace.pas (EWSDR_TXTRACE=1) и стенд test/hpsdr — кольцевой tcpdump
capture.sh, разбор дампа pcap_tx_scan.py, разбор трассы txtrace_scan.py.
Стенд test/tci: часть F «Пейсинг TX», 260 проверок, провалов нет; живой клиент
с рампой, RTT и потерями — test/tci/tx_chrono_bench.py.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014gmVQnna1i4EbSZ2VGm6KD
This commit is contained in:
2026-08-23 22:06:10 +03:00
co-authored by Claude Opus 5
parent ced8581ada
commit d2096976df
15 changed files with 2796 additions and 39 deletions
+268
View File
@@ -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);
+329
View File
@@ -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())