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
+49 -22
View File
@@ -23,7 +23,7 @@ TCI-клиенты ──WebSocket──► TTCIServer ──► TTCIAdapter ─
| `TCIProtocol.pas` (~380 строк) | Чистый слой протокола: разбор `имя:арг1,арг2;`, сборка строк, экранирование `^ ~ *`, словарь видов связи, пересчёт громкости/порога в дБ. Зависит только от RTL + `RadioModes`. |
| `TCIServer.pas` (~1250 строк) | WebSocket-сервер: accept-поток, поток на клиента, HTTP-Upgrade, разбор фреймов, рассылка, тик 20 мс. Сокеты и фреймы переиспользованы из веб-подсистемы (`WebUtils`, `WsClient`). |
| `TCIAdapter.pas` (~2600 строк) | Мост к `TRadioController`: реализация команд, пачка инициализации, уведомления об изменениях состояния, измерители, захват параметров (§3.5), подключение потоков к тапам аудио/IQ. |
| `TCIStreams.pas` (~600 строк) | Бинарные потоки (§3.4): дециматор/интерполятор, нарезка блоков с заголовком, кольцо записи линейного выхода и писатель WAV. Зависит только от RTL + `TCIProtocol`/`TCIServer`. |
| `TCIStreams.pas` (~600 строк) | Бинарные потоки (§3.4): дециматор/интерполятор, нарезка блоков с заголовком, буфер записи линейного выхода и писатель WAV. Зависит только от RTL + `TCIProtocol`/`TCIServer`. |
Принципы те же, что у CAT (см. `doc/CAT_STATUS.md`):
@@ -98,13 +98,23 @@ TCI-клиенты ──WebSocket──► TTCIServer ──► TTCIAdapter ─
уложиться в `TCI_HANDSHAKE_MS` (5 с), иначе закрывается по таймауту сокета;
восемь слотов `TCI_MAX_CLIENTS` считаются только среди поднявшихся. Раньше
восемь молчащих TCP-соединений навсегда закрывали дверь настоящим клиентам.
5. **Браузерные клиенты не пускаются.** Handshake с заголовком `Origin`
5. **`recv` = 0 — это конец связи, а не таймаут.** Приёмный цикл клиента ждёт
не дольше `TCI_POLL_MS`, поэтому «ошибка» `EAGAIN` для него — норма, но
спрашивать `errno` разрешено **только при отрицательном** результате: ноль
означает EOF, `errno` при нём не трогается и вполне может нести `EAGAIN` от
прошлого истёкшего опроса. Пока проверка была общей (`R <= 0`), обычный
TCP-разрыв без close-кадра — упавший клиент, выдернутый кабель — выглядел
как таймаут: поток крутился впустую, слот не освобождался до остановки
сервера, и после нескольких аварийных отключений новые клиенты упирались в
`TCI_MAX_CLIENTS`. Места в буфере при этом всегда хватает (кадр крупнее
буфера рвётся раньше), так что иного смысла у нуля нет.
6. **Браузерные клиенты не пускаются.** Handshake с заголовком `Origin`
получает 403. Origin шлёт только браузер, а авторизации в TCI нет: без этой
проверки любая открытая вкладка дотягивалась бы по `ws://127.0.0.1:40001`
до `TRX`, `TUNE` и `VFO`. Своей web-странице нужен явный прокси, а не дыра
по умолчанию.
6. **Блоки потоков нарезает и раскладывает DSP-поток.** Тап зовётся прямо из
7. **Блоки потоков нарезает и раскладывает DSP-поток.** Тап зовётся прямо из
потока WDSP, поэтому в `TCIStreams` нет ни одного ожидания: блок уходит в
кольцо клиента (микросекунды под его локом), а в сокет его пишет, как и
команды, поток самого клиента. Порядок локов везде один: `FSliceLock`
@@ -420,13 +430,17 @@ TCI-клиент отобрал бы звук у динамика и у брау
в заголовке мы не будем ни в каком случае. Смена rate устройства или DDC пана
на ходу пересобирает прореживание (`SetSourceRate`).
**Размер блока.** `AUDIO_STREAM_SAMPLES` — это сэмплы **на канал**, а в
`Stream.length` уходит, как велит §3.4, количество вещественных отсчётов
(`samples × channels`). Сходится и с умолчаниями ExpertSDR3 (2048 при 48 кГц
даёт ~43 мс), и с потолком `data[16384]`: 2048 × 2 канала × float32 = ровно
16384 байта. Смена любого параметра потока на ходу **перезапускает** уже идущие
потоки этого клиента: блок с новой частотой посреди старого потока клиенты
разбирают как мусор.
**Размер блока.** `AUDIO_STREAM_SAMPLES` — это сэмплы **на канал**, и у
аудиопотоков ровно это число уходит в `Stream.length`: §4.3 говорит про arg1
«количество сэмплов, указываемое в поле Stream.length», а умолчание 2048 при
48 кГц сходится с потолком `data[16384]` только так — 2048 × 2 канала ×
float32 = ровно 16384 байта, то есть `length` считает сэмплы, а не отсчёты.
У **IQ** единица другая: §3.4 определяет число комплексных отсчётов как
`length / channels`, поэтому там в `length` уходит `samples × channels`.
Развилку держит `TCIFillHeader` (по типу потока), обратную — разбор
`TX_AUDIO_STREAM` в `HandleBinary`. Смена любого параметра потока на ходу
**перезапускает** уже идущие потоки этого клиента: блок с новой частотой
посреди старого потока клиенты разбирают как мусор.
**Передача (§3.4, §4.2).** `TRX:0,true,tci` берёт модуляцию из аудиопотока
клиента — но только если у него запущен `AUDIO_START` (буквально по документу:
@@ -440,9 +454,19 @@ web-клиента: явная просьба сильнее умолчания
отбрасывается молча: отвечать ошибкой на каждый чужой блок значит захлебнуться.
**Запись линейного выхода.** Рекордер один на приёмник (а не на клиента):
пишет он то, что слышно в аппарате. Кольцо на запрошенное время (потолок 300 с)
пишет он то, что слышно в аппарате. Буфер на запрошенное время (потолок 300 с)
в int16 48 кГц стерео — это ровно то, что уйдёт в WAV, и вдвое меньше памяти,
чем float32. `SAVE` завершает запись и отдаёт кольцо отдельному потоку-писателю:
чем float32. ★Буфер **линейный, не кольцевой**: §4.3 называет arg2
максимальным временем записи и прямо говорит, что по его истечении запись
удаляется, а чтобы сохранить файл, `SAVE` нужно прислать внутри интервала.
Поэтому `START` открывает окно длиной arg2, пишем от его начала, а по концу
окна данные выбрасываются вместе с памятью (`DropData` из `Feed` и из `Take`).
Срок считается **по часам** от `START`, а не по накопленным сэмплам: линейный
выход может молчать (мьют, стоящий приёмник), а время записи всё равно идёт.
Кольцо «последние N секунд» вело себя иначе в обе стороны — начало записи
затирало само себя, а `SAVE` через час после `START` отдавал файл, которого у
ExpertSDR3 давно бы не было.
`SAVE` завершает запись и отдаёт буфер отдельному потоку-писателю:
файл бывает в десятки мегабайт, а команда пришла в потоке клиента, который в
это время не читает свой сокет. MP3 не поддержан — кодера в проекте нет,
и на `.mp3` уходит честный `tci_error`.
@@ -563,8 +587,10 @@ web-клиента: явная просьба сильнее умолчания
HTTP-заголовков.
- **Транспорт:** 101 на заголовки с табуляцией и без пробела после двоеточия;
отказ 403 при `Origin`; ровно восемь поднявшихся клиентов и 503 девятому;
освобождение слотов после отключения; молчащий сокет уходит по таймауту
handshake; ответный close-кадр; `Stop` с живым клиентом.
освобождение слотов после отключения**обоими** путями, и штатным
close-кадром, и обычным TCP-разрывом без него (см. правило 5 в §1.1);
молчащий сокет уходит по таймауту handshake; ответный close-кадр;
`Stop` с живым клиентом.
- **Адаптер:** `vfo:0,0,abc`, `vfo:0,0,-1`, `dds:0,broken`,
`rx_filter_band:0,x,y` не меняют ничего, а годная частота проходит; захват
параметра (второй клиент не перебивает первого раньше 200 мс и перебивает
@@ -593,8 +619,8 @@ web-клиента: явная просьба сильнее умолчания
### Стенд этапа 2 (бинарные потоки) — 112 проверок, все зелёные
Отдельная программа (`tcitest.pas` в scratchpad, собирается тем же fpc без
внешних библиотек) проверяет потоки на четырёх уровнях:
Отдельная программа (`test/tci/tcitest.pas`, прогон — `test/tci/run.sh`,
внешних библиотек не требует) проверяет потоки на четырёх уровнях:
- **Формат и математика:** коэффициенты прореживания (в том числе «просят выше,
чем есть» и «нацело не делится»), умолчания размера блока, поля заголовка,
@@ -612,9 +638,10 @@ web-клиента: явная просьба сильнее умолчания
стерео float32 с верным заголовком и длиной; IQ 384→48 кГц уходит только
целым блоком (полблока не отправляется); моно int16; пересчёт после смены
rate источника; переполнение кольца теряет старые блоки, но клиент жив.
- **Рекордер и WAV:** кольцо ограничено запрошенным временем, после заворота
первым идёт самый старый отсчёт, `Take` опустошает, файл получает верные
RIFF/fmt/data и длину.
- **Рекордер и WAV:** буфер ограничен запрошенным временем и пишется с начала
окна (переполнение отбрасывает новое, а не затирает старое), до срока запись
есть, после срока её нет и истёкший рекордер не оживает, `Take` завершает
запись, файл получает верные RIFF/fmt/data и длину.
- **Команды на живом сервере** (настоящий `TRadioController`, WS-клиент на
сыром сокете): отказ на несуществующий приёмник и на нечисловой аргумент,
отказ на старт потока с незапущенного пана, подтверждение и отбраковка
@@ -651,9 +678,9 @@ send просто возвращает EPIPE, и клиент выбрасыва
TCI в его граф пока не заведён — юниты LCL-free, подключается одной строкой в
`ewsdrd.lpr`, как web).
★Пробная сборка стенда: `-Mobjfpc` обязателен. С `-Mdelphi` в командной строке
вложенные комментарии выключаются, и `{$MODE Delphi}` внутри шапки `WebUtils.pas`
закрывает комментарий раньше времени — компиляция падает на «illegal character».
★Про сборку стенда`test/tci/README.md`: там записаны обе грабли (обязательный
`-Mobjfpc` и отдельный каталог `.ppu` с абсолютным путём, иначе линковка тянет
`PlatformUtils` из GUI-сборки, собранный с LCL).
**На реальном железе и с реальным клиентом (Log4OM/N1MM/WSJT-X/CW Skimmer) не
проверялось — ни команды, ни потоки.**