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
+72 -14
View File
@@ -524,6 +524,15 @@ type
FLastError: string;
FBeaconDec: TBeaconDecoder; // не владеет; тап маяка в PushIQItemToDSP
FIQTap: TIQTapEvent; // тап сырого IQ наружу (TCI), не владеет
// ★Лок тапа. Снять тап обязан тот, кто вот-вот освободит его владельца
// (адаптер TCI), а зовёт тап DSP-поток. Без лока SetIQTap(nil) возвращался
// раньше, чем DSP-поток выходил из вызова, — и следующая же строка
// деструктора адаптера убивала объект прямо под ним.
FIQTapLock: TCriticalSection;
FIQTapOn: Boolean; // дешёвая проверка «тап вообще есть»
// Кто-то (TCI/web) слушает аудио помимо динамика: тогда молчащий (mute)
// слайс всё равно надо считать — RX_AUDIO снимается ДО громкости и мьюта.
FAudioTapsActive: Boolean;
function ModeToWDSP(Mode: Integer): Integer;
function ModeToWDSPTX(Mode: Integer): Integer;
@@ -638,6 +647,10 @@ type
// Тап сырого RX-IQ наружу (потоки IQ по TCI). nil — снять. Ставит и
// снимает поток контроллера; вызывается тап из DSP-потока.
procedure SetIQTap(T: TIQTapEvent);
// Есть ли слушатели аудио-тапов (TCI/web). Влияет только на молчащие
// слайсы: с ними их всё равно надо считать, иначе клиент TCI остаётся без
// RX_AUDIO ровно потому, что оператор убрал звук у себя в комнате.
procedure SetAudioTapsActive(V: Boolean);
// --- Панадаптеры на аппаратных DDC (этап 3.2) ---
// Создаёт доп. пан PanId (1..MAX_PANS-1): аккумулятор + analyzer
@@ -1506,6 +1519,11 @@ begin
FBcnPixCount := 0;
SetLength(FBcnDispBuf, DISPLAY_BLOCK_SIZE * 2);
// Тап IQ наружу (TCI)
FIQTapLock := TCriticalSection.Create;
FIQTapOn := False;
FAudioTapsActive := False;
// Мультислайсы
FSliceLock := TCriticalSection.Create;
FillChar(FSlices, SizeOf(FSlices), 0);
@@ -1606,6 +1624,7 @@ begin
FSliceLock.Free;
FMainLock.Free;
FBcnLock.Free;
FIQTapLock.Free;
inherited;
end;
@@ -2424,8 +2443,21 @@ begin
end;
procedure TWDSPEngine.SetIQTap(T: TIQTapEvent);
// Поток контроллера. Под локом: снятие обязано ДОЖДАТЬСЯ выхода DSP-потока из
// вызова — сразу после нас владельца тапа освобождают.
begin
FIQTap := T;
FIQTapLock.Enter;
try
FIQTap := T;
FIQTapOn := Assigned(T);
finally
FIQTapLock.Leave;
end;
end;
procedure TWDSPEngine.SetAudioTapsActive(V: Boolean);
begin
FAudioTapsActive := V;
end;
procedure TWDSPEngine.PushDDCPacket(const Buf: array of Byte;
@@ -2543,9 +2575,19 @@ begin
// Тап IQ пана — как у главного тракта, один вызов на блок. Он идёт
// ПОД FSliceLock (весь разбор пакета пана здесь), поэтому обработчик
// обязан только скопировать данные и вернуться: любое ожидание тут
// остановит DSP-поток вместе со всеми панами.
if Assigned(FIQTap) then
FIQTap(PanId, @Pan^.AccI[0], @Pan^.AccQ[0], Pan^.BufSize, Pan^.Rate);
// остановит DSP-поток вместе со всеми панами. Порядок локов
// FSliceLock → FIQTapLock; обратного нет нигде (SetIQTap не берёт
// FSliceLock), так что клина не выходит.
if FIQTapOn then
begin
FIQTapLock.Enter;
try
if Assigned(FIQTap) then
FIQTap(PanId, @Pan^.AccI[0], @Pan^.AccQ[0], Pan^.BufSize, Pan^.Rate);
finally
FIQTapLock.Leave;
end;
end;
if Assigned(FOnSliceAudio) or Assigned(FOnSliceDemodAudio) then
try
ProcessSlicesFor(PanId, Pan^.AccI, Pan^.AccQ, Pan^.BufSize);
@@ -2666,8 +2708,17 @@ begin
// Тап IQ наружу — по накопленному блоку и ДО обработки: fexchange0
// забирает аккумулятор как вход и не портит его, но полагаться на это
// незачем, а один вызов на блок вместо вызова на сэмпл экономит всё.
if Assigned(FIQTap) then
FIQTap(0, @FRXAccI[0], @FRXAccQ[0], FBufSize, FSampleRate);
// Под FIQTapLock: см. SetIQTap.
if FIQTapOn then
begin
FIQTapLock.Enter;
try
if Assigned(FIQTap) then
FIQTap(0, @FRXAccI[0], @FRXAccQ[0], FBufSize, FSampleRate);
finally
FIQTapLock.Leave;
end;
end;
ProcessRXBlock;
end;
end;
@@ -2923,7 +2974,10 @@ begin
if not (FSlices[s].Active and FSlices[s].Opened) then Continue;
if FSlices[s].PanId <> PanId then Continue;
// Muting a DMR speaker must not make its decoder lose synchronization.
if FSlices[s].Muted and not (FSlices[s].Mode in [MODE_DMR, MODE_FMRAW]) then Continue;
// ★То же и для тапов: RX_AUDIO по TCI снимается ДО громкости и мьюта, и
// молчащий в комнате слайс обязан продолжать отдавать звук клиенту.
if FSlices[s].Muted and not (FSlices[s].Mode in [MODE_DMR, MODE_FMRAW])
and not FAudioTapsActive then Continue;
// Свежая копия входного IQ — fexchange0 портит вход in-place.
for i := 0 to N - 1 do
begin
@@ -2939,13 +2993,17 @@ begin
FSliceOutL[k] := FSliceOut[k * 2];
FSliceOutR[k] := FSliceOut[k * 2 + 1];
end;
if FSlices[s].Mode in [MODE_DMR, MODE_FMRAW] then
begin
if Assigned(FOnSliceDemodAudio) then
FOnSliceDemodAudio(FSlices[s].Id, FSliceOutL, FSliceOutR,
FAudioBufSize);
Continue; // decoder-grade signal uses the independent pre-volume route
end;
// Отвод ДО громкости и мьюта — у КАЖДОГО слайса, а не только у цифровых.
// Это выход демодулятора: им кормятся декодеры (DMR/FM RAW) и он же уходит
// в RX_AUDIO по TCI. Пока вызов стоял только в цифровой ветке, клиент на
// обычном слайсе (DIGU у скиммера) не получал аудиопоток вовсе.
if Assigned(FOnSliceDemodAudio) then
FOnSliceDemodAudio(FSlices[s].Id, FSliceOutL, FSliceOutR, FAudioBufSize);
// Цифровым дальше нельзя: их звук идёт мимо громкости и мьюта (см. выше).
if FSlices[s].Mode in [MODE_DMR, MODE_FMRAW] then Continue;
// Мьют — это тишина в динамике и в линейном выходе; сюда мы дошли только
// ради тапа выше.
if FSlices[s].Muted then Continue;
if Assigned(FOnSliceAudio) then
begin
V := FSlices[s].Volume;