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
+47
View File
@@ -0,0 +1,47 @@
# Стенд TCI
Прогон:
```sh
test/tci/run.sh
```
Собирает `tcitest.pas` и запускает его; код возврата ненулевой, если хоть одна
проверка провалена. Внешних библиотек стенд не требует — WebSocket-клиент в нём
написан на сыром сокете. `libwdsp` нужна только последней части (сквозной
прогон через движок); без неё эта часть сообщает о пропуске, а остальные идут
как обычно.
Что проверяется — в шапке `tcitest.pas`; чего стенд **не** покрывает —
в `doc/TCI.md`, §5.
Две грабли, из-за которых стенд собирается именно так (обе стоили по часу):
* **`-Mobjfpc` обязателен.** С `-Mdelphi` в командной строке выключаются
вложенные комментарии, и `{$MODE Delphi}` внутри шапки `WebUtils.pas`
закрывает комментарий раньше времени — компиляция падает на
«illegal character».
* **Каталог `.ppu` — свой, с именем не `lib`, и путь абсолютный.**
Относительное `lib/<cpu>-<os>` компилятор ищет и относительно `-Fu`, то есть
находит units GUI-сборки в корне проекта, а там `PlatformUtils` собран с LCL:
стенд падает на линковке с `undefined reference to TC_$FORMS_$$_SCREEN`.
По той же причине сборка идёт с `-dHEADLESS`, как у демона.
Каталоги `units/` и `bin/` — выход сборки, в git не попадают.
## Проверка на живом эфире без передачи
```sh
python3 test/tci/live_rx_test.py --host 127.0.0.1 --port 40001
```
Наглядный UI-прогон с автоматическим возвратом к исходным значениям:
```sh
python3 -u test/tci/live_rx_test.py --visible --commands-only --dwell 1.5
```
Стенд проверяет receive-only команды, IQ, аудио демодулятора и
line-out для всех обнаруженных приёмников. Он никогда не посылает
`START`, `STOP`, установку `TRX`/`TUNE`, TX-аудио и CW-текст. Если радио
уже на передаче, прогон аварийно прекращается.