fix(tci): дефекты живого прогона — length аудио, маршруты тапов, MOX, рекордер, EOF сокета

Восемь дефектов, найденных прогоном настоящего TCI-клиента (три приёмника:
NFM, DIGU, FMRAW) и его отчётом.

1. UI доп. панорам не перерисовывался: rfSliceState рассылался, но ветки в
   MainForm.OnControllerState не было (частоту несёт отдельный rfSliceFreq).
2. Stream.length у аудио — сэмплы НА КАНАЛ (§4.3), у IQ — вещественные
   отсчёты (§3.4: комплексных = length/channels). Было ×каналы везде, у
   стерео получалось вдвое больше. Развилка в TCIFillHeader + разбор
   TX-аудио в HandleBinary.
3+4. Дыры в маршрутах аудио движка: demod-тап звался только для DMR/FMRAW
   (у DIGU не было RX_AUDIO), а пост-громкостный — только для нецифровых
   (у FMRAW не было LINEOUT). Плюс мьют слайса больше не убивает RX_AUDIO:
   движку сообщают SetAudioTapsActive.
5. Клиент, поставивший TRX, уходил — MOX оставался. FTrxOwner + StopTxOf;
   TCIMicRequested снимается и по окончании любой передачи.
6. Гонка снятия IQ-тапа: SetIQTap(nil) возвращался раньше, чем DSP-поток
   выходил из вызова. FIQTapLock (порядок FSliceLock → FIQTapLock).
7. Рекордер был кольцом «последние N секунд», а §4.3 говорит про
   МАКСИМАЛЬНОЕ время записи с удалением по истечении. Переделан в линейный
   буфер с окном по часам от START.
8. TCIServer.HandleClient считал recv = 0 таймаутом: ноль — это EOF, errno
   при нём не трогается и несёт EAGAIN от прошлого истёкшего TCI_POLL_MS.
   Обычный TCP-разрыв без close-кадра не освобождал слот до остановки
   сервера, и после нескольких аварийных отключений новые клиенты упирались
   в TCI_MAX_CLIENTS. Теперь R = 0 рвёт связь безусловно, errno спрашивается
   только при R < 0.

Попутно: MainForm.RecreateDSPEngine (смена sample rate до START) терял
внутренние колбэки контроллера — введён AttachEngineCallbacks.

Стенд test/tci заведён в репозиторий (run.sh, 126/126 зелёных, включая
сквозной прогон через живой WDSP), доп. проверки на оба пути отключения
клиента. doc/TCI.md приведена в соответствие: правило про recv = 0 в §1.1,
единицы Stream.length, линейный буфер рекордера.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-18 18:10:29 +03:00
co-authored by Claude Opus 5
parent de0830fd19
commit dc6f996e70
13 changed files with 2255 additions and 97 deletions
+95 -7
View File
@@ -142,6 +142,7 @@ type
// ── TX-аудио от клиента (§3.4) ──
FTxLock: TCriticalSection;
FTxClient: TTCIClient; // кто модулирует (nil — никто)
FTrxOwner: TTCIClient; // кто поставил трансивер в эфир (§4.2)
FTxInterp: TTCIInterpolator;
FTxInRate: Integer; // частота дискретизации подачи клиента
FTxRunning: Boolean; // маркеры TX_CHRONO идут
@@ -177,6 +178,7 @@ type
procedure SyncSetMode;
procedure SyncSetFilter;
procedure SyncSetTRX;
procedure SyncStopTRX;
procedure SyncSetTune;
procedure SyncSetDrive;
procedure SyncSetTuneDrive;
@@ -225,6 +227,8 @@ type
procedure CmdStream(Client: TTCIClient; const M: TTCIMessage);
procedure CmdRecorder(Client: TTCIClient; const M: TTCIMessage);
procedure ClearTxClient(C: TTCIClient); // клиент ушёл/перестал модулировать
procedure StopTxOf(C: TTCIClient); // ★снять эфир, начатый этим клиентом
procedure ForgetTxOwner; // передача кончилась не по TCI
// ── Помощники модели ──
function CanInvoke: Boolean;
@@ -1259,6 +1263,10 @@ begin
DropHolds(Client);
DropClientStreams(Client);
ClearTxClient(Client);
// ★И только теперь — сама передача: если в эфир нас поставил именно этот
// клиент, снимаем MOX. Оставить включённый передатчик за ушедшим клиентом
// нельзя ни при какой модуляции (§4.2).
StopTxOf(Client);
end;
{ ═══════════════════════════════════════════════════════════════════════════
@@ -1319,6 +1327,16 @@ begin
FController.SetMOX(FsBool);
end;
procedure TTCIAdapter.SyncStopTRX;
// Поток контроллера. Передачу, начатую ушедшим клиентом, снимаем безусловно:
// решение принято там, где известно, кто её начал, а здесь остаётся только
// проверить, что она вообще идёт (оператор мог отпустить PTT сам).
begin
if (FController = nil) or not FController.FTransmitting then Exit;
FController.TCIMicRequested := False;
FController.SetMOX(False);
end;
procedure TTCIAdapter.SyncTaps;
// Поток контроллера: движок и список тапов трогаем только отсюда.
begin
@@ -1764,6 +1782,11 @@ begin
try
if FromTCI then FTxClient := Client
else if FTxClient = Client then FTxClient := nil;
// Кто поставил трансивер в эфир: его уход обязан эфир и снять
// (см. StopTxOf). Источник модуляции тут ни при чём — несущую без
// хозяина оставлять нельзя в любом случае.
if B then FTrxOwner := Client
else if FTrxOwner = Client then FTrxOwner := nil;
finally FTxLock.Leave; end;
FLock.Enter;
try
@@ -2638,6 +2661,8 @@ begin
FStreamLock.Leave;
end;
ClearTxClient(nil);
// Страховка: клиент мог держать эфир и без своего аудио (TRX без 'tci').
StopTxOf(nil);
end;
function TTCIAdapter.EffIQRate(C: TTCIClient): Integer;
@@ -2890,9 +2915,55 @@ begin
if not Drop then Exit;
// Пишем прямо, без Invoke: это один Boolean, который SetMOX только читает, и
// зовут нас откуда угодно — в том числе из деструктора адаптера, где ждать
// поток контроллера уже некому. Передачу при этом НЕ трогаем: оператор мог
// нажать PTT сам, и обрывать его эфир из-за ухода клиента нельзя.
if FController <> nil then FController.TCIMicRequested := False;
// поток контроллера уже некому.
if FController = nil then Exit;
FController.TCIMicRequested := False;
// ★А вот саму передачу, если она ИДЁТ ИМЕННО ИЗ ЭТОГО ПОТОКА, оставлять
// нельзя: источник модуляции только что исчез, и в эфире осталась бы
// несущая с тишиной (или с последним, что застряло в ринге), которую никто
// не снимет. Передачу с микрофона оператора это не трогает — там
// TCIMicActive не поднят.
if FController.TCIMicActive then StopTxOf(nil);
end;
procedure TTCIAdapter.StopTxOf(C: TTCIClient);
// ★Безопасность (§4.2). Клиент, поставивший трансивер в эфир, ушёл — MOX
// обязан упасть. C = nil — снять эфир, кем бы из клиентов он ни был начат
// (остановка сервера, потеря источника модуляции).
var Mine: Boolean;
begin
Mine := False;
FTxLock.Enter;
try
if (FTrxOwner <> nil) and ((C = nil) or (FTrxOwner = C)) then
begin
FTrxOwner := nil;
Mine := True;
end;
finally
FTxLock.Leave;
end;
if not Mine or (FController = nil) then Exit;
if CanInvoke then
FController.Invoke(SyncStopTRX)
else
// CanInvoke=False бывает ровно в одном случае — сервер останавливается, а
// останавливает его поток контроллера: он же сейчас и исполняет нас
// (TTCIServer.Stop, шаг 5 → Disconnected). Ждать самого себя нельзя, и не
// нужно — вызов и так на правильном потоке.
SyncStopTRX;
end;
procedure TTCIAdapter.ForgetTxOwner;
// Передача кончилась сама (оператор, CAT, PTT, тайм-аут) — забываем, кто её
// начал. Иначе уход того клиента через час снимал бы уже чужой эфир.
begin
FTxLock.Enter;
try
FTrxOwner := nil;
finally
FTxLock.Leave;
end;
end;
procedure TTCIAdapter.PushTxChrono;
@@ -2966,7 +3037,7 @@ var
H: TTCIStreamHeader;
ST: TTCISampleType;
P: PByte;
N, i, k, Chans, Rate, Factor, Bytes: Integer;
N, i, k, Chans, Rate, Factor, Bytes, Want: Integer;
Mine: Boolean;
begin
if (Data = nil) or (Len <= SizeOf(H)) then Exit;
@@ -3001,10 +3072,16 @@ begin
try
N := TCIUnpackSamples(P, Bytes, ST, FTxRaw);
if N <= 0 then Exit;
// Заголовок обещает length вещественных отсчётов — верим меньшему из двух:
// В аудиопотоке length — это сэмплы НА КАНАЛ (см. TCIFillHeader), значит
// вещественных отсчётов в блоке length × channels. Верим меньшему из двух:
// клиент вправе прислать короткий хвост, но не длиннее уместившегося.
if (H.DataLength > 0) and (Integer(H.DataLength) < N) then
N := Integer(H.DataLength);
// Заведомо чужое число (мусор в заголовке) просто игнорируем — длину нам
// и так ограничил размер кадра.
if (H.DataLength > 0) and (H.DataLength <= LongWord(N)) then
begin
Want := Integer(H.DataLength) * Chans;
if Want < N then N := Want;
end;
// В тракт идёт моно: TXA у нас один, а стерео от клиента — это его
// собственный формат вывода, а не два независимых сигнала.
@@ -3063,6 +3140,17 @@ begin
// а поток остался бы висеть на несуществующем приёмнике.
if Field in [rfDevice, rfConnected] then DropDeadRxStreams;
// Передача кончилась — чья бы она ни была. Забываем хозяина эфира (иначе его
// уход когда-нибудь потом снял бы уже чужую передачу) и снимаем просьбу
// «модулируй из потока TCI»: она относилась ровно к той передаче, которую
// клиент и начал, а следующий PTT оператора обязан идти с его микрофона.
if (Field = rfTransmitting) and (FController <> nil)
and not FController.FTransmitting then
begin
ForgetTxOwner;
FController.TCIMicRequested := False;
end;
MapChanged := False;
if Field in [rfSliceFreq, rfSliceState, rfDevice, rfPanFreq, rfSampleRate,
rfBand, rfXvtr, rfCenterFreq] then