{
  Copyright (C)
  2026 - Uladzimir Karpenka, EW8BAK

  This program is free software; you can redistribute it and/or
  modify it under the terms of the GNU General Public License
  as published by the Free Software Foundation; either version 2
  of the License, or (at your option) any later version.

  This program is distributed in the hope that it will be useful,
  but WITHOUT ANY WARRANTY; without even the implied warranty of
  MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
  GNU General Public License for more details.

  You should have received a copy of the GNU General Public License
  along with this program; if not, write to the Free Software
  Foundation, Inc., 59 Temple Place - Suite 330, Boston, MA  02111-1307, USA.
}

unit RadioController;

{
  EWSDR — Radio Controller (headless ядро)

  Назначение
  ----------
  TRadioController — «мозг» трансивера БЕЗ привязки к UI/LCL-формам. Владеет
  состоянием радио и движками (сеть HPSDR, WDSP, аудио, настройки, каналы) и
  предоставляет единый интерфейс управления для ЛЮБОГО фронтенда: GUI, Web, CAT,
  аппаратная панель Arduino.

    • Команды (SetMode, SetVfoA, TuneActiveBy, StartStop, …) — единая точка входа.
      Абсолютные (Set*) удобны UI/Web; относительные (*By/Up/Down) — энкодерам.
    • GetSnapshot → TRadioSnapshot — весь стейт одной записью (синхронизация
      нового фронтенда при подключении: LED/дисплеи панели, веб-клиент).
    • OnStateChanged(Field) — гранулярное уведомление об изменении поля; все
      фронтенды остаются синхронны, кто бы ни инициировал изменение.

  Маршалинг потоков
  -----------------
  Команды из сетевых/serial/web-потоков идут через OnInvoke (GUI = обёртка над
  TThread.Synchronize; демон = своя очередь/прямой вызов), чтобы вызовы движка
  шли из одного потока без зависимости от GUI-цикла LCL.

  СТАТУС МИГРАЦИИ (Фаза 1)
  ------------------------
  Состояние и движки вынесены сюда как ПУБЛИЧНЫЕ ПОЛЯ с теми же именами, что были
  в TMainForm (FVfoA, FNetwork, FDSPEngine, …), чтобы проводка MainForm свелась к
  механической замене FXxx -> FController.FXxx без изменения поведения. Логика
  операций (ApplyModeFilter, BtnStartStopClick, ApplyVfoA, …) пока остаётся в
  MainForm; команды контроллера обновляют поле + шлют событие и будут наполнены
  логикой на следующих фазах (помечено TODO-wiring). Поля станут приватными,
  когда MainForm перестанет писать в них напрямую.

  ВЛАДЕНИЕ ДВИЖКАМИ (Фаза 1): объекты создаёт и освобождает MainForm (в
  FormCreate/FormDestroy), сохраняя в поля контроллера. Перенос создания в
  Create/Destroy контроллера — отдельная фаза (нужен для режима демона).

  FPC 3.2+ / режим Delphi. Без inline var и анонимных процедур.
}

{$IFDEF FPC}
  {$MODE Delphi}
{$ENDIF}

interface

uses
  Classes, SysUtils, Math, SyncObjs,
  HPSDRProtocol, HPSDRNetwork, RadioBackend, PlutoBackend, IIOBindings,
  WDSPEngine, AudioOutput, AudioInput, BeaconDecoder, BeaconFEC, DMRDecoder,
  Settings, ChannelStore, FMRepeater, BoardUtils, DeviceStore, CWMorse, CWKeyer,
  CWDecoder;

// Панадаптеры (этап 3): потолок MAX_PANS живёт в WDSPEngine (общий для
// движка/контроллера/UI). Фактический лимит бэкенда — Caps.MaxPans (Pluto=1).
// Допустимые rate пан-DDC — полная сетка железа Caps.RatePresets (кратные
// 48 кГц — требование движка), см. PanRateAllowed. Дефолт/фолбэк — 384 кГц.

type
  // QO-100 beacon lock — статус контура (для UI/маркеров).
  TBeaconState = (bsIdle,     // лок выключен
                  bsSearch,   // включён, маяк ещё не подтверждён (низкий prominence)
                  bsLock);    // маяк уверенно виден и сопровождается

  // Поле, которое изменилось — для гранулярных уведомлений наружу.
  TRadioField = (
    rfVfoA, rfVfoB, rfActiveVfo, rfVfoLock,
    rfMode, rfFilter, rfFilterBW,
    rfAGCMode, rfAGCTop,
    rfRxGain,                        // Pluto: hw-gain (auto/manual dB)
    rfVolume, rfMute, rfDrive, rfAtten,
    rfBand, rfXvtr, rfBandRestore,   // rfBandRestore: явная смена диапазона (сброс Wf-avg в UI)
    rfCenterFreq, rfSpan, rfSampleRate, rfZoom,
    rfCTun, rfDuplex, rfRxMuteOnTx,   // rfRxMuteOnTx: QO-100 self-monitor toggle
    rfRunning, rfConnected, rfTransmitting, rfTuning,
    rfPureSignal, rfTwoTone,         // PureSignal вкл/статус; 2TON вкл/выкл
    rfNR, rfNB, rfSNB, rfANF,
    rfWfAGC, rfWfNF, rfDither, rfRandom,
    rfFMDeviation, rfFMCTCSS, rfFMCTCSSTone, rfFMSQ, rfFMSQLevel,
    rfFMStep, rfFMStepIdx, rfFMRpt,
    rfChannel,
    rfTXProfile,                                 // сменился активный TX-профиль
    rfSMeter, rfPower, rfSWR, rfSupply, rfPLL,   // телеметрия
    rfADCOverload,                               // ADC overload (рендер индикатора)
    rfBeaconLock,                                // QO-100 beacon lock: статус/коррекция
    rfDevice,                                    // подключённое устройство сменилось
    rfDeviceList,                                // список discovered устройств обновился
    rfPanFreq,                                   // центр доп. пана уехал (ретюн DDC извне)
    rfSliceFreq,                                 // слайс перестроен извне (CAT); Id — FSliceFreqId
    rfSliceState,                                // у слайса сменились мода/фильтр/АРУ/DSP/громкость; Id — FSliceFreqId
    rfCWSettings,                                // правка телеграфа (скорость, задержка, pitch…)
    rfMonVolume                                  // громкость self-monitor'а (TX), отдельно от rfVolume
  );

  TRadioStateEvent = procedure(Sender: TObject; Field: TRadioField) of object;

  // Маршалинг произвольного метода в поток контроллера (GUI: TThread.Synchronize).
  TUIInvokeEvent = procedure(M: TThreadMethod) of object;

  // Перехват RX-аудио потребителем (web-адаптер): вернуть True, если аудио
  // отправлено клиенту и НЕ должно идти в локальный AudioOut. Вызывается из
  // DSP-потока (как сам OnAudioReady) — реализация должна быть thread-safe.
  TAudioConsumeEvent = function(const Left, Right: array of Single;
                               Count: Integer): Boolean of object;

  // Тап RX-аудио: «послушать, не забирая», в отличие от OnAudioConsume. Нужен
  // потокам TCI (§3.4): их может быть несколько, и ни один не имеет права
  // отбирать звук у локальной звуковухи или у web-клиента.
  //   rakDemod   — ДО громкости и мьюта, без сайдтона (аудиопоток приёмника);
  //   rakLineOut — то, что реально уходит на выход (поток линейного выхода).
  // PanId: 0 = главный тракт, 1.. = доп. пан. SliceId — слайс-источник
  // (0 у главного тракта): по нему потребитель отличает канал A от канала B.
  // Вызывается из DSP-потока: обработчик обязан быть быстрым и не ждать.
  TRadioAudioKind = (rakDemod, rakLineOut);
  TRadioAudioTapEvent = procedure(Kind: TRadioAudioKind; PanId, SliceId: Integer;
                                  const Left, Right: array of Single;
                                  Count: Integer) of object;

  // Сырьё спектра/водопада наружу (фронтенды рисуют). Вызывается из DSP-потока.
  TPixelDataEvent = procedure(const Pixels: array of Single; Count: Integer) of object;

  // Лента телеграфа для окна-терминала: прибавка текста и чья она — принятая
  // декодером или эхо собственной передачи (окно красит их по-разному).
  // Зовётся из потока хозяина (ServiceCWDecoder), не из DSP.
  TCWLogEvent = procedure(const S: string; IsTX: Boolean) of object;

  // Снимок состояния для синхронизации нового фронтенда (Arduino/Web на connect).
  TRadioSnapshot = record
    VfoA, VfoB, CenterFreq, SpanHz: Double;
    SampleRate: Integer;
    ActiveVfo: Integer;
    Mode, Filter, FilterBW: Integer;
    AGCMode, AGCTop: Integer;
    RxGainMode: Integer;
    RxGainDb: Integer;
    Volume, Drive, Atten: Integer;
    Band, Xvtr: Integer;
    Muted, CTun, Duplex: Boolean;
    Running, Connected, Transmitting, Tuning: Boolean;
    NRMode, NBMode: Integer;
    SNB, ANF: Boolean;
    WfAGC, WfNF: Boolean;
    FMStepOn: Boolean;
    FMStepIdx: Integer;
    FMCTCSSOn: Boolean;
    FMCTCSSToneIdx: Integer;
    FMSQOn: Boolean;
    FMSQLevel: Integer;
    FMRptDir: Integer;
    SMeter, FwdW, SWR, SupplyV, SupplyA: Double;
    PLLLock: Boolean;
  end;

  TRadioController = class;

  // Event adapter: every DMR slice owns a decoder, while its asynchronous
  // audio callback still has to retain the originating logical slice id.
  TDMRSliceDecoder = class
  private
    FOwner: TRadioController;
    FSliceId: Integer;
    FDecoder: TDMRDecoder;
    procedure AudioReady(const PCM: array of Single; Count: Integer);
  public
    constructor Create(AOwner: TRadioController; ASliceId: Integer);
    destructor Destroy; override;
    procedure FeedAudio(const Left, Right: array of Single; Count: Integer);
    procedure GetStatus(out S: TDMRStatus);
  end;

  // Слайс уровня контроллера: логическая обёртка над WDSP-слайс-каналом
  // (в TWDSPEngine) + собственный аудио-выход со своей звуковухой.
  TCtrlSlice = record
    Active:      Boolean;
    Id:          Integer;        // = id слайса в TWDSPEngine
    Letter:      Char;           // буква флага ('A'..'F'), пока для UI
    PanId:       Integer;        // пан слайса: 0 = главный, 1.. = доп. DDC-пан
    TargetHz:    Double;         // абсолютная частота слайса
    Mode:        Integer;
    FilterLo:    Integer;
    FilterHi:    Integer;
    AGC:         TWDSPAGCMode;
    Volume:      Double;         // 0..1
    Muted:       Boolean;
    RxMuteOnTx:  Boolean;        // True (умолч.) = динамик слайса молчит на передаче
    FMSQOn:      Boolean;        // FM-squelch включён (слайс в режиме FM)
    FMSQLevel:   Integer;        // порог squelch 0..100
    // Зеркало DSP-состояния слайса (движок его не отдаёт обратно): нужно UI
    // флага и CAT-геттерам. Пишется только через SetSliceDSP.
    NRMode:      Integer;        // 0=выкл, 1=NR, 2=NR2 (как у главного)
    NBMode:      Integer;        // 0=выкл, 1=NB, 2=NB2
    SNB:         Boolean;
    ANF:         Boolean;
    DeviceIndex: Integer;        // PortAudio-индекс вывода (-1 = default)
    DevName:     string;
    Audio:       TAudioOutput;   // владеет; свой поток вывода
    // Вход слайса (mic/виртуальный кабель) — источник модуляции, когда именно
    // этот слайс выбран TX-источником. -1/'' = брать общий вход (FAudioIn).
    InDeviceIndex: Integer;      // PortAudio-индекс ввода (-1 = общий)
    InDevName:     string;
    AudioIn:       TAudioInput;  // НЕ владеет: ссылка на поток из mic-пула
    // TX-профиль слайса ПО РЕЖИМУ: индекс в FTXProfiles или -1 = «не
    // переключать». Применяется, когда слайс становится TX-источником
    // (SetTxSlice) и когда он меняет режим, будучи источником.
    TXProfByMode:  TTXProfModeTable;
    DMR:         TDMRSliceDecoder; // владеет; nil для не-DMR слайса
  end;

  // POD-снимок слайса для фронтендов, которые читают модель из своих потоков
  // (TCI-сервер: команда исполняется в потоке клиента). Только скаляры: копия
  // TCtrlSlice тащит за собой managed-поля (DevName/InDevName) и объекты, а
  // копирование строки из чужого потока, пока UI-поток её же переписывает,
  // портит счётчик ссылок — это уже не рассинхрон, а порча кучи.
  TSliceView = record
    Id:        Integer;
    PanId:     Integer;
    TargetHz:  Double;
    Mode:      Integer;
    FilterLo:  Integer;
    FilterHi:  Integer;
    AGC:       TWDSPAGCMode;
    Volume:    Double;
    Muted:     Boolean;
    FMSQOn:    Boolean;
    FMSQLevel: Integer;
    NRMode:    Integer;
    NBMode:    Integer;
    SNB:       Boolean;
    ANF:       Boolean;
  end;

  { TRadioController }
  TRadioController = class
  private
    FOnInvoke:       TUIInvokeEvent;
    FOnStateChanged: TRadioStateEvent;
    FStateListeners: array of TRadioStateEvent;  // доп. наблюдатели (CAT/Andromeda), помимо FOnStateChanged (MainForm)
    FOnAfterTune:    TThreadMethod;   // пост-тюн хук: канал-оркестрация после SetVfoA
    FOnBeforeStop:   TThreadMethod;   // хук перед гашением панов в StopRunning (персист панов)
    FOnAudioConsume: TAudioConsumeEvent;  // web-перехват RX-аудио (если активен клиент)
    FLocalAudio:     Boolean;   // локальный звук (динамик/мик ПК): GUI=True, демон=False
    FOnSpectrumData: TPixelDataEvent;  // сырьё спектра наружу (UI рисует FSpecView)
    FOnWaterfallData: TPixelDataEvent; // сырьё водопада наружу (UI: децимация+рендер)
    // Тапы RX-аудио (TCI-потоки). Список короткий и меняется редко, но пишет
    // его поток контроллера, а читает DSP — отсюда лок: снять тап на лету
    // (клиент ушёл) иначе означало бы вызов метода освобождённого объекта.
    FAudioTaps:     array of TRadioAudioTapEvent;
    FAudioTapLock:  TCriticalSection;
    FAudioTapCount: LongInt;   // быстрый гейт: без тапов не берём и лок
    FIQTap:         TIQTapEvent;  // тап IQ (TCI): помним ради пересоздания движка
    procedure Changed(Field: TRadioField);
    procedure FireAudioTap(Kind: TRadioAudioKind; PanId, SliceId: Integer;
                const Left, Right: array of Single; Count: Integer);
    // --- Мультислайсы ---
    function  FindSliceIndex(Id: Integer): Integer;   // -1 если нет
    procedure OnSliceAudioReady(SliceId: Integer;
                const Left, Right: array of Single; Count: Integer);
    procedure OnSliceDemodAudioReady(SliceId: Integer;
                const Left, Right: array of Single; Count: Integer);
    procedure OnSliceDMRAudioReady(SliceId: Integer;
                const PCM: array of Single; Count: Integer);
    function  SliceMutedByTx(Idx: Integer): Boolean;  // слайс молчит на передаче?
    // Пул входных потоков: захват/освобождение ссылки на устройство ввода.
    function  AcquireMicInput(DevIndex: Integer): TAudioInput;
    procedure ReleaseMicInput(var AI: TAudioInput);
    procedure SetSliceInDeviceIdx(Idx, DevIndex: Integer; const DevName: string);
    procedure FreeMicPool;   // финальная зачистка (FreeEngines)
    procedure RefreshSliceShifts;   // пересчёт ShiftHz слайсов пана 0 при смене центра
    // --- Панадаптеры на аппаратных DDC (этап 3.1/3.2) ---
    procedure ResetPanDDCMaps;      // все пан-слоты свободны, DDC→Pan пуст
    procedure PanZoomStepTo(PanId: Integer; NewZ: Double);  // зум с якорем на центре окна
    function  PanCenterHz(PanId: Integer): Double;  // центр пана (0 = FCenterFreq)
    procedure ApplyAGCToEngine;   // применяет FAGCMode к WDSP (единый маппинг)
    procedure ApplyModeDefaults;  // дефолтный фильтр/девиация под текущий FMode
    procedure SanitizeFilterForMode;  // FM: индекс фильтра 0/1 + BW/девиация (state)
    // Единственная точка, где выбранный слот превращается в состояние тракта
    // (FFilter/кромки/производная BW/FM-девиация). Тракт не трогает — только state.
    procedure ApplyFilterSlot(Idx: Integer);
    function  ClampFilterIdx(Mode, Idx: Integer): Integer;
    procedure CacheFilterToBand;  // фильтр текущего банда в band-cache
    function  ActiveVfoHz: Double; // частота активного VFO (A или B)
    function  AnchoredPan(AZoom: Double): Double; // пан, центрирующий окно на маркере VFO
    procedure ApplyTuneCore(VfoHz: Double);  // CTUN shift/center/scroll для актив. VFO
  public
    // ============================================================
    //  ПУБЛИЧНЫЕ ПОЛЯ (миграционные — имена совпадают с TMainForm).
    //  Внешний чистый доступ для адаптеров — через команды / GetSnapshot /
    //  OnStateChanged. Прямая запись извне — временная мера на время Фазы 1.
    // ============================================================

    // ---- Последний кадр спектра/водопада (пиксели от WDSP) ----
    // Снимок для исходящего web-зеркала (PushState читает напрямую). Пишется из
    // DSP-потока в OnSpectrumReady/OnWaterfallReady; читается из GUI-таймера —
    // та же толерантная гонка, что и раньше (косметика пикселей).
    FSpectrumBuf:      array[0..SPECTRUM_PIXELS-1] of Single;
    FWaterfallBuf:     array[0..SPECTRUM_PIXELS-1] of Single;
    FSpectrumBufCount: Integer;
    FWaterfallBufCount: Integer;
    // Потолок точек кадра для web (настройка web.spec_pixels, 1024/2048/4096).
    // Кадр анализатора шире — децимируем до него; уже — уходит как есть.
    FWebSpecPixels:    Integer;
    // Водопад копится max-hold'ом до тех пор, пока PushState не заберёт строку
    // (анализатор даёт до 60 кадров/с, web шлёт 20 строк/с). Сбрасывает флаг
    // тот, кто забрал строку, — тогда следующий кадр начинает новую.
    FWfAccumFresh:     Boolean;

    // ---- Движки (владение: см. шапку юнита) ----
    // FNetwork — абстрактный бэкенд железа (TRadioBackend): конкретный класс
    // (THPSDRNetwork / TPlutoBackend позже) выбирается при подключении.
    FNetwork:      TRadioBackend;
    FDSPEngine:    TWDSPEngine;
    FAudioOut:     TAudioOutput;
    FAudioIn:      TAudioInput;

    // ---- Мультислайсы (софт-fan-out) ----
    // Слайс = доп. приёмник (свой WDSP-канал в движке) + своя звуковуха.
    // TargetHz абсолютна; ShiftHz в движок = TargetHz - FCenterFreq.
    FSlices:       array[0..MAX_SLICES-1] of TCtrlSlice;
    FNextSliceId:  Integer;   // монотонный генератор id слайсов

    // ---- Пул входных потоков (mic) ----
    // Каждому УНИКАЛЬНОМУ устройству ввода — один открытый TAudioInput на всё
    // приложение; слайсы держат на него ссылку по refcount. Потоки живут, пока
    // на них кто-то ссылается: переключение TX-источника = смена указателя,
    // без Close/Open (иначе на старте передачи терялись бы первые сэмплы).
    // Читатель у кольца всегда один — TX-источник в данный момент только один.
    FMicPool: array of record
      DevIdx: Integer;
      Refs:   Integer;
      AI:     TAudioInput;
    end;
    FSettings:     TSettingsManager;
    FChannelStore: TChannelStore;
    FDeviceStore:  TDeviceStore;   // общий список устройств (saved+discovered)
    FWDSPReady:    Boolean;

    // ---- Частоты / режим / фильтр / AGC ----
    FVfoA, FVfoB:   Double;
    FActiveVfo:     Integer;
    FMode:          Integer;
    FFilter:        Integer;     // слот 0..FILT_SLOTS-1 (10/11 = VAR1/VAR2)
    // Знаковые кромки текущего фильтра — ИСТИНА. FFilterBW производная
    // (FilterBWFromEdges) и оставлена ради прежних читателей (web, зум,
    // band-cache, CAT); писать в неё напрямую нельзя.
    FFilterLo:      Integer;
    FFilterHi:      Integer;
    FFilterBW:      Integer;
    FFilterTable:   TFilterTable;  // редактируемые фильтры по режиму
    FAGCMode:       Integer;
    FAGCTop:        Integer;
    FRxGainMode:    Integer;   // Pluto hw-gain: 0=manual,1=fast,2=slow,3=hybrid
    FRxGainDb:      Integer;   // Pluto manual gain (дБ, 0..73)
    FCTun:          Boolean;
    FCenterFreq:    Double;
    FSpanHz:        Double;
    FSampleRate:    Integer;
    FZoomFactor:    Double;    // зум спектра/водопада 0.05..1.0 (1.0 = без зума)
    FPanSlider:     Double;    // положение окна зума 0..1 (0.5 = центр)
    FCurrentBand:   Integer;
    FCurrentXvtr:   Integer;

    // ---- QO-100 beacon lock (декодер маяка 10489.750 = эталон частоты) ----
    // Один концепт: лок = декодер + трим LOError. Декодер точно ведёт несущую,
    // его измеренный дрейф стекаем в LOError → весь даунлинк стабилен.
    FBeaconLockOn:  Boolean;         // лок (=декодер) включён пользователем
    FBeaconLockHz:  Double;          // коррекция, накопленная за ТЕКУЩУЮ сессию лока
                                     // (только индикация на кнопке); сама поправка
                                     // живёт в LOError активного трансвертера
    FBeaconRefHz:   Double;          // опорная частота маяка (Hz, downlink), деф. 10489.750e6
    FBeaconTrackHz:  Double;         // измеренная АБСОЛЮТНАЯ display-частота маяка
                                     // (наведение + остаток декодера) — для оранж. маркера
    FBeaconSNRdB:    Double;         // SNR из последнего снимка декодера (индикация лока)
    FBeaconBal:      Double;         // EMA доли меньшинства знаков I (BPSK-ность)
    FBeaconLocked:   Boolean;        // декодер солидно залочен (гистерезис по SNR)
    FBeaconCorrCooldown: Integer;    // тиков паузы после коррекции (осадка ретюна LO)
    FBeaconSaveCooldown: Integer;    // троттлинг записи LOError на диск (тиков)
    // ---- декодер маяка (BPSK 400 бод, AO-40) — фронтенд+констелляция+бюллетень ----
    FBeaconDec:     TBeaconDecoder;  // владеет; тап IQ в WDSPEngine
    FBeaconDecHz:   Double;          // АБСОЛЮТНАЯ display-частота наведения декодера
                                     // (узкий фильтр, ставится кликом); 0 = ещё не наведён
    FBeaconDecInvert: Boolean;       // зеркальный baseband (QO-100/LNB) — деф. True
    // Лупа наведения (окно Beacon) — состояние окна, переживающее перезапуск.
    // Центр хранится СДВИГОМ от FBeaconRefHz: так он не зависит ни от правки
    // опорной частоты, ни от накопленной калибровки LOError.
    FBcnZoomSpanHz:   Double;        // ширина окна лупы, Гц
    FBcnZoomOffsetHz: Double;        // центр = FBeaconRefHz + этот сдвиг
    FBcnZoomFollow:   Boolean;       // держать наведение декодера в окне
    FBcnZoomAuto:     Boolean;       // автопоиск маяка в окне лупы

    // ---- DMR 4FSK receive decoder ----
    FDMRDec: TDMRDecoder;            // 48 kHz FM discriminator -> DMR worker

    // ---- Аудио / TX ----
    FVolume:        Integer;   // громкость RX
    FTxMonVolume:   Integer;   // громкость self-monitor RX-аудио на TX (RX MUTE off, DUP)
    FMuted:         Boolean;
    FDrivePercent:  Integer;   // драйв 0..100 % (UI-уровень, источник истины)
    FDriveLevel:    Byte;      // вычисляемый hardware-байт (CalcDriveByte)
    FLastVfoScrolled: Boolean; // последний SetVfoA прокрутил центр (для водопада)
    FAtten:         Integer;
    FPAMaxPower:    Double;
    FPlutoTxMaxAttDb: Double;  // Pluto: TX-аттенюация на drive=100% (потолок, дБ<0)
    FRunning:       Boolean;
    FTransmitting:  Boolean;
    FTuning:        Boolean;
    // ---- PureSignal ----
    FPSEnabled:     Boolean;   // авто-калибровка PS включена (кнопка PS)
    FPSSingleCal:   Boolean;   // запрошена одиночная калибровка
    FPSResetReq:    Boolean;   // запрошен сброс коррекции (OFF)
    FPSCmdState:    Integer;   // состояние автомата команд PureSignal
    FPSAAState:     Integer;   // auto-attenuate state (0=monitor,1=set,2=restore)
    FPSAASaveAuto:  Integer;   // сохранённый automode на время смены атт.
    FPSAASaveSingle: Integer;
    FPSAADeltaDB:   Integer;
    FPSInfo:        array[0..15] of Integer;  // кэш GetPSInfo (UI-поток)
    FPSInfoPrev:    array[0..15] of Integer;
    FPSMaxTX:       Double;    // кэш GetPSMaxTX
    // Feedback-тракт вооружён (сеть DDC0/1 + pscc в движке). Пишет UI-поток
    // (PureSignalTick/PSSetFeedbackPath), читает сетевой (OnDDCIQ).
    FPSFeedbackOn:  Boolean;
    FTwoTone:       Boolean;   // 2TON активен
    FSplitTxB:      Boolean;
    FTxSliceId:     Integer;   // 0 = главный VFO (A/B split); >0 = слайс-источник TX
    // Id слайса, перестроенного извне (CAT): полезная нагрузка rfSliceFreq —
    // Changed несёт только поле, а обработчику нужен конкретный флаг.
    FSliceFreqId:   Integer;
    // Auto TX слайса (индекс = СЛОТ слайса, не Id): PTT по CAT-порту слайса сам
    // назначает его TX-источником. Флаг живёт на слоте, а не на слайсе, потому
    // что настройка привязана к букве (B..G) и переживает удаление слайса.
    FSliceAutoTx:   array[0..MAX_SLICES-1] of Boolean;
    // Аудио-устройства слота (буквы B..G): '' = общий выход приложения / общий
    // вход. Это НЕ шаблон, а привязка слота: применяется к живому слайсу сразу
    // и достаётся новому слайсу, который встанет на этот слот.
    FSliceOutDev:   array[0..MAX_SLICES-1] of string;
    FSliceInDev:    array[0..MAX_SLICES-1] of string;
    FVfoLock:       Boolean;   // блокировка перестройки VFO (Andromeda Lock)
    FDisplayDuplex: Boolean;
    // QO-100 full-duplex self-monitor: True — RX-аудио глушится на TX (по умолч.),
    // False — слышим свой downlink со спутника при передаче. Кнопка RX MUTE.
    FRxMuteOnTx:    Boolean;
    FHWPTTActive:    Boolean;
    FHWPTTStartedTX: Boolean;
    FWebClientActive: Boolean;          // выставляет web-слой (connect/disconnect клиента)
    FWebMicActive:    Boolean;          // True если текущий TX идёт через txmsWeb
    FTCIMicRequested: Boolean;          // TCI-клиент попросил TRX:…,tci
    FTCIMicActive:    Boolean;          // True если текущий TX модулируется из TCI
    FTCITxLeadFrames: Integer;          // аванс подачи TCI, отсчётов 48 кГц (ставит адаптер)
    FSendAudioToRadio: Boolean;
    FAudioOutDevName:  string;
    FAudioInDevName:   string;
    FDevConnected:  Boolean;
    FDiscoverNoneFound: Boolean;        // discovery завершился без устройств (для рендера)

    // ---- DSP-фильтры шума / водопад ----
    FNRMode, FNBMode: Integer;   // источник истины (раньше Btn*.Tag)
    FSNB, FANF:       Boolean;   // источник истины (раньше Btn*.Tag)
    FWfAGCEnabled:    Boolean;
    FWfNFEnabled:     Boolean;
    FDitherEnabled:   Boolean;
    FRandomEnabled:   Boolean;

    // ---- Display state (persist в TGlobalSettings; рендерится FSpecView/Wideband) ----
    FShowSpectrum:   Boolean;
    FShowWaterfall:  Boolean;
    FShowWideband:   Boolean;
    FWidebandFill:   Boolean;
    FSpectrumFill:   Boolean;
    FWfManualHigh:   Double;
    FWfManualLow:    Double;
    FWfAGCOffset:    Double;
    FWfPalette:      Integer;  // 0=Classic, 1=Inferno, 2=Turbo
    FWfGamma:        Double;   // контраст палитры водопада
    FDisplayPixels:  Integer;  // потолок точек анализатора
    FWfSpeedDiv:     Integer;  // делитель скорости водопада (1/2/4/8, max-hold)
    FSpecMSAA:       Integer;  // MSAA GL-спектра (1=off, 2, 4, 8)
    FSpecRefLevel:   Double;
    FSpecRange:      Double;
    FSpecGridStep:   Double;
    FTXSpecRefLevel: Double;   // опорный уровень TX-сетки при FTransmitting
    FTXSpecRange:    Double;
    FTXSpecGridStep: Double;

    // ---- FM ----
    FFMDeviation:    Double;
    FRawFMDeviation: Double;
    FFMCTCSSOn:      Boolean;
    FFMCTCSSToneIdx: Integer;
    FFMSQOn:         Boolean;
    FFMSQLevel:      Integer;
    FFMStepOn:       Boolean;
    FFMStepIdx:      Integer;
    FFMRptDir:       Integer;
    FFMRptOffsetHz:  Double;
    FFMRptAutoActive:   Boolean;
    FFMCTCSSAutoActive: Boolean;

    // ---- Конфиги / устройство (per-MAC) ----
    FTXSettings:   TTXSettings;
    // Именованные TX-профили («звуковая» часть FTXSettings + drive, см.
    // TTXProfile). Истина для эфира — всегда живые FTXSettings/FDrivePercent;
    // активный профиль ведётся за ними (StoreActiveTXProfile).
    FTXProfiles:   TTXProfileList;
    // Телеграф: параметры кейера/сайдтона/pitch (DUC Specific 5..13,17 + CW-тракт).
    FCWSettings:   TCWSettings;
    // Программная передача текста (бит CWX). Поток создаётся лениво — у тех,
    // кто телеграфом не пользуется, лишней нити не заводится.
    FCWSender:     TCWSender;
    // Чужая манипуляция по сети (TCI KEYER): очередь готовых интервалов.
    // Тоже лениво — заводится на первом элементе.
    FCWElem:       TCWElemPlayer;
    // Состояние ключа для ПРОГРАММНОГО сайдтона. Пишется потоком передачи и
    // разбором HP-статуса (прямой ключ), читается DSP-потоком в MixSidetone —
    // толерантная гонка на одном булеве, как FHPp*.
    FCWKeyDown:    Boolean;
    // Прошлое состояние лепестков — обрыв программной передачи по фронту.
    FCWPrevDot:    Boolean;
    FCWPrevDash:   Boolean;
    // Момент последнего замыкания ключа прошивкой — только для ИНДИКАЦИИ
    // (см. RadioKeyed): без выдержки метр моргал бы RX/TX на каждой точке.
    FCWKeyedAt:    QWord;
    // Предыдущее значение RadioKeyed — событие рендера шлём только на фронте.
    FCWKeyedShown: Boolean;
    // Последний отправленный байт 5 — чтобы не гнать DUC-пакет на каждую
    // смену режима, а только когда телеграфная конфигурация реально поменялась.
    FLastCWOptions: Byte;
    FCWSTPhase:    Double;    // фаза генератора (только DSP-поток)
    FCWSTEnv:      Double;    // огибающая 0..1, сглаживает щелчки (DSP-поток)
    FCWSTBufL:     array of Single;   // буферы подмешивания (только DSP-поток)
    FCWSTBufR:     array of Single;
    FCWSTEnvBuf:   array of Single;   // огибающая от локального кейера (DSP-поток)
    // ---- Локальный кейер (CWKeyer.pas) --------------------------------------
    // Генератор несущей с огибающей прямо в поток IQ. Обязателен там, где кейера
    // в железе нет (Pluto/LibreSDR), и опционален у openHPSDR. Оба объекта
    // создаются лениво — кто телеграфом не пользуется, лишних нитей не получает.
    FCWLocal:      TCWLocalKeyer;
    FCWKeyPort:    TCWKeyPort;
    // Сессия локального кейера подняла передачу сама (break-in): по этому флагу
    // её снимаем и не путаем с ручным MOX.
    FCWLocalTX:    Boolean;
    // Локальный кейер вооружён — считается в SyncCWKeyer, читается пейсингом
    // частоты DUC (CWCarrierOffsetHz) и аттенюатором Pluto.
    FCWLocalArmed: Boolean;
    // ---- Декодер приёма (CWDecoder.pas) --------------------------------------
    // Создаётся лениво вместе с окном-терминалом. Кормится из OnDemodAudioReady
    // (до громкости и мьюта, без сайдтона), разбирается на таймере хозяина.
    FCWDec:        TCWDecoder;
    // Лента расшифровки: последние знаки приёма (для полосы под спектром) и
    // счётчик, по которому потребители понимают, что появилось новое.
    FCWLogText:    string;
    FCWLogSeq:     Int64;
    FOnCWLog:      TCWLogEvent;
    FAlexSettings: TAlexSettings;
    FOCSettings:   TOCSettings;   // OC Control (Open Collector, openHPSDR-only)
    // Антенные разъёмы AD936x (Pluto/LibreSDR) на диапазон/трансвертер.
    FAnt936x:      TAD936xAnt;
    FXvtrSettings: TXvtrSettings;
    FBandCache:    array[0..CFG_BAND_COUNT-1] of TBandSettings;
    FPABandCal:    array[0..CFG_BAND_COUNT-1] of Double;
    FVHFBandCal:   array[0..CFG_XVTR_COUNT-1] of Double;
    // Калибровки (детектор мощности/КСВ, напряжение/ток, S-метр) — вкладка
    // Calibration. Читается сетевым потоком в OnHPStatus (double-поля, torn
    // read безопасен как у FPABandCal); менять через SetCalibration.
    FCal:          TCalibration;
    FDevMAC:       array[0..5] of Byte;
    // Глобальные настройки устройства, загруженные ConnectDevice. Хранятся для
    // UI-рендера полей, которых нет в контроллере (DisplayFPS/FreqMhzDigits/CAT).
    FLoadedGlobal: TGlobalSettings;

    // ---- Телеметрия (наполняется в Фазе 3/4 вместе с сетевыми колбэками) ----
    FLastSMeter, FLastFwdW, FLastSWR, FLastSupplyV, FLastSupplyA: Double;
    FLastPLLLock: Boolean;
    // Pluto-телеметрия (нет HP-status): температура трансивера °C / Zynq SoC °C /
    // RSSI приёмника дБ. Опрашиваются ServicePlutoTelemetry с UI-таймера;
    // TELEMETRY_NONE = нет данных.
    FLastPlutoTempC, FLastPlutoSocTempC, FLastPlutoRSSIdB: Double;
    // Фактическое усиление RX-тракта AD936x (дБ) — в режимах AGC его крутит
    // железо. Снимается тем же опросом; UI показывает его в блоке RF AGC.
    FLastPlutoGainDb: Double;
    FPlutoTeleTick: QWord;   // GetTickCount64 последнего опроса (троттлинг)
    FPlutoTempBad:  Integer; // подряд отброшенных выбросов температуры (анти-залипание)

    // ---- RX/seq телеметрия data-path (пишется из сетевого колбэка OnDDCIQ) ----
    FRXPacketCount: LongWord;   // счётчик принятых IQ пакетов
    FRXStartTime:   QWord;      // GetTickCount64 момента START
    FRXLastPktTime: QWord;      // GetTickCount64 последнего принятого пакета
    FActiveDDC:     Integer;    // DDC index для текущей платы (0 или 2)
    // ---- Панадаптеры на аппаратных DDC (этап 3.1) ----
    // PanId 1..MAX_PANS-1 → hw DDC index (-1 = пан не создан); пан 0 = главный
    // тракт (FActiveDDC). FDDCPanMap — обратный маппинг DDC→PanId для роутинга
    // IQ в OnDDCIQ (пишется под FSliceLock-дисциплиной: только UI-поток).
    FPanDDCIdx:  array[1..MAX_PANS-1] of Integer;
    FPanFreqHz:  array[1..MAX_PANS-1] of Double;
    FPanRateKHz: array[1..MAX_PANS-1] of Word;
    FPanADC:     array[1..MAX_PANS-1] of Byte;   // ADC-источник пана (3.4)
    FPanZoomF:   array[1..MAX_PANS-1] of Double; // зум пана 0..1 (0 = весь span)
    FPanPanSl:   array[1..MAX_PANS-1] of Double; // положение окна пана 0..1
    FMainADCSrc: Byte;                           // ADC главного DDC (пан 0)
    FDDCPanMap:  array[0..MAX_DDCS-1] of Integer;
    FLastDDCSeq:    LongWord;   // последний seq (из сетевого потока)
    FLastDDCIndex:  Integer;    // последний DDC index
    FDDCLastSeq:      array[0..6] of LongWord;
    FDDCSeqValid:     array[0..6] of Boolean;
    FSeqErrorCount:   LongWord;
    FLastSeqErrorDDC: Integer;
    FLastSeqErrorDelta: Int64;
    FSeqOkStreak:     Integer;
    FADCOverloadActive: Boolean;   // ADC overload (рендерится по rfADCOverload)
    // ---- TX DUC-очередь (накопление IQ от WDSP в пакеты по 240, OnTXIQ) ----
    FDUCPendingI:     array[0..239] of Integer;
    FDUCPendingQ:     array[0..239] of Integer;
    FDUCPendingCount: Integer;
    // Pending-значения декода HP-статуса (сетевой поток → ApplyHPStatus через
    // Invoke; Synchronize блокирующий и последовательный, гонок нет).
    FHPpFwdW, FHPpSWR, FHPpSupplyV, FHPpSupplyA: Double;
    FHPpPLL, FHPpHWPTT: Boolean;
    FHPpOverload: Byte;
    // Pending discovered-устройство (сетевой поток discovery → ApplyDeviceFound
    // через Invoke; Synchronize блокирующий — устройства приходят по одному).
    FPendingFoundDev:   THPSDRDevice;
    FPendingFoundEntry: string;
    // URI для probe сетевых Pluto при discovery (см. SetPlutoProbeURIs).
    FPlutoProbeURIs:    array of string;

    constructor Create;
    destructor Destroy; override;

    // Lifecycle движков: создаёт FNetwork/FDSPEngine/FAudioOut/FAudioIn с
    // заданным sample rate (вызывающий уже определил его из настроек). Колбэки
    // движков навешивает вызывающий (GUI: методы MainForm; демон: свои). Парный
    // 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
    // вызывающим (GUI или демон) после CreateEngines.
    procedure OnAudioReady(const Left, Right: array of Single; Count: Integer);
    procedure OnDemodAudioReady(const Left, Right: array of Single; Count: Integer);
    procedure OnDMRAudioReady(const PCM: array of Single; Count: Integer);
    // Общая выборка калибровочной таблицы «диапазон/трансвертер» по частоте.
    function  CalLookup(VisibleHz: Double;
                        const BandTbl: array of Double;
                        const XvtrTbl: array of Double): Double;
    procedure OnSpectrumReady(const Pixels: array of Single; Count: Integer);
    procedure OnWaterfallReady(const Pixels: array of Single; Count: Integer);
    procedure OnMicPacket(const Data: TMicDataPacket);
    function  PullSoundCardMic(MaxN: Integer): Integer;
    procedure OnDDCIQ(DDCIndex: Integer; const Data: TDDCIQPacket);
    procedure OnHPStatus(const Status: THighPriorityStatus);
    procedure ApplyHPStatus;   // маршалится из OnHPStatus (сглаживание/overload/HWPTT)
    procedure OnTXIQ(const Buf: array of Double; Count: Integer);
    procedure OnDeviceFound(const Dev: THPSDRDevice);  // сетевой поток discovery
    procedure ApplyDeviceFound;        // маршалится из OnDeviceFound (store + rfDeviceList)
    procedure DiscoverFinishedNone;    // маршалится из discover-потока: устройств нет

    // Маршалинг команды в поток контроллера (GUI: TThread.Synchronize).
    procedure Invoke(M: TThreadMethod);

    // Снимок всего состояния (для синхронизации нового фронтенда).
    function GetSnapshot: TRadioSnapshot;

    // Частотные хелперы — чистые вычисления над состоянием, общие для всех
    // фронтендов и сетевого слоя (Vfo/Mode/Band/CTun/TX/FM-Rpt).
    //   XvtrTranslate — переводит видимую частоту в частоту IF-тракта с учётом
    //     активного трансвертора (LOOffset/LOError).
    //   ActiveTXFreqHz — текущая TX-частота (split/active VFO + FM-репитер сдвиг).
    function XvtrTranslate(VisibleHz: Double): Double;
    //   XvtrTranslateTX — то же для ПЕРЕДАЧИ. Обычный трансвертер: = XvtrTranslate.
    //     QO-100 (FullDuplexTx): отдельный TX-LO (visible-TxLOOffset, без LOError —
    //     uplink-LO не подвержен дрейфу LNB).
    function XvtrTranslateTX(VisibleHz: Double): Double;
    function ActiveTXFreqHz: Double;
    //   TXTuneFreqHz — ЕДИНАЯ дверь «какую частоту передачи класть в железо»:
    //     ActiveTXFreqHz через трансвертер, минус сдвиг несущей локального кейера
    //     (CWCarrierOffsetHz). Прямых XvtrTranslateTX(ActiveTXFreqHz) в пушах
    //     состояния быть не должно — иначе телеграф уедет от VFO ровно на сдвиг.
    function TXTuneFreqHz: Double;
    // Режим передающего тракта: слайс-источник TX (FTxSliceId) либо главный FMode.
    function ActiveTXMode: Integer;
    // Диапазон для калибровки драйва/антенны TX (банд TX-слайса при кросс-банде).
    function TxBandIdx: Integer;
    // Выбор слайса-источника передачи (0 = главный VFO). Эксклюзивно.
    procedure SetTxSlice(SliceId: Integer);
    property  TxSliceId: Integer read FTxSliceId;
    // Складывает коррекцию маяка dLock в LOError активного трансвертера (живая
    // калибровка LNB), ретюнит радио и троттлингом сохраняет на диск.
    procedure ApplyBeaconLOError(dLock: Double);
    // Flush LOError на диск при выключении лока (если троттлинг не успел).
    procedure PersistBeaconCalibration;

    // Применяет текущие FMode/FFilterBW/FM-параметры к DSP (RX/TX фильтр,
    // FM-девиация, squelch). Вызывается командами и UI-путями смены режима.
    procedure ApplyModeFilter;

    // Вычисляет аппаратный drive-байт из текущего состояния мощности.
    // (band/xvtr cal, TUN/drive%, PA max). Чистая функция над состоянием.
    function CalcDriveByte: Byte;

    // Источник микрофона по умолчанию для MOX-кнопки/Web-TX: звуковая карта
    // если настроена, иначе HW mic-стрим трансивера. HW PTT → txmsRadio в SetMOX.
    function DefaultMicSource: TTXMicSource;

    // ---- TX/DSP-цепь (чистые над состоянием: читают F*/FTXSettings, пишут в движок) ----
    function  BuildMicLineSelectByte: Byte;        // DUC Specific byte 50 (mic/line биты)
    procedure ApplyNoiseFilterButtonsToDSP;        // NR/NB/SNB/ANF → WDSP
    procedure SendDUCSpecificFromSettings;         // DUC Specific (mic/keyer/atten) в сеть
    procedure ApplyTXSettingsToDSP;                // TX-цепь (фильтр/gain/EQ/comp/...) → WDSP
    // ---- Телеграф ----------------------------------------------------------
    // Живой контекст «где мы сейчас»: в трансвертере правки запоминаются в его
    // слоте, иначе в кэше диапазона. ★Развилка обязана стоять у КАЖДОГО поля,
    // которое помнится по диапазону: пока её не было у режима/фильтра/AGC/CTUN,
    // трансвертер травил ими тот HF-диапазон, на котором стоял до входа
    // (FCurrentBand при входе в слот не меняется).
    function  XvtrSlotActive: Boolean;
    function  TXProhibited: Boolean;               // DoNotTx-бэнд / RX-only XVTR
    function  CWTXActive: Boolean;                 // манипуляцией владеет не MOX
    function  CWLocalSource: Boolean;              // манипуляцию рисует PC
    function  CWFWSource: Boolean;                 // манипуляцию рисует прошивка
    function  CWOptionsByte: Byte;                 // DUC Specific байт 5
    procedure SyncCWKeyer;                         // байт 5 под текущий TX-режим
    procedure CWXKeyEvent(Down: Boolean);          // колбэк потока передачи
    procedure CWElemKey(Down: Boolean);            // колбэк проигрывателя KEYER
    // Сдвиг несущей манипуляции от гетеродина (уход от утечки LO у zero-IF).
    // На него же уезжает DUC/LO, чтобы РЧ встала ровно на VFO — см. TXTuneFreqHz.
    function  CWCarrierOffsetHz: Double;
    // ---- Локальный кейер ----------------------------------------------------
    procedure EnsureCWLocal;                       // ленивое создание генератора
    procedure PushCWLocalConfig;                   // настройки → генератор/порт
    procedure CWLocalIQ(const Buf: array of Double; Count: Integer);
    procedure CWLocalSession(Active: Boolean);     // T/R под сессию манипуляции
    procedure CWLocalTick;                         // «ключ живой» (индикация)
    procedure CWKeyPortEvent(Dot, Dash: Boolean);  // лепестки с COM-порта
    // Подмешать программный сайдтон в блок RX-аудио (DSP-поток). Возвращает
    // False, если подмешивать нечего — тогда вызывающий отдаёт исходный блок.
    function  MixSidetone(const Left, Right: array of Single;
                          Count: Integer): Boolean;
    function  PSOutlierSigmaEff: Double;           // σ outlier-фильтра calcc (авто по плате)

    // ---- TX-профили --------------------------------------------------------
    // Профиль — именованный снимок «звуковой» части TX-цепи (состав в
    // TTXProfile). Отдельной кнопки «сохранить» нет: правки ручками пишутся в
    // активный профиль сразу (StoreActiveTXProfile), как и всё остальное в
    // EWSDR. Переключение применяет снимок в эфир немедленно.
    function  TXProfileCount: Integer;
    function  TXProfileName(Idx: Integer): string;
    function  ActiveTXProfile: Integer;
    // Единая дверь для правки TX-настроек снаружи (вкладка Transmit, CAT, web):
    // применяет запись к движку/железу, дописывает активный профиль и персистит.
    // Notify=False — вызывающий сам отрисует изменения (так делает MainForm,
    // иначе rfTXProfile перезагрузил бы форму настроек прямо под руками).
    procedure SetTXSettings(const T: TTXSettings; Notify: Boolean = True);
    // Auto=True — переключение сделала автоматика привязок; ручной выбор
    // (дропдаун, список настроек) дополнительно ЗАПОМИНАЕТСЯ за текущей парой
    // «источник + модуляция» (BindTXProfileToCurrentMode).
    procedure SelectTXProfile(Idx: Integer; Auto: Boolean = False);
    procedure StoreActiveTXProfile(Persist: Boolean = False);
    function  AddTXProfile(const AName: string): Integer;  // форк текущего звука
    procedure RenameTXProfile(Idx: Integer; const AName: string);
    procedure DeleteTXProfile(Idx: Integer);
    procedure ResetTXProfilesToFactory;    // заводской набор заново
    // Профиль на TX-источник, ключ — режим этого флага (-1 = не переключать,
    // звучит активный). Таблица у каждого флага своя: главный в FTXProfiles,
    // слайсы в своих записях.
    function  FlagMode(SliceId: Integer): Integer;
    procedure BindTXProfileToCurrentMode(Idx: Integer);  // ручной выбор → в таблицу
    function  SliceTXProfile(SliceId: Integer): Integer;
    procedure SetSliceTXProfile(SliceId, ProfIdx: Integer);
    function  SliceTXProfileTable(SliceId: Integer): TTXProfModeTable;
    procedure SetSliceTXProfileTable(SliceId: Integer; const T: TTXProfModeTable);
    procedure RemapTXProfileBindings(Deleted: Integer);  // после удаления/замены списка
    procedure ApplyBoundTXProfile;         // профиль текущего TX-источника → в эфир
    procedure SaveTXProfilesToConfig;

    // ---- Управление диапазонами (состояние + DSP; рендер через события) ----
    // MakeBandSettings — снимок текущего состояния в запись диапазона.
    // SaveCurrentBand — сохраняет текущее состояние в кэш+конфиг (или в XVTR-слот).
    // RestoreBand — загружает состояние диапазона, применяет к DSP, шлёт события
    //   рендера. Финальную перестройку VFO A делает вызывающий (UI: ApplyVfoA).
    function  MakeBandSettings: TBandSettings;
    procedure SaveCurrentBand;
    procedure RestoreBand(BandIdx: Integer);

    // Глобальные настройки устройства (persist по MAC). BuildGlobalSettings
    // собирает радио-поля из своего состояния, а НЕ-радио поля (тема/FPS/digits/
    // CAT) переносит сквозь из FLoadedGlobal — контроллер их не трактует, только
    // round-trip в файл. SaveGlobalSettings пишет блоб (гейт по FDevConnected).
    function  BuildGlobalSettings: TGlobalSettings;
    procedure SaveGlobalSettings;
    // Применяет DSP-настройки диапазона (mode/filter/AGC/CTUN/FM) + шлёт события
    // рендера. НЕ трогает частоты VFO/центр — это переиспользуется как RestoreBand
    // (с загрузкой VFO), так и SetActiveVfo (bandstack: смена бэнда без сброса VFO).
    procedure ApplyBandDSP(const B: TBandSettings);
    // Полоса фильтра для (режим, индекс фильтра) — выбор per-mode таблицы.
    function  FilterBWFor(M, Idx: Integer): Integer;
    // Обратное отображение: ближайший индекс пресета к полосе BW (для CAT/UI,
    // где полоса задана в Гц, а протокол оперирует индексом).
    function  FilterIdxFor(M, BW: Integer): Integer;

    // ---- XVTR (трансвертер) ----
    // ApplyXvtrToNetwork — XVTR enable bit + DisablePA + RX ant в сеть.
    // ActivateXvtr — вход в слот: state+DSP (через ApplyBandDSP) + VFO/центр + сеть;
    //   рендер через rfXvtr/rfBand(XVTR-aware)/rfActiveVfo. UI: wideband+web.
    // DeactivateXvtr — сохраняет слот (SaveCurrentBand) и выходит; HF-возврат
    //   (RestoreBand + финальная перестройка VFO) делает UI-обёртка.
    procedure ApplyXvtrToNetwork;
    procedure ActivateXvtr(Idx: Integer);
    procedure DeactivateXvtr;

    // Полный HP-кадр в радио (RX/TX freq через трансвертор + drive + MOX).
    // Вызывается командами смены частоты/центра и UI после правки drive.
    procedure PushNetworkState;

    // ====================================================================
    //  КОМАНДЫ — единый API для GUI / Web / CAT / Arduino.
    //  Фаза 1: обновляют поле + шлют событие; логика движка — TODO-wiring.
    // ====================================================================

    // Частоты / VFO
    procedure SetVfoA(Hz: Double);
    procedure StoreVfoA(Hz: Double);   // сохранить частоту A без перестройки (когда активен B)
    procedure SetVfoB(Hz: Double);
    procedure SetActiveVfo(Idx: Integer);
    procedure TuneActiveBy(DeltaHz: Double);   // энкодер VFO
    procedure SetCenter(Hz: Double);
    procedure VfoSwap;
    procedure VfoACopyB;
    procedure VfoBCopyA;

    // Режим / фильтр / AGC
    procedure SetMode(M: Integer);
    procedure SetFilterIdx(Idx: Integer);
    procedure SetFilterBW(BWHz: Integer);
    procedure SetRawFMDeviation(Hz: Integer);
    procedure SetAGCMode(M: Integer);
    procedure SetAGCTop(DB: Integer);
    procedure AGCTopBy(Delta: Integer);

    // RX hw-gain (Pluto): режим (0=manual,1=fast,2=slow,3=hybrid) / ручной дБ.
    // На HPSDR — no-op в бэкенде.
    procedure SetRxGainMode(M: Integer);
    procedure SetRxGain(Db: Integer);
    procedure RxGainBy(Delta: Integer);
    procedure ApplyRxGainToBackend;   // FRxGain* → FNetwork.SetRxGain

    // Громкость / mute / drive / аттенюатор
    procedure SetVolume(V: Integer);
    procedure VolumeBy(Delta: Integer);
    // Громкость self-monitor'а на передаче отдельно от RX-громкости: слайдер
    // правит её только когда MonitoringTX, а CAT (ZZTM) — в любой момент.
    { Адресная громкость ПРИЁМА: правит FVolume независимо от того, идёт ли
      передача. Слайдер (SetVolume) правит активную — на self-monitor это
      громкость монитора; внешнему клиенту (TCI VOLUME) такой контекст не нужен. }
    procedure SetRxVolume(V: Integer);
    procedure SetTXMonVolume(V: Integer);
    // True — сейчас звучит self-monitor даунлинка на передаче (TX + DUP + RX MUTE
    // off). В этом контексте SetVolume/слайдер правят FTxMonVolume, иначе FVolume.
    function  MonitoringTX: Boolean;
    // Активная громкость под текущий контекст (RX или TX-монитор) — то, что видит
    // слайдер (rfVolume) и получает движок.
    function  ActiveVolume: Integer;
    // Применить активную громкость к движку + синхронизировать слайдер (rfVolume).
    // Зовётся на T/R-переходах и смене RX MUTE / DUP.
    procedure ApplyActiveVolume;
    procedure SetMute(On_: Boolean);
    procedure ToggleMute;
    procedure SetDrive(V: Integer);
    procedure DriveBy(Delta: Integer);
    procedure SetAtten(Db: Integer);   // ADC step attenuator, дБ (0..31)

    // ---- Мультислайсы (софт-fan-out) ----
    // Создаёт слайс на TargetHz: заводит WDSP-канал в движке + свой аудио-выход
    // на устройстве DevIndex (-1=default). Возвращает Id слайса или -1 при отказе
    // (нет слотов / TargetHz вне полосы пана). DevName для персиста/UI.
    // APanId>0 — слайс внутри доп. DDC-пана (тот должен быть создан AddPanDDC).
    function  AddSlice(TargetHz: Double; Mode, FilterLo, FilterHi: Integer;
                       AGC: TWDSPAGCMode; Vol: Double;
                       DevIndex: Integer; const DevName: string;
                       APanId: Integer = 0): Integer;
    procedure RemoveSlice(Id: Integer);
    procedure SetSliceTarget(Id: Integer; TargetHz: Double);
    procedure SetSliceMode(Id, Mode: Integer);
    procedure SetSliceFilter(Id, Low, High: Integer);
    procedure SetSliceModeBW(Id, Mode, BW: Integer);   // режим+полоса → кромки фильтра
    class procedure FilterEdgesFor(Mode, BW: Integer; out Lo, Hi: Integer);
    // --- Редактируемая таблица фильтров (пресеты + VAR1/VAR2 на режим) ---
    function  FilterSlot(Mode, Idx: Integer): TFilterSlot;
    procedure SetFilterSlot(Mode, Idx: Integer; const S: TFilterSlot);
    procedure ResetFilterSlot(Mode, Idx: Integer);
    procedure ResetFilterSet(Mode: Integer);
    // Произвольные кромки (drag на спектре, CAT ZZFL/ZZFH, web): пишутся в
    // VAR1 и переключают на него, если текущий слот — пресет. Пресеты таким
    // образом невозможно испортить случайным движением мыши.
    procedure SetFilterEdges(LoHz, HiHz: Integer);
    // Только state, без применения к тракту: UpdateFilterButtons — рендер,
    // DSP в его путях перестраивается отдельно (как было до таблицы).
    procedure SelectFilterSlotState(Idx: Integer);
    procedure SanitizeFilterState;
    // Знаковые края TX-полосы (для отрисовки полосы фильтра при передаче).
    procedure TXFilterEdges(out Lo, Hi: Integer);
    property  FilterLo: Integer read FFilterLo;
    property  FilterHi: Integer read FFilterHi;
    class function  AGCModeFromUI(M: Integer): TWDSPAGCMode;   // UI-индекс 0..4 → WDSP enum
    class function  AGCModeToUI(A: TWDSPAGCMode): Integer;     // и обратно
    procedure SetSliceAGCMode(Id: Integer; AGC: TWDSPAGCMode);
    procedure SetSliceVolume(Id: Integer; Vol: Double);   // 0..1
    procedure SetSliceMute(Id: Integer; Mute: Boolean);
    // Слайс слушает внешний потребитель (поток RX_AUDIO по TCI): молчащий в
    // комнате слайс обязан считаться, пока его кто-то качает по сети. Зеркала
    // в TCtrlSlice нет намеренно — флаг принадлежит владельцу потоков, а не
    // модели, и пересоздание движка слайсы всё равно не переживают.
    procedure SetSliceTapWanted(Id: Integer; V: Boolean);
    procedure SetSliceRxMuteOnTx(Id: Integer; On_: Boolean);  // self-monitor слайса на TX
    function  PanPrimarySliceId(PanId: Integer): Integer;     // первый созданный слайс пана
    function  PanHasFMSlice(PanId: Integer): Boolean;         // есть слайс в канальном режиме (FM/DMR/FMRAW)?
    procedure SetSliceDSP(Id, NRMode, NBMode: Integer; SNBOn, ANFOn: Boolean);
    procedure SetSliceFMSquelch(Id: Integer; On_: Boolean; Level: Integer);
    function  SliceSMeter(Id: Integer): Double;           // dBm, из DSP-потока
    // S-метр главного приёмника, dBm, с калибровочным офсетом текущего
    // диапазона/трансвертера (движок отдаёт сырое значение).
    function  ReadSMeterDBm: Double;
    // Офсет S-метра (дБ) для видимой частоты: слот трансвертера или диапазон.
    function  SMeterCalFor(VisibleHz: Double): Double;
    // Офсет спектра/водопада (дБ) для дисплея: PanId 0 = главный, 1..N — пан.
    // Колбэк движка (display-поток), см. TOnDispCalQuery.
    function  DispCalFor(PanId: Integer): Double;
    // Поправка на регулируемое усиление входа (аттенюатор ADC у openHPSDR,
    // hardwaregain у AD936x) — входит в оба офсета выше.
    function  FrontEndCalDB: Double;
    procedure SetSliceDevice(Id, DevIndex: Integer; const DevName: string);
    // Устройство ВВОДА слайса (источник модуляции, когда слайс = TX-источник).
    // DevIndex=-1 / DevName='' — вернуться на общий вход (FAudioIn).
    procedure SetSliceInDevice(Id, DevIndex: Integer; const DevName: string);
    // Общее устройство ввода (главный VFO = слайс A + фолбэк для слайсов).
    procedure SetMainMicDevice(DevIndex: Integer; const DevName: string);
    // Поток/имя устройства ввода активного TX-источника (слайс или общий).
    function  ActiveMicInput: TAudioInput;
    function  ActiveMicInDevName: string;
    function  SliceCount: Integer;
    function  GetSlice(Id: Integer; out S: TCtrlSlice): Boolean;
    // Снимок слайса для фронтендов, читающих модель из СВОИХ потоков (TCI).
    // См. TSliceView: копировать целиком TCtrlSlice оттуда нельзя.
    function  GetSliceView(Id: Integer; out V: TSliceView): Boolean;
    // Слайсы пана по порядку (0-й = «канал A» пана): та же нумерация, что у
    // флагов. Нужны TCI, где приёмник — это пан, а канал — слайс на нём.
    function  PanSliceCount(PanId: Integer): Integer;
    function  PanSliceId(PanId, Index: Integer): Integer;      // 0 = нет такого
    function  SlicePanIndex(Id: Integer; out PanId, Index: Integer): Boolean;
    // Слот ↔ слайс. Слот 0..MAX_SLICES-1 = буква B..G и не зависит от того,
    // создан ли слайс сейчас: на слот вешаются настройки (CAT-порт, Auto TX).
    class function SliceSlotLetter(Slot: Integer): Char;
    function  SliceIdBySlot(Slot: Integer): Integer;   // 0 = слот пуст
    function  SliceSlotOf(Id: Integer): Integer;       // -1 = нет такого слайса
    // Auto TX слайса (см. FSliceAutoTx). Геттер по слоту — для UI настроек,
    // по Id — для флага слайса и CAT.
    function  SliceSlotAutoTx(Slot: Integer): Boolean;
    procedure SetSliceSlotAutoTx(Slot: Integer; On_: Boolean);
    function  SliceAutoTx(Id: Integer): Boolean;
    // Аудио-устройства слота. Сеттер применяет их к слайсу, который стоит на
    // слоте прямо сейчас (имя → PA-индекс текущей сессии); '' = общий.
    function  SliceSlotOutDev(Slot: Integer): string;
    function  SliceSlotInDev(Slot: Integer): string;
    procedure SetSliceSlotAudio(Slot: Integer; const OutDev, InDev: string);
    // Заявка на передачу ОТ ИМЕНИ слайса (CAT-порт слайса, TCI с номером
    // приёмника). Единственная точка, где внешняя PTT может сменить TX-источник,
    // поэтому здесь же — защита «в эфире одновременно только один слайс».
    // Tune = True — то же самое, но несущей настройки (TUN) вместо модуляции.
    // Выполнять в потоке контроллера.
    procedure RequestSliceTx(SliceId: Integer; On_: Boolean;
                             Tune: Boolean = False);
    // Диапазон захваченной полосы пана (0 = главный), куда можно ставить слайс.
    procedure CapturedSpan(out LoHz, HiHz: Double; PanId: Integer = 0);
    function  SliceFitsCapture(TargetHz: Double; PanId: Integer = 0): Boolean;
    // Границы ВКЛЮЧЁННОГО сейчас диапазона (трансвертер — его FreqBegin/FreqEnd,
    // иначе band-план бэкенда). False = диапазон неизвестен.
    function  CurrentBandEdges(out LoHz, HiHz: Double): Boolean;
    // Перестроить слайс на TargetHz В ПРЕДЕЛАХ текущего диапазона: если частота
    // вне захваченной полосы пана — двигаем окно DDC. False = отказ (другой
    // диапазон либо окно не подвинуть, не оглушив главный приёмник).
    function  TuneSliceInBand(Id: Integer; TargetHz: Double): Boolean;
    // Уведомление UI «частота слайса пришла извне»: перезалить флаг (цифры +
    // кромки фильтра), позиция флага и так живая.
    procedure SliceFreqChanged(Id: Integer);
    procedure SliceStateChanged(Id: Integer);

    // ---- Панадаптеры на аппаратных DDC (этап 3.1; UI/движок — 3.2/3.3) ----
    // Создаёт пан PanId (1..MAX_PANS-1) на своём DDC: FreqHz = центр в ВИДИМЫХ
    // Гц (как FVfoA/FCenterFreq: при активном XVTR транслируется в IF только
    // на границе с железом, XvtrTranslate), RateKHz ∈ PanRateAllowed (иначе
    // 384), ADCSource — АЦП платы. False = нет слота/бэкенд не умеет/не Running.
    function  AddPanDDC(PanId: Integer; FreqHz: Double; RateKHz: Word = 384;
                        ADCSource: Byte = 0): Boolean;
    procedure RemovePanDDC(PanId: Integer);
    procedure SetPanDDCFreq(PanId: Integer; FreqHz: Double);   // ретюн (HP-пакет)
    // Допустимое окно ВИДИМОЙ частоты пана: активный XVTR — его диапазон,
    // иначе пределы железа (UI-кламп drag/тюна панов N).
    procedure VisibleFreqBounds(out LoHz, HiHz: Double);
    // Вход/выход/перекалибровка XVTR при живых панах: клампит видимые частоты
    // панов в новое окно, перешивает freq-слова DDC (XvtrTranslate), слайсы
    // панов — к краям их полос захвата.
    procedure RefreshPanDDCs;
    // Rate ∈ сетке бэкенда (Caps.RatePresets) и кратен 48 кГц (движок).
    function  PanRateAllowed(RateKHz: Word): Boolean;
    // Смена rate живого пана: движок переоткрывает аккумулятор/анализатор/слайсы,
    // DDC реконфигурируется; слайсы вне новой полосы клампятся к её краям.
    function  SetPanDDCRate(PanId: Integer; RateKHz: Word): Boolean;
    // Переезд пана на другой аппаратный DDC (пан/слайсы/движок не пересоздаются,
    // меняется только hw-индекс): бывает, что конкретный DDC платы ведёт себя
    // хуже соседей — тогда пан можно просто увести на другой.
    function  MovePanDDC(PanId, NewDDC: Integer): Boolean;
    function  PanDDCIndex(PanId: Integer): Integer;   // hw-индекс DDC пана
    function  MaxDDCIndex: Integer;                   // последний DDC железа
    function  DDCFree(DDCIdx: Integer): Boolean;      // индекс свободен?
    function  PanDDCActive(PanId: Integer): Boolean;
    function  PanDDCFreq(PanId: Integer): Double;
    function  PanDDCRateKHz(PanId: Integer): Word;
    // ADC-источник пана (3.4). PanId=0 = главный DDC (реконфиг ConfigureDDCs),
    // N>0 = его пан-DDC (SetPanDDC). ADC ∈ [0..Caps.NumADCs-1].
    // Антенные разъёмы AD936x: настройки (вкладка Antenna) + применение текущего
    // контекста (диапазон | активный трансвертер) к железу.
    procedure SetAnt936x(const A: TAD936xAnt);
    procedure ApplyAntPorts;
    property  Ant936x: TAD936xAnt read FAnt936x;
    function  PanADC(PanId: Integer): Byte;
    function  SetPanADC(PanId: Integer; ADC: Byte): Boolean;
    // ---- Зум/пан доп. панов (PanId=0 делегирует главному зуму) ----
    // Видимое окно пана — та же формула, что span-clip его анализатора.
    procedure GetPanViewWindow(PanId: Integer; out ACenter, ASpan: Double);
    procedure SetPanZoomPan(PanId: Integer; AZoom, APan: Double);
    procedure PanZoomIn(PanId: Integer);       // шаг по лестнице, якорь = центр окна
    procedure PanZoomOut(PanId: Integer);
    procedure PanZoomDefault(PanId: Integer);
    function  PanZoomFactor(PanId: Integer): Double;
    function  PanPanSlider(PanId: Integer): Double;

    // Pluto TX-мощность через аттенюацию: drive% → дБ (потолок FPlutoTxMaxAttDb на
    // 100%, выкл на приёме). Логика/маппинг здесь; бэкенд лишь пишет hardwaregain.
    function  PlutoTxAttForDrive: Double;   // дБ<0 под текущий FDrivePercent
    procedure ApplyPlutoTxAtten;            // ставит att по FTransmitting/FTuning
    procedure SetPlutoTxMaxAtt(Db: Double); // потолок на drive=100% (внешний усилитель)
    procedure SetCalibration(const C: TCalibration); // вкладка Calibration → FCal (офсеты S-метра — в SMeterCalFor)
    procedure ApplyWDSPDrive(Pct: Integer); // цифровой drive WDSP: Pluto=1.0 (мощность — атт), HPSDR=Pct/100

    // Диапазоны / трансвертеры / спан
    procedure SetBand(Idx: Integer);
    procedure BandUp;
    procedure BandDown;
    procedure SetXvtrBand(Idx: Integer);       // -1 = выход в HF
    procedure SetCurrentBandIdx(NewBand: Integer); // лёгкая смена HF-диапазона (band+drive+рендер), без restore
    procedure SetSpan(Hz: Integer);
    procedure SetSampleRate(Hz: Integer);

    // ---- Zoom/pan спектра и водопада ----
    // GetViewWindow — единая истина «видимого окна» (центр+span в Гц) для всех
    // оверлеев и маппинга мыши: при зуме отдаёт окно из WDSP-анализатора, иначе
    // базовые FCenterFreq/FSpanHz.
    procedure GetViewWindow(out ACenter: Double; out ASpan: Double);
    procedure ApplyZoom(AZoom, APan: Double);   // задать зум+пан, сохранить в band, Changed(rfZoom)
    procedure ZoomIn;                           // шаг увеличения (якорь — маркер активного VFO)
    procedure ZoomOut;                          // шаг уменьшения
    procedure ZoomDefault;                      // сброс в 1.0 (весь span)
    function  ZoomFactor: Double;
    function  PanSlider: Double;

    // QO-100 beacon lock — стабилизация дрейфа LNB по опорному маяку.
    // QO-100 beacon lock (= декодер + трим LOError; декодер = эталон частоты).
    procedure SetBeaconLock(On_: Boolean);          // вкл/выкл лок (запускает декодер)
    procedure ServiceBeaconLock;          // одна итерация контура (MeterTimer @10 Гц)
    procedure SetBeaconRefHz(Hz: Double); // опорная частота маяка (downlink)
    procedure BeaconSeedAtHz(DisplayHz: Double);  // клик по маяку → наведение декодера
    function  BeaconState: TBeaconState;
    function  BeaconCorrectionHz: Double; // коррекция за сессию лока (индикация; в LOError)
    function  BeaconSNRdB: Double;
    function  BeaconLockEnabled: Boolean;
    function  BeaconRefHz: Double;        // опорная частота маяка (для маркера)
    function  BeaconTrackedFreqHz: Double;// измеренная display-частота маяка (наведение+остаток)
    function  BeaconDecodeFreqHz: Double;             // частота наведения (для узкого маркера)
    procedure SetBeaconDecodeInvert(On_: Boolean);    // зеркальный baseband вкл/выкл
    function  BeaconDecodeInvert: Boolean;
    function  GetBeaconScope(out S: TBeaconScope): Boolean;
    function  GetBeaconFrame(out Frame: TBeaconFrame; out RSErrors: Integer): Boolean;
    // --- Лупа маяка (окно наведения в BeaconScopeForm) -------------------
    // Отдельный WDSP-анализатор на IQ главного тракта, вырезающий узкое окно
    // вокруг маяка: на общей спектрограмме 576к маяк — пара пикселей, точно
    // ткнуть в него нельзя. Все частоты — АБСОЛЮТНЫЕ display-Гц, как у
    // BeaconSeedAtHz. Зовётся с таймера окна маяка; окно закрыто → Enabled=False
    // и анализатор уничтожается.
    procedure SetBeaconZoomWindow(AEnabled: Boolean; ACenterHz, ASpanHz: Double);
    function  GetBeaconZoomSpectrum(var Pixels: array of Single;
                out Count: Integer; out LowHz, HighHz: Double): Boolean;
    function  GetBeaconZoomWaterfall(var Pixels: array of Single;
                out Count: Integer): Boolean;
    // Полоса захвата (display-Гц): за её пределы лупу не увести.
    procedure GetCaptureWindow(out ALowHz, AHighHz: Double);
    function  BeaconZoomBinHz: Double;   // разрешение лупы, Гц/бин (для подписи)
    function  GetDMRStatus(out S: TDMRStatus): Boolean;
    function  GetSliceDMRStatus(SliceId: Integer; out S: TDMRStatus): Boolean;

    // Отображение / приём
    procedure SetCTun(On_: Boolean);
    procedure SetDuplex(On_: Boolean);
    procedure SetRxMuteOnTx(On_: Boolean);    // QO-100 self-monitor (кнопка RX MUTE)
    procedure ApplyRxMuteToEngine;            // FRxMuteOnTx → FDSPEngine.MonitorRXDuringTX
    procedure SetWfAGC(On_: Boolean);
    procedure SetWfNF(On_: Boolean);

    // DSP-фильтры шума
    procedure SetNR(Mode: Integer);
    procedure SetNB(Mode: Integer);
    procedure SetSNB(On_: Boolean);
    procedure SetANF(On_: Boolean);

    // Передача
    procedure SetMOX(On_: Boolean);
    procedure SetTune(On_: Boolean);           // TUN

    // ---- Телеграф ----------------------------------------------------------
    // Настройки применяются целиком: pitch уезжает в движок (там он двигает
    // гетеродин и полосу), остальное — в DUC Specific прошивке.
    // «Железо сейчас в эфире» — ДЛЯ ИНДИКАЦИИ (S-метр, лампочки), не для тракта.
    // В телеграфе FTransmitting не поднимается: передачей владеет кейер прошивки,
    // и единственный признак эфира — HPS_PTT в статусе.
    function  RadioKeyed: Boolean;
    // Фронты RadioKeyed → индикация. Зовётся из разбора HP-статуса (openHPSDR) и
    // с UI-таймера: у Pluto статуса нет вовсе, а телеграф там локальный.
    procedure ServiceCWKeyed;
    function  CWSettings: TCWSettings;
    procedure SetCWSettings(const C: TCWSettings);
    // Программная передача текста: PC сам выдерживает тайминги и дёргает бит
    // CWX в High Priority — точки/тире рисует прошивка по этому биту.
    // Занят ли передатчик — CWXBusy; ЛЮБОЕ касание
    // манипулятора или снятие MOX обрывает передачу (CWXAbort).
    procedure CWXSend(const Text: string);
    // Чужая манипуляция: один готовый интервал (посылка или пауза) заданной
    // длины. Так приходит телеграф по TCI (команда KEYER) — знаков там нет,
    // есть измеренные клиентом длительности. См. TCWElemPlayer.
    procedure CWKeyerElement(Mark: Boolean; Ms: Integer);
    procedure CWXAbort;
    function  CWXBusy: Boolean;
    // ---- Окно-терминал -----------------------------------------------------
    // Стереть последний НЕ ушедший в эфир знак (Backspace при наборе).
    function  CWXBackspace: Boolean;
    // Что ещё стоит в очереди передачи (строка набора показывает именно это).
    function  CWXPendingText: string;
    // Ключ с клавиатуры: при иамбике — лепестки, при прямом ключе — уровень.
    // На кейере прошивки лепестков нет (их читает FPGA со своего разъёма),
    // поэтому там клавиатура работает через бит CWX, то есть прямым ключом.
    procedure CWKeyboardKey(Dot, Dash: Boolean);
    // Разбор принимаемого телеграфа: включение и обслуживание (таймер хозяина).
    procedure SetCWDecoder(On_: Boolean);
    function  CWDecoderOn: Boolean;
    procedure ServiceCWDecoder;
    // Последние знаки ленты (для полосы под спектром) и счётчик изменений.
    function  CWLogTail(MaxChars: Integer): string;
    property  CWLogSeq: Int64 read FCWLogSeq;
    // Оценка скорости корреспондента и расстройка тона (индикация в терминале).
    function  CWDecSpeedWPM: Integer;
    function  CWDecToneOffsetHz: Integer;
    function  CWDecSignal: Boolean;
    property  OnCWLog: TCWLogEvent read FOnCWLog write FOnCWLog;

    // ---- PureSignal ----
    // Доступность PS у активного бэкенда/платы (Caps.HasPureSignal).
    function  PSAvailable: Boolean;
    // Вкл/выкл авто-калибровки PureSignal (кнопка PS). Вооружает feedback-тракт
    // (DDC0/1 на TX) и WDSP calcc automode; выкл = reset коррекции.
    procedure SetPureSignal(On_: Boolean);
    procedure PSSingleCal;                     // одиночная калибровка
    procedure PSReset;                         // выключить и сбросить коррекцию
    // Тик PS (~10 Гц, из UI-таймера): автомат команд, опрос GetPSInfo и
    // автоматическая регулировка AttOnTX по уровню обратной связи.
    procedure PureSignalTick;
    procedure ApplyPSSettingsToDSP;            // FTXSettings.PS* → WDSP calcc
    // Вооружение/снятие feedback-тракта (сеть + движок), идемпотентно.
    procedure PSSetFeedbackPath(En: Boolean);
    // Статус для UI (кэш последнего PureSignalTick)
    function  PSFeedbackLevel: Integer;        // info[4], оптимум ~PSFBTarget
    // Эффективная цель уровня feedback: настройка ps_fb_target, либо авто по
    // плате (Orion MkII/ANAN-7000/8000 → 22 — выше перегружается вход ADC,
    // замеры на Anvelina PRO3; для остальных используется значение 152.
    function  PSFBTarget: Integer;
    function  PSCalCount: Integer;             // info[5]
    function  PSCorrecting: Boolean;           // info[14]=1 — коррекция применяется
    function  PSMaxTXVal: Double;              // GetPSMaxTX (пик TX для SetPk)
    function  PSSingleCalActive: Boolean;      // идёт одиночная калибровка

    // 2TON: двухтональник (PostGen two-tone) + MOX — тест IMD / проверка PS.
    procedure SetTwoTone(On_: Boolean);

    procedure SetSplit(TxOnB: Boolean);        // split: TX на VFO-B (иначе VFO-A)
    procedure SetVfoLock(On_: Boolean);        // блокировка перестройки VFO

    // FM
    procedure SetFMStep(Idx: Integer);
    procedure SetFMCTCSS(On_: Boolean);
    procedure SetFMCTCSSTone(Idx: Integer);
    procedure SetFMSquelch(On_: Boolean);
    procedure SetFMSquelchLevel(V: Integer);
    procedure SetFMRpt(Dir: Integer);         // явное направление; снимает авто-флаг
    procedure SetFMRptOffset(Hz: Double);     // сдвиг репитера; применяет, если RPT вкл
    procedure UpdateRptAutoState;             // авто-MINUS для 2 м под FVfoA

    // Каналы: применение/деактивация каналов вынесено в TChannelController
    // (отдельный юнит-адаптер поверх контроллера), оркеструет команды ниже.

    // Жизненный цикл движка / устройства
    procedure StartStop;
    procedure SetRun(On_: Boolean);
    procedure StartRunning(SpectrumWidth: Integer);   // Run=1 bring-up: General/DDC/DUC + DSP restore
    procedure StopRunning;   // Run=0: graceful-стоп RX-потока + сброс TX/телеметрии
    function  ConnectDevice(const Dev: THPSDRDevice): Boolean;  // Connect + load-by-MAC; рендер через rfDevice
    function  ConnectAndRun(const Dev: THPSDRDevice; SpectrumWidth: Integer): Boolean;  // ConnectDevice + band/xvtr/vfo restore + go-live
    procedure StopAndDisconnect;   // graceful Run=0 + персист настроек + Disconnect (headless)
    // По IP/URI ищет устройство в store (discovered→saved), восстанавливая
    // Kind/URI/Serial/BoardType/MAC. Нужно для Pluto (открывается по URI, выбор
    // бэкенда — по Kind). Не найдено → простой HPSDR по IP. Общий путь GUI+демон.
    function  ResolveDevice(const IP: string): THPSDRDevice;
    // Добирает MAC устройства, если он неизвестен (старт по сохранённому IP /
    // AutoStart идёт без дискавери). MAC — ключ профиля настроек, без него
    // настройки уезжают в «нулевой» профиль. Источники по порядку: список
    // найденного в этой сессии → MAC из hpsdr_devices.ini → короткий поиск в
    // сети (ответ discovery несёт MAC). False = добыть не удалось.
    function  EnsureDeviceMac(var Dev: THPSDRDevice): Boolean;
    // Сохраняет устройство по введённому адресу. Схема URI ('ip:'/'usb:'/'local:')
    // → Pluto (AddSavedPluto), иначе HPSDR-IP. Общий путь web-dev_add для GUI и
    // демона (десктоп-диалог сохраняет Pluto своим путём через AddSavedPluto).
    procedure AddSavedDevice(const AName, AAddr: string);
    procedure Discover;
    // Гарантирует, что FNetwork — бэкенд нужного типа (HPSDR/Pluto): при
    // несовпадении пересоздаёт его, перенося колбэки данных. Вызывается из
    // ConnectDevice по Dev.Kind. Discovery использует транзиентные экземпляры.
    procedure EnsureBackend(Kind: TBackendKind);
    // Возможности текущего бэкенда (для гейтинга UI / пресетов sample rate).
    function  BackendCaps: TBackendCaps;
    // Единый источник пресетов sample-rate для UI (desktop overlay + web).
    // Берётся из caps текущего бэкенда (HPSDR 48k..1536k / Pluto 576k..5760k).
    function  SampleRatePresets: TBackendRateArray;
    // Единый источник band-плана для UI (HF / VHF-UHF по типу бэкенда).
    function  BandPlan: TBandPlanArray;
    // True если активный бэкенд — Pluto (VHF/UHF band-план вместо HF).
    function  IsPluto: Boolean;
    // True — активен QO-100 (Pluto + full-duplex XVTR-слот); гейт UI beacon.
    function  InQO100: Boolean;
    // True — включён режим DUP (дисплей+RX держатся на TX). Гейт активности
    // кнопки RX MUTE (self-monitor) на ЛЮБОМ бэкенде — без DUP звук на TX не идёт.
    function  DisplayDuplex: Boolean;
    // Человекочитаемое имя подключённой платы для статус-строки (UI/Web).
    // HPSDR → тип платы; Pluto/LibreSDR → hw_model + серийник (если есть).
    function  BoardDisplayName: string;
    // Опрос аппаратной телеметрии Pluto (температура/RSSI) с троттлингом — зовётся
    // с UI-таймера (MeterTimer @10 Гц), как ServiceBeaconLock. No-op для HPSDR.
    // Результат — в FLastPlutoTempC/FLastPlutoRSSIdB (UI читает и форматирует).
    procedure ServicePlutoTelemetry;
    // Список URI для probe сетевых Pluto (mDNS недоступен) — задаёт фронтенд
    // перед Discover (сохранённые Pluto + ручной ввод IP).
    procedure SetPlutoProbeURIs(const URIs: array of string);
    // Сидирует probe-список из сохранённых Pluto (для бэкенд-агностичного Discover
    // без UI-диалога — демон). GUI добавляет ручной IP поверх в BuildProbeURIs.
    procedure SeedPlutoProbeFromSaved;

    // ---- События наружу ----
    property OnInvoke: TUIInvokeEvent read FOnInvoke write FOnInvoke;
    property OnStateChanged: TRadioStateEvent read FOnStateChanged write FOnStateChanged;
    // Доп. наблюдатели состояния (помимо единственного OnStateChanged, который
    // занимает MainForm). Нужны CAT/Andromeda для push индикаторов панели.
    procedure AddStateListener(L: TRadioStateEvent);
    // Снять наблюдателя. Обязателен для тех, кто умирает раньше контроллера
    // (TCI-адаптер): иначе Changed() позовёт метод освобождённого объекта.
    procedure RemoveStateListener(L: TRadioStateEvent);
    // Пост-тюн хук: вызывается в КОНЦЕ SetVfoA (после Changed(rfVfoA), т.е. вне
    // рендера) — сюда вешается канальная оркестрация (ChannelController.OnVfoTuned),
    // чтобы любой путь тюнинга (desktop/web/CAT/энкодер) был канал-aware headless.
    property OnAfterTune: TThreadMethod read FOnAfterTune write FOnAfterTune;
    property OnBeforeStop: TThreadMethod read FOnBeforeStop write FOnBeforeStop;
    property OnAudioConsume: TAudioConsumeEvent read FOnAudioConsume write FOnAudioConsume;
    // Тапы RX-аудио: слушают, ничего не забирая (см. TRadioAudioTapEvent).
    // Снимать обязан тот, кто умирает раньше контроллера.
    procedure AddAudioTap(T: TRadioAudioTapEvent);
    procedure RemoveAudioTap(T: TRadioAudioTapEvent);
    // Тап сырого RX-IQ (потоки IQ по TCI). Пас в движок: он же и владелец
    // потока, из которого тап зовётся. nil — снять.
    procedure SetIQTap(T: TIQTapEvent);
    // Частота дискретизации источника IQ для приёмника: главный тракт — rate
    // устройства, доп. пан — rate его DDC.
    function  IQTapRateHz(PanId: Integer): Integer;
    // TX-аудио от TCI-клиента: 48 кГц моно в тот же ринг, что и web-микрофон.
    procedure PushTCIAudio(const Samples: array of Double; N: Integer);
    // Локальный звук ПК (динамик RX / микрофон TX). Демон ставит False: аудио
    // только через web (OnAudioConsume), локальная звуковая карта не трогается.
    property LocalAudioEnabled: Boolean read FLocalAudio write FLocalAudio;
    // Клиент TCI просит брать модуляцию из своего аудиопотока (TRX:0,true,tci,
    // §4.2). Ставит адаптер, читает SetMOX при выборе источника.
    property TCIMicRequested: Boolean read FTCIMicRequested write FTCIMicRequested;
    // Сколько звука клиент TCI держит в авансе (отсчётов 48 кГц). Ставит
    // адаптер по измеренной зернистости клиента; SetMOX по этому числу
    // растягивает нулевой pre-roll DUC.
    property TCITxLeadFrames: Integer read FTCITxLeadFrames write FTCITxLeadFrames;
    // Идёт ли текущая передача с модуляцией из TCI (адаптеру — гнать ли
    // маркеры TX_CHRONO).
    property TCIMicActive: Boolean read FTCIMicActive;
    property OnSpectrumData: TPixelDataEvent read FOnSpectrumData write FOnSpectrumData;
    property OnWaterfallData: TPixelDataEvent read FOnWaterfallData write FOnWaterfallData;
  end;

implementation

const
  // Амплитуда TUN-тона (WDSP PostGen). НЕ 1.0: на резком старте тона фильтры
  // TXA-тракта дают переходный выброс ~×1.18 (замерено: peak=1.176 при
  // mag=1.0) поверх полной шкалы → жёсткий кламп 24-бит → сплэттер на старте
  // TUN. 0.83×1.18≈0.98 — выброс остаётся в шкале. Мощность TUN задаёт drive
  // (TUNLevel), падение уровня IQ ~1.6дБ компенсируется калибровкой TUNLevel.
  TUN_TONE_MAG = 0.83;

  // Состояния автомата команд PureSignal. Восстановление сохранённой
  // коррекции не реализовано.
  PS_ST_OFF           = 0;
  PS_ST_TURNON_AUTO   = 1;
  PS_ST_AUTOCAL       = 2;
  PS_ST_TURNON_SINGLE = 3;
  PS_ST_SINGLECAL     = 4;
  PS_ST_STAYON        = 5;
  PS_ST_TURNOFF       = 6;

constructor TDMRSliceDecoder.Create(AOwner: TRadioController;
  ASliceId: Integer);
begin
  inherited Create;
  FOwner := AOwner;
  FSliceId := ASliceId;
  FDecoder := TDMRDecoder.Create;
  FDecoder.SetInverted(True);
  FDecoder.OnAudio := AudioReady;
  FDecoder.SetEnabled(True);
end;

destructor TDMRSliceDecoder.Destroy;
begin
  if Assigned(FDecoder) then
  begin
    FDecoder.SetEnabled(False);
    FDecoder.OnAudio := nil;
    FreeAndNil(FDecoder);
  end;
  FOwner := nil;
  inherited Destroy;
end;

procedure TDMRSliceDecoder.FeedAudio(const Left, Right: array of Single;
  Count: Integer);
begin
  if Assigned(FDecoder) then FDecoder.FeedAudio(Left, Right, Count);
end;

procedure TDMRSliceDecoder.GetStatus(out S: TDMRStatus);
begin
  if Assigned(FDecoder) then FDecoder.GetStatus(S)
  else FillChar(S, SizeOf(S), 0);
end;

procedure TDMRSliceDecoder.AudioReady(const PCM: array of Single;
  Count: Integer);
begin
  if Assigned(FOwner) then FOwner.OnSliceDMRAudioReady(FSliceId, PCM, Count);
end;

constructor TRadioController.Create;
begin
  inherited Create;
  FSettings := TSettingsManager.Create;   // владелец настроек (GUI и демон)
  FDeviceStore := TDeviceStore.Create;    // общий список устройств (desktop+web)
  FAudioTapLock := TCriticalSection.Create;
  FAudioTapCount := 0;
  // Дефолты (дублируют TMainForm.FormCreate; в GUI перезапишутся, в демоне нужны).
  FVfoA := 14200000; FVfoB := 7100000; FActiveVfo := 0;
  FMode := MODE_USB; FFilter := 5;
  DefaultFilterTable(FFilterTable);   // до подключения — заводские фильтры
  ApplyFilterSlot(FFilter);
  FAGCMode := 1; FAGCTop := 90;
  TSettingsManager.DefaultCW(FCWSettings);   // до подключения — заводской телеграф
  FLastCWOptions := $FF;                     // «ещё не слали» — первый пуш пройдёт
  FRxGainMode := 2; FRxGainDb := 40;   // Pluto: slow_attack по умолчанию
  FRxMuteOnTx := True;                  // QO-100 self-monitor выкл по умолчанию (RX заглушён на TX)
  FVolume := 70; FTxMonVolume := 40; FDrivePercent := 50; FDriveLevel := 0; FAtten := 0;
  FCenterFreq := FVfoA; FSpanHz := 192000; FSampleRate := 192000;
  FNextSliceId := 1;                        // мультислайсы: генератор id
  FillChar(FSlices, SizeOf(FSlices), 0);
  ResetPanDDCMaps;                          // паны: все слоты свободны
  FZoomFactor := 0.0; FPanSlider := 0.5;   // 0.0 = весь span (без зума)
  FCurrentBand := 5; FCurrentXvtr := -1;
  // Кадра ещё не было — 0 точек. Ненулевой счётчик заставил бы web рисовать
  // нулевой (= 0 dBm, полная шкала) буфер белой полосой.
  FSpectrumBufCount := 0; FWaterfallBufCount := 0; FWfAccumFresh := False;
  FWebSpecPixels := 1024;   // дефолт web.spec_pixels; хост переопределит из настроек
  FPAMaxPower := 100.0;
  FPlutoTxMaxAttDb := -10.0;   // ADI-дефолт: линейный режим выходных каскадов
  FWfAGCEnabled := True; FDitherEnabled := True; FRandomEnabled := True;
  FWfPalette := 0; FWfGamma := 1.0; FDisplayPixels := 4096;
  FWfSpeedDiv := 1; FSpecMSAA := 4;
  FFMStepOn := True;
  FRawFMDeviation := RAW_FM_DEV_DEFAULT;
  FLocalAudio := True;   // GUI: звук на локальную карту; демон выключит явно
  TSettingsManager.DefaultCalibration(FCal);
  FLastSMeter := -130; FLastSWR := 1; FLastSupplyV := -1; FLastSupplyA := -1;
  FLastPlutoTempC := TELEMETRY_NONE; FLastPlutoSocTempC := TELEMETRY_NONE;
  FLastPlutoRSSIdB := TELEMETRY_NONE;
  FLastPlutoGainDb := TELEMETRY_NONE;
  FPlutoTeleTick := 0; FPlutoTempBad := 0;
  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);
  // Послайсовые флаги «этот слайс слушают» сюда не входят: слайсы пересозданный
  // движок не наследует вовсе (их заводит заново тот, кто их создавал), а
  // владелец потоков расставит флаги на первом же обновлении карты слайсов.
  // Декодер маяка QO-100 создаётся позже самого движка (в CreateEngines ниже),
  // зато при ПЕРЕсоздании движка он уже есть и его надо вернуть на место.
  if Assigned(FBeaconDec) then FDSPEngine.SetBeaconDecoder(FBeaconDec);
end;

procedure TRadioController.CreateEngines(ASampleRate: Integer);
// Создаёт движки в полях контроллера. Колбэки (OnAudio/OnSpectrum/OnHPStatus…)
// навешивает вызывающий после возврата — они указывают на UI/демон.
begin
  FNetwork := THPSDRNetwork.Create;

  // BufSize=512: латентность 512/48000 ≈ 10.7мс — допустимо для SDR; меньший
  // буфер вдвое сокращает построение downsampler в OpenChannel RXA.
  FDSPEngine := TWDSPEngine.Create(ASampleRate, 48000, 512);
  AttachEngineCallbacks;

  // QO-100 beacon lock: эталон частоты = декодер (см. ServiceBeaconLock).
  FBeaconRefHz   := 10489750000.0;   // центральный BPSK-маяк NB-транспондера (downlink)
  FBeaconLockHz  := 0.0;
  FBeaconLockOn  := False;

  // Декодер маяка: фронтенд BPSK 400 бод (тап IQ в движке).
  FBeaconDec := TBeaconDecoder.Create;
  // Тракт QO-100 (LNB+трансвертер) зеркалит спектр: проверено приборно (symMag ×4
  // и prom 500-800 при invert=ON против шума при OFF, на наведении точно на маяк).
  // Декодеру нужно сопряжение, чтобы наведённый offset доставлял маяк к DC.
  FBeaconDecInvert := True;
  FBcnZoomSpanHz   := 40000.0;
  FBcnZoomOffsetHz := 0.0;
  FBcnZoomFollow   := True;
  FBcnZoomAuto     := False;
  FBeaconDec.SetInvert(FBeaconDecInvert);
  FDSPEngine.SetBeaconDecoder(FBeaconDec);

  // DMR front-end owns a worker thread. It stays idle until MODE_DMR is active.
  FDMRDec := TDMRDecoder.Create;
  // WDSP's FM discriminator polarity on the EWSDR RX path is opposite to the
  // DMR dibit convention: captured handheld MS voice sync appears as MS data
  // without this inversion (the two sync words are complements).
  FDMRDec.SetInverted(True);
  FDMRDec.OnAudio := OnDMRAudioReady;

  // Audio out/in — объекты создаём сейчас, Open вызывается позже (после показа
  // формы / при подключении устройства).
  FAudioOut := TAudioOutput.Create(48000);
  FAudioOut.OutputBufferSize := FSettings.LoadAudioBufferSize;
  FAudioIn  := TAudioInput.Create(48000);
end;

procedure TRadioController.FreeEngines;
var i: Integer;
begin
  // Stop new slice callbacks before their decoder/audio destinations vanish.
  if Assigned(FDSPEngine) then
  begin
    FDSPEngine.OnSliceAudio := nil;
    FDSPEngine.OnSliceDemodAudio := nil;
  end;
  // Stop the DMR worker before destroying any of its audio destinations.
  if Assigned(FDMRDec) then
  begin
    FDMRDec.SetEnabled(False);
    FDMRDec.OnAudio := nil;
    FreeAndNil(FDMRDec);
  end;
  if Assigned(FNetwork) then
  begin
    if FNetwork.Connected then FNetwork.Disconnect;
    FreeAndNil(FNetwork);
  end;
  // RemoveSlice takes the DSP slice lock. Besides closing the WDSP channel it
  // waits out a callback that might already have entered before we detached it.
  if Assigned(FDSPEngine) then
    for i := 0 to MAX_SLICES - 1 do
      if FSlices[i].Active then FDSPEngine.RemoveSlice(FSlices[i].Id);
  // Слайсы: каналы уже закрыты, останавливаем декодеры и аудио.
  for i := 0 to MAX_SLICES - 1 do
    if FSlices[i].Active then
    begin
      FreeAndNil(FSlices[i].DMR);
      if FSlices[i].Audio <> nil then
      begin
        FSlices[i].Audio.Close;
        FreeAndNil(FSlices[i].Audio);
      end;
      FSlices[i].AudioIn := nil;   // владелец — mic-пул (FreeMicPool ниже)
      FSlices[i].Active := False;
    end;
  FreeMicPool;
  if Assigned(FAudioOut) then begin FAudioOut.Close; FreeAndNil(FAudioOut); end;
  if Assigned(FAudioIn)  then begin FAudioIn.Close;  FreeAndNil(FAudioIn);  end;
  if Assigned(FDSPEngine) then
  begin
    FDSPEngine.SetBeaconDecoder(nil);
    FDSPEngine.Close;
    FreeAndNil(FDSPEngine);
  end;
  if Assigned(FBeaconDec) then FreeAndNil(FBeaconDec);
end;

destructor TRadioController.Destroy;
begin
  // Потоки телеграфа гасим ПЕРВЫМИ: их колбэки лезут в бэкенд и в движок.
  // Порядок — порт ключа → генератор: иначе лепесток успел бы разбудить уже
  // разрушаемый генератор.
  if Assigned(FCWKeyPort) then FreeAndNil(FCWKeyPort);
  if Assigned(FCWLocal) then
  begin
    FCWLocal.SetArmed(False);
    FreeAndNil(FCWLocal);
  end;
  if Assigned(FCWSender) then
  begin
    FCWSender.AbortSending;
    FreeAndNil(FCWSender);
  end;
  if Assigned(FCWElem) then
  begin
    FCWElem.AbortPlay;
    FreeAndNil(FCWElem);
  end;
  if Assigned(FCWDec) then FCWDec.Enabled := False;   // кольцо больше не кормим
  FreeEngines;
  // Декодер — ПОСЛЕ движка: его кольцо наполняет DSP-поток, и освобождать
  // объект, пока поток жив, нельзя.
  if Assigned(FCWDec) then FreeAndNil(FCWDec);
  FreeAndNil(FDeviceStore);
  FreeAndNil(FSettings);
  // Лок тапов — последним: до FreeEngines по нему ходит DSP-поток.
  FreeAndNil(FAudioTapLock);
  inherited Destroy;
end;

procedure TRadioController.Changed(Field: TRadioField);
var i: Integer;
begin
  if Assigned(FOnStateChanged) then FOnStateChanged(Self, Field);
  for i := 0 to High(FStateListeners) do
    if Assigned(FStateListeners[i]) then FStateListeners[i](Self, Field);
end;

procedure TRadioController.AddStateListener(L: TRadioStateEvent);
begin
  if not Assigned(L) then Exit;
  SetLength(FStateListeners, Length(FStateListeners) + 1);
  FStateListeners[High(FStateListeners)] := L;
end;

procedure TRadioController.AddAudioTap(T: TRadioAudioTapEvent);
begin
  if not Assigned(T) then Exit;
  FAudioTapLock.Enter;
  try
    SetLength(FAudioTaps, Length(FAudioTaps) + 1);
    FAudioTaps[High(FAudioTaps)] := T;
    FAudioTapCount := Length(FAudioTaps);
  finally
    FAudioTapLock.Leave;
  end;
end;

procedure TRadioController.RemoveAudioTap(T: TRadioAudioTapEvent);
var i, j: Integer;
begin
  if not Assigned(T) then Exit;
  FAudioTapLock.Enter;
  try
    for i := 0 to High(FAudioTaps) do
      if (TMethod(FAudioTaps[i]).Code = TMethod(T).Code) and
         (TMethod(FAudioTaps[i]).Data = TMethod(T).Data) then
      begin
        for j := i to High(FAudioTaps) - 1 do FAudioTaps[j] := FAudioTaps[j + 1];
        SetLength(FAudioTaps, Length(FAudioTaps) - 1);
        FAudioTapCount := Length(FAudioTaps);
        Break;
      end;
  finally
    FAudioTapLock.Leave;
  end;
end;

procedure TRadioController.FireAudioTap(Kind: TRadioAudioKind;
  PanId, SliceId: Integer; const Left, Right: array of Single; Count: Integer);
// DSP-поток. Без единого тапа (обычный случай) — одна проверка целого и выход:
// лок в звуковом маршруте на каждый блок иначе стоил бы дороже самой работы.
var i: Integer;
begin
  if FAudioTapCount = 0 then Exit;
  FAudioTapLock.Enter;
  try
    for i := 0 to High(FAudioTaps) do
      if Assigned(FAudioTaps[i]) then
        FAudioTaps[i](Kind, PanId, SliceId, Left, Right, Count);
  finally
    FAudioTapLock.Leave;
  end;
end;

procedure TRadioController.SetIQTap(T: TIQTapEvent);
// Тап помним у себя: движок пересоздают (RecreateDSPEngine при смене sample
// rate до старта), и новый про тап не знал бы — поток IQ по TCI молча умирал
// на всю сессию. Восстанавливает его AttachEngineCallbacks.
begin
  FIQTap := T;
  if Assigned(FDSPEngine) then FDSPEngine.SetIQTap(T);
end;

function TRadioController.IQTapRateHz(PanId: Integer): Integer;
begin
  if PanId <= 0 then Result := FSampleRate
  else Result := PanDDCRateKHz(PanId) * 1000;
end;

procedure TRadioController.PushTCIAudio(const Samples: array of Double; N: Integer);
// Поток клиента TCI. Ринг микрофона в движке лок-фри и рассчитан ровно на это
// (тем же путём ходит web-микрофон); переполнение — дроп, как у железа.
begin
  if FWDSPReady and Assigned(FDSPEngine) then
    FDSPEngine.PushTXMicSamplesD(Samples, N);
end;

procedure TRadioController.RemoveStateListener(L: TRadioStateEvent);
var i, j: Integer;
begin
  if not Assigned(L) then Exit;
  for i := 0 to High(FStateListeners) do
    if (TMethod(FStateListeners[i]).Code = TMethod(L).Code) and
       (TMethod(FStateListeners[i]).Data = TMethod(L).Data) then
    begin
      for j := i to High(FStateListeners) - 1 do
        FStateListeners[j] := FStateListeners[j + 1];
      SetLength(FStateListeners, Length(FStateListeners) - 1);
      Exit;
    end;
end;

procedure TRadioController.PushNetworkState;
// Полный HP-кадр в радио: RX/TX частоты (через трансвертор), drive, состояние
// MOX. Вызывается командами, которые меняют центр/частоту/репитер-сдвиг.
begin
  if Assigned(FNetwork) and FNetwork.Connected and FNetwork.Running then
  begin
    // Состояние передачи для ЖЕЛЕЗА — RadioKeyed, а не FTransmitting: в
    // телеграфе ключ замыкает прошивка, и реле T/R с антенной и OC обязаны
    // ехать за ней. PTT в кадре этим не поднимается (он живёт в SendPTT).
    FNetwork.UpdateState(XvtrTranslate(FCenterFreq), TXTuneFreqHz,
                         FDriveLevel, RadioKeyed, True, True, FTuning, FTwoTone);
    FNetwork.SendFullHP;
  end;
end;

procedure TRadioController.OnDDCIQ(DDCIndex: Integer; const Data: TDDCIQPacket);
// Сетевой поток: трекинг seq + подача IQ активного DDC в WDSP. UI не трогаем —
// статус рисует таймер по полям ниже. Backend-агностичен (Pluto синтезирует
// тот же TDDCIQPacket).
var
  SamplesPerFrame: Integer;
  Seq, ExpectedSeq: LongWord;
  Delta: Int64;
begin
  Inc(FRXPacketCount);
  FRXLastPktTime := GetTickCount64;
  Seq := (LongWord(Data.Seq[0]) shl 24) or (LongWord(Data.Seq[1]) shl 16)
      or (LongWord(Data.Seq[2]) shl 8)  or  LongWord(Data.Seq[3]);

  if (DDCIndex >= Low(FDDCLastSeq)) and (DDCIndex <= High(FDDCLastSeq)) then
  begin
    if Seq <> 0 then
    begin
      ExpectedSeq := FDDCLastSeq[DDCIndex] + 1;
      if FDDCSeqValid[DDCIndex] and (Seq <> ExpectedSeq) then
      begin
        Inc(FSeqErrorCount);
        FLastSeqErrorDDC := DDCIndex;
        Delta := Int64(Seq) - Int64(ExpectedSeq);
        FLastSeqErrorDelta := Delta;
        FSeqOkStreak := 0;
      end
      else if FDDCSeqValid[DDCIndex] then
      begin
        if FSeqErrorCount > 0 then
        begin
          Inc(FSeqOkStreak);
          if FSeqOkStreak >= 20 then
          begin
            FSeqErrorCount := 0;
            FLastSeqErrorDDC := -1;
            FLastSeqErrorDelta := 0;
            FSeqOkStreak := 0;
          end;
        end;
      end;
    end;
    FDDCLastSeq[DDCIndex] := Seq;
    FDDCSeqValid[DDCIndex] := True;
  end;

  // Подаём IQ в DSP: активный DDC → главный тракт, пан-DDC → его пан (3.2),
  // DDC0 при вооружённом PureSignal на TX → feedback в pscc (DDC0 несёт
  // interleaved пары DDC0+DDC1; на PS-платах главный DDC ≥ 2, конфликта нет).
  if FWDSPReady then
  begin
    SamplesPerFrame := (Integer(Data.SamplesPerFrame[0]) shl 8) or
                        Integer(Data.SamplesPerFrame[1]);
    if SamplesPerFrame <= 0 then SamplesPerFrame := 238;  // fallback
    if (DDCIndex = 0) and FPSFeedbackOn and (FActiveDDC <> 0) then
    begin
      if FTransmitting then
        FDSPEngine.PushPSFeedback(Data.IQData, 0, SamplesPerFrame);
    end
    else if DDCIndex = FActiveDDC then
      FDSPEngine.PushDDCPacket(Data.IQData, 0, SamplesPerFrame)
    else if (DDCIndex >= 0) and (DDCIndex < MAX_DDCS)
            and (FDDCPanMap[DDCIndex] > 0) then
      FDSPEngine.PushPanIQ(FDDCPanMap[DDCIndex], Data.IQData, 0, SamplesPerFrame);
  end;

  // Запоминаем последний seq — статус обновит таймер (без Synchronize в hot path)
  FLastDDCSeq   := Seq;
  FLastDDCIndex := DDCIndex;
end;

procedure TRadioController.OnTXIQ(const Buf: array of Double; Count: Integer);
// WDSP TXA отдаёт IQ-пары @ TX rate. Копим хвост между вызовами и шлём только
// полные DUC-пакеты по 240 (зануление хвоста давало 47 Гц гул). Клампим в
// 24-bit, чтобы пики компрессора/EQ (чуть >1.0) не оборачивались через wrap.
// Backend-агностично: Pluto-backend в SendDUCIQ переведёт 24-bit→int16.
  function ScaleIQ24(V: Double): Integer;
  var S: Double;
  begin
    S := V * 8388607.0;
    if      S >  8388607.0 then Result :=  8388607
    else if S < -8388608.0 then Result := -8388608
    else                        Result := Round(S);
  end;
var
  i: Integer;
begin
  if not (Assigned(FNetwork) and FNetwork.Connected) then Exit;
  for i := 0 to Count - 1 do
  begin
    FDUCPendingI[FDUCPendingCount] := ScaleIQ24(Buf[i * 2]);
    FDUCPendingQ[FDUCPendingCount] := ScaleIQ24(Buf[i * 2 + 1]);
    Inc(FDUCPendingCount);
    if FDUCPendingCount >= 240 then
    begin
      FNetwork.SendDUCIQ(FDUCPendingI, FDUCPendingQ);
      FDUCPendingCount := 0;
    end;
  end;
end;

procedure TRadioController.OnHPStatus(const Status: THighPriorityStatus);
// Сетевой поток: декод HP-статуса (board-specific supply/power/SWR) → pending →
// маршалинг в ApplyHPStatus (сглаживание + overload + HWPTT уже на потоке UI/ядра).
const
  SWR_MIN_FWD_W = 1.0;   // ниже — fwd это ADC-шум, SWR не считаем
  SWR_CAP       = 9.9;
  BAND_6M       = 10;    // индекс 6m в HF-плане (160m..6m)
var
  FwdPwr, RevPwr: Word;
  SupplyV, SupplyA, FwdW, RevW, SWRV, Rho, DetK: Double;
  Bi: Integer;
  Is6m, IsTx: Boolean;
  KeyDot, KeyDash: Boolean;
begin
  FwdPwr  := (Status.FwdPwrAlex0Hi shl 8) or Status.FwdPwrAlex0Lo;
  RevPwr  := (Status.RevPwrAlex0Hi shl 8) or Status.RevPwrAlex0Lo;
  SupplyV := -1.0;
  SupplyA := -1.0;

  // Board 5 (Orion MkII): напряжение на UserADC0, ток на UserADC1.
  // Прочие платы — в SupplyVolts-байтах, без тока.
  if BoardSupportsSupplyVoltage(FNetwork.Device.BoardType) then
  begin
    if FNetwork.Device.BoardType = 5 then
      SupplyV := ADCToSupplyVolts(
                   (Status.UserADC0Hi shl 8) or Status.UserADC0Lo,
                   FNetwork.Device.BoardType)
    else
      SupplyV := ADCToSupplyVolts(
                   (Status.SupplyVoltsHi shl 8) or Status.SupplyVoltsLo,
                   FNetwork.Device.BoardType);
    if SupplyV >= 0 then SupplyV := SupplyV * FCal.SupplyVoltCal;

    if BoardSupportsSupplyCurrent(FNetwork.Device.BoardType) then
      SupplyA := ADCToSupplyCurrent(
                   (Status.UserADC1Hi shl 8) or Status.UserADC1Lo,
                   FNetwork.Device.BoardType,
                   FCal.AmpVoffMV, FCal.AmpSensMVA);
  end;
  // Детектор Alex-моста: константы по модели (вкладка Calibration) + per-band
  // множитель. Множитель общий для FWD/REV, поэтому КСВ от него не зависит
  // и используется только для калибровки показаний мощности.
  Bi   := TxBandIdx;
  Is6m := Bi = BAND_6M;
  DetK := 1.0;
  if (Bi >= 0) and (Bi < CFG_BAND_COUNT) and (FCal.PADetCal[Bi] > 0) then
    DetK := FCal.PADetCal[Bi];
  FwdW := AlexDetToWatts(FwdPwr, FCal.PwrDetModel, False, Is6m) * DetK;
  RevW := AlexDetToWatts(RevPwr, FCal.PwrDetModel, True,  Is6m) * DetK;
  // FTransmitting — программный флаг; HPS_PTT — вход HW-PTT (footswitch/mic).
  IsTx := FTransmitting or ((Status.StatusBits and HPS_PTT) <> 0);
  // SWR = (1+ρ)/(1-ρ), ρ = sqrt(RevW/FwdW); только при значимой
  // fwd-мощности.
  if IsTx and (FwdW >= SWR_MIN_FWD_W) then
  begin
    Rho  := Sqrt(RevW / FwdW);
    if Rho >= 0.99 then
      SWRV := SWR_CAP
    else
    begin
      SWRV := (1.0 + Rho) / (1.0 - Rho);
      if SWRV > SWR_CAP then SWRV := SWR_CAP;
      if SWRV < 1.0     then SWRV := 1.0;
    end;
  end else
    SWRV := 1.0;
  if not IsTx then FwdW := 0;

  FHPpFwdW     := FwdW;
  FHPpSWR      := SWRV;
  FHPpSupplyV  := SupplyV;
  FHPpSupplyA  := SupplyA;
  FHPpPLL      := (Status.StatusBits and HPS_PLL_LOCKED) <> 0;
  FHPpHWPTT    := (Status.StatusBits and HPS_PTT)        <> 0;
  FHPpOverload := Status.ADCOverload;
  // Состояние ключа/манипулятора в разъёме трансивера (сетевой поток).
  // Касание обрывает программную передачу, чтобы оператор мог вклиниться.
  // ★Обрываем по
  // ФРОНТУ, а не по уровню: на плате без подключённого ключа входы могут стоять
  // в единице, и проверка уровня убивала бы передачу на первом же HP-статусе.
  KeyDot  := (Status.StatusBits and HPS_DOT)  <> 0;
  KeyDash := (Status.StatusBits and HPS_DASH) <> 0;
  if ((KeyDot and not FCWPrevDot) or (KeyDash and not FCWPrevDash))
     and CWXBusy then CWXAbort;
  FCWPrevDot  := KeyDot;
  FCWPrevDash := KeyDash;
  // Программный сайдтон от лепестков верен ТОЛЬКО у прямого ключа: при иамбике
  // элементы считает FPGA, а PC видит лишь замыкание контакта.
  if (FCWSettings.KeyerMode = 0) and (ActiveTXMode in [MODE_CWL, MODE_CWU])
     and (not CWXBusy) then
    FCWKeyDown := KeyDot or KeyDash;
  Invoke(ApplyHPStatus);
end;

procedure TRadioController.ApplyHPStatus;
// Маршалится из OnHPStatus. PEP-style баллистика (мгновенная атака, медленный
// спад) для FwdW/SWR; EMA для supply. + overload-событие + HWPTT-фронт → SetMOX.
const
  DECAY_FWD    = 0.02;
  DECAY_SWR    = 0.015;
  ALPHA_SUPPLY = 0.05;
begin
  if FHPpFwdW <= 0 then
    FLastFwdW := 0
  else if FHPpFwdW > FLastFwdW then
    FLastFwdW := FHPpFwdW                                  // attack: instant
  else
    FLastFwdW := DECAY_FWD * FHPpFwdW + (1.0 - DECAY_FWD) * FLastFwdW;
  if FHPpSWR <= 1.0 then
    FLastSWR := 1.0
  else if FHPpSWR > FLastSWR then
    FLastSWR := FHPpSWR                                    // attack: instant
  else
    FLastSWR := DECAY_SWR * FHPpSWR + (1.0 - DECAY_SWR) * FLastSWR;
  if FHPpSupplyV >= 0 then
  begin
    if FLastSupplyV < 0 then
      FLastSupplyV := FHPpSupplyV   // первое значение — без сглаживания
    else
      FLastSupplyV := ALPHA_SUPPLY * FHPpSupplyV + (1.0 - ALPHA_SUPPLY) * FLastSupplyV;
  end else
    FLastSupplyV := -1.0;
  if FHPpSupplyA >= 0 then
  begin
    if FLastSupplyA < 0 then
      FLastSupplyA := FHPpSupplyA
    else
      FLastSupplyA := ALPHA_SUPPLY * FHPpSupplyA + (1.0 - ALPHA_SUPPLY) * FLastSupplyA;
  end else
    FLastSupplyA := -1.0;
  FLastPLLLock := FHPpPLL;

  // ADC overload — индикатор рисует UI по rfADCOverload (только на изменении).
  if FADCOverloadActive <> (FHPpOverload <> 0) then
  begin
    FADCOverloadActive := FHPpOverload <> 0;
    Changed(rfADCOverload);
  end;

  // Вооружение кейера — под текущий контекст (бэнд/трансвертер/Alex могли
  // поменяться мимо SetMode и SetTxSlice). Вызов идемпотентный, см. SyncCWKeyer.
  SyncCWKeyer;
  // ★Телеграф с кейером в прошивке: HPS_PTT здесь — ЭХО собственной манипуляции
  // (при break-in железо само поднимает T/R на каждую посылку), а не действие
  // оператора. Гонять по нему SetMOX нельзя: на каждой точке пересобирался бы
  // передающий тракт, а программная передача обрывалась бы после ПЕРВОЙ посылки
  // (снятие MOX гасит CWX). Флаг держим актуальным — им выбирается источник
  // модуляции, — но передачей по нему не управляем.
  if CWTXActive then
  begin
    // ★Ниже стоит Exit, поэтому падающий фронт HWPTT здесь обработать НЕКОМУ.
    // Если в эфир нас увёл именно HWPTT, пока источником передачи был не
    // телеграф (например слайс в голосовом режиме), а телеграф включился уже
    // после — передача залипала бы НАВСЕГДА: FTransmitting остаётся взведённым,
    // Alex стоит в TX, PTT не снимается, и приёмник всю дорогу видит свой же
    // передатчик. Снимаем такую передачу здесь.
    if FHWPTTStartedTX and (not FHPpHWPTT) then
    begin
      FHWPTTStartedTX := False;
      SetMOX(False);
    end;
    FHWPTTActive := FHPpHWPTT;
    if FHPpHWPTT then FCWKeyedAt := GetTickCount64;
    ServiceCWKeyed;
    Exit;
  end;
  ServiceCWKeyed;   // ушли из телеграфа — вернуть индикацию в исходное
  // Hardware PTT (footswitch / mic PTT): фронт на rate HP Status — не пропустить
  // короткое нажатие. HWPTT не перекрывает ручной MOX; выключаем TX только если
  // его включил именно HWPTT.
  if FHPpHWPTT <> FHWPTTActive then
  begin
    FHWPTTActive := FHPpHWPTT;
    if not FTuning then
    begin
      if FHPpHWPTT then
      begin
        if not FTransmitting then
        begin
          SetMOX(True);
          FHWPTTStartedTX := True;
        end;
      end
      else
      begin
        if FHWPTTStartedTX then
        begin
          FHWPTTStartedTX := False;
          SetMOX(False);
        end;
      end;
    end;
  end;
end;

procedure TRadioController.OnAudioReady(const Left, Right: array of Single;
  Count: Integer);
// DSP-поток: маршрут RX-аудио. Встроенный динамик трансивера (DDC Audio,
// port 1028) — независимый канал. Затем web-адаптер может перехватить аудио
// (активный клиент); если перехвачено — в локальный AudioOut не пишем.
begin
  // Decoded DMR PCM will use this route later. Until then suppress raw 4FSK;
  // the independent pre-volume tap is handled by OnDemodAudioReady.
  if FMode in [MODE_DMR, MODE_FMRAW] then Exit;

  // Программный сайдтон подмешиваем ДО всех потребителей: его должны слышать
  // и локальная звуковуха, и web-клиент, и динамик трансивера.
  if MixSidetone(Left, Right, Count) then
  begin
    if FSendAudioToRadio then
      FNetwork.SendSpeakerAudio(FCWSTBufL, FCWSTBufR, Count);
    // Линейный выход (TCI LINEOUT_STREAM) — здесь: это ровно то, что слышно,
    // вместе с сайдтоном и после громкости. Тап слушает, ничего не забирая,
    // поэтому стоит ДО перехвата web-клиентом.
    FireAudioTap(rakLineOut, 0, 0, FCWSTBufL, FCWSTBufR, Count);
    if Assigned(FOnAudioConsume) and FOnAudioConsume(FCWSTBufL, FCWSTBufR, Count) then
      Exit;
    if FLocalAudio then
      FAudioOut.Write(FCWSTBufL, FCWSTBufR, Count);
    Exit;
  end;

  if FSendAudioToRadio then
    FNetwork.SendSpeakerAudio(Left, Right, Count);
  FireAudioTap(rakLineOut, 0, 0, Left, Right, Count);
  if Assigned(FOnAudioConsume) and FOnAudioConsume(Left, Right, Count) then
    Exit;
  // Без локального звука (демон) на этом маршрут заканчивается: если web-клиент
  // не активен, аудио просто отбрасывается, а не идёт на звуковую карту хоста.
  if FLocalAudio then
    FAudioOut.Write(Left, Right, Count);
end;

procedure TRadioController.OnDemodAudioReady(const Left, Right: array of Single;
  Count: Integer);
// DSP thread, pre-volume/pre-mute. FeedAudio only copies into a bounded ring.
begin
  // Аудиопоток приёмника для TCI (§3.4) — именно отсюда: скиммеру и цифре
  // нужен звук приёмника, а не то, что осталось после ручки громкости и
  // мьюта. Линейный выход у TCI отдельным потоком (см. OnAudioReady).
  FireAudioTap(rakDemod, 0, 0, Left, Right, Count);
  if FMode = MODE_DMR then
  begin
    if not Assigned(FDMRDec) then Exit;
    if not FDMRDec.Enabled then FDMRDec.SetEnabled(True);
    FDMRDec.FeedAudio(Left, Right, Count);
  end
  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
  else if Assigned(FDMRDec) and FDMRDec.Enabled then
    FDMRDec.SetEnabled(False);
  // ★Телеграфный декодер кормим отсюда же: здесь аудио идёт ДО громкости и
  // мьюта и БЕЗ программного сайдтона (он подмешивается позже, в OnAudioReady),
  // зато уже после узкого CW-фильтра — лучшего предетектора не найти.
  // На своей передаче молчим: в телеграфе приёмник намеренно жив, и собственный
  // сигнал (особенно в полном дуплексе Pluto) забил бы ленту своей же работой.
  if Assigned(FCWDec) and FCWDec.Enabled
     and (FMode in [MODE_CWL, MODE_CWU]) and (not RadioKeyed) then
    FCWDec.FeedAudio(Left, Count);
end;

procedure TRadioController.OnDMRAudioReady(const PCM: array of Single;
  Count: Integer);
// DMR worker thread, 8 kHz mono from mbelib. Linear 6x interpolation brings
// it to the common 48 kHz output route used by local, web and radio speakers.
var
  Left, Right: array of Single;
  i, Phase, OutPos, N: Integer;
  A, B, V, Gain: Single;
begin
  if (FMode <> MODE_DMR) or FMuted or (Count <= 0) then Exit;
  N := Min(Count, Length(PCM));
  SetLength(Left, N * 6);
  SetLength(Right, N * 6);
  Gain := FVolume / 100.0;
  OutPos := 0;
  for i := 0 to N - 1 do
  begin
    A := PCM[i];
    if i + 1 < N then B := PCM[i + 1] else B := A;
    for Phase := 0 to 5 do
    begin
      V := (A + (B - A) * (Phase / 6.0)) * Gain;
      Left[OutPos] := V;
      Right[OutPos] := V;
      Inc(OutPos);
    end;
  end;
  if FSendAudioToRadio and Assigned(FNetwork) then
    FNetwork.SendSpeakerAudio(Left, Right, OutPos);
  FireAudioTap(rakLineOut, 0, 0, Left, Right, OutPos);
  if Assigned(FOnAudioConsume) and FOnAudioConsume(Left, Right, OutPos) then Exit;
  if FLocalAudio and Assigned(FAudioOut) then FAudioOut.Write(Left, Right, OutPos);
end;

// ---------------------------------------------------------------------------
// Мультислайсы (софт-fan-out)
// ---------------------------------------------------------------------------

function TRadioController.FindSliceIndex(Id: Integer): Integer;
var i: Integer;
begin
  Result := -1;
  for i := 0 to MAX_SLICES - 1 do
    if FSlices[i].Active and (FSlices[i].Id = Id) then Exit(i);
end;

// DSP-поток: аудио готового слайса → его собственный выход. Слайс — локальный
// монитор (в web/на радио не уходит), поэтому только на свою звуковуху.
procedure TRadioController.OnSliceAudioReady(SliceId: Integer;
  const Left, Right: array of Single; Count: Integer);
var idx: Integer;
begin
  idx := FindSliceIndex(SliceId);
  if idx < 0 then Exit;
  // Линейный выход слайса для TCI — до гейта локального звука: в демоне
  // звуковухи нет вовсе, а поток клиенту идти обязан.
  FireAudioTap(rakLineOut, FSlices[idx].PanId, SliceId, Left, Right, Count);
  if not FLocalAudio then Exit;
  if FSlices[idx].Mode in [MODE_DMR, MODE_FMRAW] then Exit;
  if SliceMutedByTx(idx) then Exit;
  if FSlices[idx].Audio <> nil then
    FSlices[idx].Audio.Write(Left, Right, Count);
end;

// Слайс глушится на передаче? В full-duplex (Pluto/QO-100) RX-тракт живёт и во
// время TX, поэтому иначе оператор слышит сам себя на КАЖДОМ слайсе. У главного
// приёмника за это отвечает FRxMuteOnTx (кнопка RX MUTE), у слайса — свой флаг.
function TRadioController.SliceMutedByTx(Idx: Integer): Boolean;
begin
  Result := FTransmitting and FSlices[Idx].RxMuteOnTx;
end;

// DSP thread: flat, volume-independent discriminator of the corresponding
// WDSP slice channel. FeedAudio only copies it into that decoder's ring.
procedure TRadioController.OnSliceDemodAudioReady(SliceId: Integer;
  const Left, Right: array of Single; Count: Integer);
var idx: Integer;
begin
  idx := FindSliceIndex(SliceId);
  if idx < 0 then Exit;
  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 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.
procedure TRadioController.OnSliceDMRAudioReady(SliceId: Integer;
  const PCM: array of Single; Count: Integer);
var
  Left, Right: array of Single;
  idx, i, Phase, OutPos, N: Integer;
  A, B, V, Gain: Single;
begin
  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 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);
  Gain := FSlices[idx].Volume;
  OutPos := 0;
  for i := 0 to N - 1 do
  begin
    A := PCM[i];
    if i + 1 < N then B := PCM[i + 1] else B := A;
    for Phase := 0 to 5 do
    begin
      V := (A + (B - A) * (Phase / 6.0)) * Gain;
      Left[OutPos] := V;
      Right[OutPos] := V;
      Inc(OutPos);
    end;
  end;
  // Линейный выход 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;
// Смена центра ГЛАВНОГО захвата: пересчитываем только слайсы пана 0 —
// слайсы доп. панов привязаны к центрам своих DDC (SetPanDDCFreq).
var i: Integer;
begin
  if not Assigned(FDSPEngine) then Exit;
  for i := 0 to MAX_SLICES - 1 do
    if FSlices[i].Active and (FSlices[i].PanId = 0) then
      FDSPEngine.SetSliceShift(FSlices[i].Id, FSlices[i].TargetHz - FCenterFreq);
end;

function TRadioController.PanCenterHz(PanId: Integer): Double;
begin
  if (PanId >= 1) and (PanId < MAX_PANS) and (FPanDDCIdx[PanId] >= 0) then
    Result := FPanFreqHz[PanId]
  else
    Result := FCenterFreq;
end;

procedure TRadioController.CapturedSpan(out LoHz, HiHz: Double; PanId: Integer);
// Полезная полоса ±47.5% от захвата (запас на краях фильтра/анти-алиасинга).
var Half, Rate: Double;
begin
  if (PanId >= 1) and (PanId < MAX_PANS) and (FPanDDCIdx[PanId] >= 0) then
    Rate := FPanRateKHz[PanId] * 1000.0
  else
    Rate := FSampleRate;
  Half := Rate * 0.475;
  LoHz := PanCenterHz(PanId) - Half;
  HiHz := PanCenterHz(PanId) + Half;
end;

function TRadioController.SliceFitsCapture(TargetHz: Double; PanId: Integer): Boolean;
var Lo, Hi: Double;
begin
  CapturedSpan(Lo, Hi, PanId);
  Result := (TargetHz >= Lo) and (TargetHz <= Hi);
end;

function TRadioController.CurrentBandEdges(out LoHz, HiHz: Double): Boolean;
begin
  LoHz := 0; HiHz := 0;
  if (FCurrentXvtr >= 0) and (FCurrentXvtr < CFG_XVTR_COUNT) and
     FXvtrSettings.Entries[FCurrentXvtr].Enabled then
  begin
    // Трансвертер: диапазон — его собственный (2 м = 144..146 и т.п.).
    LoHz := FXvtrSettings.Entries[FCurrentXvtr].FreqBegin;
    HiHz := FXvtrSettings.Entries[FCurrentXvtr].FreqEnd;
    Result := HiHz > LoHz;
  end
  else
    Result := BandEdges(FCurrentBand, IsPluto, LoHz, HiHz);
end;

procedure TRadioController.SliceFreqChanged(Id: Integer);
// Слайс перестроен НЕ из UI (CAT-порт слайса). Позицию флага UI держит сам
// (TickFlags → LayoutFlags читает живой TargetHz), а вот ЧАСТОТА и кромки
// фильтра во флаге живут в его собственных полях и обновляются только
// PushSliceFlagState. Без этого уведомления флаг уезжал на новую частоту,
// показывая старые цифры и старую полосу фильтра.
begin
  FSliceFreqId := Id;
  Changed(rfSliceFreq);
end;

procedure TRadioController.SliceStateChanged(Id: Integer);
// У слайса сменилось что-то кроме частоты: режим, фильтр, АРУ, DSP, громкость,
// мьют, шумоподавитель. Зовут сами сеттеры — слайс правят и UI, и CAT, и TCI, а
// узнать об этом должны все фронтенды сразу (у главного тракта для этого есть
// свои поля rfMode/rfFilter/…, у слайсов не было ничего).
begin
  FSliceFreqId := Id;
  Changed(rfSliceState);
end;

function TRadioController.TuneSliceInBand(Id: Integer; TargetHz: Double): Boolean;
// Свобода внешней программы: она двигает слайс куда угодно ВНУТРИ включённого
// диапазона (2 м на трансвертере: 144.174 при окне на 145.5 — можно), но сменить
// диапазон (7 МГц) — нет: это решение оператора, а не управляющего ПО.
//
// Если цель вне захваченной полосы — окно DDC переезжает центром на цель:
//   • доп. пан (свой DDC) — свободно;
//   • пан 0 — только если главный приёмник останется внутри нового окна
//     (одно железное окно на двоих; иначе главный оглох бы).
var
  idx, PanId: Integer;
  Lo, Hi, Half, Dist, NewCenter, VisLo, VisHi: Double;
begin
  Result := False;
  idx := FindSliceIndex(Id);
  if idx < 0 then Exit;
  if not CurrentBandEdges(Lo, Hi) then Exit;
  if (TargetHz < Lo) or (TargetHz > Hi) then Exit;   // другой диапазон — отказ

  PanId := FSlices[idx].PanId;
  if SliceFitsCapture(TargetHz, PanId) then
  begin
    SetSliceTarget(Id, TargetHz);   // он же шлёт SliceFreqChanged
    Exit(True);
  end;

  if PanId > 0 then
  begin
    if not PanDDCActive(PanId) then Exit;
    VisibleFreqBounds(VisLo, VisHi);
    NewCenter := EnsureRange(TargetHz, VisLo, VisHi);
    SetPanDDCFreq(PanId, NewCenter);
    SetSliceTarget(Id, TargetHz);
    Changed(rfPanFreq);        // UI: шапка/зум-бар/флаги пана под новый центр
                               // (несёт и перезалив флагов — SliceFreqChanged лишний)
    Result := True;
  end
  else
  begin
    // Пан 0: центр общий с главным приёмником. Ставим его посередине между
    // целью и активным VFO — тогда в окно попадают оба; не помещаются —
    // отказываем (перестраивать главный ради слайса нельзя).
    Half := FSampleRate * 0.475;
    Dist := Abs(TargetHz - ActiveVfoHz);
    if Dist > Half * 2 * 0.95 then Exit;
    NewCenter := (TargetHz + ActiveVfoHz) / 2;
    SetCenter(NewCenter);      // несёт Changed(rfCenterFreq) + сдвиги слайсов
    SetSliceTarget(Id, TargetHz);   // SliceFreqChanged — внутри: rfCenterFreq
                                    // флаги не перезаливает, нужен свой
    Result := True;
  end;
end;

{ ---- Панадаптеры на аппаратных DDC (этап 3.1) ---- }

procedure TRadioController.ResetPanDDCMaps;
var i: Integer;
begin
  for i := 1 to MAX_PANS - 1 do
  begin
    FPanDDCIdx[i]  := -1;
    FPanFreqHz[i]  := 0;
    FPanRateKHz[i] := 0;
    FPanADC[i]     := 0;
    FPanZoomF[i]   := 0.0;
    FPanPanSl[i]   := 0.5;
  end;
  for i := 0 to MAX_DDCS - 1 do
    FDDCPanMap[i] := -1;
end;

function TRadioController.PanRateAllowed(RateKHz: Word): Boolean;
// Полная сетка железа (Caps.RatePresets) минус некратные 48 кГц: движок
// требует целого отношения rate пана к аудио-rate (блок/WDSP-каналы).
var i: Integer;
begin
  Result := False;
  if not Assigned(FNetwork) then Exit;
  if (RateKHz = 0) or ((Integer(RateKHz) * 1000) mod 48000 <> 0) then Exit;
  for i := 0 to High(FNetwork.Caps.RatePresets) do
    if FNetwork.Caps.RatePresets[i] = Integer(RateKHz) * 1000 then Exit(True);
end;

function TRadioController.AddPanDDC(PanId: Integer; FreqHz: Double;
  RateKHz: Word; ADCSource: Byte): Boolean;
var
  i, ddc: Integer;
begin
  Result := False;
  if (PanId < 1) or (PanId >= MAX_PANS) then Exit;
  if FPanDDCIdx[PanId] >= 0 then Exit;                    // уже создан
  if not (Assigned(FNetwork) and FRunning) then Exit;
  if PanId >= FNetwork.Caps.MaxPans then Exit;            // бэкенд не умеет (Pluto=1)

  if not PanRateAllowed(RateKHz) then RateKHz := 384;

  // hw-индекс: следующий свободный после главного DDC (слот-стабильности не
  // нужно — hw-индекс скрыт за PanId, наружу торчит только PanId).
  ddc := -1;
  for i := FActiveDDC + 1 to FActiveDDC + MAX_PANS - 1 do
    if (i < MAX_DDCS) and (FDDCPanMap[i] < 0) then begin ddc := i; Break; end;
  if ddc < 0 then Exit;

  // Движок: аккумулятор + analyzer пана (до включения DDC — к приходу первого
  // пакета пан уже готов; PushPanIQ без пана просто дропает).
  if FWDSPReady and not FDSPEngine.AddPan(PanId, Integer(RateKHz) * 1000) then
    Exit;

  FPanDDCIdx[PanId]  := ddc;
  FPanFreqHz[PanId]  := FreqHz;
  FPanRateKHz[PanId] := RateKHz;
  FPanADC[PanId]     := ADCSource;
  FPanZoomF[PanId]   := 0.0;
  FPanPanSl[PanId]   := 0.5;
  FDDCPanMap[ddc]    := PanId;
  // В железо — IF-частота: FPanFreqHz хранит видимую (как FCenterFreq).
  FNetwork.SetPanDDC(ddc, True, XvtrTranslate(FreqHz), RateKHz, ADCSource);
  Result := True;
end;

function TRadioController.SetPanDDCRate(PanId: Integer; RateKHz: Word): Boolean;
var
  i: Integer;
  LoHz, HiHz: Double;
begin
  Result := False;
  if not PanDDCActive(PanId) then Exit;
  if not PanRateAllowed(RateKHz) then Exit;
  if FPanRateKHz[PanId] = RateKHz then Exit(True);
  // Движок первым: под FSliceLock пересобирает аккумулятор/анализатор и
  // переоткрывает слайс-каналы; провалился — состояние не трогаем.
  if FWDSPReady and not FDSPEngine.SetPanRate(PanId, Integer(RateKHz) * 1000) then
    Exit;
  FPanRateKHz[PanId] := RateKHz;
  // DDC железа: реконфиг rate (RebuildDDCSpecific + freq-слово, IF при XVTR).
  if Assigned(FNetwork) then
    FNetwork.SetPanDDC(FPanDDCIdx[PanId], True, XvtrTranslate(FPanFreqHz[PanId]),
                       RateKHz, FPanADC[PanId]);
  // Слайсы, выпавшие из сузившейся полосы, — к её краям (абс. TargetHz + NCO).
  CapturedSpan(LoHz, HiHz, PanId);
  for i := 0 to MAX_SLICES - 1 do
    if FSlices[i].Active and (FSlices[i].PanId = PanId) then
    begin
      if FSlices[i].TargetHz < LoHz then SetSliceTarget(FSlices[i].Id, LoHz);
      if FSlices[i].TargetHz > HiHz then SetSliceTarget(FSlices[i].Id, HiHz);
    end;
  Result := True;
end;

function TRadioController.MovePanDDC(PanId, NewDDC: Integer): Boolean;
// Тот же пан (движок/анализатор/слайсы не трогаем) — только другой аппаратный
// DDC: старый гасим, новый включаем с теми же freq/rate/ADC. Короткий разрыв IQ
// на время реконфига — норма.
begin
  Result := False;
  if not PanDDCActive(PanId) then Exit;
  if not Assigned(FNetwork) then Exit;
  if (NewDDC <= FActiveDDC) or (NewDDC > MaxDDCIndex) then Exit;
  if FPanDDCIdx[PanId] = NewDDC then Exit(True);
  if FDDCPanMap[NewDDC] >= 0 then Exit;              // занят другим паном

  FNetwork.SetPanDDC(FPanDDCIdx[PanId], False, 0, 0, 0);
  FDDCPanMap[FPanDDCIdx[PanId]] := -1;
  // Seq-трекинг обоих индексов начинаем заново (новый поток — новый счётчик).
  if FPanDDCIdx[PanId] <= High(FDDCSeqValid) then
    FDDCSeqValid[FPanDDCIdx[PanId]] := False;
  if NewDDC <= High(FDDCSeqValid) then
    FDDCSeqValid[NewDDC] := False;

  FPanDDCIdx[PanId]  := NewDDC;
  FDDCPanMap[NewDDC] := PanId;
  FNetwork.SetPanDDC(NewDDC, True, XvtrTranslate(FPanFreqHz[PanId]),
                     FPanRateKHz[PanId], FPanADC[PanId]);
  Result := True;
end;

function TRadioController.PanDDCIndex(PanId: Integer): Integer;
begin
  if (PanId >= 1) and (PanId < MAX_PANS) then Result := FPanDDCIdx[PanId]
  else if PanId = 0 then Result := FActiveDDC
  else Result := -1;
end;

function TRadioController.MaxDDCIndex: Integer;
// Железо openHPSDR (Orion/Saturn) поднимает DDC0..DDC7 — DDC8 и выше молчат
// (проверено на плате: enable-бит ставится, поток не идёт).
begin
  Result := 7;
end;

function TRadioController.DDCFree(DDCIdx: Integer): Boolean;
begin
  Result := (DDCIdx > FActiveDDC) and (DDCIdx <= MaxDDCIndex)
            and (FDDCPanMap[DDCIdx] < 0);
end;

procedure TRadioController.RemovePanDDC(PanId: Integer);
var ddc, i: Integer;
begin
  if (PanId < 1) or (PanId >= MAX_PANS) then Exit;
  ddc := FPanDDCIdx[PanId];
  if ddc < 0 then Exit;
  // Сначала снимаем роутинг (OnDDCIQ в сетевом потоке перестаёт брать пакеты
  // этого DDC), потом слайсы пана (их звуковухи), потом движок и DDC в железе.
  FDDCPanMap[ddc]    := -1;
  FPanDDCIdx[PanId]  := -1;
  FPanFreqHz[PanId]  := 0;
  FPanRateKHz[PanId] := 0;
  FPanADC[PanId]     := 0;
  FPanZoomF[PanId]   := 0.0;
  FPanPanSl[PanId]   := 0.5;
  for i := 0 to MAX_SLICES - 1 do
    if FSlices[i].Active and (FSlices[i].PanId = PanId) then
      RemoveSlice(FSlices[i].Id);
  if FWDSPReady then FDSPEngine.RemovePan(PanId);
  if Assigned(FNetwork) then FNetwork.SetPanDDC(ddc, False, 0, 0, 0);
end;

procedure TRadioController.SetPanDDCFreq(PanId: Integer; FreqHz: Double);
var i: Integer;
begin
  if (PanId < 1) or (PanId >= MAX_PANS) then Exit;
  if FPanDDCIdx[PanId] < 0 then Exit;
  FPanFreqHz[PanId] := FreqHz;   // видимая; в железо — IF при активном XVTR
  if Assigned(FNetwork) then
    FNetwork.SetPanDDCFreq(FPanDDCIdx[PanId], XvtrTranslate(FreqHz));
  // Слайсы пана держат абсолютный TargetHz — обновляем их NCO-сдвиги
  // относительно нового центра (аналог RefreshSliceShifts главного).
  if Assigned(FDSPEngine) then
    for i := 0 to MAX_SLICES - 1 do
      if FSlices[i].Active and (FSlices[i].PanId = PanId) then
        FDSPEngine.SetSliceShift(FSlices[i].Id, FSlices[i].TargetHz - FreqHz);
end;

procedure TRadioController.VisibleFreqBounds(out LoHz, HiHz: Double);
begin
  if (FCurrentXvtr >= 0) and (FCurrentXvtr < CFG_XVTR_COUNT) and
     FXvtrSettings.Entries[FCurrentXvtr].Enabled then
  begin
    // Активный XVTR: видимые частоты живут в его диапазоне, в железо уходит
    // IF (XvtrTranslate) — клампить видимую частоту пределами АЦП нельзя.
    LoHz := FXvtrSettings.Entries[FCurrentXvtr].FreqBegin;
    HiHz := FXvtrSettings.Entries[FCurrentXvtr].FreqEnd;
  end
  else if Assigned(FNetwork) then
  begin
    LoHz := FNetwork.Caps.MinFreqHz;
    HiHz := FNetwork.Caps.MaxFreqHz;
  end
  else
  begin
    LoHz := 0;
    HiHz := 61440000;
  end;
end;

procedure TRadioController.RefreshPanDDCs;
var
  p, i: Integer;
  LoV, HiV, LoHz, HiHz: Double;
begin
  VisibleFreqBounds(LoV, HiV);
  for p := 1 to MAX_PANS - 1 do
    if PanDDCActive(p) then
    begin
      // Видимый центр пана — в новое допустимое окно, freq-слово DDC — через
      // свежую трансляцию (вход/выход XVTR, правка LO/LOError).
      FPanFreqHz[p] := EnsureRange(FPanFreqHz[p], LoV, HiV);
      if Assigned(FNetwork) then
        FNetwork.SetPanDDCFreq(FPanDDCIdx[p], XvtrTranslate(FPanFreqHz[p]));
      // Слайсы держат абсолютный видимый TargetHz: выпавшие из полосы захвата
      // пана — к её краям (SetSliceTarget обновит NCO), остальные — свежий NCO.
      CapturedSpan(LoHz, HiHz, p);
      for i := 0 to MAX_SLICES - 1 do
        if FSlices[i].Active and (FSlices[i].PanId = p) then
        begin
          if FSlices[i].TargetHz < LoHz then SetSliceTarget(FSlices[i].Id, LoHz)
          else if FSlices[i].TargetHz > HiHz then SetSliceTarget(FSlices[i].Id, HiHz)
          else if Assigned(FDSPEngine) then
            FDSPEngine.SetSliceShift(FSlices[i].Id,
              FSlices[i].TargetHz - FPanFreqHz[p]);
        end;
    end;
end;

function TRadioController.PanDDCActive(PanId: Integer): Boolean;
begin
  Result := (PanId >= 1) and (PanId < MAX_PANS) and (FPanDDCIdx[PanId] >= 0);
end;

function TRadioController.PanDDCFreq(PanId: Integer): Double;
begin
  if PanDDCActive(PanId) then Result := FPanFreqHz[PanId]
  else Result := 0;
end;

function TRadioController.PanDDCRateKHz(PanId: Integer): Word;
begin
  if PanDDCActive(PanId) then Result := FPanRateKHz[PanId]
  else Result := 0;
end;

procedure TRadioController.SetAnt936x(const A: TAD936xAnt);
// Новая таблица «диапазон → разъём» из настроек: сохранить + применить текущий.
begin
  FAnt936x := A;
  if FDevConnected then
  begin
    FSettings.SaveAnt936x(FDevMAC, FAnt936x);
    FSettings.Save;
  end;
  ApplyAntPorts;
end;

procedure TRadioController.ApplyAntPorts;
// Антенна — свойство диапазона (как RxAnt/TxAnt у Alex): при смене диапазона или
// входе/выходе из трансвертера включаем разъёмы, выбранные для этого контекста.
// RX-разъём берём по диапазону ПРИЁМА, TX — по диапазону ПЕРЕДАЧИ: с мультислайс-TX
// (или split) передача может идти не на частоте центра приёма.
var
  Rx, Tx: Byte;
  BiRx, BiTx: Integer;
begin
  if not (Assigned(FNetwork) and FNetwork.Connected) then Exit;
  if not FNetwork.Caps.HasAntSwitch then Exit;
  if (FCurrentXvtr >= 0) and (FCurrentXvtr < CFG_XVTR_COUNT) then
  begin
    Rx := FAnt936x.XvtrRxAnt[FCurrentXvtr];
    Tx := FAnt936x.XvtrTxAnt[FCurrentXvtr];
  end
  else
  begin
    // Диапазоны — по ВИДИМЫМ частотам (VU-план AD936x).
    BiRx := FreqToBandIdx(FCenterFreq, IsPluto);
    if (BiRx < 0) or (BiRx >= CFG_BAND_COUNT) then Exit;   // вне плана — не трогаем
    Rx := FAnt936x.RxAnt[BiRx];
    BiTx := FreqToBandIdx(ActiveTXFreqHz, IsPluto);
    if (BiTx < 0) or (BiTx >= CFG_BAND_COUNT) then BiTx := BiRx;   // TX вне плана — как RX
    Tx := FAnt936x.TxAnt[BiTx];
  end;
  FNetwork.SetAntPorts(Rx, Tx);
end;

function TRadioController.PanADC(PanId: Integer): Byte;
begin
  if PanId = 0 then Result := FMainADCSrc
  else if PanDDCActive(PanId) then Result := FPanADC[PanId]
  else Result := 0;
end;

function TRadioController.SetPanADC(PanId: Integer; ADC: Byte): Boolean;
begin
  Result := False;
  if not (Assigned(FNetwork) and FRunning) then Exit;
  if ADC >= FNetwork.Caps.NumADCs then Exit;
  if PanId = 0 then
  begin
    // Главный DDC: тот же ConfigureDDCs, что при смене rate (кэш+RebuildDDCSpecific).
    if FMainADCSrc = ADC then Exit(True);
    FMainADCSrc := ADC;
    FNetwork.ConfigureDDCs(1, FSampleRate div 1000, ADC,
                           FDitherEnabled, FRandomEnabled);
    Exit(True);
  end;
  if not PanDDCActive(PanId) then Exit;
  if FPanADC[PanId] = ADC then Exit(True);
  FPanADC[PanId] := ADC;
  FNetwork.SetPanDDC(FPanDDCIdx[PanId], True, XvtrTranslate(FPanFreqHz[PanId]),
                     FPanRateKHz[PanId], ADC);
  Result := True;
end;

function TRadioController.AddSlice(TargetHz: Double;
  Mode, FilterLo, FilterHi: Integer; AGC: TWDSPAGCMode; Vol: Double;
  DevIndex: Integer; const DevName: string; APanId: Integer): Integer;
var
  i, slot, Id: Integer;
  DevIndex_: Integer;
  DevName_:  string;
  AO: TAudioOutput;
begin
  Result := -1;
  Mode := EnsureRange(Mode, MODE_MIN, MODE_MAX);
  if not Assigned(FDSPEngine) then Exit;
  if (APanId < 0) or (APanId >= MAX_PANS) then Exit;
  if (APanId > 0) and (FPanDDCIdx[APanId] < 0) then Exit;  // пан не создан
  if not SliceFitsCapture(TargetHz, APanId) then Exit;     // вне полосы пана

  slot := -1;
  for i := 0 to MAX_SLICES - 1 do
    if not FSlices[i].Active then begin slot := i; Break; end;
  if slot < 0 then Exit;   // нет свободных слотов

  // Устройство слота (настройка вкладки Slices) сильнее того, что попросил
  // вызывающий: слот — это буква флага, и звук у неё всегда один и тот же.
  if FSliceOutDev[slot] <> '' then
  begin
    DevName_  := FSliceOutDev[slot];
    DevIndex_ := -1;
    if Assigned(FAudioOut) then DevIndex_ := FAudioOut.FindDeviceByName(DevName_);
  end
  else
  begin
    DevName_  := DevName;
    DevIndex_ := DevIndex;
  end;

  Id := FNextSliceId;
  if not FDSPEngine.AddSlice(Id, Mode, FilterLo, FilterHi,
                             TargetHz - PanCenterHz(APanId), Vol, AGC,
                             APanId) then Exit;
  Inc(FNextSliceId);

  // Свой аудио-выход слайса на выбранной звуковухе.
  AO := TAudioOutput.Create(48000);
  AO.OutputBufferSize := FSettings.LoadAudioBufferSize;
  AO.DeviceIndex := DevIndex_;
  if FLocalAudio then AO.Open;

  FSlices[slot].Active      := True;
  FSlices[slot].Id          := Id;
  // Буква флага. Слайс A — это главный приёмник (не в FSlices[]), поэтому доп.
  // слайсы нумеруются с 'B' (slot 0 → B, 1 → C, …), иначе получалось два «A».
  FSlices[slot].Letter      := Chr(Ord('B') + slot);
  FSlices[slot].PanId       := APanId;
  FSlices[slot].TargetHz    := TargetHz;
  FSlices[slot].Mode        := Mode;
  FSlices[slot].FilterLo    := FilterLo;
  FSlices[slot].FilterHi    := FilterHi;
  FSlices[slot].AGC         := AGC;
  FSlices[slot].Volume      := Vol;
  FSlices[slot].Muted       := False;
  FSlices[slot].RxMuteOnTx  := True;    // себя на передаче по умолчанию не слышим
  FSlices[slot].FMSQOn      := False;   // squelch по умолчанию выключен
  FSlices[slot].FMSQLevel   := 40;
  ClearTXProfModeTable(FSlices[slot].TXProfByMode);   // звучит активный профиль
  FSlices[slot].NRMode      := 0;       // DSP слайса стартует выключенным
  FSlices[slot].NBMode      := 0;
  FSlices[slot].SNB         := False;
  FSlices[slot].ANF         := False;
  FSlices[slot].DeviceIndex := DevIndex_;
  FSlices[slot].DevName     := DevName_;
  FSlices[slot].Audio       := AO;
  // Вход слайса по умолчанию общий (FAudioIn); свой ставится SetSliceInDevice.
  FSlices[slot].InDeviceIndex := -1;
  FSlices[slot].InDevName     := '';
  FSlices[slot].AudioIn       := nil;
  FSlices[slot].DMR         := nil;
  if Mode = MODE_DMR then
    FSlices[slot].DMR := TDMRSliceDecoder.Create(Self, Id);

  // Вход слота (настройка вкладки Slices) — тем же правилом, что и выход.
  if (FSliceInDev[slot] <> '') and Assigned(FAudioIn) then
    SetSliceInDeviceIdx(slot, FAudioIn.FindDeviceByName(FSliceInDev[slot]),
                        FSliceInDev[slot]);

  Result := Id;
  Changed(rfDevice);
end;

procedure TRadioController.RemoveSlice(Id: Integer);
var idx: Integer;
begin
  idx := FindSliceIndex(Id);
  if idx < 0 then Exit;
  // Удаляемый слайс был источником TX — вернуть передачу на главный VFO.
  if FTxSliceId = Id then SetTxSlice(0);
  if Assigned(FDSPEngine) then FDSPEngine.RemoveSlice(Id);
  FreeAndNil(FSlices[idx].DMR);
  if FSlices[idx].Audio <> nil then
  begin
    FSlices[idx].Audio.Close;
    FreeAndNil(FSlices[idx].Audio);
  end;
  ReleaseMicInput(FSlices[idx].AudioIn);   // поток закроется, когда уйдёт последняя ссылка
  FSlices[idx].InDeviceIndex := -1;
  FSlices[idx].InDevName     := '';
  FSlices[idx].Active := False;
  Changed(rfDevice);
end;

procedure TRadioController.SetSliceTarget(Id: Integer; TargetHz: Double);
var idx: Integer; Moved: Boolean;
begin
  idx := FindSliceIndex(Id);
  if idx < 0 then Exit;
  Moved := FSlices[idx].TargetHz <> TargetHz;
  FSlices[idx].TargetHz := TargetHz;
  if Assigned(FDSPEngine) then
    FDSPEngine.SetSliceShift(Id, TargetHz - PanCenterHz(FSlices[idx].PanId));
  // Слайс — источник передачи ⇒ он же задаёт частоту DUC. Раньше её дошлёт бы
  // первый MOX, но в телеграфе ключ замыкает прошивка без всякого MOX: DUC
  // обязан стоять правильно ВСЕГДА, а не только на передаче.
  if FTxSliceId = Id then PushNetworkState;
  // Уведомление — здесь, а не у каждого вызывающего: слайс двигают мышью, CAT,
  // TCI и бэнд-логика, и каждый забывал сказать об этом остальным (TCI-клиенты
  // оставались на старой частоте после перетаскивания флага мышью).
  if Moved then SliceFreqChanged(Id);
end;

procedure TRadioController.SetSliceMode(Id, Mode: Integer);
var idx, OldMode: Integer;
begin
  Mode := EnsureRange(Mode, MODE_MIN, MODE_MAX);
  idx := FindSliceIndex(Id);
  if idx < 0 then Exit;
  OldMode := FSlices[idx].Mode;
  if (Mode = MODE_DMR) and not Assigned(FSlices[idx].DMR) then
    FSlices[idx].DMR := TDMRSliceDecoder.Create(Self, Id);
  FSlices[idx].Mode := Mode;
  if Assigned(FDSPEngine) then FDSPEngine.SetSliceMode(Id, Mode);
  // DMR is receive-only. If this slice used to be the TX source, hand TX back
  // to the main receiver as soon as it enters DMR. FM RAW supports flat TX.
  if (Mode = MODE_DMR) and (FTxSliceId = Id) then SetTxSlice(0);
  if (OldMode = MODE_DMR) and (Mode <> MODE_DMR) then
    FreeAndNil(FSlices[idx].DMR);
  // Этот слайс — источник передачи: передающий тракт идёт за его режимом
  // (иначе после смены режима в эфир ушёл бы прежний вид модуляции). Вход FM
  // RAW — внешний PCM, поэтому заодно пересматриваем источник модуляции.
  // Профиль этого слайса в НОВОМ режиме — до пуша цепи (см. SetMode).
  if (FTxSliceId = Id) and (Mode <> OldMode) then ApplyBoundTXProfile;
  if (FTxSliceId = Id) and (Mode <> OldMode) and
     FWDSPReady and Assigned(FDSPEngine) then
  begin
    FDSPEngine.SetTXMode(ActiveTXMode);
    if (Mode = MODE_FMRAW) or (OldMode = MODE_FMRAW) then
    begin
      if Mode = MODE_FMRAW then
      begin
        FWebMicActive := False;
        FTCIMicActive := False;
      end;
      FDSPEngine.SetTXMicSource(DefaultMicSource);
      if Assigned(ActiveMicInput) and ActiveMicInput.IsOpen then
        ActiveMicInput.Flush;
    end;
  end;
  if FTxSliceId = Id then SyncCWKeyer;
  SliceStateChanged(Id);
end;

procedure TRadioController.SetSliceFilter(Id, Low, High: Integer);
var idx: Integer;
begin
  idx := FindSliceIndex(Id);
  if idx < 0 then Exit;
  FSlices[idx].FilterLo := Low;
  FSlices[idx].FilterHi := High;
  if Assigned(FDSPEngine) then FDSPEngine.SetSliceFilter(Id, Low, High);
  SliceStateChanged(Id);
end;

procedure TRadioController.SetSliceAGCMode(Id: Integer; AGC: TWDSPAGCMode);
var idx: Integer;
begin
  idx := FindSliceIndex(Id);
  if idx < 0 then Exit;
  FSlices[idx].AGC := AGC;
  if Assigned(FDSPEngine) then FDSPEngine.SetSliceAGC(Id, AGC);
  SliceStateChanged(Id);
end;

procedure TRadioController.SetSliceVolume(Id: Integer; Vol: Double);
var idx: Integer;
begin
  idx := FindSliceIndex(Id);
  if idx < 0 then Exit;
  FSlices[idx].Volume := Vol;
  if Assigned(FDSPEngine) then FDSPEngine.SetSliceVolume(Id, Vol);
  SliceStateChanged(Id);
end;

procedure TRadioController.SetSliceMute(Id: Integer; Mute: Boolean);
var idx: Integer;
begin
  idx := FindSliceIndex(Id);
  if idx < 0 then Exit;
  FSlices[idx].Muted := Mute;
  if Assigned(FDSPEngine) then FDSPEngine.SetSliceMute(Id, Mute);
  SliceStateChanged(Id);
end;

procedure TRadioController.SetSliceTapWanted(Id: Integer; V: Boolean);
begin
  if Assigned(FDSPEngine) then FDSPEngine.SetSliceTapWanted(Id, V);
end;

procedure TRadioController.SetSliceRxMuteOnTx(Id: Integer; On_: Boolean);
var idx: Integer;
begin
  idx := FindSliceIndex(Id);
  if idx < 0 then Exit;
  FSlices[idx].RxMuteOnTx := On_;
end;

// «Главный» слайс пана = созданный на нём первым. Id монотонно растёт
// (FNextSliceId), поэтому первый = слайс с минимальным Id. 0 = слайсов нет.
function TRadioController.PanPrimarySliceId(PanId: Integer): Integer;
var i: Integer;
begin
  Result := 0;
  for i := 0 to MAX_SLICES - 1 do
    if FSlices[i].Active and (FSlices[i].PanId = PanId) and
       ((Result = 0) or (FSlices[i].Id < Result)) then
      Result := FSlices[i].Id;
end;

function TRadioController.PanHasFMSlice(PanId: Integer): Boolean;
// Пан «канальный», если хотя бы один его слайс сидит в FM/DMR/FMRAW: под это
// UI рисует сетку частот по шагу FFMStepIdx (как на главном панадаптере).
var i: Integer;
begin
  Result := False;
  for i := 0 to MAX_SLICES - 1 do
    if FSlices[i].Active and (FSlices[i].PanId = PanId) and
       (FSlices[i].Mode in [MODE_FM, MODE_DMR, MODE_FMRAW]) then
    begin
      Result := True;
      Exit;
    end;
end;

procedure TRadioController.SetSliceDSP(Id, NRMode, NBMode: Integer;
  SNBOn, ANFOn: Boolean);
var idx: Integer;
begin
  idx := FindSliceIndex(Id);
  if idx < 0 then Exit;
  FSlices[idx].NRMode := NRMode;
  FSlices[idx].NBMode := NBMode;
  FSlices[idx].SNB    := SNBOn;
  FSlices[idx].ANF    := ANFOn;
  if Assigned(FDSPEngine) then
  begin
    FDSPEngine.SetSliceNRMode(Id, NRMode);
    FDSPEngine.SetSliceNBMode(Id, NBMode);
    FDSPEngine.SetSliceSNB(Id, SNBOn);
    FDSPEngine.SetSliceANF(Id, ANFOn);
  end;
  SliceStateChanged(Id);
end;

procedure TRadioController.SetSliceFMSquelch(Id: Integer; On_: Boolean; Level: Integer);
var idx: Integer;
begin
  idx := FindSliceIndex(Id);
  if idx < 0 then Exit;
  FSlices[idx].FMSQOn    := On_;
  FSlices[idx].FMSQLevel := Level;
  if Assigned(FDSPEngine) then
    FDSPEngine.SetSliceFMSquelch(Id, On_, Level);
  SliceStateChanged(Id);
end;

function TRadioController.SliceSMeter(Id: Integer): Double;
// Слайс может стоять на другом диапазоне (паны N кросс-банд), поэтому офсет
// берём по ЕГО видимой частоте, а не по главному диапазону.
var idx: Integer;
begin
  Result := -130.0;
  if not Assigned(FDSPEngine) then Exit;
  Result := FDSPEngine.GetSliceSMeter(Id);
  idx := FindSliceIndex(Id);
  if idx >= 0 then Result := Result + SMeterCalFor(FSlices[idx].TargetHz);
end;

function TRadioController.ReadSMeterDBm: Double;
begin
  if not Assigned(FDSPEngine) then Exit(-130.0);
  Result := FDSPEngine.GetSMeterDBm + SMeterCalFor(ActiveVfoHz);
end;

function TRadioController.CalLookup(VisibleHz: Double;
  const BandTbl: array of Double; const XvtrTbl: array of Double): Double;
// Трансвертер имеет приоритет над диапазоном: его окно частот — видимые
// (не IF), и усиление тракта задаёт конвертер/LNB, а не КВ-вход трансивера.
// Индекс диапазона — по плану активного железа (HF openHPSDR / VHF-UHF Pluto).
var i, Bi: Integer;
begin
  Result := 0.0;
  if (FCurrentXvtr >= 0) and (FCurrentXvtr < CFG_XVTR_COUNT)
     and FXvtrSettings.Entries[FCurrentXvtr].Enabled
     and (VisibleHz >= FXvtrSettings.Entries[FCurrentXvtr].FreqBegin)
     and (VisibleHz <= FXvtrSettings.Entries[FCurrentXvtr].FreqEnd) then
    Exit(XvtrTbl[FCurrentXvtr]);
  for i := 0 to CFG_XVTR_COUNT-1 do
    if FXvtrSettings.Entries[i].Enabled
       and (VisibleHz >= FXvtrSettings.Entries[i].FreqBegin)
       and (VisibleHz <= FXvtrSettings.Entries[i].FreqEnd) then
      Exit(XvtrTbl[i]);
  Bi := FreqToBandIdx(VisibleHz, IsPluto);
  if (Bi >= 0) and (Bi < CFG_BAND_COUNT) then Result := BandTbl[Bi];
end;

function TRadioController.SMeterCalFor(VisibleHz: Double): Double;
begin
  Result := CalLookup(VisibleHz, FCal.SMeterBandDB, FCal.SMeterXvtrDB)
            + FrontEndCalDB;
end;

function TRadioController.FrontEndCalDB: Double;
// Поправка на регулируемое усиление входного тракта — то, что меняется ручкой
// или само и не должно двигать абсолютную шкалу dBm.
//   openHPSDR: шаговый аттенюатор ADC режет вход → его дБ возвращаем в
//              показания.
//   AD936x:    hardwaregain поднимает уровень (а в режимах AGC железо крутит
//              его само) → вычитаем текущее значение.
// Постоянная часть тракта (LNB, кабель, конвертер) остаётся за таблицами
// калибровки на диапазон/трансвертер.
var G: Double;
begin
  if not IsPluto then Exit(FAtten);
  Result := 0.0;
  if FNetwork = nil then Exit;
  G := FNetwork.RxGainNowDb;
  if G > TELEMETRY_NONE then Result := -G;
end;

function TRadioController.DispCalFor(PanId: Integer): Double;
// Зовётся из display-потока движка раз в кадр. Главный дисплей калибруем по
// центру полосы, доп. пан — по своей частоте (паны бывают кросс-банд).
var Hz: Double;
begin
  if (PanId > 0) and (PanId < MAX_PANS) then Hz := FPanFreqHz[PanId]
  else                                       Hz := FCenterFreq;
  if Hz <= 0 then Exit(0.0);
  Result := CalLookup(Hz, FCal.DispBandDB, FCal.DispXvtrDB) + FrontEndCalDB;
end;

procedure TRadioController.SetSliceDevice(Id, DevIndex: Integer;
  const DevName: string);
var idx: Integer;
begin
  idx := FindSliceIndex(Id);
  if idx < 0 then Exit;
  FSliceOutDev[idx] := DevName;   // выбор во флаге = устройство слота (одна истина)
  FSlices[idx].DeviceIndex := DevIndex;
  FSlices[idx].DevName     := DevName;
  if FSlices[idx].Audio <> nil then
  begin
    FSlices[idx].Audio.Close;
    FSlices[idx].Audio.DeviceIndex := DevIndex;
    if FLocalAudio then FSlices[idx].Audio.Open;
  end;
end;

// --- Пул входных потоков -----------------------------------------------------

function TRadioController.AcquireMicInput(DevIndex: Integer): TAudioInput;
// Ссылка на открытый поток устройства DevIndex (создаёт при первом обращении).
// DevIndex<0 = «своего входа нет» → nil (читатель упадёт на общий FAudioIn).
var
  i, n: Integer;
  AI:   TAudioInput;
begin
  Result := nil;
  if DevIndex < 0 then Exit;
  // Общий вход уже открыт на этом же устройстве — переиспользуем его: второй
  // Open того же PA-устройства на эксклюзивном ALSA-hw просто не прошёл бы.
  if Assigned(FAudioIn) and (FAudioIn.DeviceIndex = DevIndex) then
    Exit(FAudioIn);
  for i := 0 to High(FMicPool) do
    if FMicPool[i].DevIdx = DevIndex then
    begin
      // Запись с Refs=0 — поток закрыт, но объект жив (см. ReleaseMicInput).
      if (FMicPool[i].Refs <= 0) and FLocalAudio and
         (not FMicPool[i].AI.IsOpen) then
        try FMicPool[i].AI.Open; except end;
      Inc(FMicPool[i].Refs);
      Exit(FMicPool[i].AI);
    end;
  AI := TAudioInput.Create(48000);
  AI.DeviceIndex := DevIndex;
  if FLocalAudio then
    try AI.Open; except end;
  n := Length(FMicPool);
  SetLength(FMicPool, n + 1);
  FMicPool[n].DevIdx := DevIndex;
  FMicPool[n].Refs   := 1;
  FMicPool[n].AI     := AI;
  Result := AI;
end;

procedure TRadioController.ReleaseMicInput(var AI: TAudioInput);
// Последняя ссылка ушла — поток ЗАКРЫВАЕМ, но объект оставляем в пуле до
// выхода: TX-поток (PullSoundCardMic) мог взять указатель прямо сейчас, а
// освобождение памяти под ним = падение. Повторный выбор устройства просто
// переоткроет ту же запись.
var i: Integer;
begin
  if AI = nil then Exit;
  if AI = FAudioIn then begin AI := nil; Exit; end;   // общий вход не из пула
  for i := 0 to High(FMicPool) do
    if FMicPool[i].AI = AI then
    begin
      Dec(FMicPool[i].Refs);
      if FMicPool[i].Refs <= 0 then
      begin
        FMicPool[i].Refs := 0;
        FMicPool[i].AI.Close;
      end;
      Break;
    end;
  AI := nil;
end;

procedure TRadioController.FreeMicPool;
var i: Integer;
begin
  for i := 0 to High(FMicPool) do
    if FMicPool[i].AI <> nil then
    begin
      FMicPool[i].AI.Close;
      FMicPool[i].AI.Free;
    end;
  SetLength(FMicPool, 0);
end;

procedure TRadioController.SetSliceInDevice(Id, DevIndex: Integer;
  const DevName: string);
begin
  SetSliceInDeviceIdx(FindSliceIndex(Id), DevIndex, DevName);
end;

procedure TRadioController.SetSliceInDeviceIdx(Idx, DevIndex: Integer;
  const DevName: string);
begin
  if (Idx < 0) or (Idx >= MAX_SLICES) or (not FSlices[Idx].Active) then Exit;
  if DevName = '' then DevIndex := -1;
  FSliceInDev[Idx] := DevName;   // выбор во флаге = вход слота (одна истина)
  ReleaseMicInput(FSlices[Idx].AudioIn);
  FSlices[Idx].InDeviceIndex := DevIndex;
  FSlices[Idx].InDevName     := DevName;
  FSlices[Idx].AudioIn       := AcquireMicInput(DevIndex);
  // Слайс сейчас источник TX — источник модуляции мог сменить тип
  // (звуковуха ↔ микрофон радио).
  if (FTxSliceId = FSlices[Idx].Id) and FWDSPReady and Assigned(FDSPEngine) then
    FDSPEngine.SetTXMicSource(DefaultMicSource);
end;

procedure TRadioController.SetMainMicDevice(DevIndex: Integer;
  const DevName: string);
var i: Integer;
begin
  if Assigned(FAudioIn) then
  begin
    if FAudioIn.IsOpen then FAudioIn.Close;
    FAudioIn.DeviceIndex := DevIndex;
    if (DevIndex >= 0) and FLocalAudio then
      try FAudioIn.Open; except end;
  end;
  FAudioInDevName := DevName;
  // Слайсы могли ссылаться на общий вход (или держать свой поток на устройстве,
  // которое теперь стало общим) — перепривязать все ссылки к новой картине.
  for i := 0 to MAX_SLICES - 1 do
    if FSlices[i].Active then
    begin
      ReleaseMicInput(FSlices[i].AudioIn);
      FSlices[i].AudioIn := AcquireMicInput(FSlices[i].InDeviceIndex);
    end;
  if FWDSPReady and Assigned(FDSPEngine) then
    FDSPEngine.SetTXMicSource(DefaultMicSource);
end;

function TRadioController.ActiveMicInput: TAudioInput;
// Вход TX-источника: свой поток слайса, иначе общий (слайс без выбора).
var idx: Integer;
begin
  Result := FAudioIn;
  if FTxSliceId = 0 then Exit;
  idx := FindSliceIndex(FTxSliceId);
  // Поток слайса мог не открыться (устройство занято/исчезло) — тогда лучше
  // передавать с общего входа, чем молчать в эфир.
  if (idx >= 0) and (FSlices[idx].AudioIn <> nil) and
     FSlices[idx].AudioIn.IsOpen then
    Result := FSlices[idx].AudioIn;
end;

function TRadioController.ActiveMicInDevName: string;
var idx: Integer;
begin
  Result := FAudioInDevName;
  if FTxSliceId = 0 then Exit;
  idx := FindSliceIndex(FTxSliceId);
  if (idx >= 0) and (FSlices[idx].InDevName <> '') then
    Result := FSlices[idx].InDevName;
end;

function TRadioController.SliceCount: Integer;
var i: Integer;
begin
  Result := 0;
  for i := 0 to MAX_SLICES - 1 do
    if FSlices[i].Active then Inc(Result);
end;

function TRadioController.GetSlice(Id: Integer; out S: TCtrlSlice): Boolean;
var idx: Integer;
begin
  idx := FindSliceIndex(Id);
  Result := idx >= 0;
  if Result then S := FSlices[idx];
end;

function TRadioController.GetSliceView(Id: Integer; out V: TSliceView): Boolean;
// Читается из чужих потоков (TCI), поэтому — поле за полем и только скаляры.
// Active перечитывается после копирования: слайс могли удалить прямо во время
// снятия снимка, и тогда честнее вернуть False, чем полуживую запись.
var idx: Integer;
begin
  FillChar(V, SizeOf(V), 0);
  Result := False;
  idx := FindSliceIndex(Id);
  if idx < 0 then Exit;
  V.Id        := FSlices[idx].Id;
  V.PanId     := FSlices[idx].PanId;
  V.TargetHz  := FSlices[idx].TargetHz;
  V.Mode      := FSlices[idx].Mode;
  V.FilterLo  := FSlices[idx].FilterLo;
  V.FilterHi  := FSlices[idx].FilterHi;
  V.AGC       := FSlices[idx].AGC;
  V.Volume    := FSlices[idx].Volume;
  V.Muted     := FSlices[idx].Muted;
  V.FMSQOn    := FSlices[idx].FMSQOn;
  V.FMSQLevel := FSlices[idx].FMSQLevel;
  V.NRMode    := FSlices[idx].NRMode;
  V.NBMode    := FSlices[idx].NBMode;
  V.SNB       := FSlices[idx].SNB;
  V.ANF       := FSlices[idx].ANF;
  Result := FSlices[idx].Active and (FSlices[idx].Id = Id);
end;

function TRadioController.PanSliceCount(PanId: Integer): Integer;
var i: Integer;
begin
  Result := 0;
  for i := 0 to MAX_SLICES - 1 do
    if FSlices[i].Active and (FSlices[i].PanId = PanId) then Inc(Result);
end;

function TRadioController.PanSliceId(PanId, Index: Integer): Integer;
var i, Seen: Integer;
begin
  Result := 0;
  if Index < 0 then Exit;
  Seen := 0;
  for i := 0 to MAX_SLICES - 1 do
    if FSlices[i].Active and (FSlices[i].PanId = PanId) then
    begin
      if Seen = Index then Exit(FSlices[i].Id);
      Inc(Seen);
    end;
end;

function TRadioController.SlicePanIndex(Id: Integer; out PanId, Index: Integer): Boolean;
var i, Seen, Pan: Integer;
begin
  Result := False;
  PanId := 0; Index := 0;
  if Id <= 0 then Exit;
  Pan := -1;
  for i := 0 to MAX_SLICES - 1 do
    if FSlices[i].Active and (FSlices[i].Id = Id) then
    begin
      Pan := FSlices[i].PanId;
      Break;
    end;
  if Pan < 0 then Exit;

  Seen := 0;
  for i := 0 to MAX_SLICES - 1 do
    if FSlices[i].Active and (FSlices[i].PanId = Pan) then
    begin
      if FSlices[i].Id = Id then
      begin
        PanId := Pan;
        Index := Seen;
        Exit(True);
      end;
      Inc(Seen);
    end;
end;

// ---------------------------------------------------------------------------
// Слоты слайсов: настройки (CAT-порт, Auto TX) висят на слоте/букве, а слайс
// на слоте может появляться и исчезать.
// ---------------------------------------------------------------------------

class function TRadioController.SliceSlotLetter(Slot: Integer): Char;
begin
  // Слайс A — главный приёмник, поэтому слоты нумеруются с 'B' (как AddSlice).
  if (Slot < 0) or (Slot >= MAX_SLICES) then Exit('?');
  Result := Chr(Ord('B') + Slot);
end;

function TRadioController.SliceIdBySlot(Slot: Integer): Integer;
begin
  Result := 0;
  if (Slot < 0) or (Slot >= MAX_SLICES) then Exit;
  if FSlices[Slot].Active then Result := FSlices[Slot].Id;
end;

function TRadioController.SliceSlotOf(Id: Integer): Integer;
begin
  Result := FindSliceIndex(Id);
end;

function TRadioController.SliceSlotAutoTx(Slot: Integer): Boolean;
begin
  Result := (Slot >= 0) and (Slot < MAX_SLICES) and FSliceAutoTx[Slot];
end;

procedure TRadioController.SetSliceSlotAutoTx(Slot: Integer; On_: Boolean);
// Без Changed(): единственный вызывающий — TCATAdapter.ApplySettings, а он
// работает и на старте (до постройки виджетов), и на connect. Перерисовку
// бейджей делает UI-путь настроек (MainForm.OnSliceCATSettingsChange).
begin
  if (Slot < 0) or (Slot >= MAX_SLICES) then Exit;
  FSliceAutoTx[Slot] := On_;
end;

function TRadioController.SliceAutoTx(Id: Integer): Boolean;
begin
  Result := SliceSlotAutoTx(FindSliceIndex(Id));
end;

function TRadioController.SliceSlotOutDev(Slot: Integer): string;
begin
  if (Slot >= 0) and (Slot < MAX_SLICES) then Result := FSliceOutDev[Slot]
  else Result := '';
end;

function TRadioController.SliceSlotInDev(Slot: Integer): string;
begin
  if (Slot >= 0) and (Slot < MAX_SLICES) then Result := FSliceInDev[Slot]
  else Result := '';
end;

procedure TRadioController.SetSliceSlotAudio(Slot: Integer; const OutDev, InDev: string);
// Настройка = устройство слота, поэтому применяем сразу: слайс, который стоит
// на слоте, переезжает на новое устройство (как выбор во вкладке AUD флага).
var Id: Integer;
begin
  if (Slot < 0) or (Slot >= MAX_SLICES) then Exit;
  Id := SliceIdBySlot(Slot);
  if FSliceOutDev[Slot] <> OutDev then
  begin
    FSliceOutDev[Slot] := OutDev;
    if Id > 0 then
    begin
      if (OutDev <> '') and Assigned(FAudioOut) then
        SetSliceDevice(Id, FAudioOut.FindDeviceByName(OutDev), OutDev)
      else
        SetSliceDevice(Id, -1, '');    // общий выход приложения
    end;
  end;
  if FSliceInDev[Slot] <> InDev then
  begin
    FSliceInDev[Slot] := InDev;
    if Id > 0 then
    begin
      if (InDev <> '') and Assigned(FAudioIn) then
        SetSliceInDevice(Id, FAudioIn.FindDeviceByName(InDev), InDev)
      else
        SetSliceInDevice(Id, -1, '');  // общий вход приложения
    end;
  end;
end;

procedure TRadioController.RequestSliceTx(SliceId: Integer; On_: Boolean;
  Tune: Boolean = False);
// PTT, пришедшая по CAT-порту слайса или по TCI с номером приёмника. При
// включённом Auto TX слайс сам становится
// TX-источником, без него — передаёт, только если оператор уже выбрал его
// источником (бейдж TX на флаге).
// Выполняется в потоке контроллера (CAT и TCI маршалят через Invoke), поэтому
// проверка «уже в эфире» и смена источника атомарны относительно GUI.
var idx: Integer;
begin
  idx := FindSliceIndex(SliceId);
  if idx < 0 then Exit;

  if not On_ then
  begin
    // Отпускание PTT снимает только СВОЮ передачу: RX; на порту слайса C не
    // должен обрывать эфир слайса B.
    if (FTxSliceId = SliceId) and (FTransmitting or FTuning) then
    begin
      if FTuning then SetTune(False) else SetMOX(False);
    end;
    Exit;
  end;

  // Передатчик один: пока кто-то в эфире (другой слайс, главный VFO, кнопка
  // MOX/TUNE, аппаратная PTT) — чужую передачу не перехватываем и источник не
  // переключаем. Своя же передача уже идёт, повторный TX; — no-op.
  if FTransmitting or FTuning then Exit;
  if FSlices[idx].Mode = MODE_DMR then Exit;   // AMBE-передатчика нет

  if FTxSliceId <> SliceId then
  begin
    if not FSliceAutoTx[idx] then Exit;   // без Auto TX слайс сам себя не назначает
    SetTxSlice(SliceId);
    if FTxSliceId <> SliceId then Exit;   // SetTxSlice отказал (DMR/нет слайса)
  end;
  if Tune then SetTune(True) else SetMOX(True);
end;

// Снимок кадра для web-зеркала: потолок точек задаёт настройка web.spec_pixels
// (Limit), при большем разрешении анализатора децимируем max-детектором
// (не обрезаем).
// DstCount — сколько точек РЕАЛЬНО заполнено; отправлять больше нельзя, иначе
// хвост буфера (нули = 0 dBm) уедет в эфир белой полосой, а частотная шкала
// клиента растянется на несуществующие точки.
//
// Группы — строго одинаковой ширины (целочисленный фактор). Прежнее деление
// «i*Count div N» давало на нецелом отношении соседние группы разной ширины
// (1 и 2 точки), а смещение max-детектора зависит от числа точек в группе —
// по спектру шла гребёнка ±3 дБ, в водопаде видимая как вертикальная рябь.
procedure SnapshotPixels(const Pixels: array of Single; Count: Integer;
  var Dst: array of Single; Limit: Integer; out DstCount: Integer);
var
  i, j, S0, S1, N, K, M: Integer;
  V: Single;
begin
  N := EnsureRange(Limit, 64, Length(Dst));
  if Count > Length(Pixels) then Count := Length(Pixels);
  if Count < 0 then Count := 0;
  if Count <= N then
  begin
    for i := 0 to Count - 1 do Dst[i] := Pixels[i];
    DstCount := Count;
    Exit;
  end;
  K := (Count + N - 1) div N;        // точек анализатора на точку web
  M := (Count + K - 1) div K;        // столько точек получится (<= N)
  for i := 0 to M - 1 do
  begin
    S0 := i * K;
    S1 := S0 + K - 1;
    if S1 > Count - 1 then S1 := Count - 1;   // хвостовая группа короче
    V := Pixels[S0];
    for j := S0 + 1 to S1 do
      if Pixels[j] > V then V := Pixels[j];
    Dst[i] := V;
  end;
  DstCount := M;
end;

procedure TRadioController.OnSpectrumReady(const Pixels: array of Single;
  Count: Integer);
// DSP-поток: сохраняем последний кадр для web-зеркала и отдаём сырьё наружу
// (UI рисует FSpecView). Децимации у спектра нет.
begin
  SnapshotPixels(Pixels, Count, FSpectrumBuf, FWebSpecPixels, FSpectrumBufCount);
  if Assigned(FOnSpectrumData) then FOnSpectrumData(Pixels, Count);
end;

procedure TRadioController.OnWaterfallReady(const Pixels: array of Single;
  Count: Integer);
// DSP-поток: web-зеркало получает КАЖДЫЙ кадр; децимация рендера водопада —
// забота фронтенда (OnWaterfallData).
//
// Web шлёт 20 строк/с, анализатор даёт до 60 кадров/с — просто «последний
// кадр» терял бы 2 из 3 (короткая посылка CW/FT8 могла не попасть ни в одну
// строку). Копим кадры max-hold'ом до тех пор, пока PushState не заберёт
// строку (идиома TWaterfallView.SetWaterfallData при WfFrameInterval>1).
var
  Tmp: array[0..SPECTRUM_PIXELS-1] of Single;
  N, i: Integer;
begin
  SnapshotPixels(Pixels, Count, Tmp, FWebSpecPixels, N);
  if (not FWfAccumFresh) or (N <> FWaterfallBufCount) then
  begin
    for i := 0 to N - 1 do FWaterfallBuf[i] := Tmp[i];
    FWaterfallBufCount := N;
  end
  else
    for i := 0 to N - 1 do
      if Tmp[i] > FWaterfallBuf[i] then FWaterfallBuf[i] := Tmp[i];
  FWfAccumFresh := True;
  if Assigned(FOnWaterfallData) then FOnWaterfallData(Pixels, Count);
end;

procedure TRadioController.OnMicPacket(const Data: TMicDataPacket);
// HW mic-стрим уходит в WDSP только если выбран источник Radio; иначе сэмплы
// игнорируются (mic берётся со звуковой карты).
begin
  if FTransmitting and FWDSPReady and
     (FDSPEngine.TXMicSource = txmsRadio) then
    FDSPEngine.PushTXMicSamples16(Data.Samples, 64);
end;

function TRadioController.PullSoundCardMic(MaxN: Integer): Integer;
// Колбэк TTXDSPThread при TXMicSource=txmsSoundCard. Тянем только реально
// доступные сэмплы из FAudioIn (иначе пушили бы тишину в mic-цепь WDSP),
// пачкой в локальный буфер — экономим будилки семафора.
var
  i, n: Integer;
  AI:   TAudioInput;
  Buf:  array[0..2047] of Double;
begin
  Result := 0;
  AI := ActiveMicInput;   // вход TX-слайса (свой) либо общий
  if (AI = nil) or (not AI.IsOpen) then Exit;
  n := AI.Available;
  if n > MaxN then n := MaxN;
  if n > Length(Buf) then n := Length(Buf);
  for i := 0 to n - 1 do
    Buf[i] := AI.ReadSample;
  if n > 0 then
    FDSPEngine.PushTXMicSamplesD(Buf, n);
  Result := n;
end;

procedure TRadioController.Invoke(M: TThreadMethod);
begin
  if Assigned(FOnInvoke) then FOnInvoke(M)
  else if Assigned(M) then M();   // нет хозяина-маршалера — прямой вызов
end;

function TRadioController.GetSnapshot: TRadioSnapshot;
begin
  FillChar(Result, SizeOf(Result), 0);
  Result.VfoA := FVfoA; Result.VfoB := FVfoB;
  Result.CenterFreq := FCenterFreq; Result.SpanHz := FSpanHz;
  Result.SampleRate := FSampleRate; Result.ActiveVfo := FActiveVfo;
  Result.Mode := FMode; Result.Filter := FFilter; Result.FilterBW := FFilterBW;
  Result.AGCMode := FAGCMode; Result.AGCTop := FAGCTop;
  Result.RxGainMode := FRxGainMode; Result.RxGainDb := FRxGainDb;
  Result.Volume := FVolume; Result.Drive := FDrivePercent; Result.Atten := FAtten;
  Result.Band := FCurrentBand; Result.Xvtr := FCurrentXvtr;
  Result.Muted := FMuted; Result.CTun := FCTun; Result.Duplex := FDisplayDuplex;
  Result.Running := FRunning; Result.Connected := FDevConnected;
  Result.Transmitting := FTransmitting; Result.Tuning := FTuning;
  Result.NRMode := FNRMode; Result.NBMode := FNBMode;
  Result.SNB := FSNB; Result.ANF := FANF;
  Result.WfAGC := FWfAGCEnabled; Result.WfNF := FWfNFEnabled;
  Result.FMStepOn := FFMStepOn; Result.FMStepIdx := FFMStepIdx;
  Result.FMCTCSSOn := FFMCTCSSOn; Result.FMCTCSSToneIdx := FFMCTCSSToneIdx;
  Result.FMSQOn := FFMSQOn; Result.FMSQLevel := FFMSQLevel;
  Result.FMRptDir := FFMRptDir;
  Result.SMeter := FLastSMeter; Result.FwdW := FLastFwdW; Result.SWR := FLastSWR;
  Result.SupplyV := FLastSupplyV; Result.SupplyA := FLastSupplyA;
  Result.PLLLock := FLastPLLLock;
end;

function TRadioController.XvtrTranslate(VisibleHz: Double): Double;
begin
  Result := VisibleHz;
  if (FCurrentXvtr < 0) or (FCurrentXvtr >= CFG_XVTR_COUNT) then Exit;
  if not FXvtrSettings.Entries[FCurrentXvtr].Enabled then Exit;
  // f_IF = f_visible - LOOffset + LOError.
  // QO-100 beacon lock складывает свою коррекцию дрейфа LNB ПРЯМО в LOError
  // (живая калибровка): она сразу видна в настройках и сохраняется. Отдельного
  // слагаемого нет — иначе двойной счёт.
  Result := VisibleHz
            - FXvtrSettings.Entries[FCurrentXvtr].LOOffset
            + FXvtrSettings.Entries[FCurrentXvtr].LOError;
end;

function TRadioController.XvtrTranslateTX(VisibleHz: Double): Double;
begin
  Result := VisibleHz;
  if (FCurrentXvtr < 0) or (FCurrentXvtr >= CFG_XVTR_COUNT) then Exit;
  if not FXvtrSettings.Entries[FCurrentXvtr].Enabled then Exit;
  // QO-100 / split-LO: TX идёт на собственный uplink-LO (visible - TxLOOffset),
  // без LOError — дрейф LNB к передаче отношения не имеет.
  if FXvtrSettings.Entries[FCurrentXvtr].FullDuplexTx then
    // TO Error: сам транспондер-offset держим стандартным, уход тракта — здесь.
    Result := VisibleHz - FXvtrSettings.Entries[FCurrentXvtr].TxLOOffset
              + FXvtrSettings.Entries[FCurrentXvtr].TxLOError
  else
    // Обычный трансвертер: RX=TX LO (с калибровкой LOError) — как раньше.
    Result := VisibleHz
              - FXvtrSettings.Entries[FCurrentXvtr].LOOffset
              + FXvtrSettings.Entries[FCurrentXvtr].LOError;
end;

function TRadioController.ActiveTXFreqHz: Double;
// Снимок (GetSliceView), а не GetSlice: функцию зовут и внешние фронтенды из
// своих потоков, а копия TCtrlSlice тащит managed-строки слайса.
var S: TSliceView;
begin
  // Мультислайс-TX: если выбран слайс-источник — передаём на его частоте
  // (слайсы не несут repeater-конфиг, FM-сдвиг не применяем).
  if (FTxSliceId > 0) and GetSliceView(FTxSliceId, S) then
    Result := S.TargetHz
  else
  begin
    // TX-VFO задаётся ТОЛЬКО селектором FSplitTxB (UI: кнопки TX-A/TX-B),
    // независимо от активного RX-VFO. Раньше при не-split TX ошибочно следовал
    // за активным RX (TX-A горит, но передача шла на B при активном B).
    if FSplitTxB then Result := FVfoB
    else Result := FVfoA;
    if FMode = MODE_FM then
      case FFMRptDir of
        RPT_MINUS: Result := Result - FFMRptOffsetHz;
        RPT_PLUS:  Result := Result + FFMRptOffsetHz;
      end;
  end;
  // Телеграф + TUN: несущую даёт SSB-модулятор из тона pitch, поэтому DUC надо
  // сдвинуть навстречу — иначе настроечная несущая уедет от VFO на pitch. При
  // работе ключом (кейер/CWX) сдвигать нечего: РЧ формирует прошивка на DUC.
  if FTuning and (ActiveTXMode in [MODE_CWL, MODE_CWU]) and FWDSPReady
     and Assigned(FDSPEngine) then
    Result := Result + FDSPEngine.CWLOOffset(ActiveTXMode);
end;

function TRadioController.TXTuneFreqHz: Double;
// Единая дверь «какую частоту передачи класть в железо». Кроме трансвертера
// учитывает сдвиг несущей локального кейера: манипуляция идёт не на нуле
// бейсбенда, а на CWCarrierOffsetHz, значит гетеродин обязан уехать НАВСТРЕЧУ,
// иначе телеграф уйдёт в эфир мимо VFO ровно на этот сдвиг.
// ★Сдвиг применяется, пока кейер ВООРУЖЁН, а не только на передаче: железо
// может замкнуть ключ в любой момент (тот же урок, что был с частотой DUC).
//
// ★★ЗНАК — ЗАМЕРЕН НА ЭФИРЕ (Pluto, 2026-08-10), а не выведен: положительная
// частота бейсбенда в нашей конвенции выходит в эфир НИЖЕ гетеродина
// (РЧ = LO + CW_TX_BB_SIGN·f). Первая версия считала наоборот, и несущая вставала
// на 2×сдвига НИЖЕ маркера. Отсюда гетеродин уезжает ВВЕРХ на величину сдвига.
// Если когда-нибудь понадобится ненулевой сдвиг у openHPSDR — проверить там
// отдельно: тракт другой (цифровой DUC вместо zero-IF), и знак может отличаться.
const
  CW_TX_BB_SIGN = -1;
begin
  Result := XvtrTranslateTX(ActiveTXFreqHz) - CW_TX_BB_SIGN * CWCarrierOffsetHz;
end;

function TRadioController.ActiveTXMode: Integer;
var S: TCtrlSlice;
begin
  if (FTxSliceId > 0) and GetSlice(FTxSliceId, S) then Result := S.Mode
  else Result := FMode;
end;

function TRadioController.TxBandIdx: Integer;
begin
  // При TX на слайсе другого диапазона (только КВ) калибровка/антенна — по
  // банду TX-частоты, иначе главный банд.
  Result := FCurrentBand;
  if (FTxSliceId > 0) and (FCurrentXvtr < 0) then
  begin
    Result := FreqToBandIdx(ActiveTXFreqHz, IsPluto);
    if (Result < 0) or (Result >= CFG_BAND_COUNT) then Result := FCurrentBand;
  end;
end;

procedure TRadioController.SetTxSlice(SliceId: Integer);
var S: TCtrlSlice;
begin
  if SliceId < 0 then SliceId := 0;
  // Слайс-источник должен существовать (0 = главный всегда валиден).
  if SliceId > 0 then
  begin
    if not GetSlice(SliceId, S) then Exit;
    // EWSDR has no DMR transmitter/AMBE encoder.
    if S.Mode = MODE_DMR then Exit;
  end;
  if FTxSliceId = SliceId then Exit;
  // Selecting main A while it is in DMR is allowed as an RX selection, but it
  // cannot inherit an active carrier from a previously selected TX slice.
  if (SliceId = 0) and (FMode = MODE_DMR) then
  begin
    if FTuning then SetTune(False)
    else if FTransmitting then SetMOX(False);
  end;
  FTxSliceId := SliceId;
  // Профиль, привязанный к новому источнику, — ДО пуша режима и цепи: иначе
  // ApplyTXSettingsToDSP внутри SelectTXProfile лёг бы на старый режим.
  ApplyBoundTXProfile;
  // Передающий тракт под режим нового источника (idle-канал, применится к PTT).
  if FWDSPReady and Assigned(FDSPEngine) then
  begin
    FDSPEngine.SetTXMode(ActiveTXMode);
    // Источник модуляции — вход НОВОГО слайса (у него может быть свой).
    FDSPEngine.SetTXMicSource(DefaultMicSource);
  end;
  // Кольцо нового входа копило звук всё время, пока он не был источником:
  // без сброса первые ~200мс передачи ушли бы в эфир «из прошлого».
  if Assigned(ActiveMicInput) and ActiveMicInput.IsOpen then
    ActiveMicInput.Flush;
  SyncCWKeyer;                    // кейер прошивки — под режим нового источника
  FDriveLevel := CalcDriveByte;   // калибровка драйва банда TX-слайса
  ApplyAntPorts;                  // AD936x: TX-разъём — по диапазону TX-слайса
  PushNetworkState;               // TX-частота/Alex через ActiveTXFreqHz/TxBandIdx
  Changed(rfTransmitting);        // UI: перерисовать TX-бейджи флагов/оверлей
end;

// ---------------------------------------------------------------------------
// QO-100 beacon lock
// ---------------------------------------------------------------------------

const
  // --- контур коррекции (измерение частоты — от ДЕКОДЕРА, не от display-FFT) ---
  // Декодер точно ведёт несущую маяка своим NCO (RunAcquisition + Costas-offload):
  // суммарный остаток ResidHz+CarFreqHz = текущее смещение маяка от точки наведения.
  // Это смещение медленно стекаем в LOError активного XVTR → ретюн LO компенсирует
  // дрейф LNB, весь даунлинк стоит. Внутренний NCO декодера держит маяк
  // декодированным во время плавного ретюна (наведение едет feed-forward за маяком).
  BEACON_GAIN        = 0.5;      // гейн контура: <1 — запас от перелёта (декодерная
                                 // оценка точная, но ретюн LO имеет латентность пайплайна)
  BEACON_DEADBAND_HZ = 20.0;     // внутри — коррекции стоп (декодер точнее FFT → дедбэнд уже)
  BEACON_MAX_STEP_HZ = 400.0;    // клам один шаг коррекции (защита от выброса оценки)
  BEACON_SETTLE_TICKS = 8;       // пауза (~0.8 с @10 Гц) после коррекции — осадка ретюна LO
  BEACON_SAVE_TICKS  = 30;       // троттлинг записи LOError на диск (~3 с @10 Гц)
  // --- знак отображения остатка декодера ---
  // invert=True (QO-100/LNB) выравнивает частотную ось декодера с дисплеем, поэтому
  // display_маяка = наведение + (ResidHz+CarFreqHz). Если на эфире контур убегает —
  // ось перевёрнута, флипнуть на -1.0 (см. лог: e и дрейф должны иметь один знак).
  BEACON_RESID_SIGN  = 1.0;
  // --- метрика лока (берём из снимка декодера: CarrierLock + SNR) ---
  BEACON_LOCK_SNR_DB   = 3.0;    // солидный лок: декодер несёт несущую и SNR выше этого
  BEACON_UNLOCK_SNR_DB = 0.0;    // гистерезис: лок гаснет ниже этого
  // --- «это точно BPSK, а не голая несущая» ---
  // CarrierLock+SNR подтверждают лишь то, что в полосе есть узкая линия и Costas
  // держит фазу. Ровная несущая (телеграф, пораженка, остаток несущей у цифры) даёт
  // ровно это, причём с ОТЛИЧНЫМ SNR: захваченная Costas'ом, она лежит целиком на
  // оси I, Q пустой. Контур тогда честно уводил LOError, подтягивая чужой сигнал к
  // опорной частоте маяка. Различаем по констелляции: у настоящего BPSK точки ходят
  // между двумя сгустками ±I (данные после свёрточного кодера равновероятны), у
  // несущей все точки в одном. Меряем долю меньшинства по знаку I.
  BEACON_BAL_MIN     = 0.15;     // ниже — на BPSK не похоже, лок не даём
  BEACON_BAL_DROP    = 0.08;     // гистерезис: уже взятый лок гаснет ниже этого
  BEACON_BAL_MIN_PTS = 64;       // меньше точек в кольце — судить рано

procedure TRadioController.SetBeaconRefHz(Hz: Double);
begin
  FBeaconRefHz := Hz;
end;

procedure TRadioController.BeaconSeedAtHz(DisplayHz: Double);
// Пользователь кликнул по маяку → наводим узкий тракт декодера на эту частоту и
// переинициализируем петли (RunAcquisition заново оценит несущую). Дальше декодер
// сам ведёт маяк своим NCO, а ServiceBeaconLock стекает измеренный дрейф в LOError.
begin
  if not FBeaconLockOn then SetBeaconLock(True);
  FBeaconDecHz   := DisplayHz;
  FBeaconTrackHz := DisplayHz;
  if Assigned(FBeaconDec) then
  begin
    FBeaconDec.SetOffsetHz(DisplayHz - FCenterFreq);
    FBeaconDec.Reseed;                       // одноразовый грубый захват частоты заново
  end;
  FBeaconBal := 0.0;                          // новое наведение — BPSK-ность доказывается заново
  FBeaconLocked := False;
  FBeaconCorrCooldown := BEACON_SETTLE_TICKS; // дать декодеру захватиться до 1-й коррекции
  Changed(rfBeaconLock);
end;

procedure TRadioController.SetBeaconLock(On_: Boolean);
// Один концепт: лок = декодер + трим LOError. Включение запускает декодер маяка;
// пользователь кликает по маяку (BeaconSeedAtHz) для наведения. Когда декодер
// солидно залочен, ServiceBeaconLock стекает его измеренный дрейф в LOError.
begin
  if On_ = FBeaconLockOn then Exit;
  FBeaconLockOn := On_;
  if On_ then
  begin
    FBeaconCorrCooldown := 0;
    FBeaconSaveCooldown := 0;
    FBeaconLocked       := False;
    FBeaconBal          := 0.0;
    FBeaconSNRdB        := -99.0;
    FBeaconLockHz       := 0.0;       // сессионный аккумулятор индикации (LOError несёт калибровку)
    FBeaconDecHz        := 0.0;       // ещё не наведён — ждём клик по маяку
    FBeaconTrackHz      := FBeaconRefHz;
    if Assigned(FBeaconDec) then
    begin
      FBeaconDec.SetOffsetHz(0);
      FBeaconDec.SetEnabled(True);    // ResetDSP внутри
    end;
  end
  else
  begin
    if Assigned(FBeaconDec) then FBeaconDec.SetEnabled(False);
    FBeaconDecHz := 0.0;
    PersistBeaconCalibration;         // финальный flush LOError на диск
  end;
  Changed(rfBeaconLock);
end;

function BeaconIQBalance(const S: TBeaconScope): Double;
// Доля меньшинства среди знаков I в кольце констелляции: 0.5 — идеально
// сбалансированный BPSK, 0 — все точки в одном сгустке (немодулированная
// несущая). -1 = точек мало, судить рано.
var
  i, Neg: Integer;
begin
  if S.Count < BEACON_BAL_MIN_PTS then Exit(-1.0);
  Neg := 0;
  for i := 0 to S.Count - 1 do
    if S.PtI[i] < 0 then Inc(Neg);
  Result := Min(Neg, S.Count - Neg) / S.Count;
end;

procedure TRadioController.ServiceBeaconLock;
// Одна итерация контура (UI-таймер @10 Гц, MeterTimer). Эталон частоты — ДЕКОДЕР:
// он точно ведёт несущую маяка своим NCO (RunAcquisition + Costas-offload), и его
// суммарный остаток ResidHz+CarFreqHz = текущее смещение маяка от точки наведения.
// Измеренную АБСОЛЮТНУЮ частоту маяка (наведение + остаток) подтягиваем к опорной,
// стекая ошибку в LOError активного XVTR → ретюн LO компенсирует дрейф LNB, весь
// даунлинк стоит. Наведение декодера едет feed-forward за маяком (FBeaconDecHz),
// так что внутренний NCO держит маяк декодированным сквозь плавный ретюн (нетто
// baseband-смещение ≈ 0: маяк сдвинулся на −dLock железом, наведение тоже на −dLock).
var
  S: TBeaconScope;
  residHz, measured, e, dLock, Bal: Double;
begin
  if not FBeaconLockOn then Exit;
  if not Assigned(FBeaconDec) then Exit;

  // Держим наведение декодера через АКТУАЛЬНЫЙ FCenterFreq (панорама/CTUN не ломают
  // снос). До клика (FBeaconDecHz=0) — DC; acquisition попробует поймать у центра.
  if FBeaconDecHz > 0 then
    FBeaconDec.SetOffsetHz(FBeaconDecHz - FCenterFreq)
  else
    FBeaconDec.SetOffsetHz(0);

  if FCurrentXvtr < 0 then Exit;               // трим LOError только в XVTR-режиме
  if FBeaconSaveCooldown > 0 then Dec(FBeaconSaveCooldown);

  FBeaconDec.GetScope(S);
  FBeaconSNRdB := FBeaconSNRdB + 0.2 * (S.SNRdB - FBeaconSNRdB);
  // Доля меньшинства по знаку I: у BPSK ≈0.5, у голой несущей ≈0. Кольцо в 256
  // точек при 400 Bd набирается за ~0.6 с, EMA добавляет ещё столько же — то есть
  // чужой сигнал отваливается примерно за секунду, а не за десяток (кадр FEC).
  Bal := BeaconIQBalance(S);
  if Bal >= 0 then FBeaconBal := FBeaconBal + 0.2 * (Bal - FBeaconBal);
  // Солидный лок: декодер несёт несущую, SNR выше порога И картинка похожа на
  // BPSK (гистерезис по обоим — против мерцания).
  if S.CarrierLock and (FBeaconSNRdB >= BEACON_LOCK_SNR_DB)
     and (FBeaconBal >= BEACON_BAL_MIN) then FBeaconLocked := True
  else if (FBeaconSNRdB < BEACON_UNLOCK_SNR_DB)
     or (FBeaconBal < BEACON_BAL_DROP) then FBeaconLocked := False;

  // Остаток несущей декодера (Гц, относительно наведения). invert выравнивает ось
  // декодера с дисплеем → display_маяка = наведение + остаток (знак BEACON_RESID_SIGN).
  residHz  := BEACON_RESID_SIGN * (S.ResidHz + S.CarFreqHz);
  if FBeaconDecHz > 0 then
  begin
    measured := FBeaconDecHz + residHz;       // абсолютная display-частота маяка
    FBeaconTrackHz := measured;               // для оранжевого маркера
  end
  else
    measured := FBeaconTrackHz;

  // Коррекция: не каждый тик (ретюн LO имеет латентность пайплайна). После коррекции
  // ждём BEACON_SETTLE_TICKS. Только при солидном локе и после наведения кликом.
  if FBeaconCorrCooldown > 0 then
    Dec(FBeaconCorrCooldown)
  else if FBeaconLocked and (FBeaconDecHz > 0) then
  begin
    e := measured - FBeaconRefHz;
    if Abs(e) > BEACON_DEADBAND_HZ then
    begin
      dLock := BEACON_GAIN * e;
      if dLock >  BEACON_MAX_STEP_HZ then dLock :=  BEACON_MAX_STEP_HZ;
      if dLock < -BEACON_MAX_STEP_HZ then dLock := -BEACON_MAX_STEP_HZ;
      ApplyBeaconLOError(dLock);              // в LOError + ретюн + троттл-сохранение
      // Маяк уедет на −dLock (display); ведём наведение туда же feed-forward, чтобы
      // baseband декодера не дёрнулся и лок не потерялся.
      FBeaconDecHz   := measured - dLock;
      FBeaconTrackHz := FBeaconDecHz;
      FBeaconDec.SetOffsetHz(FBeaconDecHz - FCenterFreq);
      FBeaconCorrCooldown := BEACON_SETTLE_TICKS;
    end;
  end;
  Changed(rfBeaconLock);
end;

procedure TRadioController.ApplyBeaconLOError(dLock: Double);
// Складывает коррекцию маяка ПРЯМО в LOError активного трансвертера — это и есть
// живая калибровка дрейфа LNB: сразу видна в настройках, переживает рестарт.
// FBeaconLockHz — лишь сессионный аккумулятор для индикации. Запись на диск
// троттлим (BEACON_SAVE_TICKS): коррекции редки (deadband), но при долгом прогреве
// LNB их много — не молотим диск каждую.
begin
  if (FCurrentXvtr < 0) or (FCurrentXvtr >= CFG_XVTR_COUNT) then Exit;
  FXvtrSettings.Entries[FCurrentXvtr].LOError :=
    FXvtrSettings.Entries[FCurrentXvtr].LOError + dLock;
  FBeaconLockHz := FBeaconLockHz + dLock;     // сколько накрутили за сессию (индикация)
  PushNetworkState;                            // ретюн: XvtrTranslate читает новый LOError
  if (FBeaconSaveCooldown = 0) and FDevConnected then
  begin
    FSettings.SaveXvtr(FDevMAC, FXvtrSettings);
    FBeaconSaveCooldown := BEACON_SAVE_TICKS;
  end;
end;

procedure TRadioController.PersistBeaconCalibration;
// Коррекция уже живёт в LOError (ApplyBeaconLOError). Здесь — финальный flush на
// диск при выключении лока, на случай неотправленной из-за троттлинга.
begin
  if (FCurrentXvtr < 0) or (FCurrentXvtr >= CFG_XVTR_COUNT) then Exit;
  if FDevConnected then FSettings.SaveXvtr(FDevMAC, FXvtrSettings);
  FBeaconSaveCooldown := 0;
end;

function TRadioController.BeaconState: TBeaconState;
begin
  if not FBeaconLockOn then Exit(bsIdle);
  if FBeaconLocked then Result := bsLock else Result := bsSearch;
end;

function TRadioController.BeaconCorrectionHz: Double;
begin
  Result := FBeaconLockHz;
end;

function TRadioController.BeaconSNRdB: Double;
begin
  Result := FBeaconSNRdB;    // SNR последнего снимка декодера (dB)
end;

function TRadioController.BeaconLockEnabled: Boolean;
begin
  Result := FBeaconLockOn;
end;

function TRadioController.BeaconRefHz: Double;
begin
  Result := FBeaconRefHz;
end;

function TRadioController.BeaconTrackedFreqHz: Double;
begin
  Result := FBeaconTrackHz;
end;

function TRadioController.BeaconDecodeFreqHz: Double;
begin
  Result := FBeaconDecHz;
end;

procedure TRadioController.SetBeaconDecodeInvert(On_: Boolean);
begin
  FBeaconDecInvert := On_;
  if Assigned(FBeaconDec) then FBeaconDec.SetInvert(On_);
  Changed(rfBeaconLock);
end;

function TRadioController.BeaconDecodeInvert: Boolean;
begin
  Result := FBeaconDecInvert;
end;

function TRadioController.GetBeaconScope(out S: TBeaconScope): Boolean;
begin
  Result := Assigned(FBeaconDec);
  if Result then FBeaconDec.GetScope(S)
  else FillChar(S, SizeOf(S), 0);
end;

function TRadioController.GetBeaconFrame(out Frame: TBeaconFrame; out RSErrors: Integer): Boolean;
begin
  Result := Assigned(FBeaconDec) and FBeaconDec.GetLastFrame(Frame, RSErrors);
end;

// ---------------------------------------------------------------------------
// Лупа маяка: окно наведения высокого разрешения (BeaconScopeForm)
// ---------------------------------------------------------------------------
// Движок работает в Гц относительно центра DDC, наружу отдаём абсолютные
// display-Гц — в них живут BeaconSeedAtHz, маркеры и клики пользователя.

procedure TRadioController.GetCaptureWindow(out ALowHz, AHighHz: Double);
var Half: Double;
begin
  Half := FSpanHz / 2.0;
  if Half <= 0 then Half := FSampleRate / 2.0;
  ALowHz  := FCenterFreq - Half;
  AHighHz := FCenterFreq + Half;
end;

procedure TRadioController.SetBeaconZoomWindow(AEnabled: Boolean;
  ACenterHz, ASpanHz: Double);
begin
  if not Assigned(FDSPEngine) then Exit;
  if AEnabled and (ACenterHz > 0) then
    FDSPEngine.SetBeaconZoom(True, ACenterHz - FCenterFreq, ASpanHz)
  else
    FDSPEngine.SetBeaconZoom(False, 0, 0);
end;

function TRadioController.GetBeaconZoomSpectrum(var Pixels: array of Single;
  out Count: Integer; out LowHz, HighHz: Double): Boolean;
begin
  Count  := 0;
  LowHz  := 0;
  HighHz := 0;
  Result := False;
  if not Assigned(FDSPEngine) then Exit;
  Result := FDSPEngine.GetBeaconZoomSpectrum(Pixels, Count, LowHz, HighHz);
  if Count > 0 then
  begin
    LowHz  := LowHz  + FCenterFreq;
    HighHz := HighHz + FCenterFreq;
  end;
end;

function TRadioController.GetBeaconZoomWaterfall(var Pixels: array of Single;
  out Count: Integer): Boolean;
begin
  Count  := 0;
  Result := False;
  if not Assigned(FDSPEngine) then Exit;
  Result := FDSPEngine.GetBeaconZoomWaterfall(Pixels, Count);
end;

function TRadioController.BeaconZoomBinHz: Double;
begin
  Result := 0;
  if not Assigned(FDSPEngine) then Exit;
  if FDSPEngine.BeaconZoomFFT > 0 then
    Result := FDSPEngine.SampleRate / FDSPEngine.BeaconZoomFFT;
end;

function TRadioController.GetDMRStatus(out S: TDMRStatus): Boolean;
begin
  Result := Assigned(FDMRDec);
  if Result then FDMRDec.GetStatus(S)
  else FillChar(S, SizeOf(S), 0);
end;

function TRadioController.GetSliceDMRStatus(SliceId: Integer;
  out S: TDMRStatus): Boolean;
var
  Idx: Integer;
begin
  Idx := FindSliceIndex(SliceId);
  Result := (Idx >= 0) and (FSlices[Idx].Mode = MODE_DMR) and
            Assigned(FSlices[Idx].DMR);
  if Result then FSlices[Idx].DMR.GetStatus(S)
  else FillChar(S, SizeOf(S), 0);
end;

// Кромки полосы под режим/полосу (единый расчёт для главного канала и слайсов).
class procedure TRadioController.FilterEdgesFor(Mode, BW: Integer; out Lo, Hi: Integer);
// Обёртка над единственной таблицей знака (Settings.FilterEdgesFromBW) —
// оставлена ради прежних вызывающих (MainForm, SetSliceModeBW).
begin
  FilterEdgesFromBW(Mode, BW, Lo, Hi);
end;

procedure TRadioController.SetSliceModeBW(Id, Mode, BW: Integer);
var Lo, Hi: Integer;
begin
  // DMR has one fixed RF channel width; callers select only the mode.
  if Mode = MODE_DMR then BW := FILT_DMR_BW;
  FilterEdgesFor(Mode, BW, Lo, Hi);
  SetSliceMode(Id, Mode);
  SetSliceFilter(Id, Lo, Hi);
end;

procedure TRadioController.ApplyModeFilter;
var
  Lo, Hi: Integer;
begin
  // Кромки берём как есть: они уже знаковые и уже под текущий режим (таблица
  // фильтров ведётся ПО режиму), конвертировать боковую больше нечем и негде.
  Lo := FFilterLo;
  Hi := FFilterHi;
  if FWDSPReady and Assigned(FDSPEngine) then
  begin
    // Только RX. TX-полоса НЕ следует за RX-фильтром — её
    // единственный источник TX-фильтр из настроек (SetTXFilterFull/ApplyTXChain
    // через TXSignedEdges). Иначе широкий RX раздувал бы излучаемый сигнал.
    FDSPEngine.SetFilter(Lo, Hi);
    if FMode in [MODE_FM, MODE_DMR, MODE_FMRAW] then
    begin
      if FMode = MODE_FMRAW then
        FDSPEngine.SetRawFMDeviation(FRawFMDeviation)
      else
        FDSPEngine.SetRXFMDeviation(FFMDeviation);
      if FMode <> MODE_FMRAW then
        FDSPEngine.SetTXFMParams(FFMDeviation, FTXSettings.FMLowCut,
                                 FTXSettings.FMHighCut, FTXSettings.FMEmphPosition);
      // A squelch would remove sync preambles; DMR always receives open.
      FDSPEngine.SetFMSquelch((FMode = MODE_FM) and FFMSQOn, FFMSQLevel);
    end
    else
      FDSPEngine.SetFMSquelch(False, 0);
  end;
end;

// Выбирает дефолтный фильтр (индекс + полоса + FM-девиация) под текущий FMode.
// Зеркалит state-часть UI UpdateFilterButtons, чтобы смена режима была
// корректной и без UI (headless). FM сохраняет текущий NFM/WFM, прочие — дефолт.
function TRadioController.ClampFilterIdx(Mode, Idx: Integer): Integer;
// VAR1/VAR2 есть у любого режима, поэтому проверяем только пресетную часть.
// Выход за таблицу режима возвращает ЗАВОДСКОЙ индекс (а не крайний) — так
// вело себя прежнее «if FFilter > FILT_WFM_COUNT-1 then FILT_WFM_DEF».
begin
  Result := Idx;
  if (Idx = FILT_VAR1) or (Idx = FILT_VAR2) then Exit;
  if (Idx < 0) or (Idx > FilterPresetCount(Mode) - 1) then
    Result := FilterDefIdx(Mode);
end;

procedure TRadioController.ApplyFilterSlot(Idx: Integer);
var S: TFilterSlot;
begin
  FFilter   := ClampFilterIdx(FMode, Idx);
  S         := FFilterTable[FMode][FFilter];
  FFilterLo := S.Lo;
  FFilterHi := S.Hi;
  FFilterBW := FilterBWFromEdges(FMode, S.Lo, S.Hi);
  // FM-девиация привязана к пресету (NFM/WFM), у VAR берём девиацию дефолта.
  if FMode = MODE_FM then
  begin
    if FFilter <= 1 then FFMDeviation := FILT_FM_DEV[FFilter]
    else                 FFMDeviation := FILT_FM_DEV[FILT_FM_DEF];
  end
  else if FMode = MODE_DMR then
    FFMDeviation := DMR_FM_DEV;
end;

procedure TRadioController.ApplyModeDefaults;
begin
  // FM/WFM/DMR/FMRAW сохраняют выбранный пресет (NFM↔WFM и т.п.), остальные
  // режимы возвращаются к заводскому индексу — как было до таблицы фильтров.
  case FMode of
    MODE_FM, MODE_WFM, MODE_DMR, MODE_FMRAW: ApplyFilterSlot(FFilter);
  else                                       ApplyFilterSlot(FilterDefIdx(FMode));
  end;
end;

function TRadioController.FilterBWFor(M, Idx: Integer): Integer;
// Полоса выбранного фильтра под режим. Зеркалит выбор таблицы в UpdateFilterButtons,
// но НЕ меняет индекс (в отличие от ApplyModeDefaults) — нужно для XVTR-restore.
var S: TFilterSlot;
begin
  M := EnsureRange(M, 0, CFG_MODE_MAX);
  S := FFilterTable[M][ClampFilterIdx(M, Idx)];
  Result := FilterBWFromEdges(M, S.Lo, S.Hi);
end;

function TRadioController.FilterIdxFor(M, BW: Integer): Integer;
// Обратно к FilterBWFor: индекс пресета с минимальным отклонением от BW.
var
  i, Cnt, D, Best: Integer;
begin
  Result := 0;
  case M of
    MODE_FM:    Cnt := FILT_FM_COUNT;
    MODE_WFM:   Cnt := FILT_WFM_COUNT;
    MODE_FMRAW: Cnt := FILT_RAW_COUNT;
    MODE_DMR:   Exit;
  else          Cnt := FILT_COUNT;
  end;
  Best := MaxInt;
  for i := 0 to Cnt - 1 do
  begin
    D := Abs(FilterBWFor(M, i) - BW);
    if D < Best then begin Best := D; Result := i; end;
  end;
end;

// =====================================================================
//  Команды (Фаза 1 — каркас). Полная логика переносится из TMainForm.
// =====================================================================

function TRadioController.CalcDriveByte: Byte;
// Преобразование целевой мощности в аппаратный байт drive:
//   drive_byte = int( min(target_volts/0.8, 1.0) * 1.02 * 255 )
//   target_volts = sqrt( 10^(target_dBm/10) * 0.05 )
//   target_dBm   = 10*log10(pwr_W*1000) - Cal ; pwr_W = Pos/100 * FPAMaxPower
var
  Cal:          Double;
  Pos:          Integer;
  XvtrActive:   Boolean;
  pwr_W:        Double;
  target_dBm:   Double;
  target_volts: Double;
  audio_vol:    Double;
begin
  XvtrActive := (FCurrentXvtr >= 0) and (FCurrentXvtr < CFG_XVTR_COUNT) and
                FXvtrSettings.Entries[FCurrentXvtr].Enabled;
  if XvtrActive then
    Cal := FVHFBandCal[FCurrentXvtr]
  else if (TxBandIdx >= 0) and (TxBandIdx < CFG_BAND_COUNT) then
    Cal := FPABandCal[TxBandIdx]   // банд TX-слайса при кросс-банд TX
  else
    Cal := 38.8;

  if FTuning then
  begin
    // Уровень настройки всегда идёт от глобального TUN Level — иначе ручка в
    // Transmit в трансвертере оказывалась мёртвой. Мощность слота (если
    // включена галка) работает МНОЖИТЕЛЕМ, ровно как для обычного drive ниже:
    // это «сколько мощности трансиверa разрешено этому трансвертеру», и к
    // настройке оно относится так же, как к передаче.
    Pos := EnsureRange(FTXSettings.TUNLevel, 0, 100);
    if XvtrActive and FXvtrSettings.UseXVTRTunePower then
      Pos := Round(Pos * EnsureRange(FXvtrSettings.Entries[FCurrentXvtr].TXPower,
                                     0, 100) / 100.0);
  end
  else
  begin
    if XvtrActive then
      Pos := Round(FDrivePercent * FXvtrSettings.Entries[FCurrentXvtr].TXPower / 100.0)
    else
      Pos := FDrivePercent;
  end;

  if Pos <= 0 then begin Result := 0; Exit; end;

  pwr_W        := Pos / 100.0 * FPAMaxPower;
  target_dBm   := 10.0 * Log10(pwr_W * 1000.0) - Cal;
  target_volts := Sqrt(Power(10.0, target_dBm * 0.1) * 0.05);
  audio_vol    := Min(target_volts / 0.8, 1.0);
  Result       := Min(Round(audio_vol * 1.02 * 255.0), 255);
end;

procedure TRadioController.SanitizeFilterForMode;
// У FM/DMR/FMRAW пресетов меньше, чем у SSB: приводим индекс к допустимому и
// синхронизируем кромки/BW/девиацию. Чистая (state) часть UpdateFilterButtons.
begin
  if FMode in [MODE_FM, MODE_DMR, MODE_FMRAW] then ApplyFilterSlot(FFilter);
end;

function TRadioController.MakeBandSettings: TBandSettings;
begin
  Result.VfoA      := FVfoA;
  Result.VfoB      := FVfoB;
  Result.Mode      := FMode;
  Result.FilterIdx := FFilter;
  Result.FilterBW  := FFilterBW;
  Result.AGCMode   := FAGCMode;
  Result.AGCTop    := FAGCTop;
  Result.CTun      := FCTun;
  Result.CenterHz  := FCenterFreq;   // DDC-центр → восстановим offset CTun при загрузке
  Result.SpanHz    := FSpanHz;
  Result.ZoomFactor := FZoomFactor;
  Result.PanSlider  := FPanSlider;
  Result.FMSQOn       := FFMSQOn;
  Result.FMSQLevel    := FFMSQLevel;
  Result.CTCSSOn      := FFMCTCSSOn;
  Result.CTCSSToneIdx := FFMCTCSSToneIdx;
  Result.FMStepOn     := FFMStepOn;
  Result.FMStepIdx    := FFMStepIdx;
  Result.FMRptDir      := FFMRptDir;
  Result.FMRptOffsetHz := FFMRptOffsetHz;
  // Аппаратный AGC AD936x — на диапазон (усиление тракта разное на 2м и QO-100).
  Result.RxGainMode    := FRxGainMode;
  Result.RxGainDb      := FRxGainDb;
  Result.AttenDB       := FAtten;   // шаговый аттенюатор ADC — тоже на диапазон
  // Waterfall AGC/NF — глобальные (не диапазонные)
end;

procedure TRadioController.SaveCurrentBand;
// Сохраняет текущее состояние диапазона. В XVTR-режиме пишем в XVTR-слот
// (HF band-cache не трогаем — там сохранён HF-диапазон до активации XVTR).
var
  i: Integer;
  B: TBandSettings;
begin
  if not FDevConnected then Exit;
  if FCurrentXvtr >= 0 then
  begin
    if (FCurrentXvtr < CFG_XVTR_COUNT) then
    begin
      i := FCurrentXvtr;
      FXvtrSettings.Entries[i].LastFreq            := FVfoA;
      FXvtrSettings.Entries[i].LastFreqB           := FVfoB;
      FXvtrSettings.Entries[i].LastMode            := FMode;
      FXvtrSettings.Entries[i].LastFilterIdx       := FFilter;
      FXvtrSettings.Entries[i].LastFMSQOn          := FFMSQOn;
      FXvtrSettings.Entries[i].LastFMSQLevel       := FFMSQLevel;
      FXvtrSettings.Entries[i].LastCTCSSOn         := FFMCTCSSOn;
      FXvtrSettings.Entries[i].LastCTCSSToneIdx    := FFMCTCSSToneIdx;
      FXvtrSettings.Entries[i].LastFMStepOn        := FFMStepOn;
      FXvtrSettings.Entries[i].LastFMStepIdx       := FFMStepIdx;
      FXvtrSettings.Entries[i].LastFMRptDir        := FFMRptDir;
      FXvtrSettings.Entries[i].LastFMRptOffsetHz   := FFMRptOffsetHz;
      FXvtrSettings.Entries[i].LastCTun            := FCTun;
      FXvtrSettings.Entries[i].LastCenterHz        := FCenterFreq;
      FXvtrSettings.Entries[i].LastAGCMode         := FAGCMode;
      FXvtrSettings.Entries[i].LastAGCTop          := FAGCTop;
      FXvtrSettings.Entries[i].LastCTCSSAutoActive := FFMCTCSSAutoActive;
      FXvtrSettings.Entries[i].LastFMRptAutoActive := FFMRptAutoActive;
      FXvtrSettings.Entries[i].LastZoomFactor      := FZoomFactor;
      FXvtrSettings.Entries[i].LastPanSlider       := FPanSlider;
      FXvtrSettings.Entries[i].LastRxGainMode      := FRxGainMode;
      FXvtrSettings.Entries[i].LastRxGainDb        := FRxGainDb;
      FXvtrSettings.Entries[i].LastAttenDB         := FAtten;
    end;
    FSettings.SaveXvtr(FDevMAC, FXvtrSettings);
    Exit;
  end;
  // HPSDR: приёмник только HF — если VFO ушёл в VHF/UHF (напр. канал на 2м без
  // XVTR), не сохраняем, иначе HF-кэш диапазона перезапишется VHF-частотой.
  // Pluto: VHF/UHF — нативные диапазоны (VU-план), сохранять обязательно;
  // от перезаписи чужим диапазоном защищает FreqToBandIdx-проверка ниже.
  if (not IsPluto) and (FVfoA > 61000000) then Exit;
  B := MakeBandSettings;
  // Bandstack: VFO A и B независимы и могут стоять на разных бэндах. В кэш
  // ЭТОГО бэнда пишем только ту частоту VFO, что реально в нём — иначе чужая
  // частота (напр. VFO A на 40м при сохранении 30м) загрязнит memory бэнда.
  if FreqToBandIdx(FVfoA, IsPluto) <> FCurrentBand then B.VfoA := FBandCache[FCurrentBand].VfoA;
  if FreqToBandIdx(FVfoB, IsPluto) <> FCurrentBand then B.VfoB := FBandCache[FCurrentBand].VfoB;
  FBandCache[FCurrentBand] := B;
  FSettings.SaveBand(FDevMAC, FCurrentBand, FBandCache[FCurrentBand]);
end;

function TRadioController.BuildGlobalSettings: TGlobalSettings;
// Радио-поля — из живого состояния; тема/FPS/digits/CAT — сквозь из FLoadedGlobal
// (контроллер их не трактует). Сидируем загруженным блобом, поверх — радио.
var i: Integer;
begin
  Result := FLoadedGlobal;   // несёт не-радио поля (тема/FPS/digits/CAT) round-trip

  Result.Volume           := FVolume;
  Result.TxMonVolume      := FTxMonVolume;
  Result.DriveLevel       := FDrivePercent;
  Result.PAMaxPower       := FPAMaxPower;
  Result.PlutoTxMaxAttDb  := FPlutoTxMaxAttDb;
  for i := 0 to CFG_BAND_COUNT - 1 do Result.PABandCal[i]  := FPABandCal[i];
  for i := 0 to CFG_XVTR_COUNT - 1 do Result.VHFBandCal[i] := FVHFBandCal[i];
  Result.Cal              := FCal;
  Result.ActiveVfo        := FActiveVfo;
  Result.NRMode           := FNRMode;
  Result.NBMode           := FNBMode;
  Result.SNBEnabled       := FSNB;
  Result.ANFEnabled       := FANF;
  Result.AGCSlope         := 0;
  Result.AGCHangThreshold := 100;
  Result.WfAGCEnabled     := FWfAGCEnabled;
  Result.WfNFEnabled      := FWfNFEnabled;
  Result.DitherEnabled    := FDitherEnabled;
  Result.RandomEnabled    := FRandomEnabled;
  Result.LastBand         := FCurrentBand;
  Result.LastXvtr         := FCurrentXvtr;
  Result.SampleRate       := FSampleRate;
  Result.RxGainMode       := FRxGainMode;
  Result.RxGainDb         := FRxGainDb;
  if FWDSPReady then
  begin
    Result.FFTSize       := FDSPEngine.FFTSize;
    Result.WindowType    := FDSPEngine.WindowType;
    Result.SpecDetector  := FDSPEngine.SpecDetector;
    Result.SpecAvgMode   := FDSPEngine.SpecAvgMode;
    Result.SpecAvgTimeMS := FDSPEngine.SpecAvgTimeMS;
    Result.WfDetector    := FDSPEngine.WfDetector;
    Result.WfAvgMode     := FDSPEngine.WfAvgMode;
    Result.WfAvgTimeMS   := FDSPEngine.WfAvgTimeMS;
  end
  else
  begin
    Result.FFTSize       := 131072;
    Result.WindowType    := 2;
    Result.SpecDetector  := 0;
    Result.SpecAvgMode   := 3;
    Result.SpecAvgTimeMS := 30.0;
    Result.WfDetector    := 0;
    Result.WfAvgMode     := 3;
    Result.WfAvgTimeMS   := 120.0;
  end;
  Result.WfManualHigh    := FWfManualHigh;
  Result.WfManualLow     := FWfManualLow;
  Result.WfAGCOffset     := FWfAGCOffset;
  Result.WfPalette       := FWfPalette;
  Result.WfGamma         := FWfGamma;
  Result.DisplayPixels   := FDisplayPixels;
  Result.WfSpeedDiv      := FWfSpeedDiv;
  Result.SpecMSAA        := FSpecMSAA;
  Result.SpecRefLevel    := FSpecRefLevel;
  Result.SpecRange       := FSpecRange;
  Result.SpecGridStep    := FSpecGridStep;
  Result.BeaconZoomSpanHz   := FBcnZoomSpanHz;
  Result.BeaconZoomOffsetHz := FBcnZoomOffsetHz;
  Result.BeaconZoomFollow   := FBcnZoomFollow;
  Result.BeaconZoomAuto     := FBcnZoomAuto;
  Result.AudioSampleRate := FAudioOut.SampleRate;
  Result.ShowSpectrum    := FShowSpectrum;
  Result.ShowWaterfall   := FShowWaterfall;
  Result.ShowWideband    := FShowWideband;
  Result.WidebandFill    := FWidebandFill;
  Result.SpectrumFill    := FSpectrumFill;
  Result.DisplayDuplex   := FDisplayDuplex;
  Result.RxMuteOnTx      := FRxMuteOnTx;
  Result.AudioOutDevice  := FAudioOutDevName;
  Result.AudioInDevice   := FAudioInDevName;
  Result.SendAudioToRadio := FSendAudioToRadio;
  Result.RawFMDeviation   := Round(FRawFMDeviation);
end;

procedure TRadioController.SaveGlobalSettings;
begin
  if FDevConnected then FSettings.SaveGlobal(FDevMAC, BuildGlobalSettings);
end;

procedure TRadioController.ApplyBandDSP(const B: TBandSettings);
// DSP-настройки диапазона + события рендера. Частоты VFO/центр НЕ трогает.
begin
  // --- Режим ---
  FMode := EnsureRange(B.Mode, MODE_MIN, MODE_MAX);
  if Assigned(FDMRDec) then FDMRDec.SetEnabled(FMode = MODE_DMR);
  if FWDSPReady and Assigned(FDSPEngine) then FDSPEngine.SetMode(FMode);

  // --- Фильтр: санитизация FM до применения к DSP ----------------------
  // Банд помнит ТОЛЬКО выбранный слот; кромки живут в таблице фильтров
  // отдельно по режимам. Иначе у VAR было бы два источника истины —
  // ровно та болезнь, от которой уходили.
  ApplyFilterSlot(B.FilterIdx);
  ApplyModeFilter;

  // --- AGC (единый маппинг — исправляет каст-баг RestoreBand) ---
  FAGCMode := B.AGCMode;
  FAGCTop  := B.AGCTop;
  if FWDSPReady and Assigned(FDSPEngine) then FDSPEngine.SetAGCTop(FAGCTop);
  ApplyAGCToEngine;

  // --- Аппаратный AGC AD936x (Pluto/LibreSDR): режим + manual-гейн диапазона.
  // У openHPSDR SetRxGain — no-op, поля просто ездят с диапазоном.
  FRxGainMode := EnsureRange(B.RxGainMode, 0, 3);
  FRxGainDb   := EnsureRange(B.RxGainDb, 0, 73);
  ApplyRxGainToBackend;
  Changed(rfRxGain);   // UI: кнопки FAST/SLOW/HYB/MAN + ползунок

  // --- Шаговый аттенюатор ADC (openHPSDR) — на диапазон/слот трансвертера.
  FAtten := EnsureRange(B.AttenDB, 0, 31);
  if Assigned(FNetwork) then FNetwork.SetStepAtten(Byte(FAtten));
  Changed(rfAtten);

  // --- FM squelch / CTCSS / step / repeater ---
  FFMSQOn         := B.FMSQOn;
  FFMSQLevel      := B.FMSQLevel;
  FFMCTCSSOn      := B.CTCSSOn;
  FFMCTCSSToneIdx := B.CTCSSToneIdx;
  if FWDSPReady and Assigned(FDSPEngine) then
  begin
    FDSPEngine.SetTXCTCSS(FFMCTCSSOn, CTCSS_TONES[FFMCTCSSToneIdx]);
    FDSPEngine.SetFMSquelch(FFMSQOn and (FMode = MODE_FM), FFMSQLevel);
  end;
  FFMStepOn  := B.FMStepOn;
  FFMStepIdx := EnsureRange(B.FMStepIdx, 0, 3);
  if FMode in [MODE_DMR, MODE_FMRAW] then
  begin
    FFMStepOn := True;
    FFMStepIdx := 1;  // DMR default raster: 12.5 kHz
  end;
  FFMRptDir      := B.FMRptDir;
  FFMRptOffsetHz := B.FMRptOffsetHz;
  if FFMRptOffsetHz <= 0 then FFMRptOffsetHz := RptDefaultOffsetHz(B.VfoA);

  // --- TX-профиль под новый режим ---
  // Смена диапазона и вход/выход трансвертера меняют режим ГЛАВНОГО здесь, а
  // не через SetMode, поэтому автоматику привязок надо звать и отсюда — иначе
  // при переходе КВ↔трансвертер оставался профиль прошлого режима.
  if FTxSliceId = 0 then ApplyBoundTXProfile;
  // FDSPEngine.SetMode выше насильно выставил TXA в режим главного приёмника,
  // но передавать можем со слайса — возвращаем режим настоящего источника.
  if FWDSPReady and Assigned(FDSPEngine) and (ActiveTXMode <> FMode) then
    FDSPEngine.SetTXMode(ActiveTXMode);

  // --- CTUN ---
  FCTun := B.CTun;
  if FWDSPReady and Assigned(FDSPEngine) and not FCTun then FDSPEngine.SetShift(0.0);

  // --- Span (sample rate глобальный, не per-band — лишь ресинк) ---
  FSpanHz := FSampleRate;

  // --- Рендер: единичные события (UI рисует через OnControllerState) ---
  Changed(rfBand);
  Changed(rfMode);     // rfMode-рендер зовёт UpdateFilterButtons (FM-панели)
  Changed(rfFilter);
  Changed(rfAGCMode);
  Changed(rfAGCTop);
  Changed(rfFMSQ);
  Changed(rfFMSQLevel);
  Changed(rfFMCTCSS);
  Changed(rfFMCTCSSTone);
  Changed(rfFMStep);      // кнопка вкл/выкл STEP
  Changed(rfFMStepIdx);   // дропдаун шага + web + grid
  Changed(rfFMRpt);
  Changed(rfCTun);
end;

procedure TRadioController.RestoreBand(BandIdx: Integer);
// Загружает состояние диапазона из кэша, применяет к DSP, делает финальную
// перестройку приёмника на активный VFO и шлёт события рендера. Полная команда —
// зовётся напрямую desktop/web/channel/connect-restore.
var B: TBandSettings;
begin
  if (BandIdx < 0) or (BandIdx >= CFG_BAND_COUNT) then Exit;
  Changed(rfBandRestore);   // явная смена диапазона → UI сбросит Wf-avg буфер
  B := FBandCache[BandIdx];
  FCurrentBand := BandIdx;

  // Pluto: кэш диапазона может нести HF-частоту (дефолты Settings). Если VfoA не
  // попадает в VHF-слот — берём частоту по умолчанию из плана.
  if IsPluto and (FreqToBandIdx(B.VfoA, True) <> BandIdx) then
  begin
    B.VfoA := BandDefaultFreq(BandIdx, True);
    B.VfoB := B.VfoA;
  end;

  ApplyBandDSP(B);

  // --- Zoom/pan диапазона: восстанавливаем и переармируем анализатор ---
  FZoomFactor := EnsureRange(B.ZoomFactor, 0.0, 1.0);
  FPanSlider  := EnsureRange(B.PanSlider, 0.0, 1.0);
  if Assigned(FDSPEngine) then FDSPEngine.SetZoomPan(FZoomFactor, FPanSlider);
  Changed(rfZoom);

  // --- VFO / drive (только RestoreBand — bandstack-путь VFO не трогает) ---
  // При CTun восстанавливаем сохранённый DDC-центр, чтобы VFO остался на своём
  // месте в полосе (а не прыгал в центр). Берём только если offset влезает в
  // текущий span — иначе (сменился sample rate / битый конфиг) центрируем на VFO.
  // ApplyTuneCore в SetVfoA при необходимости сам прокрутит центр к краю.
  if FCTun and (B.CenterHz > 0) and (Abs(B.VfoA - B.CenterHz) < FSpanHz / 2) then
    FCenterFreq := B.CenterHz
  else
    FCenterFreq := B.VfoA;
  FVfoB       := B.VfoB;
  FVfoA       := B.VfoA;
  FDriveLevel := CalcDriveByte;

  Changed(rfVfoB);
  // Финальная перестройка приёмника на АКТИВНЫЙ VFO. VFO A — через SetVfoA
  // (хук OnAfterTune несёт канальную оркестрацию); VFO B — через SetVfoB.
  // Иначе смена диапазона при активном B центрировала бы приёмник на A.
  if FActiveVfo = 0 then SetVfoA(FVfoA)
  else                   SetVfoB(FVfoB);
  ApplyAntPorts;   // AD936x: разъёмы, выбранные для этого диапазона
end;

function TRadioController.ActiveVfoHz: Double;
begin
  if FActiveVfo = 0 then Result := FVfoA else Result := FVfoB;
end;

procedure TRadioController.ApplyTuneCore(VfoHz: Double);
// Общее ядро перестройки приёмника на частоту АКТИВНОГО VFO (A или B).
//   CTUN OFF: DDC = VfoHz, сдвиг 0.
//   CTUN ON:  DDC фиксирован, демодулятор сдвигаем на VfoHz-FCenterFreq;
//             если полоса фильтра вышла за край дисплея — прокручиваем центр.
// Ставит FCenterFreq / shift / FLastVfoScrolled.
var
  Offset, FiltLo, FiltHi, HalfSpan: Double;
begin
  FLastVfoScrolled := False;
  if not FCTun then
  begin
    FCenterFreq := VfoHz;
    if FWDSPReady and Assigned(FDSPEngine) then FDSPEngine.SetShift(0.0);
  end
  else
  begin
    Offset := VfoHz - FCenterFreq;
    if FWDSPReady and Assigned(FDSPEngine) then FDSPEngine.SetShift(Offset);
    HalfSpan := FSpanHz / 2;
    case FMode of
      MODE_LSB: begin FiltLo := Offset - FFilterBW;   FiltHi := Offset - 100; end;
      MODE_USB: begin FiltLo := Offset + 100;         FiltHi := Offset + FFilterBW; end;
    else begin FiltLo := Offset - FFilterBW/2; FiltHi := Offset + FFilterBW/2; end;
    end;
    if (FiltHi > HalfSpan) or (FiltLo < -HalfSpan) then
    begin
      if FiltHi > HalfSpan then
        FCenterFreq := VfoHz - HalfSpan * 0.5
      else
        FCenterFreq := VfoHz + HalfSpan * 0.5;
      if FWDSPReady and Assigned(FDSPEngine) then FDSPEngine.SetShift(VfoHz - FCenterFreq);
      FLastVfoScrolled := True;
    end;
  end;
end;

procedure TRadioController.SetVfoA(Hz: Double);
// Перестройка VFO A: XVTR-клэмп, CTUN ядро, детект диапазона, FM авто-RPT,
// проталкивание в сеть. Рендер — в UI через OnControllerState(rfVfoA/rfBand).
// Канальную оркестрацию (авто-CTCSS, pre-channel drive) делает UI-путь.
var
  NewFreq:  Int64;
  BandIdx:  Integer;
begin
  NewFreq := Round(Hz);
  // Клэмп в пределах XVTR-band — не даём VFO уйти за FreqBegin/FreqEnd
  if (FCurrentXvtr >= 0) and (FCurrentXvtr < CFG_XVTR_COUNT) and
     FXvtrSettings.Entries[FCurrentXvtr].Enabled then
  begin
    if NewFreq < FXvtrSettings.Entries[FCurrentXvtr].FreqBegin then
      NewFreq := Round(FXvtrSettings.Entries[FCurrentXvtr].FreqBegin);
    if NewFreq > FXvtrSettings.Entries[FCurrentXvtr].FreqEnd then
      NewFreq := Round(FXvtrSettings.Entries[FCurrentXvtr].FreqEnd);
  end;
  FVfoA := NewFreq;
  ApplyTuneCore(FVfoA);

  // Детект диапазона (только HF: в XVTR клэмп держит VFO внутри band,
  // FreqToBandIdx вернёт -1 для VHF/UHF).
  if FCurrentXvtr < 0 then
  begin
    BandIdx := FreqToBandIdx(FVfoA, IsPluto);
    if (BandIdx >= 0) and (BandIdx <> FCurrentBand) then
    begin
      FCurrentBand := BandIdx;
      FDriveLevel  := CalcDriveByte;
      Changed(rfBand);
    end;
  end;

  // Авто-MINUS в 2 м репитерном сегменте (само снимает при уходе из сегмента).
  if FMode = MODE_FM then UpdateRptAutoState;

  PushNetworkState;
  Changed(rfVfoA);
  // Канальная оркестрация — ПОСЛЕ рендера rfVfoA (вне его try/finally-гарда), без
  // ре-ентранси: OnVfoTuned зовёт команды (SetDrive/SetFMRpt), которым нужен
  // чистый верхний уровень OnControllerState.
  if Assigned(FOnAfterTune) then FOnAfterTune;
end;

procedure TRadioController.StoreVfoA(Hz: Double);
// Сохранить частоту VFO A БЕЗ перестройки приёмника (когда активен B — слушаем B,
// A только хранится/отображается). Симметрично «неактивной» ветке SetVfoB.
// Рендер FreqDispA — через rfVfoA (спектр он не трогает, т.к. SyncSpecViewFreq
// держит активный VFO B); смена band-кнопки — через rfBand.
var BandIdx: Integer;
begin
  FVfoA := Hz;
  if FCurrentXvtr < 0 then
  begin
    BandIdx := FreqToBandIdx(FVfoA, IsPluto);
    if (BandIdx >= 0) and (BandIdx <> FCurrentBand) then
    begin
      FCurrentBand := BandIdx;
      FDriveLevel  := CalcDriveByte;
      Changed(rfBand);
    end;
  end;
  Changed(rfVfoA);
end;

procedure TRadioController.SetVfoB(Hz: Double);
// VFO B всегда сохраняется. Если B активен — перестраиваем приёмник через
// ApplyTuneCore (с учётом CTUN), детектим диапазон и толкаем в сеть. Если B
// неактивен (split, слушаем A) — только сохраняем частоту (для TX).
// Рендер FreqDispB/спектра — в UI через OnControllerState(rfVfoB).
var
  BandIdx: Integer;
  NewFreq: Int64;
begin
  NewFreq := Round(Hz);
  // Клэмп в пределах XVTR-band (как в SetVfoA) — VFO B тоже не уходит за край.
  if (FCurrentXvtr >= 0) and (FCurrentXvtr < CFG_XVTR_COUNT) and
     FXvtrSettings.Entries[FCurrentXvtr].Enabled then
  begin
    if NewFreq < FXvtrSettings.Entries[FCurrentXvtr].FreqBegin then
      NewFreq := Round(FXvtrSettings.Entries[FCurrentXvtr].FreqBegin);
    if NewFreq > FXvtrSettings.Entries[FCurrentXvtr].FreqEnd then
      NewFreq := Round(FXvtrSettings.Entries[FCurrentXvtr].FreqEnd);
  end;
  FVfoB := NewFreq;
  if FActiveVfo = 1 then
  begin
    ApplyTuneCore(FVfoB);
    if FCurrentXvtr < 0 then
    begin
      BandIdx := FreqToBandIdx(FVfoB, IsPluto);
      if (BandIdx >= 0) and (BandIdx <> FCurrentBand) then
      begin
        FCurrentBand := BandIdx;
        FDriveLevel  := CalcDriveByte;
        Changed(rfBand);
      end;
    end;
    PushNetworkState;
  end;
  Changed(rfVfoB);
end;

procedure TRadioController.SetActiveVfo(Idx: Integer);
// Переключение активного (RX) VFO с перестройкой приёмника на его частоту.
// Bandstack: если активируемый VFO стоит на ДРУГОМ ham-бэнде — сохраняем
// покидаемый бэнд и восстанавливаем DSP-настройки нового (mode/filter/AGC/CTUN/
// FM). Частоты обоих VFO при этом НЕ трогаем — каждый VFO несёт свой диапазон.
// CTUN (того же бэнда): если частота нового VFO видна в окне — только shift
// (DDC/сеть не трогаем); иначе CTUN сбрасывается и центрируемся на частоте.
// Рендер (дисплеи/спектр/band/CTUN-кнопка) — в UI через OnControllerState.
var
  ActiveFreq: Double;
  HalfSpan, Offset: Double;
  FreqVisible, NeedPush: Boolean;
  NewBand: Integer;
begin
  FActiveVfo := Idx;
  ActiveFreq := ActiveVfoHz;
  NeedPush   := False;

  // --- Bandstack: активируемый VFO на другом ham-бэнде → восстановить бэнд ---
  if FCurrentXvtr < 0 then
  begin
    NewBand := FreqToBandIdx(ActiveFreq, IsPluto);
    if (NewBand >= 0) and (NewBand <> FCurrentBand) then
    begin
      SaveCurrentBand;                    // сохранить покидаемый бэнд
      FCurrentBand := NewBand;
      ApplyBandDSP(FBandCache[NewBand]);  // mode/filter/AGC/CTUN/FM + рендер
      FCenterFreq := ActiveFreq;          // центр на активную (CTUN центрируется тут)
      FDriveLevel := CalcDriveByte;       // PA-калибровка нового бэнда
      NeedPush    := True;                // DDC-центр сменился → толкнуть в сеть
    end;
  end;

  if FCTun then
  begin
    HalfSpan    := FSpanHz / 2;
    Offset      := ActiveFreq - FCenterFreq;
    FreqVisible := (Offset > -HalfSpan) and (Offset < HalfSpan);
    if FreqVisible then
    begin
      // Частота видна — просто сдвигаем демодулятор.
      if FWDSPReady and Assigned(FDSPEngine) then FDSPEngine.SetShift(Offset);
    end
    else
    begin
      // Частота за окном — сбрасываем CTUN и центрируемся на ней.
      FCTun := False;
      if XvtrSlotActive then FXvtrSettings.Entries[FCurrentXvtr].LastCTun := False
      else                   FBandCache[FCurrentBand].CTun := False;
      FCenterFreq := ActiveFreq;
      if FWDSPReady and Assigned(FDSPEngine) then FDSPEngine.SetShift(0.0);
      NeedPush := True;
      Changed(rfCTun);
    end;
  end
  else
  begin
    FCenterFreq := ActiveFreq;
    if FWDSPReady and Assigned(FDSPEngine) then FDSPEngine.SetShift(0.0);
    NeedPush := True;
  end;

  RefreshSliceShifts;   // центр мог сместиться — держим слайсы на их TargetHz
  if NeedPush then PushNetworkState;
  Changed(rfActiveVfo);
end;

procedure TRadioController.TuneActiveBy(DeltaHz: Double);
begin
  if FVfoLock then Exit;                    // VFO заблокирован — перестройку игнорируем
  if FActiveVfo = 0 then SetVfoA(FVfoA + DeltaHz)
  else SetVfoB(FVfoB + DeltaHz);
end;

procedure TRadioController.SetCenter(Hz: Double);
// CTUN-скролл окна просмотра: двигаем DDC-центр БЕЗ смены VFO. Демодулятор
// сдвигаем = активный VFO − новый центр; полный HP-кадр в радио. Рендер
// (ruler/спектр) — в UI через OnControllerState(rfCenterFreq).
// Точки входа: десктопный CTUN-drag и web (SyncWebCenter).
begin
  FCenterFreq := Hz;
  if FWDSPReady and Assigned(FDSPEngine) then
    FDSPEngine.SetShift(ActiveVfoHz - FCenterFreq);
  RefreshSliceShifts;   // слайсы привязаны к абсолютной частоте — сдвинуть NCO
  PushNetworkState;
  Changed(rfCenterFreq);
end;

procedure TRadioController.VfoSwap;
// Меняем VFO A/B местами и перестраиваем приёмник на новую частоту активного
// VFO (центр=частота, shift 0 — CTUN-оффсет после свопа теряет смысл).
var
  T:          Double;
  ActiveFreq: Double;
  NewBand:    Integer;
begin
  T := FVfoA; FVfoA := FVfoB; FVfoB := T;
  ActiveFreq := ActiveVfoHz;
  // Bandstack: если активный после свопа VFO на другом ham-бэнде — сохраняем
  // покидаемый бэнд и восстанавливаем DSP нового (mode/filter/AGC/CTUN/FM), как
  // SetActiveVfo. Иначе своп на другой диапазон оставлял бы старые mode/фильтр/AGC
  // при подсвеченной кнопке нового бэнда (рассинхрон).
  if FCurrentXvtr < 0 then
  begin
    NewBand := FreqToBandIdx(ActiveFreq, IsPluto);
    if (NewBand >= 0) and (NewBand <> FCurrentBand) then
    begin
      SaveCurrentBand;
      FCurrentBand := NewBand;
      ApplyBandDSP(FBandCache[NewBand]);   // несёт Changed(rfBand) + рендер DSP-полей
      FDriveLevel := CalcDriveByte;        // PA-калибровка нового бэнда
    end;
  end;
  FCenterFreq := ActiveFreq;
  if FWDSPReady and Assigned(FDSPEngine) then FDSPEngine.SetShift(0.0);
  RefreshSliceShifts;
  PushNetworkState;
  Changed(rfVfoA);
  Changed(rfVfoB);
end;

procedure TRadioController.VfoACopyB;   // A>B: частота A копируется в B
begin
  SetVfoB(FVfoA);   // сохраняет B:=A, перестраивает если B активен, рендер rfVfoB
end;

procedure TRadioController.VfoBCopyA;    // B>A: частота B копируется в A
begin
  FVfoA := FVfoB;
  if FActiveVfo = 0 then SetVfoA(FVfoA)   // A активен — перестраиваем приёмник
  else Changed(rfVfoA);                   // A неактивен — только рендер дисплея
end;

procedure TRadioController.SetMode(M: Integer);
begin
  M := EnsureRange(M, MODE_MIN, MODE_MAX);
  FMode := M;
  // DMR channels use a 12.5 kHz raster by default. Keep the ordinary STEP
  // controls enabled so the operator can select another raster afterwards.
  if FMode in [MODE_DMR, MODE_FMRAW] then
  begin
    FFMStepOn := True;
    if FMode = MODE_DMR then FFMStepIdx := 1;  // 12.5 kHz
    Changed(rfFMStep);
    Changed(rfFMStepIdx);
  end;
  // Entering receive-only DMR on the selected main TX source immediately
  // releases an existing carrier. SetTune(False) also releases MOX.
  if ActiveTXMode = MODE_DMR then
  begin
    if FTuning then SetTune(False)
    else if FTransmitting then SetMOX(False);
  end;
  if Assigned(FDMRDec) then FDMRDec.SetEnabled(FMode = MODE_DMR);
  ApplyModeDefaults;                    // дефолтный фильтр/девиация под режим
  if XvtrSlotActive then FXvtrSettings.Entries[FCurrentXvtr].LastMode := FMode
  else                   FBandCache[FCurrentBand].Mode := FMode;
  // Профиль привязан к паре «флаг + модуляция»: если передаём с главного,
  // новый режим приносит свой профиль. До пуша в WDSP — ApplyTXChainSettings
  // внутри SetTXMode ниже перешьёт цепь уже с новыми значениями.
  if FTxSliceId = 0 then ApplyBoundTXProfile;
  if FWDSPReady and Assigned(FDSPEngine) then
  begin
    FDSPEngine.SetMode(FMode);
    // SetMode configures the main RX/TX pair; a selected slice may still be
    // the actual TX source and therefore owns the transmitter mode.
    FDSPEngine.SetTXMode(ActiveTXMode);
    // A live mode change must also switch away from web/HW microphone when
    // entering FM RAW; normally the modem is connected through PortAudio.
    if FTransmitting and (ActiveTXMode = MODE_FMRAW) then
    begin
      FWebMicActive := False;
      FTCIMicActive := False;
      FDSPEngine.SetTXMicSource(DefaultMicSource);
      if Assigned(ActiveMicInput) and ActiveMicInput.IsOpen then
        ActiveMicInput.Flush;
    end;
    // Если TUN активен — пересчитываем знак частоты под новый режим
    if FTuning then
    begin
      if ActiveTXMode in [MODE_CWL, MODE_CWU] then
        FDSPEngine.SetTXTone(True, FCWSettings.Pitch, TUN_TONE_MAG)
      else
        FDSPEngine.SetTXTone(True, FTXSettings.TUNFreq, TUN_TONE_MAG);
    end;
  end;
  SyncCWKeyer;
  ApplyModeFilter;
  Changed(rfMode);
end;

procedure TRadioController.SetFilterIdx(Idx: Integer);
begin
  // Публичная команда: Idx может прийти из CAT/web без проверки. Клампится
  // внутри ApplyFilterSlot (у FM/WFM/FMRAW пресетов меньше; VAR всегда есть).
  ApplyFilterSlot(Idx);
  CacheFilterToBand;
  ApplyModeFilter;
  Changed(rfFilter);
end;

procedure TRadioController.CacheFilterToBand;
begin
  if XvtrSlotActive then
  begin
    FXvtrSettings.Entries[FCurrentXvtr].LastFilterIdx := FFilter;
    Exit;
  end;
  FBandCache[FCurrentBand].FilterIdx := FFilter;
  FBandCache[FCurrentBand].FilterBW  := FFilterBW;   // легаси-поле, для старых конфигов
end;

procedure TRadioController.SelectFilterSlotState(Idx: Integer);
begin
  ApplyFilterSlot(Idx);
end;

procedure TRadioController.SanitizeFilterState;
begin
  SanitizeFilterForMode;
end;

procedure TRadioController.TXFilterEdges(out Lo, Hi: Integer);
begin
  Lo := 0; Hi := 0;
  if FWDSPReady and Assigned(FDSPEngine) then FDSPEngine.TXFilterEdgesHz(Lo, Hi);
end;

function TRadioController.FilterSlot(Mode, Idx: Integer): TFilterSlot;
begin
  Result := FFilterTable[EnsureRange(Mode, 0, CFG_MODE_MAX)]
                        [EnsureRange(Idx, 0, FILT_SLOTS - 1)];
end;

procedure TRadioController.SetFilterSlot(Mode, Idx: Integer; const S: TFilterSlot);
begin
  Mode := EnsureRange(Mode, 0, CFG_MODE_MAX);
  Idx  := EnsureRange(Idx, 0, FILT_SLOTS - 1);
  if S.Hi <= S.Lo then Exit;                 // пустая полоса = глухой приём
  FFilterTable[Mode][Idx] := S;
  if Assigned(FSettings) then FSettings.SaveFilters(FDevMAC, FFilterTable);
  // Правка активного слота слышна сразу — форма настроек в EWSDR мгновенная.
  if (Mode = FMode) and (Idx = FFilter) then
  begin
    ApplyFilterSlot(Idx);
    CacheFilterToBand;
    ApplyModeFilter;
    Changed(rfFilter);
  end;
end;

procedure TRadioController.ResetFilterSlot(Mode, Idx: Integer);
var Def: TFilterSet;
begin
  Mode := EnsureRange(Mode, 0, CFG_MODE_MAX);
  Def  := DefaultFilterSet(Mode);
  SetFilterSlot(Mode, Idx, Def[EnsureRange(Idx, 0, FILT_SLOTS - 1)]);
end;

procedure TRadioController.ResetFilterSet(Mode: Integer);
var i: Integer;
begin
  for i := 0 to FILT_SLOTS - 1 do ResetFilterSlot(Mode, i);
end;

procedure TRadioController.SetFilterEdges(LoHz, HiHz: Integer);
// Drag на спектре / CAT ZZFL-ZZFH / web: пресеты не портим — уходим в VAR1.
var S: TFilterSlot; Target: Integer;
begin
  if HiHz - LoHz < FILT_MIN_WIDTH then Exit;
  if (FFilter = FILT_VAR1) or (FFilter = FILT_VAR2) then Target := FFilter
  else                                                   Target := FILT_VAR1;
  S      := FFilterTable[FMode][Target];
  S.Lo   := LoHz;
  S.Hi   := HiHz;
  FFilterTable[FMode][Target] := S;
  if Assigned(FSettings) then FSettings.SaveFilters(FDevMAC, FFilterTable);
  ApplyFilterSlot(Target);
  CacheFilterToBand;
  ApplyModeFilter;
  Changed(rfFilter);
end;

procedure TRadioController.SetFilterBW(BWHz: Integer);
// Непрерывная полоса (web-ползунок). Точное совпадение с пресетом выбирает
// пресет, произвольная ширина уходит в VAR — как и перетаскивание края мышью.
var
  Lo, Hi, i, n: Integer;
begin
  if FMode = MODE_DMR then BWHz := FILT_DMR_BW;
  FilterEdgesFromBW(FMode, BWHz, Lo, Hi);
  n := FilterPresetCount(FMode);
  for i := 0 to n - 1 do
    if (FFilterTable[FMode][i].Lo = Lo) and (FFilterTable[FMode][i].Hi = Hi) then
    begin
      SetFilterIdx(i);
      Exit;
    end;
  SetFilterEdges(Lo, Hi);
end;

procedure TRadioController.SetRawFMDeviation(Hz: Integer);
begin
  FRawFMDeviation := EnsureRange(Hz, RAW_FM_DEV_MIN, RAW_FM_DEV_MAX);
  if FWDSPReady and Assigned(FDSPEngine) then
    FDSPEngine.SetRawFMDeviation(FRawFMDeviation);
  SaveGlobalSettings;
  Changed(rfFMDeviation);
end;

// UI-индекс 0..4 (FAST/MED/SLOW/LONG/OFF) -> WDSP enum. Единый маппинг для всех
// фронтендов и слайсов (раньше web/слайдер ошибочно кастовали индекс прямо в enum).
class function TRadioController.AGCModeFromUI(M: Integer): TWDSPAGCMode;
const
  AGCModes: array[0..4] of TWDSPAGCMode = (agcFast, agcMedium, agcSlow, agcLong, agcOff);
begin
  if (M < 0) or (M > 4) then M := 1;
  Result := AGCModes[M];
end;

class function TRadioController.AGCModeToUI(A: TWDSPAGCMode): Integer;
// Обратно к AGCModeFromUI: 0=FAST,1=MED,2=SLOW,3=LONG,4=OFF (шкала UI и CAT).
begin
  case A of
    agcFast:   Result := 0;
    agcSlow:   Result := 2;
    agcLong:   Result := 3;
    agcOff:    Result := 4;
  else         Result := 1;   // agcMedium
  end;
end;

procedure TRadioController.ApplyAGCToEngine;
begin
  if FWDSPReady and Assigned(FDSPEngine) and (FAGCMode >= 0) and (FAGCMode <= 4) then
    FDSPEngine.SetAGC(AGCModeFromUI(FAGCMode), 50.0);
end;

procedure TRadioController.SetAGCMode(M: Integer);
begin
  if (M < 0) or (M > 4) then Exit;
  FAGCMode := M;
  ApplyAGCToEngine;
  if XvtrSlotActive then FXvtrSettings.Entries[FCurrentXvtr].LastAGCMode := FAGCMode
  else                   FBandCache[FCurrentBand].AGCMode := FAGCMode;
  Changed(rfAGCMode);
end;

procedure TRadioController.SetAGCTop(DB: Integer);
begin
  if DB < 20 then DB := 20 else if DB > 120 then DB := 120;
  FAGCTop := DB;
  if FWDSPReady and Assigned(FDSPEngine) then FDSPEngine.SetAGCTop(FAGCTop);
  ApplyAGCToEngine;   // ре-применяем режим с новым уровнем
  if XvtrSlotActive then FXvtrSettings.Entries[FCurrentXvtr].LastAGCTop := FAGCTop
  else                   FBandCache[FCurrentBand].AGCTop := FAGCTop;
  Changed(rfAGCTop);
end;

procedure TRadioController.AGCTopBy(Delta: Integer);
begin SetAGCTop(FAGCTop + Delta); end;

procedure TRadioController.ApplyRxGainToBackend;
begin
  if Assigned(FNetwork) and FNetwork.Connected then
    FNetwork.SetRxGain(FRxGainMode, FRxGainDb);
end;

procedure TRadioController.SetRxGainMode(M: Integer);
begin
  if M < 0 then M := 0 else if M > 3 then M := 3;
  FRxGainMode := M;
  ApplyRxGainToBackend;
  Changed(rfRxGain);
end;

procedure TRadioController.SetRxGain(Db: Integer);
begin
  if Db < 0 then Db := 0 else if Db > 73 then Db := 73;
  FRxGainDb := Db;
  // Ручная установка усиления подразумевает manual-режим (0).
  FRxGainMode := 0;
  ApplyRxGainToBackend;
  Changed(rfRxGain);
end;

procedure TRadioController.RxGainBy(Delta: Integer);
begin SetRxGain(FRxGainDb + Delta); end;

function TRadioController.MonitoringTX: Boolean;
begin
  // Звук даунлинка на передаче идёт только в full-duplex (DUP) при выключенном
  // RX MUTE — тот же гейт, что в движке (FKeepRXDuringTX and FMonitorRXDuringTX).
  Result := FTransmitting and FDisplayDuplex and (not FRxMuteOnTx);
end;

function TRadioController.ActiveVolume: Integer;
begin
  if MonitoringTX then Result := FTxMonVolume else Result := FVolume;
end;

procedure TRadioController.ApplyActiveVolume;
begin
  if FWDSPReady and Assigned(FDSPEngine) then
    FDSPEngine.SetVolume(ActiveVolume / 100.0);
  Changed(rfVolume);   // слайдер/оверлей ← ActiveVolume
end;

procedure TRadioController.SetVolume(V: Integer);
begin
  if V < 0 then V := 0 else if V > 100 then V := 100;
  // Правим ту громкость, что сейчас активна: на TX-мониторе — FTxMonVolume,
  // иначе FVolume. Обе персистятся; слайдер редактирует активную.
  if MonitoringTX then FTxMonVolume := V else FVolume := V;
  if FWDSPReady and Assigned(FDSPEngine) then FDSPEngine.SetVolume(ActiveVolume / 100.0);
  // rfVolume — для слайдера и оверлея: они показывают ActiveVolume. А вот кто
  // именно изменился, слайдеру всё равно, зато не всё равно внешним клиентам:
  // у них громкость приёма и громкость монитора — РАЗНЫЕ величины.
  if MonitoringTX then Changed(rfMonVolume);
  Changed(rfVolume);
end;

procedure TRadioController.SetRxVolume(V: Integer);
begin
  FVolume := EnsureRange(V, 0, 100);
  if MonitoringTX then Changed(rfVolume)   // звучит монитор — трогать тракт нечем
                  else ApplyActiveVolume;  // он же несёт Changed(rfVolume)
end;

procedure TRadioController.VolumeBy(Delta: Integer);
begin SetVolume(ActiveVolume + Delta); end;

procedure TRadioController.SetTXMonVolume(V: Integer);
// Адресно правит громкость self-monitor'а. Слайдер добирается до неё только на
// передаче (SetVolume через ActiveVolume), CAT-клиенту такой контекст не нужен.
begin
  FTxMonVolume := EnsureRange(V, 0, 100);
  if MonitoringTX then ApplyActiveVolume;
  Changed(rfMonVolume);
end;

procedure TRadioController.SetMute(On_: Boolean);
begin
  FMuted := On_;
  if FWDSPReady and Assigned(FDSPEngine) then FDSPEngine.SetMute(FMuted);
  Changed(rfMute);
end;

procedure TRadioController.ToggleMute;
begin SetMute(not FMuted); end;

procedure TRadioController.SetDrive(V: Integer);
begin
  if V < 0 then V := 0 else if V > 100 then V := 100;
  FDrivePercent := V;
  // Мощность входит в профиль: ведём её в активном без записи на диск (слайдер
  // шлёт события пачками; на диск профили уходят на переключении/остановке).
  if (FTXProfiles.ActiveIdx >= 0) and (FTXProfiles.ActiveIdx < FTXProfiles.Count) then
    FTXProfiles.Items[FTXProfiles.ActiveIdx].DrivePercent := FDrivePercent;
  FDriveLevel := CalcDriveByte;
  ApplyWDSPDrive(FDrivePercent);
  ApplyPlutoTxAtten;  // Pluto: drive живьём меняет TX-аттенюацию во время передачи
  PushNetworkState;   // протолкнуть новый drive в радио (если подключено+Running)
  Changed(rfDrive);
end;

procedure TRadioController.DriveBy(Delta: Integer);
begin SetDrive(FDrivePercent + Delta); end;

function TRadioController.PlutoTxAttForDrive: Double;
const
  TX_OFF = -89.75;   // макс. аттенюация AD9361 (0% → практически выкл)
var
  Pct: Integer;
begin
  // На тюне (TUN) уровень берём из TX-настроек (TUNLevel), иначе из drive%.
  if FTuning then Pct := FTXSettings.TUNLevel else Pct := FDrivePercent;
  if Pct < 0 then Pct := 0 else if Pct > 100 then Pct := 100;
  if Pct <= 0 then begin Result := TX_OFF; Exit; end;   // 0% → TX практически выкл
  // power-linear: мощность ∝ drive%. dB = потолок + 20·log10(pct/100); 100% → потолок,
  // 50% → −6 дБ, 10% → −20 дБ. (Раньше dB-линейно от −89.75 — низкие % улетали в −80 дБ.)
  Result := FPlutoTxMaxAttDb + 20.0 * Log10(Pct / 100.0);
  if Result > FPlutoTxMaxAttDb then Result := FPlutoTxMaxAttDb;
  if Result < TX_OFF then Result := TX_OFF;
end;

procedure TRadioController.ApplyPlutoTxAtten;
const
  TX_OFF = -89.75;
begin
  if (FNetwork = nil) or not IsPluto then Exit;
  // На передаче (MOX/TUN) — мощность от drive; на приёме — макс. аттенюация (выкл).
  // ★В телеграфе FTransmitting не поднимается вовсе (передачей владеет кейер),
  // поэтому спрашиваем и сессию локального генератора — иначе манипуляция шла бы
  // в закрытый аттенюатор, то есть в никуда.
  if FTransmitting or FTuning or FCWLocalTX then
    FNetwork.SetTxAtten(PlutoTxAttForDrive)
  else
    FNetwork.SetTxAtten(TX_OFF);
end;

procedure TRadioController.SetPlutoTxMaxAtt(Db: Double);
begin
  if Db > 0 then Db := 0 else if Db < -50 then Db := -50;
  FPlutoTxMaxAttDb := Db;
  ApplyPlutoTxAtten;        // применить живьём, если сейчас передаём
  SaveGlobalSettings;       // персист потолка (per-device)
end;

procedure TRadioController.SetCalibration(const C: TCalibration);
begin
  FCal := C;
  // Санитизация: битый конфиг не должен дать деление на ноль/абсурдные ватты.
  FCal.PwrDetModel := EnsureRange(FCal.PwrDetModel, 0, 3);
  if FCal.AmpSensMVA <= 0 then FCal.AmpSensMVA := 120.0;
  FCal.FreqCalPPM := EnsureRange(FCal.FreqCalPPM, -100.0, 100.0);
  // Опорник: у HPSDR правим частотное слово, у AD936x — xo_correction.
  // Ручные LOError/TO Error трансвертеров остаются сверху.
  if Assigned(FNetwork) then FNetwork.SetFreqCalPPM(FCal.FreqCalPPM);
  // Офсет S-метра берётся из FCal на каждом чтении (SMeterCalFor) — применять
  // отдельно нечего: он зависит от диапазона/трансвертера, а не от движка.
  SaveGlobalSettings;       // персист (гейт по FDevConnected внутри)
end;

procedure TRadioController.ApplyWDSPDrive(Pct: Integer);
// На Pluto мощность задаётся ОДИН раз — железной аттенюацией (PlutoTxAttForDrive),
// поэтому цифровой уровень WDSP держим на максимуме (1.0), а не режем по drive%.
// HPSDR — как раньше, Pct/100 (реальная мощность там — drive-байт в радио).
begin
  if not (FWDSPReady and Assigned(FDSPEngine)) then Exit;
  if IsPluto then FDSPEngine.SetDriveLevel(1.0)
  else            FDSPEngine.SetDriveLevel(EnsureRange(Pct, 0, 100) / 100.0);
end;

procedure TRadioController.SetAtten(Db: Integer);
// Шаговый аттенюатор ADC — плавный 0..31 дБ (как в железе openHPSDR), а не
// три фиксированные ступени. Уровни S-метра/спектра компенсируются на ту же
// величину (CalLookup), поэтому шкала dBm остаётся абсолютной.
begin
  FAtten := EnsureRange(Db, 0, 31);
  if Assigned(FNetwork) then FNetwork.SetStepAtten(Byte(FAtten));
  Changed(rfAtten);
  // Персист — вместе с диапазоном/слотом (SaveCurrentBand на выходе из них),
  // писать JSON на каждый шаг ползунка незачем, как и у drive.
  if (FCurrentXvtr >= 0) and (FCurrentXvtr < CFG_XVTR_COUNT) then
    FXvtrSettings.Entries[FCurrentXvtr].LastAttenDB := FAtten
  else if (FCurrentBand >= 0) and (FCurrentBand < CFG_BAND_COUNT) then
    FBandCache[FCurrentBand].AttenDB := FAtten;
end;

procedure TRadioController.SetBand(Idx: Integer);
// Смена HF-диапазона по индексу (web/CAT/BandUp-Down). Выход из XVTR (если активен),
// персист покидаемого, restore нового. Полная команда — не трогает UI.
begin
  if (Idx < 0) or (Idx >= CFG_BAND_COUNT) then Exit;
  // Пустой слот плана (HF: 12-й, зарезервирован под 12-местный план Pluto) —
  // не диапазон, игнорируем (защита CAT/web/BandUp).
  if BandName(Idx, IsPluto) = '' then Exit;
  if FCurrentXvtr >= 0 then
  begin
    DeactivateXvtr;                // выход в HF (HF-возврат внутри)
    if Idx = FCurrentBand then Exit;
  end
  else if Idx = FCurrentBand then Exit;
  if FDevConnected then begin SaveCurrentBand; FSettings.Save; end;
  FCurrentBand := Idx;
  RestoreBand(Idx);
end;

procedure TRadioController.BandUp;
begin if FCurrentBand < CFG_BAND_COUNT - 1 then SetBand(FCurrentBand + 1); end;

procedure TRadioController.BandDown;
begin if FCurrentBand > 0 then SetBand(FCurrentBand - 1); end;

procedure TRadioController.ApplyXvtrToNetwork;
// Передаёт XVTR-режим в сеть (enable bit + T/R relay suppression + RX ant).
var
  En, DisablePA: Boolean;
  RxAnt: Byte;
begin
  if (FCurrentXvtr >= 0) and (FCurrentXvtr < CFG_XVTR_COUNT) and
     FXvtrSettings.Entries[FCurrentXvtr].Enabled then
  begin
    En        := True;
    DisablePA := FXvtrSettings.Entries[FCurrentXvtr].DisablePA;
    RxAnt     := FXvtrSettings.Entries[FCurrentXvtr].RXAntenna;
  end
  else
  begin
    En := False; DisablePA := False; RxAnt := 0;
  end;
  if Assigned(FNetwork) and FNetwork.Connected then
    if En then FNetwork.SetXvtrMode(En, DisablePA, RxAnt, FCurrentXvtr)
    else       FNetwork.SetXvtrMode(En, DisablePA, RxAnt, -1);
end;

procedure TRadioController.ActivateXvtr(Idx: Integer);
// Вход в XVTR-слот: state+DSP через общий ApplyBandDSP + VFO/центр/сеть.
// Рендер: rfXvtr (XVTR-кнопки), rfBand (XVTR-aware гасит HF), rfActiveVfo
// (дисплеи A/B + полный перерисов спектра). UI-обёртка добавляет wideband+web.
var
  E:   TXvtrEntry;
  B:   TBandSettings;
  Vis: Double;
begin
  if (Idx < 0) or (Idx >= CFG_XVTR_COUNT) then Exit;
  if not FXvtrSettings.Entries[Idx].Enabled then Exit;
  FCurrentXvtr := Idx;
  E := FXvtrSettings.Entries[Idx];
  // Начальная частота: LastFreq если в диапазоне, иначе середина.
  Vis := E.LastFreq;
  if (Vis < E.FreqBegin) or (Vis > E.FreqEnd) then
    Vis := (E.FreqBegin + E.FreqEnd) / 2.0;

  ApplyXvtrToNetwork;   // XVTR enable bit + DisablePA + RX ant

  // Авто-флаги канала (не входят в TBandSettings/ApplyBandDSP).
  FFMCTCSSAutoActive := E.LastCTCSSAutoActive;
  FFMRptAutoActive   := E.LastFMRptAutoActive;

  // DSP-настройки слота — через общий путь Band (mode/filter/AGC/CTUN/FM + рендер).
  B.Mode         := E.LastMode;
  B.FilterIdx    := E.LastFilterIdx;
  B.FilterBW     := FilterBWFor(E.LastMode, E.LastFilterIdx);
  B.AGCMode      := E.LastAGCMode;
  B.AGCTop       := E.LastAGCTop;
  B.CTun         := E.LastCTun;
  B.SpanHz       := FSampleRate;
  B.FMSQOn       := E.LastFMSQOn;
  B.FMSQLevel    := E.LastFMSQLevel;
  B.CTCSSOn      := E.LastCTCSSOn;
  B.CTCSSToneIdx := E.LastCTCSSToneIdx;
  B.FMStepOn     := E.LastFMStepOn;
  B.FMStepIdx    := E.LastFMStepIdx;
  B.FMRptDir      := E.LastFMRptDir;
  B.FMRptOffsetHz := E.LastFMRptOffsetHz;
  B.RxGainMode    := E.LastRxGainMode;   // hw-AGC слота (AD936x)
  B.RxGainDb      := E.LastRxGainDb;
  B.AttenDB       := E.LastAttenDB;      // аттенюатор ADC слота
  B.VfoA := Vis;
  B.VfoB := FVfoB;
  ApplyBandDSP(B);

  // Zoom/pan слота: восстанавливаем и переармируем анализатор.
  FZoomFactor := EnsureRange(E.LastZoomFactor, 0.0, 1.0);
  FPanSlider  := EnsureRange(E.LastPanSlider, 0.0, 1.0);
  if Assigned(FDSPEngine) then FDSPEngine.SetZoomPan(FZoomFactor, FPanSlider);
  Changed(rfZoom);

  // VFO: B — своя сохранённая частота слота (клэмп в диапазон); A = visible.
  FVfoB       := EnsureRange(E.LastFreqB, E.FreqBegin, E.FreqEnd);
  FVfoA       := Vis;
  // При CTun восстанавливаем сохранённый DDC-центр, чтобы VFO остался на своём
  // месте в полосе (а не прыгал в центр). Берём только если offset влезает в
  // текущий span — иначе центрируемся на видимой частоте.
  if FCTun and (E.LastCenterHz > 0) and (Abs(Vis - E.LastCenterHz) < FSpanHz / 2) then
  begin
    FCenterFreq := E.LastCenterHz;
    if FWDSPReady and Assigned(FDSPEngine) then FDSPEngine.SetShift(Vis - FCenterFreq);
  end
  else
  begin
    FCenterFreq := Vis;
    if FWDSPReady and Assigned(FDSPEngine) then FDSPEngine.SetShift(0.0);
  end;
  FDriveLevel := CalcDriveByte;
  // QO-100 / split-LO: авто display-duplex (мониторинг своего downlink при TX).
  if E.FullDuplexTx and not FDisplayDuplex then SetDuplex(True);
  PushNetworkState;
  RefreshPanDDCs;   // живые паны — в диапазон XVTR, DDC — на IF
  ApplyAntPorts;    // AD936x: разъёмы, выбранные для этого трансвертера

  Changed(rfXvtr);
  Changed(rfActiveVfo);
end;

procedure TRadioController.DeactivateXvtr;
// Сохраняет текущий XVTR-слот и выходит в HF: HF-возврат (RestoreBand + финальная
// перестройка активного VFO + ResetWfAvgBuf через rfBandRestore) делается здесь.
// Changed(rfXvtr) шлём ПОСЛЕ RestoreBand — чтобы wideband/web-extras в его рендере
// увидели уже восстановленное HF-состояние.
begin
  if FCurrentXvtr < 0 then Exit;
  SaveCurrentBand;                 // XVTR-ветка: пишет весь слот + SaveXvtr
  if FDevConnected then FSettings.Save;
  FCurrentXvtr := -1;
  ApplyXvtrToNetwork;              // снять XVTR enable bit
  RefreshPanDDCs;                  // живые паны — обратно в пределы железа
  if (FCurrentBand >= 0) and (FCurrentBand < CFG_BAND_COUNT) then
    RestoreBand(FCurrentBand);     // HF-возврат (состояние/DSP/retune)
  Changed(rfXvtr);                 // погасить XVTR-кнопки + wideband/web (rfXvtr-рендер)
end;

procedure TRadioController.SetXvtrBand(Idx: Integer);
// Единая точка для GUI/Web/CAT: Idx>=0 — войти в слот, Idx<0 — выйти в HF.
// ВНИМАНИЕ: HF-возврат при выходе делает UI-обёртка DeactivateXvtr (RestoreBand).
begin
  if Idx >= 0 then ActivateXvtr(Idx)
  else             DeactivateXvtr;
end;

procedure TRadioController.SetCurrentBandIdx(NewBand: Integer);
// Лёгкая смена HF-диапазона без полного restore: фиксируем индекс диапазона
// (для drive-кал и подсветки кнопок) и пересчитываем drive-байт. Используется
// канальным адаптером при переходе канала на другой HF-диапазон.
begin
  FCurrentBand := NewBand;
  FDriveLevel  := CalcDriveByte;
  Changed(rfBand);
end;

procedure TRadioController.SetSpan(Hz: Integer);
begin FSpanHz := Hz; Changed(rfSpan); { TODO wiring } end;

// ════════════════════════════════════════════════════════════════════════════
// Zoom / pan спектра и водопада
// ════════════════════════════════════════════════════════════════════════════

function TRadioController.ZoomFactor: Double;
begin Result := FZoomFactor; end;

function TRadioController.PanSlider: Double;
begin Result := FPanSlider; end;

procedure ViewWindowFor(BaseC, BaseS, AZoom, APan: Double;
  out ACenter, ASpan: Double);
// Видимое окно (центр+span, Гц) с учётом зума. Формула совпадает с расчётом
// span-clip в WDSPEngine.Apply{RX,Pan}AnalyzerSettings (bw=BaseS, clp=0),
// поэтому оверлеи точно ложатся на пиксели. Без зума → базовые центр/span.
var
  FVis, OneMinusFVis, VisSpan, LowEdgeOff, CenterOff: Double;
begin
  if (AZoom <= 0.0) or (BaseS <= 0.0) then
  begin
    ACenter := BaseC;
    ASpan   := BaseS;
    Exit;
  end;
  OneMinusFVis := 0.99 * Log10(9.0 * EnsureRange(AZoom, 0.0, 1.0) + 1.0);
  FVis := 1.0 - OneMinusFVis;                       // доля span после зума
  if FVis < 1.0 / 100.0 then FVis := 1.0 / 100.0;   // ZOOM_LIMIT
  VisSpan    := FVis * BaseS;
  LowEdgeOff := -BaseS / 2.0 + EnsureRange(APan, 0.0, 1.0) * OneMinusFVis * BaseS;
  CenterOff  := LowEdgeOff + VisSpan / 2.0;
  ACenter    := BaseC + CenterOff;
  ASpan      := VisSpan;
end;

procedure TRadioController.GetViewWindow(out ACenter: Double; out ASpan: Double);
begin
  ViewWindowFor(FCenterFreq, FSpanHz, FZoomFactor, FPanSlider, ACenter, ASpan);
end;

function TRadioController.AnchoredPan(AZoom: Double): Double;
// Положение пана, при котором видимое окно центрировано на маркере активного
// VFO. Клампится в [0..1], чтобы окно не вылезало за span (у краёв маркер
// перестаёт быть строго по центру — окно упирается в край).
var OneMinusFVis, U: Double;
begin
  if (AZoom <= 0.0) or (FSpanHz <= 0.0) then Exit(0.5);
  OneMinusFVis := 0.99 * Log10(9.0 * EnsureRange(AZoom, 0.0, 1.0) + 1.0);
  if OneMinusFVis <= 1e-9 then Exit(0.5);
  U := (ActiveVfoHz - FCenterFreq) / FSpanHz;       // нормализованный сдвиг маркера
  Result := EnsureRange(0.5 + U / OneMinusFVis, 0.0, 1.0);
end;

procedure TRadioController.ApplyZoom(AZoom, APan: Double);
begin
  FZoomFactor := EnsureRange(AZoom, 0.0, 1.0);
  FPanSlider  := EnsureRange(APan, 0.0, 1.0);
  if Assigned(FDSPEngine) then
    FDSPEngine.SetZoomPan(FZoomFactor, FPanSlider);
  // Кэшируем в активный слот (XVTR-слот при активном трансверторе, иначе HF-band);
  // персист на диск — в SaveCurrentBand при смене/выходе.
  if FCurrentXvtr >= 0 then
  begin
    if FCurrentXvtr < CFG_XVTR_COUNT then
    begin
      FXvtrSettings.Entries[FCurrentXvtr].LastZoomFactor := FZoomFactor;
      FXvtrSettings.Entries[FCurrentXvtr].LastPanSlider  := FPanSlider;
    end;
  end
  else if (FCurrentBand >= 0) and (FCurrentBand < CFG_BAND_COUNT) then
  begin
    FBandCache[FCurrentBand].ZoomFactor := FZoomFactor;
    FBandCache[FCurrentBand].PanSlider  := FPanSlider;
  end;
  Changed(rfZoom);
end;

// Лестница кратностей увеличения: мелкий шаг в начале (1.0→1.2→1.45…), крупнее
// к концу. Кнопки [+]/[-] шагают по ней; ZoomFactor (0..1) хранит соответствующий
// z. Перевод кратность↔z — инверсия формулы анализатора (см. ApplyRXAnalyzerSettings).
const
  ZOOM_MAG_LADDER: array[0..16] of Double =
    (1.0, 1.1, 1.15, 1.2, 1.45, 1.75, 2.1, 2.5, 3.0, 3.7, 4.7, 6.2, 8.5, 12.0, 20.0, 40.0, 100.0);

function ZoomMagToZ(M: Double): Double;
begin
  if M <= 1.0 then Exit(0.0);
  // M = 1/(1-0.99*log10(9z+1)) ⇒ z = (10^((1-1/M)/0.99) - 1)/9
  Result := EnsureRange((Power(10.0, (1.0 - 1.0 / M) / 0.99) - 1.0) / 9.0, 0.0, 1.0);
end;

function ZoomZToMag(Z: Double): Double;
begin
  Result := 1.0 / (1.0 - 0.99 * Log10(9.0 * EnsureRange(Z, 0.0, 1.0) + 1.0));
end;

procedure TRadioController.ZoomIn;
var CurMag, Z: Double; i: Integer;
begin
  CurMag := ZoomZToMag(FZoomFactor);
  for i := 0 to High(ZOOM_MAG_LADDER) do
    if ZOOM_MAG_LADDER[i] > CurMag * 1.001 then
    begin
      Z := ZoomMagToZ(ZOOM_MAG_LADDER[i]);
      ApplyZoom(Z, AnchoredPan(Z));
      Exit;
    end;
end;

procedure TRadioController.ZoomOut;
var CurMag, Z: Double; i: Integer;
begin
  CurMag := ZoomZToMag(FZoomFactor);
  for i := High(ZOOM_MAG_LADDER) downto 0 do
    if ZOOM_MAG_LADDER[i] < CurMag / 1.001 then
    begin
      if ZOOM_MAG_LADDER[i] <= 1.0 then ApplyZoom(0.0, 0.5)
      else
      begin
        Z := ZoomMagToZ(ZOOM_MAG_LADDER[i]);
        ApplyZoom(Z, AnchoredPan(Z));
      end;
      Exit;
    end;
  ApplyZoom(0.0, 0.5);
end;

procedure TRadioController.ZoomDefault;
begin
  ApplyZoom(0.0, 0.5);
end;

{ ---- Зум/пан доп. панов (PanId=0 делегирует главному) ---- }

procedure TRadioController.GetPanViewWindow(PanId: Integer;
  out ACenter, ASpan: Double);
begin
  if PanId = 0 then
  begin
    GetViewWindow(ACenter, ASpan);
    Exit;
  end;
  ACenter := 0; ASpan := 0;
  if not PanDDCActive(PanId) then Exit;
  ViewWindowFor(FPanFreqHz[PanId], FPanRateKHz[PanId] * 1000.0,
                FPanZoomF[PanId], FPanPanSl[PanId], ACenter, ASpan);
end;

procedure TRadioController.SetPanZoomPan(PanId: Integer; AZoom, APan: Double);
// Зум пана — состояние сессии пана (не персистится с бандом, в отличие от
// главного ApplyZoom): пан живёт до STOP.
begin
  if PanId = 0 then
  begin
    ApplyZoom(AZoom, APan);
    Exit;
  end;
  if not PanDDCActive(PanId) then Exit;
  FPanZoomF[PanId] := EnsureRange(AZoom, 0.0, 1.0);
  FPanPanSl[PanId] := EnsureRange(APan, 0.0, 1.0);
  if FWDSPReady and Assigned(FDSPEngine) then
    FDSPEngine.SetPanZoomPan(PanId, FPanZoomF[PanId], FPanPanSl[PanId]);
end;

procedure TRadioController.PanZoomStepTo(PanId: Integer; NewZ: Double);
// Ставит новый зум, удерживая ЦЕНТР текущего видимого окна (у пана нет VFO-якоря
// главного AnchoredPan; фиксация центра — естественный аналог).
var
  C, S, BaseC, BaseS, U, OneMinus, FVis, NewPan: Double;
begin
  if not PanDDCActive(PanId) then Exit;
  GetPanViewWindow(PanId, C, S);
  BaseC := FPanFreqHz[PanId];
  BaseS := FPanRateKHz[PanId] * 1000.0;
  if BaseS <= 0 then Exit;
  NewZ := EnsureRange(NewZ, 0.0, 1.0);
  // Инверсия ViewWindowFor: U = CenterOff/BaseS = −0.5 + Pan·OneMinus + FVis/2.
  U := (C - BaseC) / BaseS;
  OneMinus := 0.99 * Log10(9.0 * NewZ + 1.0);
  if OneMinus <= 1e-9 then
    NewPan := 0.5
  else
  begin
    FVis := 1.0 - OneMinus;
    if FVis < 1.0 / 100.0 then FVis := 1.0 / 100.0;
    NewPan := EnsureRange((U + 0.5 - FVis / 2.0) / OneMinus, 0.0, 1.0);
  end;
  SetPanZoomPan(PanId, NewZ, NewPan);
end;

procedure TRadioController.PanZoomIn(PanId: Integer);
var CurMag: Double; i: Integer;
begin
  if not PanDDCActive(PanId) then Exit;
  CurMag := ZoomZToMag(FPanZoomF[PanId]);
  for i := 0 to High(ZOOM_MAG_LADDER) do
    if ZOOM_MAG_LADDER[i] > CurMag * 1.001 then
    begin
      PanZoomStepTo(PanId, ZoomMagToZ(ZOOM_MAG_LADDER[i]));
      Exit;
    end;
end;

procedure TRadioController.PanZoomOut(PanId: Integer);
var CurMag: Double; i: Integer;
begin
  if not PanDDCActive(PanId) then Exit;
  CurMag := ZoomZToMag(FPanZoomF[PanId]);
  for i := High(ZOOM_MAG_LADDER) downto 0 do
    if ZOOM_MAG_LADDER[i] < CurMag / 1.001 then
    begin
      if ZOOM_MAG_LADDER[i] <= 1.0 then SetPanZoomPan(PanId, 0.0, 0.5)
      else PanZoomStepTo(PanId, ZoomMagToZ(ZOOM_MAG_LADDER[i]));
      Exit;
    end;
  SetPanZoomPan(PanId, 0.0, 0.5);
end;

procedure TRadioController.PanZoomDefault(PanId: Integer);
begin
  SetPanZoomPan(PanId, 0.0, 0.5);
end;

function TRadioController.PanZoomFactor(PanId: Integer): Double;
begin
  if PanId = 0 then Result := FZoomFactor
  else if PanDDCActive(PanId) then Result := FPanZoomF[PanId]
  else Result := 0.0;
end;

function TRadioController.PanPanSlider(PanId: Integer): Double;
begin
  if PanId = 0 then Result := FPanSlider
  else if PanDDCActive(PanId) then Result := FPanPanSl[PanId]
  else Result := 0.5;
end;

procedure TRadioController.SetSampleRate(Hz: Integer);
// Смена частоты дискретизации DDC. В этом приложении span ≡ sample rate.
// Несёт: пересоздание WDSP-канала (Close+FlushQueue+Open), новый rate в
// трансивер и полное восстановление DSP-состояния. Команда сама персистит
// (sample rate глобальный); рендер overlay/AGC-линий — в OnControllerState.
begin
  if (Hz <= 0) or (Hz = FSampleRate) then Exit;
  FSampleRate := Hz;
  FSpanHz     := Hz;
  FBandCache[FCurrentBand].SpanHz := FSpanHz;   // для обратной совместимости

  // 1. Меняем rate главного тракта. Хирургически: движок гейтит и дропает
  // только main-пакеты, DSP-поток/анализаторы/паны N живут без пауз —
  // поэтому FWDSPReady НЕ сбрасываем (иначе OnDDCIQ перестал бы кормить
  // паны на всё время пересборки и их водопады замирали).
  if FWDSPReady and Assigned(FDSPEngine) then
    FDSPEngine.ChangeSampleRate(Hz);

  // 2. Новый rate трансиверу. Короткий хвост пакетов старого rate, дошедших
  // до переключения радио, попадает в новый канал — транзиент в единицы мс
  // (то же окно было и при старой схеме: FWDSPReady поднимался до того, как
  // радио реально применяло DDC Specific).
  if Assigned(FNetwork) and FNetwork.Connected then
    FNetwork.ConfigureDDCs(1, Hz div 1000, FMainADCSrc,
                           FDitherEnabled, FRandomEnabled);

  // 3. Восстанавливаем DSP-состояние (ChangeSampleRate пересоздал канал).
  if Assigned(FDSPEngine) then
  begin
    FWDSPReady := FDSPEngine.Initialized;
    if FWDSPReady then
    begin
      FDSPEngine.SetMode(FMode);
      FDSPEngine.SetVolume(FVolume / 100.0);
      ApplyModeFilter;
      FDSPEngine.SetNRMode(FNRMode);
      FDSPEngine.SetNBMode(FNBMode);
      FDSPEngine.SetSNB(FSNB);
      FDSPEngine.SetANF(FANF);
      FDSPEngine.SetAGCTop(FAGCTop);
      ApplyAGCToEngine;   // единый маппинг (раньше здесь был ошибочный каст индекса)
      if Assigned(FNetwork) and FNetwork.Connected then
        FDSPEngine.SetShift(FVfoA - FCenterFreq);
    end;
  end;
  SaveGlobalSettings;   // sample rate глобальный — персистим (гейт по FDevConnected внутри)
  Changed(rfSampleRate);
end;

procedure TRadioController.SetCTun(On_: Boolean);
begin
  FCTun := On_;
  if XvtrSlotActive then FXvtrSettings.Entries[FCurrentXvtr].LastCTun := FCTun
  else                   FBandCache[FCurrentBand].CTun := FCTun;
  if not FCTun then
  begin
    // Выключили CTUN: центрируемся на АКТИВНОМ VFO, shift=0. (Включение CTUN не
    // трогает движок — FCenterFreq остаётся как есть, спектр не прыгает.)
    FCenterFreq := ActiveVfoHz;
    if FWDSPReady and Assigned(FDSPEngine) then FDSPEngine.SetShift(0.0);
    PushNetworkState;
  end;
  Changed(rfCTun);
end;

procedure TRadioController.SetDuplex(On_: Boolean);
begin
  FDisplayDuplex := On_;
  // Применяем сразу: при передаче держим RX-IQ в DSP-цепи (full-duplex монитор —
  // для QO-100 слышим свой downlink через спутник). SetMOX тоже это выставляет,
  // но здесь — чтобы тумблер DUP действовал и в момент передачи.
  if FWDSPReady and Assigned(FDSPEngine) then
    FDSPEngine.KeepRXDuringTX := FDisplayDuplex;
  ApplyActiveVolume;   // смена DUP на передаче может включить/выключить TX-монитор
  Changed(rfDuplex);
end;

procedure TRadioController.ApplyRxMuteToEngine;
begin
  // Монитор имеет смысл только в full-duplex (KeepRXDuringTX); сам гейт в движке
  // это учитывает. Здесь просто транслируем инверсию флага RX MUTE.
  if FWDSPReady and Assigned(FDSPEngine) then
    FDSPEngine.MonitorRXDuringTX := not FRxMuteOnTx;
end;

procedure TRadioController.SetRxMuteOnTx(On_: Boolean);
begin
  FRxMuteOnTx := On_;
  ApplyRxMuteToEngine;
  ApplyActiveVolume;   // на передаче тумблер монитора меняет активную громкость
  Changed(rfRxMuteOnTx);
end;

procedure TRadioController.SetWfAGC(On_: Boolean);
begin FWfAGCEnabled := On_; Changed(rfWfAGC); end;

procedure TRadioController.SetWfNF(On_: Boolean);
begin FWfNFEnabled := On_; Changed(rfWfNF); end;

procedure TRadioController.SetNR(Mode: Integer);
begin
  if Mode < 0 then Mode := 0 else if Mode > 4 then Mode := 4;
  FNRMode := Mode;
  if FWDSPReady and Assigned(FDSPEngine) then FDSPEngine.SetNRMode(FNRMode);
  Changed(rfNR);
end;

procedure TRadioController.SetNB(Mode: Integer);
begin
  if Mode < 0 then Mode := 0 else if Mode > 2 then Mode := 2;
  FNBMode := Mode;
  if FWDSPReady and Assigned(FDSPEngine) then FDSPEngine.SetNBMode(FNBMode);
  Changed(rfNB);
end;

procedure TRadioController.SetSNB(On_: Boolean);
begin
  FSNB := On_;
  if FWDSPReady and Assigned(FDSPEngine) then FDSPEngine.SetSNB(FSNB);
  Changed(rfSNB);
end;

procedure TRadioController.SetANF(On_: Boolean);
begin
  FANF := On_;
  if FWDSPReady and Assigned(FDSPEngine) then FDSPEngine.SetANF(FANF);
  Changed(rfANF);
end;

function TRadioController.DefaultMicSource: TTXMicSource;
// Источник модуляции решает ТЕКУЩИЙ TX-слайс: у него может быть свой вход
// (виртуальный кабель), даже когда общего входа нет, и наоборот.
begin
  if ActiveMicInDevName <> '' then Result := txmsSoundCard
  else                             Result := txmsRadio;
end;

function TRadioController.BuildMicLineSelectByte: Byte;
// openHPSDR Ethernet Protocol v4.3, DUC Specific byte 50:
//   bit 0  — Line In (1 = line-in вместо mic-in)
//   bit 1  — Mic Boost (+20 dB); не ставим когда Line In
//   bit 2  — 0 = Orion mic PTT enabled, 1 = disabled (инверсия!)
//   bit 3  — Tip/Ring (0=Tip, 1=Ring; Orion/OrionMkII)
//   bit 4  — Orion mic bias (1 = enable +5V bias)
//   bit 5  — Saturn XLR/balanced (не используем)
begin
  Result := 0;
  if FTXSettings.MicLineIn then
    Result := Result or $01               // bit 0: Line In
  else if FTXSettings.MicBoost then
    Result := Result or $02;              // bit 1: Boost только при Mic In
  if not FTXSettings.MicPTTEnabled then Result := Result or $04;  // bit 2: инверсия
  if FTXSettings.MicTipRing         then Result := Result or $08;  // bit 3: Ring
  if FTXSettings.MicBias             then Result := Result or $10;  // bit 4: bias
end;

procedure TRadioController.ApplyNoiseFilterButtonsToDSP;
begin
  if not FWDSPReady then Exit;
  FDSPEngine.SetNRMode(FNRMode);
  FDSPEngine.SetNBMode(FNBMode);
  FDSPEngine.SetSNB(FSNB);
  FDSPEngine.SetANF(FANF);
end;

function TRadioController.XvtrSlotActive: Boolean;
begin
  Result := (FCurrentXvtr >= 0) and (FCurrentXvtr < CFG_XVTR_COUNT);
end;

function TRadioController.TXProhibited: Boolean;
// Единая точка запретов передачи: диапазон с DoNotTx (Alex) и RX-only слот
// трансвертера. Раньше эта пара была скопирована в SetMOX/SetTune/SetTwoTone —
// то есть жила ТОЛЬКО на пути через софтовый MOX. С кейером в прошивке этого
// пути нет вовсе, и на RX-only трансвертере ключ спокойно выходил в эфир.
begin
  Result := (FDevConnected and (FCurrentXvtr < 0)
             and FAlexSettings.DoNotTx[EnsureRange(TxBandIdx, 0, 10)])
         or ((FCurrentXvtr >= 0) and (FCurrentXvtr < CFG_XVTR_COUNT)
             and FXvtrSettings.Entries[FCurrentXvtr].RXOnly);
end;

function TRadioController.CWFWSource: Boolean;
// Манипуляцию рисует ПРОШИВКА (кейер в FPGA, openHPSDR P2). Путь по умолчанию:
// тайминги в железе, джиттер PC не влияет вовсе.
begin
  Result := BackendCaps.HasCWKeyer and FCWSettings.FWKeyer
            and (not FCWSettings.LocalKeyer);
end;

function TRadioController.CWLocalSource: Boolean;
// Манипуляцию рисует САМ EWSDR (CWKeyer.pas). Выбирается списком в настройках, а
// на железе без кейера в прошивке (Pluto/LibreSDR — голый AD936x) выбор
// «прошивка» вырождается сюда: другого пути там просто нет.
// ★Оба источника False = кейер выключен намеренно (третий пункт списка): тогда
// телеграф идёт только через MOX, и ключевать некому.
begin
  Result := FCWSettings.LocalKeyer
            or ((not BackendCaps.HasCWKeyer) and FCWSettings.FWKeyer);
end;

function TRadioController.CWTXActive: Boolean;
// «Передачей в телеграфе владеет не MOX»: режим ИСТОЧНИКА передачи — CW и
// манипуляция кому-то поручена (прошивке или локальному генератору). В этом
// состоянии голосовой TXA не запускается вовсе,
// а несущую даёт кейер / бит CWX / тон TUN.
begin
  Result := (ActiveTXMode in [MODE_CWL, MODE_CWU])
            and (CWFWSource or CWLocalSource)
            and (not TXProhibited);   // где передавать нельзя — кейер разоружён
end;

function TRadioController.CWCarrierOffsetHz: Double;
// Сдвиг несущей манипуляции от гетеродина — и в генераторе, и в частоте DUC/LO.
// ★У zero-IF (AD936x) ноль бейсбенда = частота гетеродина, и ТАМ ЖЕ сидит его
// утечка. Держи несущую на нуле — между посылками в эфир пойдёт слабый backwave
// РОВНО на рабочей частоте (самое неприятное, что бывает в телеграфе). Поэтому
// у Pluto манипуляция идёт на сдвиге, а LO уезжает навстречу (знак — в
// TXTuneFreqHz, он замерен на эфире). У openHPSDR DUC цифровой, утекать
// нечему — сдвиг 0.
const
  CW_AUTO_OFFSET_HZ = 12000;
begin
  Result := 0;
  if not FCWLocalArmed then Exit;
  // TUN/2TON идут через TXA (несущую даёт SSB-модулятор из тона), их частота
  // уже посчитана в ActiveTXFreqHz — сдвигать нечего.
  if FTuning or FTwoTone then Exit;
  // −1 = «без сдвига» ЯВНО (несущая прямо на гетеродине). Нужно не только для
  // отладки: сдвиг разносит TX LO и RX LO на единицы-десятки килогерц, а два
  // fractional-N синтезатора в одном кристалле на близких частотах тянут друг
  // друга и дают симметричные боковые вокруг несущей. Что хуже — утечка LO на
  // рабочей частоте или эти боковые, решает оператор на своём железе.
  if FCWSettings.CarrierOffsetHz < 0 then Exit;
  if FCWSettings.CarrierOffsetHz > 0 then
    Result := FCWSettings.CarrierOffsetHz
  else if not BackendCaps.HasCWKeyer then
    Result := CW_AUTO_OFFSET_HZ;
end;

function TRadioController.CWOptionsByte: Byte;
// Формирует DUC Specific byte 5; iambic-бит выставляется и для Mode B.
// ★Вне телеграфа байт ОБЯЗАН быть нулевым: иначе прошивка сама поднимет PTT
// на замыкание ключа посреди SSB.
begin
  Result := 0;
  // ★Байт обязан быть нулевым не только вне телеграфа, но и когда манипуляцию
  // взял на себя локальный генератор: иначе прошивка ключевала бы ПАРАЛЛЕЛЬНО
  // с ним (и поднимала бы PTT на замыкание своего разъёма).
  if not (CWTXActive and CWFWSource) then Exit;
  Result := Result or CW_MODE;
  if FCWSettings.ReverseKeys   then Result := Result or CW_REVERSE_KEYS;
  case FCWSettings.KeyerMode of
    1: Result := Result or CW_IAMBIC;                 // Mode A
    2: Result := Result or CW_IAMBIC or CW_MODE_B;    // Mode B
  end;                                                // 0 = прямой ключ
  if FCWSettings.SidetoneHW and (FCWSettings.SidetoneHWLevel > 0) then
    Result := Result or CW_SIDETONE;
  if FCWSettings.StrictSpacing then Result := Result or CW_STRICT_SPACING;
  if FCWSettings.BreakIn       then Result := Result or CW_BREAK_IN;
end;

procedure TRadioController.SendDUCSpecificFromSettings;
var
  DUCPkt: TDUCSpecificPacket;
  RateKsps: Word;
  HangMs:   Word;
begin
  // Assigned обязателен: сюда приходят и по PTT/TUN, а нажать их можно и до
  // того, как устройство выбрано, — тогда FNetwork ещё nil (то же и с движком
  // ниже). Раньше на этом месте была Access violation, и разбирать её приходил
  // уже кто-то другой: SetMOX зовёт SyncCWKeyer, а тот — эту процедуру.
  if not (Assigned(FNetwork) and FNetwork.Connected and FNetwork.Running) then Exit;
  if not (FWDSPReady and Assigned(FDSPEngine)) then Exit;
  FillChar(DUCPkt, SizeOf(DUCPkt), 0);
  // DUC0 rate в ksps берём из WDSP-движка (фиксированный TX rate, не RX).
  RateKsps := FDSPEngine.TXSampleRate div 1000;
  DUCPkt.NumDACs        := 1;
  DUCPkt.CWOptions      := CWOptionsByte;
  // Уровень/частота аппаратного сайдтона — тон в наушниках САМОГО трансивера.
  if FCWSettings.SidetoneHW then
    DUCPkt.SidetoneLevel := Byte(EnsureRange(FCWSettings.SidetoneHWLevel, 0, 127))
  else
    DUCPkt.SidetoneLevel := 0;
  DUCPkt.SidetoneFreqHi := Hi(Word(EnsureRange(FCWSettings.Pitch, 200, 1200)));
  DUCPkt.SidetoneFreqLo := Lo(Word(EnsureRange(FCWSettings.Pitch, 200, 1200)));
  DUCPkt.KeyerSpeed     := Byte(EnsureRange(FCWSettings.Speed,  1, 60));
  DUCPkt.KeyerWeight    := Byte(EnsureRange(FCWSettings.Weight, 33, 66));
  // Hang — сколько прошивка держит передачу после последнего элемента (semi
  // break-in). При выключенном break-in железу его знать незачем.
  if FCWSettings.BreakIn then
    HangMs := Word(EnsureRange(FCWSettings.HangTimeMS, 0, 2000))
  else
    HangMs := 0;
  DUCPkt.HangDelayHi    := Hi(HangMs);
  DUCPkt.HangDelayLo    := Lo(HangMs);
  DUCPkt.RFDelay        := Byte(EnsureRange(FCWSettings.RFDelayMS, 0, 255));
  DUCPkt.CWRampPeriod   := Byte(EnsureRange(FCWSettings.RampMS, 0, 20));
  DUCPkt.DUC0RateHi     := Hi(RateKsps);
  DUCPkt.DUC0RateLo     := Lo(RateKsps);
  DUCPkt.DUC0Bits       := 24;
  DUCPkt.MicLineSelect  := BuildMicLineSelectByte;
  // Line In Gain (byte 51): 0=-34.5dB .. 31=+12dB с шагом 1.5 dB.
  // При Mic In значение игнорируется железом, но шлём корректное для Line In.
  DUCPkt.LineInGain := Byte(EnsureRange(Round((FTXSettings.LineInGainDB + 34.5) / 1.5), 0, 31));
  // ATT on TX — ступенчатый аттенюатор RX-ADC во время передачи.
  // Дублируем значение во все три ADC-байта: на однопотоковых платах активен только
  // ADC0, на Saturn/Hermes Lite 2 — могут использоваться ADC1/ADC2.
  DUCPkt.StepAtten0 := Byte(EnsureRange(FTXSettings.AttOnTX, 0, 31));
  DUCPkt.StepAtten1 := DUCPkt.StepAtten0;
  DUCPkt.StepAtten2 := DUCPkt.StepAtten0;
  FLastCWOptions := DUCPkt.CWOptions;
  FNetwork.SendDUCSpecific(DUCPkt);
end;

// ---------------------------------------------------------------------------
// TX-профили
// ---------------------------------------------------------------------------

function TRadioController.TXProfileCount: Integer;
begin
  Result := FTXProfiles.Count;
end;

function TRadioController.TXProfileName(Idx: Integer): string;
begin
  if (Idx < 0) or (Idx >= FTXProfiles.Count) then Result := ''
  else Result := FTXProfiles.Items[Idx].Name;
end;

function TRadioController.ActiveTXProfile: Integer;
begin
  Result := FTXProfiles.ActiveIdx;
end;

procedure TRadioController.SaveTXProfilesToConfig;
begin
  if (not FDevConnected) or (not Assigned(FSettings)) then Exit;
  FSettings.SaveTXProfiles(FDevMAC, FTXProfiles);
  FSettings.Save;
end;

procedure TRadioController.SetTXSettings(const T: TTXSettings; Notify: Boolean);
// Одна дверь для правки TX-настроек — по образцу SetCWSettings. Клиенту
// (вкладка Transmit, CAT, web) достаточно отдать запись целиком: применение к
// WDSP, перепосыл DUC Specific при смене mic-битов/ATT, живое обновление TUN,
// запись в активный профиль и персист — здесь.
var
  OldMicByte: Byte;
  OldAtt, OldTUNLevel, OldTUNFreq: Integer;
begin
  OldMicByte  := BuildMicLineSelectByte;
  OldAtt      := FTXSettings.AttOnTX;
  OldTUNLevel := FTXSettings.TUNLevel;
  OldTUNFreq  := FTXSettings.TUNFreq;
  FTXSettings := T;
  // TX-сетка спектра — рабочие поля рендера; их перерисовку заказывает вызывающий.
  FTXSpecRefLevel := FTXSettings.TXSpecRefLevel;
  FTXSpecRange    := FTXSettings.TXSpecRange;
  if FTXSettings.TXSpecGridStep > 0 then FTXSpecGridStep := FTXSettings.TXSpecGridStep;
  ApplyTXSettingsToDSP;
  // Маршрутизация микрофона и ATT on TX живут в железе (DUC Specific), WDSP тут
  // ни при чём — пакет перепосылаем только когда байты реально поменялись.
  if (BuildMicLineSelectByte <> OldMicByte) or (OldAtt <> FTXSettings.AttOnTX) then
    SendDUCSpecificFromSettings;
  // TUN-параметры live: идёт настройка — обновить тон/уровень, иначе просто запомнить.
  if FTuning then
  begin
    if (OldTUNFreq <> FTXSettings.TUNFreq) and FWDSPReady then
      FDSPEngine.SetTXTone(True, FTXSettings.TUNFreq, 1.0);
    if OldTUNLevel <> FTXSettings.TUNLevel then
    begin
      FDriveLevel := CalcDriveByte;
      if FWDSPReady then
        FDSPEngine.SetDriveLevel(EnsureRange(FTXSettings.TUNLevel, 0, 100) / 100.0);
      if Assigned(FNetwork) and FNetwork.Connected and FNetwork.Running then
      begin
        FNetwork.UpdateState(XvtrTranslate(FCenterFreq), TXTuneFreqHz, FDriveLevel,
                             FTransmitting, True, True, FTuning, FTwoTone);
        FNetwork.SendFullHP;
      end;
    end;
  end;
  // Правка «звуковых» полей — это правка активного профиля: отдельной кнопки
  // «сохранить» нет, профиль ведётся за живым состоянием (см. TTXProfile).
  StoreActiveTXProfile;
  if FDevConnected and Assigned(FSettings) then
  begin
    FSettings.SaveTX(FDevMAC, FTXSettings);
    FSettings.SaveTXProfiles(FDevMAC, FTXProfiles);
    FSettings.Save;
  end;
  if Notify then Changed(rfTXProfile);
end;

procedure TRadioController.StoreActiveTXProfile(Persist: Boolean = False);
// Живое состояние → активный профиль. Зовётся на каждой правке TX-настроек и
// на смене drive; Persist=False не трогает диск (drive ездит слайдером).
var Idx: Integer;
begin
  Idx := FTXProfiles.ActiveIdx;
  if (Idx < 0) or (Idx >= FTXProfiles.Count) then Exit;
  TXProfileCapture(FTXSettings, FDrivePercent, FTXProfiles.Items[Idx]);
  if Persist then SaveTXProfilesToConfig;
end;

procedure TRadioController.SelectTXProfile(Idx: Integer; Auto: Boolean);
var
  OldMic: Byte;
  NewDrive: Integer;
begin
  if (Idx < 0) or (Idx >= FTXProfiles.Count) then Exit;
  // Уходящий профиль дописываем живыми значениями — ручные правки не теряются.
  StoreActiveTXProfile;
  OldMic   := BuildMicLineSelectByte;
  NewDrive := FDrivePercent;
  FTXProfiles.ActiveIdx := Idx;
  TXProfileApply(FTXProfiles.Items[Idx], FTXSettings, NewDrive);
  ApplyTXSettingsToDSP;
  // Маршрутизация микрофона живёт в железе (DUC Specific byte 50) — при смене
  // Mic In/Line In/boost/bias пакет надо перепослать, WDSP тут ни при чём.
  if BuildMicLineSelectByte <> OldMic then SendDUCSpecificFromSettings;
  // SetDrive пересчитает калиброванный байт, толкнёт WDSP/Pluto и HP-кадр;
  // зовём безусловно — при активном TUN уровень берётся из TUNLevel профиля.
  SetDrive(NewDrive);
  if FTuning and FWDSPReady then
    FDSPEngine.SetDriveLevel(EnsureRange(FTXSettings.TUNLevel, 0, 100) / 100.0);
  // ★Ручной выбор ЗАПОМИНАЕТСЯ за парой «источник передачи + его модуляция» —
  // это и есть обещанное «профиль помнится по виду модуляции». Без этого в
  // таблице оказывалось только то, что назначено пилюлей во флаге, а профиль,
  // выбранный дропдауном в блоке TX, нигде не оставался следа.
  if not Auto then BindTXProfileToCurrentMode(Idx);
  if FDevConnected and Assigned(FSettings) then
  begin
    FSettings.SaveTX(FDevMAC, FTXSettings);
    FSettings.SaveTXProfiles(FDevMAC, FTXProfiles);
    FSettings.Save;
  end;
  Changed(rfTXProfile);
end;

function TRadioController.AddTXProfile(const AName: string): Integer;
// Форк: новый профиль = текущий звук под новым именем, он же становится активным.
begin
  Result := -1;
  if FTXProfiles.Count >= TXPROF_MAX then Exit;
  Result := FTXProfiles.Count;
  TXProfileCapture(FTXSettings, FDrivePercent, FTXProfiles.Items[Result]);
  FTXProfiles.Items[Result].Name := Copy(Trim(AName), 1, 31);
  if FTXProfiles.Items[Result].Name = '' then
    FTXProfiles.Items[Result].Name := 'Profile ' + IntToStr(Result + 1);
  Inc(FTXProfiles.Count);
  FTXProfiles.ActiveIdx := Result;
  SaveTXProfilesToConfig;
  Changed(rfTXProfile);
end;

procedure TRadioController.RenameTXProfile(Idx: Integer; const AName: string);
var S: string;
begin
  if (Idx < 0) or (Idx >= FTXProfiles.Count) then Exit;
  S := Copy(Trim(AName), 1, 31);
  if S = '' then Exit;
  FTXProfiles.Items[Idx].Name := S;
  SaveTXProfilesToConfig;
  Changed(rfTXProfile);
end;

procedure TRadioController.DeleteTXProfile(Idx: Integer);
// Последний профиль не удаляем: список без активного элемента сломал бы
// авто-сохранение (StoreActiveTXProfile писать некуда).
var i: Integer;
begin
  if (Idx < 0) or (Idx >= FTXProfiles.Count) or (FTXProfiles.Count <= 1) then Exit;
  for i := Idx to FTXProfiles.Count - 2 do
    FTXProfiles.Items[i] := FTXProfiles.Items[i + 1];
  Dec(FTXProfiles.Count);
  // Привязки флагов — это ИНДЕКСЫ: после сдвига списка их надо переписать,
  // иначе слайс молча начнёт включать соседний профиль.
  RemapTXProfileBindings(Idx);
  if FTXProfiles.ActiveIdx > Idx then Dec(FTXProfiles.ActiveIdx)
  else if FTXProfiles.ActiveIdx = Idx then
  begin
    // Удалили активный: встаём на соседний и применяем его в эфир. ActiveIdx
    // гасим — иначе StoreActiveTXProfile внутри Select затрёт соседа живым
    // звуком удалённого профиля.
    FTXProfiles.ActiveIdx := -1;
    SelectTXProfile(EnsureRange(Idx, 0, FTXProfiles.Count - 1));
    Exit;                                  // SelectTXProfile уже сохранил и отрендерил
  end;
  SaveTXProfilesToConfig;
  Changed(rfTXProfile);
end;

procedure TRadioController.RemapTXProfileBindings(Deleted: Integer);
// Профиль удалён из середины списка: привязку на него снимаем, привязки на
// профили правее — сдвигаем. Deleted < 0 = список заменён целиком, снимаем все.

  procedure Fix(var B: Integer);
  begin
    if B < 0 then Exit;
    if Deleted < 0 then B := -1
    else if B = Deleted then B := -1
    else if B > Deleted then Dec(B);
  end;

var i, m: Integer;
begin
  for m := 0 to CFG_MODE_MAX do Fix(FTXProfiles.MainByMode[m]);
  for i := 0 to High(FSlices) do
    if FSlices[i].Active then
      for m := 0 to CFG_MODE_MAX do Fix(FSlices[i].TXProfByMode[m]);
end;

procedure TRadioController.BindTXProfileToCurrentMode(Idx: Integer);
// Пишет профиль в ячейку «текущий TX-источник + его модуляция». Отдельно от
// SetSliceTXProfile: там ещё и применение в эфир, а здесь профиль уже применён
// (нас зовут из SelectTXProfile), повторный ApplyBoundTXProfile был бы циклом.
var m, Slot: Integer;
begin
  if (Idx < 0) or (Idx >= FTXProfiles.Count) then Exit;
  m := FlagMode(FTxSliceId);
  if FTxSliceId <= 0 then
    FTXProfiles.MainByMode[m] := Idx    // персист — общим блоком в SelectTXProfile
  else
  begin
    Slot := FindSliceIndex(FTxSliceId);
    if Slot >= 0 then FSlices[Slot].TXProfByMode[m] := Idx;
  end;
end;

function TRadioController.FlagMode(SliceId: Integer): Integer;
// Режим, в котором сейчас стоит этот флаг: у главного — FMode, у слайса — свой.
var idx: Integer;
begin
  Result := FMode;
  if SliceId > 0 then
  begin
    idx := FindSliceIndex(SliceId);
    if idx >= 0 then Result := FSlices[idx].Mode;
  end;
  Result := EnsureRange(Result, 0, CFG_MODE_MAX);
end;

function TRadioController.SliceTXProfile(SliceId: Integer): Integer;
// Привязка «этот источник передачи В ЭТОМ РЕЖИМЕ → этот профиль».
// -1 = не переключать. Ключ — режим самого флага, поэтому смена модуляции
// сама достаёт профиль, с которым в этом виде работали в прошлый раз.
var idx: Integer;
begin
  Result := -1;
  if SliceId <= 0 then
    Result := FTXProfiles.MainByMode[FlagMode(SliceId)]
  else
  begin
    idx := FindSliceIndex(SliceId);
    if idx >= 0 then Result := FSlices[idx].TXProfByMode[FlagMode(SliceId)];
  end;
  if (Result < 0) or (Result >= FTXProfiles.Count) then Result := -1;
end;

procedure TRadioController.SetSliceTXProfile(SliceId, ProfIdx: Integer);
// Пишем в ячейку ТЕКУЩЕГО режима этого флага: выбор запоминается за модуляцией.
var idx, m: Integer;
begin
  if (ProfIdx < -1) or (ProfIdx >= FTXProfiles.Count) then ProfIdx := -1;
  m := FlagMode(SliceId);
  if SliceId <= 0 then
  begin
    if FTXProfiles.MainByMode[m] = ProfIdx then Exit;
    FTXProfiles.MainByMode[m] := ProfIdx;
    SaveTXProfilesToConfig;
  end
  else
  begin
    idx := FindSliceIndex(SliceId);
    if (idx < 0) or (FSlices[idx].TXProfByMode[m] = ProfIdx) then Exit;
    FSlices[idx].TXProfByMode[m] := ProfIdx;
    // Персист слайсов делает MainForm на общем сохранении панов — здесь только
    // состояние. Привязку главного пишем сразу: она живёт в секции профилей.
  end;
  // Привязали источник, который передаёт прямо сейчас, — применяем немедленно,
  // иначе профиль подхватился бы только при следующей смене TX-источника.
  if SliceId = FTxSliceId then ApplyBoundTXProfile;
  Changed(rfTXProfile);
end;

procedure TRadioController.SetSliceTXProfileTable(SliceId: Integer;
  const T: TTXProfModeTable);
// Восстановление сохранённой таблицы целиком (персист слайсов/панов).
var idx, m: Integer;
begin
  if SliceId <= 0 then
  begin
    FTXProfiles.MainByMode := T;
    Exit;
  end;
  idx := FindSliceIndex(SliceId);
  if idx < 0 then Exit;
  for m := 0 to CFG_MODE_MAX do
    if (T[m] >= 0) and (T[m] < FTXProfiles.Count) then
      FSlices[idx].TXProfByMode[m] := T[m]
    else
      FSlices[idx].TXProfByMode[m] := -1;
end;

function TRadioController.SliceTXProfileTable(SliceId: Integer): TTXProfModeTable;
var idx: Integer;
begin
  ClearTXProfModeTable(Result);
  if SliceId <= 0 then Result := FTXProfiles.MainByMode
  else
  begin
    idx := FindSliceIndex(SliceId);
    if idx >= 0 then Result := FSlices[idx].TXProfByMode;
  end;
end;

procedure TRadioController.ApplyBoundTXProfile;
// Активный профиль ОДНОЗНАЧНО определяется парой «TX-источник + его модуляция»:
//   ячейка таблицы заполнена  → её профиль;
//   ячейка пуста (−1)         → базовый профиль (первый в списке).
// Фолбэк обязателен: без него профиль прошлого режима оставался висеть в новом
// (симптом «перешёл из FM в USB, а звучит FM»). Правило детерминированное —
// работает и после перезапуска, в отличие от «вернуть, что было до автоматики».
var P, M: Integer;
begin
  if FTXProfiles.Count = 0 then Exit;
  M := FlagMode(FTxSliceId);
  // В DMR передатчика нет, в FM RAW профиль ни на что не влияет
  // (ApplyTXModeSettings глушит там всю обработку) — активный не трогаем вовсе,
  // иначе заход в эти режимы молча перебивал бы выбор оператора.
  if (M = MODE_DMR) or (M = MODE_FMRAW) then Exit;
  P := SliceTXProfile(FTxSliceId);
  if P < 0 then P := 0;
  if P <> FTXProfiles.ActiveIdx then SelectTXProfile(P, True);
end;

procedure TRadioController.ResetTXProfilesToFactory;
// Пересобирает заводской набор поверх текущего списка. База — ЖИВЫЕ настройки,
// поэтому «Default» = микрофонный вход, полоса, динамика и мощность как сейчас,
// но с заводским (ровным) EQ. Единственный способ доехать до обновлённых
// заводских профилей с уже записанной секцией tx_profiles: она читается, а не
// пересоздаётся.
begin
  DefaultTXProfiles(FTXSettings, FDrivePercent, FTXProfiles);
  RemapTXProfileBindings(-1);   // список другой — старые привязки бессмысленны
  // ActiveIdx гасим перед Select: иначе StoreActiveTXProfile внутри него
  // впишет в свежий «Default» живой EQ и сброс тембра тут же отменится.
  FTXProfiles.ActiveIdx := -1;
  // Применяем «Default» в эфир — иначе живой EQ остался бы кривым, а профиль
  // говорил бы «ровный», и первая же правка TX-настроек вернула бы кривую.
  SelectTXProfile(0);   // сам сохранит и отрендерит
end;

procedure TRadioController.ApplyTXSettingsToDSP;
begin
  if not FWDSPReady then Exit;
  // Для openHPSDR Protocol 2 CFIR постоянно включён:
  // он компенсирует АЧХ FPGA CIC-интерполятора TX. Pluto этого фильтра не
  // имеет, поэтому при смене бэкенда явно выключаем его.
  FDSPEngine.SetTXCFIR(BackendCaps.Kind = bkHPSDR);
  // Mic source — выставляем значение по умолчанию для MOX-кнопки;
  // при реальном PTT SetMOX скорректирует источник.
  FDSPEngine.SetTXMicSource(DefaultMicSource);
  FDSPEngine.SetMicGain(FTXSettings.MicGainDB);
  FDSPEngine.SetTXFilterFull(FTXSettings.FilterLow, FTXSettings.FilterHigh,
                              FTXSettings.FilterNC, FTXSettings.FilterMP,
                              FTXSettings.FilterWindow);
  FDSPEngine.SetTXCompressor(FTXSettings.CompressorOn, FTXSettings.CompressorGain);
  FDSPEngine.SetTXLeveler(FTXSettings.LevelerOn, FTXSettings.LevelerTop,
                           FTXSettings.LevelerDecay);
  FDSPEngine.SetTXALC(FTXSettings.ALCOn, FTXSettings.ALCMaxGain,
                       FTXSettings.ALCDecay);
  FDSPEngine.SetTXPhaseRot(FTXSettings.PhaseRotOn, FTXSettings.PhaseRotStages,
                            FTXSettings.PhaseRotFreq);
  FDSPEngine.SetTXEQ(FTXSettings.EQOn, FTXSettings.EQNumBands,
                      FTXSettings.EQGains, FTXSettings.EQFreqs);
  FDSPEngine.SetTXAMCarrierLevel(FTXSettings.AMCarrierLevel);
  FDSPEngine.SetTXFMParams(FTXSettings.FMDeviation, FTXSettings.FMLowCut,
                            FTXSettings.FMHighCut, FTXSettings.FMEmphPosition);
  FDSPEngine.SetTXCTCSS(FTXSettings.CTCSSOn, FTXSettings.CTCSSFreq);
  // TX display
  FDSPEngine.SetTXFFTParams(FTXSettings.TXFFTSize, FTXSettings.TXWindowType);
  FDSPEngine.SetTXSpectrumDisplay(FTXSettings.TXSpecDetector,
                                   FTXSettings.TXSpecAvgMode,
                                   FTXSettings.TXSpecAvgTimeMS);
  FDSPEngine.SetTXWaterfallDisplay(FTXSettings.TXWfDetector,
                                    FTXSettings.TXWfAvgMode,
                                    FTXSettings.TXWfAvgTimeMS);
  // Reapply the mode overlay last: FM RAW must stay flat even when the user
  // edits and saves ordinary voice-chain settings while this mode is active.
  FDSPEngine.SetTXMode(ActiveTXMode);
  // PureSignal-параметры calcc (mox/loop/tx delay, ptol, pin/map/stbl, SetPk)
  ApplyPSSettingsToDSP;
end;

procedure TRadioController.SetMOX(On_: Boolean);
var
  WasTransmitting: Boolean;
begin
  // DMR support is receive-only. This guard also covers CAT, web and hardware
  // PTT. FM RAW is a flat external-modem transmit path.
  if On_ and (ActiveTXMode = MODE_DMR) then Exit;
  // Safety: бэнды с DoNotTx (Alex) и RX-only трансвертер — см. TXProhibited.
  if On_ and TXProhibited then Exit;
  WasTransmitting := FTransmitting;
  // Снятие MOX руками обрывает программную передачу текста: оператор решил
  // прекратить — ключ обязан отпуститься немедленно.
  if (not On_) and CWXBusy then CWXAbort;
  FTransmitting := On_;
  // 2TON: прямое снятие MOX (кнопкой/CAT/web) гасит и двухтональник.
  if (not On_) and FTwoTone then
  begin
    FTwoTone := False;
    if FWDSPReady and Assigned(FDSPEngine) then
      FDSPEngine.SetTXTwoTone(False, 0, 0);
    Changed(rfTwoTone);
  end;
  if On_ then
  begin
    FDriveLevel := CalcDriveByte;
    ApplyAntPorts;   // AD936x: страховка — верный разъём до выхода в эфир
  end;
  FDUCPendingCount := 0;   // сброс TX DUC-очереди на смене TX-состояния
  // Сетевая PTT-последовательность TX-on: сначала реле (Alex bit27=1, drive) без
  // PTT, затем PTT=1 — переключаем реле раньше передачи.
  if FTransmitting and Assigned(FNetwork) and FNetwork.Connected and FNetwork.Running then
  begin
    FNetwork.ClearDUCIQQueue;
    FNetwork.UpdateState(XvtrTranslate(FCenterFreq), TXTuneFreqHz,
                         FDriveLevel, True, True, True, FTuning, FTwoTone);
    FNetwork.SendFullHP;    // 1й пакет: Alex bit27=1 (T/R relay), drive — без PTT
    FNetwork.SendPTT(True); // 2й пакет: PTT=1 после переключения реле
    // PureSignal: перевести feedback-DDC0/1 в TX-конфигурацию (частоты = TX,
    // sync, Alex bit18). No-op если PS не вооружён (гейт внутри бэкенда).
    FNetwork.SetPureSignalTX(True);
    // Pre-roll: THROTTLE (2000, порог FPGA — DAC не стартует с пустого буфера)
    // + ДВА WDSP-блока (2×2048) нулевых пар подушкой в очередь. Раньше было 3
    // блока: обоснование «2 не хватало — щелчок через ~0.5с» теперь под вопросом,
    // т.к. тот щелчок по времени (0.5с, не ~21мс) похож не на underrun подушки, а
    // на bfo-глитч тона (устранён коммитом f2d0519). Снижено до 2 блоков ради
    // латентности старта TX (~10мс). Если под нагрузкой DUP / быстром пере-PTT
    // вернётся щелчок РОВНО на старте — это реальный underrun, вернуть 8144.
    // ★При модуляции из TCI 6096 пар (31.75 мс) не хватает: клиент отвечает на
    // первый маркер только через ~33 мс, плюс наша задержка на фронте — радио
    // остаётся без IQ и даёт всплеск на старте КАЖДОЙ передачи. Растягиваем
    // pre-roll до измеренного аванса клиента (в парах @192 кГц = кадры × 4).
    // Путь микрофона радио, CW и web этого не касается: там источник свой.
    if FTCIMicRequested and (FTCITxLeadFrames > 0) then
      FNetwork.PrimeDUCIQ(Max(6096, FTCITxLeadFrames * 4))
    else
      FNetwork.PrimeDUCIQ(6096);
  end;
  if FWDSPReady and Assigned(FDSPEngine) then
  begin
    // Mic source автоматически по источнику TX. FM RAW always uses the
    // configured default input (normally a virtual PortAudio cable), even if
    // a web client is connected or hardware PTT keyed the transmitter.
    if FTransmitting then
    begin
      if ActiveTXMode = MODE_FMRAW then
        FDSPEngine.SetTXMicSource(DefaultMicSource)
      // TCI впереди web: клиент попросил модуляцию из своего потока ЯВНО
      // (TRX:0,true,tci), а web-микрофон включается самим фактом подключения
      // браузера. Явная просьба сильнее умолчания.
      else if FTCIMicRequested then
      begin
        FTCIMicActive := True;
        FDSPEngine.SetTXMicSource(txmsWeb);   // тот же ринг внешней подачи
      end
      else if FWebClientActive then
      begin
        FWebMicActive := True;
        FDSPEngine.SetTXMicSource(txmsWeb);
      end
      else if FHWPTTActive then
        FDSPEngine.SetTXMicSource(txmsRadio)
      else
        FDSPEngine.SetTXMicSource(DefaultMicSource);
    end
    else if FWebMicActive or FTCIMicActive then
    begin
      FWebMicActive := False;
      FTCIMicActive := False;
      FDSPEngine.SetTXMicSource(DefaultMicSource);
    end;
    // Сброс бэклога sound-card микрофона на RX→TX: пока шёл приём, ринг
    // PortAudio наполнился до упора (~200мс). Без сброса весь этот «старый»
    // звук бёрстом уходит в тракт: стоячая задержка ~190мс в DUC-очереди
    // + в эфир передаётся аудио, записанное ДО нажатия PTT (на TUN оно
    // складывается с тоном поверх полной шкалы → клип-всплеск).
    if FTransmitting and (not WasTransmitting) and Assigned(ActiveMicInput)
       and ActiveMicInput.IsOpen then
      ActiveMicInput.Flush;
    // В non-DUP RX-IQ пакеты дропаются на входе DSP-потока во время TX, чтобы
    // TX leakage не накапливался в RXA pipeline / FFT-истории анализатора.
    // ★Телеграф — исключение: приёмник должен жить, иначе с ним умолкает и
    // программный сайдтон (он подмешивается в RX-аудио), а при break-in
    // пропадал бы эфир между посылками. От утечки прикрывает ATT on TX.
    FDSPEngine.KeepRXDuringTX := FDisplayDuplex or
                                 (ActiveTXMode in [MODE_CWL, MODE_CWU]);
    // Мультислайс-TX: передающий тракт под режим слайса-источника (SetMode мог
    // оставить главный режим). Ставим перед стартом TX-потока.
    if FTransmitting then FDSPEngine.SetTXMode(ActiveTXMode);
    // PureSignal: mox-флаг calcc (гейтит сбор данных калибровки в pscc).
    FDSPEngine.PSSetMox(FTransmitting);
    // ★Телеграф: голосовой TXA не запускается ВООБЩЕ. В WDSP режим TXA_CWL —
    // буквально ветка SSB, поэтому
    // без этого гарда MOX в CW отправлял бы в эфир микрофон на 2.9 кГц.
    // Несущую в телеграфе даёт прошивка: кейер, бит CWX или тон TUN.
    // Гард по РЕЖИМУ, а не по CWTXActive: даже с выключенным кейером прошивки
    // голос в телеграфном участке недопустим (тогда MOX = только PTT).
    if FTransmitting and (ActiveTXMode in [MODE_CWL, MODE_CWU])
       and (not FTuning) and (not FTwoTone) then
      FDSPEngine.SetTXRun(False)
    else
      FDSPEngine.SetTXRun(FTransmitting);
    if WasTransmitting and (not FTransmitting) and (not FDisplayDuplex) then
    begin
      FDSPEngine.FlushRX;
      if Assigned(FAudioOut) then
        FAudioOut.Clear;
      {$IFDEF WINDOWS}
      // Windows: на TX→RX дропаем И RX-IQ, И аудио-выход — radio FPGA TX-buffer +
      // PA slew-down дотравливают «хвост» (на Windows тянется 200–500мс).
      FDSPEngine.BeginPostTXMute(250);
      {$ENDIF}
    end;
    // SetTXRun переключает FActiveDisplayID на TX_DISP_ID; при DUP возвращаем
    // источник на RX, чтобы видеть приём во время передачи.
    if FTransmitting and FDisplayDuplex then
      FDSPEngine.SetDisplaySourceTX(False);
  end;
  // Сетевая PTT-последовательность TX-off: сначала снимаем PTT, затем реле RX.
  if (not FTransmitting) and Assigned(FNetwork) and FNetwork.Connected and FNetwork.Running then
  begin
    FNetwork.SendPTT(False); // 1й пакет: PTT=0 — сначала снимаем PTT
    // PureSignal: вернуть DDC0/1 в RX-конфигурацию (убрать feedback-DDC).
    FNetwork.SetPureSignalTX(False);
    FNetwork.UpdateState(XvtrTranslate(FCenterFreq), TXTuneFreqHz,
                         FDriveLevel, False, True, True, FTuning, FTwoTone);
    FNetwork.ClearDUCIQQueue;
    FNetwork.SendFullHP;     // 2й пакет: Alex bit27=0 (T/R relay RX)
  end;
  ApplyPlutoTxAtten;   // Pluto: TX-мощность по drive на передаче, OFF на приёме
  ApplyActiveVolume;   // T/R: переключаем громкость RX ↔ TX-монитор (если monitoring)
  // При выключенном break-in локальный кейер вооружён только вместе с MOX
  // (оператор держит T/R руками) — вооружение обязано пойти за этой кнопкой.
  SyncCWKeyer;
  Changed(rfTransmitting);
end;

procedure TRadioController.SetTune(On_: Boolean);
begin
  if On_ = FTuning then Exit;
  if On_ and (ActiveTXMode in [MODE_DMR, MODE_FMRAW]) then Exit;
  // Safety: те же блокировки, что и в SetMOX — TUN это TX через WDSP PostGen-тон,
  // он тоже должен молчать на бэндах с DoNotTx и на RX-only XVTR.
  if On_ and TXProhibited then Exit;
  if On_ and FTwoTone then SetTwoTone(False);   // TUN поверх 2TON — гасим тона
  if On_ then
  begin
    // Тон включаем ДО запуска TX-аудио, чтобы первые сэмплы уже шли с тоном.
    // В телеграфе тон = pitch: TX-частота уезжает на CWLOOffset (ActiveTXFreqHz),
    // и несущая настройки встаёт РОВНО на VFO, а не на VFO±тон.
    if FWDSPReady and Assigned(FDSPEngine) then
    begin
      if ActiveTXMode in [MODE_CWL, MODE_CWU] then
        FDSPEngine.SetTXTone(True, FCWSettings.Pitch, TUN_TONE_MAG)
      else
        FDSPEngine.SetTXTone(True, FTXSettings.TUNFreq, TUN_TONE_MAG);
    end;
    FTuning := True;
    FDriveLevel := CalcDriveByte;   // CalcDriveByte увидит FTuning=True (TUN-уровень)
    ApplyWDSPDrive(FTXSettings.TUNLevel);
    if not FTransmitting then SetMOX(True);   // PTT + run TXA-канал
  end
  else
  begin
    if FTransmitting then SetMOX(False);
    if FWDSPReady and Assigned(FDSPEngine) then
      FDSPEngine.SetTXTone(False, 0, 0);
    FTuning := False;
    FDriveLevel := CalcDriveByte;             // вернуть обычный drive%
    ApplyWDSPDrive(FDrivePercent);
    if Assigned(FNetwork) and FNetwork.Connected and FNetwork.Running then
    begin
      FNetwork.UpdateState(XvtrTranslate(FCenterFreq), TXTuneFreqHz,
                           FDriveLevel, FTransmitting, True, True, FTuning, FTwoTone);
      FNetwork.SendFullHP;
    end;
  end;
  ApplyPlutoTxAtten;   // финальное состояние att по FTuning/FTransmitting (Pluto)
  Changed(rfTuning);
end;

// ===========================================================================
//  Телеграф
// ===========================================================================

function TRadioController.RadioKeyed: Boolean;
// Выдержка после последней посылки: точка на 20 WPM длится 60 мс, а индикаторы
// перерисовываются 10 раз в секунду — без неё бейджи и метр мигали бы внутри
// одного знака. Держим 8 точек, но не меньше 500 мс: на медленной скорости
// межсловный интервал (7 точек) сам по себе длиннее полусекунды, и фиксированная
// выдержка гасила бы индикацию между словами.
var Hold: QWord;
begin
  Result := FTransmitting;
  if Result then Exit;
  if not CWTXActive then Exit(False);
  if FCWKeyedAt = 0 then Exit(False);
  // Выдержка = hang прошивки + запас, но не меньше 8 точек: полурывок semi-QSK.
  // По ней же переключается реле T/R, поэтому щёлкать оно обязано РАЗ на
  // передачу, а не на каждую посылку.
  Hold := 8 * (1200 div EnsureRange(FCWSettings.Speed, 5, 60));
  if Hold < QWord(FCWSettings.HangTimeMS) + 250 then
    Hold := QWord(FCWSettings.HangTimeMS) + 250;
  if Hold < 500 then Hold := 500;
  Result := (GetTickCount64 - FCWKeyedAt) < Hold;
end;

procedure TRadioController.ServiceCWKeyed;
// UI про телеграфную передачу иначе не узнает вовсе: SetMOX в телеграфе не
// вызывается, а значит и rfTransmitting никто не шлёт. Фронт RadioKeyed (с
// выдержкой) — единственный сигнал «покрасить бейджи и полосы».
// Зовётся из разбора HP-статуса (openHPSDR, быстро) и с UI-таймера — у Pluto
// статуса нет вообще, а телеграф там локальный.
begin
  // Вооружение кейера обязано быть САМОВОССТАНАВЛИВАЮЩИМСЯ: у openHPSDR это
  // делает разбор HP-статуса, а у Pluto статуса нет — значит здесь. Разоружить
  // может что угодно (бэнд, слот трансвертера, выход из TUN), перечислять такие
  // точки поимённо — гарантия однажды пропустить очередную.
  SyncCWKeyer;
  if FCWKeyedShown = RadioKeyed then Exit;
  FCWKeyedShown := RadioKeyed;
  // ★Железная обвязка обязана идти за фактическим ключом: реле T/R (Alex
  // bit 27), TX-антенна, bypass/Ext1 и выходы OC (ими ключуется усилитель)
  // считаются в бэкенде от состояния передачи. Без этого пути прошивка гнала бы
  // несущую, пока Alex стоит в ПРИЁМНОЙ конфигурации. У локального кейера то же
  // делает сессия (CWLocalSession) — синхронно, до первой посылки; здесь остаётся
  // хвост выдержки, когда реле пора отпустить.
  PushNetworkState;
  Changed(rfTransmitting);
end;

function TRadioController.CWSettings: TCWSettings;
begin
  Result := FCWSettings;
end;

procedure TRadioController.SetCWSettings(const C: TCWSettings);
// Одна дверь для любой правки телеграфа: pitch уезжает в движок (там он
// двигает гетеродин и полосу всех CW-каналов), кейер/сайдтон — в прошивку,
// скорость — в поток программной передачи.
begin
  FCWSettings := C;
  FCWSettings.Pitch           := EnsureRange(FCWSettings.Pitch, 200, 1200);
  FCWSettings.Speed           := EnsureRange(FCWSettings.Speed, 5, 60);
  FCWSettings.Weight          := EnsureRange(FCWSettings.Weight, 33, 66);
  FCWSettings.SidetoneHWLevel := EnsureRange(FCWSettings.SidetoneHWLevel, 0, 127);
  FCWSettings.SidetoneSWLevel := EnsureRange(FCWSettings.SidetoneSWLevel, 0, 100);
  if FWDSPReady and Assigned(FDSPEngine) then
  begin
    FDSPEngine.SetCWPitch(FCWSettings.Pitch);
    FDSPEngine.SetCWSpeed(FCWSettings.Speed);   // ширина полосы CW на экране
  end;
  if Assigned(FCWSender) then
    FCWSender.SetSpeed(FCWSettings.Speed, FCWSettings.Weight);
  SendDUCSpecificFromSettings;
  // Локальный кейер: параметры + вооружение (галку «кейер в программе», как и
  // запрет передачи, разбирает SyncCWKeyer — одна дверь на всё).
  PushCWLocalConfig;
  SyncCWKeyer;
  // Декодер слушает на том же pitch и стартует адаптацию со своей скорости.
  // Создаём лениво прямо здесь: настройки — единственная дверь, через которую
  // он включается (галка в настройках, кнопка в терминале, загрузка профиля).
  if FCWSettings.Decoder and (not Assigned(FCWDec)) then
  begin
    FCWDec := TCWDecoder.Create;
    FCWDec.SetSpeedHint(FCWSettings.Speed);
  end;
  if Assigned(FCWDec) then
  begin
    FCWDec.SetPitch(FCWSettings.Pitch);
    FCWDec.Enabled := FCWSettings.Decoder;
  end;
  // Тон TUN в телеграфе равен pitch (несущая обязана лечь на VFO, см. SetTune):
  // если крутят pitch прямо на настройке — обновляем живьём.
  if FTuning and FWDSPReady and Assigned(FDSPEngine)
     and (ActiveTXMode in [MODE_CWL, MODE_CWU]) then
    FDSPEngine.SetTXTone(True, FCWSettings.Pitch, TUN_TONE_MAG);
  if FDevConnected and Assigned(FSettings) then
  begin
    FSettings.SaveCW(FDevMAC, FCWSettings);
    FSettings.Save;
  end;
  // Телеграф правят и оператор, и CAT, и TCI, а скорость с задержкой макросов —
  // величины общие для радио: об их смене обязаны узнать все фронтенды, а не
  // только тот, кто её заказал.
  Changed(rfCWSettings);
end;

procedure TRadioController.SyncCWKeyer;
// Байт 5 зависит от режима ИСТОЧНИКА передачи, поэтому каждая смена режима или
// TX-слайса обязана его перепослать: вне телеграфа он должен стать нулевым,
// иначе прошивка поднимет PTT на замыкание ключа посреди SSB.
var
  WasArmed: Boolean;
begin
  // Дёшево и идемпотентно: пара сравнений плюс ранний выход внутри
  // SetCWKeyerArmed. Поэтому зовётся не только на смене режима/источника, но и
  // из разбора HP-статуса — вооружение кейера обязано быть САМОВОССТАНАВЛИВАЮЩИМСЯ.
  // Разоружить его может что угодно: заход в RX-only слот трансвертера, переход
  // на бэнд с DoNotTx, правка Alex. Перечислять такие точки поимённо — гарантия
  // однажды пропустить очередную (ровно так уже вышло с DUC-частотой и мощностью).
  if (not (ActiveTXMode in [MODE_CWL, MODE_CWU]))
     and ((Assigned(FCWSender) and FCWSender.Busy)
          or (Assigned(FCWElem) and FCWElem.Busy)) then CWXAbort;
  // Вооружённый кейер = железо может выйти в эфир само ⇒ бэкенд обязан класть
  // уровень мощности в КАЖДЫЙ HP-кадр, а не только на передаче. Локальный
  // генератор — та же история: FTransmitting в телеграфе не поднимается, а
  // несущая идёт.
  if Assigned(FNetwork) then FNetwork.SetCWKeyerArmed(CWTXActive);

  // ---- Локальный кейер ----------------------------------------------------
  // Вооружение самовосстанавливающееся, как и у прошивки: SyncCWKeyer зовётся
  // из разбора статуса и со всех смен режима/источника/бэнда.
  WasArmed := FCWLocalArmed;
  // Без break-in передачей владеет оператор: ключ имеет право звучать только
  // когда MOX уже поднят — иначе несущая ушла бы в приёмную обвязку.
  // ★TUN и 2TON исключены намеренно: там несущую даёт TXA, и в DUC-очередь уже
  // пишет поток WDSP. Двух продюсеров одной очереди быть не должно.
  FCWLocalArmed := CWTXActive and CWLocalSource
                   and FDevConnected and FRunning
                   and (FCWSettings.BreakIn or FTransmitting)
                   and (not FTuning) and (not FTwoTone);
  if FCWLocalArmed and (not Assigned(FCWLocal)) then EnsureCWLocal;
  if Assigned(FCWLocal) then
  begin
    if FCWLocalArmed then PushCWLocalConfig;
    FCWLocal.SetArmed(FCWLocalArmed);
  end;
  if Assigned(FCWKeyPort) then
    if not FCWLocalArmed then CWKeyPortEvent(False, False);
  // Частота DUC/LO зависит от вооружения (сдвиг несущей) — на фронте
  // перекладываем железо, иначе манипуляция уехала бы от VFO на сдвиг.
  if WasArmed <> FCWLocalArmed then PushNetworkState;

  if CWOptionsByte <> FLastCWOptions then SendDUCSpecificFromSettings;
end;

procedure TRadioController.EnsureCWLocal;
// Ленивое создание: у тех, кто телеграфом не пользуется, лишних нитей нет.
begin
  if not Assigned(FCWLocal) then
  begin
    FCWLocal := TCWLocalKeyer.Create;
    FCWLocal.OnIQ      := CWLocalIQ;
    FCWLocal.OnSession := CWLocalSession;
    FCWLocal.OnTick    := CWLocalTick;
  end;
  if not Assigned(FCWKeyPort) then
  begin
    FCWKeyPort := TCWKeyPort.Create;
    FCWKeyPort.OnKey := CWKeyPortEvent;
  end;
  PushCWLocalConfig;
end;

procedure TRadioController.PushCWLocalConfig;
// Настройки → генератор и порт манипулятора. Дёшево и идемпотентно: зовётся из
// SyncCWKeyer (то есть с каждой смены режима/бэнда) и из SetCWSettings.
var
  C: TCWLocalConfig;
  P: TCWKeyPortConfig;
begin
  if not Assigned(FCWLocal) then Exit;
  FillChar(C, SizeOf(C), 0);
  if FWDSPReady and Assigned(FDSPEngine) then C.Rate := FDSPEngine.TXSampleRate
  else C.Rate := TX_SAMPLE_RATE;
  C.OffsetHz := CWCarrierOffsetHz;
  // Уровень несущей. У Pluto мощность задаёт аттенюатор, цифровой уровень
  // держим на максимуме; у openHPSDR цифровой drive работает вместе с
  // байтом 345 — так же, как WDSP-драйв голосового тракта (ApplyWDSPDrive).
  if IsPluto then C.Amplitude := 0.99
  else C.Amplitude := 0.99 * EnsureRange(FDrivePercent, 0, 100) / 100.0;
  C.WPM       := FCWSettings.Speed;
  C.Weight    := FCWSettings.Weight;
  C.RampMS    := FCWSettings.RampMS;
  case FCWSettings.KeyerMode of
    1: C.Mode := clmIambicA;
    2: C.Mode := clmIambicB;
  else C.Mode := clmStraight;
  end;
  C.Reverse   := FCWSettings.ReverseKeys;
  C.Strict    := FCWSettings.StrictSpacing;
  C.BreakIn   := FCWSettings.BreakIn;
  C.HangMS    := FCWSettings.HangTimeMS;
  // Реле T/R, LO передатчика (Pluto гасит его на приёме) и аттенюатор успевают
  // встать за это окно. Ноль у прошивки означает «железо знает само», у нас
  // ждать некому — держим минимум.
  C.RFDelayMS := Max(FCWSettings.RFDelayMS, 10);
  C.Sidetone  := FCWSettings.SidetoneSW and (FCWSettings.SidetoneSWLevel > 0);
  FCWLocal.Configure(C);

  if Assigned(FCWKeyPort) then
  begin
    FillChar(P, SizeOf(P), 0);
    P.Enabled  := FCWSettings.KeyPortEnabled and (FCWSettings.KeyPort <> '');
    P.Port     := FCWSettings.KeyPort;
    P.DotLine  := TCWKeyLine(EnsureRange(FCWSettings.KeyDotLine,  0, 3));
    P.DashLine := TCWKeyLine(EnsureRange(FCWSettings.KeyDashLine, 0, 3));
    P.Invert   := FCWSettings.KeyInvert;
    P.PowerDTR := FCWSettings.KeyPowerDTR;
    P.PowerRTS := FCWSettings.KeyPowerRTS;
    FCWKeyPort.Configure(P);
  end;
end;

procedure TRadioController.CWLocalIQ(const Buf: array of Double; Count: Integer);
// Поток генератора. Тот же маршрут, что у WDSP TXA (OnTXIQ): пары копятся в
// DUC-пакеты и уходят в бэкенд. Голосовой тракт в телеграфе стоит, так что
// продюсер тут ровно один.
begin
  OnTXIQ(Buf, Count);
end;

procedure TRadioController.CWLocalSession(Active: Boolean);
// Поток генератора. Сессия = одна передача (не одна посылка!): поднимаем PTT,
// реле T/R с антенной и OC, аттенюатор Pluto — и держим до конца hang.
// ★FTransmitting НЕ трогаем: в телеграфе передающий тракт WDSP не запускается,
// а вся индикация идёт через RadioKeyed (см. ApplyHPStatus/ServiceCWKeyed).
begin
  if Active then FCWKeyedAt := GetTickCount64;
  FCWLocalTX := Active;
  if Assigned(FNetwork) and FNetwork.Connected and FNetwork.Running then
  begin
    if Active then
    begin
      PushNetworkState;          // RadioKeyed=True: Alex bit27, TX-антенна, OC
      FNetwork.SendPTT(True);    // у Pluto здесь же поднимается LO передатчика
    end
    else
    begin
      // ★Если оператор ДЕРЖИТ MOX (ручной T/R поверх break-in), снимать передачу
      // не наше дело — иначе конец посылки выбрасывал бы его из эфира.
      if not FTransmitting then
      begin
        FNetwork.SendPTT(False);
        FNetwork.ClearDUCIQQueue;
      end;
      FCWKeyedAt := GetTickCount64;   // выдержка RadioKeyed идёт от КОНЦА сессии
      PushNetworkState;
    end;
  end;
  ApplyPlutoTxAtten;
  // ★Индикацию отсюда НЕ дёргаем: Invoke у GUI — это TThread.Synchronize, то
  // есть блокировка потока генератора до кадра UI. Мало того что она вклинилась
  // бы в путь к эфиру, так ещё и давала бы классический клинч на выходе из
  // программы (UI ждёт WaitFor генератора, генератор — UI). Фронты снимет
  // ServiceCWKeyed с таймера хозяина: для бейджа задержка в кадр незаметна,
  // а выдержка RadioKeyed всё равно не меньше 500 мс.
end;

procedure TRadioController.CWLocalTick;
// Поток генератора, на каждом блоке (1.25 мс): держим «железо в эфире» живым,
// иначе выдержка RadioKeyed истекла бы посреди длинного сообщения.
begin
  FCWKeyedAt := GetTickCount64;
end;

procedure TRadioController.CWKeyPortEvent(Dot, Dash: Boolean);
// Поток опроса порта. Касание манипулятора обрывает программную передачу —
// оператор всегда может вклиниться (то же правило, что и у кейера прошивки).
begin
  if not Assigned(FCWLocal) then Exit;
  if (Dot or Dash) and FCWLocal.Busy then FCWLocal.AbortText;
  FCWLocal.Paddle(Dot, Dash);
end;

procedure TRadioController.CWXKeyEvent(Down: Boolean);
// Поток передачи, НЕ GUI. Обе операции потокобезопасны: SetCWXKey шлёт HP-пакет
// под своим локом бэкенда, FCWKeyDown читает DSP-поток.
begin
  FCWKeyDown := Down;
  if Assigned(FNetwork) and FNetwork.Connected and FNetwork.Running then
    FNetwork.SetCWXKey(Down);
end;

function TRadioController.CWXBusy: Boolean;
begin
  Result := (Assigned(FCWSender) and FCWSender.Busy)
         or (Assigned(FCWLocal)  and FCWLocal.Busy)
         or (Assigned(FCWElem)   and FCWElem.Busy);
end;

procedure TRadioController.CWElemKey(Down: Boolean);
// Поток проигрывателя. Чужая манипуляция — это ПРЯМОЙ КЛЮЧ, только с точными
// длительностями, поэтому и уходит она в те же две двери, что и ключ оператора:
// у Pluto — во вход прямого ключа локального генератора (он сам поднимает
// сессию, рисует огибающую и сайдтон), у openHPSDR — в бит CWX прошивки (тем же
// путём идёт и передача текста).
begin
  if CWLocalSource then
  begin
    if Assigned(FCWLocal) then FCWLocal.StraightKey(Down);
    Exit;
  end;
  CWXKeyEvent(Down);
end;

procedure TRadioController.CWKeyerElement(Mark: Boolean; Ms: Integer);
// Поток контроллера (адаптер TCI маршалит через Invoke). Ставим элемент в
// очередь — играет её отдельный поток по абсолютным дедлайнам.
begin
  if Ms <= 0 then Exit;
  // Тот же гейт, что у CWXSend: CWTXActive учитывает и режим, и запрет
  // передачи (RX-only слот трансвертера, бэнд с DoNotTx) — там кейер разоружён,
  // и трогать ключ незачем.
  if not CWTXActive then Exit;
  // У Pluto манипуляцию рисуем сами: генератор обязан быть заведён и вооружён,
  // иначе первый же элемент уйдёт в никуда (то же делает CWXSend).
  if CWLocalSource then
  begin
    EnsureCWLocal;
    SyncCWKeyer;
  end;
  if not Assigned(FCWElem) then FCWElem := TCWElemPlayer.Create(Self.CWElemKey);
  FCWElem.Enqueue(Mark, Ms);
end;

procedure TRadioController.CWXSend(const Text: string);
// Передача текста. Break-in ВКЛ — PTT поднимает прошивка сама (как на ключ);
// выключен — оператор держит MOX руками, как при ручном T/R.
begin
  if Text = '' then Exit;
  // CWTXActive, а не просто режим: он же учитывает запрет передачи (RX-only
  // слот трансвертера, бэнд с DoNotTx) — там кейер разоружён, и дёргать бит
  // CWX незачем.
  if not CWTXActive then Exit;
  // Локальный генератор сам держит тайминги В СЭМПЛАХ — текст уходит ему как
  // есть; поток TCWSender (бит CWX прошивки) при этом не нужен вовсе.
  if CWLocalSource then
  begin
    EnsureCWLocal;
    SyncCWKeyer;              // вооружиться, если пришли сюда первым делом
    FCWLocal.SendText(Text);
    Exit;
  end;
  if not Assigned(FCWSender) then
    FCWSender := TCWSender.Create(Self.CWXKeyEvent);
  FCWSender.SetSpeed(FCWSettings.Speed, FCWSettings.Weight);
  FCWSender.Enqueue(Text);
end;

procedure TRadioController.CWXAbort;
begin
  if Assigned(FCWSender) then FCWSender.AbortSending;
  if Assigned(FCWLocal)  then FCWLocal.AbortText;
  // Чужая манипуляция обрывается тем же движением: касание манипулятора,
  // снятие MOX и уход из телеграфа гасят её так же, как передачу текста.
  if Assigned(FCWElem)   then FCWElem.AbortPlay;
end;

function TRadioController.CWXBackspace: Boolean;
begin
  if CWLocalSource then
    Result := Assigned(FCWLocal) and FCWLocal.Backspace
  else
    Result := Assigned(FCWSender) and FCWSender.Backspace;
end;

function TRadioController.CWXPendingText: string;
begin
  if CWLocalSource then
  begin
    if Assigned(FCWLocal) then Result := FCWLocal.PendingText else Result := '';
  end
  else
  begin
    if Assigned(FCWSender) then Result := FCWSender.PendingText else Result := '';
  end;
end;

procedure TRadioController.CWKeyboardKey(Dot, Dash: Boolean);
// Манипулятор с клавиатуры. У локального генератора это те же лепестки, что с
// COM-порта — включая иамбик. У кейера прошивки лепестков нет: их читает FPGA
// со своего разъёма, а из программы доступен только бит CWX, то есть прямой
// ключ; это ограничение удалённого управления телеграфом.
begin
  if not CWTXActive then
  begin
    // Разоружены (не CW, запрет передачи) — ключ обязан быть отпущен, иначе
    // несущая повиснет при выходе из режима с зажатой клавишей.
    if Assigned(FCWLocal) then FCWLocal.Paddle(False, False);
    CWXKeyEvent(False);
    Exit;
  end;
  if CWLocalSource then
  begin
    EnsureCWLocal;
    SyncCWKeyer;
    FCWLocal.Paddle(Dot, Dash);
  end
  else
    CWXKeyEvent(Dot or Dash);
end;

procedure TRadioController.SetCWDecoder(On_: Boolean);
begin
  FCWSettings.Decoder := On_;
  if On_ then
  begin
    if not Assigned(FCWDec) then FCWDec := TCWDecoder.Create;
    FCWDec.SetPitch(FCWSettings.Pitch);
    FCWDec.SetSpeedHint(FCWSettings.Speed);   // старт адаптации со своей скорости
    FCWDec.Reset;
    FCWDec.Enabled := True;
  end
  else if Assigned(FCWDec) then
    FCWDec.Enabled := False;
  if FDevConnected and Assigned(FSettings) then
  begin
    FSettings.SaveCW(FDevMAC, FCWSettings);
    FSettings.Save;
  end;
end;

function TRadioController.CWDecoderOn: Boolean;
begin
  Result := Assigned(FCWDec) and FCWDec.Enabled;
end;

procedure TRadioController.ServiceCWDecoder;
// Поток хозяина (таймер GUI / цикл демона): разбор накопленного аудио и сборка
// ленты. Здесь же эхо собственной передачи — чтобы в окне была цельная лента
// связи, а не только принятое.
var
  S: string;

  procedure Append(const Txt: string; IsTX: Boolean);
  begin
    if Txt = '' then Exit;
    FCWLogText := FCWLogText + Txt;
    if Length(FCWLogText) > 8192 then
      FCWLogText := Copy(FCWLogText, Length(FCWLogText) - 8191, 8192);
    Inc(FCWLogSeq, Length(Txt));
    if Assigned(FOnCWLog) then FOnCWLog(Txt, IsTX);
  end;

begin
  // Эхо передачи забираем всегда: терминал показывает свою работу и при
  // выключенном декодере.
  if Assigned(FCWLocal) then Append(FCWLocal.TakeSentText, True);
  if Assigned(FCWSender) then Append(FCWSender.TakeSentText, True);
  if not Assigned(FCWDec) then Exit;
  if not FCWDec.Enabled then Exit;
  FCWDec.Process;
  S := FCWDec.TakeText;
  Append(S, False);
end;

function TRadioController.CWLogTail(MaxChars: Integer): string;
begin
  if MaxChars <= 0 then Exit('');
  if Length(FCWLogText) <= MaxChars then Result := FCWLogText
  else Result := Copy(FCWLogText, Length(FCWLogText) - MaxChars + 1, MaxChars);
end;

function TRadioController.CWDecSpeedWPM: Integer;
begin
  if Assigned(FCWDec) then Result := FCWDec.SpeedWPM else Result := 0;
end;

function TRadioController.CWDecToneOffsetHz: Integer;
begin
  if Assigned(FCWDec) then Result := FCWDec.ToneOffsetHz else Result := 0;
end;

function TRadioController.CWDecSignal: Boolean;
begin
  Result := Assigned(FCWDec) and FCWDec.SignalPresent;
end;

function TRadioController.MixSidetone(const Left, Right: array of Single;
  Count: Integer): Boolean;
// DSP-поток. Генератор синуса на pitch с трапецеидальной огибающей — без неё
// фронт посылки даёт щелчок в наушниках громче самой посылки. Результат
// кладём в свои буферы (входные массивы const), вызывающий отдаёт их дальше.
const
  RAMP_MS  = 5;
  AUDIO_SR = 48000;
var
  i, Got: Integer;
  Amp, Step, Rate, S: Double;
  Local: Boolean;
begin
  Result := False;
  if not FCWSettings.SidetoneSW then Exit;
  if FCWSettings.SidetoneSWLevel <= 0 then Exit;
  if Count <= 0 then Exit;
  // ★Локальный кейер даёт ГОТОВУЮ огибающую — ту же самую, что ушла в эфир, с
  // точностью до сэмпла. Это снимает старую границу «программный сайдтон верен
  // только для текста и прямого ключа»: при иамбике в прошивке PC видел лишь
  // состояние лепестков, а здесь тайминг его собственный.
  Got   := 0;
  Local := FCWLocalArmed and Assigned(FCWLocal);
  if Local then
  begin
    if Length(FCWSTEnvBuf) < Count then SetLength(FCWSTEnvBuf, Count);
    Got := FCWLocal.PullSidetone(FCWSTEnvBuf, Count);
    // Кольцо пусто и хвост уже сошёлся в ноль — блок трогать незачем.
    if (Got <= 0) and (FCWSTEnv <= 0.0) then Exit;
  end
  else if (not FCWKeyDown) and (FCWSTEnv <= 0.0) then
    Exit;
  if Length(FCWSTBufL) < Count then
  begin
    SetLength(FCWSTBufL, Count);
    SetLength(FCWSTBufR, Count);
  end;
  Amp  := (FCWSettings.SidetoneSWLevel / 100.0) * 0.7;
  Step := 2.0 * Pi * FCWSettings.Pitch / AUDIO_SR;
  Rate := 1000.0 / (RAMP_MS * AUDIO_SR);
  for i := 0 to Count - 1 do
  begin
    if Local then
    begin
      // Дальше конца кольца доигрываем спадом: генератор либо отстал на
      // блок, либо сессия кончилась.
      if i < Got then FCWSTEnv := FCWSTEnvBuf[i]
      else if FCWSTEnv > 0.0 then
      begin
        FCWSTEnv := FCWSTEnv - Rate;
        if FCWSTEnv < 0.0 then FCWSTEnv := 0.0;
      end;
    end
    else if FCWKeyDown then
    begin
      FCWSTEnv := FCWSTEnv + Rate;
      if FCWSTEnv > 1.0 then FCWSTEnv := 1.0;
    end
    else
    begin
      FCWSTEnv := FCWSTEnv - Rate;
      if FCWSTEnv < 0.0 then FCWSTEnv := 0.0;
    end;
    FCWSTPhase := FCWSTPhase + Step;
    if FCWSTPhase > 2.0 * Pi then FCWSTPhase := FCWSTPhase - 2.0 * Pi;
    S := Amp * FCWSTEnv * Sin(FCWSTPhase);
    FCWSTBufL[i] := EnsureRange(Left[i]  + S, -1.0, 1.0);
    FCWSTBufR[i] := EnsureRange(Right[i] + S, -1.0, 1.0);
  end;
  Result := True;
end;

// ===========================================================================
// PureSignal — оркестрация автомата калибровки и обратной связи.
// ===========================================================================

function TRadioController.PSAvailable: Boolean;
begin
  Result := FDevConnected and BackendCaps.HasPureSignal;
end;

procedure TRadioController.PSSetFeedbackPath(En: Boolean);
// Единая точка вкл/выкл feedback-тракта: сеть (DDC0/1 на TX) + движок (pscc).
// Намеренно БЕЗ раннего выхода при том же значении: движок сбрасывает armed
// при Close (ChangeSampleRate), сеть гейтится внутри — повторный вызов с тем
// же значением дёшев и восстанавливает рассинхрон.
begin
  FPSFeedbackOn := En;
  if FWDSPReady and Assigned(FDSPEngine) then
    FDSPEngine.SetPureSignalArmed(En);
  if Assigned(FNetwork) then
  begin
    FNetwork.SetPureSignal(En);
    // Включение/выключение посреди передачи — сразу перестроить DDC.
    if FTransmitting then FNetwork.SetPureSignalTX(En);
  end;
end;

procedure TRadioController.SetPureSignal(On_: Boolean);
// Кнопка PS включает режим автоматической калибровки. Выключение —
// через запрос сброса: state machine выполнит reset коррекции и погасит тракт.
begin
  if On_ = FPSEnabled then Exit;
  if On_ and not PSAvailable then Exit;
  FPSEnabled := On_;
  if On_ then
  begin
    ApplyPSSettingsToDSP;         // параметры calcc до старта калибровки
    FPSResetReq := False;
  end
  else
    FPSResetReq := True;
  Changed(rfPureSignal);
end;

procedure TRadioController.PSSingleCal;
// Одиночная калибровка выключает автоматический режим.
begin
  if not PSAvailable then Exit;
  if FPSSingleCal then begin FPSSingleCal := False; Exit; end;  // отмена
  ApplyPSSettingsToDSP;
  FPSEnabled  := False;
  FPSSingleCal := True;
  Changed(rfPureSignal);
end;

procedure TRadioController.PSReset;
begin
  FPSEnabled  := False;
  FPSSingleCal := False;
  FPSResetReq := True;
  Changed(rfPureSignal);
end;

function TRadioController.PSFBTarget: Integer;
begin
  Result := FTXSettings.PSFBTarget;
  if Result <= 0 then
  begin
    if FDevConnected and (FNetwork <> nil) and (FNetwork.Device.BoardType = 5) then
      Result := 22      // Orion MkII: замеры eu2av — при 152 фидбек-АЦП сам даёт IMD
    else
      Result := 152;    // стандартный целевой уровень
  end;
end;

function TRadioController.PSOutlierSigmaEff: Double;
begin
  Result := FTXSettings.PSOutlierSigma;
  if Result < 0 then
  begin
    if FDevConnected and (FNetwork <> nil) and (FNetwork.Device.BoardType = 5) then
      Result := 5.0     // Orion MkII: значение по умолчанию
    else
      Result := 0.0;    // выключен
  end;
end;

procedure TRadioController.ApplyPSSettingsToDSP;
var
  Ints, Spi: Integer;
begin
  if not (FWDSPReady and Assigned(FDSPEngine)) then Exit;
  case FTXSettings.PSIntsIdx of
    1:   begin Ints := 8;  Spi := 512;  end;
    2:   begin Ints := 4;  Spi := 1024; end;
    else begin Ints := 16; Spi := 256;  end;
  end;
  FDSPEngine.PSSetParams(FTXSettings.PSMoxDelay, FTXSettings.PSLoopDelay,
                         FTXSettings.PSTXDelayNs, FTXSettings.PSRelaxPtol,
                         FTXSettings.PSPin, FTXSettings.PSMap,
                         FTXSettings.PSStabilize, Ints, Spi,
                         FTXSettings.PSHWPeak, PSOutlierSigmaEff);
  // Правка частот двухтональника при активном 2TON — применяем живьём.
  if FTwoTone then
    FDSPEngine.SetTXTwoTone(True, FTXSettings.PSTTFreq1, FTXSettings.PSTTFreq2);
end;

function TRadioController.PSFeedbackLevel: Integer;
begin
  Result := FPSInfo[4];
end;

function TRadioController.PSCalCount: Integer;
begin
  Result := FPSInfo[5];
end;

function TRadioController.PSCorrecting: Boolean;
begin
  Result := FPSInfo[14] = 1;
end;

function TRadioController.PSMaxTXVal: Double;
begin
  Result := FPSMaxTX;
end;

function TRadioController.PSSingleCalActive: Boolean;
begin
  Result := FPSSingleCal
            or (FPSCmdState in [PS_ST_TURNON_SINGLE, PS_ST_SINGLECAL]);
end;

procedure TRadioController.PureSignalTick;
// Вызывается из UI-таймера (~10 Гц). Автомат управляет калибровкой без
// восстановления сохранённой коррекции и подстраивает AttOnTX по feedback.
var
  NewAtt: Integer;
  FBTarget: Integer;
  ddB: Double;
  CalAttemptsChanged: Boolean;
begin
  if not (FWDSPReady and Assigned(FDSPEngine)) then Exit;
  if not PSAvailable then
  begin
    // Устройство сменилось/отключилось: гарантированно всё погасить.
    if FPSFeedbackOn then PSSetFeedbackPath(False);
    FPSCmdState := PS_ST_OFF;
    Exit;
  end;
  // Полный простой (PS ни разу не включался / выключен и сброшен) — не дёргаем
  // WDSP каждые 100 мс впустую.
  if (FPSCmdState = PS_ST_OFF) and not (FPSEnabled or FPSSingleCal
     or FPSResetReq or FPSFeedbackOn) then Exit;

  // Опрос статуса WDSP calcc
  Move(FPSInfo[0], FPSInfoPrev[0], SizeOf(FPSInfo));
  FDSPEngine.PSGetInfo(FPSInfo);
  FPSMaxTX := FDSPEngine.PSGetMaxTX;

  // --- Командная state machine ---
  case FPSCmdState of
    PS_ST_OFF:
      begin
        FDSPEngine.PSControl(1, 0, 0, 0);
        PSSetFeedbackPath(False);
        FPSResetReq := False;
        if FPSEnabled then
          FPSCmdState := PS_ST_TURNON_AUTO
        else if FPSSingleCal then
          FPSCmdState := PS_ST_TURNON_SINGLE;
      end;
    PS_ST_TURNON_AUTO:
      begin
        FDSPEngine.PSControl(1, 0, 1, 0);
        PSSetFeedbackPath(True);
        FPSCmdState := PS_ST_AUTOCAL;
      end;
    PS_ST_AUTOCAL:
      begin
        PSSetFeedbackPath(True);   // ре-синк (движок мог переоткрыться)
        if FPSResetReq or (not FPSEnabled) then
          FPSCmdState := PS_ST_TURNOFF
        else if FPSSingleCal then
          FPSCmdState := PS_ST_TURNON_SINGLE;
      end;
    PS_ST_TURNON_SINGLE:
      begin
        FDSPEngine.PSControl(1, 1, 0, 0);
        PSSetFeedbackPath(True);
        FPSCmdState := PS_ST_SINGLECAL;
      end;
    PS_ST_SINGLECAL:
      begin
        PSSetFeedbackPath(True);   // ре-синк (движок мог переоткрыться)
        FPSSingleCal := False;
        if FPSResetReq then
          FPSCmdState := PS_ST_TURNOFF
        else if FPSEnabled then
          FPSCmdState := PS_ST_TURNON_AUTO
        else if PSCorrecting then
          FPSCmdState := PS_ST_STAYON;
      end;
    PS_ST_STAYON:
      begin
        // Коррекция применяется, калибровка остановлена, feedback не нужен.
        PSSetFeedbackPath(False);
        if FPSResetReq then
          FPSCmdState := PS_ST_TURNOFF
        else if FPSEnabled then
          FPSCmdState := PS_ST_TURNON_AUTO
        else if FPSSingleCal then
          FPSCmdState := PS_ST_TURNON_SINGLE;
      end;
    PS_ST_TURNOFF:
      begin
        // Reset исполняется внутри pscc — держим feedback-тракт, пока calcc
        // не дойдёт до LRESET при PSEnabled=true в состоянии TurnOFF.
        FDSPEngine.PSControl(1, 0, 0, 0);
        PSSetFeedbackPath(True);
        FPSResetReq := False;
        if FPSEnabled then
          FPSCmdState := PS_ST_TURNON_AUTO
        else if FPSSingleCal then
          FPSCmdState := PS_ST_TURNON_SINGLE
        else if (not PSCorrecting) and (FPSInfo[15] = 0) then  // 0 = LRESET
          FPSCmdState := PS_ST_OFF;
      end;
  end;

  // --- Автоматическая регулировка аттенюатора обратной связи ----------
  // По завершении очередной попытки калибровки: если feedback слишком горячий
  // (>1.5×цели) или слабый при ненулевом атт. (<0.7×цели), подстроить AttOnTX
  // так, чтобы уровень лёг к цели PSFBTarget (22 на Orion MkII — выше вход
  // фидбек-АЦП интермодулирует сам, и PS корректирует по искажённому сигналу).
  FBTarget := PSFBTarget;
  case FPSAAState of
    0: begin
         CalAttemptsChanged := FPSInfo[5] <> FPSInfoPrev[5];
         if FTXSettings.PSAutoAtten and CalAttemptsChanged
            and ((FPSInfo[4] > Round(FBTarget * 1.5)) or
                 ((FPSInfo[4] < Round(FBTarget * 0.7)) and (FTXSettings.AttOnTX > 0))) then
         begin
           FPSAAState := 1;
           if (FPSInfo[4] > 0) and (FPSInfo[4] <= 256) then
           begin
             ddB := 20.0 * Log10(FPSInfo[4] / FBTarget);
             if ddB < -100.0 then ddB := -100.0;
             if ddB > +100.0 then ddB := +100.0;
           end
           else
             ddB := 31.1;
           FPSAADeltaDB    := Round(ddB);
           FPSAASaveAuto   := Ord(FPSCmdState = PS_ST_AUTOCAL);
           FPSAASaveSingle := Ord(FPSCmdState = PS_ST_SINGLECAL);
           FDSPEngine.PSControl(1, 0, 0, 0);   // всё стоп + reset на время смены
         end;
       end;
    1: begin
         FPSAAState := 2;
         NewAtt := EnsureRange(FTXSettings.AttOnTX + FPSAADeltaDB, 0, 31);
         if NewAtt <> FTXSettings.AttOnTX then
         begin
           FTXSettings.AttOnTX := NewAtt;
           SendDUCSpecificFromSettings;        // применить атт. немедленно
           Changed(rfPureSignal);              // UI: показать новый ATT
         end;
       end;
    2: begin
         FPSAAState := 0;
         FDSPEngine.PSControl(0, FPSAASaveSingle, FPSAASaveAuto, 0);
       end;
  end;

  // UI-статус (feedback-лампа/счётчик) обновляем при изменении info
  if not CompareMem(@FPSInfo[0], @FPSInfoPrev[0], SizeOf(FPSInfo)) then
    Changed(rfPureSignal);
end;

procedure TRadioController.SetTwoTone(On_: Boolean);
// 2TON: двухтональник PostGen + MOX. Уровень — обычный
// Drive; тона PSTTFreq1/2 (700/1900 по умолчанию).
begin
  if On_ = FTwoTone then Exit;
  if On_ and (ActiveTXMode in [MODE_DMR, MODE_FMRAW]) then Exit;
  if On_ and FTuning then Exit;   // не поверх TUN-тона
  // Safety: те же блокировки, что SetMOX/SetTune.
  if On_ and TXProhibited then Exit;
  if On_ then
  begin
    // Генератор ДО запуска TX-тракта — первые блоки уже с тонами.
    if FWDSPReady and Assigned(FDSPEngine) then
      FDSPEngine.SetTXTwoTone(True, FTXSettings.PSTTFreq1, FTXSettings.PSTTFreq2);
    FTwoTone := True;
    if not FTransmitting then SetMOX(True);
  end
  else
  begin
    FTwoTone := False;
    if FTransmitting then SetMOX(False);
    if FWDSPReady and Assigned(FDSPEngine) then
      FDSPEngine.SetTXTwoTone(False, 0, 0);
  end;
  Changed(rfTwoTone);
end;

procedure TRadioController.SetFMStep(Idx: Integer);
begin
  if Idx < 0 then Idx := 0 else if Idx > 3 then Idx := 3;   // FM_STEP_COUNT = 4
  FFMStepIdx := Idx;
  Changed(rfFMStepIdx);
end;

procedure TRadioController.SetFMCTCSS(On_: Boolean);
begin
  FFMCTCSSOn := On_;
  if FWDSPReady and Assigned(FDSPEngine) then
    FDSPEngine.SetTXCTCSS(FFMCTCSSOn, CTCSS_TONES[FFMCTCSSToneIdx]);
  Changed(rfFMCTCSS);
end;

procedure TRadioController.SetFMCTCSSTone(Idx: Integer);
begin
  if Idx < 0 then Idx := 0 else if Idx > CTCSS_COUNT - 1 then Idx := CTCSS_COUNT - 1;
  FFMCTCSSToneIdx := Idx;
  if FWDSPReady and Assigned(FDSPEngine) then
    FDSPEngine.SetTXCTCSS(FFMCTCSSOn, CTCSS_TONES[FFMCTCSSToneIdx]);
  Changed(rfFMCTCSSTone);
end;

procedure TRadioController.SetFMSquelch(On_: Boolean);
begin
  FFMSQOn := On_;
  if FWDSPReady and Assigned(FDSPEngine) then
    FDSPEngine.SetFMSquelch(FFMSQOn and (FMode = MODE_FM), FFMSQLevel);
  Changed(rfFMSQ);
end;

procedure TRadioController.SetFMSquelchLevel(V: Integer);
begin
  FFMSQLevel := V;
  if FWDSPReady and Assigned(FDSPEngine) then
    FDSPEngine.SetFMSquelch(FFMSQOn and (FMode = MODE_FM), FFMSQLevel);
  Changed(rfFMSQLevel);
end;

procedure TRadioController.SetSplit(TxOnB: Boolean);
// Split: TX-VFO = B (TxOnB) либо A. TX-частота берётся из ActiveTXFreqHz
// (он смотрит на FSplitTxB), поэтому достаточно протолкнуть HP-кадр в сеть.
// Рендер VFO-дисплеев и подсветки TX-A/TX-B кнопок — через rfActiveVfo.
begin
  if FSplitTxB = TxOnB then Exit;
  FSplitTxB := TxOnB;
  ApplyAntPorts;   // AD936x: TX-разъём — по диапазону нового TX-VFO
  PushNetworkState;
  Changed(rfActiveVfo);
end;

procedure TRadioController.SetVfoLock(On_: Boolean);
// VFO Lock: пока включён, относительная перестройка (TuneActiveBy — энкодер VFO
// панели, CAT UP/DN) игнорируется. Подсветка — индикатор aiVFOLock (#11).
begin
  if FVfoLock = On_ then Exit;
  FVfoLock := On_;
  Changed(rfVfoLock);
end;

procedure TRadioController.SetFMRpt(Dir: Integer);
begin
  FFMRptDir        := Dir;
  FFMRptAutoActive := False;   // явная установка направления — ручное действие
  PushNetworkState;            // TX-сдвиг применяется через ActiveTXFreqHz
  Changed(rfFMRpt);
end;

procedure TRadioController.SetFMRptOffset(Hz: Double);
begin
  if Hz <= 0.0 then Exit;
  FFMRptOffsetHz := Hz;
  if FFMRptDir <> RPT_NONE then PushNetworkState;
  Changed(rfFMRpt);
end;

procedure TRadioController.UpdateRptAutoState;
// Авто-включение MINUS-сдвига в 2-метровом репитерном сегменте и авто-снятие
// при уходе из него (только если MINUS был выставлен автоматически).
begin
  if RptAutoDir(FVfoA) = RPT_MINUS then
  begin
    if FFMRptDir = RPT_NONE then
    begin
      FFMRptDir        := RPT_MINUS;
      FFMRptAutoActive := True;
      Changed(rfFMRpt);
    end;
  end
  else
  begin
    if (FFMRptDir = RPT_MINUS) and FFMRptAutoActive then
    begin
      FFMRptDir        := RPT_NONE;
      FFMRptAutoActive := False;
      Changed(rfFMRpt);
    end;
  end;
end;

procedure TRadioController.StartStop;
begin SetRun(not FRunning); end;

procedure TRadioController.SetRun(On_: Boolean);
begin FRunning := On_; Changed(rfRunning); { TODO wiring: логика BtnStartStopClick } end;

procedure TRadioController.StartRunning(SpectrumWidth: Integer);
// «Go-live» последовательность Run=1: General packet, wideband/DDC-конфиг,
// синхронизация WDSP sample rate, восстановление DSP (mode/volume/filter/NR/AGC),
// Run=1 → seq-сброс → DDC/DUC Specific + TX-цепь. Рендер run-кнопки/sleep — UI
// через OnControllerState(rfRunning); статус-бар/FSpecView.AGCTop/resize — обёртка.
var
  GenPkt: TGeneralPacket;
begin
  // General packet
  FillChar(GenPkt, SizeOf(GenPkt), 0);
  GenPkt.Command  := CMD_GENERAL;
  GenPkt.Flags37  := $08;
  GenPkt.Flags38  := $01;
  GenPkt.PAConfig := $01;
  GenPkt.WBPort[0] := (PORT_WIDEBAND_ADC0 shr 8) and $FF;
  GenPkt.WBPort[1] := PORT_WIDEBAND_ADC0 and $FF;
  GenPkt.WBEnable := IfThen(FShowWideband, 1, 0);
  GenPkt.WBSamplesPerPkt[0] := 512 shr 8;
  GenPkt.WBSamplesPerPkt[1] := 512 and $FF;
  GenPkt.WBSampleSize := 16;
  GenPkt.WBUpdateRate := 70;
  GenPkt.WBPacketsPerFrame := 32;
  if FNetwork.Device.BoardType = 5 then
    GenPkt.AlexEnable := $03
  else
    GenPkt.AlexEnable := $01;
  FNetwork.SendGeneralPacket(GenPkt);
  FNetwork.ConfigureWideband(0, FShowWideband, 512, 16, 70, 32);

  // ANGELIA=3, ORION=4, ORION2=5, SATURN=10: DDC0/1 — PureSignal, юзерский с 2
  // (тот же список, что DDCBaseIndex бэкенда; Saturn раньше был пропущен).
  if FNetwork.Device.BoardType in [3, 4, 5, 10] then
    FActiveDDC := 2
  else
    FActiveDDC := 0;
  ResetPanDDCMaps;   // пул пан-DDC заново от нового FActiveDDC

  // Синхронизируем samplerate WDSP если нужно
  if FWDSPReady and (FDSPEngine.SampleRate <> FSampleRate) then
  begin
    FWDSPReady := False;
    FDSPEngine.ChangeSampleRate(FSampleRate);
    FWDSPReady := FDSPEngine.Initialized;
  end;

  if FWDSPReady then
  begin
    FDSPEngine.SetMode(FMode);
    FDSPEngine.SetVolume(FVolume / 100.0);
    ApplyModeFilter;
    ApplyNoiseFilterButtonsToDSP;
    // ChangeSampleRate пересоздаёт WDSP-канал — восстанавливаем все настройки
    FDSPEngine.SetAGCTop(FAGCTop);
    ApplyAGCToEngine;   // единый маппинг UI-индекс→enum (не прямой каст)
  end;

  // Сначала отправляем Run=1 — эмулятор/железо создают ddc_specific_thread
  // (порт 1025) и rx_thread (порт 1035+) только после получения HP Run=1.
  // DDC Specific и DUC Specific отправляем ПОСЛЕ, иначе порты ещё не открыты.
  // Уровень пересчитываем здесь же: с вооружённым кейером он уедет в железо
  // сразу, до всякой передачи, а после конструктора FDriveLevel ещё нулевой.
  FDriveLevel := CalcDriveByte;
  FNetwork.UpdateState(XvtrTranslate(FCenterFreq), TXTuneFreqHz, FDriveLevel, False, True, True, FTuning, FTwoTone);
  // ★DUC — на TX-ЧАСТОТУ, а не на центр дисплея. Раньше здесь стоял центр, и до
  // первой перестройки VFO (или до первого MOX, который частоту дошлёт сам)
  // передатчик стоял не там, где маркер: при CTUN центр и VFO — разные частоты.
  // С кейером в прошивке это стало слышно в эфире: железо может замкнуть ключ
  // сразу после старта, никакого MOX перед этим не будет.
  FNetwork.SetRunAndFreq(True, XvtrTranslate(FCenterFreq),
                         TXTuneFreqHz, FDriveLevel);
  FRunning       := True;
  FRXStartTime   := GetTickCount64;
  FRXLastPktTime := 0;
  FRXPacketCount := 0;
  FillChar(FDDCSeqValid, SizeOf(FDDCSeqValid), 0);
  FSeqErrorCount     := 0;
  FLastSeqErrorDDC   := -1;
  FLastSeqErrorDelta := 0;
  FSeqOkStreak       := 0;

  // DDC и DUC Specific — теперь потоки эмулятора слушают на своих портах.
  // DUC Specific содержит mic-конфигурацию (boost/bias/line/PTT) из FTXSettings.
  FNetwork.ConfigureDDCs(1, FSampleRate div 1000, FMainADCSrc,
                         FDitherEnabled, FRandomEnabled);
  // Применяем сохранённое состояние динамика/отправки аудио (мьют byte 1400 bit1).
  FNetwork.SetSpeakerAudio(FSendAudioToRadio);
  // RX hw-gain в бэкенд (Pluto). HPSDR — no-op.
  ApplyRxGainToBackend;
  // Антенные разъёмы AD936x для текущего диапазона/трансвертера. HPSDR — no-op.
  ApplyAntPorts;
  // QO-100 self-monitor: RX-аудио на TX (full-duplex). По умолчанию заглушено.
  ApplyRxMuteToEngine;
  // Тон телеграфа в движок: на момент ConnectDevice канал WDSP мог быть ещё
  // не открыт, а здесь он гарантированно жив.
  if FWDSPReady and Assigned(FDSPEngine) then
  begin
    FDSPEngine.SetCWPitch(FCWSettings.Pitch);
    FDSPEngine.SetCWSpeed(FCWSettings.Speed);
  end;
  SendDUCSpecificFromSettings;
  SyncCWKeyer;   // вооружение кейера + уровень мощности в HP-кадре
  // Применяем TX-цепь (фильтр, mic gain, EQ, leveler, ALC, comp, ...).
  ApplyTXSettingsToDSP;
  ApplyModeFilter;

  // Принудительно обновляем AGC линии — гарантированно после всех SetAGC вызовов
  if FWDSPReady then
    FDSPEngine.UpdateAGCLines(SpectrumWidth);   // ещё не получили ни одного пакета

  Changed(rfRunning);   // UI: caption STOP / MOX.Enabled / InhibitSleep
end;

procedure TRadioController.StopRunning;
// Graceful-останов RX-потока (Run=0) + сброс TX и RX-телеметрии. Сеть остаётся
// Connected — Disconnect отдельно (UI-обёртка делает это после persistence).
// Рендер run-кнопки/sleep — UI через OnControllerState(rfRunning); MOX — rfTransmitting.
var
  p: Integer;
begin
  if FRunning then
  begin
    // Персист панов/слайсов ДО их гашения (UI-снимок живого состояния).
    if Assigned(FOnBeforeStop) then FOnBeforeStop;
    // Паны на доп. DDC живут только внутри Run-сессии — гасим до Run=0
    // (3.3 UI пересоздаст по персисту при следующем старте).
    for p := 1 to MAX_PANS - 1 do
      RemovePanDDC(p);
    FNetwork.UpdateState(XvtrTranslate(FCenterFreq), TXTuneFreqHz, 0, False, True, True, FTuning, FTwoTone);
    FNetwork.SetRunAndFreq(False, XvtrTranslate(FCenterFreq),
                           TXTuneFreqHz, 0);
    FRunning      := False;
    FTransmitting := False;
    // ★Локальный кейер обязан разоружиться ЗДЕСЬ: таймер хозяина, который его
    // пересинхронизирует, на остановленном радио уже не работает — иначе
    // манипулятор продолжал бы гонять IQ в закрытую сессию.
    SyncCWKeyer;
    // Стоп при активном TX/TUN: хвост IQ в DUC-очереди не должен пережить
    // остановку (иначе при следующем старте уйдёт в эфир старый тон/голос),
    // а TUN-состояние (PostGen-тон) — вернуться в выключенное.
    FNetwork.ClearDUCIQQueue;
    if FTuning then
    begin
      FTuning := False;
      if FWDSPReady then FDSPEngine.SetTXTone(False, 0, 0);
      Changed(rfTuning);   // UI: TUN-кнопка off
    end;
    if FTwoTone then
    begin
      FTwoTone := False;
      if FWDSPReady then FDSPEngine.SetTXTwoTone(False, 0, 0);
      Changed(rfTwoTone);  // UI: 2TON-кнопка off
    end;
    // PureSignal: погасить калибровку и feedback-тракт (без сброса коррекции —
    // сеть уже останавливается; state machine стартует заново при след. Run).
    if FPSEnabled or FPSSingleCal or FPSFeedbackOn then
    begin
      FPSEnabled   := False;
      FPSSingleCal := False;
      FPSResetReq  := False;
      FPSCmdState  := PS_ST_OFF;
      FPSAAState   := 0;
      PSSetFeedbackPath(False);
      if FWDSPReady then FDSPEngine.PSControl(1, 0, 0, 0);
      Changed(rfPureSignal);
    end;
    if FWDSPReady then FDSPEngine.SetTXRun(False);
    Changed(rfTransmitting);   // MOX-кнопка off
  end;
  FRXPacketCount := 0;
  FActiveDDC     := 0;
  FLastPlutoTempC := TELEMETRY_NONE; FLastPlutoSocTempC := TELEMETRY_NONE;
  FLastPlutoRSSIdB := TELEMETRY_NONE;
  FLastPlutoGainDb := TELEMETRY_NONE;
  FPlutoTeleTick := 0; FPlutoTempBad := 0;
  Changed(rfRunning);   // безусловный сброс run-кнопки/MOX.Enabled/sleep (как старый хвост STOP)
end;

function TRadioController.ResolveDevice(const IP: string): THPSDRDevice;
// discovered→saved по IP/URI: восстанавливает Kind/URI/Serial/BoardType/MAC,
// иначе простой HPSDR по IP. См. одноимённую обёртку в GUI (TMainForm).
var
  i:  Integer;
  dd: TDiscoveredDevice;
  sd: TSavedDevice;
begin
  FillChar(Result, SizeOf(Result), 0);
  Result.Kind      := bkHPSDR;
  Result.IPAddress := IP;

  i := FDeviceStore.FindDiscoveredByIP(IP);
  if i >= 0 then
  begin
    dd := FDeviceStore.Discovered(i);
    Result.Kind      := dd.Kind;
    Result.URI       := dd.URI;
    Result.Serial    := dd.Serial;
    Result.BoardType := dd.BoardType;
    Move(dd.MAC[0], Result.MAC[0], 6);
    Exit;
  end;

  for i := 0 to FDeviceStore.SavedCount - 1 do
  begin
    sd := FDeviceStore.Saved(i);
    if SameText(sd.IPAddress, IP) or SameText(sd.URI, IP) then
    begin
      Result.Kind      := sd.Kind;
      Result.URI       := sd.URI;
      Result.Serial    := sd.Serial;
      Result.BoardType := sd.BoardType;
      Move(sd.MAC[0], Result.MAC[0], 6);   // MAC с прошлого connect — ключ профиля настроек
      Exit;
    end;
  end;
end;

function TRadioController.EnsureDeviceMac(var Dev: THPSDRDevice): Boolean;
// См. комментарий в объявлении. Поиск в сети делаем с временно снятым
// OnDeviceFound: это служебный пробник, а не пользовательский DISCOVER —
// список устройств в UI он трогать не должен.
const
  MAC_PROBE_MS = 700;   // хватает на LAN: пакет уходит сразу, дальше только ожидание
var
  i:      Integer;
  sd:     TSavedDevice;
  SavedCb: TOnDeviceFound;
  SavedIP: string;
  Found:  THPSDRDeviceArray;
begin
  Result := not MacIsZero(Dev.MAC);
  if Result then Exit;

  i := FDeviceStore.FindDiscoveredByIP(Dev.IPAddress);
  if (i >= 0) and (not MacIsZero(FDeviceStore.Discovered(i).MAC)) then
  begin
    Move(FDeviceStore.Discovered(i).MAC[0], Dev.MAC[0], 6);
    Exit(True);
  end;

  i := FDeviceStore.FindSavedByAddr(Dev.IPAddress);
  if i >= 0 then
  begin
    sd := FDeviceStore.Saved(i);
    if not MacIsZero(sd.MAC) then
    begin
      Move(sd.MAC[0], Dev.MAC[0], 6);
      Exit(True);
    end;
  end;

  SavedCb := FNetwork.OnDeviceFound;
  SavedIP := FNetwork.DirectIP;
  FNetwork.OnDeviceFound := nil;
  FNetwork.DirectIP      := Dev.IPAddress;   // unicast — достанет радио и вне broadcast-домена
  try
    Found := FNetwork.Discover(MAC_PROBE_MS);
  finally
    FNetwork.OnDeviceFound := SavedCb;
    FNetwork.DirectIP      := SavedIP;
  end;

  for i := 0 to High(Found) do
    if SameText(Found[i].IPAddress, Dev.IPAddress) and (not MacIsZero(Found[i].MAC)) then
    begin
      Move(Found[i].MAC[0], Dev.MAC[0], 6);
      if Dev.BoardType = 0 then Dev.BoardType := Found[i].BoardType;
      Exit(True);
    end;
end;

procedure TRadioController.AddSavedDevice(const AName, AAddr: string);
// web-dev_add: Pluto по схеме URI ('ip:'/'usb:'/'local:'), иначе HPSDR-IP.
// Serial при ручном добавлении неизвестен (допишется при connect/probe).
var
  A: string;
begin
  A := Trim(AAddr);
  if A = '' then Exit;
  if (Pos('ip:', A) = 1) or (Pos('usb:', A) = 1) or (Pos('local:', A) = 1) then
    FDeviceStore.AddSavedPluto(AName, A, '')
  else
    FDeviceStore.AddSaved(AName, A, 0);
end;

function TRadioController.ConnectDevice(const Dev: THPSDRDevice): Boolean;
// Подключение + загрузка per-device настроек по MAC в поля контроллера + конфиг
// движка (DSP display, audio). Возвращает False, если Connect не удался (UI
// покажет сообщение по FNetwork.LastError). Рендер ВСЕХ device-виджетов — UI
// через OnControllerState(rfDevice). Перестройку диапазона/VFO (RestoreBand/
// XVTR/ActiveVfo) делает UI-обёртка ПОСЛЕ (там финальная перестройка через
// ApplyVfoA + wideband/web-extras). FLoadedGlobal хранит загруженный G_Settings
// для рендера полей, которых нет в контроллере (FPS/FreqMhzDigits/CAT).
var
  i: Integer;
  D: THPSDRDevice;
begin
  D := Dev;
  // Подбираем бэкенд под тип устройства (HPSDR/Pluto) до подключения.
  EnsureBackend(D.Kind);
  // MAC — ключ профиля настроек. Старт по сохранённому IP (AutoStart/CONNECT без
  // дискавери) приносит нулевой MAC, и весь профиль устройства уехал бы в чужую
  // секцию конфига; добираем MAC до Connect.
  if D.Kind = bkHPSDR then EnsureDeviceMac(D);
  Result := FNetwork.Connect(D);
  if not Result then Exit;
  // Узнанный MAC запоминаем за сохранённым устройством — следующий старт по
  // AutoStart возьмёт его без поиска в сети.
  if D.IPAddress <> '' then FDeviceStore.SetSavedMac(D.IPAddress, D.MAC)
  else                      FDeviceStore.SetSavedMac(D.URI, D.MAC);

  // --- Загружаем настройки устройства по MAC ---
  Move(FNetwork.Device.MAC[0], FDevMAC[0], 6);
  FDevConnected := True;
  FSettings.LoadDevice(FDevMAC, FLoadedGlobal, FBandCache);
  // TX/Alex/XVTR per-device
  FSettings.LoadTX(FDevMAC, FTXSettings);
  FSettings.LoadCW(FDevMAC, FCWSettings);
  if FWDSPReady and Assigned(FDSPEngine) then
  begin
    FDSPEngine.SetCWPitch(FCWSettings.Pitch);
    FDSPEngine.SetCWSpeed(FCWSettings.Speed);
  end;
  // Декодер приёма — по настройке этого устройства (создаётся лениво).
  if FCWSettings.Decoder and (not Assigned(FCWDec)) then
  begin
    FCWDec := TCWDecoder.Create;
    FCWDec.SetSpeedHint(FCWSettings.Speed);
  end;
  if Assigned(FCWDec) then
  begin
    FCWDec.SetPitch(FCWSettings.Pitch);
    FCWDec.Reset;
    FCWDec.Enabled := FCWSettings.Decoder;
  end;
  FSettings.LoadAlex(FDevMAC, FAlexSettings);
  FSettings.LoadAnt936x(FDevMAC, FAnt936x);
  FNetwork.SetAlexConfig(FAlexSettings);
  FSettings.LoadOC(FDevMAC, FOCSettings);
  FNetwork.SetOCConfig(FOCSettings);   // no-op на Pluto (base-класс)
  FSettings.LoadFilters(FDevMAC, FFilterTable);
  ApplyFilterSlot(FFilter);            // кромки под таблицу этого устройства
  FSettings.LoadXvtr(FDevMAC, FXvtrSettings);
  FCurrentXvtr := -1;
  ApplyXvtrToNetwork;
  FTXSpecRefLevel := FTXSettings.TXSpecRefLevel;
  FTXSpecRange    := FTXSettings.TXSpecRange;
  if FTXSettings.TXSpecGridStep > 0 then
    FTXSpecGridStep := FTXSettings.TXSpecGridStep;
  FVolume     := FLoadedGlobal.Volume;
  FTxMonVolume := EnsureRange(FLoadedGlobal.TxMonVolume, 0, 100);
  FActiveVfo  := EnsureRange(FLoadedGlobal.ActiveVfo, 0, 1);   // битый конфиг не должен дать невалидный VFO
  FPAMaxPower := FLoadedGlobal.PAMaxPower;
  FPlutoTxMaxAttDb := EnsureRange(FLoadedGlobal.PlutoTxMaxAttDb, -50.0, 0.0);
  for i := 0 to CFG_BAND_COUNT - 1 do
    FPABandCal[i] := FLoadedGlobal.PABandCal[i];
  for i := 0 to CFG_XVTR_COUNT - 1 do
    FVHFBandCal[i] := FLoadedGlobal.VHFBandCal[i];
  SetCalibration(FLoadedGlobal.Cal);
  // drive% (0..100) — раньше грузился через TrkDrive.Position; теперь напрямую,
  // слайдер выставит rfDevice-рендер. Калиброванный байт считаем после FCurrentBand.
  FDrivePercent := EnsureRange(FLoadedGlobal.DriveLevel, 0, 100);
  // TX-профили. Секции нет (конфиг до этой версии) — строим заводской набор из
  // ТЕКУЩИХ настроек: «Default» повторяет их один в один, звук после
  // обновления не меняется. Активный профиль на старте не применяем — живая
  // секция "tx" уже хранит последнее состояние, оно и есть истина.
  if not FSettings.LoadTXProfiles(FDevMAC, FTXProfiles) then
    DefaultTXProfiles(FTXSettings, FDrivePercent, FTXProfiles);
  // Клампим LastBand — иначе битый/старый конфиг → RestoreBand выйдет по гарду,
  // а последующие SetMode/SetFilterIdx запишут в FBandCache[невалид] за массив.
  FCurrentBand  := EnsureRange(FLoadedGlobal.LastBand, 0, CFG_BAND_COUNT - 1);
  FDriveLevel   := CalcDriveByte;
  // SampleRate — глобальный, до RestoreBand
  if FLoadedGlobal.SampleRate > 0 then
  begin
    FSampleRate := FLoadedGlobal.SampleRate;
    FSpanHz     := FSampleRate;
  end;
  // Pluto: HPSDR-наследие rate (48..1536k, часто <520k) невалидно. Клампим к
  // дефолту бэкенда, если ниже минимума (overlay покажет валидный набор).
  with FNetwork.Caps do
    if (Kind = bkPluto) and (FSampleRate < MinSampleRate) then
    begin
      FSampleRate := 1536000;
      FSpanHz     := FSampleRate;
    end;
  FRxGainMode := EnsureRange(FLoadedGlobal.RxGainMode, 0, 3);
  FRxGainDb   := EnsureRange(FLoadedGlobal.RxGainDb, 0, 73);
  FNRMode := EnsureRange(FLoadedGlobal.NRMode, 0, 4);
  FNBMode := EnsureRange(FLoadedGlobal.NBMode, 0, 2);
  FSNB := FLoadedGlobal.SNBEnabled;
  FANF := FLoadedGlobal.ANFEnabled;
  FWfAGCEnabled    := FLoadedGlobal.WfAGCEnabled;
  FWfNFEnabled     := FLoadedGlobal.WfNFEnabled;
  FDitherEnabled   := FLoadedGlobal.DitherEnabled;
  FRandomEnabled   := FLoadedGlobal.RandomEnabled;
  FSendAudioToRadio := FLoadedGlobal.SendAudioToRadio;
  FRawFMDeviation := EnsureRange(FLoadedGlobal.RawFMDeviation,
                                 RAW_FM_DEV_MIN, RAW_FM_DEV_MAX);
  if FWDSPReady and Assigned(FDSPEngine) then
    FDSPEngine.SetRawFMDeviation(FRawFMDeviation);
  FWfManualHigh := FLoadedGlobal.WfManualHigh;
  FWfManualLow  := FLoadedGlobal.WfManualLow;
  FWfAGCOffset  := FLoadedGlobal.WfAGCOffset;
  FWfPalette    := EnsureRange(FLoadedGlobal.WfPalette, 0, 2);
  FWfGamma      := EnsureRange(FLoadedGlobal.WfGamma, 0.3, 3.0);
  FDisplayPixels := EnsureRange(FLoadedGlobal.DisplayPixels, 64, 4096);
  FWfSpeedDiv   := EnsureRange(FLoadedGlobal.WfSpeedDiv, 1, 8);
  FSpecMSAA     := EnsureRange(FLoadedGlobal.SpecMSAA, 1, 8);
  FBcnZoomSpanHz   := EnsureRange(FLoadedGlobal.BeaconZoomSpanHz, 600.0, 200000.0);
  FBcnZoomOffsetHz := EnsureRange(FLoadedGlobal.BeaconZoomOffsetHz, -500000.0, 500000.0);
  FBcnZoomFollow   := FLoadedGlobal.BeaconZoomFollow;
  FBcnZoomAuto     := FLoadedGlobal.BeaconZoomAuto;
  if FWDSPReady then FDSPEngine.SetDisplayResLimit(FDisplayPixels);
  FShowSpectrum  := FLoadedGlobal.ShowSpectrum;
  FShowWaterfall := FLoadedGlobal.ShowWaterfall;
  FShowWideband  := FLoadedGlobal.ShowWideband;
  FWidebandFill  := FLoadedGlobal.WidebandFill;
  FSpectrumFill  := FLoadedGlobal.SpectrumFill;
  FDisplayDuplex := FLoadedGlobal.DisplayDuplex;
  FRxMuteOnTx    := FLoadedGlobal.RxMuteOnTx;
  // Настройки дисплея/сетки + DSP display + аудио-устройства
  if FLoadedGlobal.FFTSize > 0 then
  begin
    FSpecRefLevel := FLoadedGlobal.SpecRefLevel;
    FSpecRange    := FLoadedGlobal.SpecRange;
    if FLoadedGlobal.SpecGridStep > 0 then FSpecGridStep := FLoadedGlobal.SpecGridStep;
    if FWDSPReady then
    begin
      FDSPEngine.SetFFTParams(FLoadedGlobal.FFTSize, FLoadedGlobal.WindowType);
      FDSPEngine.SetSpectrumDisplay(FLoadedGlobal.SpecDetector, FLoadedGlobal.SpecAvgMode,
        FLoadedGlobal.SpecAvgTimeMS);
      FDSPEngine.SetWaterfallDisplay(FLoadedGlobal.WfDetector, FLoadedGlobal.WfAvgMode,
        FLoadedGlobal.WfAvgTimeMS);
    end;
    // Локальная звуковая карта (динамик RX / микрофон TX). В демоне FLocalAudio=
    // False: карту не открываем (иначе headless-хост хватает ALSA-устройство и
    // играет звук). Web-аудио идёт через OnAudioConsume и от FAudioOut не зависит.
    if FLocalAudio then
    begin
      // Аудио устройство вывода
      FAudioOut.Close;
      if FLoadedGlobal.AudioSampleRate > 0 then
        FAudioOut.SampleRate := FLoadedGlobal.AudioSampleRate;
      if FLoadedGlobal.AudioOutDevice <> '' then
      begin
        FAudioOut.DeviceIndex := FAudioOut.FindDeviceByName(FLoadedGlobal.AudioOutDevice);
        FAudioOutDevName := FLoadedGlobal.AudioOutDevice;
      end
      else
      begin
        FAudioOut.DeviceIndex := -1;
        FAudioOutDevName := '';
      end;
      FAudioOut.Open;
      // Аудио устройство ввода (TX mic). MicSource определяется в SetMOX по PTT —
      // здесь только открываем устройство.
      if FLoadedGlobal.AudioInDevice <> '' then
        SetMainMicDevice(FAudioIn.FindDeviceByName(FLoadedGlobal.AudioInDevice),
                         FLoadedGlobal.AudioInDevice)
      else
        SetMainMicDevice(-1, '');
    end;
  end;

  Changed(rfDevice);   // UI: полная синхронизация device-виджетов из состояния
end;

function TRadioController.ConnectAndRun(const Dev: THPSDRDevice;
  SpectrumWidth: Integer): Boolean;
// Headless-оркестрация подключения: Connect+load-by-MAC (ConnectDevice) →
// перестройка диапазона/XVTR/активного VFO → go-live (StartRunning). Рендер всех
// device/band/run-виджетов идёт через события (rfDevice/rfBandRestore/rfXvtr/
// rfActiveVfo/rfRunning). UI-обёртка добавляет лишь статус-строки/AGCTop/resize.
// Возвращает False, если Connect не удался (тогда bring-up не делается).
begin
  Result := ConnectDevice(Dev);
  if not Result then Exit;

  // Перестройка диапазона — команда несёт финальную перестройку по активному VFO.
  RestoreBand(FCurrentBand);

  // Pluto/VHF: HF-частоты вне диапазона. Если восстановленный VFO ниже минимума
  // бэкенда — ставим дефолт VHF (временно 100 МГц, пока нет VHF band-плана).
  with FNetwork.Caps do
    if (Kind = bkPluto) and (FVfoA < MinFreqHz) then
    begin
      FVfoB       := 100000000;
      FCenterFreq := 100000000;
      SetVfoA(100000000);   // перестроит дисплей + LO
    end;

  // Восстанавливаем XVTR-режим, если он был активен при выходе. FCurrentBand уже
  // загружен (HF-диапазон до XVTR) — ActivateXvtr только выставит FVfoA/FCurrentXvtr.
  if (FLoadedGlobal.LastXvtr >= 0) and (FLoadedGlobal.LastXvtr < CFG_XVTR_COUNT)
     and FXvtrSettings.Entries[FLoadedGlobal.LastXvtr].Enabled then
    ActivateXvtr(FLoadedGlobal.LastXvtr);

  // Активный VFO загружен из настроек; RestoreBand/ActivateXvtr строят только
  // VFO A. Если активен B — перестраиваем приёмник И подсветку на него. Для
  // активного A подсветку освежает UI-обёртка (чистый рендер).
  if FActiveVfo = 1 then
    SetActiveVfo(1);

  // Go-live Run=1 (General/DDC/DUC + DSP restore); рендер — через rfRunning.
  StartRunning(SpectrumWidth);
end;

procedure TRadioController.StopAndDisconnect;
// Headless STOP+teardown: graceful Run=0 (StopRunning) + персист настроек
// устройства/диапазона/глобала + разрыв сети. Персист идёт ДО Disconnect (saves
// читают живое состояние). Рендер run-кнопки/MOX/sleep — через StopRunning→
// rfRunning; UI-обёртка добавляет очистку спектра и offline-статус.
begin
  StopRunning;
  // Сохраняем текущую частоту/настройки чтобы следующий START восстановил их.
  if FDevConnected then
  begin
    SaveCurrentBand;
    SaveGlobalSettings;
    FSettings.SaveAlex(FDevMAC, FAlexSettings);
    FSettings.SaveAnt936x(FDevMAC, FAnt936x);
    FSettings.SaveOC(FDevMAC, FOCSettings);
    FSettings.SaveFilters(FDevMAC, FFilterTable);
    // Активный TX-профиль дописываем живыми значениями (drive/громкость микро-
    // фона ездят мимо диалога настроек) и пишем список целиком.
    StoreActiveTXProfile;
    FSettings.SaveTXProfiles(FDevMAC, FTXProfiles);
    // XVTR LastFreq → актуальная видимая частота в момент остановки.
    if (FCurrentXvtr >= 0) and (FCurrentXvtr < CFG_XVTR_COUNT) then
      FXvtrSettings.Entries[FCurrentXvtr].LastFreq := FVfoA;
    FSettings.SaveXvtr(FDevMAC, FXvtrSettings);
  end;
  FSettings.SaveStartupPreview(FVfoA, FVfoB, FSampleRate);
  FSettings.Save;
  FNetwork.Disconnect;
end;

// ---------------------------------------------------------------------------
// Discovery — поиск устройств в фоновом потоке. Найденные приходят через
// FNetwork.OnDeviceFound (=OnDeviceFound) и копятся в FDeviceStore.Discovered;
// по завершении без устройств — DiscoverFinishedNone. UI слушает rfDeviceList.
// ---------------------------------------------------------------------------
type
  TDiscoverThread = class(TThread)
  private
    FCtrl: TRadioController;
  protected
    procedure Execute; override;
  public
    constructor Create(ACtrl: TRadioController);
  end;

constructor TDiscoverThread.Create(ACtrl: TRadioController);
begin
  FCtrl := ACtrl;
  FreeOnTerminate := True;
  inherited Create(False);
end;

procedure TDiscoverThread.Execute;
// Сканируем ОБА бэкенда транзиентными экземплярами (независимо от FNetwork —
// он может быть уже подключён/другого типа). Найденные сигналятся колбэком
// OnDeviceFound во время Discover() и копятся в store.
var
  Found: Integer;
  HP:    THPSDRNetwork;
  Pluto: TPlutoBackend;
  PDev:  TRadioDevice;
  i:     Integer;
begin
  Found := 0;

  // openHPSDR (UDP broadcast)
  HP := THPSDRNetwork.Create;
  try
    HP.OnDeviceFound := FCtrl.OnDeviceFound;
    Found := Found + Length(HP.Discover(2000));
  finally
    HP.Free;
  end;

  // Pluto (libiio scan + прямой probe сетевых URI) — если libiio доступна.
  if IIOLoad then
  begin
    Pluto := TPlutoBackend.Create;
    try
      Pluto.OnDeviceFound := FCtrl.OnDeviceFound;
      // USB scan
      Found := Found + Length(Pluto.Discover(1500));
      // Прямой probe сетевых адресов (mDNS недоступен) — из списка фронтенда.
      for i := 0 to High(FCtrl.FPlutoProbeURIs) do
        if Pluto.ProbeURI(FCtrl.FPlutoProbeURIs[i], PDev) then
          Inc(Found);
    finally
      Pluto.Free;
    end;
  end;

  if Found = 0 then
    FCtrl.Invoke(FCtrl.DiscoverFinishedNone);
end;

procedure TRadioController.EnsureBackend(Kind: TBackendKind);
// Пересоздаёт FNetwork нужного класса, перенося колбэки данных. No-op если тип
// уже верный. Старый бэкенд отключается и освобождается.
var
  NewB: TRadioBackend;
begin
  if (FNetwork <> nil) and (FNetwork.Caps.Kind = Kind) then Exit;

  case Kind of
    bkPluto: NewB := TPlutoBackend.Create;
  else       NewB := THPSDRNetwork.Create;
  end;

  if FNetwork <> nil then
  begin
    if FNetwork.Connected then FNetwork.Disconnect;
    NewB.OnDeviceFound := FNetwork.OnDeviceFound;
    NewB.OnDDCIQ       := FNetwork.OnDDCIQ;
    NewB.OnMicPacket   := FNetwork.OnMicPacket;
    NewB.OnHPStatus    := FNetwork.OnHPStatus;
    NewB.OnWideband    := FNetwork.OnWideband;
    FreeAndNil(FNetwork);
  end;
  FNetwork := NewB;
end;

function TRadioController.BackendCaps: TBackendCaps;
begin
  if FNetwork <> nil then Result := FNetwork.Caps
  else FillChar(Result, SizeOf(Result), 0);
end;

function TRadioController.SampleRatePresets: TBackendRateArray;
begin
  Result := BackendCaps.RatePresets;
end;

function TRadioController.BandPlan: TBandPlanArray;
begin
  Result := BuildBandPlan(IsPluto);
end;

function TRadioController.IsPluto: Boolean;
begin
  Result := (FNetwork <> nil) and (FNetwork.Caps.Kind = bkPluto);
end;

function TRadioController.InQO100: Boolean;
// True — активен full-duplex транспондер (QO-100): Pluto + выбран XVTR-слот с
// флагом FullDuplexTx. Гейт для UI-элементов, осмысленных только в этом режиме
// (beacon-lock, RX MUTE self-monitor).
begin
  Result := IsPluto and (FCurrentXvtr >= 0)
            and FXvtrSettings.Entries[FCurrentXvtr].FullDuplexTx;
end;

function TRadioController.DisplayDuplex: Boolean;
begin
  Result := FDisplayDuplex;
end;

function TRadioController.BoardDisplayName: string;
var
  Dev: THPSDRDevice;
begin
  if FNetwork = nil then Exit('');
  Dev := FNetwork.Device;
  if Dev.Kind = bkPluto then
  begin
    Result := PlutoModelName(Dev.Model);
    // Серийник дописываем, только если это реальный hw_serial, а не URI-fallback
    // ('usb:1.5.5' / 'ip:…'), который PlutoBackend кладёт при отсутствии серийника.
    if (Dev.Serial <> '') and (Pos('usb:', Dev.Serial) = 0)
       and (Pos('ip:', Dev.Serial) = 0) then
      Result := Result + ' SN:' + Dev.Serial;
  end
  else
    Result := BoardTypeName(Dev.BoardType);
end;

procedure TRadioController.ServicePlutoTelemetry;
const
  POLL_MS     = 500;    // опрос ~2 Гц: чтение attr — USB round-trip, чаще ни к чему
  TEMP_MIN_C  = 0.0;    // работающий AD936x всегда теплее окружающей (само-нагрев)
  TEMP_MAX_C  = 120.0;  // абсолютный потолок (датчик исправен)
  TEMP_STEP_C = 12.0;   // макс. физичный шаг между опросами (тепловая инерция чипа)
  TEMP_FORCE  = 3;      // принять «выброс», если держится N раз подряд (не залипаем)
var
  t, s, r: Double;
  now_: QWord;
begin
  if (FNetwork = nil) or (not FRunning) or (FNetwork.Caps.Kind <> bkPluto) then Exit;
  now_ := GetTickCount64;
  if (FPlutoTeleTick <> 0) and (now_ - FPlutoTeleTick < POLL_MS) then Exit;
  FPlutoTeleTick := now_;
  if not FNetwork.ReadTelemetry(t, s, r) then Exit;

  // Температура: датчик AD9361 иногда отдаёт мусор (мид-конверсия / контеншн на
  // USB-контексте с RX-потоком) — наблюдалось редкое -15 °C. Фильтруем: жёсткое
  // окно правдоподобия + slew-limit по тепловой инерции; устойчивый «выброс»
  // (реальный скачок) всё же принимаем через TEMP_FORCE опросов, чтобы не залипнуть.
  if t > TELEMETRY_NONE then
  begin
    if (t >= TEMP_MIN_C) and (t <= TEMP_MAX_C) then
    begin
      if (FLastPlutoTempC <= TELEMETRY_NONE) or (Abs(t - FLastPlutoTempC) <= TEMP_STEP_C) then
      begin
        FLastPlutoTempC := t; FPlutoTempBad := 0;
      end
      else
      begin
        Inc(FPlutoTempBad);
        if FPlutoTempBad >= TEMP_FORCE then
        begin FLastPlutoTempC := t; FPlutoTempBad := 0; end;
      end;
    end;
    // вне окна правдоподобия — молча отбрасываем, держим прошлое валидное значение
  end;

  // Температура Zynq SoC (xadc): стабильный локальный датчик (не подвержен мусору
  // мид-конверсии AD9361) — достаточно окна правдоподобия без slew-limit.
  if (s > TELEMETRY_NONE) and (s >= TEMP_MIN_C) and (s <= TEMP_MAX_C) then
    FLastPlutoSocTempC := s;

  // RSSI быстро меняется с сигналом — без сглаживания; обновляем, только если валиден.
  if r > TELEMETRY_NONE then FLastPlutoRSSIdB := r;
  // Фактическое усиление тракта (в MANUAL = заданное, в AGC — снятое с железа).
  FLastPlutoGainDb := FNetwork.RxGainNowDb;
  Changed(rfRxGain);   // UI: живая цифра усиления в блоке RF AGC

  Changed(rfSupply);   // поле телеметрии статус-бара (поле 3); web-адаптер тоже
end;

procedure TRadioController.SetPlutoProbeURIs(const URIs: array of string);
var i: Integer;
begin
  SetLength(FPlutoProbeURIs, Length(URIs));
  for i := 0 to High(URIs) do FPlutoProbeURIs[i] := URIs[i];
end;

procedure TRadioController.SeedPlutoProbeFromSaved;
// Заполняет probe-список URI всех сохранённых Pluto (mDNS нет → сетевой Pluto
// находится только прямым probe его URI). Демон зовёт это перед Discover; GUI
// добавляет ещё и ручной IP из диалога. Дедупликация по URI.
var
  URIs: array of string;
  i, k: Integer;
  U:    string;
begin
  SetLength(URIs, 0);
  for i := 0 to FDeviceStore.SavedCount - 1 do
    if FDeviceStore.Saved(i).Kind = bkPluto then
    begin
      U := FDeviceStore.Saved(i).URI;
      if U = '' then Continue;
      for k := 0 to High(URIs) do
        if SameText(URIs[k], U) then begin U := ''; Break; end;
      if U = '' then Continue;
      SetLength(URIs, Length(URIs) + 1);
      URIs[High(URIs)] := U;
    end;
  SetPlutoProbeURIs(URIs);
end;

procedure TRadioController.OnDeviceFound(const Dev: THPSDRDevice);
// Сетевой/scan-поток: строим строку для списка → pending → маршалинг в
// ApplyDeviceFound. Строка зависит от типа бэкенда (HPSDR vs Pluto).
begin
  FPendingFoundDev := Dev;
  if Dev.Kind = bkPluto then
    FPendingFoundEntry := Format('PlutoSDR  %s', [Dev.URI])
  else
    FPendingFoundEntry := Format('%s  %s  FW:%d  DDC:%d',
                            [Dev.IPAddress, BoardTypeName(Dev.BoardType),
                             Dev.FirmwareVersion, Dev.NumDDCs]);
  Invoke(ApplyDeviceFound);
end;

procedure TRadioController.ApplyDeviceFound;
// Маршалится из OnDeviceFound (Synchronize). Дописываем в store и уведомляем UI.
begin
  FDiscoverNoneFound := False;
  FDeviceStore.AddDiscovered(FPendingFoundDev.IPAddress, FPendingFoundEntry,
                             FPendingFoundDev.BoardType, FPendingFoundDev.MAC,
                             FPendingFoundDev.Kind, FPendingFoundDev.URI,
                             FPendingFoundDev.Serial);
  Changed(rfDeviceList);
end;

procedure TRadioController.DiscoverFinishedNone;
// Маршалится из discover-потока, когда устройств не найдено.
begin
  FDiscoverNoneFound := True;
  Changed(rfDeviceList);
end;

procedure TRadioController.Discover;
// Старт поиска: чистим прошлый список, сбрасываем direct-IP, запускаем поток.
begin
  FDeviceStore.ClearDiscovered;
  FDiscoverNoneFound := False;
  FNetwork.DirectIP  := '';
  Changed(rfDeviceList);
  TDiscoverThread.Create(Self);
end;

end.
