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:
+524
-31
@@ -44,7 +44,7 @@ interface
|
||||
uses
|
||||
Classes, SysUtils, DateUtils, Math, SyncObjs,
|
||||
RadioController, RadioBackend, WDSPEngine, Settings, PlatformUtils,
|
||||
DXSpotStore, TCIProtocol, TCIServer, TCIStreams;
|
||||
DXSpotStore, TCIProtocol, TCIServer, TCIStreams, TxTrace;
|
||||
|
||||
const
|
||||
// ★Приёмник TCI: 0 — главный тракт (каналы A/B = VFO A/B), N — слайс СЛОТА
|
||||
@@ -60,6 +60,60 @@ const
|
||||
// Аудио на выходе движка всегда 48 кГц (TWDSPEngine.Create), от него и
|
||||
// считаются все прореживания и пересчёты потоков.
|
||||
TCI_AUDIO_ENGINE_RATE = 48000;
|
||||
|
||||
// ── Запрос TX-аудио у клиента (§3.4) ──
|
||||
// Квант = один блок TXA (512 отсчётов движка = 10.667 мс). Просить крупнее
|
||||
// нельзя: подушка отправителя DUC — 2000 отсчётов @192 кГц, то есть 10.42 мс,
|
||||
// и любой запрос длиннее её по времени оставляет очередь сухой на разницу.
|
||||
TCI_TX_QUANTUM_ENGINE = 512;
|
||||
// ★Потолок долга обязан быть БОЛЬШЕ окна в полёте, иначе связывающим
|
||||
// ограничением становится он, а окно превращается в украшение: при долге,
|
||||
// упёртом в потолок, новый маркер можно послать только взамен ответа, то есть
|
||||
// темп подачи падает до «окно / время ответа». На клиенте с ответом 55 мс это
|
||||
// давало 58% реального времени при полностью исправном клиенте — тракт
|
||||
// недокармливался ровно так же, как при потерях. Держим потолок на квант выше
|
||||
// окна.
|
||||
TCI_TX_OWED_HEADROOM_Q = 1;
|
||||
// Долгу разрешено уходить в минус: клиент вправе прислать больше
|
||||
// запрошенного, и зажим в ноль заставил бы переспросить уже полученное, то
|
||||
// есть подать звук дважды.
|
||||
TCI_TX_OWED_FLOOR_Q = 4;
|
||||
// Окно в полёте. Глубина конвейера = ceil(задержка ответа / период кванта),
|
||||
// поэтому фиксированные 4 кванта сами становятся защёлкой на клиенте с
|
||||
// задержкой больше ~42 мс. Растёт по измеренной задержке, но не выше
|
||||
// серверного потолка: читать сильно вперёд опасно — у клиента можно
|
||||
// вычерпать ещё не сформированный звук, и это отказ МОЛЧАЛИВЫЙ (в эфир уйдут
|
||||
// неготовые данные, ни один счётчик их не покажет).
|
||||
TCI_TX_WINDOW_MIN_Q = 4;
|
||||
TCI_TX_MAX_INFLIGHT_MS = 80;
|
||||
// Сторож. Отставание от реального времени видно ровно одним признаком:
|
||||
// долг и окно ОДНОВРЕМЕННО стоят у своих потолков. Разность Owed−InFlight для
|
||||
// этого не годится — в защёлке обе величины на потолке, и разность выглядит
|
||||
// здоровой, как при нормальной работе.
|
||||
TCI_TX_STALL_Q = 5; // выдержка насыщения, квантов
|
||||
TCI_TX_STALL_RTT_K = 3; // и не меньше стольких оценённых задержек
|
||||
TCI_TX_GRACE_MS = 300; // grace на старте: клиент собирает первый блок
|
||||
TCI_TX_MARKERS_PER_TICK = 8; // явный потолок пачки за одно пробуждение
|
||||
TCI_TX_STALL_MAX_MS = 250; // и не дольше этого: ждать больше нечего
|
||||
// ★Аванс под ЗЕРНИСТОСТЬ КЛИЕНТА. Мелкого запроса мало: MSHV пишет свой
|
||||
// TX-буфер granule'ами STREAM_C = 4096 отсчётов при 96 кГц (network.cpp), то
|
||||
// есть 42.7 мс, и отвечает на маркеры ПАЧКАМИ по 4-5 блоков раз в ~44 мс — как
|
||||
// ни дроби запрос. Замер на железе: 218 осушений очереди за 11 с, ровно по
|
||||
// одному на пачку, из них 12 длиннее подушки. Лечится только запасом не меньше
|
||||
// пачки. Это НЕ регулятор по уровню очереди (тот управлял бы темпом запроса,
|
||||
// то есть скоростью звука) — разовая монотонная добавка к долгу с потолком.
|
||||
TCI_TX_BURST_GAP_US = 2000; // пауза, разделяющая пачки ответов клиента
|
||||
TCI_TX_GAP_MAX_US = 200000; // пауза длиннее — это не зернистость, а сбой
|
||||
TCI_TX_LEAD_MAX_MS = 120; // потолок аванса (он же задержка передачи)
|
||||
// ★Посев аванса, пока про клиента ничего не известно. Первая передача после
|
||||
// подключения иначе стартует с pre-roll 31.75 мс против первого ответа MSHV
|
||||
// на 41-й миллисекунде — и сохнет. Обучение к этому моменту физически не
|
||||
// успевает: аванс появляется только после первого ответа.
|
||||
TCI_TX_LEAD_DEF_MS = 50;
|
||||
// Аванс умеет и уменьшаться, иначе посев выше — налог на клиента с мелкой
|
||||
// гранулой (у него аванс это чистая задержка). Вниз только по выдержке:
|
||||
// осушение дороже лишних миллисекунд задержки.
|
||||
TCI_TX_LEAD_DOWN_MS = 3000;
|
||||
// Потолок разворота одного блока TX-аудио в 48 кГц: 8192 отсчёта int16 на
|
||||
// 8 кГц дают ×6. Больше в блок не влезает по протоколу (data[16384]).
|
||||
TCI_TX_OUT_MAX = (TCI_STREAM_DATA_MAX div 2) * 6;
|
||||
@@ -158,8 +212,39 @@ type
|
||||
FTxInterp: TTCIInterpolator;
|
||||
FTxInRate: Integer; // частота дискретизации подачи клиента
|
||||
FTxRunning: Boolean; // маркеры TX_CHRONO идут
|
||||
FTxOwed: Double; // сколько сэмплов клиент нам «должен»
|
||||
FTxLastMs: QWord;
|
||||
// ── Бухгалтерия запросов TX-аудио ──
|
||||
// ★ЕДИНИЦЫ: и долг, и окно считаются в КАДРАХ НА КАНАЛ при частоте клиента
|
||||
// — ровно в тех, в которых называется квант в маркере. Гасить их надо на k
|
||||
// (моно-кадры ДО интерполятора), а не на length из шапки (это значения,
|
||||
// вдвое больше при стерео) и не на N после интерполяции (это уже 48 кГц,
|
||||
// вшестеро больше у клиента на 8 кГц). Подмена любой из двух величин даёт
|
||||
// стабильно неверный темп запроса, который не проявится на 48 кГц моно.
|
||||
FTxOwed: Double; // сколько кадров клиент должен нам по часам
|
||||
FTxInFlight: Double; // запрошено, но ещё не пришло
|
||||
FTxLastUs: Int64; // монотонные часы долга
|
||||
FTxProgressUs: Int64; // когда последний раз пришёл валидный блок
|
||||
FTxSatSinceUs: Int64; // с какого момента насыщены И долг, И окно (0 — нет)
|
||||
FTxArmed: Boolean; // сторож вооружён (после первого ответа/grace)
|
||||
FTxReqUs: array[0..63] of Int64; // времена отправки маркеров (FIFO)
|
||||
FTxReqHead: Integer;
|
||||
FTxReqTail: Integer;
|
||||
FTxLatencyUs: Double; // оценка «маркер → ответ», мкс
|
||||
FTxWindowQ: Integer; // окно в полёте, квантов
|
||||
FTxQuantum: Integer; // текущий квант, кадров на канал
|
||||
FTxHealthy: Boolean; // участок без потерь/прощений (можно мерить задержку)
|
||||
// ★Мерим ПЕРИОД между пачками, а не их размер. Размер зависит и от того,
|
||||
// сколько мы запросили: попросили больше — клиент ответил длиннее — аванс
|
||||
// подрос — попросили ещё больше. Это положительная обратная связь. Период
|
||||
// же равен внутренней грануле клиента (у MSHV — STREAM_C/96 кГц = 42.7 мс)
|
||||
// и от нашего темпа не зависит вовсе.
|
||||
FTxGapUs: Double; // оценка периода между пачками, мкс
|
||||
FTxLead: Integer; // выданный аванс под зернистость клиента, кадров
|
||||
FTxLeadKeep: Integer; // ★он же, но ПЕРЕЖИВАЮЩИЙ конец передачи
|
||||
FTxLeadRate: Integer; // частота, при которой аванс измерен
|
||||
FTxLeadLowUs: Int64; // с какого момента оценка держится НИЖЕ аванса
|
||||
FTxTraceLast: Int64; // приборы: время прошлого маркера
|
||||
FTxTraceAudio: Int64; // приборы: время прошлого блока аудио
|
||||
FTxLastRxUs: Int64; // когда пришёл прошлый блок (границы пачек)
|
||||
// Рабочие буферы разбора TX-блока. Полем, а не на стеке: развёрнутый в
|
||||
// 48 кГц блок — это сотни килобайт, и класть их в стек потока клиента
|
||||
// (да ещё на каждый блок двадцать раз в секунду) незачем.
|
||||
@@ -239,7 +324,15 @@ type
|
||||
const Left, Right: array of Single; Count: Integer);
|
||||
procedure OnIQTap(PanId: Integer; PI_, PQ_: PDouble; N, RateHz: Integer);
|
||||
procedure HandleBinary(Client: TTCIClient; Data: PByte; Len: Integer);
|
||||
procedure PushTxChrono; // тик: маркеры времени клиенту
|
||||
procedure PushTxChrono; // планировщик: маркеры времени клиенту
|
||||
function TxQuantumFor(C: TTCIClient; Rate: Integer): Integer;
|
||||
procedure TxResetAccounting(Rate: Integer; C: TTCIClient);
|
||||
procedure TxNoteRequest(NowUs: Int64);
|
||||
procedure TxNoteReply(NowUs: Int64);
|
||||
procedure TxUpdateWindow(Rate: Integer);
|
||||
function TxOwedCap: Double;
|
||||
procedure TxPublishLead(Rate: Integer);
|
||||
procedure TxPreWarm(Client: TTCIClient; Rx: Integer);
|
||||
procedure CmdStream(Client: TTCIClient; const M: TTCIMessage);
|
||||
procedure CmdRecorder(Client: TTCIClient; const M: TTCIMessage);
|
||||
function EnqueueWav(const APath: string; const R: TTCIRecTake;
|
||||
@@ -331,6 +424,7 @@ type
|
||||
procedure HandleConnect(Client: TTCIClient);
|
||||
procedure HandleDisconnect(Client: TTCIClient);
|
||||
procedure HandleTick;
|
||||
procedure HandleTxTick;
|
||||
procedure PushSensors(Client: TTCIClient);
|
||||
procedure OnState(Sender: TObject; Field: TRadioField);
|
||||
|
||||
@@ -347,6 +441,9 @@ type
|
||||
False — включить просили, а порт не открылся (занят/нет прав/кривой
|
||||
адрес): вызывающий обязан сказать это оператору, иначе тот останется с
|
||||
галкой «включено» и мёртвым сервером. }
|
||||
// Диагностика для стенда: состояние бухгалтерии запросов TX-аудио.
|
||||
function TxDbgState(out Owed, InFlight: Double;
|
||||
out WindowQ, Quantum, LeadFrames: Integer): Boolean;
|
||||
function ApplySettings(const T: TTCISettings): Boolean;
|
||||
|
||||
function Active: Boolean;
|
||||
@@ -418,6 +515,7 @@ begin
|
||||
FServer.OnConnect := HandleConnect;
|
||||
FServer.OnDisconnect := HandleDisconnect;
|
||||
FServer.OnTick := HandleTick;
|
||||
FServer.OnTxTick := HandleTxTick;
|
||||
|
||||
RefreshDev;
|
||||
// Первый снимок — прямо здесь: конструктор идёт в потоке контроллера, а
|
||||
@@ -2052,6 +2150,8 @@ begin
|
||||
Name := LowerCase(Trim(TCIArg(M, 2)));
|
||||
FromTCI := B and (not Tune) and (Name = 'tci') and HasAudioStream(Client);
|
||||
Started := False;
|
||||
// ★До SetMOX: запрос клиенту вперёд подготовки тракта (см. TxPreWarm).
|
||||
if FromTCI then TxPreWarm(Client, Rx);
|
||||
FLock.Enter;
|
||||
try
|
||||
FsInt := Rx;
|
||||
@@ -2087,6 +2187,11 @@ begin
|
||||
begin
|
||||
FTxClient := Client;
|
||||
FTxRx := Rx;
|
||||
// ★Будим планировщик немедленно. Иначе первый маркер уходил на
|
||||
// 10-20 мс позже фронта PTT (дремотный шаг), и ровно этих
|
||||
// миллисекунд не хватало нулевому pre-roll, чтобы дожить до первого
|
||||
// ответа клиента: очередь DUC пересыхала на старте каждой передачи.
|
||||
FServer.KickTxTick;
|
||||
end
|
||||
// Эфир поднял он же, но модуляцию просит НЕ из TCI — снимаем свою.
|
||||
else if Started and (not Tune) and (FTxClient = Client) then
|
||||
@@ -2765,13 +2870,31 @@ begin
|
||||
end;
|
||||
end;
|
||||
|
||||
procedure TTCIAdapter.HandleTxTick;
|
||||
begin
|
||||
// Свой поток планировщика: исключение здесь остановило бы подачу модуляции
|
||||
// до перезапуска сервера.
|
||||
// ★Под замком освобождения клиентов: FTxClient указывает на объект, который
|
||||
// тик-поток вправе освободить в любой момент, а этот поток — не тот, что
|
||||
// раньше пользовался этим указателем.
|
||||
FServer.BeginTxClientUse;
|
||||
try
|
||||
try
|
||||
PushTxChrono;
|
||||
except
|
||||
// молча: следующий тик попробует снова
|
||||
end;
|
||||
finally
|
||||
FServer.EndTxClientUse;
|
||||
end;
|
||||
end;
|
||||
|
||||
procedure TTCIAdapter.HandleTick;
|
||||
begin
|
||||
// Тик крутится в своём потоке сервера: исключение здесь остановило бы
|
||||
// измерители у ВСЕХ клиентов до перезапуска сервера.
|
||||
try
|
||||
FServer.EnumClients(PushSensors);
|
||||
PushTxChrono;
|
||||
SweepRecorders;
|
||||
except
|
||||
// молча: следующий тик через 20 мс попробует снова
|
||||
@@ -3414,6 +3537,10 @@ begin
|
||||
FTxClient := nil;
|
||||
FTxRx := 0;
|
||||
FTxRunning := False;
|
||||
// Ушёл клиент — ушла и его зернистость: следующий может быть любым.
|
||||
FTxLeadKeep := 0;
|
||||
FTxLead := 0;
|
||||
FTxGapUs := 0;
|
||||
Drop := True;
|
||||
end;
|
||||
finally
|
||||
@@ -3473,18 +3600,224 @@ begin
|
||||
end;
|
||||
end;
|
||||
|
||||
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);
|
||||
|
||||
Reference in New Issue
Block a user