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
+55 -29
View File
@@ -165,23 +165,37 @@ type
property SrcRate: Integer read FSrcRate;
end;
{ Запись линейного выхода (LINE_OUT_RECORDER_*, §4.3). Кольцо на MaxSec
секунд 48 кГц стерео в int16: во-первых, ровно то, что уйдёт в WAV, а
во-вторых, float32 на предельных 300 с — это 115 МБ вместо 57. }
{ Запись линейного выхода (LINE_OUT_RECORDER_*, §4.3). Буфер на MaxSec секунд
48 кГц стерео в int16: во-первых, ровно то, что уйдёт в WAV, а во-вторых,
float32 на предельных 300 с — это 115 МБ вместо 57.
★Не кольцо. Документ говорит про arg2 «максимальное время записи», и дальше
прямо: «по истечении времени запись УДАЛЯЕТСЯ, чтобы сохранить запись в файл
необходимо в заданном временном интервале выслать LINE_OUT_RECORDER_SAVE».
То есть START открывает окно длиной MaxSec, и всё, что не сохранили внутри
него, пропадает. Кольцо «последние N секунд» вело себя иначе в обе стороны:
начало активной записи затирало само себя, а SAVE через час после START
отдавал файл, которого у ExpertSDR3 давно бы не было.
Срок считаем по ЧАСАМ, а не по накопленным сэмплам: линейный выход молчит
(мьют, пауза приёмника), а время записи всё равно идёт. }
TTCIRecorder = class
private
FLock: TCriticalSection;
FRing: array of SmallInt; // чередование L/R
FBuf: array of SmallInt; // чередование L/R
FCap: Integer; // ёмкость в сэмплах на канал
FCount: Integer; // накоплено сэмплов на канал
FHead: Integer; // позиция записи (в сэмплах на канал)
FRate: Integer;
FRx: Integer;
FExpired: Boolean; // окно записи закрылось, данные удалены
FEndsAt: QWord; // GetTickCount64 конца окна
procedure DropData; // под FLock
public
constructor Create(ARx, ARateHz, AMaxSec: Integer);
destructor Destroy; override;
procedure Feed(const L, R: array of Single; N: Integer);
{ Забрать накопленное В ПОРЯДКЕ ВРЕМЕНИ и обнулить кольцо. }
{ Забрать накопленное и завершить запись. nil — либо не записано ничего,
либо окно уже истекло (по документу это одно и то же: записи нет). }
function Take: TTCIPcm;
property Rx: Integer read FRx;
property Rate: Integer read FRate;
@@ -671,9 +685,12 @@ begin
if AMaxSec < 1 then AMaxSec := 1;
if AMaxSec > TCI_RECORD_MAX_SEC then AMaxSec := TCI_RECORD_MAX_SEC;
FCap := ARateHz * AMaxSec;
SetLength(FRing, FCap * 2);
FCount := 0;
FHead := 0;
SetLength(FBuf, FCap * 2);
FCount := 0;
FExpired := False;
// Окно открывается прямо здесь: клиент отсчитывает его от своей команды
// START, и ждать первого блока аудио, чтобы завести часы, нельзя.
FEndsAt := GetTickCount64 + QWord(AMaxSec) * 1000;
end;
destructor TTCIRecorder.Destroy;
@@ -682,10 +699,17 @@ begin
inherited;
end;
procedure TTCIRecorder.DropData;
// Под FLock. Память отдаём сразу: истёкшая запись на 300 с держала бы 57 МБ
// до тех пор, пока клиент не вспомнит про BREAK.
begin
FCount := 0;
FExpired := True;
SetLength(FBuf, 0);
end;
procedure TTCIRecorder.Feed(const L, R: array of Single; N: Integer);
// DSP-поток. Кольцо: по исчерпании ёмкости затирается самое старое — запись
// «последние N секунд» именно так и работает (§4.3: по истечении времени
// накопленное пропадает, если клиент не сохранил).
// DSP-поток. Пишем линейно до конца окна; истекло — данных больше нет (§4.3).
var
i: Integer;
A, B: Single;
@@ -695,17 +719,24 @@ begin
if N > Length(R) then N := Length(R);
FLock.Enter;
try
if FExpired then Exit;
// Часы и ёмкость закрывают окно вместе: ёмкость — это те же MaxSec звука,
// но при паузе в аудио доживёт до конца только счёт по часам.
if GetTickCount64 >= FEndsAt then
begin
DropData;
Exit;
end;
if N > FCap - FCount then N := FCap - FCount;
for i := 0 to N - 1 do
begin
A := L[i]; B := R[i];
if A > 1.0 then A := 1.0; if A < -1.0 then A := -1.0;
if B > 1.0 then B := 1.0; if B < -1.0 then B := -1.0;
FRing[FHead * 2] := Round(A * 32767);
FRing[FHead * 2 + 1] := Round(B * 32767);
Inc(FHead);
if FHead >= FCap then FHead := 0;
if FCount < FCap then Inc(FCount);
FBuf[(FCount + i) * 2] := Round(A * 32767);
FBuf[(FCount + i) * 2 + 1] := Round(B * 32767);
end;
Inc(FCount, N);
finally
FLock.Leave;
end;
@@ -713,23 +744,18 @@ end;
function TTCIRecorder.Take: TTCIPcm;
var
Start, i, n: Integer;
i: Integer;
begin
Result := nil;
FLock.Enter;
try
if FCount <= 0 then Exit;
// Проверка срока и здесь: аудио могло не идти вовсе (мьют, стоящий
// приёмник), и тогда Feed часы не смотрел ни разу.
if (not FExpired) and (GetTickCount64 >= FEndsAt) then DropData;
if FExpired or (FCount <= 0) then Exit;
SetLength(Result, FCount * 2);
Start := FHead - FCount;
if Start < 0 then Inc(Start, FCap);
for i := 0 to FCount - 1 do
begin
n := (Start + i) mod FCap;
Result[i * 2] := FRing[n * 2];
Result[i * 2 + 1] := FRing[n * 2 + 1];
end;
FCount := 0;
FHead := 0;
for i := 0 to FCount * 2 - 1 do Result[i] := FBuf[i];
DropData; // SAVE завершает запись (§4.3)
finally
FLock.Leave;
end;