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
+15 -4
View File
@@ -87,7 +87,8 @@ type
Format: LongWord; // TTCISampleType
Codec: LongWord; // всегда 0 (сжатие не реализовано)
CRC: LongWord; // всегда 0
DataLength: LongWord; // количество вещественных отсчётов в data[]
DataLength: LongWord; // IQ — вещественных отсчётов, аудио — на канал
// (см. TCIFillHeader)
StreamType: LongWord; // TTCIStreamType
Channels: LongWord;
Reserv: array[0..7] of LongWord;
@@ -133,8 +134,14 @@ function TCIDefaultAudioSamples(RateHz: Integer): Integer;
{ Сколько сэмплов на канал влезает в блок с таким форматом и числом каналов. }
function TCIMaxBlockSamples(T: TTCISampleType; Channels: Integer): Integer;
{ Заголовок блока. Count — сэмплов НА КАНАЛ; в DataLength уходит, как велит
протокол, количество ВЕЩЕСТВЕННЫХ отсчётов, то есть Count × Channels. }
{ Заголовок блока. Count — сэмплов НА КАНАЛ. В DataLength уходит то, что для
этого типа потока велит документ, а он для IQ и для аудио говорит РАЗНОЕ:
• IQ (§3.4): «количество вещественных отсчётов… количество комплексных
вычисляется как Stream.length/Stream.channels» ⇒ Count × Channels;
• аудио (§4.3, AUDIO_STREAM_SAMPLES): «arg1 — количество сэмплов,
указываемое в поле Stream.length», по умолчанию 2048 при 48 кГц ⇒ Count,
то есть сэмплов НА КАНАЛ. Сходится и с потолком data[16384]:
2048 × 2 канала × float32 — ровно 16384 байта. }
procedure TCIFillHeader(out H: TTCIStreamHeader; Kind: TTCIStreamType;
Rx, RateHz: Integer; T: TTCISampleType; Count, Channels: Integer);
@@ -392,7 +399,11 @@ begin
H.Receiver := LongWord(Rx);
H.SampleRate := LongWord(RateHz);
H.Format := LongWord(Ord(T));
H.DataLength := LongWord(Count * Channels);
// ★Единицы length разные у IQ и у аудио — см. шапку объявления.
if Kind = tstIQ then
H.DataLength := LongWord(Count * Channels)
else
H.DataLength := LongWord(Count);
H.StreamType := LongWord(Ord(Kind));
H.Channels := LongWord(Channels);
end;