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,
  HPSDRProtocol, HPSDRNetwork, RadioBackend, PlutoBackend, IIOBindings,
  WDSPEngine, AudioOutput, AudioInput, BeaconDecoder, BeaconFEC, DMRDecoder,
  Settings, ChannelStore, FMRepeater, BoardUtils, DeviceStore;

// Панадаптеры (этап 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,
    rfNR, rfNB, rfSNB, rfANF,
    rfWfAGC, rfWfNF, rfDither, rfRandom,
    rfFMDeviation, rfFMCTCSS, rfFMCTCSSTone, rfFMSQ, rfFMSQLevel,
    rfFMStep, rfFMStepIdx, rfFMRpt,
    rfChannel,
    rfSMeter, rfPower, rfSWR, rfSupply, rfPLL,   // телеметрия
    rfADCOverload,                               // ADC overload (рендер индикатора)
    rfBeaconLock,                                // QO-100 beacon lock: статус/коррекция
    rfDevice,                                    // подключённое устройство сменилось
    rfDeviceList                                 // список discovered устройств обновился
  );

  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;

  // Сырьё спектра/водопада наружу (фронтенды рисуют). Вызывается из DSP-потока.
  TPixelDataEvent = procedure(const Pixels: array of Single; Count: Integer) 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;
    FMSQOn:      Boolean;        // FM-squelch включён (слайс в режиме FM)
    FMSQLevel:   Integer;        // порог squelch 0..100
    DeviceIndex: Integer;        // PortAudio-индекс (-1 = default)
    DevName:     string;
    Audio:       TAudioOutput;   // владеет; свой поток вывода
    DMR:         TDMRSliceDecoder; // владеет; nil для не-DMR слайса
  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: децимация+рендер)
    procedure Changed(Field: TRadioField);
    // --- Мультислайсы ---
    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);
    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)
    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..1023] of Single;
    FWaterfallBuf:     array[0..1023] of Single;
    FSpectrumBufCount: Integer;
    FWaterfallBufCount: Integer;

    // ---- Движки (владение: см. шапку юнита) ----
    // 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 слайсов
    FSettings:     TSettingsManager;
    FChannelStore: TChannelStore;
    FDeviceStore:  TDeviceStore;   // общий список устройств (saved+discovered)
    FWDSPReady:    Boolean;

    // ---- Частоты / режим / фильтр / AGC ----
    FVfoA, FVfoB:   Double;
    FActiveVfo:     Integer;
    FMode:          Integer;
    FFilter:        Integer;
    FFilterBW:      Integer;
    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 из последнего снимка декодера (индикация лока)
    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

    // ---- 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;
    FSplitTxB:      Boolean;
    FTxSliceId:     Integer;   // 0 = главный VFO (A/B split); >0 = слайс-источник TX
    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
    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-сетка (Thetis-style): применяется при FTransmitting
    FTXSpecRange:    Double;
    FTXSpecGridStep: Double;

    // ---- FM ----
    FFMDeviation:    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;
    FAlexSettings: TAlexSettings;
    // Антенные разъёмы 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;
    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;
    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;

    // ---- Колбэки движков (вызываются из рабочих потоков сети/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);
    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;
    // Режим передающего тракта: слайс-источник 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;

    // Вычисляет hardware drive-байт (Thetis-формула) из текущего состояния
    // (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

    // ---- Управление диапазонами (состояние + 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;

    // ---- 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 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);
    // 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(Idx: Integer);

    // ---- Мультислайсы (софт-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);
    class function  AGCModeFromUI(M: Integer): TWDSPAGCMode;   // UI-индекс 0..4 → WDSP enum
    procedure SetSliceAGCMode(Id: Integer; AGC: TWDSPAGCMode);
    procedure SetSliceVolume(Id: Integer; Vol: Double);   // 0..1
    procedure SetSliceMute(Id: Integer; Mute: Boolean);
    procedure SetSliceDSP(Id, NRMode, NBMode: Integer; SNBOn, ANFOn: Boolean);
    procedure SetSliceFMSquelch(Id: Integer; On_: Boolean; Level: Integer);
    function  SliceSMeter(Id: Integer): Double;           // dBm, из DSP-потока
    procedure SetSliceDevice(Id, DevIndex: Integer; const DevName: string);
    function  SliceCount: Integer;
    function  GetSlice(Id: Integer; out S: TCtrlSlice): Boolean;
    // Диапазон захваченной полосы пана (0 = главный), куда можно ставить слайс.
    procedure CapturedSpan(out LoHz, HiHz: Double; PanId: Integer = 0);
    function  SliceFitsCapture(TargetHz: Double; PanId: Integer = 0): Boolean;

    // ---- Панадаптеры на аппаратных 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 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;
    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
    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;
    // Сохраняет устройство по введённому адресу. Схема 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);
    // Пост-тюн хук: вызывается в КОНЦЕ 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 / микрофон TX). Демон ставит False: аудио
    // только через web (OnAudioConsume), локальная звуковая карта не трогается.
    property LocalAudioEnabled: Boolean read FLocalAudio write FLocalAudio;
    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;

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)
  // Дефолты (дублируют TMainForm.FormCreate; в GUI перезапишутся, в демоне нужны).
  FVfoA := 14200000; FVfoB := 7100000; FActiveVfo := 0;
  FMode := 1; FFilter := 5; FFilterBW := 2700;
  FAGCMode := 1; FAGCTop := 90;
  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;
  FSpectrumBufCount := 1024; FWaterfallBufCount := 1024;
  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;
  FLocalAudio := True;   // GUI: звук на локальную карту; демон выключит явно
  FLastSMeter := -130; FLastSWR := 1; FLastSupplyV := -1; FLastSupplyA := -1;
  FLastPlutoTempC := TELEMETRY_NONE; FLastPlutoSocTempC := TELEMETRY_NONE;
  FLastPlutoRSSIdB := TELEMETRY_NONE;
  FPlutoTeleTick := 0; FPlutoTempBad := 0;
  FillChar(FDevMAC, SizeOf(FDevMAC), 0);
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);
  // Роутинг аудио слайсов на их звуковухи — внутренний, вешаем сразу (не
  // зависит от фронтенда, в отличие от OnAudio/OnSpectrum).
  FDSPEngine.OnSliceAudio := OnSliceAudioReady;
  FDSPEngine.OnSliceDemodAudio := OnSliceDemodAudioReady;
  FDSPEngine.OnDemodAudio := OnDemodAudioReady;
  FDSPEngine.SetDisplayResLimit(FDisplayPixels);

  // 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;
  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].Active := False;
    end;
  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
  FreeEngines;
  FreeAndNil(FDeviceStore);
  FreeAndNil(FSettings);
  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.PushNetworkState;
// Полный HP-кадр в радио: RX/TX частоты (через трансвертор), drive, состояние
// MOX. Вызывается командами, которые меняют центр/частоту/репитер-сдвиг.
begin
  if Assigned(FNetwork) and FNetwork.Connected and FNetwork.Running then
  begin
    FNetwork.UpdateState(XvtrTranslate(FCenterFreq), XvtrTranslateTX(ActiveTXFreqHz),
                         FDriveLevel, FTransmitting, True, True);
    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).
  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 = 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;
var
  FwdPwr, RevPwr: Word;
  SupplyV, SupplyA, FwdW, SWRV, Rho: Double;
  IsTx: 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 (как Thetis).
  // Прочие платы — в 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 BoardSupportsSupplyCurrent(FNetwork.Device.BoardType) then
      SupplyA := ADCToSupplyCurrent(
                   (Status.UserADC1Hi shl 8) or Status.UserADC1Lo,
                   FNetwork.Device.BoardType);
  end;
  FwdW := ADCToWatts100(FwdPwr);
  // FTransmitting — программный флаг; HPS_PTT — вход HW-PTT (footswitch/mic).
  IsTx := FTransmitting or ((Status.StatusBits and HPS_PTT) <> 0);
  // SWR = (1+ρ)/(1-ρ), ρ = adc_r/adc_f; только при значимой fwd-мощности.
  if IsTx and (FwdW >= SWR_MIN_FWD_W) and (FwdPwr > 0) then
  begin
    Rho  := RevPwr / FwdPwr;
    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;
  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;

  // 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 = MODE_DMR then Exit;

  if FSendAudioToRadio then
    FNetwork.SendSpeakerAudio(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
  if not Assigned(FDMRDec) then Exit;
  if FMode = MODE_DMR then
  begin
    if not FDMRDec.Enabled then FDMRDec.SetEnabled(True);
    FDMRDec.FeedAudio(Left, Right, Count);
  end
  else if FDMRDec.Enabled then
    FDMRDec.SetEnabled(False);
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);
  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
  if not FLocalAudio then Exit;
  idx := FindSliceIndex(SliceId);
  if idx < 0 then Exit;
  if FSlices[idx].Mode = MODE_DMR then Exit;
  if FSlices[idx].Audio <> nil then
    FSlices[idx].Audio.Write(Left, Right, Count);
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) or (FSlices[idx].Mode <> MODE_DMR) then Exit;
  if Assigned(FSlices[idx].DMR) then
    FSlices[idx].DMR.FeedAudio(Left, Right, Count);
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 not FLocalAudio or (Count <= 0) then Exit;
  idx := FindSliceIndex(SliceId);
  if (idx < 0) or (FSlices[idx].Mode <> MODE_DMR) or
     FSlices[idx].Muted or not Assigned(FSlices[idx].Audio) 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;
  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;

{ ---- Панадаптеры на аппаратных 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;
  AO: TAudioOutput;
begin
  Result := -1;
  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;   // нет свободных слотов

  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].FMSQOn      := False;   // squelch по умолчанию выключен
  FSlices[slot].FMSQLevel   := 40;
  FSlices[slot].DeviceIndex := DevIndex;
  FSlices[slot].DevName     := DevName;
  FSlices[slot].Audio       := AO;
  FSlices[slot].DMR         := nil;
  if Mode = MODE_DMR then
    FSlices[slot].DMR := TDMRSliceDecoder.Create(Self, Id);

  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;
  FSlices[idx].Active := False;
  Changed(rfDevice);
end;

procedure TRadioController.SetSliceTarget(Id: Integer; TargetHz: Double);
var idx: Integer;
begin
  idx := FindSliceIndex(Id);
  if idx < 0 then Exit;
  FSlices[idx].TargetHz := TargetHz;
  if Assigned(FDSPEngine) then
    FDSPEngine.SetSliceShift(Id, TargetHz - PanCenterHz(FSlices[idx].PanId));
end;

procedure TRadioController.SetSliceMode(Id, Mode: Integer);
var idx, OldMode: Integer;
begin
  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.
  if (Mode = MODE_DMR) and (FTxSliceId = Id) then SetTxSlice(0);
  if (OldMode = MODE_DMR) and (Mode <> MODE_DMR) then
    FreeAndNil(FSlices[idx].DMR);
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);
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);
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);
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);
end;

procedure TRadioController.SetSliceDSP(Id, NRMode, NBMode: Integer;
  SNBOn, ANFOn: Boolean);
var idx: Integer;
begin
  idx := FindSliceIndex(Id);
  if idx < 0 then Exit;
  if Assigned(FDSPEngine) then
  begin
    FDSPEngine.SetSliceNRMode(Id, NRMode);
    FDSPEngine.SetSliceNBMode(Id, NBMode);
    FDSPEngine.SetSliceSNB(Id, SNBOn);
    FDSPEngine.SetSliceANF(Id, ANFOn);
  end;
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);
end;

function TRadioController.SliceSMeter(Id: Integer): Double;
begin
  if Assigned(FDSPEngine) then Result := FDSPEngine.GetSliceSMeter(Id)
  else Result := -130.0;
end;

procedure TRadioController.SetSliceDevice(Id, DevIndex: Integer;
  const DevName: string);
var idx: Integer;
begin
  idx := FindSliceIndex(Id);
  if idx < 0 then Exit;
  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.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;

// Снимок кадра для web-зеркала: протокол фиксирован на 1024 точки, поэтому
// при большем разрешении анализатора децимируем max-детектором (не обрезаем).
procedure SnapshotPixels(const Pixels: array of Single; Count: Integer;
  var Dst: array of Single; out DstCount: Integer);
var
  i, j, S0, S1, N: Integer;
  V: Single;
begin
  N := Length(Dst);
  if Count > Length(Pixels) then Count := Length(Pixels);
  if Count <= N then
  begin
    for i := 0 to Count - 1 do Dst[i] := Pixels[i];
    DstCount := Count;
    Exit;
  end;
  for i := 0 to N - 1 do
  begin
    S0 := Int64(i) * Count div N;
    S1 := (Int64(i) + 1) * Count div N - 1;
    V := Pixels[S0];
    for j := S0 + 1 to S1 do
      if Pixels[j] > V then V := Pixels[j];
    Dst[i] := V;
  end;
  DstCount := N;
end;

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

procedure TRadioController.OnWaterfallReady(const Pixels: array of Single;
  Count: Integer);
// DSP-поток: web-зеркало получает КАЖДЫЙ кадр; децимация рендера водопада —
// забота фронтенда (OnWaterfallData).
begin
  SnapshotPixels(Pixels, Count, FWaterfallBuf, FWaterfallBufCount);
  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;
  Buf:  array[0..2047] of Double;
begin
  Result := 0;
  if (FAudioIn = nil) or (not FAudioIn.IsOpen) then Exit;
  n := FAudioIn.Available;
  if n > MaxN then n := MaxN;
  if n > Length(Buf) then n := Length(Buf);
  for i := 0 to n - 1 do
    Buf[i] := FAudioIn.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
    Result := VisibleHz - FXvtrSettings.Entries[FCurrentXvtr].TxLOOffset
  else
    // Обычный трансвертер: RX=TX LO (с калибровкой LOError) — как раньше.
    Result := VisibleHz
              - FXvtrSettings.Entries[FCurrentXvtr].LOOffset
              + FXvtrSettings.Entries[FCurrentXvtr].LOError;
end;

function TRadioController.ActiveTXFreqHz: Double;
var S: TCtrlSlice;
begin
  // Мультислайс-TX: если выбран слайс-источник — передаём на его частоте
  // (слайсы не несут repeater-конфиг, FM-сдвиг не применяем).
  if (FTxSliceId > 0) and GetSlice(FTxSliceId, S) then
    Exit(S.TargetHz);
  // 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;

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. Do not accidentally use the
    // analog WFM transmitter for a receive-only DMR slice.
    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;
  // Передающий тракт под режим нового источника (idle-канал, применится к PTT).
  if FWDSPReady and Assigned(FDSPEngine) then
    FDSPEngine.SetTXMode(ActiveTXMode);
  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;    // гистерезис: лок гаснет ниже этого

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;
  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;
    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;

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: 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);
  // Солидный лок: декодер несёт несущую И SNR выше порога (гистерезис против мерцания).
  if S.CarrierLock and (FBeaconSNRdB >= BEACON_LOCK_SNR_DB) then FBeaconLocked := True
  else if FBeaconSNRdB < BEACON_UNLOCK_SNR_DB 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;

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);
var Half: Integer;
begin
  Half := BW div 2;
  case Mode of
    MODE_LSB:  begin Lo := -BW;  Hi := -100; end;
    MODE_USB:  begin Lo := 100;  Hi := BW;   end;
    // DIGI: полоса от нуля — поднесущие цифровых видов начинаются низко,
    // срез на 100 Гц как в SSB съел бы их.
    MODE_DIGL: begin Lo := -BW;  Hi := 0;    end;
    MODE_DIGU: begin Lo := 0;    Hi := BW;   end;
  else  Lo := -Half; Hi := Half;            // DSB/CW/FM/AM/SAM/WFM
  end;
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
  FilterEdgesFor(FMode, FFilterBW, Lo, Hi);
  if FWDSPReady and Assigned(FDSPEngine) then
  begin
    FDSPEngine.SetFilter(Lo, Hi);
    FDSPEngine.SetTXFilter(Lo, Hi);
    if (FMode = MODE_FM) or (FMode = MODE_DMR) then
    begin
      FDSPEngine.SetRXFMDeviation(FFMDeviation);
      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, прочие — дефолт.
procedure TRadioController.ApplyModeDefaults;
begin
  case FMode of
    MODE_FM:
      begin
        if FFilter > 1 then FFilter := FILT_FM_DEF;
        FFilterBW    := FILT_FM_BW[FFilter];
        FFMDeviation := FILT_FM_DEV[FFilter];
      end;
    MODE_WFM:
      begin
        if FFilter > FILT_WFM_COUNT - 1 then FFilter := FILT_WFM_DEF;
        FFilterBW := FILT_WFM_BW[FFilter];
      end;
    MODE_DMR:
      begin
        FFilter := 0;
        FFilterBW := FILT_DMR_BW;
        FFMDeviation := DMR_FM_DEV;
      end;
    MODE_LSB, MODE_USB:
          begin FFilter := FILT_SSB_DEF;  FFilterBW := FILT_SSB_BW[FFilter];  end;
    MODE_DIGU, MODE_DIGL:
          begin FFilter := FILT_DIGI_DEF; FFilterBW := FILT_DIGI_BW[FFilter]; end;
    MODE_DSB:
          begin FFilter := FILT_DSB_DEF;  FFilterBW := FILT_DSB_BW[FFilter];  end;
    MODE_CWL, MODE_CWU:
          begin FFilter := FILT_CW_DEF;   FFilterBW := FILT_CW_BW[FFilter];   end;
  else    begin FFilter := FILT_AM_DEF;   FFilterBW := FILT_AM_BW[FFilter];   end;
  end;
end;

function TRadioController.FilterBWFor(M, Idx: Integer): Integer;
// Полоса выбранного фильтра под режим. Зеркалит выбор таблицы в UpdateFilterButtons,
// но НЕ меняет индекс (в отличие от ApplyModeDefaults) — нужно для XVTR-restore.
begin
  case M of
    MODE_FM:  Result := FILT_FM_BW[EnsureRange(Idx, 0, FILT_FM_COUNT - 1)];
    MODE_WFM: Result := FILT_WFM_BW[EnsureRange(Idx, 0, FILT_WFM_COUNT - 1)];
    MODE_DMR: Result := FILT_DMR_BW;
    MODE_LSB, MODE_USB:
              Result := FILT_SSB_BW[EnsureRange(Idx, 0, FILT_COUNT - 1)];
    MODE_DIGU, MODE_DIGL:
              Result := FILT_DIGI_BW[EnsureRange(Idx, 0, FILT_COUNT - 1)];
    MODE_DSB: Result := FILT_DSB_BW[EnsureRange(Idx, 0, FILT_COUNT - 1)];
    MODE_CWL, MODE_CWU:
              Result := FILT_CW_BW[EnsureRange(Idx, 0, FILT_COUNT - 1)];
  else        Result := FILT_AM_BW[EnsureRange(Idx, 0, FILT_COUNT - 1)];
  end;
end;

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

function TRadioController.CalcDriveByte: Byte;
// Thetis-совместимая формула:
//   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
    if XvtrActive and FXvtrSettings.UseXVTRTunePower then
      Pos := EnsureRange(FXvtrSettings.Entries[FCurrentXvtr].TXPower, 0, 100)
    else
      Pos := EnsureRange(FTXSettings.TUNLevel, 0, 100);
  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 индексы фильтров только 0/1 (NFM/WFM); приводим FFilter и
// синхронизируем FFilterBW/FFMDeviation. Чистая (state) часть UpdateFilterButtons.
begin
  if FMode = MODE_FM then
  begin
    if FFilter > 1 then FFilter := FILT_FM_DEF;
    FFilterBW    := FILT_FM_BW[FFilter];
    FFMDeviation := FILT_FM_DEV[FFilter];
  end;
  if FMode = MODE_DMR then
  begin
    FFilter := 0;
    FFilterBW := FILT_DMR_BW;
    FFMDeviation := DMR_FM_DEV;
  end;
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;
  // 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;
    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.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.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;
end;

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

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

  // --- Фильтр (санитизация FM до применения к DSP — как в оригинале) ---
  FFilter   := B.FilterIdx;
  FFilterBW := B.FilterBW;
  SanitizeFilterForMode;
  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 + ползунок

  // --- 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 = MODE_DMR 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);

  // --- 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
      0: begin FiltLo := Offset - FFilterBW;   FiltHi := Offset - 100; end;
      1: 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;
      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
  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 = MODE_DMR then
  begin
    FFMStepOn := True;
    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;                    // дефолтный фильтр/девиация под режим
  FBandCache[FCurrentBand].Mode := FMode;
  if FWDSPReady and Assigned(FDSPEngine) then
  begin
    FDSPEngine.SetMode(FMode);
    // Если TUN активен — пересчитываем знак частоты под новый режим
    if FTuning then FDSPEngine.SetTXTone(True, FTXSettings.TUNFreq, TUN_TONE_MAG);
  end;
  ApplyModeFilter;
  Changed(rfMode);
end;

procedure TRadioController.SetFilterIdx(Idx: Integer);
begin
  // Клампим индекс под текущий режим (как FilterBWFor) — публичная команда,
  // Idx может прийти из CAT/web без проверки; FM/WFM-таблицы короче остальных.
  case FMode of
    MODE_FM:  Idx := EnsureRange(Idx, 0, FILT_FM_COUNT - 1);
    MODE_WFM: Idx := EnsureRange(Idx, 0, FILT_WFM_COUNT - 1);
    MODE_DMR: Idx := 0;
  else        Idx := EnsureRange(Idx, 0, FILT_COUNT - 1);
  end;
  FFilter := Idx;
  case FMode of
    MODE_LSB, MODE_USB: FFilterBW := FILT_SSB_BW[Idx];
    MODE_DIGU, MODE_DIGL: FFilterBW := FILT_DIGI_BW[Idx];
    MODE_DSB:           FFilterBW := FILT_DSB_BW[Idx];
    MODE_CWL, MODE_CWU: FFilterBW := FILT_CW_BW[Idx];
    MODE_WFM:           FFilterBW := FILT_WFM_BW[Idx];
    MODE_DMR:           FFilterBW := FILT_DMR_BW;
    MODE_FM:
      begin
        FFilterBW    := FILT_FM_BW[Idx];
        FFMDeviation := FILT_FM_DEV[Idx];
      end;
  else    FFilterBW := FILT_AM_BW[Idx];
  end;
  FBandCache[FCurrentBand].FilterBW := FFilterBW;
  ApplyModeFilter;
  Changed(rfFilter);
end;

procedure TRadioController.SetFilterBW(BWHz: Integer);
// Непрерывная полоса (web-ползунок): применяем запрошенный BW как есть, а
// FFilter подбираем ближайшим пресетом — только для подсветки кнопки.
var
  BW_arr: array[0..FILT_COUNT-1] of Integer;
  best, dist, i: Integer;
begin
  if FMode = MODE_DMR then BWHz := FILT_DMR_BW;
  FFilterBW := BWHz;
  for i := 0 to FILT_COUNT-1 do BW_arr[i] := MaxInt;
  case FMode of
    MODE_LSB, MODE_USB:
              for i := 0 to FILT_COUNT-1 do BW_arr[i] := FILT_SSB_BW[i];
    MODE_DIGU, MODE_DIGL:
              for i := 0 to FILT_COUNT-1 do BW_arr[i] := FILT_DIGI_BW[i];
    MODE_DSB: for i := 0 to FILT_COUNT-1 do BW_arr[i] := FILT_DSB_BW[i];
    MODE_CWL, MODE_CWU:
              for i := 0 to FILT_COUNT-1 do BW_arr[i] := FILT_CW_BW[i];
    MODE_FM:  for i := 0 to FILT_FM_COUNT-1 do BW_arr[i] := FILT_FM_BW[i];
    MODE_WFM: for i := 0 to FILT_WFM_COUNT-1 do BW_arr[i] := FILT_WFM_BW[i];
    MODE_DMR: BW_arr[0] := FILT_DMR_BW;
  else        for i := 0 to FILT_COUNT-1 do BW_arr[i] := FILT_AM_BW[i];
  end;
  best := 0; dist := MaxInt;
  for i := 0 to FILT_COUNT-1 do
    if Abs(BW_arr[i] - BWHz) < dist then
    begin
      dist := Abs(BW_arr[i] - BWHz);
      best := i;
    end;
  FFilter := best;
  if FMode = MODE_FM then FFMDeviation := FILT_FM_DEV[best];
  FBandCache[FCurrentBand].FilterBW := FFilterBW;
  ApplyModeFilter;
  Changed(rfFilter);
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;

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;
  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;   // ре-применяем режим с новым уровнем
  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);
  Changed(rfVolume);
end;

procedure TRadioController.VolumeBy(Delta: Integer);
begin SetVolume(ActiveVolume + Delta); 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;
  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; на приёме — макс. аттенюация (выкл).
  if FTransmitting or FTuning 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.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(Idx: Integer);
begin
  if Idx < 0 then Idx := 0 else if Idx > 2 then Idx := 2;
  FAtten := Idx;
  if Assigned(FNetwork) then FNetwork.SetStepAtten(FAtten * 10);
  Changed(rfAtten);
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
    FNetwork.SetXvtrMode(En, DisablePA, RxAnt);
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.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_;
  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;
begin
  if FAudioInDevName <> '' 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;

procedure TRadioController.SendDUCSpecificFromSettings;
var
  DUCPkt: TDUCSpecificPacket;
  RateKsps: Word;
begin
  if not (FNetwork.Connected and FNetwork.Running) then Exit;
  FillChar(DUCPkt, SizeOf(DUCPkt), 0);
  // DUC0 rate в ksps берём из WDSP-движка (фиксированный TX rate, не RX).
  RateKsps := FDSPEngine.TXSampleRate div 1000;
  DUCPkt.NumDACs        := 1;
  DUCPkt.SidetoneLevel  := 50;
  DUCPkt.SidetoneFreqHi := Hi(600);
  DUCPkt.SidetoneFreqLo := Lo(600);
  DUCPkt.KeyerSpeed     := 20;
  DUCPkt.KeyerWeight    := 50;
  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, формула Thetis).
  // При Mic In значение игнорируется железом, но шлём корректное для Line In.
  DUCPkt.LineInGain := Byte(EnsureRange(Round((FTXSettings.LineInGainDB + 34.5) / 1.5), 0, 31));
  // ATT on TX — RX-ADC step attenuator во время передачи (Thetis-style protection).
  // Дублируем значение во все три ADC-байта: на однопотоковых платах активен только
  // ADC0, на Saturn/Hermes Lite 2 — могут использоваться ADC1/ADC2.
  DUCPkt.StepAtten0 := Byte(EnsureRange(FTXSettings.AttOnTX, 0, 31));
  DUCPkt.StepAtten1 := DUCPkt.StepAtten0;
  DUCPkt.StepAtten2 := DUCPkt.StepAtten0;
  FNetwork.SendDUCSpecific(DUCPkt);
end;

procedure TRadioController.ApplyTXSettingsToDSP;
begin
  if not FWDSPReady then Exit;
  // 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);
end;

procedure TRadioController.SetMOX(On_: Boolean);
var
  WasTransmitting: Boolean;
begin
  // DMR support is receive-only. This guard also covers CAT, web and hardware
  // PTT. A selected non-DMR TX slice remains valid while main A receives DMR.
  if On_ and (ActiveTXMode = MODE_DMR) then Exit;
  // Safety: блокируем TX на бэндах с DoNotTx (Alex) и на RX-only трансвертере.
  if On_ and FDevConnected and (FCurrentXvtr < 0)
     and FAlexSettings.DoNotTx[EnsureRange(TxBandIdx, 0, 10)] then
    Exit;
  if On_ and (FCurrentXvtr >= 0) and (FCurrentXvtr < CFG_XVTR_COUNT)
     and FXvtrSettings.Entries[FCurrentXvtr].RXOnly then
    Exit;
  WasTransmitting := FTransmitting;
  FTransmitting := On_;
  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), XvtrTranslateTX(ActiveTXFreqHz),
                         FDriveLevel, True, True, True);
    FNetwork.SendFullHP;    // 1й пакет: Alex bit27=1 (T/R relay), drive — без PTT
    FNetwork.SendPTT(True); // 2й пакет: PTT=1 после переключения реле
    // 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.
    FNetwork.PrimeDUCIQ(6096);
  end;
  if FWDSPReady and Assigned(FDSPEngine) then
  begin
    // Mic source автоматически по источнику TX:
    //   Web TX → txmsWeb; HW PTT → txmsRadio; MOX/CAT → DefaultMicSource.
    if FTransmitting then
    begin
      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 then
    begin
      FWebMicActive := False;
      FDSPEngine.SetTXMicSource(DefaultMicSource);
    end;
    // Сброс бэклога sound-card микрофона на RX→TX: пока шёл приём, ринг
    // PortAudio наполнился до упора (~200мс). Без сброса весь этот «старый»
    // звук бёрстом уходит в тракт: стоячая задержка ~190мс в DUC-очереди
    // + в эфир передаётся аудио, записанное ДО нажатия PTT (на TUN оно
    // складывается с тоном поверх полной шкалы → клип-всплеск).
    if FTransmitting and (not WasTransmitting) and Assigned(FAudioIn)
       and FAudioIn.IsOpen then
      FAudioIn.Flush;
    // В non-DUP RX-IQ пакеты дропаются на входе DSP-потока во время TX, чтобы
    // TX leakage не накапливался в RXA pipeline / FFT-истории анализатора.
    FDSPEngine.KeepRXDuringTX := FDisplayDuplex;
    // Мультислайс-TX: передающий тракт под режим слайса-источника (SetMode мог
    // оставить главный режим). Ставим перед стартом TX-потока.
    if FTransmitting then FDSPEngine.SetTXMode(ActiveTXMode);
    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
    FNetwork.UpdateState(XvtrTranslate(FCenterFreq), XvtrTranslateTX(ActiveTXFreqHz),
                         FDriveLevel, False, True, True);
    FNetwork.ClearDUCIQQueue;
    FNetwork.SendFullHP;     // 2й пакет: Alex bit27=0 (T/R relay RX)
  end;
  ApplyPlutoTxAtten;   // Pluto: TX-мощность по drive на передаче, OFF на приёме
  ApplyActiveVolume;   // T/R: переключаем громкость RX ↔ TX-монитор (если monitoring)
  Changed(rfTransmitting);
end;

procedure TRadioController.SetTune(On_: Boolean);
begin
  if On_ = FTuning then Exit;
  if On_ and (ActiveTXMode = MODE_DMR) then Exit;
  // Safety: те же блокировки, что и в SetMOX — TUN это TX через WDSP PostGen-тон,
  // он тоже должен молчать на бэндах с DoNotTx и на RX-only XVTR.
  if On_ and FDevConnected and (FCurrentXvtr < 0)
     and FAlexSettings.DoNotTx[EnsureRange(TxBandIdx, 0, 10)] then Exit;
  if On_ and (FCurrentXvtr >= 0) and (FCurrentXvtr < CFG_XVTR_COUNT)
     and FXvtrSettings.Entries[FCurrentXvtr].RXOnly then Exit;
  if On_ then
  begin
    // Тон включаем ДО запуска TX-аудио, чтобы первые сэмплы уже шли с тоном.
    if FWDSPReady and Assigned(FDSPEngine) then
      FDSPEngine.SetTXTone(True, FTXSettings.TUNFreq, TUN_TONE_MAG);
    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), XvtrTranslateTX(ActiveTXFreqHz),
                           FDriveLevel, FTransmitting, True, True);
      FNetwork.SendFullHP;
    end;
  end;
  ApplyPlutoTxAtten;   // финальное состояние att по FTuning/FTransmitting (Pluto)
  Changed(rfTuning);
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 отправляем ПОСЛЕ, иначе порты ещё не открыты.
  FNetwork.UpdateState(XvtrTranslate(FCenterFreq), XvtrTranslateTX(ActiveTXFreqHz), FDriveLevel, False, True, True);
  FNetwork.SetRunAndFreq(True, XvtrTranslate(FCenterFreq), XvtrTranslate(FCenterFreq), 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;
  SendDUCSpecificFromSettings;
  // Применяем 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), XvtrTranslateTX(ActiveTXFreqHz), 0, False, True, True);
    FNetwork.SetRunAndFreq(False, XvtrTranslate(FCenterFreq), XvtrTranslate(FCenterFreq), 0);
    FRunning      := False;
    FTransmitting := False;
    // Стоп при активном 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 FWDSPReady then FDSPEngine.SetTXRun(False);
    Changed(rfTransmitting);   // MOX-кнопка off
  end;
  FRXPacketCount := 0;
  FActiveDDC     := 0;
  FLastPlutoTempC := TELEMETRY_NONE; FLastPlutoSocTempC := TELEMETRY_NONE;
  FLastPlutoRSSIdB := 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;
      Exit;
    end;
  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;
begin
  // Подбираем бэкенд под тип устройства (HPSDR/Pluto) до подключения.
  EnsureBackend(Dev.Kind);
  Result := FNetwork.Connect(Dev);
  if not Result then Exit;

  // --- Загружаем настройки устройства по 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.LoadAlex(FDevMAC, FAlexSettings);
  FSettings.LoadAnt936x(FDevMAC, FAnt936x);
  FNetwork.SetAlexConfig(FAlexSettings);
  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];
  // drive% (0..100) — раньше грузился через TrkDrive.Position; теперь напрямую,
  // слайдер выставит rfDevice-рендер. Калиброванный байт считаем после FCurrentBand.
  FDrivePercent := EnsureRange(FLoadedGlobal.DriveLevel, 0, 100);
  // Клампим 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;
  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);
  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
      begin
        FAudioIn.DeviceIndex := FAudioIn.FindDeviceByName(FLoadedGlobal.AudioInDevice);
        FAudioInDevName := FLoadedGlobal.AudioInDevice;
        FAudioIn.Open;
      end
      else
      begin
        FAudioIn.DeviceIndex := -1;
        FAudioInDevName := '';
      end;
    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);
    // 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;

  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.
