mirror of
https://git.vladimir.cc/vladimir/ewsdr.git
synced 2026-08-25 18:43:51 +00:00
fix(tci): всплески на передаче — TX-аудио просилось общим тиком 20 мс
Посреди передачи из MSHV на водопаде появлялись всплески своего сигнала. Цепочка: очередь DUC пустеет дольше подушки отправителя (DUC_FIFO_THROTTLE = 2000 отсчётов = 10.4 мс) → FIFO радио сохнет → модуляция обрывается → в эфире остаётся голая несущая на частоте гетеродина DUC, в стороне от тона ровно на звуковой сдвиг. Доказано pcap-съёмом: шесть всплесков в дампе — ровно столько, сколько видел оператор, и каждый стоит за паузой 10.3-23.5 мс, а паузы 8 мс и короче не дали ни одного. Виноват не клиент и не блокировка UI, а зернистость НАШЕГО запроса. Слой первый: PushTxChrono жил на общем тике сервера 20 мс, а просил блок клиента целиком (2048 отсчётов = 42.7 мс) — маркер выходил через два или три тика, то есть через 40 или 60 мс. Слой второй: MSHV отвечает пачками по 4-5 блоков раз в ~44 мс (STREAM_C = 4096 при 96 кГц), и мелкий квант этого не лечит — нужен запас не меньше пачки. Сделано: * квант запроса = один блок TXA (512 отсчётов движка), а не блок клиента; * свой поток-планировщик TxTickLoop с АБСОЛЮТНЫМИ дедлайнами (опоздание одного пробуждения не сдвигает сетку); общий тик маркеров больше не шлёт; * бухгалтерия Owed/InFlight в кадрах на канал, гасится по k до интерполятора; потолок долга обязан быть выше окна в полёте (TCI_TX_OWED_HEADROOM_Q), иначе связывающим становится он и подача падает до 58% реального времени при полностью исправном клиенте; * SendBinNow: маркеры пишутся в сокет напрямую под FWriteLock, минуя очередь (та выпускается лишь на пробуждении потока клиента, TCI_POLL_MS = 20 мс — вдвое больше кванта, и подача снова рвалась); * FReapLock: планировщик TX — новый поток, а правило «клиента освобождает только тик-поток» держалось на том, что им же он и пользуется; * аванс под зернистость клиента: измеряется по ПЕРИОДУ между пачками (размер пачки зависит от того, сколько мы запросили ⇒ положительная обратная связь), переживает конец передачи, умеет уменьшаться по выдержке TCI_TX_LEAD_DOWN_MS, потолок TCI_TX_LEAD_MAX_MS; * старт передачи: KickTxTick будит планировщика на фронте PTT, TxPreWarm шлёт один маркер ДО SetMOX (41 мс раздумий клиента накладываются на нашу же подготовку тракта) под гейтом «передатчик свободен и чужого источника нет», PrimeDUCIQ для источника TCI растянут до Max(6096, аванс×4) — путь микрофона радио, CW и web не затронут; * посев аванса TCI_TX_LEAD_DEF_MS = 50 мс, пока про клиента ничего не известно: обучение к первому осушению физически не успевает. Монотонные часы одного источника для всех потоков — PlatformUtils.MonotonicUs (абсолютные дедлайны не терпят часов, способных прыгнуть от NTP). На железе: опасных осушений посреди передачи НОЛЬ (было 12 за 11 с), четыре передачи из пяти вообще без единого, включая старт; прогон 15:21 чист везде, в том числе на первой передаче после подключения. Всплесков оператор больше не видит. Приборы: TxTrace.pas (EWSDR_TXTRACE=1) и стенд test/hpsdr — кольцевой tcpdump capture.sh, разбор дампа pcap_tx_scan.py, разбор трассы txtrace_scan.py. Стенд test/tci: часть F «Пейсинг TX», 260 проверок, провалов нет; живой клиент с рампой, RTT и потерями — test/tci/tx_chrono_bench.py. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_014gmVQnna1i4EbSZ2VGm6KD
This commit is contained in:
+170
-1
@@ -59,7 +59,7 @@ interface
|
||||
|
||||
uses
|
||||
Classes, SysUtils,
|
||||
WebUtils, WsClient, TCIProtocol
|
||||
WebUtils, WsClient, TCIProtocol, PlatformUtils
|
||||
{$IFDEF WINDOWS}, Windows, WinSock2{$ELSE}, Sockets{$ENDIF},
|
||||
SyncObjs; // ← после платформенных юнитов (конфликт идентификатора Create)
|
||||
|
||||
@@ -67,6 +67,14 @@ const
|
||||
TCI_MAX_CLIENTS = 8; // прошедших handshake (слоты протокола)
|
||||
TCI_MAX_SOCKETS = 32; // всего сокетов, включая ещё не поднявшиеся
|
||||
TCI_TICK_MS = 20; // период OnTick (сенсоры троттлятся адаптером)
|
||||
// ★TX-аудио тикает ОТДЕЛЬНО от сенсоров. Общий тик в 20 мс не может выдавать
|
||||
// запросы с шагом в один TXA-блок (10.667 мс): маркеры выходили через два или
|
||||
// три тика, то есть через 40 или 60 мс, и на каждом трёхтактном интервале
|
||||
// очередь DUC пересыхала — это и были всплески на передаче. Планировщик ниже
|
||||
// ведёт АБСОЛЮТНЫЕ дедлайны, период ему задаёт адаптер (квант / частота
|
||||
// клиента), а опоздание одного пробуждения не сдвигает всю сетку.
|
||||
TCI_TX_TICK_MIN_MS = 1; // ниже не опускаемся: спать точнее всё равно не выйдет
|
||||
TCI_TX_TICK_IDLE_MS = 20; // не передаём — будим планировщик редко
|
||||
TCI_WS_GUID = '258EAFA5-E914-47DA-95CA-C5AB0DC85B11';
|
||||
TCI_SEND_TIMEOUT = 300; // мс на SockSend, иначе клиент считается мёртвым
|
||||
TCI_HANDSHAKE_MS = 5000; // мс на HTTP-запрос от подключившегося
|
||||
@@ -123,6 +131,13 @@ type
|
||||
FBinHead: Integer; // куда класть
|
||||
FBinTail: Integer; // откуда брать
|
||||
FBinDropped: LongInt; // сколько блоков выброшено (диагностика)
|
||||
// ★Запись в сокет клиента. Обычно пишет только его собственный поток, но
|
||||
// маркеры TX_CHRONO шлёт планировщик TX — им очередь не годится: она
|
||||
// выпускается лишь на пробуждении потока клиента (recv с таймаутом
|
||||
// TCI_POLL_MS), то есть запрос на модуляцию задерживался бы на все 20 мс.
|
||||
// При кванте в 10.7 мс это вдвое больше самого кванта, и подача клиента
|
||||
// становилась рваной ровно так же, как от старого тика 20 мс.
|
||||
FWriteLock: TCriticalSection;
|
||||
FDead: Boolean; // сокет уже не пишется — гасим соединение
|
||||
FKilled: Boolean; // shutdown сокета уже сделан
|
||||
FClosed: Boolean; // клиентский поток вышел (можно освобождать)
|
||||
@@ -142,6 +157,10 @@ type
|
||||
{ Блок бинарного потока (заголовок + сэмплы) в очередь. Зовётся из
|
||||
DSP-потока, поэтому только копирование под коротким локом: сеть тут не
|
||||
трогается. False — клиент мёртв (блок никуда не пошёл). }
|
||||
{ Немедленная отправка блока, минуя очередь: для TX_CHRONO, где 20 мс
|
||||
задержки очереди сопоставимы с самим квантом. Пишет из чужого потока,
|
||||
поэтому под FWriteLock. }
|
||||
function SendBinNow(const Hdr: TTCIStreamHeader): Boolean;
|
||||
function SendBin(const Hdr: TTCIStreamHeader; Data: Pointer;
|
||||
Bytes: Integer): Boolean;
|
||||
{ Сколько блоков потока выброшено из-за отставания клиента. }
|
||||
@@ -191,6 +210,21 @@ type
|
||||
FClientLock: TCriticalSection;
|
||||
FAcceptThread: TThread;
|
||||
FTickThread: TThread;
|
||||
FTxTickThread: TThread;
|
||||
// ★Освобождение клиентов и планировщик TX_CHRONO обязаны быть взаимно
|
||||
// исключены. Правило «указатель, взятый под FClientLock, живёт до
|
||||
// следующего прохода тика» держалось на том, что клиентов освобождает
|
||||
// ТОЛЬКО тик-поток и он же ими пользуется. У планировщика TX свой поток, и
|
||||
// без этого лока он может писать в клиента, которого ReapClients уже
|
||||
// освободил. Одного FClientLock мало: Reap освобождает память, уже выйдя
|
||||
// из него.
|
||||
FReapLock: TCriticalSection;
|
||||
// ★Будилка планировщика TX. Без неё первый маркер уходил в среднем на 10, а
|
||||
// в худшем на 20 мс позже фронта PTT (поток дремал с шагом
|
||||
// TCI_TX_TICK_IDLE_MS), и ровно эти миллисекунды не хватало нулевому
|
||||
// pre-roll, чтобы дожить до первого ответа клиента.
|
||||
FTxWake: PRTLEvent;
|
||||
FTxKick: Boolean; // «проснись и тикай сейчас», ставит KickTxTick
|
||||
FThreadCount: LongInt; // живых клиентских потоков (Interlocked*)
|
||||
FRunning: Boolean;
|
||||
FStopping: Boolean;
|
||||
@@ -201,6 +235,8 @@ type
|
||||
FOnConnect: TTCIClientEvent;
|
||||
FOnDisconnect: TTCIClientEvent;
|
||||
FOnTick: TThreadMethod;
|
||||
FOnTxTick: TThreadMethod; // планировщик TX_CHRONO (свой поток)
|
||||
FTxTickPeriodMs: Integer; // период планировщика, мс (ставит адаптер)
|
||||
function InitListen: Boolean;
|
||||
procedure ReapClients; // освободить клиентов, чьи потоки вышли
|
||||
procedure KillAll;
|
||||
@@ -235,6 +271,11 @@ type
|
||||
{ Внутреннее (зовётся потоками сервера). }
|
||||
procedure AcceptLoop;
|
||||
procedure TickLoop;
|
||||
procedure TxTickLoop;
|
||||
procedure BeginTxClientUse;
|
||||
procedure EndTxClientUse;
|
||||
{ Немедленно разбудить планировщик TX_CHRONO — зовётся на фронте передачи. }
|
||||
procedure KickTxTick;
|
||||
procedure HandleClient(Client: TTCIClient);
|
||||
function Promote(Client: TTCIClient): Boolean; // handshake прошёл
|
||||
procedure ThreadDone; // клиентский поток отработал
|
||||
@@ -250,6 +291,10 @@ type
|
||||
property OnConnect: TTCIClientEvent read FOnConnect write FOnConnect;
|
||||
property OnDisconnect: TTCIClientEvent read FOnDisconnect write FOnDisconnect;
|
||||
property OnTick: TThreadMethod read FOnTick write FOnTick;
|
||||
property OnTxTick: TThreadMethod read FOnTxTick write FOnTxTick;
|
||||
// Период планировщика TX_CHRONO. Адаптер ставит его равным длительности
|
||||
// кванта запроса; 0 — передачи нет, планировщик дремлет.
|
||||
property TxTickPeriodMs: Integer read FTxTickPeriodMs write FTxTickPeriodMs;
|
||||
end;
|
||||
|
||||
{ IPv4 из строки в сетевом порядке. Строгий: ровно четыре десятичных октета
|
||||
@@ -276,6 +321,15 @@ type
|
||||
public constructor Create(AServer: TTCIServer);
|
||||
end;
|
||||
|
||||
TTCITxTickThread = class(TThread)
|
||||
private
|
||||
FServer: TTCIServer;
|
||||
protected
|
||||
procedure Execute; override;
|
||||
public
|
||||
constructor Create(AServer: TTCIServer);
|
||||
end;
|
||||
|
||||
TTCITickThread = class(TThread)
|
||||
private FServer: TTCIServer;
|
||||
protected procedure Execute; override;
|
||||
@@ -312,6 +366,18 @@ begin
|
||||
FServer.TickLoop;
|
||||
end;
|
||||
|
||||
constructor TTCITxTickThread.Create(AServer: TTCIServer);
|
||||
begin
|
||||
inherited Create(True);
|
||||
FServer := AServer;
|
||||
FreeOnTerminate := False;
|
||||
end;
|
||||
|
||||
procedure TTCITxTickThread.Execute;
|
||||
begin
|
||||
FServer.TxTickLoop;
|
||||
end;
|
||||
|
||||
constructor TTCIClientThread.Create(AServer: TTCIServer; AClient: TTCIClient);
|
||||
begin
|
||||
inherited Create(True);
|
||||
@@ -438,6 +504,7 @@ begin
|
||||
FTxSensors := False;
|
||||
FTxSensorsMs := 200;
|
||||
FOutLock := TCriticalSection.Create;
|
||||
FWriteLock := TCriticalSection.Create;
|
||||
FOutCount := 0;
|
||||
SetLength(FOut, 64);
|
||||
FBinHead := 0;
|
||||
@@ -457,6 +524,7 @@ var i: Integer;
|
||||
begin
|
||||
for i := 0 to TCI_BIN_QUEUE - 1 do FBinOut[i] := nil;
|
||||
FOutLock.Free;
|
||||
FWriteLock.Free;
|
||||
FStateLock.Free;
|
||||
inherited;
|
||||
end;
|
||||
@@ -624,6 +692,24 @@ begin
|
||||
end;
|
||||
end;
|
||||
|
||||
function TTCIClient.SendBinNow(const Hdr: TTCIStreamHeader): Boolean;
|
||||
var
|
||||
Blk: TBytes;
|
||||
begin
|
||||
Result := False;
|
||||
if FDead or (FWs = nil) then Exit;
|
||||
SetLength(Blk, SizeOf(Hdr));
|
||||
Move(Hdr, Blk[0], SizeOf(Hdr));
|
||||
FWriteLock.Enter;
|
||||
try
|
||||
if FDead or (FWs = nil) then Exit;
|
||||
Result := FWs.SendBinary(Blk[0], Length(Blk));
|
||||
finally
|
||||
FWriteLock.Leave;
|
||||
end;
|
||||
if not Result then FDead := True; // гасит сокет поток клиента (см. Flush)
|
||||
end;
|
||||
|
||||
function TTCIClient.SendBin(const Hdr: TTCIStreamHeader; Data: Pointer;
|
||||
Bytes: Integer): Boolean;
|
||||
// Кладёт готовый блок в кольцо. Зовётся из DSP-потока: единственное, что тут
|
||||
@@ -709,6 +795,9 @@ begin
|
||||
|
||||
// Склейка: несколько команд в одном фрейме протокол разрешает (§3.1), а
|
||||
// syscall'ов и заголовков становится в разы меньше.
|
||||
// ★Под FWriteLock: в тот же сокет пишет планировщик TX (SendBinNow).
|
||||
FWriteLock.Enter;
|
||||
try
|
||||
Chunk := '';
|
||||
for i := 0 to N - 1 do
|
||||
begin
|
||||
@@ -733,6 +822,9 @@ begin
|
||||
end;
|
||||
Bins[i] := nil;
|
||||
end;
|
||||
finally
|
||||
FWriteLock.Leave;
|
||||
end;
|
||||
end;
|
||||
|
||||
procedure TTCIClient.Kill;
|
||||
@@ -774,14 +866,20 @@ begin
|
||||
FClientCount := 0;
|
||||
FUpCount := 0;
|
||||
FClientLock := TCriticalSection.Create;
|
||||
FReapLock := TCriticalSection.Create;
|
||||
FTxWake := RTLEventCreate;
|
||||
FTxKick := False;
|
||||
FPort := TCI_DEFAULT_PORT;
|
||||
FBindIP := '127.0.0.1';
|
||||
FTxTickPeriodMs := 0; // передачи нет — планировщик TX_CHRONO дремлет
|
||||
end;
|
||||
|
||||
destructor TTCIServer.Destroy;
|
||||
begin
|
||||
Stop;
|
||||
FClientLock.Free;
|
||||
FReapLock.Free;
|
||||
RTLEventDestroy(FTxWake);
|
||||
{$IFDEF WINDOWS}
|
||||
WSACleanup;
|
||||
{$ENDIF}
|
||||
@@ -865,6 +963,8 @@ begin
|
||||
TTCIAcceptThread(FAcceptThread).Start;
|
||||
FTickThread := TTCITickThread.Create(Self);
|
||||
TTCITickThread(FTickThread).Start;
|
||||
FTxTickThread := TTCITxTickThread.Create(Self);
|
||||
TTCITxTickThread(FTxTickThread).Start;
|
||||
Result := True;
|
||||
end;
|
||||
|
||||
@@ -918,6 +1018,7 @@ begin
|
||||
// Шаг 3: будим клиентские потоки, висящие в recv, и останавливаем тик.
|
||||
KillAll;
|
||||
JoinPumped(FTickThread);
|
||||
JoinPumped(FTxTickThread);
|
||||
|
||||
// Шаг 4: ждём выхода клиентских потоков — БЕЗ таймаута. Прокачивая очередь
|
||||
// Synchronize: Stop зовёт поток контроллера (UI), а клиентский поток может
|
||||
@@ -1050,6 +1151,22 @@ begin
|
||||
end;
|
||||
end;
|
||||
|
||||
procedure TTCIServer.KickTxTick;
|
||||
begin
|
||||
FTxKick := True;
|
||||
RTLEventSetEvent(FTxWake);
|
||||
end;
|
||||
|
||||
procedure TTCIServer.BeginTxClientUse;
|
||||
begin
|
||||
FReapLock.Enter;
|
||||
end;
|
||||
|
||||
procedure TTCIServer.EndTxClientUse;
|
||||
begin
|
||||
FReapLock.Leave;
|
||||
end;
|
||||
|
||||
procedure TTCIServer.ReapClients;
|
||||
// Освобождение клиентов — единственное место, кроме Stop. Зовёт только
|
||||
// тик-поток, поэтому указатель, взятый кем угодно под FClientLock, живёт до
|
||||
@@ -1059,6 +1176,8 @@ var
|
||||
Doomed: array[0..TCI_MAX_SOCKETS-1] of TTCIClient;
|
||||
begin
|
||||
N := 0;
|
||||
FReapLock.Enter;
|
||||
try
|
||||
FClientLock.Enter;
|
||||
try
|
||||
i := 0;
|
||||
@@ -1084,6 +1203,9 @@ begin
|
||||
Doomed[i].Ws.Free; // закрывает сокет
|
||||
Doomed[i].Free;
|
||||
end;
|
||||
finally
|
||||
FReapLock.Leave;
|
||||
end;
|
||||
end;
|
||||
|
||||
procedure TTCIServer.Disconnected(Client: TTCIClient);
|
||||
@@ -1473,6 +1595,53 @@ begin
|
||||
Result := FUpCount;
|
||||
end;
|
||||
|
||||
procedure TTCIServer.TxTickLoop;
|
||||
// Планировщик TX_CHRONO. Отдельный поток и АБСОЛЮТНЫЕ дедлайны: период здесь —
|
||||
// длительность одного кванта запроса (около 10.7 мс при 512 отсчётах на 48 кГц),
|
||||
// и он не обязан быть кратен чему-либо ещё в сервере. Дробный остаток копится в
|
||||
// микросекундах, поэтому сетка не уезжает от округления периода до миллисекунд.
|
||||
var
|
||||
NextDueUs: Int64;
|
||||
NowUs: Int64;
|
||||
PeriodUs: Int64;
|
||||
SleepMs: Integer;
|
||||
begin
|
||||
NextDueUs := MonotonicUs;
|
||||
while FRunning do
|
||||
begin
|
||||
// Период ставит адаптер по кванту запроса. Пока передачи нет, он нулевой —
|
||||
// но тикать всё равно надо, иначе адаптеру негде будет его выставить, когда
|
||||
// передача начнётся (тик и период определяют друг друга).
|
||||
PeriodUs := Int64(FTxTickPeriodMs) * 1000;
|
||||
if PeriodUs < TCI_TX_TICK_MIN_MS * 1000 then
|
||||
PeriodUs := TCI_TX_TICK_IDLE_MS * 1000;
|
||||
|
||||
NowUs := MonotonicUs;
|
||||
// Фронт передачи: тикаем немедленно, не дожидаясь дремотного шага.
|
||||
if FTxKick then
|
||||
begin
|
||||
FTxKick := False;
|
||||
NextDueUs := NowUs;
|
||||
end;
|
||||
if NextDueUs <= NowUs then
|
||||
begin
|
||||
if Assigned(FOnTxTick) then FOnTxTick;
|
||||
Inc(NextDueUs, PeriodUs);
|
||||
// Проспали больше периода (планировщик ОС, пауза процесса) — не
|
||||
// отыгрываем пропущенные тики пачкой: долг всё равно считается по часам
|
||||
// внутри адаптера, а пачка маркеров только раздует окно в полёте.
|
||||
NowUs := MonotonicUs;
|
||||
if NextDueUs < NowUs then NextDueUs := NowUs;
|
||||
Continue;
|
||||
end;
|
||||
|
||||
SleepMs := Integer((NextDueUs - NowUs) div 1000);
|
||||
if SleepMs <= 0 then SleepMs := TCI_TX_TICK_MIN_MS;
|
||||
// Просыпаемся либо по расписанию, либо по звонку с фронта PTT.
|
||||
RTLEventWaitFor(FTxWake, SleepMs);
|
||||
end;
|
||||
end;
|
||||
|
||||
procedure TTCIServer.TickLoop;
|
||||
begin
|
||||
while FRunning do
|
||||
|
||||
Reference in New Issue
Block a user