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
+524 -31
View File
@@ -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);