# TCI в EWSDR — статус реализации

Ветка разработки: `feature/tci-protocol`.
Дата последнего обновления: 2026-08-18.

Реализация соответствует спецификации «Протокол TCI, версия 2.0»
(`doc/TCI Protocol_RU.pdf`, 12 января 2024). EWSDR выступает **сервером**
по стандартной серверной модели: порт слушаем мы, клиенты — логгеры, скиммеры, программы
цифровых видов, внешние усилители и коммутаторы.

---

## 1. Архитектура

```
TCI-клиенты ──WebSocket──► TTCIServer ──► TTCIAdapter ──► TRadioController
логгер/скиммер/цифра        транспорт      команды +          ядро
                                            уведомления
```

| Файл | Назначение |
|---|---|
| `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`. |

Принципы те же, что у CAT (см. `doc/CAT_STATUS.md`):

- **TCI — равноправный клиент контроллера.** Никаких обращений к `MainForm`;
  единственное исключение — стор спотов `TDXSpotStore`, который передаётся
  адаптеру ссылкой (команды `SPOT`/`SPOT_DELETE`/`SPOT_CLEAR` кладут споты в
  ту же базу, что и DX-кластер).
- **Потоки.** Геттеры читают поля контроллера напрямую из потока клиента;
  сеттеры пишут параметр в scratch-поля под `FLock` и зовут
  `FController.Invoke(SyncXxx)` — исполнение в потоке контроллера
  (GUI = `TThread.Synchronize`).
- **Железо сетевые потоки не читают вовсе.** `BackendCaps` и
  `BoardDisplayName` смотрят в `FNetwork`, а его UI освобождает при смене типа
  устройства — обращение туда из потока клиента (да ещё и с копированием
  строки имени платы) означало бы чтение освобождённой памяти прямо во время
  подключения TCI-клиента. Поэтому адаптер держит снимок `TTCIDevSnap` (имя
  платы, границы настройки, число панов, наличие TX, sample rate), который
  обновляет **поток контроллера** (`RefreshDev`: конструктор, `ApplySettings`,
  `OnState` на `rfDevice`/`rfConnected`/`rfDeviceList`/`rfXvtr`/`rfBand`/
  `rfSampleRate`). Строка имени принадлежит адаптеру и присваивается только под
  `FDevLock`. По той же причине `TRadioController.ActiveTXFreqHz` перешёл на
  `GetSliceView`: он тоже вызывается снаружи и раньше копировал `TCtrlSlice`.
- **Слайсы сетевые потоки не читают вовсе.** Снимок таблицы
  (`RefreshSlices` → `FSliceSnap`) делает **поток контроллера**: в конструкторе
  адаптера, в `ApplySettings` и в `OnState` на каждое событие, которое слайсов
  касается (`rfSliceFreq`, `rfSliceState`, `rfDevice`, `rfPanFreq`,
  `rfSampleRate`, `rfBand`, `rfXvtr`, `rfCenterFreq`) — там писателей нет, и
  каждая запись снимается целиком. Команды и измерители читают уже снимок под
  `FSliceLock`. Прямое чтение `FSlices` из чужого потока давало не только порчу
  managed-строк (`DevName`/`InDevName`), но и смесь полей одного слайса:
  частота новая, мода ещё старая. Единственная оставшаяся цена — снимок может
  отставать на такт. `Sync`-методы (они уже в потоке контроллера) читают живую
  таблицу: команда на установку обязана попасть в тот слайс, который есть
  сейчас.
- **Синхронизация клиентов.** Изменение любого поля контроллера приходит в
  `OnState` (multicast-подписка `AddStateListener`) и рассылается всем
  подключённым — как того требует §3.5 спецификации. Отвечающий на команду
  клиент дополнительно получает прямой ответ.

### 1.1 Правила, на которых держится транспорт

Всё это не украшения, а лечение конкретных отказов — менять с оглядкой.

1. **Отправка никогда не блокирует ни вызывающего, ни соседей.**
   `Send`/`Broadcast` кладут строку в очередь клиента (микросекунды под его
   локом), а в сокет её пишет **собственный поток клиента**: его `recv`
   просыпается каждые `TCI_POLL_MS` (20 мс) и сливает очередь. Уведомления
   рождаются внутри `Changed()` контроллера, то есть в UI-потоке — писать
   оттуда прямо в сокет означало бы отдать интерфейс во власть самого
   медленного клиента. Общий поток отправки был лишь половиной решения: один
   `SockSend` ждёт до `TCI_SEND_TIMEOUT` (300 мс), и восемь клиентов давали
   секунды задержки всем остальным. Теперь медленный клиент задерживает только
   себя; переполнилась его очередь (`TCI_OUT_MAX`) или не прошла запись — он
   выбрасывается.
2. **Объект клиента освобождает только тик-поток** (`ReapClients`) и только
   после того, как клиентский поток честно вышел. Поэтому указатель, взятый
   кем угодно под `FClientLock`, гарантированно жив внутри лока. Единственное
   исключение — `Stop`, но он к этому моменту уже дождался всех потоков. И в
   том и в другом случае наверх уходит `OnDisconnect` (единая точка
   `Disconnected`): иначе после остановки у адаптера оставались висеть захваты
   параметров ушедших клиентов.
3. **`Stop` ждёт выхода ВСЕХ своих потоков без таймаута** и прокачивает при
   этом очередь `Synchronize`. Останавливает сервер поток контроллера (UI), а
   клиентский поток в этот момент может висеть как раз на `Invoke` в него же:
   без прокачки это взаимный клин. ★Это относится и к accept- с тик-потоком
   (общий `JoinPumped`), а не только к клиентским: тик-поток ходит в контроллер
   через `ReapClients` → `Disconnected` → `Invoke`, и проверка `CanInvoke` от
   клина не спасает — она читает `Stopping` ДО входа в `Synchronize`, так что
   клиент, отвалившийся ровно в момент остановки, успевает проскочить в это
   окно. Выйти по таймауту нельзя — следом
   освобождаются и клиенты, и сам сервер с адаптером, а не вышедший поток
   вернулся бы в эту память. Поэтому: `Stopping` (адаптер новых `Invoke` не
   начинает) + закрытые сокеты + повторный `shutdown` раз в полсекунды, и
   ожидание гарантированно конечно.
4. **Слот протокола выдаётся только после handshake.** Соединение до
   `Upgrade` живёт в общем массиве (`TCI_MAX_SOCKETS` = 32) и обязано
   уложиться в `TCI_HANDSHAKE_MS` (5 с), иначе закрывается по таймауту сокета;
   восемь слотов `TCI_MAX_CLIENTS` считаются только среди поднявшихся. Раньше
   восемь молчащих TCP-соединений навсегда закрывали дверь настоящим клиентам.
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-странице нужен явный прокси, а не дыра
   по умолчанию.

7. **Блоки потоков нарезает и раскладывает DSP-поток.** Тап зовётся прямо из
   потока WDSP, поэтому в `TCIStreams` нет ни одного ожидания: блок уходит в
   кольцо клиента (микросекунды под его локом), а в сокет его пишет, как и
   команды, поток самого клиента. Порядок локов везде один: `FSliceLock` →
   `FStreamLock` (тап сначала выясняет, чей это слайс, и только потом ищет
   подписчиков) — обратный дал бы клин с потоком контроллера. Тапы навешивает
   и снимает **только поток контроллера**: снятие обязано дождаться выхода
   DSP-потока из вызова, иначе тот позвал бы метод освобождённого адаптера.

Разбор HTTP — построчный (`TCIHttpHeader`), а не поиском подстроки
«`upgrade: websocket`»: заголовок с табуляцией или без пробела после
двоеточия валиден. Close-кадр подтверждается ответным close с тем же кодом
(RFC 6455 §5.5.1); полезная нагрузка close длиной ровно один байт невалидна и
рвёт соединение. Текстовые сообщения проверяются на UTF-8 (`TCIValidUTF8`,
§5.6): обрыв последовательности, избыточно длинная форма, суррогаты и всё
выше U+10FFFF закрывают соединение, а не уходят в разбор команд.

### 1.2 Настройки

Секция `tci` в корне `settings.json`:

```json
"tci": { "enabled": false, "port": 40001, "bind_addr": "127.0.0.1",
         "record_dir": "" }
```

`record_dir` — единственный каталог, куда TCI пишет записи линейного выхода;
пусто = `<каталог конфигурации>/records`, каталог создаётся при первом `SAVE`.
Поля в UI у него нет намеренно: это не рабочая настройка, а граница, и менять
её осмысленно только руками в файле. Почему каталог вообще существует и почему
путь из команды в него не попадает — §2.5, «Имя файла: каталог всегда наш».

UI — вкладка **CAT → TCI Server**, справа от «TCP CAT Server» (галка, порт,
интерфейс): TCI — такой же канал внешнего управления трансивером, что и CAT,
и оператор ищет его там, а не в «Advanced». В протоколе
**нет авторизации**: открытый наружу порт означает полный доступ к трансиверу,
поэтому умолчание слушает только петлю.

Отсюда же правила вокруг адреса:

- разбор `bind_addr` строгий (ровно четыре октета 0..255); всё непонятное —
  отказ поднимать сервер, а не молчаливый `0.0.0.0`. Пустая строка и явный
  `0.0.0.0` — единственные способы попросить «все интерфейсы»;
- порт и адрес применяются по уходу фокуса из поля и по кнопке Close, а не на
  каждое нажатие клавиши: иначе набор `127.0.0.1` по дороге проходил бы через
  «`127.0.0.`» и сервер успевал перезапуститься на всех интерфейсах.

**Сначала применяем, потом сохраняем.** `ApplySettings` проверяет новую
конфигурацию ДО остановки работающего сервера, а если новый слушатель не
поднялся (порт занят) — возвращает прежний. `MainForm.ApplyTCISettings` пишет
`settings.json` только после успеха и на отказе возвращает поля окна к тому,
что реально работает. Иначе занятый порт оставлял оператора вообще без TCI, да
ещё и с нерабочей конфигурацией на следующий запуск.

### 1.3 Маппинг модели

| TCI | EWSDR |
|---|---|
| приёмник (`TRX_COUNT` = 7) | 0 — главный тракт; N — **слайс слота N−1**, то есть буквы B..G с флага, на каком бы пане он ни стоял. Потолок = 1 + `MAX_SLICES` |
| канал A/B (`CHANNEL_COUNT` = 2) | у приёмника 0 — VFO A / VFO B; у приёмника-слайса канал один (A): второго саб-приёмника внутри слайса не бывает |
| панорама | не адресуется: стала свойством приёмника — от неё берутся `DDS` и поток IQ |
| `DDS` | центр панорамы, на которой стоит приёмник. Пишется только у приёмника 0 (`SetCenter`); у слайса читается |
| `IF` | смещение канала от центра его панорамы |
| `VFO` | абсолютная частота канала (у слайса — `TuneSliceInBand`) |
| передатчик (`arg1` у TRX/TUNE) | передатчик один, но `arg1` называет, ЧЕЙ слайс должен в него попасть: 0 — главный VFO (как обычный CAT-порт), N — слайс слота N−1 через `RequestSliceTx`. См. §2.7 |
| `arg1` у `DRIVE`, `TUNE_DRIVE` | мощность одна на радио — номер не адресует ничего |

★**Почему приёмник — слайс, а не панорама.** Сперва приёмником был панадаптер,
в модели, где приёмник соответствует DDC. В EWSDR это разошлось с жизнью сразу на
двух концах. У Pluto панорама ровно одна (`BackendCaps.MaxPans = 1`), и второй
приёмник существует там **только** как слайс главного пана — при нумерации по
панам он был бы недоступен вообще, а `TRX_COUNT` навсегда равнялся единице.
Кроме того, некоторые клиенты позволяют выбрать только приёмники 0 и 1.
Значит
«первый добавленный слайс» обязан быть приёмником **1** на любом железе.

Слот слайса даёт ровно это. Слоты глобальные и стабильные, у каждого своя
буква на флаге (`SliceIdBySlot`, `SliceSlotLetter`), и по ним же раздаются
порты слайс-CAT — то есть номер приёмника TCI совпадает с буквой, которую
оператор видит на экране: B → 1, C → 2, … Первый созданный слайс занимает слот
B независимо от того, где он живёт: на openHPSDR это может быть слайс второго
пана, на Pluto — слайс главного, и в обоих случаях он приёмник 1.

Цена: панорама без единого слайса из TCI пропадает (слушать там нечего, но и
её IQ клиенту недоступен), а второй слайс панорамы перестал быть «каналом B» и
стал самостоятельным приёмником — что скорее выигрыш: у канала B в протоколе
есть только частота, IF и громкость, а у приёмника — мода, фильтр, АРУ,
шумодавы, squelch, свои потоки и `TRX`.

---

## 2. Что реализовано

### 2.1 Инициализация (§4.1)

`PROTOCOL`, `DEVICE`, `RECEIVE_ONLY`, `TRX_COUNT`, `CHANNEL_COUNT`,
`VFO_LIMITS`, `IF_LIMITS`, `MODULATIONS_LIST`, `READY`.

`READY` шлётся **после** полного дампа состояния: клиент, дождавшийся его,
уже знает всё. `IF_LIMITS` = ±sample rate/2, пересылается при смене частоты
дискретизации.

`VFO_LIMITS` **переобъявляются на лету**: при `rfDevice`/`rfXvtr`/`rfBand`
адаптер пересчитывает границы и, если они изменились, рассылает `vfo_limits`
заново (дедуп по последнему известному значению — `rfDevice` приходит и на
создание слайса). Кэш границ ведётся **независимо от того, есть ли клиенты**:
иначе радио, отвалившееся в момент, когда не подключён никто, осталось бы для
кэша незамеченным, и после его возвращения рассылка подавилась бы — новый
клиент навсегда остался бы с запасным диапазоном из своей пачки инициализации. Перезапросить границы клиент не может, а сценарий обычный: логгер
подключился до радио и получил запасные 10 кГц…30 МГц, потом появился Pluto
или включился трансвертер — и его представление о пределах устарело, хотя
команды уже отбраковываются по новым.

`VFO_LIMITS` и проверка частоты в командах берутся из **одного** источника —
`FreqLimits` поверх `VisibleFreqBounds`: под трансвертером это диапазон его
слота, иначе пределы устройства, а пока устройства нет — объявленный запасной
диапазон 10 кГц…30 МГц. Раньше источников было два, и они расходились: клиенту
объявлялись пределы АЦП, а команда под трансвертером принимала любое число.
Для `DDS` это опаснее, чем для `VFO`: `SetCenter`/`SetPanDDCFreq` ничего не
клампят и отдают частоту прямо в backend.

В дампе состояния есть и то, что иначе клиент не получил бы часами:
`TX_FREQUENCY` (уведомление шлётся по изменению, а на стабильном радио его нет
— особенно важно при split и TX-слайсе), текущий `APP_FOCUS` и `VFO_LOCK` на
каждый канал.

**Приёмник живёт ровно столько, сколько его слайс.** Удалили слайс — приёмник
исчез вместе с ним: ни частоты, ни состояния, ни потоков. Создали снова — он
займёт первый свободный слот, то есть, как правило, тот же номер (так же
устроены и порты слайс-CAT, привязанные к слоту).

**Приёмники, которых ещё нет, молчат.** `TRX_COUNT` объявляет потолок (1 + 6
слотов слайсов) один раз и навсегда, а слот из этого потолка может быть пуст.
Про пустой слот не шлётся ничего — ни `vfo`, ни `dds`, ни показания
измерителей, и команды к нему тоже игнорируются целиком (`LiveRx`). Раньше
уходили `dds`/`vfo`/`if` с нулём, и клиент принимал ноль за настоящую частоту,
завершая инициализацию несуществующего приёмника. Состояние появляется в момент
создания слайса: `PushChannelMap` рассылает картину нового приёмника целиком,
включая `tx_enable` с его номером.

Список видов связи: `am,sam,dsb,lsb,usb,cw,nfm,wfm,digl,digu,dmr,fmraw`.
`dmr`/`fmraw` — наше расширение (протокол расширяемый, §1.4). `CWL`/`CWU`
схлопываются в `cw`; при установке `cw` боковая сохраняется, если уже
телеграф, иначе выбирается по частоте (ниже 10 МГц — CWL).

### 2.2 Двунаправленное управление (§4.2)

Общее для всех установок:

- **аргумент разбирается строго** (`TCITryArgInt/Float/Bool`). Не разобрался —
  параметр не трогаем и отвечаем текущим значением. Раньше `vfo:0,0,abc;`
  превращалось в честный ноль и уводило приёмник на 0 Гц, а любой мусор в
  Boolean-командах читался как `false`;
- **частота проверяется дважды**. В потоке клиента — `FreqSane` по тем же
  границам, что объявлены в `VFO_LIMITS` (см. §2.1): ноль и отрицательные —
  отказ. И ещё раз в потоке контроллера, уже по живым границам (`FreqSaneLive`
  в `SyncSetVfo`/`SyncSetCenter`): между разбором команды и её исполнением
  оператор успевает сменить устройство, и снимок, по которому частоту
  пропустили, описывает уже не то радио, куда она уедет. Ни `SetVfoA`, ни
  `SetCenter`, ни `SetPanDDCFreq` границ не клампят — число уходит прямо в
  backend. Кромки фильтра обязаны разбираться обе и идти по возрастанию;
- **слайс перестраивается только `TuneSliceInBand`** — тем же путём, что у
  CAT-порта слайса: внутри включённого диапазона он ходит свободно, за
  захваченную полосу окно DDC переедет само, а за границы диапазона команда
  отбрасывается. Прямой `SetSliceTarget` (как было) уводил слайс куда угодно, и
  для TX-слайса эта частота попадала прямо в DUC — то есть в эфир на чужом
  диапазоне, без переключения антенн и фильтров. Смена диапазона остаётся
  решением оператора, а не управляющего ПО;
- **параметр захватывается на 200 мс** (§3.5, `Claim`). Пока клиент крутит
  частоту, второй логгер её не перебьёт; изменение от оператора захватывает
  параметр так же, но у клиента, который им прямо сейчас управляет, не
  отбирает. Захваты клиента снимаются при его отключении.

Полностью проведено в контроллер:

| Команда | Куда легло |
|---|---|
| `START` / `STOP` | `SetRun` |
| `DDS` | `SetCenter` / `SetPanDDCFreq` |
| `IF`, `VFO` | `SetVfoA/B`, `TuneSliceInBand` |
| `MODULATION` | `SetMode` / `SetSliceMode` |
| `TRX` | `SetMOX` (приёмник 0) / `RequestSliceTx` (приёмник N) |
| `TUNE` | `SetTune` (приёмник 0) / `RequestSliceTx(…, Tune)` (приёмник N) |
| `DRIVE` | `SetDrive` |
| `TUNE_DRIVE` | `FTXSettings.TUNLevel` через `SetTXSettings` |
| `SPLIT_ENABLE` | `SetSplit` |
| `RX_FILTER_BAND` | `SetFilterEdges` / `SetSliceFilter` |
| `VOLUME`, `MUTE` | `SetRxVolume`, `SetMute` (дБ ↔ 0..100) |
| `RX_MUTE`, `RX_VOLUME` | громкость/мьют слайса |
| `MON_VOLUME`, `MON_ENABLE` | `SetTXMonVolume`, `SetRxMuteOnTx` |

Громкость приёма и громкость самоконтроля — **разные** величины, и в TCI это
разные команды. У слайдера программы они одна: `SetVolume` правит ту, что
сейчас звучит (на передаче в DUP с выключенным RX MUTE — монитор). Поэтому
`VOLUME` и `RX_VOLUME` ходят через адресный `SetRxVolume`: иначе команда,
пришедшая на передаче, уезжала бы в громкость монитора, а в ответ клиент
получал бы нетронутый `FVolume`. Обратный путь тоже разделён — у монитора
появилось своё событие `rfMonVolume`, раньше его правка рассылалась клиентам
как обычный `volume`.
| `AGC_MODE` | `off`→Off, `fast`→Fast, `normal`→Medium |
| `AGC_GAIN` | `SetAGCTop` (AGC-T) — **только приёмник 0**, см. §3 |
| `RX_NR_ENABLE`, `RX_NB_ENABLE`, `RX_ANF_ENABLE` | `SetNR/SetNB/SetANF`, для панов — `SetSliceDSP` |
| `LOCK` | `SetVfoLock` |
| `SQL_ENABLE`, `SQL_LEVEL` | FM-шумоподавитель; дБ (-140..0) ↔ порог 0..100 |
| `CW_MACROS_SPEED`, `CW_KEYER_SPEED` | `TCWSettings.Speed` |
| `CW_MACROS_DELAY` | `TCWSettings.RFDelayMS` |

### 2.3 Однонаправленное управление (§4.3)

`TX_ENABLE` (по `BackendCaps.HasTX` + `TXProhibited`), `CW_MACROS_SPEED_UP/DOWN`,
`SET_IN_FOCUS` (поднимает окно программы через `OnFocusRequest` — адаптер до
окна не дотягивается, действие ставит MainForm),
`SPOT`, `SPOT_DELETE`, `SPOT_CLEAR` (в `TDXSpotStore`, спот виден на всех
панадаптерах; время спота — UTC, как у кластера, а не местное),
`RX_SENSORS_ENABLE`, `TX_SENSORS_ENABLE` (период — на клиента),
`KEYER` (чужой ключ — см. §2.6),
`IQ_SAMPLERATE`, `AUDIO_SAMPLERATE`, `AUDIO_STREAM_*`, `TX_STREAM_AUDIO_BUFFERING`
(значения принимаются и подтверждаются). Параметры
потоков — настройки **клиента**, а не устройства: живут в `TTCIClient`, и один
клиент не переопределяет их остальным; подписки и параметры читаются/пишутся
под локом клиента, потому что пишет их его поток, а читает тик-поток.

Значения сверяются со списками протокола: `IQ_SAMPLERATE` — 48/96/192/384 кГц,
`AUDIO_SAMPLERATE` — 8/12/24/48 кГц, `AUDIO_STREAM_SAMPLE_TYPE` —
int16/int24/int32/float32. Чужое значение не принимается, в ответе уходит
действующее.

Команды **запуска** потоков (`IQ_START`/`IQ_STOP`, `AUDIO_START`/`AUDIO_STOP`,
`LINE_OUT_*`) разобраны в §2.5 — этап 2 сделан, и потоки настоящие.

### 2.4 Уведомления (§4.4, §4.5)

`RX_CHANNEL_SENSORS` + устаревшая `RX_SENSORS` (S-метр в дБм, период 30…1000 мс
на клиента), `TX_SENSORS`, `TX_FREQUENCY`, `VFO_LOCK`, `APP_FOCUS`
(активация/деактивация главного окна), `RX_CLICKED_ON_SPOT` + устаревшая
`CLICKED_ON_SPOT` (клик по подписи спота на любом панадаптере),
`CALLSIGN_SEND` (после `CW_MSG`).

**Пачка инициализации уходит одним куском.** Сервер зовёт `OnConnect` под
`FClientLock`, то есть рассылка ждёт, пока весь дамп не уложен в очередь
клиента. Без этого изменение, случившееся после строки снимка, но до
`Ready = True`, пропадало навсегда: `Broadcast` пропускает не-Ready клиента, а
тот считал инициализацию завершённой и оставался со старым значением. Отсюда
запрет для `HandleConnect`: никаких `Invoke` в поток контроллера — он сам может
стоять на этом локе внутри `Broadcast`.

**Создание и удаление слайса.** `AddSlice`/`RemoveSlice` шлют только `rfDevice`,
и по нему адаптер сравнивает расстановку до и после (`SliceMapSig`: id и пан
каждого слайса по порядку слотов). Изменилась — уходит полная картина каналов
каждого живого доп. приёмника (`PushChannelMap`). Иначе подключённый клиент не
узнавал ни о появлении канала, ни о его исчезновении, а при удалении первого
слайса второй молча становился каналом A. Сказать «приёмника больше нет» в
TCI 2.0 нечем — про канал B есть `RX_CHANNEL_ENABLE`, про сам приёмник ничего;
это ограничение протокола, а не наше упрощение.

**Глобальные величины рассылаются всем.** `TUNE_DRIVE`, `CW_MACROS_SPEED`,
`CW_MACROS_DELAY` и `SPLIT_ENABLE` — свойства радио, а не клиента, поэтому
команда отвечает `Broadcast`, а не `Reply` (автор входит в рассылку). Правки от
оператора приходят событиями: `rfTXProfile` для уровня TUN, `rfActiveVfo` для
split, `rfMonVolume` для громкости самоконтроля и новый `rfCWSettings`, который
теперь шлёт `SetCWSettings` — своего события у телеграфа не было вовсе, и
клиенты о смене скорости из окна настроек не узнавали.

Отдельная история — **доп. приёмники**. У главного тракта на каждое поле есть
своё `rfXxx`, а у слайсов не было ничего: правка слайса не доходила ни до UI,
ни до остальных клиентов. Поэтому в контроллере появилось `rfSliceState`
(полезная нагрузка — `FSliceFreqId`, как у `rfSliceFreq`), и его шлют сами
сеттеры слайса: `SetSliceMode`, `SetSliceFilter`, `SetSliceAGCMode`,
`SetSliceVolume`, `SetSliceMute`, `SetSliceDSP`, `SetSliceFMSquelch`.
Адаптер разворачивает Id обратно в пару (приёмник, канал) и рассылает
состояние именно этого канала.

Частоту слайса объявляет сам `SetSliceTarget`: `SliceFreqChanged` живёт
**внутри** него, а не у каждого вызывающего. Раньше об этом помнили CAT и TCI,
но не UI — перетаскивание флага мышью обновляло только свой пан, и TCI-клиенты
оставались на старой частоте. Дублирующие вызовы у вызывающих (в том числе
ручные `PushSliceFlagState`/`LayoutFlags` в MainForm) убраны: теперь один
путь на всех — мышь, колесо, CAT, TCI, бэнд-логика.

### 2.5 Бинарные потоки (§3.4)

Реализованы все четыре типа: `IQ_STREAM`, `RX_AUDIO_STREAM`, `LINEOUT_STREAM`
(наружу) и `TX_AUDIO_STREAM` + `TX_CHRONO` (внутрь), плюс запись линейного
выхода в файл. Команды: `IQ_START/STOP`, `AUDIO_START/STOP`,
`LINE_OUT_START/STOP`, `LINE_OUT_RECORDER_START/SAVE/BREAK` и параметры
`IQ_SAMPLERATE`, `AUDIO_SAMPLERATE`, `AUDIO_STREAM_SAMPLES/CHANNELS/SAMPLE_TYPE`,
`TX_STREAM_AUDIO_BUFFERING`.

**Откуда берётся звук.** Протокол различает «аудиопоток приёмника» и «поток
линейного выхода», и это не одно и то же:

| Поток TCI | Точка в ewsdr | Что это значит |
|---|---|---|
| `RX_AUDIO_STREAM` | `OnDemodAudioReady` (тап `rakDemod`) | выход демодулятора **до** громкости и мьюта, без сайдтона — то, что нужно скиммеру и цифре |
| `LINEOUT_STREAM` | `OnAudioReady` (тап `rakLineOut`) | ровно то, что слышно: после громкости, с сайдтоном; MUTE его глушит (движок не зовёт `OnAudio` под мьютом) |
| `IQ_STREAM` | `TWDSPEngine.PushIQItemToDSP` / `PushPanItemToDSP` | сырой IQ приёмника, один вызов тапа на накопленный блок |

Тап — многоадресный и **ничего не забирает**, в отличие от `OnAudioConsume`
(им владеет web-адаптер и им же глушит локальный звук). Иначе первый же
TCI-клиент отобрал бы звук у динамика и у браузера.

Приёмник в потоках — панадаптер, как и везде (§1.3). У доп. пана в потоках
участвует только **канал A** (первый слайс): «аудиопоток приёмника» в протоколе
один на приёмник, отдельного потока канала B нет.

**Пересчёт частоты вниз — многоступенчатый.** Аудио 48 кГц → 8/12/24 и IQ
до 48/96/192/384 кГц считает `TCIStreams`. Коэффициент раскладывается на
множители, и каждая ступень фильтрует своё: одной ступенью большие
коэффициенты не берутся, потому что длина FIR растёт вместе с коэффициентом и
упирается в потолок. ★Это не теория: на верхнем пресете Pluto (5760 кГц,
коэффициент 120 к 48 кГц) одноступенчатый дециматор давал **завал 1.3 дБ в
полосе и подавление зеркала всего 16 дБ**, то есть поток IQ с мусором.

Спецификацию фильтра каждой ступени задаёт **итоговая** полоса, а не её
собственная: ранняя ступень обязана убрать лишь те узкие зоны, которые в конце
сложатся в полезную полосу, поэтому её переходная полоса шире в десятки раз, а
фильтр во столько же короче. Свёртка идёт со сложением симметричных пар
(фильтр линейнофазовый). Замеры после переделки: −83 дБ по зеркалу на **любом**
коэффициенте, полоса ровная, цена ≈10% ядра на потоке 5.76 МГц (было 20% —
и с мусором) и ≈1% на 384 кГц.

Вверх (TX-аудио клиента → 48 кГц тракта) — линейная интерполяция: её образы
лежат за полосой TX-фильтра, а завал в полосе 0.2 дБ на 3 кГц.

**Ответ на `IQ_SAMPLERATE` называет достижимое, а не запрошенное.** Просьбу
клиента храним как есть (на другом устройстве она может стать выполнимой), а в
ответ отдаём то, что он реально получит на главном приёмнике. Подтвердить
«384000» и слать 192 кГц значило бы соврать в единственном месте, куда клиент
и смотрит. Из-за этого же `iq_samplerate` **переобъявляется без запроса** при
смене частоты дискретизации и устройства (`rfSampleRate`, `rfDevice`,
`rfConnected`) — иначе клиент, попросивший 384 кГц на openHPSDR, после
перехода на Pluto 576 кГц молча получал бы 192. По той же логике устроен и
`IF_LIMITS`: §4.1 прямо требует высылать его «при подключении и изменении
частоты дискретизации устройства», и он уходит на `rfSampleRate`.

**Какая частота IQ достанется клиенту.** Набор протокола (48/96/192/384 кГц)
и частоты дискретизации железа сходятся не всегда. У openHPSDR (48…384 кГц)
сходятся все, а из пресетов Pluto (576/768/960/1536/2304/3072/3840/5760 кГц)
на 384 не делятся 576 и 960. Поэтому отдаём **наибольшую законную** частоту,
не выше запрошенной и делящую частоту источника нацело (`TCIPickIQRate`):
на 576 и 960 кГц просьба «384» превращается в 192 кГц. Иначе клиент,
попросивший 384, получал бы поток на 576/480 кГц — и не по протоколу, и вчетверо
толще ожидаемого. Настоящая частота всегда стоит в заголовке блока; если
законной не нашлось вовсе (чужой rate), уходит ближайшая достижимая — врать
в заголовке мы не будем ни в каком случае. Смена rate устройства или DDC пана
на ходу пересобирает прореживание (`SetSourceRate`).

**Размер блока.** Две разные величины, которые §4.3 называет одним словом.
`AUDIO_STREAM_SAMPLES` — это кадры **на канал** (умолчание 2048 при 48 кГц =
42.7 мс), а `Stream.length` — вещественные отсчёты **всего блока**, то есть
`кадры × channels`, и так у **всех** типов потока. Единицу `length` задаёт
§3.4 для IQ («количество вещественных отсчётов… комплексных =
`length/channels`») и тут же распространяет на аудио: «аудиопоток приёмника
полностью повторяет IQ поток», отличия перечислены и их ровно три — каналы,
формат сэмплов, число сэмплов в пакете. Формулировка §4.3 «arg1 — количество
сэмплов, указываемое в поле Stream.length» верна только для моно, и однажды
она уже увела нас в развилку по типу потока: у стерео клиент разбирал
половину блока (звук с дырами 50%, FT4/FT8 не декодировались вовсе). Что
`arg1` считает именно кадры на канал, видно из самой же §4.3: рекомендуемый
минимум 512/256/128/100 сэмплов на 48/24/12/8 кГц даёт «не меньше 10 мс»
только при счёте на канал, и потолок `data[16384]` сходится тем же счётом —
2048 × 2 канала × float32 = ровно 16384 байта. Единое правило держит
`TCIFillHeader`, обратное — разбор `TX_AUDIO_STREAM` в `HandleBinary`;
`TX_CHRONO` замыкает круг (клиент шлёт столько отсчётов, сколько названо в
`length` маркера). Смена любого параметра потока на ходу
**перезапускает** уже идущие потоки этого клиента: блок с новой частотой
посреди старого потока клиенты разбирают как мусор.

**Передача (§3.4, §4.2).** `TRX:0,true,tci` берёт модуляцию из аудиопотока
клиента — но только если у него запущен `AUDIO_START` (буквально по документу:
«работает, если включен аудиопоток по TCI»). В контроллере это отдельный флаг
`TCIMicRequested`, который `SetMOX` читает при выборе микрофона, **впереди**
web-клиента: явная просьба сильнее умолчания «подключён браузер». Дальше тик
сервера гонит маркеры `TX_CHRONO` по часам (плюс разовая подушка
`TX_STREAM_AUDIO_BUFFERING` на старте передачи), а приходящие блоки
`TX_AUDIO_STREAM` разворачиваются в 48 кГц моно и кладутся в тот же ринг
микрофона, что и web-аудио. Аудио от клиента, который не просил `tci`,
отбрасывается молча: отвечать ошибкой на каждый чужой блок значит захлебнуться.

**★Маркер `TX_CHRONO` называет приёмник КЛИЕНТА, а не нулевой.** Клиент шлёт
TX-аудио строго в ответ на маркер и может фильтровать входящие бинарные блоки
по номеру приёмника. С жёстким нулём в заголовке клиент,
сидящий на **втором слайсе** (`tci_trx = 1`), маркеров не видел вовсе: эфир по
`trx:1,true,tci` поднимался, а звука не было ни одного блока. Поэтому вместе с
клиентом-модулятором запоминается номер приёмника из его же `TRX`
(`FTxRx`), и маркеры идут под ним. ★Пара «клиент + приёмник» меняется **только
когда команда и правда что-то сделала** (`Started`, тот же признак, по которому
назначается хозяин эфира): иначе клиент, уже передающий с приёмника 1, своим же
`trx:0,true,tci` — командой-пустышкой, потому что передатчик занят, — перевёл бы
маркеры идущей передачи на номер 0 и замолчал.

**★`rfTransmitting` — не всегда «эфир изменился».** Контроллер шлёт это поле и
просто «перерисуй TX-бейджи»: `SetTxSlice` заканчивается `Changed(rfTransmitting)`,
хотя передачи ещё нет. Адаптер раньше понимал любой такой сигнал при
`FTransmitting = false` как «передача кончилась» и снимал `TCIMicRequested` —
а приходил он **посередине нашей же команды**: `SyncSetTRX` ставит просьбу →
`RequestSliceTx` → `SetTxSlice` → `Changed` → просьба стёрта → `SetMOX` выбирает
микрофон уже без неё. На живом железе это выглядело так: клиент на слайсе
поднимает эфир, а модуляция идёт с микрофона оператора, то есть в эфир —
тишина (и TX-аудио клиента отбрасывалось, `TCIMicActive` не поднят). Теперь
ловится **фронт** «было → стало» (`FLastTxOn`), а не всякое уведомление.

**Запись линейного выхода.** Рекордер один на приёмник (а не на клиента):
пишет он то, что слышно в аппарате. Буфер на запрошенное время (потолок 300 с)
в int16 48 кГц стерео — это ровно то, что уйдёт в WAV, и вдвое меньше памяти,
чем float32. ★Буфер **линейный, не кольцевой**: §4.3 называет arg2
максимальным временем записи и прямо говорит, что по его истечении запись
удаляется, а чтобы сохранить файл, `SAVE` нужно прислать внутри интервала.
Поэтому `START` открывает окно длиной arg2, пишем от его начала, а по концу
окна данные выбрасываются вместе с памятью (`DropData` из `Feed` и из `Take`).
Срок считается **по часам** от `START`, а не по накопленным сэмплам: линейный
выход может молчать (мьют, стоящий приёмник), а время записи всё равно идёт.
Кольцо «последние N секунд» вело себя иначе в обе стороны — начало записи
затирало само себя, а `SAVE` через час после `START` отдавало бы файл уже после
завершения заданного окна записи.
`SAVE` завершает запись и ставит её в очередь писателю: файл бывает в десятки
мегабайт, а команда пришла в потоке клиента, который в это время не читает свой
сокет. MP3 не поддержан — кодера в проекте нет, и на `.mp3` уходит честный
`tci_error`.

**★Писатель — один поток с очередью, и он принадлежит адаптеру.** Раньше
`SAVE` был равен «создать поток с `FreeOnTerminate`»: его никто не держал и
никто не ждал. Отсюда две беды сразу.

- **Штатный выход из программы обрывал запись.** Замерено отдельным процессом:
  при закрытии сразу после `SAVE` от ожидаемых 100000044 байт на диске
  оставалось 40960 — то, что успел сбросить буфер файловой системы, — а
  заголовок при этом заявлял полную длину.
- **Медленный (или зависший сетевой) каталог плодил потоки.** Идущие подряд
  сохранения запускали их сотнями, и память их стеков в 128-МБ бюджет не
  входила вовсе.

Теперь писатель один (`TTCIWavWriter`, заводится лениво — на первом `SAVE`),
очередь ограничена `TCI_RECORD_MAX_JOBS` = 16 заданиями (сверх — клиенту
`writer busy`, а место в бюджете возвращает `CmdRecorder`), а деструктор
адаптера гасит его через `TCIStopWriter`: закрыть приём заданий, дождаться,
пока очередь допишется, и освободить.

**★Срока у этого ожидания нет намеренно** — и это вывод из двух неверных
попыток его завести. Внутрипроцессный поток, стоящий в `write(2)` или `fsync`,
остановить нечем: `Terminate` ему не указ, а `Free` обязан сделать `WaitFor` —
бросить живой `TThread` нельзя, он ходит в общий бюджет (`RecBudgetLock`),
который освобождает финализация юнита. Значит, срок **не ограничивает выход**:
на мёртвом каталоге программа всё равно стоит в syscall. Ограничивал он ровно
одно — сколько подтверждённых клиенту записей мы выбросим по дороге (сперва
весь остаток очереди по общему сроку, потом, с отсчётом от последнего
продвижения, — остаток очереди на каталоге, который тормозил дольше срока и
оживал). То есть не покупал ничего и стоил данных. Убран вместе с
`FProgress`/`Advance`/`Terminate` в остановке.

Так что при закрытии программы очередь **дописывается вся**, а поток ждётся
по-настоящему (`Destroy` = `Terminate` + `WaitFor`): ни брошенных потоков, ни
выброшенных записей после нас не остаётся. Цена названа прямо: на мёртвой
сетевой ФС выход подвиснет вместе с syscall'ом. Кому нужен гарантированно
ограниченный выход — писателя придётся выносить в отдельный процесс, который
гасится средствами ОС; одним `TThread` это не делается.

**★Файл появляется целиком или не появляется вовсе.** Прежний код не смотрел
на результат записи: `FileWrite` (как и `THandleStream.Write` под ним)
возвращает **число записанных байт** и при ошибке отдаёт 0 или -1, не поднимая
исключения, — то есть на полном диске файл дописывался «до конца» и оставался
огрызком с заголовком на полную длину. Теперь:

1. данные пишутся во **временный файл** рядом (`<имя>.<pid>-<n>.part`,
   создаётся эксклюзивно и не по симлинку — `TCICreateNewFile`), циклом, с
   проверкой каждого вызова (короткая запись законна, её дописываем; 0 или
   -1 — провал). ★Имя перебирается (`TCICreateTempNear`): pid со счётчиком
   уникальны внутри процесса, но не между запусками, и `.part`, оставшийся от
   прошлой жизни, вместе с повторно выданным системой pid дал бы `EEXIST` —
   занятое имя не повод терять запись, а вот другая ошибка (нет прав, нет
   каталога) перебором не лечится и обрывает попытку сразу;
2. перед публикацией идёт `FileFlush`: на ext4 с отложенным размещением «нет
   места» приходит не в `write`, а именно здесь;
3. и только после этого файл появляется под целевым именем — **одним вызовом
   ядра и сразу целиком** (`TCIPublishFile`).

**★Целевого имени до этого момента не существует вовсе, и публикация ничего не
заменяет.** Промежуточный вариант — занять имя пустым файлом заранее — был
хуже обоих: на медленном диске клиент всю запись видел WAV на 0 байт, а по
имени файла он вправе считать запись готовой.

Портируемо получить сразу три свойства — атомарное появление, запрет замены и
работу на любой файловой системе — нельзя, поэтому `TCIPublishFile` идёт по
списку и на последнем шаге честно отказывается:

1. **`renameat2(AT_FDCWD, tmp, AT_FDCWD, dst, RENAME_NOREPLACE)`** — ровно то,
   что нужно, одним вызовом ядра. Обёртки в RTL нет, зовём напрямую через
   `Do_SysCall` (номера ABI: x86_64 316, i386 353, aarch64 276, arm 382);
   `EEXIST` — имя занято, это отказ по существу.
2. **`link(2)` + `unlink`** — если `renameat2` нет (старое ядро, другая
   архитектура, ФС не умеет флаг: `ENOSYS`/`EINVAL`). Семантика та же: новое
   имя обязано не существовать, иначе `EEXIST`, и на симлинк по этому имени
   `link` тоже не пойдёт. Каталог у временного и целевого файла один
   (`tci.record_dir`), так что `EXDEV` здесь не бывает.
3. **Отказ**, если нет и жёстких ссылок (FAT/exFAT, часть CIFS/SMB и FUSE).
   ★`FileExists` + `rename` в этом месте недопустим: `rename` затирает то, что
   лежит по имени СЕЙЧАС, а между проверкой и переносом туда может попасть что
   угодно — это ровно то окно (TOCTOU), ради закрытия которого всё и
   затевалось. Данные при этом не пропадают: писатель **оставляет их во
   временном файле** `<имя>.wav.<pid>-<n>.part` — сказать клиенту уже нечем
   (`SAVE` подтверждён давно), а стирать его десятки мегабайт из-за нашей
   неспособности переименовать хуже, чем оставить их лежать. Лечится
   настройкой `tci.record_dir` на обычную файловую систему.

На Windows нужной семантикой обладает `MoveFileW` без
`MOVEFILE_REPLACE_EXISTING`: существующее имя = отказ.

Провал записи (и занятое имя) убирает временный файл и не оставляет целевого;
единственное исключение — шаг 3 выше. Клиент, увидевший файл под запрошенным
именем, всегда вправе считать запись готовой.

**★Память рекордера — три замка, и все три нужны.** Авторизации в протоколе
нет (§3.1), поэтому «сколько памяти займёт одна строка из сети» — это вопрос
не об аккуратности, а о живучести процесса. Раньше `START` выделял буфер
целиком: 300 с × 48 кГц × 2 канала × int16 = 57.6 МБ на команду, а приёмников
1 + `MAX_SLICES` = 7, то есть 403 МБ семью строками, и держать их можно было
сколько угодно.

1. **Память набирается кусками по секунде, а не вся сразу.** `START` не стоит
   ни байта; молчащий, замьюченный или просто не звучащий приёмник не стоит
   ничего вовсе. Куски не перевыделяются (никакого `realloc` в DSP-потоке) и
   **не склеиваются вовсе**: `Take` отдаёт их писателю как есть, а тот пишет
   их в файл подряд. Сам `Take` идёт уже после того, как рекордер вынут из
   таблицы, то есть без DSP-потока на плечах.
2. **Общий бюджет `TCI_RECORD_MAX_BYTES` (128 МБ) на все рекордеры сразу.**
   Спрашивается при выделении каждого куска. Отказ не рушит запись: набранное
   остаётся сохраняемым, просто дальше она не растёт — иначе клиент терял бы
   уже записанное из-за чужой записи на другом приёмнике. ★`SAVE` бюджет не
   обходит **и не удваивает пик**: `Take` отдаёт писателю САМИ КУСКИ
   (`TTCIRecTake`) вместе с их местом в бюджете, а не сплошную копию. Копия
   была бы худшим из вариантов ровно там, где памяти меньше всего: при полном
   бюджете рядом жили бы 128 МБ кусков и 128 МБ копии, а счётчик показывал бы
   128. Куски и так лежат встык, поэтому `TTCIWavWriter` пишет их в файл
   подряд (хвост последнего за `Count` — не данные), а место отпускает в
   `ReleaseJob` — ровно один раз, сколько бы путей выхода ни было у записи.
   Пока писатель ждёт медленный или зависший сетевой каталог, эти байты
   остаются занятыми: потолок держит и очередь сохранений, а не только сами
   записи.
3. **Освобождение по трём событиям, а не по одному.** Раньше срок проверял
   только `Feed`, то есть DSP-поток — а к мёртвому приёмнику он не приходит
   никогда, и `START` на несуществующий номер оставлял память навсегда.
   Теперь: `START` требует **живого** приёмника (`RxActive`, ответ
   `receiver is not running` — как у потоков); тик сервера подметает истёкшие
   окна по часам (`SweepRecorders`, окно закрывается от `START` и без единого
   блока звука); уход клиента забирает его записи (`DropClientRecorders` —
   рекордер живёт на приёмнике, но платит за него тот, кто нажал `START`, иначе
   пары «подключился, `START`, отключился» набивали бы бюджет до потолка).

**★Имя файла: каталог всегда наш.** `LINE_OUT_RECORDER_SAVE` в §4.3 принимает
«полное имя файла», и раньше оно почти без изменений уходило в `fmCreate` —
то есть любой, кто дотянулся до порта (а bind наружу разрешён), писал файлы от
имени EWSDR куда угодно и затирал существующие. Теперь из строки берётся
**только имя файла** (`TCIRecordPath`), а каталог — настроенный каталог
записей (`tci.record_dir`, пусто = `<каталог конфигурации>/records`). Полный
путь на СЕРВЕРЕ клиенту всё равно бесполезен: файл ложится не на его машину, а
на нашу, поэтому каталог из просьбы отбрасывается молча — клиент получает
разумный файл, а не ошибку. Само имя проверяется: пусто, `.`, `..`,
управляющие символы, `:`, длиннее 120 и расширение не `.wav` — отказ
(`bad file name`). Выйти за каталог после `ExtractFileName` нечем:
разделителей в имени уже не осталось, а `..` не проходит проверку. Файл
создаётся **эксклюзивно** (`TCICreateNewFile`: `O_EXCL or O_NOFOLLOW` на Unix,
`CREATE_NEW` на Windows) — симлинк не уводит наружу, причём одним вызовом
ядра, без окна между `FileExists` и созданием; существующий файл не
перезаписывается и при публикации (`renameat2(RENAME_NOREPLACE)`/`link`/
`MoveFileW` — все без замены, см. выше). Клиенту про уже занятое имя отвечаем
`file exists` до постановки задачи писателю — но это вежливость, а не защита:
защита в самих вызовах.

**Потолок кадра.** Приёмный буфер соединения (`WsClient.WS_BUF_SIZE`) поднят
с 4 до 32 КБ: блок TX-аудио — это 64 байта заголовка плюс `data[16384]`, а
кадр крупнее буфера не собирается никогда (BufLen упирается в потолок и разбор
встаёт). Кадр длиннее одного блока рвёт соединение — длиннее протокол не
определяет.

**Переполнение.** У очереди команд и у кольца блоков разная политика: команду
терять нельзя (клиент выбрасывается), блок потока — можно и нужно (теряется
самый старый, `TTCIClient.BinDropped` считает). Иначе отставший скиммер рвал бы
себе и управление тоже.

### 2.6 Телеграф (§3.2)

`CW_MACROS`, `CW_MSG`, `CW_MACROS_STOP`, `CW_TERMINAL`, `KEYER`.

Текст приводится к тому, что понимает передатчик текста ewsdr (`CWXSend`):
экранирование `^ ~ *` снимается, `CALL$N` разворачивается в N повторов
позывного, префикс/суффикс `_` считаются пустыми. Подтверждение
`callsign_send` уходит **автору сообщения** и содержит позывной ДО развёртки
повторов (`RA6LH`, а не «RA6LH RA6LH»). ★Расхождение со спекой, осознанное:
документ шлёт эту команду по факту окончания передачи позывного, а наш
передатчик текста моментов внутри очереди не отмечает — подтверждаем сразу.
Доотправка всё равно не поддержана, так что финальный вариант известен уже
здесь и позже не изменится. **Не поддержано:** шаг
скорости внутри текста (`<` / `>`) и слитная передача аббревиатур (`|SK|`) —
эти символы просто снимаются, потому что `TCWSender` работает на одной
скорости и не знает прос-знаков. Доотправка позывного (`cw_msg:arg1;`)
игнорируется: уже отданный в очередь текст не редактируется.

**`KEYER` — чужой ключ (§4.3).** `KEYER:<передатчик>,<нажата>,<мс>` — не «нажми
сейчас», а **описание уже закончившегося интервала**. Документ задаёт это
алгоритмом: первое нажатие даёт `keyer:0,true,0`, отпускание —
`keyer:0,false,142` («посылка длилась 142 мс»), следующее нажатие —
`keyer:0,true,58` («пауза длилась 58 мс»). Отсюда правило перевода: тип
интервала — это состояние ключа **до** фронта, то есть обратное пришедшему;
`arg3 = 0` играть нечего, это лишь открытие передачи.

★Ключевое место — **почему нельзя дёргать ключ по приходу пакета**. Приход
говорит, что интервал кончился, а не сколько он длился: манипуляция «по
приходу» — это сетевой джиттер прямо в эфир, тот самый «пьяный матрос», ради
которого третий аргумент в протоколе и появился. Поэтому элементы становятся в
очередь и играются подряд по абсолютным дедлайнам (`TCWElemPlayer` в
`CWMorse.pas`): сумма длительностей равна времени у клиента, значит отставание
постоянно (сеть + один элемент) и не накапливается.

Дальше элементы уходят туда же, куда ключ оператора, — чужая манипуляция это и
есть прямой ключ, только с точными длительностями: у Pluto во вход прямого
ключа локального генератора (он сам поднимает сессию, рисует огибающую и
сайдтон), у openHPSDR в бит `CWX` прошивки (тем же путём идёт передача текста).
Гейт тот же, что у `CWXSend`, — `CWTXActive`: не телеграфный режим, RX-only
слот трансвертера или бэнд с `DoNotTx` — кейер разоружён, и трогать ключ
незачем. Передачу `KEYER` не поднимает: как и у текста, при включённом break-in
PTT даёт сама прошивка (или сессия локального генератора), при выключенном
оператор держит MOX сам.

Три оговорки, о которых честно:

- **очередь опустела — ключ отпускается**, и следующая пачка начинается с
  чистого дедлайна. Элементы всегда чередуются, так что искажения тут нет,
  зато оборвавшийся клиент не оставляет в эфире несущую;
- **паузы обрезаются** `TCI_KEYER_GAP_MAX_MS` = 1 с, посылки —
  `TCI_KEYER_MARK_MAX_MS` = 5 с. Пауза целиком прибавляется к отставанию от
  клиента; всё, что дольше секунды, — это «оператор задумался», и честнее
  догнать реальное время, чем тащить его дальше. Посылка длиннее пяти секунд —
  уже не телеграф, а залипший ключ;
- **обрыв — общий с текстом**: касание манипулятора, снятие MOX, уход из
  телеграфа гасят чужую манипуляцию тем же `CWXAbort`. Оператор всегда сильнее
  сети.

`arg1` проверяется, как у `TRX` (не число или вне `TRX_COUNT` —
`bad receiver`; номер в потолке, но пана под ним нет — `receiver is not
running`), а сам телеграф уходит **текущему TX-источнику** — ровно так же, как
`CW_MACROS`. Захват (§3.5) общий с `TRX`: передатчик один, и ключ держит тот
же, кто держит эфир.

### 2.7 Кто именно уходит в эфир (`TRX`, `TUNE`)

★`arg1` у этих двух команд — **номер передатчика**, и он не декорация. Клиент
живёт на своём приёмнике: скиммер на пане 2, цифра на пане 1, логгер на
главном. Нажимая передачу, он просит эфир **своему** слайсу — а не тому,
который оператор выбрал мышкой минуту назад. Пока номер игнорировался,
`trx:2,true` уводил в эфир чужой слайс: другая частота, а с кросс-бандовым
мультислайс-TX — и другой диапазон, то есть чужие антенна и фильтры. Клиент об
этом не узнавал никак (ответ всегда говорил `trx:0,…`), а оператор видел
передачу там, где её никто не просил.

Как устроено теперь:

- номер разбирается и проверяется, как у всех остальных команд:
  не число или вне `TRX_COUNT` — `tci_error … bad receiver`; номер в потолке,
  но пана под ним нет — `receiver is not running`. Свести такую просьбу к
  главному VFO нельзя: молча передать не на той частоте хуже, чем отказать;
- **приёмник 0** — это «радио целиком», то есть главный VFO. Ведём себя как
  обычный CAT-порт: в эфир уходит выбранный оператором TX-источник;
- **приёмник N > 0** адресует слайс слота N−1, и заявка идёт через
  `TRadioController.RequestSliceTx` — ту же дверь, что у CAT-порта слайса
  (см. `doc/CAT_SLICES.md`). Оттуда бесплатно достаются оба правила: «в эфире
  одновременно только один» (чужую передачу не перехватываем) и уважение к
  флагу **Auto TX** (без него слайс сам себя источником не назначает);
- `TUNE` ходит той же дорогой — `RequestSliceTx(…, Tune := True)`: несущая
  настройки обязана вставать на тот же слайс, что и модуляция;
- **ответ автору называет ЕГО номер приёмника**, а состояние в нём — «в эфире
  именно твой слайс» (`FTransmitting and (TxRx = Rx)`). Так надо потому, что
  клиенты могут фильтровать входящие строки по `arg1`, и отказ, названный
  чужим номером, до
  него бы просто не дошёл. В **рассылку** уходит номер того, чей слайс сейчас
  источник передачи (`TxRx` по `TxSliceId`): клиент на приёмнике 1 по ней
  видит, что в эфире не он. Смена TX-источника оператором доходит до клиентов
  сама: `SetTxSlice` заканчивается `Changed(rfTransmitting)`;
- ключ захвата (§3.5) — **один на передатчик**, `TRX/0/0`: за него спорят и
  `TRX`, и `TUNE`, и изменения от оператора (`rfTransmitting`, `rfTuning`).
  Отдельный ключ `TUNE` делал защиту дырявой — TUN поверх чужого MOX не спорил
  ни с кем;
- **чужую передачу не трогаем вовсе.** Пока передатчик занят (оператор, другой
  клиент, аппаратная PTT), `TRX:…,true` и `TUNE:…,true` не делают ничего — то же
  правило, что внутри `RequestSliceTx`, только теперь оно работает и для
  приёмника 0. Дырок было две: `SetMOX(True)` **не выходит рано** на уже идущей
  передаче — он заново выбирает источник модуляции, и `trx:0,true,tci` посреди
  передачи оператора уводил её в ринг TCI (а уход клиента оставлял в эфире
  несущую с тишиной: хозяином он при этом не становился, снимать было некому);
  `tune:0,true` таким же образом подмешивал настроечный тон в чужую передачу и
  оставался в эфире после ухода;
- хозяином эфира (`FTrxOwner`, чей уход эфир снимает) клиент записывается,
  только если передача началась **именно от его команды**. Знает об этом лишь
  сам Sync-метод — он один видит состояние передатчика до и после, на потоке
  контроллера. Снимок «шла ли передача», взятый в потоке клиента до `Invoke`,
  врал бы: между разбором команды и её исполнением оператор успевает нажать
  PTT, и хозяином стал бы не тот. Поэтому решение принимается внутри, а наружу
  возвращается один флаг (`FsRes`, под тем же `FLock`, что и аргументы);
- по той же причине отказ откатывает `TCIMicRequested`: иначе следующая PTT
  оператора ушла бы в эфир с микрофоном ушедшего клиента.

Попутно на этой дороге нашлась Access violation, не связанная с TCI:
`SendDUCSpecificFromSettings` трогала `FNetwork` без `Assigned`, а зовут её по
любому PTT/TUN (`SetMOX` → `SyncCWKeyer` → она), в том числе **до подключения
устройства**. В GUI падение маскировалось, у TCI ошибку глотал обработчик
команды — и «передача» жила только в ответе клиенту. Теперь там обычная
проверка, как в остальных двенадцати местах контроллера.

---

## 3. Осознанные заглушки («эхо»)

Значения принимаются, хранятся в адаптере и рассылаются клиентам — так
синхронизация между несколькими клиентами остаётся честной, но на радио они
не влияют, потому что соответствующего тракта в EWSDR нет:

| Команда | Причина |
|---|---|
| `RIT_ENABLE`, `RIT_OFFSET`, `XIT_ENABLE`, `XIT_OFFSET` | расстройки RX/TX в контроллере нет вообще |
| `RX_BIN_ENABLE` | псевдостерео не реализовано |
| `RX_ANC_ENABLE`, `RX_APF_ENABLE`, `RX_DSE_ENABLE`, `RX_NF_ENABLE` | таких блоков в тракте нет |
| `RX_NB_PARAM` | параметры NB в WDSP наружу не выведены |
| `RX_BALANCE` | баланса каналов у слайса нет |
| `DIGL_OFFSET`, `DIGU_OFFSET` | смещения цифровых мод не реализованы |
| `RX_CHANNEL_ENABLE` | канал B главного приёмника — это VFO B, он есть всегда; создание второго слайса на пане по TCI не делаем (см. §4) |

**Почему именно заглушки, а не «сделать по-быстрому».** Правило одно: *TCI не
заводит в радио состояние, которого оператор не видит на экране и не может
снять*. Клиент подключается и отключается когда ему вздумается, а оставленный
им перекос остаётся в аппарате навсегда — искать его будет человек, у которого
нет ни кнопки, ни индикации. Поэтому настоящими сделаны ровно те параметры, у
которых в интерфейсе есть орган управления (`NR`/`NB`/`ANF`, АРУ, фильтр,
громкость, squelch, скорость CW), а остальные приняты, сохранены и разосланы
ради честной синхронизации клиентов между собой — и только.

Дальше — построчно, потому что причины у них разные:

- **`RIT`/`XIT`.** Расстройка — не кнопка, а сквозной сдвиг: `VFO` → DDC/DUC,
  панорама, флаг слайса, CAT, каналы памяти. Сделанная **только** ради TCI, она
  сразу разошлась бы с тем, что показывает экран и что отвечает CAT-логгеру, —
  и это худший вид ошибки в аппарате: слышно одно, написано другое. Заводить
  надо целиком, как фичу радио, тогда TCI получит её даром.
- **`RX_BIN_ENABLE`, `RX_BALANCE`.** Единственные два, где тракт-то есть:
  `SetRXAPanelBinaural` и `SetRXAPanelPan` в биндингах WDSP лежат готовые. Но в
  EWSDR нет ни органа управления, ни поля в профиле, ни персиста, — включённое
  клиентом псевдостерео или уехавший баланс оператор не найдёт чем вернуть.
  Порядок обратный: сперва UI, потом TCI.
- **`RX_ANC_ENABLE`, `RX_APF_ENABLE`, `RX_DSE_ENABLE`, `RX_NF_ENABLE`.** Этих
  узлов в тракте нет физически. У нас `NR`/`NR2` (EMNR), `NB`/`NB2` (ANB/SNBA)
  и `ANF` — они в TCI настоящие. Для `ANC`, `DSE`, `APF` и `NF` потребовались
  бы новые узлы обработки в цепочке WDSP.
- **`RX_NB_PARAM`.** Пороги шумодава у нас — подобранные константы
  `FILTER_NB_*`, одинаковые для всех каналов и не выведенные даже в интерфейс.
  Клиент, покрутивший их по TCI, оставил бы шумодав расстроенным насовсем.
- **`DIGL_OFFSET`, `DIGU_OFFSET`.** `DIGU`/`DIGL` у нас — обычные SSB-моды со
  своим фильтром, отдельного смещения в тракте нет. Введённое ради одной
  команды, оно опять развело бы частоту на панораме с той, что слышно.

## 3.1 Ограничения, о которых честнее знать заранее

| Что | Как ведёт себя |
|---|---|
| `AGC_GAIN` у приёмника > 0 | AGC-T в ewsdr один на приёмный тракт, у слайса своего нет. Команда от имени доп. приёмника **игнорируется** (раньше молча правила главный), в ответ уходит текущее значение |
| `DDS` у приёмника-слайса | только читается (центр его панорамы): панорама под слайсом общая, и увести её по просьбе одного клиента значит утащить соседей по пану и картинку оператора. Слайсу двигаться незачем — за окном DDC следит `TuneSliceInBand` |
| Панорама без слайсов | в TCI не видна вовсе: приёмник = слайс. Слушать там нечего, но и IQ такой панорамы клиенту недоступен |
| Цвет спота (`SPOT`, arg4 ARGB) | не читается: `TDXSpot` цвета не хранит, подписи красятся по моде/возрасту |
| `TX_FOOTSWITCH` | не реализована — см. ниже под таблицей (`KEYER` сделан, см. §2.6) |
| Канал B | есть только у приёмника 0 (VFO B). У приёмника-слайса канал один: слайс — это и есть «приёмник» целиком, со своими модой, фильтром, АРУ, шумодавами и потоками |
| Захват параметра (§3.5) | реализован для того, что клиенты действительно перетягивают (частота, DDS, мода, фильтр, TRX/TUNE/DRIVE, split, громкости, АРУ, шумодавы, squelch, скорость CW). Эхо-параметры (RIT/XIT, BIN/ANC/…) не захватываются: на радио они не влияют |
| Браузерные клиенты | отвергаются по `Origin` (403), см. §1.1. Web-интерфейсу ewsdr TCI не нужен — у него свой канал |
| `TRX_COUNT` | равен потолку 1 + `MAX_SLICES` = 7, а не числу живых приёмников: протокол объявляет его один раз. Про несуществующий приёмник просто ничего не шлётся (§2.1) |
| Потоки на передаче | RX-аудио и линейный выход на TX замолкают — движок не зовёт аудио-колбэки, пока идёт передача (кроме дуплекса с самоконтролем). То же и с IQ: RX-пакеты на TX дропаются на входе DSP. Это поведение приёмного тракта, а не потоков |
| `MUTE` и линейный выход | глушит и поток: движок под мьютом не зовёт `OnAudio`. Аудиопоток приёмника (`AUDIO_START`) мьют не трогает — он снимается до громкости |
| DIGL/DIGU, 2 канала | по §3.4 в цифровых модах два канала должны нести комплексный сигнал; у нас это обычное стерео с выхода WDSP — оба тапа стоят там, где сигнал уже вещественный: `rakDemod` сразу за демодулятором, `rakLineOut` за громкостью. Комплексный выход потребовал бы отдельной ветки панели RXA и третьего маршрута тапа, а цифровые клиенты берут `IQ_START` — он честно комплексный |
| Поток канала B | не бывает: в протоколе аудиопоток один на приёмник, и он всегда про канал A |
| Формат IQ | всегда float32, два канала — `AUDIO_STREAM_SAMPLE_TYPE` относится к аудио (§4.3), а TCI IQ иначе и не шлёт |
| `IQ_SAMPLERATE` 384 кГц на Pluto 576/960 кГц | нацело не делится, поэтому уходит 192 кГц (см. §2.5). Клиент обязан читать частоту из заголовка блока, а не считать её равной запрошенной |
| MP3 у рекордера | не поддержан: кодера в проекте нет, а тащить внешний (lame) ради рекордера — это новая зависимость и её лицензия в сборке, которых у ewsdr сейчас нигде нет. WAV пишется без потерь и открывается всем; на `.mp3` уходит честный `tci_error`, а не молчаливый WAV с чужим расширением |

**`TX_FOOTSWITCH` — почему его нет.** (`KEYER` был в этом же пункте и теперь
сделан — см. §2.6: очередь элементов по `arg3`, а не проброс сетевых фронтов.)

`TX_FOOTSWITCH` шлёт **сервер**, и это просто не выведено: флаг у контроллера
уже есть — `HPS_PTT` из HP-статуса ловится по фронту (`FHPpHWPTT` →
`FHWPTTActive`), — но события наружу нет. Плюс две оговорки, из-за которых
уведомление было бы полуправдой: бит не отличает педаль от тангенты микрофона,
а у Pluto такой линии нет вовсе. Работы тут — новый `rf`-эвент контроллера и
строчка в адаптере.

Отдельно: у `TX_SENSORS` второй аргумент — уровень микрофона; измерителя
микрофона в EWSDR нет, шлём нижнюю границу шкалы (-60 дБм), чтобы клиент не
рисовал случайные значения. Третий аргумент (RMS) и четвёртый (пик) отдаём
одинаковыми — в телеметрии платы одно значение forward power.

У `TRX` третий аргумент разобран только для `tci` (см. §2.5). Значения
`mic1`/`mic2`/`micpc`/`ecoder2` называют физические входы TCI, которых у
нас нет: они значат «микрофон, выбранный в программе», то есть ровно то же, что
и отсутствие аргумента.

---

## 4. Что осталось

Бинарные потоки (§3.4) реализованы целиком — см. §2.5. Открыто:

1. **`RX_CHANNEL_ENABLE` как реальное создание/удаление второго слайса пана.**
   Сейчас это эхо, и причин две. Первая техническая: слайс **заводит MainForm**
   (`AddSliceAtFreq` + `CreateSliceFlag` — флаг, раскладка, персист), у
   контроллера есть только `RemoveSlice`. Адаптер TCI, как и CAT, до MainForm
   не дотягивается и не имеет права: он обязан собираться в демоне, где LCL
   нет вовсе. Вторая по существу: канал B у TCI — второй приёмник внутри
   того же DDC, а у нас слайс — полноценная сущность со своим флагом, звуком,
   входом и CAT-портом. Клиент, «выключивший канал B», снёс бы оператору
   рабочий слайс вместе с его модой, фильтром и портом. Правильный порядок:
   сперва завести создание слайса в контроллер (от этого выиграет и демон), и
   только потом привязать к нему команду.
2. **TCI в демоне.** Юниты LCL-free (стенд собирает и гоняет их вместе с
   `TRadioController` без единого виджета), подключается одной строкой в
   `ewsdrd.lpr`, как web. Не сделано намеренно и в этом порядке: в headless
   сперва попадает то, что уже прошло живого клиента в GUI, где отказ виден
   глазами. Цена ошибки в демоне выше — грабля с `SIGPIPE` (см. конец §5)
   убивала именно `ewsdrd`, целиком и молча.
3. **`TX_FOOTSWITCH`** — новый `rf`-эвент контроллера и строчка в адаптере,
   см. §3.1 (флаг `FHWPTTActive` уже есть, наружу не выведен).
4. **Проверка `KEYER` на железе** — код прошёл стенд, живого ключа по сети ещё
   не было.

## 5. Проверено

Стендом (WS-клиент на сыром сокете, без внешних библиотек):

- **Транспорт:** строгий разбор bind-адреса и отказ подниматься на кривом;
  первый кадр, приклеенный к пакету handshake; сборка фрагментированного
  сообщения; несколько команд в одном кадре; отказ от незамаскированных кадров
  с разрывом соединения; ping/pong; рассылка двум клиентам; чистая остановка с
  живыми клиентами; медленный клиент (5000 рассылок не блокируют вызывающего,
  клиент вылетает сам); остановка сервера в тот момент, когда команда клиента
  висит в `Synchronize` у потока контроллера.
- **Сквозной прогон** с настоящим `TRadioController` (движки созданы, железо не
  подключено): пачка инициализации со всеми обязательными
  командами и `READY` в конце; `VFO`, `MODULATION` (`cw` на 7 МГц дал CWL),
  `RX_FILTER_BAND`, `DRIVE`, `VOLUME`, `MUTE`, `AGC_GAIN`, `LOCK`,
  `CW_MACROS_SPEED`, `SPOT`, `RIT_*` — состояние контроллера после прогона
  совпало с посланным; чтение (`vfo:0,1;`) отвечает; `RX_SENSORS_ENABLE:true,100`
  даёт поток `rx_channel_sensors` с заданным периодом.
- **Устойчивость:** исключение в обработке команды не рвёт соединение — клиенту
  уходит `tci_error:<команда>,<сообщение>;`, остальные команды продолжают
  работать (проверено на контроллере без движков, где `SetMode`/`SetCWSettings`
  падают с AV на неинициализированном `FNetwork`).

Прогон после ревизии (49 проверок, все зелёные):

- **Протокол:** строгий разбор (`abc`, пустой аргумент, переполнение Integer,
  дробная запись), списки частот потоков и форматов сэмплов, построчный разбор
  HTTP-заголовков.
- **Транспорт:** 101 на заголовки с табуляцией и без пробела после двоеточия;
  отказ 403 при `Origin`; ровно восемь поднявшихся клиентов и 503 девятому;
  освобождение слотов после отключения — **обоими** путями, и штатным
  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 мс и перебивает
  позже); `iq_samplerate:44100` отвергается, `iq_start` отвечает ошибкой;
  в пачке состояния есть `tx_frequency`, `app_focus`, поканальный `vfo_lock`
  и нет частот несуществующего пана; отказ применения настроек (кривой адрес,
  занятый порт) возвращает прежний работающий слушатель.
- **Остановка под `Synchronize`:** команда клиента висит в очереди главного
  потока, `Ad.Free` в этот момент — сервер прокачивает очередь, команда
  доисполняется, зависания нет.

Прогон после второй ревизии (60 проверок, все зелёные) добавил к этому:
проверку UTF-8 (обрыв, избыточная форма, суррогат — и то же кадром в эфире),
отказ на однобайтовый close, `OnDisconnect` при остановке сервера, отбраковку
частоты за пределами `VFO_LIMITS` (в том числе `DDS`), переобъявление
`vfo_limits` после появления устройства (в том числе когда радио пропадало и
возвращалось, пока клиентов не было) и изоляцию медленного клиента: четыре
тысячи рассылок в молчащий сокет не мешают соседу получить ответ быстрее
секунды. Всего 73 проверки — добавились отбраковка частоты и центра живыми
границами, когда снимок ещё разрешает (устройство «пропало» без событий),
рассылка `tune_drive`, `mon_volume` и
`split_enable` соседнему клиенту, адресность `VOLUME` на передаче с
самоконтролем, устойчивость к падению сеттера CW (на стенде `SetCWSettings` без
движков и сети валится с AV — клиент получает `tci_error`, соединение живо) и
неразрывность пачки инициализации под крутящейся ручкой.

### Стенд этапа 2 (бинарные потоки) — 244 проверки, все зелёные

Отдельная программа (`test/tci/tcitest.pas`, прогон — `test/tci/run.sh`,
внешних библиотек не требует) проверяет потоки на четырёх уровнях:

- **Формат и математика:** коэффициенты прореживания (в том числе «просят выше,
  чем есть» и «нацело не делится»), умолчания размера блока, поля заголовка,
  сквозная упаковка/распаковка всех четырёх форматов сэмплов и клип на
  перегрузе, разбор пути с `|` вместо `:`.
- **Пересчёт частоты:** единичное усиление дециматора на постоянке, тон 1 кГц
  проходит, тон 20 кГц при 48→12 кГц давится больше чем на 40 дБ (то самое
  зеркало, ради которого и стоит фильтр), прозрачность при коэффициенте 1,
  счёт отсчётов у интерполятора и отсутствие выбросов. Отдельно — каскад на
  всех интересных коэффициентах (192→48, 384→48 у openHPSDR; 576→48, 1536→48,
  5760→48 и 5760→384 у Pluto): полоса ровная, зеркало давится больше чем на
  60 дБ. И выбор законной частоты IQ: 576 и 960 кГц на просьбу «384» отдают
  192 кГц, 768 кГц отдаёт 384 кГц, чужой rate возвращает просьбу как есть.
- **Блок до сокета** (пара сокетов вместо сети): четыре блока RX-аудио 12 кГц
  стерео float32 с верным заголовком и длиной; IQ 384→48 кГц уходит только
  целым блоком (полблока не отправляется); моно int16; пересчёт после смены
  rate источника; переполнение кольца теряет старые блоки, но клиент жив.
- **Рекордер и WAV:** буфер ограничен запрошенным временем и пишется с начала
  окна (переполнение отбрасывает новое, а не затирает старое), до срока запись
  есть, после срока её нет и истёкший рекордер не оживает, `Take` завершает
  запись, файл получает верные RIFF/fmt/data и длину. ★Память: `START` не
  выделяет ни байта, бюджет растёт кусками по мере звука и возвращается по
  `Free`, потолок берётся целиком и сверх него следует отказ, при отказе запись
  не рушится, окно истекает по часам **без единого `Feed`** и отдаёт память
  само, `Take` отдаёт САМИ КУСКИ (2.5 с записи = три куска по секунде, а не
  один свёрнутый — сплошной копии не появляется ни на миг), счёт бюджета при
  этом не меняется, склеенные куски дают непрерывный звук, и писатель
  возвращает резерв по окончании записи. ★Писатель: задание принимается и
  дописывается очередью, после удачи временного файла рядом не остаётся,
  неудачная запись (счёт обещает больше, чем есть в кусках) не оставляет НИ
  файла, НИ `.part`, занятое имя `.part` (файл от прошлого запуска с тем же
  pid) записи не теряет, за всё время записи 32 МБ целевое имя ни разу не видно
  незаконченным, сверх потолка заданий следует отказ, после `Close` заданий не
  берут, а `TCIStopWriter` дожидается 32-МБ записи — файл цел сразу после его
  возврата, и **хвост очереди за ней дописан тоже** (это и есть проверка на
  обрыв при выходе; без ожидания краснеют обе).
  ★Имя файла: простое имя ложится в каталог записей, а каталог из
  просьбы отбрасывается — абсолютный путь, `..` и буква диска наружу не
  выводят; пусто, `..`, не-`.wav`, управляющий символ и отсутствие каталога
  записей дают отказ; существующий файл писатель не перезаписывает.
- **Чужая манипуляция** (`TCWElemPlayer`, отдельная часть стенда): интервалы
  играются подряд и **их длительности сохраняются** (посылка 150, пауза 60,
  посылка 150 — по фронтам ключа с допуском 30 мс на планировщик), на пустой
  очереди ключ отпускается, следующая пачка после простоя играется целиком (а
  не «догоняет» старый дедлайн), обрыв гасит очередь и отпускает ключ, нулевая
  длительность не порождает ни одного фронта.
- **Команды на живом сервере** (настоящий `TRadioController`, WS-клиент на
  сыром сокете): отказ на несуществующий приёмник и на нечисловой аргумент,
  отказ на старт потока с незапущенного пана, подтверждение и отбраковка
  параметров, `SAVE` без записи и `SAVE` в `.mp3` отвечают ошибкой,
  `LINE_OUT_RECORDER_START` на мёртвом приёмнике и с чужим номером отвечает
  ошибкой и **не стоит памяти**, `SAVE` с чужим путём отбивается по имени;
  запись, начатая ушедшим клиентом, освобождается вместе с ним (видно насквозь
  по общему бюджету в части E);
  `TRX:0,true,tci` без аудиопотока модуляцию не берёт, а с потоком берёт,
  реально поднимает передачу и снимает её по `TRX:0,false` и по уходу клиента;
  чужой бинарный блок не рвёт соединение; без передачи маркеров `TX_CHRONO`
  нет. Номер передатчика (§2.7): `trx:9`, `trx:abc` и `tune:9` отвечают ошибкой
  и **не поднимают эфир**, приёмник без живого пана — тоже; ответ называет
  передающий приёмник; у `KEYER` номер разбирается так же (`keyer:9`,
  `keyer:abc`, незапущенный приёмник и нечисловое состояние — ошибка), первое
  нажатие (`arg3 = 0`) не ошибка и ничего не играет, а `keyer:0,true,<мс>`
  ключ **не** замыкает (это пауза) в отличие от `keyer:0,false,<мс>`; `TUNE` через TCI включается и гасится, не оставляя за
  собой ни тона, ни поднятой PTT. Чужая передача: поверх MOX оператора `TRX`
  не уводит микрофон и не снимает передачу, `TUNE` не включает тон, а уход
  такого клиента передачу оператора не гасит. Конкурирующий TCI-клиент
  не получает `TX_CHRONO` чужой передачи, а его уход не снимает эфир
  первого клиента. Отдельно — согласование
  частот: на источнике 192 кГц просьба «384» подтверждается как 192, смена
  rate устройства сама переобъявляет и `iq_samplerate`, и `if_limits`, а на
  576 кГц (Pluto) та же просьба даёт законные 192 кГц.
- **Слайс как приёмник** (часть E, живой движок): созданный на ГЛАВНОМ пане
  слайс становится приёмником 1, отвечает на `vfo:1,0;` своей частотой,
  слушается командой `vfo:1,0,<Гц>`, отдаёт своё
  аудио блоками с `receiver = 1`, ★по `trx:1,true,tci` берёт модуляцию из TCI
  (то есть просьбу не стирает `Changed(rfTransmitting)` из `SetTxSlice`) и шлёт
  маркеры `TX_CHRONO` **под номером 1**; а `trx:0,true,tci` поверх этой же передачи
  (команда-пустышка) номер приёмника в маркерах не меняет, а после удаления слайса приёмник 1 замолкает
  целиком — вместо прежнего `vfo:1,0,0`.
- **Сквозной прогон через живой WDSP:** синтетический 24-битный IQ подаётся
  в движок, а клиент по WebSocket получает блоки RX-аудио 12 кГц и IQ 48 кГц
  с верными заголовками; после `AUDIO_STOP`/`IQ_STOP` блоки прекращаются.
  Передача: `TRX:0,true,tci` поднимает маркеры `TX_CHRONO`, а присланное
  клиентом аудио 12 кГц доходит до конца тракта (появляются блоки TX-IQ).

Чего стенд этапа 2 не проверяет: доп. паны (для них нужен живой DDC),
одновременную работу нескольких клиентов на одном потоке и длительный прогон
(дрейф пейсинга TX_CHRONO виден только на минутах).

Чего стенд не проверяет: поведение пана без слайсов и рассылку каналов при
создании/удалении слайса — для них нужен живой DSP-движок, которого на стенде
нет. Остаётся и известное окно: показания измерителей читают `FDSPEngine` из
тик-потока, и смена устройства в этот момент теоретически может застать его уже
освобождённым (та же схема, что у web- и CAT-подсистем).

Попутно стенд поймал ещё одно: запись в сокет, закрытый клиентом, приносила
SIGPIPE, а он по умолчанию убивает процесс (в GUI сигнал гасит виджетсет, а
демону гасить некому). `WebUtils.SockSend` теперь шлёт с `MSG_NOSIGNAL` —
send просто возвращает EPIPE, и клиент выбрасывается штатно. Правка общая
с web-подсистемой: пишет в сокеты клиентов она тем же вызовом.

Сборка: `lazbuild -B --ws=qt6 ewsdr.lpr` и `./build-ewsdrd.sh` (демон собирается,
TCI в его граф пока не заведён — юниты LCL-free, подключается одной строкой в
`ewsdrd.lpr`, как web).

★Про сборку стенда — `test/tci/README.md`: там записаны обе грабли (обязательный
`-Mobjfpc` и отдельный каталог `.ppu` с абсолютным путём, иначе линковка тянет
`PlatformUtils` из GUI-сборки, собранный с LCL).

**На реальном железе и с реальным клиентом (Log4OM/N1MM/WSJT-X/CW Skimmer) не
проверялось — ни команды, ни потоки.**

Ответ на команду-установку клиент получает дважды: прямым ответом и рассылкой
из `OnState`. Это осознанно — дубли идемпотентны, а рассылка нужна для тех
случаев, когда значение поменял не клиент, а оператор.
