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
+71 -17
View File
@@ -249,7 +249,9 @@ type
FAudioTaps: array of TRadioAudioTapEvent;
FAudioTapLock: TCriticalSection;
FAudioTapCount: LongInt; // быстрый гейт: без тапов не берём и лок
FIQTap: TIQTapEvent; // тап IQ (TCI): помним ради пересоздания движка
procedure Changed(Field: TRadioField);
procedure PushAudioTapsActive;
procedure FireAudioTap(Kind: TRadioAudioKind; PanId, SliceId: Integer;
const Left, Right: array of Single; Count: Integer);
// --- Мультислайсы ---
@@ -627,6 +629,11 @@ type
// FreeEngines закрывает и освобождает их; вызывается из Destroy.
procedure CreateEngines(ASampleRate: Integer);
procedure FreeEngines;
// Внутренние колбэки движка (аудио слайсов, отвод демодулятора, калибровка
// дисплея, тапы TCI). Зовёт CreateEngines — и ОБЯЗАН звать всякий, кто
// пересоздал FDSPEngine: без этого новый движок остаётся без отвода
// демодулятора (декодеры CW/DMR/FM RAW и RX_AUDIO по TCI) и без тапа IQ.
procedure AttachEngineCallbacks;
// ---- Колбэки движков (вызываются из рабочих потоков сети/DSP) ----
// Чистая обработка данных без UI; навешиваются на FNetwork/FDSPEngine
@@ -1388,6 +1395,31 @@ begin
FillChar(FDevMAC, SizeOf(FDevMAC), 0);
end;
procedure TRadioController.AttachEngineCallbacks;
// Всё, что движок должен знать о САМОМ контроллере. Отдельным методом, потому
// что движок пересоздают на ходу (смена sample rate до старта): раньше эти
// колбэки навешивались только в CreateEngines, и после пересоздания движок
// оставался без отвода демодулятора — молча пропадали декодеры CW/DMR/FM RAW,
// аудио слайсов, калибровка дисплея и оба тапа TCI.
begin
if not Assigned(FDSPEngine) then Exit;
// Роутинг аудио слайсов на их звуковухи — внутренний (не зависит от
// фронтенда, в отличие от OnAudio/OnSpectrum).
FDSPEngine.OnSliceAudio := OnSliceAudioReady;
FDSPEngine.OnSliceDemodAudio := OnSliceDemodAudioReady;
FDSPEngine.OnDemodAudio := OnDemodAudioReady;
// Калибровка уровня спектра/водопада на диапазон/трансвертер — движок
// спрашивает её раз в кадр (сам он про диапазоны не знает).
FDSPEngine.OnDispCal := DispCalFor;
FDSPEngine.SetDisplayResLimit(FDisplayPixels);
// Тапы наружу (TCI): их владелец живёт дольше движка.
FDSPEngine.SetIQTap(FIQTap);
PushAudioTapsActive;
// Декодер маяка QO-100 создаётся позже самого движка (в CreateEngines ниже),
// зато при ПЕРЕсоздании движка он уже есть и его надо вернуть на место.
if Assigned(FBeaconDec) then FDSPEngine.SetBeaconDecoder(FBeaconDec);
end;
procedure TRadioController.CreateEngines(ASampleRate: Integer);
// Создаёт движки в полях контроллера. Колбэки (OnAudio/OnSpectrum/OnHPStatus…)
// навешивает вызывающий после возврата — они указывают на UI/демон.
@@ -1397,15 +1429,7 @@ begin
// BufSize=512: латентность 512/48000 ≈ 10.7мс — допустимо для SDR; меньший
// буфер вдвое сокращает построение downsampler в OpenChannel RXA.
FDSPEngine := TWDSPEngine.Create(ASampleRate, 48000, 512);
// Роутинг аудио слайсов на их звуковухи — внутренний, вешаем сразу (не
// зависит от фронтенда, в отличие от OnAudio/OnSpectrum).
FDSPEngine.OnSliceAudio := OnSliceAudioReady;
FDSPEngine.OnSliceDemodAudio := OnSliceDemodAudioReady;
FDSPEngine.OnDemodAudio := OnDemodAudioReady;
// Калибровка уровня спектра/водопада на диапазон/трансвертер — движок
// спрашивает её раз в кадр (сам он про диапазоны не знает).
FDSPEngine.OnDispCal := DispCalFor;
FDSPEngine.SetDisplayResLimit(FDisplayPixels);
AttachEngineCallbacks;
// QO-100 beacon lock: эталон частоты = декодер (см. ServiceBeaconLock).
FBeaconRefHz := 10489750000.0; // центральный BPSK-маяк NB-транспондера (downlink)
@@ -1545,6 +1569,7 @@ begin
finally
FAudioTapLock.Leave;
end;
PushAudioTapsActive;
end;
procedure TRadioController.RemoveAudioTap(T: TRadioAudioTapEvent);
@@ -1560,11 +1585,20 @@ begin
for j := i to High(FAudioTaps) - 1 do FAudioTaps[j] := FAudioTaps[j + 1];
SetLength(FAudioTaps, Length(FAudioTaps) - 1);
FAudioTapCount := Length(FAudioTaps);
Exit;
Break;
end;
finally
FAudioTapLock.Leave;
end;
PushAudioTapsActive;
end;
procedure TRadioController.PushAudioTapsActive;
// Движку интересен только факт «слушатели есть»: от него зависит, считать ли
// молчащие (mute) слайсы — их звук всё равно уходит в RX_AUDIO по TCI.
begin
if Assigned(FDSPEngine) then
FDSPEngine.SetAudioTapsActive(FAudioTapCount > 0);
end;
procedure TRadioController.FireAudioTap(Kind: TRadioAudioKind;
@@ -1585,7 +1619,11 @@ begin
end;
procedure TRadioController.SetIQTap(T: TIQTapEvent);
// Тап помним у себя: движок пересоздают (RecreateDSPEngine при смене sample
// rate до старта), и новый про тап не знал бы — поток IQ по TCI молча умирал
// на всю сессию. Восстанавливает его AttachEngineCallbacks.
begin
FIQTap := T;
if Assigned(FDSPEngine) then FDSPEngine.SetIQTap(T);
end;
@@ -1984,6 +2022,10 @@ begin
else if FMode = MODE_FMRAW then
begin
// FM RAW decoder audio deliberately bypasses speaker volume and mute.
// Значит и линейный выход у него — вот этот сигнал: OnAudioReady для
// FM RAW выходит в самом начале, и без этой строки поток LINEOUT по TCI
// на FM RAW не шёл вовсе.
FireAudioTap(rakLineOut, 0, 0, Left, Right, Count);
if FLocalAudio and Assigned(FAudioOut) then
FAudioOut.Write(Left, Right, Count);
end
@@ -2082,9 +2124,15 @@ begin
FireAudioTap(rakDemod, FSlices[idx].PanId, SliceId, Left, Right, Count);
if (FSlices[idx].Mode = MODE_DMR) and Assigned(FSlices[idx].DMR) then
FSlices[idx].DMR.FeedAudio(Left, Right, Count);
if (FSlices[idx].Mode = MODE_FMRAW) and FLocalAudio and
Assigned(FSlices[idx].Audio) then
FSlices[idx].Audio.Write(Left, Right, Count);
if FSlices[idx].Mode = MODE_FMRAW then
begin
// У FM RAW звук идёт мимо громкости и мьюта, поэтому линейный выход слайса
// — это он же (OnSliceAudioReady для FM RAW не вызывается вовсе). Без этой
// строки поток LINEOUT по TCI на FM RAW-слайсе молчал.
FireAudioTap(rakLineOut, FSlices[idx].PanId, SliceId, Left, Right, Count);
if FLocalAudio and Assigned(FSlices[idx].Audio) then
FSlices[idx].Audio.Write(Left, Right, Count);
end;
end;
// DMR worker thread: decoded 8 kHz mono -> this slice's 48 kHz local output.
@@ -2095,11 +2143,13 @@ var
idx, i, Phase, OutPos, N: Integer;
A, B, V, Gain: Single;
begin
if not FLocalAudio or (Count <= 0) then Exit;
if (Count <= 0) or not (FLocalAudio or (FAudioTapCount > 0)) then Exit;
idx := FindSliceIndex(SliceId);
if (idx < 0) or (FSlices[idx].Mode <> MODE_DMR) or
FSlices[idx].Muted or not Assigned(FSlices[idx].Audio) then Exit;
if (idx < 0) or (FSlices[idx].Mode <> MODE_DMR) or FSlices[idx].Muted then Exit;
if SliceMutedByTx(idx) then Exit;
// Звуковухи может не быть вовсе (демон), но линейный выход слайса слушает
// ещё и TCI — тогда декодированный звук всё равно надо построить.
if not (Assigned(FSlices[idx].Audio) or (FAudioTapCount > 0)) then Exit;
N := Min(Count, Length(PCM));
SetLength(Left, N * 6);
SetLength(Right, N * 6);
@@ -2117,7 +2167,11 @@ begin
Inc(OutPos);
end;
end;
FSlices[idx].Audio.Write(Left, Right, OutPos);
// Линейный выход DMR-слайса — декодированный звук, как и у главного тракта
// (см. OnDMRAudioReady): 4FSK до декодера в LINEOUT слать нечего.
FireAudioTap(rakLineOut, FSlices[idx].PanId, SliceId, Left, Right, OutPos);
if FLocalAudio and Assigned(FSlices[idx].Audio) then
FSlices[idx].Audio.Write(Left, Right, OutPos);
end;
procedure TRadioController.RefreshSliceShifts;