unit MainForm;

{
  OpenHPSDR Transceiver - Main Form
  Lazarus / FPC 3.2+  (нет inline var, нет анонимных процедур)

  ИСПРАВЛЕНИЯ:
    - (Web NR/NB/ANF fix) BtnNRClick, BtnNBClick, BtnANFClick переписаны
      без использования «Sender as TFlatButton» — теперь обращаются напрямую
      к BtnNR / BtnNB / BtnANF. Это устраняет EAccessViolation / EInvalidCast
      при вызове из SyncWebNR/SyncWebNB/SyncWebANF через TThread.Synchronize,
      где Sender передавался как nil. Исключение в UI-потоке приводило к
      разрыву WebSocket-соединения и «отключению» веб-клиента.
    - SyncWebNR / SyncWebNB / SyncWebANF: передают реальную кнопку (BtnNR /
      BtnNB / BtnANF) вместо nil — защита на случай изменения обработчиков.
}

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

interface

uses
  Classes, SysUtils, StrUtils, FreqDisplay, DeviceForm, VfoOverlay, SampleRateOverlay,
  FlatButton, FlatSlider, FlatDropDown, AppTheme, Forms, Controls, Graphics, Dialogs,
  StdCtrls, ExtCtrls, Buttons, Menus, Math, Types,
  OpenGLContext,
  LCLIntf, LCLType, GraphType,
  HPSDRProtocol, HPSDRNetwork,
  WDSP, WDSPEngine, AudioOutput, AudioInput,
  Settings,
  WebServer,
  CATEngine, CATSerial, CATTcp,
  SpectrumView, SpectrumViewOpengl,
  WidebandView,
  StatusBar,
  PlatformUtils,
  WinFirewall,
  BoardUtils, WisdomBuilder, UISync,
  FlatEdit, FMRepeater,
  ChannelStore, ChannelsForm,
  PowerInhibit,
  RadioController;

const
  CLR_BG         = TColor($00101010);
  CLR_PANEL      = TColor($00181818);
  CLR_BORDER     = TColor($00303030);
  CLR_FREQ       = TColor($0000FF00);
  CLR_FREQ_DIM   = TColor($00007800);
  CLR_AMBER      = TColor($0000AAFF);
  CLR_ACTIVE     = TColor($000C3010);
  CLR_ACTIVE_BDR = TColor($00387838);
  CLR_INACTIVE   = TColor($001A1A1A);
  CLR_TEXT       = TColor($00CCCCCC);
  CLR_TEXTDIM    = TColor($00666666);
  CLR_METER_ON   = TColor($0000CC44);
  CLR_METER_OVR  = TColor($000000CC);
  CLR_SPECTRUM   = TColor($0044FF44);
  WDSP_WISDOM_FILE = 'wdspWisdom00';

  BAND_COUNT = 11;
  MODE_COUNT = 8;
  FILT_COUNT = 10;

  // Группы фильтров по типу модуляции
  // SSB: LSB/USB
  FILT_SSB_NAMES: array[0..FILT_COUNT-1] of string =
    ('5.0k','4.4k','3.8k','3.3k','2.9k','2.7k','2.4k','2.1k','1.8k','1.0k');
  FILT_SSB_BW:    array[0..FILT_COUNT-1] of Integer =
    (5000, 4400, 3800, 3300, 2900, 2700, 2400, 2100, 1800, 1000);
  FILT_SSB_DEF = 5;   // 2.7k

  // CW: CWL/CWU
  FILT_CW_NAMES: array[0..FILT_COUNT-1] of string =
    ('1.0k','800','600','500','400','250','150','100','50','25');
  FILT_CW_BW:    array[0..FILT_COUNT-1] of Integer =
    (1000, 800, 600, 500, 400, 250, 150, 100, 50, 25);
  FILT_CW_DEF = 3;    // 500

  // DSB
  FILT_DSB_NAMES: array[0..FILT_COUNT-1] of string =
    ('16k','12k','10k','8.0k','6.6k','5.2k','4.0k','3.1k','2.9k','2.4k');
  FILT_DSB_BW:    array[0..FILT_COUNT-1] of Integer =
    (16000, 12000, 10000, 8000, 6600, 5200, 4000, 3100, 2900, 2400);
  FILT_DSB_DEF = 3;   // 8.0k

  // AM/SAM
  FILT_AM_NAMES: array[0..FILT_COUNT-1] of string =
    ('20k','18k','16k','12k','10k','9.0k','8.0k','7.0k','6.0k','5.0k');
  FILT_AM_BW:    array[0..FILT_COUNT-1] of Integer =
    (20000, 18000, 16000, 12000, 10000, 9000, 8000, 7000, 6000, 5000);
  FILT_AM_DEF = 4;    // 10k

  // FM: NFM (2.5 kHz deviation / 11 kHz BW) and FM (5.0 kHz deviation / 16 kHz BW)
  FILT_FM_COUNT = 2;
  FILT_FM_NAMES: array[0..1] of string  = ('NFM', 'FM');
  FILT_FM_BW:    array[0..1] of Integer = (11000, 16000);
  FILT_FM_DEV:   array[0..1] of Double  = (2500.0, 5000.0);
  FILT_FM_DEF = 0;    // NFM

  // CTCSS tones (38 standard tones, no None — toggle via CTCSS button)
  CTCSS_COUNT = 38;
  CTCSS_TONES: array[0..37] of Double = (
    67.0,  71.9,  74.4,  77.0,  79.7,  82.5,  85.4,  88.5,  91.5,  94.8,
    97.4,  100.0, 103.5, 107.2, 110.9, 114.8, 118.8, 123.0, 127.3, 131.8,
    136.5, 141.3, 146.2, 151.4, 156.7, 162.2, 167.9, 173.8, 179.9, 186.2,
    192.8, 203.5, 210.7, 218.1, 225.7, 233.6, 241.8, 250.3);
  CTCSS_NAMES: array[0..37] of string = (
    '67.0', '71.9', '74.4', '77.0', '79.7', '82.5', '85.4', '88.5',
    '91.5', '94.8', '97.4', '100.0', '103.5', '107.2', '110.9', '114.8',
    '118.8', '123.0', '127.3', '131.8', '136.5', '141.3', '146.2', '151.4',
    '156.7', '162.2', '167.9', '173.8', '179.9', '186.2', '192.8', '203.5',
    '210.7', '218.1', '225.7', '233.6', '241.8', '250.3');

  // FM Step tuning
  FM_STEP_COUNT = 4;
  FM_STEP_HZ:    array[0..3] of Integer = (6250, 12500, 20000, 25000);
  FM_STEP_NAMES: array[0..3] of string  = ('6.25k', '12.5k', '20k', '25k');
  FM_STEP_DEF    = 3;   // 25 kHz

  BAND_NAMES: array[0..BAND_COUNT-1] of string = (
    '160m','80m','60m','40m','30m','20m','17m','15m','12m','10m','6m');
  BAND_FREQ: array[0..BAND_COUNT-1] of Double = (
    1900000, 3750000, 5357000, 7100000, 10125000,
    14200000, 18120000, 21200000, 24940000, 28500000, 50150000);

  MODE_NAMES: array[0..MODE_COUNT-1] of string = (
    'LSB','USB','DSB','CWL','CWU','FM','AM','SAM');

  SMETER_WIDTH_RATIO = 0.32;
  SMETER_MARGIN      = 3;
  TOP_SMETER_GAP     = 8;
  TOP_VFO_FONT       = 25;
  LEFT_PANEL_BTN_PAD  = 3;
  LEFT_PANEL_BTN_GAP  = 2;

type
  TDeviceItem = record
    Dev:     THPSDRDevice;
    Display: string;
  end;

  { TMainForm }
  TMainForm = class(TForm)
  private
    // ---- Headless ядро (состояние + движки). Фаза 1 миграции: см.
    //      RadioController.pas. Поля состояния/движков теперь живут в
    //      FController; обращения идут как FController.FXxx. ----
    FController:  TRadioController;
    // True пока OnControllerState обновляет виджеты — чтобы программное
    // изменение слайдера/кнопки не вызывало повторно команду из OnChange.
    FSyncingFromController: Boolean;

    // ---- Network ----
    FDevices:     array of TDeviceItem;
    FDeviceCount:  Integer;
    FDeviceDialog: TDeviceDialog;
    FPendingIP:        string;   // IP устройства для подключения
    FVfoOverlay:         TVfoOverlay;         // накладка SmartSDR-стиль на спектре
    FSampleRateOverlay:  TSampleRateOverlay;  // оверлей span/hide слева вверху спектра
    FPanelHidden:        Boolean;             // True = левая панель скрыта
    FLeftPanelW:         Integer;             // ширина панели до скрытия (DPI-safe restore)
    FPendingBoardType: Integer;  // BoardType устройства для подключения

    // ---- UI-local state (мышь на спектре/водопаде) ----
    FSpecDrag:      Boolean;
    FSpecDragX0:    Integer;  // X при нажатии
    FSpecDragFreq:  Double;   // FCenterFreq при нажатии
    FSpectrumDirty: Boolean;  // таймер должен перерисовать спектр/водопад
    // DUC IQ packetizer: TXA отдаёт блок FTXOutBufSize пар, который
    // не обязан быть кратен 240 (DUC packet). Хвост переносим в следующий
    // вызов OnTXIQReady, иначе зануление давало 47 Гц гул.
    FDUCPendingI:     array[0..239] of Integer;
    FDUCPendingQ:     array[0..239] of Integer;
    FDUCPendingCount: Integer;
    FLastSMeter:      Double;
    FSMeterPeak:      Double;    // верхняя граница светлой зоны
    FSMeterMin:       Double;    // нижняя граница светлой зоны
    FSMeterAvg:       Double;    // сглаженное среднее (EMA)
    FRXPacketCount: LongWord;   // счётчик принятых IQ пакетов
    FRXStartTime:   QWord;      // GetTickCount64 момента нажатия START
    FRXLastPktTime: QWord;      // GetTickCount64 последнего принятого пакета
    FActiveDDC:    Integer;    // DDC index для текущей платы (0 или 2)
    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;
    FLastFwdW:     Double;
    FLastSWR:      Double;
    // Эти значения приходят с HP Status пакетами (50..200 раз/с). UI обновляется
    // из MeterTimerTick (10 Гц), чтобы цифры/полоски не дёргались.
    FLastSupplyV:  Double;
    FLastSupplyA:  Double;
    FLastPLLLock:  Boolean;

    // ---- Spectrum / Waterfall view ----
    FSpecView:       TSpectrumView;
    FWidebandView:   TWidebandView;
    FUseOpenGLSpectrum: Boolean;
    FSpectrumWidth:  Integer;   // актуальная ширина для FDSPEngine и resize-детектора
    FSpectrumHeight: Integer;
    FWaterfallHeight:Integer;
    FShowSpectrum:   Boolean;
    FShowWaterfall:  Boolean;
    FShowWideband:   Boolean;
    FWidebandFill:   Boolean;
    FSpectrumFill:   Boolean;
    FDisplayFPS:     Integer;
    FWaterfallDirty: Boolean;
    FWidebandDirty:  Boolean;
    FPreChannelPower:  Integer;  // мощность до применения канала (-1 = не сохранена)
    FWfManualHigh:  Double;
    FWfManualLow:   Double;
    FWfAGCOffset:   Double;
    // Spectrum display settings (for settings save/load)
    FSpecRefLevel:  Double;
    FSpecRange:     Double;
    FSpecGridStep:  Double;
    // TX-specific grid (Thetis-style: при TX рисуем спектр в своих границах).
    // Применяются к FSpecView когда FTransmitting=True; иначе используются RX-значения.
    FTXSpecRefLevel: Double;
    FTXSpecRange:    Double;
    FTXSpecGridStep: Double;
    // DSP data buffers (copy of last DSP output, for WebServer.PushSpectrum)
    FSpectrumBuf:      array[0..1023] of Single;
    FWaterfallBuf:     array[0..1023] of Single;
    FSpectrumBufCount: Integer;
    FWaterfallBufCount:Integer;
    // Waterfall frame timing
    FWaterfallFrameInterval: Integer;
    FWaterfallFrameCounter:  Integer;
    FAgcLineCounter:         Integer;
    // ---- Splitter ----
    FSplitterDrag:   Boolean;
    FSplitterDragY0: Integer;
    FSplitterSH0:    Integer;
    FSplitterRatio:  Double;
    // --- Settings ---
    FWebServer:      TWebServer;
    FWebEnabled:     Boolean;
    FWebPort:        Integer;
    FWebBindAddr:    string;
    FWebUser:        string;
    FWebPass:        string;
    FWebMicActive:   Boolean;            // True если текущий TX идёт через txmsWeb
    // --- CAT ---
    FCATEngine:         TCATEngine;
    FCATSerial:         TCATSerialManager;
    FCATTcp:            TCATTcpServer;
    FCATSyncFreq:       Double;
    FCATLastGlobal:     TGlobalSettings; // текущие CAT-настройки (для сохранения)
    // Временные поля для передачи параметров в Synchronize-методы
    FWebSyncFreq:    Double;
    FWebSyncInt:     Integer;
    FWebSyncBool:    Boolean;
    FWebSyncM:       TThreadMethod;
    FSettingsForm:   TObject;            // TSettingsForm (cast при использовании)
    FPendingDev:     THPSDRDevice;        // устройство ожидающее открытия WDSP
    FLightTheme:     Boolean;            // True = светлая тема
    FFreqMhzDigits:  Integer;            // 3=999MHz, 4=9.999GHz, 5=99.999GHz

    // ---- Timers ----
    FMeterTimer:    TTimer;
    FSpectrumTimer: TTimer;
    FAfterShowTimer:TTimer;   // однократный таймер для пост-инициализации
    FRestoreL, FRestoreT: Integer;  // позиция для отложенного восстановления
    FHasPendingRestore:   Boolean;

    // ---- Top panel (toolbar + VFO group + S-meter) ----
    PanelToolbar:  TPanel;
      BtnDiscover:  TFlatButton;
      BtnStartStop: TFlatButton;
      BtnSettings:  TFlatButton;

    // ---- Left panel ----
    PanelLeft: TPanel;

    PanelVfoA:    TPanel;
      LblVfoALabel: TLabel;
      FreqDispA:    TFreqDisplay;
      BtnTopVfoASelect: TFlatButton;
      BtnTopVfoATX: TFlatButton;

    PanelVfoB:    TPanel;
      LblVfoBLabel: TLabel;
      FreqDispB:    TFreqDisplay;
      BtnTopVfoBSelect: TFlatButton;
      BtnTopVfoBTX: TFlatButton;

    PanelVfoButtons: TPanel;
      BtnVfoSwap: TFlatButton;
      BtnVfoACopyB: TFlatButton;
      BtnVfoBCopyA: TFlatButton;
    PanelTopVfoGroup: TPanel;
    PanelTopVfoA: TPanel;
    PanelTopVfoB: TPanel;

    PanelBands:   TPanel;
      BtnBand:    array[0..BAND_COUNT-1] of TFlatButton;
      // Динамические кнопки XVTR (создаются/удаляются по мере того,
      // как пользователь enabled/disabled слоты в Settings → Transverter).
      BtnXvtrBand: array[0..CFG_XVTR_COUNT-1] of TFlatButton;

    PanelMode:    TPanel;
      BtnMode:    array[0..MODE_COUNT-1] of TFlatButton;

    PanelFilter:  TPanel;
      BtnFilter:  array[0..FILT_COUNT-1] of TFlatButton;
    BtnCTun: TFlatButton;
    BtnDUP:  TFlatButton;
    PanelFMSQ:    TPanel;
      BtnFMSQ:    TFlatButton;
      TrkFMSQ:    TFlatSlider;
      LblFMSQ:    TLabel;
    PanelFMCTCSS:     TPanel;
      BtnFMCTCSS:     TFlatButton;
      BtnFMCTCSSTone: TFlatButton;
    FCTCSSDropDown:   TFlatDropDown;
    PanelFMStep:     TPanel;
      BtnFMStep:     TFlatButton;
      BtnFMStepSel:  TFlatButton;
    FStepDropDown:   TFlatDropDown;
    PanelFMRpt:      TPanel;
      BtnFMRptMinus:   TFlatButton;
      BtnFMRptPlus:    TFlatButton;
      EdFMRptOffset:   TFlatEdit;

    PanelAGC:      TPanel;
      BtnAGCMode: array[0..4] of TFlatButton;  // FAST MED SLOW LONG OFF
      LblAGCTop:  TLabel;    // показывает значение уровня
      TrkAGC:     TFlatSlider;
      TrkVolume:  TFlatSlider;
      BtnNR: TFlatButton;
      BtnNB: TFlatButton;
      BtnSNB: TFlatButton;
      BtnANF: TFlatButton;
      BtnMute: TFlatButton;

    PanelSMeterRight: TPanel;
    PbSMeterRight:    TPaintBox;

    PanelTX:      TPanel;
      LblDrv:     TLabel;
      TrkDrive:   TFlatSlider;
      BtnMOX: TFlatButton;
      BtnTUN: TFlatButton;

    PanelRXBlock: TPanel;
      BtnChannels:      TFlatButton;
    FChannelsDropDown:  TFlatDropDown;
    FChannelsForm:      TObject;   // TChannelsForm (cast при использовании)
    FActiveChannelIdx:  Integer;   // -1 = нет активного канала

    // ---- Right panel ----
    PanelRight:   TPanel;
    PbSpectrum:   TControl;
    PbWideband:   TControl;
    PbWidebandRuler: TPaintBox;
    PbRuler:      TPaintBox;   // полоса частотных меток между спектром и водопадом
    PanelSplitter: TPanel;     // перетаскиваемый разделитель спектр/водопад
    PbWaterfall:  TControl;

    // ---- Status bar ----
    StatusPanel:  TMainStatusBar;

    // ---- Helpers ----
    procedure DoInvoke(M: TThreadMethod);   // маршалинг команд контроллера в UI-поток
    procedure OnControllerState(Sender: TObject; Field: TRadioField);  // контроллер → UI
    procedure BuildUI;
    function LeftPanelButtonLeft(PanelWidth, ColCount, ColIndex: Integer): Integer;
    function LeftPanelButtonWidth(PanelWidth, ColCount, ColIndex: Integer): Integer;
    procedure ApplyDarkTheme;
    procedure SetLightTheme(V: Boolean);
    procedure StyleButton(B: TFlatButton; Active: Boolean = False);
    procedure StyleSpanButton(B: TFlatButton; Active: Boolean = False);
    procedure SetStatusText(Index: Integer; const Text: string);
    procedure SetRadioOfflineStatus(const ConnText: string; ClearDevice: Boolean = False);
    procedure ApplyStatusTheme(const T: TAppTheme);
    procedure PositionSampleRateOverlay;
    procedure OnSampleRateOverlayInvalidate(Sender: TObject);
    procedure OnSampleRateSelect(SampleRate: Integer);
    procedure OnSampleRateHidePanel;
    procedure UpdateVfoDisplay;
    function  FormatFreq(Hz: Double): string;
    procedure LayoutTopVfoBlock;

    procedure ResizeSMeter;
    procedure ResizeSpectrumPanels;
    function  UpdateWidebandFrequencyView: Boolean;
    procedure InvalidateGridCache;
    procedure SyncSpecViewFreq;
    procedure RecreateDSPEngine(ASampleRate: Integer);
    function  EnsureWDSPWisdom: Boolean;

    // Network callbacks
    procedure OnDeviceFound(const Dev: THPSDRDevice);
    procedure OnHPStatusCB(const Status: THighPriorityStatus);
    procedure OnDDCIQCB(DDCIndex: Integer; const Data: TDDCIQPacket);
    procedure OnWidebandCB(ADCIndex: Integer; const Samples: array of SmallInt;
      Count: Integer);
    procedure OnWDSPOpenDone(Success: Boolean);
    procedure DoConnectDevice(const Dev: THPSDRDevice);
    procedure OnMicPacketCB(const Data: TMicDataPacket);

    // DSP/Audio callbacks (вызываются из рабочих потоков)
    procedure OnAudioReady(const Left, Right: array of Single; Count: Integer);
    procedure OnSpectrumReady(const Pixels: array of Single; Count: Integer);
    procedure OnWaterfallReady(const Pixels: array of Single; Count: Integer);

    // Public UI update (called from sync objects)
    procedure DoAddDevice(const Dev: THPSDRDevice; const Entry: string);
    procedure DoUpdateStatus(FwdW, SWRV, SupplyV, SupplyA: Double; PLLLock, HWPTT: Boolean;
      ADCOverload: Byte);
    procedure UpdateTXMeters;
    procedure DoUpdateDDCSeq(DDCIdx: Integer; Seq: LongWord);
    procedure DoUpdateDDCSeqOrNoDevice(DDCIdx: Integer; Seq: LongWord);

    // Event handlers
    procedure BtnDiscoverClick(Sender: TObject);
    procedure BtnDiscoverFromDialog(Sender: TObject);
    procedure BtnStartStopClick(Sender: TObject);
    procedure FreqDispAChanged(Sender: TObject; NewFreq: Int64);
    procedure ApplyVfoA(NewFreq: Int64);
    procedure FreqDispAClick(Sender: TObject);
    procedure FreqDispBClick(Sender: TObject);
    procedure BtnTopVfoASelectClick(Sender: TObject);
    procedure BtnTopVfoATXClick(Sender: TObject);
    procedure BtnTopVfoBSelectClick(Sender: TObject);
    procedure BtnTopVfoBTXClick(Sender: TObject);
    procedure ActivateVfo(Idx: Integer);
    function  ActiveVfoFreq: Int64;
    // Настройки
    procedure SaveCurrentBand;
    procedure RestoreBand(BandIdx: Integer);
    procedure SaveAllAndExit;
    procedure RestoreWindowBounds;
    procedure SaveWindowBounds;
    function  MakeGlobalSettings: TGlobalSettings;
    function  MakeBandSettings: TBandSettings;
    procedure FreqDispBChanged(Sender: TObject; NewFreq: Int64);
    procedure BtnBandClick(Sender: TObject);
    procedure ApplyModeFilter;
    procedure BtnModeClick(Sender: TObject);
    procedure BtnFilterClick(Sender: TObject);
    procedure BtnCTunClick(Sender: TObject);
    procedure BtnDUPClick(Sender: TObject);
    procedure ApplyDUP(Active: Boolean);
    procedure BtnTUNClick(Sender: TObject);
    procedure ApplyTUN(Active: Boolean);
    procedure UpdateFilterButtons;
    procedure BtnFMSQClick(Sender: TObject);
    procedure TrkFMSQChange(Sender: TObject);
    procedure ApplyFMSquelch;
    procedure BtnFMCTCSSClick(Sender: TObject);
    procedure BtnFMCTCSSToneClick(Sender: TObject);
    procedure SetFMCTCSSTone(Idx: Integer);
    procedure CloseCTCSSPopup;
    procedure BtnFMRptMinusClick(Sender: TObject);
    procedure BtnFMRptPlusClick(Sender: TObject);
    procedure EdFMRptOffsetChange(Sender: TObject);
    procedure ApplyFMRpt;
    procedure UpdateRptAutoState;
    procedure SetFMStep(Idx: Integer);
    procedure CloseFMStepPopup;
    procedure BtnFMStepClick(Sender: TObject);
    procedure BtnFMStepSelClick(Sender: TObject);
    procedure OnCTCSSDropDownSelect(Sender: TObject; Idx: Integer);
    procedure OnStepDropDownSelect(Sender: TObject; Idx: Integer);
    procedure BtnChannelsClick(Sender: TObject);
    procedure BtnChannelsMouseDown(Sender: TObject; Button: TMouseButton;
      Shift: TShiftState; X, Y: Integer);
    procedure OnChannelsDropDownSelect(Sender: TObject; Idx: Integer);
    procedure RefreshChannelsDropDown;
    procedure OnChannelStoreChanged(Sender: TObject);
    procedure ApplyChannel(const Ch: TChannel);
    procedure CheckChannelActive;
    procedure PbSpectrumMouseDown(Sender: TObject; Button: TMouseButton;
      Shift: TShiftState; X, Y: Integer);
    procedure PbSpectrumMouseMove(Sender: TObject; Shift: TShiftState;
      X, Y: Integer);
    procedure PbSpectrumMouseUp(Sender: TObject; Button: TMouseButton;
      Shift: TShiftState; X, Y: Integer);
    procedure PbSpectrumMouseLeave(Sender: TObject);
    procedure PbWidebandMouseDown(Sender: TObject; Button: TMouseButton;
      Shift: TShiftState; X, Y: Integer);
    procedure PbWaterfallMouseDown(Sender: TObject; Button: TMouseButton;
      Shift: TShiftState; X, Y: Integer);
    procedure PbWaterfallMouseMove(Sender: TObject; Shift: TShiftState;
      X, Y: Integer);
    procedure PbWaterfallMouseUp(Sender: TObject; Button: TMouseButton;
      Shift: TShiftState; X, Y: Integer);
    procedure DoSpectrumClick(PixelX: Integer; PanelWidth: Integer);
    procedure DoSpectrumDrag(PixelX: Integer; PanelWidth: Integer);
    procedure DoWidebandClick(PixelX: Integer; PanelWidth: Integer);
    procedure BtnVfoSwapClick(Sender: TObject);
    procedure BtnVfoACopyBClick(Sender: TObject);
    procedure BtnVfoBCopyAClick(Sender: TObject);
    procedure BtnMOXClick(Sender: TObject);
    procedure BtnMuteClick(Sender: TObject);
    procedure UpdateNRButton;
    procedure UpdateNBButton;
    procedure UpdateSNBButton;
    procedure UpdateANFButton;
    procedure ApplyNoiseFilterButtonsToDSP;
    procedure BtnNRClick(Sender: TObject);
    procedure BtnNBClick(Sender: TObject);
    procedure BtnSNBClick(Sender: TObject);
    procedure BtnANFClick(Sender: TObject);
    // Веб-интерфейс: callbacks от TWebServer
    procedure WebOnFreq(Hz: Double);
    procedure WebOnMode(Mode: Integer);
    procedure WebOnFilter(BW: Integer);
    procedure WebOnAGC(Mode: Integer);
    procedure WebOnAGCTop(DB: Integer);
    procedure WebOnBand(Idx: Integer);
    procedure WebOnSpan(Hz: Integer);
    procedure WebOnVolume(V: Integer);
    procedure WebOnWfAGC(On_: Boolean);
    procedure WebOnWfNF(On_: Boolean);
    procedure WebOnRun(On_: Boolean);
    procedure WebOnMute(On_: Boolean);
    procedure WebOnCtun(On_: Boolean);
    procedure WebOnNR(Mode: Integer);
    procedure WebOnNB(Mode: Integer);
    procedure WebOnSNB(On_: Boolean);
    procedure WebOnANF(On_: Boolean);
    procedure WebOnFreqB(Hz: Double);
    procedure WebOnActiveVfo(Idx: Integer);
    procedure WebOnCenter(Hz: Double);
    procedure WebOnMOX(On_: Boolean);
    procedure WebOnDrive(V: Integer);
    procedure WebOnFreqA(Hz: Double);
    procedure WebOnAttn(Idx: Integer);
    procedure WebOnTun(On_: Boolean);
    procedure WebOnFMStep(Idx: Integer);
    procedure WebOnMic(Samples: PSingle; Count: Integer);
    procedure WebOnXvtrBand(Idx: Integer);
    // Synchronize-обёртки (выполняются в UI-потоке)
    procedure SyncWebFreq;
    procedure SyncWebMode;
    procedure SyncWebFilter;
    procedure SyncWebAGC;
    procedure SyncWebAGCTop;
    procedure SyncWebBand;
    procedure SyncWebXvtrBand;
    procedure SyncWebSpan;
    procedure SyncWebVolume;
    procedure SyncWebWfAGC;
    procedure SyncWebWfNF;
    procedure SyncWebRun;
    procedure SyncWebMute;
    procedure SyncWebCtun;
    procedure SyncWebNR;
    procedure SyncWebNB;
    procedure SyncWebSNB;
    procedure SyncWebANF;
    procedure SyncWebFreqB;
    procedure SyncWebActiveVfo;
    procedure SyncWebCenter;
    procedure SyncWebMOX;
    procedure SyncWebDrive;
    procedure SyncWebFreqA;
    procedure SyncWebAttn;
    procedure SyncWebTun;
    procedure SyncWebFMStep;
    // --- CAT callbacks -------------------------------------------------------
    function  CATGetVfoA:       Double;
    function  CATGetVfoB:       Double;
    function  CATGetMode:       Integer;
    function  CATGetActiveVfo:  Integer;
    function  CATGetAGCMode:    Integer;
    function  CATGetVolume:     Integer;
    function  CATGetDriveLevel: Integer;
    function  CATGetFilterIdx:  Integer;
    function  CATGetFilterBW:   Integer;
    function  CATGetNRMode:     Integer;
    function  CATGetNBMode:     Integer;
    function  CATGetSNB:        Boolean;
    function  CATGetANF:        Boolean;
    function  CATGetTX:         Boolean;
    function  CATGetRunning:    Boolean;
    function  CATGetSMeter:     Double;
    function  CATGetBand:       Integer;
    procedure CATSetVfoA(V: Double);
    procedure CATSetVfoB(V: Double);
    procedure CATSetFilterIdx(V: Integer);
    procedure CATDoBandUp;
    procedure CATDoBandDown;
    procedure CATDoTuneUp;
    procedure CATDoTuneDown;
    procedure SyncCATVfoA;
    procedure SyncCATVfoB;
    procedure SyncCATBandUp;
    procedure SyncCATBandDown;
    procedure SyncCATTuneUp;
    procedure SyncCATTuneDown;
    procedure InitCATEngine;
    procedure CATApplySettings(const G: TGlobalSettings);
    procedure OnCATSettingsChange(
      const SerEnabled: array of Boolean;
      const SerPort: array of string;
      const SerBaud, SerDataBits, SerStopBits, SerParity: array of Integer;
      TcpEnabled: Boolean; TcpPort: Integer);
    procedure OnAlexSettingsChange(const A: TAlexSettings);
    procedure OnXvtrSettingsChange(const X: TXvtrSettings);
    procedure PushXvtrToWeb;
    // XVTR helpers
    function  FindXvtrIdxForFreq(FreqHz: Double): Integer;
    function  XvtrTranslate(VisibleHz: Double): Double;
    procedure ApplyXvtrToNetwork;
    procedure RebuildXvtrButtons;
    procedure RelayoutBelowBands;
    procedure BtnXvtrBandClick(Sender: TObject);
    procedure ActivateXvtrBand(Idx: Integer);
    procedure DeactivateXvtr;
    // -------------------------------------------------------------------------
    procedure BtnSettingsClick(Sender: TObject);
    procedure PositionVfoOverlay;
    procedure OnModeFilterSelect(Mode: Integer; FilterBW: Integer);
    procedure OnVfoOverlayDSPChange(NRMode, NBMode: Integer; SNBOn, ANFOn: Boolean);
    procedure OnVfoOverlayAGCChange(AGCMode: Integer);
    procedure OnVfoOverlayInvalidate(Sender: TObject);
    procedure PbSpectrumDblClick(Sender: TObject);
    procedure TrkDriveChange(Sender: TObject);
    procedure TrkVolumeChange(Sender: TObject);
    function  CalcDriveByte: Byte;
    function  ActiveTXFreqHz: Double;
    function  DefaultMicSource: TTXMicSource;
    procedure ApplyMOX(Active: Boolean);
    procedure OnTXIQReady(const Buf: array of Double; Count: Integer);
    procedure OnPASettingsChange(MaxPower: Double; const BandCal: array of Double);
    procedure OnVHFCalSettingsChange(const VHFCal: array of Double);
    procedure OnTXSettingsChange(const T: TTXSettings);
    procedure BtnAGCModeClick(Sender: TObject);
    procedure TrkAGCChange(Sender: TObject);
    procedure MeterTimerTick(Sender: TObject);
    procedure SpectrumTimerTick(Sender: TObject);
    procedure RightPanelResize(Sender: TObject);
    procedure AfterShowTick(Sender: TObject);
    // Splitter handlers
    procedure SplitterMouseDown(Sender: TObject; Button: TMouseButton;
      Shift: TShiftState; X, Y: Integer);
    procedure SplitterMouseMove(Sender: TObject; Shift: TShiftState;
      X, Y: Integer);
    procedure SplitterMouseUp(Sender: TObject; Button: TMouseButton;
      Shift: TShiftState; X, Y: Integer);

  public
    // Методы для SettingsForm — немедленное применение настроек
    procedure ApplyDisplayParams(FFTSize, WinType, SpecDet, SpecAvgMode: Integer;
      SpecAvgTimeMS: Double);
    procedure ApplyWaterfallParams(WfDet, WfAvgMode: Integer;
      WfAvgTimeMS, WfHigh, WfLow, WfAGCOffset: Double);
    procedure ApplyWfAGCNF(WfAGC, WfNF: Boolean);
    procedure ApplyADCSettings(Dither, Random: Boolean);
    procedure ApplyWebSettings(Enabled: Boolean; Port: Integer;
      const BindAddr, User, Pass: string);
    procedure ApplyGridParams(RefLevel, Range, GridStep: Double);
    // Пушит активные grid-параметры (RX или TX в зависимости от FTransmitting)
    // в FSpecView и сбрасывает кэш сетки. Вызывается при смене RX↔TX и при
    // правке любого из соответствующих полей (FSpec*, FTXSpec*).
    procedure ApplySpecViewGridFromState;
    procedure ApplyAudioDevice(DevIndex: Integer; const DevName: string);
    procedure ApplyAudioInputDevice(DevIndex: Integer; const DevName: string);
    procedure ApplyAudioBufferSize(BufferSize: Integer);
    procedure ApplySendAudioToRadio(Enabled: Boolean);
    // TX settings: применить FTXSettings к WDSP и переслать DUC Specific
    // (mic-биты Boost/Bias/LineIn/PTT попадают в byte 50 DUCSpecific).
    procedure ApplyTXSettingsToDSP;
    procedure SendDUCSpecificFromSettings;
    function  PullSoundCardMic(MaxN: Integer): Integer;
    function  BuildMicLineSelectByte: Byte;
    procedure ApplyVisibility(ShowSpectrum, ShowWaterfall, ShowWideband,
      WidebandFill, SpectrumFill: Boolean);
    procedure ApplyFPS(FPS: Integer);
    procedure ApplyFreqMhzDigits(Digits: Integer);

  published
    // Обработчики событий формы — должны быть в published для RTTI/LFM
    procedure FormCreate(Sender: TObject);
    procedure FormDestroy(Sender: TObject);
    procedure FormClose(Sender: TObject; var CloseAction: TCloseAction);
    procedure FormMouseWheel(Sender: TObject; Shift: TShiftState;
                             WheelDelta: Integer; MousePos: TPoint;
                             var Handled: Boolean);
  end;

var
  MainForm: TMainForm;

implementation

uses SettingsForm;

{$R *.lfm}

function TMainForm.LeftPanelButtonWidth(PanelWidth, ColCount,
  ColIndex: Integer): Integer;
var
  InnerW, BaseW, Extra: Integer;
begin
  if ColCount <= 0 then Exit(0);
  InnerW := PanelWidth - 2 * LEFT_PANEL_BTN_PAD -
            (ColCount - 1) * LEFT_PANEL_BTN_GAP;
  BaseW := InnerW div ColCount;
  Extra := InnerW mod ColCount;
  Result := BaseW;
  if ColIndex < Extra then Inc(Result);
end;

function TMainForm.LeftPanelButtonLeft(PanelWidth, ColCount,
  ColIndex: Integer): Integer;
var
  i: Integer;
begin
  Result := LEFT_PANEL_BTN_PAD;
  for i := 0 to ColIndex - 1 do
    Inc(Result, LeftPanelButtonWidth(PanelWidth, ColCount, i) +
      LEFT_PANEL_BTN_GAP);
end;

// ===========================================================================
// Settings helpers
// ===========================================================================

procedure TMainForm.RestoreWindowBounds;
var
  L, T, Wd, Ht, SavedDPI, CurDPI: Integer;
  Maximized: Boolean;
  R, WA: TRect;
  Mon: TMonitor;
begin
  FController.FSettings.LoadWindowBounds(L, T, Wd, Ht, SavedDPI, Maximized);
  if (Wd > 400) and (Ht > 300) then
  begin
    CurDPI := CurrentScreenDPI;
    if SavedDPI <= 0 then SavedDPI := CurDPI;

    if SavedDPI <> CurDPI then
    begin
      // Позицию не масштабируем: на LCLWin32 координаты физические и не зависят
      // от DPI-фактора; на Qt6/GTK/Cocoa — логические (device-independent), тоже
      // инвариантны. Выход за рабочую область поймает EnsureRange ниже.
      //
      // Размер масштабируем, чтобы окно выглядело одинаково при смене DPI:
      //   LCLWin32: физические пиксели → Wd × CurDPI/SavedDPI
      //   Qt6/GTK/Cocoa: логические пиксели → Wd × SavedDPI/CurDPI (обратное)
      {$IFDEF LCLWin32}
      Wd := MulDiv(Wd, CurDPI, SavedDPI);
      Ht := MulDiv(Ht, CurDPI, SavedDPI);
      {$ELSE}
      Wd := MulDiv(Wd, SavedDPI, CurDPI);
      Ht := MulDiv(Ht, SavedDPI, CurDPI);
      {$ENDIF}
    end;

    // Минимальный размер:
    //   LCLWin32 — в физических пикселях, масштабируется с DPI.
    //   Qt6/GTK/Cocoa — в логических пикселях, device-independent, фиксированный.
    {$IFDEF LCLWin32}
    Wd := Max(MulDiv(900, CurDPI, 96), Wd);
    Ht := Max(MulDiv(560, CurDPI, 96), Ht);
    {$ELSE}
    Wd := Max(900, Wd);
    Ht := Max(560, Ht);
    {$ENDIF}
    R := Bounds(L, T, Wd, Ht);
    Mon := Screen.MonitorFromRect(R);
    if Mon <> nil then WA := Mon.WorkareaRect
    else              WA := Screen.PrimaryMonitor.WorkareaRect;

    if Wd > WA.Right - WA.Left then Wd := WA.Right - WA.Left;
    if Ht > WA.Bottom - WA.Top then Ht := WA.Bottom - WA.Top;
    L := EnsureRange(L, WA.Left,  Max(WA.Left, WA.Right - Wd));
    T := EnsureRange(T, WA.Top,   Max(WA.Top,  WA.Bottom - Ht));

    SetBounds(L, T, Wd, Ht);
    if Maximized then
      WindowState := wsMaximized
    else
    begin
      // На Qt6/X11 frame extents ещё не известны Qt во время FormCreate,
      // поэтому move() может поставить контент туда, куда мы хотели рамку.
      // Повторно применим позицию в AfterShowTick, когда WM уже декорировал окно.
      FRestoreL := L;
      FRestoreT := T;
      FHasPendingRestore := True;
    end;
  end;
end;

procedure TMainForm.SaveWindowBounds;
var
  L, T, Wd, Ht, DPI: Integer;
  Maximized: Boolean;
begin
  Maximized := WindowState = wsMaximized;
  if WindowState = wsNormal then
  begin
    L := Left; T := Top; Wd := Width; Ht := Height;
  end
  else
  begin
    L := RestoredLeft; T := RestoredTop;
    Wd := RestoredWidth; Ht := RestoredHeight;
  end;

  // RestoredLeft/Top могут вернуть мусор на Qt6 при максимизации;
  // ставим разумные запасные значения
  if (L < -32000) or (T < -32000) then begin L := 80; T := 80; end;
  if (Wd <= 400) or (Ht <= 300) then Exit;
  DPI := CurrentScreenDPI;
  FController.FSettings.SaveWindowBounds(L, T, Wd, Ht, DPI, Maximized);
end;

function TMainForm.MakeBandSettings: TBandSettings;
begin
  Result.VfoA      := FController.FVfoA;
  Result.VfoB      := FController.FVfoB;
  Result.Mode      := FController.FMode;
  Result.FilterIdx := FController.FFilter;
  Result.FilterBW  := FController.FFilterBW;
  Result.AGCMode   := FController.FAGCMode;
  Result.AGCTop    := FController.FAGCTop;
  Result.CTun      := FController.FCTun;
  Result.SpanHz    := FController.FSpanHz;
  Result.FMSQOn       := FController.FFMSQOn;
  Result.FMSQLevel    := FController.FFMSQLevel;
  Result.CTCSSOn      := FController.FFMCTCSSOn;
  Result.CTCSSToneIdx := FController.FFMCTCSSToneIdx;
  Result.FMStepOn     := FController.FFMStepOn;
  Result.FMStepIdx    := FController.FFMStepIdx;
  Result.FMRptDir      := FController.FFMRptDir;
  Result.FMRptOffsetHz := FController.FFMRptOffsetHz;
  // Waterfall AGC/NF — глобальные (не диапазонные)
end;

function TMainForm.MakeGlobalSettings: TGlobalSettings;
var i: Integer;
begin
  Result.Volume           := FController.FVolume;
  Result.DriveLevel       := FController.FDrivePercent;
  Result.PAMaxPower       := FController.FPAMaxPower;
  for i := 0 to BAND_COUNT - 1 do
    Result.PABandCal[i]   := FController.FPABandCal[i];
  for i := 0 to CFG_XVTR_COUNT - 1 do
    Result.VHFBandCal[i]  := FController.FVHFBandCal[i];
  Result.ActiveVfo        := FController.FActiveVfo;
  Result.NRMode           := FController.FNRMode;
  Result.NBMode           := FController.FNBMode;
  Result.SNBEnabled       := FController.FSNB;
  Result.ANFEnabled       := FController.FANF;
  Result.AGCSlope         := 0;
  Result.AGCHangThreshold := 100;
  Result.WfAGCEnabled     := FController.FWfAGCEnabled;
  Result.WfNFEnabled      := FController.FWfNFEnabled;
  Result.DitherEnabled    := FController.FDitherEnabled;
  Result.RandomEnabled    := FController.FRandomEnabled;
  Result.LastBand         := FController.FCurrentBand;
  Result.LastXvtr         := FController.FCurrentXvtr;
  Result.SampleRate       := FController.FSampleRate;
  // Display settings
  if FController.FWDSPReady then
  begin
    Result.FFTSize      := FController.FDSPEngine.FFTSize;
    Result.WindowType   := FController.FDSPEngine.WindowType;
    Result.SpecDetector := FController.FDSPEngine.SpecDetector;
    Result.SpecAvgMode  := FController.FDSPEngine.SpecAvgMode;
    Result.SpecAvgTimeMS := FController.FDSPEngine.SpecAvgTimeMS;
    Result.WfDetector   := FController.FDSPEngine.WfDetector;
    Result.WfAvgMode    := FController.FDSPEngine.WfAvgMode;
    Result.WfAvgTimeMS  := FController.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.SpecRefLevel    := FSpecRefLevel;
  Result.SpecRange       := FSpecRange;
  Result.SpecGridStep    := FSpecGridStep;
  Result.AudioSampleRate := FController.FAudioOut.SampleRate;
  Result.ShowSpectrum    := FShowSpectrum;
  Result.ShowWaterfall   := FShowWaterfall;
  Result.ShowWideband    := FShowWideband;
  Result.WidebandFill    := FWidebandFill;
  Result.SpectrumFill    := FSpectrumFill;
  Result.DisplayDuplex   := FController.FDisplayDuplex;
  Result.DisplayFPS      := FDisplayFPS;
  Result.LightTheme      := FLightTheme;
  Result.FreqMhzDigits   := FFreqMhzDigits;
  // Audio device names
  Result.AudioOutDevice := FController.FAudioOutDevName;
  Result.AudioInDevice  := FController.FAudioInDevName;
  Result.SendAudioToRadio := FController.FSendAudioToRadio;
  // CAT settings — carried from the last loaded/saved device config
  Result.CATSerialEnabled  := FCATLastGlobal.CATSerialEnabled;
  Result.CATSerialPort     := FCATLastGlobal.CATSerialPort;
  Result.CATSerialBaud     := FCATLastGlobal.CATSerialBaud;
  Result.CATSerialDataBits := FCATLastGlobal.CATSerialDataBits;
  Result.CATSerialStopBits := FCATLastGlobal.CATSerialStopBits;
  Result.CATSerialParity   := FCATLastGlobal.CATSerialParity;
  Result.CATTcpEnabled     := FCATLastGlobal.CATTcpEnabled;
  Result.CATTcpPort        := FCATLastGlobal.CATTcpPort;
end;

procedure TMainForm.SaveCurrentBand;
begin
  if not FController.FDevConnected then Exit;
  // В XVTR-режиме НЕ трогаем HF band-cache: FController.FBandCache[FController.FCurrentBand]
  // относится к HF-диапазону, на котором пользователь был ДО активации
  // трансвертера, и FController.FVfoA сейчас на XVTR-частоте (144.x, 432.x и т.д.).
  // Запись в FController.FBandCache[FController.FCurrentBand] стерла бы корректное HF-состояние.
  // Вместо этого сохраняем LastFreq в XVTR-настройках.
  if FController.FCurrentXvtr >= 0 then
  begin
    if (FController.FCurrentXvtr < CFG_XVTR_COUNT) then
    begin
      FController.FXvtrSettings.Entries[FController.FCurrentXvtr].LastFreq         := FController.FVfoA;
      FController.FXvtrSettings.Entries[FController.FCurrentXvtr].LastMode         := FController.FMode;
      FController.FXvtrSettings.Entries[FController.FCurrentXvtr].LastFilterIdx    := FController.FFilter;
      FController.FXvtrSettings.Entries[FController.FCurrentXvtr].LastFMSQOn       := FController.FFMSQOn;
      FController.FXvtrSettings.Entries[FController.FCurrentXvtr].LastFMSQLevel    := FController.FFMSQLevel;
      FController.FXvtrSettings.Entries[FController.FCurrentXvtr].LastCTCSSOn      := FController.FFMCTCSSOn;
      FController.FXvtrSettings.Entries[FController.FCurrentXvtr].LastCTCSSToneIdx := FController.FFMCTCSSToneIdx;
      FController.FXvtrSettings.Entries[FController.FCurrentXvtr].LastFMStepOn     := FController.FFMStepOn;
      FController.FXvtrSettings.Entries[FController.FCurrentXvtr].LastFMStepIdx    := FController.FFMStepIdx;
      FController.FXvtrSettings.Entries[FController.FCurrentXvtr].LastFMRptDir        := FController.FFMRptDir;
      FController.FXvtrSettings.Entries[FController.FCurrentXvtr].LastFMRptOffsetHz   := FController.FFMRptOffsetHz;
      FController.FXvtrSettings.Entries[FController.FCurrentXvtr].LastCTun            := FController.FCTun;
      FController.FXvtrSettings.Entries[FController.FCurrentXvtr].LastAGCMode         := FController.FAGCMode;
      FController.FXvtrSettings.Entries[FController.FCurrentXvtr].LastAGCTop          := FController.FAGCTop;
      FController.FXvtrSettings.Entries[FController.FCurrentXvtr].LastCTCSSAutoActive := FController.FFMCTCSSAutoActive;
      FController.FXvtrSettings.Entries[FController.FCurrentXvtr].LastFMRptAutoActive := FController.FFMRptAutoActive;
    end;
    FController.FSettings.SaveXvtr(FController.FDevMAC, FController.FXvtrSettings);
    Exit;
  end;
  // Не сохраняем, если VFO ушёл в VHF/UHF область (например, после применения
  // канала на 2м без XVTR) — иначе HF-кэш диапазона будет перезаписан VHF-частотой.
  if FController.FVfoA > 61000000 then Exit;
  FController.FBandCache[FController.FCurrentBand] := MakeBandSettings;
  FController.FSettings.SaveBand(FController.FDevMAC, FController.FCurrentBand, FController.FBandCache[FController.FCurrentBand]);
end;

procedure TMainForm.RestoreBand(BandIdx: Integer);
var
  B: TBandSettings;
  i: Integer;
begin
  if (BandIdx < 0) or (BandIdx >= CFG_BAND_COUNT) then Exit;
  B := FController.FBandCache[BandIdx];

  // --- Кнопки диапазонов ---
  for i := 0 to BAND_COUNT - 1 do
    StyleButton(BtnBand[i], i = BandIdx);
  // Сбрасываем waterfall thresholds при смене диапазона
  FSpecView.ResetWfAvgBuf;

  // --- Режим: сначала устанавливаем FController.FMode, потом кнопки и WDSP ---
  FController.FMode := B.Mode;
  for i := 0 to MODE_COUNT - 1 do
    StyleButton(BtnMode[i], i = FController.FMode);
  if FController.FWDSPReady then
    FController.FDSPEngine.SetMode(FController.FMode);

  // --- Фильтр ---
  FController.FFilter   := B.FilterIdx;
  FController.FFilterBW := B.FilterBW;
  UpdateFilterButtons;          // обновляет кнопки фильтра
  ApplyModeFilter;              // применяет Lo/Hi в WDSP с учётом нового FController.FMode

  // --- AGC ---
  FController.FAGCMode := B.AGCMode;
  FController.FAGCTop  := B.AGCTop;
  for i := 0 to 4 do
    StyleButton(BtnAGCMode[i], i = FController.FAGCMode);
  TrkAGC.Position := FController.FAGCTop;
  LblAGCTop.Caption := Format('%ddB', [FController.FAGCTop]);
  if FController.FWDSPReady then
  begin
    FController.FDSPEngine.SetAGCTop(FController.FAGCTop);
    FController.FDSPEngine.SetAGC(TWDSPAGCMode(FController.FAGCMode), 50.0);
  end;
  FSpecView.AGCTop := FController.FAGCTop;

  // --- FM Squelch + CTCSS ---
  FController.FFMSQOn    := B.FMSQOn;
  FController.FFMSQLevel := B.FMSQLevel;
  // UI update happens inside UpdateFilterButtons (called above) when mode=FM
  FController.FFMCTCSSOn := B.CTCSSOn;
  if BtnFMCTCSS <> nil then StyleButton(BtnFMCTCSS, FController.FFMCTCSSOn);
  SetFMCTCSSTone(B.CTCSSToneIdx);
  ApplyFMSquelch;
  FController.FFMStepOn := B.FMStepOn;
  SetFMStep(B.FMStepIdx);
  if BtnFMStep <> nil then StyleButton(BtnFMStep, FController.FFMStepOn);
  FController.FFMRptDir      := B.FMRptDir;
  FController.FFMRptOffsetHz := B.FMRptOffsetHz;
  if FController.FFMRptOffsetHz <= 0 then
    FController.FFMRptOffsetHz := RptDefaultOffsetHz(B.VfoA);
  if BtnFMRptMinus <> nil then StyleButton(BtnFMRptMinus, FController.FFMRptDir = RPT_MINUS);
  if BtnFMRptPlus  <> nil then StyleButton(BtnFMRptPlus,  FController.FFMRptDir = RPT_PLUS);
  if EdFMRptOffset <> nil then EdFMRptOffset.Text := RptFormatOffset(FController.FFMRptOffsetHz);

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

  // --- Sample Rate / Span ---
  // SampleRate — глобальный, не per-band. Не восстанавливаем из диапазона.
  FController.FSpanHz := FController.FSampleRate;
  if Assigned(FSampleRateOverlay) then
    FSampleRateOverlay.SetCurrentRate(FController.FSampleRate);

  // --- VFO A: центрируем на новой частоте, сбрасываем shift ---
  FController.FCenterFreq := B.VfoA;        // DDC = центр диапазона
  FController.FVfoB       := B.VfoB;
  FreqDispB.Frequency := Round(FController.FVfoB);

  // Пересчитываем drive byte для нового диапазона перед отправкой в сеть
  FController.FDriveLevel := CalcDriveByte;
  // ApplyVfoA обновит сеть, FreqDisp, shift, перерисует
  ApplyVfoA(Round(B.VfoA));
end;

procedure TMainForm.SaveAllAndExit;
begin
  if FController.FDevConnected then
  begin
    SaveCurrentBand;
    FController.FSettings.SaveGlobal(FController.FDevMAC, MakeGlobalSettings);
    if FController.FDevConnected then
      FController.FSettings.SaveTX(FController.FDevMAC, FController.FTXSettings);
    // Запомнить LastFreq XVTR для следующего запуска
    if (FController.FCurrentXvtr >= 0) and (FController.FCurrentXvtr < CFG_XVTR_COUNT) then
      FController.FXvtrSettings.Entries[FController.FCurrentXvtr].LastFreq := FController.FVfoA;
    FController.FSettings.SaveXvtr(FController.FDevMAC, FController.FXvtrSettings);
    FController.FSettings.Save;
  end;
end;

procedure TMainForm.FormCreate(Sender: TObject);
var
  bi_, StartupRate, i: Integer;
  WebCfg: TWebSettings;
  StartupVfoA, StartupVfoB: Double;
begin
  SetExceptionMask(GetExceptionMask + [exZeroDivide, exInvalidOp]);
  // Headless ядро: владеет состоянием и движками. Создаём первым — все
  // присваивания FController.FXxx ниже зависят от него. Движки создаются/
  // освобождаются здесь же (в полях FController) — Фаза 1 миграции.
  FController := TRadioController.Create;
  FController.OnInvoke := DoInvoke;
  FController.OnStateChanged := OnControllerState;
  FController.FVfoA      := 14200000;
  FController.FVfoB      := 7100000;
  FController.FActiveVfo := 0;
  FController.FMode      := 1;
  FController.FFilter    := 5;
  FController.FAGCMode   := 1;   // MEDIUM
  FController.FAGCTop    := 90;  // −90 dBm
   // default: зависит от режима, будет сброшен в UpdateFilterButtons
  FController.FCTun      := False;
  FController.FFilterBW  := 2700;
  FSpecDrag  := False;
  FController.FDriveLevel    := 0;
  FController.FPAMaxPower    := 100.0;
  for i := 0 to BAND_COUNT - 1 do FController.FPABandCal[i] := CFG_PA_BAND_CAL_DEFAULT[i];
  for i := 0 to CFG_XVTR_COUNT - 1 do FController.FVHFBandCal[i] := CFG_VHF_CAL_DEFAULT;
  FController.FRunning       := False;
  FController.FTransmitting  := False;
  FController.FSplitTxB      := False;
  FDUCPendingCount := 0;
  FController.FDisplayDuplex := False;
  FController.FTuning        := False;
  FController.FMuted     := False;
  FController.FVolume    := 70;
  FController.FCenterFreq    := FController.FVfoA;
  FController.FSpanHz        := 192000;
  FController.FSampleRate    := 192000;
  FController.FCurrentBand   := 5;  // 20m по умолчанию
  // Waterfall normalization
  FWfManualHigh := -80.0;
  FWfManualLow  := -130.0;
  FWfAGCOffset  := 0.0;
  FController.FWfAGCEnabled := True;
  FController.FWfNFEnabled  := False;
  FController.FDitherEnabled := True;
  FController.FRandomEnabled := True;
  FController.FFMDeviation    := FILT_FM_DEV[FILT_FM_DEF];
  FController.FFMCTCSSOn      := False;
  FController.FFMCTCSSToneIdx := 0;
  FController.FFMSQOn         := False;
  FController.FFMSQLevel      := 30;
  FController.FFMStepOn       := True;
  FController.FFMStepIdx      := FM_STEP_DEF;
  FController.FFMRptDir          := RPT_NONE;
  FController.FFMRptOffsetHz     := RPT_DEFAULT_2M;
  FController.FFMRptAutoActive   := False;
  FController.FFMCTCSSAutoActive := False;
  FPreChannelPower   := -1;
  FSpectrumBufCount := 1024;
  FWaterfallBufCount := 1024;
  FWaterfallFrameInterval := 2;
  FWaterfallFrameCounter := 0;
  FController.FDevConnected  := False;
  // Spectrum grid settings defaults
  FSpecRefLevel  := -20.0;
  FSpecRange     := 110.0;
  FSpecGridStep  := 10.0;
  // TX grid defaults (синхронно с TSettingsManager.DefaultTX)
  FTXSpecRefLevel := 0.0;
  FTXSpecRange    := 80.0;
  FTXSpecGridStep := 10.0;
  FSettingsForm  := nil;
  FChannelsForm     := nil;
  FActiveChannelIdx := -1;
  FillChar(FController.FDevMAC, SizeOf(FController.FDevMAC), 0);

  // Инициализируем кэш диапазонов умолчаниями
  for bi_ := 0 to CFG_BAND_COUNT - 1 do
    TSettingsManager.DefaultBand(bi_, FController.FBandCache[bi_]);
  FController.FCurrentBand := 5;

  // Загружаем JSON настройки
  FController.FSettings := TSettingsManager.Create;
  FController.FSettings.Load;

  // Загружаем список каналов
  FController.FChannelStore := TChannelStore.Create(CHANNELS_FILE);
  FController.FChannelStore.Load;
  if FController.FSettings.LoadStartupPreview(StartupVfoA, StartupVfoB, StartupRate) then
  begin
    FController.FVfoA := StartupVfoA;
    FController.FVfoB := StartupVfoB;
    FController.FCenterFreq := FController.FVfoA;
    if StartupRate > 0 then
    begin
      FController.FSampleRate := StartupRate;
      FController.FSpanHz     := StartupRate;
    end;
  end;
  // Восстанавливаем размер/позицию окна при старте (до подключения устройства)
  RestoreWindowBounds;
  // S-метр позиционируем после рестора размера окна
  // (будет пересчитан в первом тике SpectrumTimerTick)
  FLastSMeter    := -130;

  // Загружаем настройки веб-сервера из JSON и создаём сервер
  FController.FSettings.LoadWebSettings(WebCfg);
  FWebEnabled  := WebCfg.Enabled;
  FWebPort     := WebCfg.Port;
  FWebBindAddr := WebCfg.BindAddr;
  FWebUser     := WebCfg.User;
  FWebPass     := WebCfg.Pass;
  FWebServer := TWebServer.Create(FWebUser, FWebPass, Word(FWebPort), FWebBindAddr);
  FWebServer.OnFreq   := WebOnFreq;
  FWebServer.OnMode   := WebOnMode;
  FWebServer.OnFilter := WebOnFilter;
  FWebServer.OnAGC    := WebOnAGC;
  FWebServer.OnAGCTop := WebOnAGCTop;
  FWebServer.OnBand   := WebOnBand;
  FWebServer.OnSpan   := WebOnSpan;
  FWebServer.OnVolume := WebOnVolume;
  FWebServer.OnWfAGC  := WebOnWfAGC;
  FWebServer.OnWfNF   := WebOnWfNF;
  FWebServer.OnRun    := WebOnRun;
  FWebServer.OnMute   := WebOnMute;
  FWebServer.OnCtun   := WebOnCtun;
  FWebServer.OnNR     := WebOnNR;
  FWebServer.OnNB     := WebOnNB;
  FWebServer.OnSNB    := WebOnSNB;
  FWebServer.OnANF    := WebOnANF;
  FWebServer.OnFreqB     := WebOnFreqB;
  FWebServer.OnActiveVfo := WebOnActiveVfo;
  FWebServer.OnCenter    := WebOnCenter;
  FWebServer.OnMOX       := WebOnMOX;
  FWebServer.OnDrive     := WebOnDrive;
  FWebServer.OnFreqA     := WebOnFreqA;
  FWebServer.OnAttn      := WebOnAttn;
  FWebServer.OnTun       := WebOnTun;
  FWebServer.OnFMStep    := WebOnFMStep;
  FWebServer.OnWebMic    := WebOnMic;
  FWebServer.OnXvtrBand  := WebOnXvtrBand;
  if FWebEnabled then FWebServer.Start;
  PushXvtrToWeb;
  FillChar(FCATLastGlobal, SizeOf(FCATLastGlobal), 0);
  FCATLastGlobal.CATTcpPort := 19090;
  for i := 0 to 3 do
  begin
    FCATLastGlobal.CATSerialBaud[i]     := 9600;
    FCATLastGlobal.CATSerialDataBits[i] := 8;
    FCATLastGlobal.CATSerialStopBits[i] := 1;
    {$IFDEF MSWINDOWS}
    FCATLastGlobal.CATSerialPort[i] := 'COM' + IntToStr(i + 1);
    {$ELSE}
    FCATLastGlobal.CATSerialPort[i] := '/dev/ttyS' + IntToStr(i);
    {$ENDIF}
  end;
  FController.FSettings.LoadCATSettings(FCATLastGlobal);
  FLightTheme := FController.FSettings.LoadTheme;
  InitCATEngine;
  CATApplySettings(FCATLastGlobal);
  FSMeterPeak    := -130;
  FSMeterMin     := -130;
  FSMeterAvg     := -130;
  FRXPacketCount := 0;
  FActiveDDC     := 0;
  FLastDDCSeq    := 0;
  FLastDDCIndex  := 0;
  FillChar(FDDCLastSeq,  SizeOf(FDDCLastSeq),  0);
  FillChar(FDDCSeqValid, SizeOf(FDDCSeqValid), 0);
  FSeqErrorCount    := 0;
  FLastSeqErrorDDC  := -1;
  FLastSeqErrorDelta:= 0;
  FSeqOkStreak      := 0;
  FLastFwdW   := 0;
  FLastSWR    := 1;
  FLastSupplyV:= -1;
  FLastSupplyA:= -1;
  FLastPLLLock:= False;
  FDeviceCount  := 0;
  FDeviceDialog := TDeviceDialog.Create(Self);
  FDeviceDialog.OnDiscover := BtnDiscoverFromDialog;
  FVfoOverlay := TVfoOverlay.Create(Self);
  FVfoOverlay.OnSelect    := OnModeFilterSelect;
  FVfoOverlay.OnDSPChange := OnVfoOverlayDSPChange;
  FVfoOverlay.OnAGCChange := OnVfoOverlayAGCChange;
  FVfoOverlay.OnInvalidate := OnVfoOverlayInvalidate;
  FVfoOverlay.Visible := False;
  FPanelHidden  := False;
  FShowSpectrum  := True;
  FShowWaterfall := True;
  FShowWideband  := False;
  FWidebandFill  := False;
  FSpectrumFill  := True;
  FDisplayFPS    := 60;
  FFreqMhzDigits := 3;
  FSplitterRatio := 0.40;
  FSplitterDrag  := False;

  FUseOpenGLSpectrum := HasOpenGLSpectrumSwitch;
  if FUseOpenGLSpectrum and (not QtPlatformAllowsOpenGLControls) then
  begin
    MessageDlg('OpenGL rendering disabled',
      'OpenGL rendering is not available in the current Qt Wayland mode.' + LineEnding +
      LineEnding +
      'The Lazarus/LCL Qt6 OpenGL control used by EWSDR requires an X11 display handle, ' +
      'but the native Wayland backend does not provide one. Using it here can cause an access violation during startup.' +
      LineEnding +
      LineEnding +
      'To run EWSDR with OpenGL rendering, start it through XWayland:' + LineEnding +
      'QT_QPA_PLATFORM=xcb ./ewsdr --opengl' + LineEnding +
      LineEnding +
      'OpenGL rendering will be disabled for this session.',
      mtWarning, [mbOK], 0);
    FUseOpenGLSpectrum := False;
  end;
  if FUseOpenGLSpectrum then
    FWaterfallFrameInterval := 1;
  if FUseOpenGLSpectrum then
    FSpecView := TSpectrumViewOpenGL.Create
  else
    FSpecView := TSpectrumView.Create;
  FSpecView.VfoOverlay := FVfoOverlay;
  FSpecView.VfoA           := FController.FVfoA;
  FSpecView.VfoB           := FController.FVfoB;
  FSpecView.ActiveVfo      := FController.FActiveVfo;
  FSpecView.CenterFreq     := FController.FCenterFreq;
  FSpecView.SpanHz         := FController.FSpanHz;
  FSpecView.Mode           := FController.FMode;
  FSpecView.FilterBW       := FController.FFilterBW;
  FSpecView.AGCTop         := FController.FAGCTop;
  FSpecView.WfAGCEnabled   := FController.FWfAGCEnabled;
  FSpecView.WfNFEnabled    := FController.FWfNFEnabled;
  FSpecView.WfManualHigh   := FWfManualHigh;
  FSpecView.WfManualLow    := FWfManualLow;
  FSpecView.WfAGCOffset    := FWfAGCOffset;
  FSpecView.SpecRefLevel   := FSpecRefLevel;
  FSpecView.SpecRange      := FSpecRange;
  FSpecView.SpecGridStep   := FSpecGridStep;
  // TX overlay: ширина окна = TX_SAMPLE_RATE из движка (фикс 192k)
  FSpecView.TXSpanHz        := WDSPEngine.TX_SAMPLE_RATE;
  FSpecView.WfFrameInterval := FWaterfallFrameInterval;
  FSpecView.PAMaxPower      := FController.FPAMaxPower;
  FWidebandView := TWidebandView.Create;

  FController.FNetwork := THPSDRNetwork.Create;
  FController.FNetwork.OnDeviceFound := OnDeviceFound;
  FController.FNetwork.OnHPStatus    := OnHPStatusCB;
  FController.FNetwork.OnDDCIQ       := OnDDCIQCB;
  FController.FNetwork.OnMicPacket   := OnMicPacketCB;
  FController.FNetwork.OnWideband    := OnWidebandCB;
  // Дефолты TX, Alex и XVTR (на случай если устройство ещё не выбрано).
  TSettingsManager.DefaultTX(FController.FTXSettings);
  TSettingsManager.DefaultAlex(FController.FAlexSettings);
  TSettingsManager.DefaultXvtr(FController.FXvtrSettings);
  FController.FCurrentXvtr := -1;

  BuildUI;
  ResizeSMeter;
  ApplyDarkTheme;
  UpdateVfoDisplay;
  UpdateFilterButtons;

  // DSP Engine — создаём объект, Open вызовется при нажатии START
  // (FController.FDSPEngine.Open загружает libwdsp и занимает ~1-2 сек)
  FController.FWDSPReady := False;
  // BufSize=512: FBufSize=2048 @ 192kHz, FAudioBufSize=512 @ 48kHz.
  // Уменьшение с 1024 вдвое сокращает время построения downsampler в OpenChannel RXA
  // (~1300ms → ~650ms). Латентность: 512/48000 ≈ 10.7ms — допустимо для SDR.
  FController.FDSPEngine := TWDSPEngine.Create(FController.FSampleRate, 48000, 512);
  FController.FDSPEngine.OnAudio     := OnAudioReady;
  FController.FDSPEngine.OnSpectrum  := OnSpectrumReady;
  FController.FDSPEngine.OnWaterfall := OnWaterfallReady;
  FController.FDSPEngine.OnTXIQ      := OnTXIQReady;
  // Sound-card mic путь: WDSP TX-thread дёргает этот колбэк перед каждым
  // тиком, если выбран источник SoundCard. Для Radio колбэк не вызывается.
  FController.FDSPEngine.OnPullMicSamples := PullSoundCardMic;

  // Audio output/input — создаём объекты сейчас, открываем после показа формы
  // (Pa_Initialize на Linux пишет в stderr до перехвата сигналов FPC)
  FController.FAudioOut := TAudioOutput.Create(48000);
  FController.FAudioOut.OutputBufferSize := FController.FSettings.LoadAudioBufferSize;
  FController.FAudioIn  := TAudioInput.Create(48000);

  FMeterTimer := TTimer.Create(Self);
  FMeterTimer.Interval := 100;
  FMeterTimer.OnTimer  := MeterTimerTick;
  FMeterTimer.Enabled  := True;


  FSpectrumTimer := TTimer.Create(Self);
  FSpectrumTimer.Interval := 50;
  FSpectrumTimer.OnTimer  := SpectrumTimerTick;
  FSpectrumTimer.Enabled  := True;

  // Однократный таймер — инициализация аудио после показа формы
  FAfterShowTimer := TTimer.Create(Self);
  FAfterShowTimer.Interval := 200;
  FAfterShowTimer.OnTimer  := AfterShowTick;
  FAfterShowTimer.Enabled  := True;

  OnMouseWheel := FormMouseWheel;

  // Проверяем и при необходимости добавляем исключение в Windows Firewall
  // (UDP входящий трафик для HPSDR Protocol 2)
  {$IFDEF WINDOWS}
  FirewallEnsureAllowed(ParamStr(0), 'OpenHPSDR Transceiver');
  {$ENDIF}
end;

procedure TMainForm.FormDestroy(Sender: TObject);
begin
  FMeterTimer.Enabled    := False;
  FSpectrumTimer.Enabled := False;
  if FController.FNetwork.Running then
    FController.FNetwork.SetRunAndFreq(False, XvtrTranslate(FController.FCenterFreq), XvtrTranslate(FController.FCenterFreq), 0);
  FController.FNetwork.Disconnect;
  FController.FNetwork.Free;
  FController.FAudioOut.Close;
  FController.FAudioOut.Free;
  FController.FAudioIn.Close;
  FController.FAudioIn.Free;
  FController.FDSPEngine.Close;
  FController.FDSPEngine.Free;
  FreeAndNil(FSpecView);
  FreeAndNil(FWidebandView);

  // Сохраняем настройки при закрытии
  if FController.FDevConnected then
  begin
    SaveCurrentBand;
    FController.FSettings.SaveGlobal(FController.FDevMAC, MakeGlobalSettings);
    if FController.FDevConnected then
      FController.FSettings.SaveTX(FController.FDevMAC, FController.FTXSettings);
  end;
  // Размер окна сохраняем всегда (не зависит от подключения)
  FController.FSettings.SaveStartupPreview(FController.FVfoA, FController.FVfoB, FController.FSampleRate);
  FController.FSettings.Save;
  FController.FSettings.Free;
  FreeAndNil(FController.FChannelStore);
  FWebServer.Stop;
  FWebServer.Free;
  if Assigned(FCATTcp)    then begin FCATTcp.Stop; FreeAndNil(FCATTcp); end;
  if Assigned(FCATSerial) then begin FCATSerial.StopAll; FreeAndNil(FCATSerial); end;
  FreeAndNil(FCATEngine);
  // Движки (поля FController) уже освобождены выше.
  // Освобождаем само ядро последним.
  FreeAndNil(FController);
end;

// Маршалинг команд контроллера в UI-поток (фронтенды зовут FController.Invoke).
// В режиме демона хозяин подставит другой механизм (очередь/прямой вызов).
procedure TMainForm.DoInvoke(M: TThreadMethod);
begin
  TThread.Synchronize(nil, M);
end;

// Контроллер изменил состояние — обновляем соответствующие виджеты.
// Выполняется в UI-потоке (команды контроллера вызываются из UI-потока либо
// маршалятся адаптерами). FSyncingFromController защищает от рекурсии, когда
// программное изменение виджета триггерит его OnChange.
procedure TMainForm.OnControllerState(Sender: TObject; Field: TRadioField);
var
  i: Integer;
begin
  FSyncingFromController := True;
  try
    case Field of
      rfMute:
        begin
          StyleButton(BtnMute, FController.FMuted);
          if FController.FMuted then BtnMute.Caption := 'UNMUTE'
          else BtnMute.Caption := 'MUTE';
          BtnMute.Invalidate;
        end;
      rfNR:  UpdateNRButton;
      rfNB:  UpdateNBButton;
      rfSNB: UpdateSNBButton;
      rfANF: UpdateANFButton;
      rfAGCMode:
        for i := 0 to 4 do StyleButton(BtnAGCMode[i], i = FController.FAGCMode);
    end;
  finally
    FSyncingFromController := False;
  end;
end;

procedure TMainForm.FormClose(Sender: TObject; var CloseAction: TCloseAction);
begin
  // Сохраняем позицию здесь — окно ещё полностью видимо и стабильно.
  // В FormDestroy Qt-виджет может быть уже в нестабильном состоянии.
  SaveWindowBounds;
  // Останавливаем трансивер как при нажатии STOP
  if FController.FNetwork.Connected then
  begin
    if FController.FRunning then
    begin
      FController.FNetwork.UpdateState(XvtrTranslate(FController.FCenterFreq), XvtrTranslate(ActiveTXFreqHz), 0, False, True, True);
      FController.FNetwork.SetRunAndFreq(False, XvtrTranslate(FController.FCenterFreq), XvtrTranslate(FController.FCenterFreq), 0);
      FController.FRunning      := False;
      UninhibitSleep;
      FController.FTransmitting := False;
      if FController.FWDSPReady then FController.FDSPEngine.SetTXRun(False);
    end;
    FController.FNetwork.Disconnect;
  end;
  CloseAction := caFree;
end;

// ===========================================================================
// Build UI (без анонимных процедур и inline var)
// ===========================================================================

procedure TMainForm.BuildUI;
const
  BTN_H  = 26;
  BTN_SM = 24;
  LEFT_W = 232;
  TOP_VFO_W = 244;
  TOP_VFO_FREQ_H = 47;
var
  i, X, Y, W: Integer;
  B: TFlatButton;
  SepLine: TPanel;

  function MakeBtn(Parent: TWinControl; const Cap: string;
    ALeft, ATop, AW, AH: Integer; Handler: TNotifyEvent): TFlatButton;
  begin
    Result := TFlatButton.Create(Self);
    Result.Parent  := Parent;
    Result.Caption := Cap;
    Result.Left    := ALeft;
    Result.Top     := ATop;
    Result.Width   := AW;
    Result.Height  := AH;
    Result.OnClick := Handler;
    StyleButton(Result, False);
  end;

  procedure MakeLbl(Parent: TWinControl; const Cap: string; ALeft, ATop: Integer);
  var
    L: TLabel;
  begin
    L := TLabel.Create(Self);
    L.Parent   := Parent;
    L.Caption  := Cap;
    L.Left     := ALeft;
    L.Top      := ATop;
    L.Font.Color := CLR_TEXTDIM;
    L.Font.Name  := 'Courier New';
    L.Font.Size  := 7;
  end;

begin
  // ---- Toolbar ----
  PanelToolbar := TPanel.Create(Self);
  PanelToolbar.Parent     := Self;
  PanelToolbar.Align      := alTop;
  PanelToolbar.Height     := 72;
  PanelToolbar.BevelOuter := bvNone;
  PanelToolbar.Color      := CLR_PANEL;


  X := 4;
  BtnDiscover  := MakeBtn(PanelToolbar, 'DISCOVER',  X,      3, 80, BTN_H, BtnDiscoverClick);
  BtnStartStop := MakeBtn(PanelToolbar, 'START',    X+84,   3, 76, BTN_H, BtnStartStopClick);
  BtnSettings  := MakeBtn(PanelToolbar, 'SETUP',    X+164,  3, 66, BTN_H, BtnSettingsClick);

  // DISCOVER — синеватый акцент
  BtnDiscover.ClrNorm    := TColor($00101828);
  BtnDiscover.ClrHot     := TColor($00182038);
  BtnDiscover.ClrActive  := TColor($00182038);
  BtnDiscover.ClrBorder  := TColor($00304860);
  BtnDiscover.ClrText    := TColor($0090B8D0);
  BtnDiscover.ClrTextAct := TColor($00B0D0F0);
  // START/STOP — зелёный тон в обоих состояниях
  BtnStartStop.ClrNorm   := TColor($00101810);
  BtnStartStop.ClrBorder := TColor($00304830);
  BtnStartStop.ClrText   := TColor($0090C090);

  // Линия-разделитель внутри PanelToolbar, прижата к его нижнему краю
  SepLine := TPanel.Create(Self);
  SepLine.Parent      := PanelToolbar;
  SepLine.BevelOuter  := bvNone;
  SepLine.Color       := TColor($00606060);
  SepLine.SetBounds(16, PanelToolbar.Height - 10, PanelToolbar.Width - 32, 2);
  SepLine.Anchors     := [akLeft, akRight, akBottom];

  // ---- Status bar ----
  StatusPanel := TMainStatusBar.Create(Self);
  StatusPanel.Parent     := Self;

  // ---- Left panel ----
  PanelLeft := TPanel.Create(Self);
  PanelLeft.Parent     := Self;
  PanelLeft.Align      := alLeft;
  PanelLeft.Width      := LEFT_W;
  PanelLeft.BevelOuter := bvNone;

  Y := 0;

  // Compact top VFO group
  PanelTopVfoGroup := TPanel.Create(Self);
  PanelTopVfoGroup.Parent := PanelToolbar;
  PanelTopVfoGroup.SetBounds(0, 0, TOP_VFO_W * 2 + 64, 60);
  PanelTopVfoGroup.BevelOuter := bvNone;
  PanelTopVfoGroup.Color := TColor($00181818);

  // VFO A
  PanelTopVfoA := TPanel.Create(Self);
  PanelTopVfoA.Parent := PanelTopVfoGroup;
  PanelTopVfoA.SetBounds(0, 0, TOP_VFO_W, 60);
  PanelTopVfoA.BevelOuter := bvNone;
  PanelTopVfoA.Color := CLR_PANEL;

  PanelVfoA := TPanel.Create(Self);
  PanelVfoA.Parent := PanelTopVfoA;
  PanelVfoA.SetBounds(0, 0, TOP_VFO_W, TOP_VFO_FREQ_H);
  PanelVfoA.BevelOuter := bvNone;

  BtnTopVfoASelect := MakeBtn(PanelVfoA, 'A', 0, 5, 20, 18, BtnTopVfoASelectClick);
  BtnTopVfoASelect.Font.Name := 'Courier New';
  BtnTopVfoASelect.Font.Size := 7;
  BtnTopVfoASelect.Font.Bold := True;
  BtnTopVfoASelect.ClrNorm := TColor($00303030);
  BtnTopVfoASelect.ClrHot := TColor($00404040);
  BtnTopVfoASelect.ClrActive := TColor($00404040);
  BtnTopVfoASelect.ClrBorder := TColor($00505050);
  BtnTopVfoASelect.ClrText := CLR_TEXTDIM;
  BtnTopVfoASelect.ClrTextAct := CLR_TEXT;

  BtnTopVfoATX := MakeBtn(PanelVfoA, 'TX', 0, 25, 20, 18, BtnTopVfoATXClick);
  BtnTopVfoATX.Font.Name := 'Courier New';
  BtnTopVfoATX.Font.Size := 7;
  BtnTopVfoATX.Font.Bold := True;
  BtnTopVfoATX.ClrNorm := TColor($00303030);
  BtnTopVfoATX.ClrHot := TColor($00404040);
  BtnTopVfoATX.ClrActive := TColor($00404040);
  BtnTopVfoATX.ClrBorder := TColor($00505050);
  BtnTopVfoATX.ClrText := CLR_TEXTDIM;
  BtnTopVfoATX.ClrTextAct := CLR_TEXT;

  LblVfoALabel := TLabel.Create(Self);
  LblVfoALabel.Parent    := PanelVfoA;
  LblVfoALabel.Caption   := 'VFO-A';
  LblVfoALabel.Left      := 0; LblVfoALabel.Top := 0;
  LblVfoALabel.Font.Name := 'Courier New'; LblVfoALabel.Font.Size := 7;
  LblVfoALabel.Font.Color := CLR_TEXTDIM;
  LblVfoALabel.Visible := False;

  FreqDispA := TFreqDisplay.Create(Self);
  FreqDispA.Parent      := PanelVfoA;
  FreqDispA.SetBounds(20, -2, TOP_VFO_W - 20, TOP_VFO_FREQ_H);
  FreqDispA.FontSize    := TOP_VFO_FONT;
  FreqDispA.FontName    := 'Courier New';
  FreqDispA.Frequency   := Round(FController.FVfoA);
  FreqDispA.ColorNormal := CLR_FREQ;
  FreqDispA.ColorHover  := TColor($0040DDFF);
  FreqDispA.ColorDim    := CLR_TEXTDIM;
  FreqDispA.MinMhzDigits := 3;
  FreqDispA.DimLeadingZeros := True;
  FreqDispA.CenterText := False;
  FreqDispA.OnChange    := FreqDispAChanged;
  FreqDispA.OnClick     := FreqDispAClick;

  // VFO B
  PanelTopVfoB := TPanel.Create(Self);
  PanelTopVfoB.Parent := PanelTopVfoGroup;
  PanelTopVfoB.SetBounds(0, 0, TOP_VFO_W, 60);
  PanelTopVfoB.BevelOuter := bvNone;
  PanelTopVfoB.Color := CLR_PANEL;

  PanelVfoB := TPanel.Create(Self);
  PanelVfoB.Parent := PanelTopVfoB;
  PanelVfoB.SetBounds(0, 0, TOP_VFO_W, TOP_VFO_FREQ_H);
  PanelVfoB.BevelOuter := bvNone;

  BtnTopVfoBSelect := MakeBtn(PanelVfoB, 'B', 0, 5, 20, 18, BtnTopVfoBSelectClick);
  BtnTopVfoBSelect.Font.Name := 'Courier New';
  BtnTopVfoBSelect.Font.Size := 7;
  BtnTopVfoBSelect.Font.Bold := True;
  BtnTopVfoBSelect.ClrNorm := TColor($00303030);
  BtnTopVfoBSelect.ClrHot := TColor($00404040);
  BtnTopVfoBSelect.ClrActive := TColor($00404040);
  BtnTopVfoBSelect.ClrBorder := TColor($00505050);
  BtnTopVfoBSelect.ClrText := CLR_TEXTDIM;
  BtnTopVfoBSelect.ClrTextAct := CLR_TEXT;

  BtnTopVfoBTX := MakeBtn(PanelVfoB, 'TX', 0, 25, 20, 18, BtnTopVfoBTXClick);
  BtnTopVfoBTX.Font.Name := 'Courier New';
  BtnTopVfoBTX.Font.Size := 7;
  BtnTopVfoBTX.Font.Bold := True;
  BtnTopVfoBTX.ClrNorm := TColor($00303030);
  BtnTopVfoBTX.ClrHot := TColor($00404040);
  BtnTopVfoBTX.ClrActive := TColor($00404040);
  BtnTopVfoBTX.ClrBorder := TColor($00505050);
  BtnTopVfoBTX.ClrText := CLR_TEXTDIM;
  BtnTopVfoBTX.ClrTextAct := CLR_TEXT;

  LblVfoBLabel := TLabel.Create(Self);
  LblVfoBLabel.Parent    := PanelVfoB;
  LblVfoBLabel.Caption   := 'VFO-B';
  LblVfoBLabel.Left      := 0; LblVfoBLabel.Top := 0;
  LblVfoBLabel.Font.Name := 'Courier New'; LblVfoBLabel.Font.Size := 7;
  LblVfoBLabel.Font.Color := CLR_TEXTDIM;
  LblVfoBLabel.Visible := False;

  FreqDispB := TFreqDisplay.Create(Self);
  FreqDispB.Parent      := PanelVfoB;
  FreqDispB.SetBounds(20, -2, TOP_VFO_W - 20, TOP_VFO_FREQ_H);
  FreqDispB.FontSize    := TOP_VFO_FONT;
  FreqDispB.FontName    := 'Courier New';
  FreqDispB.Frequency   := Round(FController.FVfoB);
  FreqDispB.ColorNormal := CLR_FREQ_DIM;
  FreqDispB.ColorHover  := TColor($0040DDFF);
  FreqDispB.ColorDim    := CLR_TEXTDIM;
  FreqDispB.MinMhzDigits := 3;
  FreqDispB.DimLeadingZeros := True;
  FreqDispB.CenterText := False;
  FreqDispB.OnChange    := FreqDispBChanged;
  FreqDispB.OnClick     := FreqDispBClick;

  // VFO transfer buttons — горизонтальный ряд по центру
  PanelVfoButtons := TPanel.Create(Self);
  PanelVfoButtons.Parent := PanelTopVfoGroup;
  PanelVfoButtons.SetBounds(0, 0, 140, 20);
  PanelVfoButtons.BevelOuter := bvNone;
  PanelVfoButtons.Color := TColor($00181818);

  BtnVfoACopyB := MakeBtn(PanelVfoButtons, 'A>B',  0, 0, 44, 20, BtnVfoACopyBClick);
  BtnVfoSwap   := MakeBtn(PanelVfoButtons, 'A<>B', 0, 0, 44, 20, BtnVfoSwapClick);
  BtnVfoBCopyA := MakeBtn(PanelVfoButtons, 'B>A',  0, 0, 44, 20, BtnVfoBCopyAClick);
  // Янтарный стиль, шрифт под размер кнопок
  StyleSpanButton(BtnVfoACopyB, False);
  StyleSpanButton(BtnVfoSwap,   False);
  StyleSpanButton(BtnVfoBCopyA, False);
  BtnVfoACopyB.Font.Size := 7;
  BtnVfoSwap.Font.Size   := 7;
  BtnVfoBCopyA.Font.Size := 7;

  LayoutTopVfoBlock;

  // TX controls
  PanelTX := TPanel.Create(Self);
  PanelTX.Parent := PanelLeft;
  PanelTX.SetBounds(0, Y, LEFT_W, 96);
  PanelTX.BevelOuter := bvNone;
  MakeLbl(PanelTX, 'TX', 4, 2);

  BtnMOX := MakeBtn(PanelTX, 'MOX',
                    LeftPanelButtonLeft(LEFT_W, 4, 0), 16,
                    LeftPanelButtonWidth(LEFT_W, 4, 0), BTN_SM, BtnMOXClick);
  BtnTUN := MakeBtn(PanelTX, 'TUN',
                    LeftPanelButtonLeft(LEFT_W, 4, 1), 16,
                    LeftPanelButtonWidth(LEFT_W, 4, 1), BTN_SM, BtnTUNClick);

  BtnDUP := MakeBtn(PanelTX, 'DUP',
                    LeftPanelButtonLeft(LEFT_W, 4, 0), 44,
                    LeftPanelButtonWidth(LEFT_W, 4, 0), BTN_SM, BtnDUPClick);
  BtnDUP.Tag := 0;
  StyleButton(BtnDUP, FController.FDisplayDuplex);

  LblDrv := TLabel.Create(Self);
  LblDrv.Parent := PanelTX; LblDrv.Left := 4; LblDrv.Top := 77;
  LblDrv.Caption := 'DRV'; LblDrv.Font.Color := CLR_TEXTDIM;
  LblDrv.Font.Name := 'Courier New'; LblDrv.Font.Size := 7;

  TrkDrive := TFlatSlider.Create(Self);
  TrkDrive.Parent := PanelTX; TrkDrive.Left := 34; TrkDrive.Top := 74;
  TrkDrive.Width := 150; TrkDrive.Height := 16;
  TrkDrive.Min := 0; TrkDrive.Max := 100; TrkDrive.Position := 50;
  TrkDrive.OnChange := TrkDriveChange;

  Inc(Y, 100);

  // RX block: CTUN + VOL + NR/NB/SNB/ANF/MUTE
  PanelRXBlock := TPanel.Create(Self);
  PanelRXBlock.Parent := PanelLeft;
  PanelRXBlock.SetBounds(0, Y, LEFT_W, 96);
  PanelRXBlock.BevelOuter := bvNone;
  MakeLbl(PanelRXBlock, 'RX', 4, 2);

  BtnCTun := MakeBtn(PanelRXBlock, 'CTUN',
                     LeftPanelButtonLeft(LEFT_W, 3, 0), 16,
                     LeftPanelButtonWidth(LEFT_W, 3, 0),
                     BTN_H, BtnCTunClick);
  BtnCTun.Tag := 0;
  StyleButton(BtnCTun, FController.FCTun);

  BtnChannels := MakeBtn(PanelRXBlock, 'Channel',
                         LeftPanelButtonLeft(LEFT_W, 3, 1), 16,
                         LeftPanelButtonWidth(LEFT_W, 3, 1),
                         BTN_H, BtnChannelsClick);
  BtnChannels.OnMouseDown := BtnChannelsMouseDown;
  StyleButton(BtnChannels, False);

  FChannelsDropDown := TFlatDropDown.Create(BtnChannels, PanelLeft, 1, BTN_SM);
  FChannelsDropDown.OnSelect := OnChannelsDropDownSelect;
  RefreshChannelsDropDown;

  MakeLbl(PanelRXBlock, 'VOL', 4, 50);
  TrkVolume := TFlatSlider.Create(Self);
  TrkVolume.Parent := PanelRXBlock; TrkVolume.Left := 34; TrkVolume.Top := 46;
  TrkVolume.Width := LEFT_W - 38; TrkVolume.Height := 16;
  TrkVolume.Min := 0; TrkVolume.Max := 100;
  TrkVolume.Position := FController.FVolume;
  TrkVolume.OnChange := TrkVolumeChange;

  BtnNR := MakeBtn(PanelRXBlock, 'NR',
                   LeftPanelButtonLeft(LEFT_W, 5, 0), 70,
                   LeftPanelButtonWidth(LEFT_W, 5, 0), BTN_SM, BtnNRClick);
  BtnNB := MakeBtn(PanelRXBlock, 'NB',
                   LeftPanelButtonLeft(LEFT_W, 5, 1), 70,
                   LeftPanelButtonWidth(LEFT_W, 5, 1), BTN_SM, BtnNBClick);
  BtnSNB := MakeBtn(PanelRXBlock, 'SNB',
                    LeftPanelButtonLeft(LEFT_W, 5, 2), 70,
                    LeftPanelButtonWidth(LEFT_W, 5, 2), BTN_SM, BtnSNBClick);
  BtnANF := MakeBtn(PanelRXBlock, 'ANF',
                    LeftPanelButtonLeft(LEFT_W, 5, 3), 70,
                    LeftPanelButtonWidth(LEFT_W, 5, 3), BTN_SM, BtnANFClick);
  BtnMute := MakeBtn(PanelRXBlock, 'MUTE',
                     LeftPanelButtonLeft(LEFT_W, 5, 4), 70,
                     LeftPanelButtonWidth(LEFT_W, 5, 4), BTN_SM, BtnMuteClick);

  Inc(Y, 100);

  // AGC controls
  PanelAGC := TPanel.Create(Self);
  PanelAGC.Parent := PanelLeft;
  PanelAGC.SetBounds(0, Y, LEFT_W, 70);
  PanelAGC.BevelOuter := bvNone;

  // AGC mode — 5 кнопок в ряд
  MakeLbl(PanelAGC, 'AGC', 4, 2);
  BtnAGCMode[0] := MakeBtn(PanelAGC, 'FAST',
                           LeftPanelButtonLeft(LEFT_W, 5, 0), 16,
                           LeftPanelButtonWidth(LEFT_W, 5, 0),
                           BTN_SM, BtnAGCModeClick);
  BtnAGCMode[0].Tag := 0;
  BtnAGCMode[1] := MakeBtn(PanelAGC, 'MED',
                           LeftPanelButtonLeft(LEFT_W, 5, 1), 16,
                           LeftPanelButtonWidth(LEFT_W, 5, 1),
                           BTN_SM, BtnAGCModeClick);
  BtnAGCMode[1].Tag := 1;
  BtnAGCMode[2] := MakeBtn(PanelAGC, 'SLOW',
                           LeftPanelButtonLeft(LEFT_W, 5, 2), 16,
                           LeftPanelButtonWidth(LEFT_W, 5, 2),
                           BTN_SM, BtnAGCModeClick);
  BtnAGCMode[2].Tag := 2;
  BtnAGCMode[3] := MakeBtn(PanelAGC, 'LONG',
                           LeftPanelButtonLeft(LEFT_W, 5, 3), 16,
                           LeftPanelButtonWidth(LEFT_W, 5, 3),
                           BTN_SM, BtnAGCModeClick);
  BtnAGCMode[3].Tag := 3;
  BtnAGCMode[4] := MakeBtn(PanelAGC, 'OFF',
                           LeftPanelButtonLeft(LEFT_W, 5, 4), 16,
                           LeftPanelButtonWidth(LEFT_W, 5, 4),
                           BTN_SM, BtnAGCModeClick);
  BtnAGCMode[4].Tag := 4;
  for i := 0 to 4 do StyleButton(BtnAGCMode[i], i = FController.FAGCMode);

  // AGC level slider
  MakeLbl(PanelAGC, 'THRESH', 4, 44);
  TrkAGC := TFlatSlider.Create(Self);
  TrkAGC.Parent := PanelAGC; TrkAGC.Left := 56; TrkAGC.Top := 40;
  TrkAGC.Width := LEFT_W - 96; TrkAGC.Height := 16;
  TrkAGC.Min := 20; TrkAGC.Max := 120;   // 20..120 → −20..-120 dBm
  TrkAGC.Position := FController.FAGCTop;
  TrkAGC.Reversed := True;
  TrkAGC.OnChange := TrkAGCChange;

  LblAGCTop := TLabel.Create(Self);
  LblAGCTop.Parent := PanelAGC;
  LblAGCTop.Left := LEFT_W - 38; LblAGCTop.Top := 44;
  LblAGCTop.Caption := Format('-%ddB', [FController.FAGCTop]);
  LblAGCTop.Font.Color := CLR_TEXT; LblAGCTop.Font.Name := 'Courier New';
  LblAGCTop.Font.Size := 7;

  Inc(Y, 74);

  // Band selector. Высота резервирует 2 дополнительных ряда под XVTR-кнопки
  // (создаются динамически в RebuildXvtrButtons). В каждом ряду до 6 кнопок,
  // итого до 12 XVTR-кнопок (наш CFG_XVTR_COUNT = 8 умещается с запасом).
  PanelBands := TPanel.Create(Self);
  PanelBands.Parent := PanelLeft;
  PanelBands.SetBounds(0, Y, LEFT_W, 74);
  PanelBands.BevelOuter := bvNone;
  MakeLbl(PanelBands, 'BAND', 4, 2);
  for i := 0 to BAND_COUNT - 1 do
  begin
    B := MakeBtn(PanelBands, BAND_NAMES[i],
                 LeftPanelButtonLeft(LEFT_W, 6, i mod 6),
                 16 + (i div 6) * 27,
                 LeftPanelButtonWidth(LEFT_W, 6, i mod 6),
                 BTN_SM, BtnBandClick);
    B.Tag := i;
    BtnBand[i] := B;
  end;
  // XVTR кнопки создаются позже (в RebuildXvtrButtons), их Tag = -(idx+1)
  for i := 0 to CFG_XVTR_COUNT - 1 do
    BtnXvtrBand[i] := nil;

  Inc(Y, 76);

  // Mode selector
  PanelMode := TPanel.Create(Self);
  PanelMode.Parent := PanelLeft;
  PanelMode.SetBounds(0, Y, LEFT_W, 74);
  PanelMode.BevelOuter := bvNone;
  MakeLbl(PanelMode, 'MODE', 4, 2);
  for i := 0 to MODE_COUNT - 1 do
  begin
    B := MakeBtn(PanelMode, MODE_NAMES[i],
                 LeftPanelButtonLeft(LEFT_W, 4, i mod 4),
                 16 + (i div 4) * 27,
                 LeftPanelButtonWidth(LEFT_W, 4, i mod 4),
                 BTN_SM, BtnModeClick);
    B.Tag := i;
    BtnMode[i] := B;
    StyleButton(B, i = FController.FMode);
  end;

  Inc(Y, 76);

  // Filter selector (10 кнопок в 2 ряда по 5)
  PanelFilter := TPanel.Create(Self);
  PanelFilter.Parent := PanelLeft;
  PanelFilter.SetBounds(0, Y, LEFT_W, 74);
  PanelFilter.BevelOuter := bvNone;
  MakeLbl(PanelFilter, 'FILTER', 4, 2);
  for i := 0 to FILT_COUNT - 1 do
  begin
    B := MakeBtn(PanelFilter, '---',
                 LeftPanelButtonLeft(LEFT_W, 5, i mod 5),
                 16 + (i div 5) * 27,
                 LeftPanelButtonWidth(LEFT_W, 5, i mod 5),
                 BTN_SM, BtnFilterClick);
    B.Tag := i;
    BtnFilter[i] := B;
  end;

  Inc(Y, 76);

  // FM Squelch panel (visible only in FM mode, above CTCSS)
  PanelFMSQ := TPanel.Create(Self);
  PanelFMSQ.Parent := PanelLeft;
  PanelFMSQ.SetBounds(0, Y, LEFT_W, 50);
  PanelFMSQ.BevelOuter := bvNone;
  PanelFMSQ.Visible := False;
  MakeLbl(PanelFMSQ, 'SQL', 4, 2);
  W := LeftPanelButtonWidth(LEFT_W, 4, 0);
  BtnFMSQ := MakeBtn(PanelFMSQ, 'SQL',
                     LeftPanelButtonLeft(LEFT_W, 4, 0), 16,
                     W, BTN_SM, BtnFMSQClick);
  StyleButton(BtnFMSQ, False);
  // Slider: vertically centred with button text (button top=16, h=24 → center=28)
  // Reserve 28px on the right for the numeric label
  TrkFMSQ := TFlatSlider.Create(Self);
  TrkFMSQ.Parent := PanelFMSQ;
  TrkFMSQ.Left   := BtnFMSQ.Left + BtnFMSQ.Width + 3;
  TrkFMSQ.Top    := 21;                         // 28 - 14/2 = 21
  TrkFMSQ.Width  := LEFT_W - TrkFMSQ.Left - 30 - 4;   // leave 30px for label + margins
  TrkFMSQ.Height := 14;
  TrkFMSQ.Min := 0; TrkFMSQ.Max := 100;
  TrkFMSQ.Position := FController.FFMSQLevel;
  TrkFMSQ.OnChange := TrkFMSQChange;
  // Numeric value label to the right of the slider
  LblFMSQ := TLabel.Create(Self);
  LblFMSQ.Parent := PanelFMSQ;
  LblFMSQ.Left   := TrkFMSQ.Left + TrkFMSQ.Width + 3;
  LblFMSQ.Top    := 21;
  LblFMSQ.Width  := 26;
  LblFMSQ.Caption := IntToStr(FController.FFMSQLevel);
  LblFMSQ.Font.Color := CLR_TEXT;
  LblFMSQ.Font.Name  := 'Courier New';
  LblFMSQ.Font.Size  := 8;

  // FM CTCSS panel (visible only when FM mode is active)
  PanelFMCTCSS := TPanel.Create(Self);
  PanelFMCTCSS.Parent := PanelLeft;
  PanelFMCTCSS.SetBounds(0, Y, LEFT_W, 50);
  PanelFMCTCSS.BevelOuter := bvNone;
  PanelFMCTCSS.Visible := False;
  MakeLbl(PanelFMCTCSS, 'CTCSS', 4, 2);
  BtnFMCTCSS := MakeBtn(PanelFMCTCSS, 'CTCSS',
                        LeftPanelButtonLeft(LEFT_W, 3, 0), 16,
                        LeftPanelButtonWidth(LEFT_W, 3, 0),
                        BTN_H, BtnFMCTCSSClick);
  StyleButton(BtnFMCTCSS, False);
  BtnFMCTCSSTone := MakeBtn(PanelFMCTCSS, CTCSS_NAMES[0],
                            LeftPanelButtonLeft(LEFT_W, 3, 1), 16,
                            LEFT_W - LEFT_PANEL_BTN_PAD -
                            LeftPanelButtonLeft(LEFT_W, 3, 1),
                            BTN_H, BtnFMCTCSSToneClick);
  StyleButton(BtnFMCTCSSTone, False);

  FCTCSSDropDown := TFlatDropDown.Create(BtnFMCTCSSTone, PanelLeft, 4, BTN_SM);
  FCTCSSDropDown.SetItems(CTCSS_NAMES);
  FCTCSSDropDown.SetItemIndex(FController.FFMCTCSSToneIdx);
  FCTCSSDropDown.OnSelect := OnCTCSSDropDownSelect;

  // FM Step panel (visible only in FM mode, between Filter and SQL)
  PanelFMStep := TPanel.Create(Self);
  PanelFMStep.Parent := PanelLeft;
  PanelFMStep.SetBounds(0, Y, LEFT_W, 50);
  PanelFMStep.BevelOuter := bvNone;
  PanelFMStep.Visible := False;
  MakeLbl(PanelFMStep, 'STEP', 4, 2);
  BtnFMStep := MakeBtn(PanelFMStep, 'STEP',
                       LeftPanelButtonLeft(LEFT_W, 3, 0), 16,
                       LeftPanelButtonWidth(LEFT_W, 3, 0),
                       BTN_H, BtnFMStepClick);
  StyleButton(BtnFMStep, FController.FFMStepOn);
  BtnFMStepSel := MakeBtn(PanelFMStep, FM_STEP_NAMES[FController.FFMStepIdx],
                           LeftPanelButtonLeft(LEFT_W, 3, 1), 16,
                           LEFT_W - LEFT_PANEL_BTN_PAD -
                           LeftPanelButtonLeft(LEFT_W, 3, 1),
                           BTN_H, BtnFMStepSelClick);
  StyleButton(BtnFMStepSel, False);

  FStepDropDown := TFlatDropDown.Create(BtnFMStepSel, PanelLeft, FM_STEP_COUNT, BTN_SM);
  FStepDropDown.SetItems(FM_STEP_NAMES);
  FStepDropDown.SetItemIndex(FController.FFMStepIdx);
  FStepDropDown.OnSelect := OnStepDropDownSelect;

  // FM Repeater offset panel (visible only in FM mode, below CTCSS)
  PanelFMRpt := TPanel.Create(Self);
  PanelFMRpt.Parent := PanelLeft;
  PanelFMRpt.SetBounds(0, Y, LEFT_W, 50);
  PanelFMRpt.BevelOuter := bvNone;
  PanelFMRpt.Visible := False;
  MakeLbl(PanelFMRpt, 'RPT', 4, 2);
  BtnFMRptMinus := MakeBtn(PanelFMRpt, '-',
    LeftPanelButtonLeft(LEFT_W, 5, 0), 16,
    LeftPanelButtonWidth(LEFT_W, 5, 0), BTN_H, BtnFMRptMinusClick);
  StyleButton(BtnFMRptMinus, False);
  BtnFMRptPlus := MakeBtn(PanelFMRpt, '+',
    LeftPanelButtonLeft(LEFT_W, 5, 4), 16,
    LeftPanelButtonWidth(LEFT_W, 5, 4), BTN_H, BtnFMRptPlusClick);
  StyleButton(BtnFMRptPlus, False);
  W := LeftPanelButtonLeft(LEFT_W, 5, 1);
  EdFMRptOffset := TFlatEdit.Create(Self);
  EdFMRptOffset.Parent := PanelFMRpt;
  EdFMRptOffset.Left   := W;
  EdFMRptOffset.Top    := 18;
  EdFMRptOffset.Width  := LeftPanelButtonLeft(LEFT_W, 5, 4) - W - LEFT_PANEL_BTN_GAP;
  EdFMRptOffset.Height := BTN_H - 4;
  EdFMRptOffset.Text   := RptFormatOffset(FController.FFMRptOffsetHz);
  EdFMRptOffset.OnChange := EdFMRptOffsetChange;

  // ---- Right panel ----
  PanelRight := TPanel.Create(Self);
  PanelRight.Parent     := Self;
  PanelRight.Align      := alClient;
  PanelRight.BevelOuter     := bvNone;
  PanelRight.DoubleBuffered := True;
  PanelRight.OnResize   := RightPanelResize;

  FSampleRateOverlay := TSampleRateOverlay.Create(Self);
  FSampleRateOverlay.OnSpanSelect := OnSampleRateSelect;
  FSampleRateOverlay.OnHidePanel  := OnSampleRateHidePanel;
  FSampleRateOverlay.OnInvalidate := OnSampleRateOverlayInvalidate;
  FSampleRateOverlay.SetCurrentRate(FController.FSampleRate);
  FSpecView.SampleRateOverlay := FSampleRateOverlay;

  // S-метр — внутри PanelToolbar, справа
  PanelSMeterRight := TPanel.Create(Self);
  PanelSMeterRight.Parent     := PanelToolbar;
  PanelSMeterRight.BevelOuter := bvNone;
  PanelSMeterRight.Color      := CLR_PANEL;
  PbSMeterRight := TPaintBox.Create(Self);
  PbSMeterRight.Parent   := PanelSMeterRight;
  PbSMeterRight.Align    := alClient;
  PbSMeterRight.OnPaint  := FSpecView.PaintSMeterRight;
  PbSMeterRight.Color    := CLR_PANEL;

  if FUseOpenGLSpectrum then
  begin
    PbWideband := TOpenGLControl.Create(Self);
    TOpenGLControl(PbWideband).AutoResizeViewport := False;
    TOpenGLControl(PbWideband).DoubleBuffered := True;
    TOpenGLControl(PbWideband).OnPaint := FWidebandView.Paint;
    TOpenGLControl(PbWideband).OnMouseDown := PbWidebandMouseDown;
  end
  else
  begin
    PbWideband := TPaintBox.Create(Self);
    TPaintBox(PbWideband).OnPaint := FWidebandView.Paint;
    TPaintBox(PbWideband).OnMouseDown := PbWidebandMouseDown;
  end;
  PbWideband.Parent := PanelRight;
  PbWideband.Visible := False;

  PbWidebandRuler := TPaintBox.Create(Self);
  PbWidebandRuler.Parent := PanelRight;
  PbWidebandRuler.OnPaint := FWidebandView.PaintRuler;
  PbWidebandRuler.Cursor := crDefault;
  PbWidebandRuler.Visible := False;

  if FUseOpenGLSpectrum then
  begin
    PbSpectrum := TOpenGLControl.Create(Self);
    TOpenGLControl(PbSpectrum).AutoResizeViewport := False;
    TOpenGLControl(PbSpectrum).DoubleBuffered := True;
    TOpenGLControl(PbSpectrum).OnPaint := FSpecView.PaintSpectrum;
    TOpenGLControl(PbSpectrum).OnMouseDown := PbSpectrumMouseDown;
    TOpenGLControl(PbSpectrum).OnDblClick := PbSpectrumDblClick;
    TOpenGLControl(PbSpectrum).OnMouseMove := PbSpectrumMouseMove;
    TOpenGLControl(PbSpectrum).OnMouseUp := PbSpectrumMouseUp;
    TOpenGLControl(PbSpectrum).OnMouseLeave := PbSpectrumMouseLeave;
    TSpectrumViewOpenGL(FSpecView).AttachControl(TOpenGLControl(PbSpectrum));
  end
  else
  begin
    PbSpectrum := TPaintBox.Create(Self);
    TPaintBox(PbSpectrum).OnPaint := FSpecView.PaintSpectrum;
    TPaintBox(PbSpectrum).OnMouseDown := PbSpectrumMouseDown;
    TPaintBox(PbSpectrum).OnDblClick := PbSpectrumDblClick;
    TPaintBox(PbSpectrum).OnMouseMove := PbSpectrumMouseMove;
    TPaintBox(PbSpectrum).OnMouseUp := PbSpectrumMouseUp;
    TPaintBox(PbSpectrum).OnMouseLeave := PbSpectrumMouseLeave;
  end;
  PbSpectrum.Parent      := PanelRight;

  PbRuler := TPaintBox.Create(Self);
  PbRuler.Parent   := PanelRight;
  PbRuler.OnPaint  := FSpecView.PaintRuler;
  PbRuler.Cursor   := crDefault;
  FSpecView.PbRuler := PbRuler;

  // ---- Splitter между спектром+линейкой и водопадом ----
  PanelSplitter := TPanel.Create(Self);
  PanelSplitter.Parent     := PanelRight;
  PanelSplitter.BevelOuter := bvNone;
  PanelSplitter.Color      := TColor($00303030);
  PanelSplitter.Cursor     := crVSplit;
  PanelSplitter.Height     := 5;
  PanelSplitter.OnMouseDown := SplitterMouseDown;
  PanelSplitter.OnMouseMove := SplitterMouseMove;
  PanelSplitter.OnMouseUp   := SplitterMouseUp;

  if FUseOpenGLSpectrum then
  begin
    PbWaterfall := TOpenGLControl.Create(Self);
    TOpenGLControl(PbWaterfall).AutoResizeViewport := False;
    TOpenGLControl(PbWaterfall).DoubleBuffered := True;
    TOpenGLControl(PbWaterfall).OnPaint := FSpecView.PaintWaterfall;
    TOpenGLControl(PbWaterfall).OnMouseDown := PbWaterfallMouseDown;
    TOpenGLControl(PbWaterfall).OnMouseMove := PbWaterfallMouseMove;
    TOpenGLControl(PbWaterfall).OnMouseUp := PbWaterfallMouseUp;
    TSpectrumViewOpenGL(FSpecView).AttachWaterfallControl(TOpenGLControl(PbWaterfall));
  end
  else
  begin
    PbWaterfall := TPaintBox.Create(Self);
    TPaintBox(PbWaterfall).OnPaint := FSpecView.PaintWaterfall;
    TPaintBox(PbWaterfall).OnMouseDown := PbWaterfallMouseDown;
    TPaintBox(PbWaterfall).OnMouseMove := PbWaterfallMouseMove;
    TPaintBox(PbWaterfall).OnMouseUp := PbWaterfallMouseUp;
  end;
  PbWaterfall.Parent     := PanelRight;

  // Initial layout
  ResizeSpectrumPanels;
end;

// ===========================================================================
// Splitter — перетаскивание границы спектр/водопад
// Обновление размеров происходит только при отпускании мыши (MouseUp),
// чтобы исключить фризы во время перетаскивания.
// Во время перетаскивания рисуем только призрак-линию на PanelSplitter.
// ===========================================================================

procedure TMainForm.SplitterMouseDown(Sender: TObject; Button: TMouseButton;
  Shift: TShiftState; X, Y: Integer);
var
  P: TPoint;
begin
  if Button <> mbLeft then Exit;
  FSplitterDrag  := True;
  FSplitterSH0   := FSpectrumHeight;
  // Y в координатах PanelRight
  P := PanelRight.ScreenToClient(PanelSplitter.ClientToScreen(Point(X, Y)));
  FSplitterDragY0 := P.Y;
  PanelSplitter.Color := TColor($005050A0);  // подсветка при захвате
  {$IFDEF WINDOWS}
  SetCapture(PanelSplitter.Handle);
  {$ENDIF}
end;

procedure TMainForm.SplitterMouseMove(Sender: TObject; Shift: TShiftState;
  X, Y: Integer);
var
  P:      TPoint;
  DeltaY: Integer;
  NewSH:  Integer;
  TopOff, AvailH: Integer;
  RULER_H: Integer;
const
  SPLITTER_H = 5;
  MIN_SH     = 60;
  MIN_WH     = 40;
begin
  RULER_H := MulDiv(18, Screen.PixelsPerInch, 96);
  if not FSplitterDrag then Exit;
  P := PanelRight.ScreenToClient(PanelSplitter.ClientToScreen(Point(X, Y)));
  DeltaY := P.Y - FSplitterDragY0;

  TopOff := 0;
  AvailH := PanelRight.ClientHeight - TopOff - RULER_H - SPLITTER_H;
  if AvailH <= 0 then Exit;

  NewSH := FSplitterSH0 + DeltaY;
  if NewSH < MIN_SH then NewSH := MIN_SH;
  if NewSH > AvailH - MIN_WH then NewSH := AvailH - MIN_WH;

  // Только двигаем сплиттер визуально — без пересчёта битмапов
  PanelSplitter.Top := TopOff + NewSH + RULER_H;
  PbWaterfall.Top   := TopOff + NewSH + RULER_H + SPLITTER_H;
  PbWaterfall.Height := AvailH - NewSH;
end;

procedure TMainForm.SplitterMouseUp(Sender: TObject; Button: TMouseButton;
  Shift: TShiftState; X, Y: Integer);
var
  P:      TPoint;
  DeltaY: Integer;
  NewSH:  Integer;
  TopOff, AvailH: Integer;
  RULER_H: Integer;
const
  SPLITTER_H = 5;
  MIN_SH     = 60;
  MIN_WH     = 40;
begin
  RULER_H := MulDiv(18, Screen.PixelsPerInch, 96);
  if not FSplitterDrag then Exit;
  FSplitterDrag := False;
  {$IFDEF WINDOWS}
  ReleaseCapture;
  {$ENDIF}
  PanelSplitter.Color := TColor($00303030);  // обычный цвет

  P := PanelRight.ScreenToClient(PanelSplitter.ClientToScreen(Point(X, Y)));
  DeltaY := P.Y - FSplitterDragY0;

  TopOff := 0;
  AvailH := PanelRight.ClientHeight - TopOff - RULER_H - SPLITTER_H;
  if AvailH <= 0 then Exit;

  NewSH := FSplitterSH0 + DeltaY;
  if NewSH < MIN_SH then NewSH := MIN_SH;
  if NewSH > AvailH - MIN_WH then NewSH := AvailH - MIN_WH;

  // Сохраняем новое соотношение и делаем полный пересчёт с перерисовкой
  FSplitterRatio := NewSH / AvailH;
  ResizeSpectrumPanels;
end;

// ===========================================================================
// Resize handler (normal method instead of anonymous procedure)
// ===========================================================================

procedure TMainForm.RightPanelResize(Sender: TObject);
begin
  ResizeSpectrumPanels;
end;

procedure TMainForm.LayoutTopVfoBlock;
const
  MARGIN = 4;
  TOP_OFFSET = 2;
  TOP_VFO_FREQ_H = 47;
  VFO_BTN_FREQ_GAP = 4;
  VFO_FREQ_LEFT_PAD = 2;
  VFO_FREQ_RIGHT_PAD = 8;
  XFER_BTN_W   = 44;
  XFER_BTN_H   = 20;
  XFER_BTN_GAP = 4;
  XFER_PANEL_W = XFER_BTN_W * 3 + XFER_BTN_GAP * 2;  // = 140
  XFER_SIDE_GAP = 14;   // минимальный зазор между VFO и кнопками трансфера
  SETTINGS_GAP  = 8;
  MAX_SPREAD    = 100;  // максимальный дополнительный отступ с каждой стороны при растяжении
  // VFO-B получает левый отступ = правому паддингу VFO-A для симметрии зазоров
  VFO_B_LEFT_PAD = VFO_FREQ_LEFT_PAD + VFO_FREQ_RIGHT_PAD;  // = 10
var
  BlockH, FreqTop, CenterY, SafeLeft, SafeRight, GroupLeft, GroupTop: Integer;
  RightW, SMW, SMLeft, AvailW, MinGroupW, ExtraPerSide, DynGap: Integer;
  TopVfoAW, TopVfoBW, NeedFreqAW, NeedFreqBW, FreqTextW, GroupW: Integer;
  XferX, XferY: Integer;
begin
  if (PanelToolbar = nil) or (PanelTopVfoGroup = nil) or
     (PanelTopVfoA = nil) or (PanelTopVfoB = nil) then Exit;

  Canvas.Font.Name := FreqDispB.FontName;
  Canvas.Font.Size := FreqDispB.FontSize;
  Canvas.Font.Style := [fsBold];
  FreqDispA.Left := BtnTopVfoASelect.Width + VFO_BTN_FREQ_GAP - VFO_FREQ_LEFT_PAD;
  FreqDispB.Left := VFO_B_LEFT_PAD + BtnTopVfoBSelect.Width + VFO_BTN_FREQ_GAP - VFO_FREQ_LEFT_PAD;
  FreqTextW  := Canvas.TextWidth(StringOfChar('0', Max(3, FFreqMhzDigits)) + '.000.000');
  NeedFreqAW := FreqDispA.Left + FreqTextW + VFO_FREQ_LEFT_PAD + VFO_FREQ_RIGHT_PAD;
  NeedFreqBW := FreqDispB.Left + FreqTextW + VFO_FREQ_LEFT_PAD + VFO_FREQ_RIGHT_PAD;
  TopVfoAW := NeedFreqAW;
  TopVfoBW := NeedFreqBW;

  // Минимальная ширина компактной группы
  MinGroupW := TopVfoAW + XFER_SIDE_GAP + XFER_PANEL_W + XFER_SIDE_GAP + TopVfoBW;

  SafeLeft := MARGIN;
  if BtnSettings <> nil then
    SafeLeft := BtnSettings.Left + BtnSettings.Width + SETTINGS_GAP;
  SafeRight := ClientWidth - MARGIN;
  if PanelLeft <> nil then
  begin
    RightW := ClientWidth - PanelLeft.Width;
    if RightW > 200 then
    begin
      SMW   := Round(RightW * SMETER_WIDTH_RATIO);
      SMLeft := ClientWidth - SMW - SMETER_MARGIN;
      SafeRight := Min(SafeRight, SMLeft - TOP_SMETER_GAP);
    end;
  end;

  AvailW := SafeRight - SafeLeft;
  if AvailW < MinGroupW then
  begin
    PanelTopVfoGroup.Visible := False;
    Exit;
  end;

  // Динамический зазор: растёт с шириной окна, но не больше MAX_SPREAD
  ExtraPerSide := Min((AvailW - MinGroupW) div 2, MAX_SPREAD);
  DynGap := XFER_SIDE_GAP + ExtraPerSide;
  GroupW := TopVfoAW + DynGap + XFER_PANEL_W + DynGap + TopVfoBW;

  BlockH := PanelToolbar.Height - (MARGIN * 2) - 8;
  if BlockH < 56 then BlockH := 56;

  // Центрируем группу в доступном пространстве
  GroupLeft := SafeLeft + (AvailW - GroupW) div 2;
  GroupTop  := TOP_OFFSET;

  CenterY := PanelToolbar.Height div 2 - GroupTop;

  PanelTopVfoGroup.SetBounds(GroupLeft, GroupTop, GroupW, BlockH);
  PanelTopVfoGroup.Visible := True;

  // VFO-A: левый край группы
  PanelTopVfoA.SetBounds(0, 0, TopVfoAW, BlockH);
  PanelTopVfoA.Visible := True;
  PanelVfoA.SetBounds(0, 0, TopVfoAW, BlockH);
  // A выше оси, TX ниже; 1px зазор с каждой стороны
  BtnTopVfoASelect.SetBounds(0, CenterY - BtnTopVfoASelect.Height - 1,
                             BtnTopVfoASelect.Width, BtnTopVfoASelect.Height);
  BtnTopVfoATX.SetBounds(0, CenterY + 1,
                         BtnTopVfoATX.Width, BtnTopVfoATX.Height);
  FreqTop := CenterY - (TOP_VFO_FREQ_H div 2);
  FreqDispA.SetBounds(FreqDispA.Left, FreqTop,
    FreqTextW + VFO_FREQ_LEFT_PAD + VFO_FREQ_RIGHT_PAD, TOP_VFO_FREQ_H);

  // Кнопки трансфера: горизонтальный ряд, вертикально по оси
  XferX := TopVfoAW + DynGap;
  XferY := CenterY - (XFER_BTN_H div 2);
  PanelVfoButtons.SetBounds(XferX, XferY, XFER_PANEL_W, XFER_BTN_H);
  BtnVfoACopyB.SetBounds(0, 0, XFER_BTN_W, XFER_BTN_H);
  BtnVfoSwap.SetBounds(XFER_BTN_W + XFER_BTN_GAP, 0, XFER_BTN_W, XFER_BTN_H);
  BtnVfoBCopyA.SetBounds((XFER_BTN_W + XFER_BTN_GAP) * 2, 0, XFER_BTN_W, XFER_BTN_H);

  // VFO-B: правый край группы
  PanelTopVfoB.SetBounds(TopVfoAW + DynGap + XFER_PANEL_W + DynGap, 0, TopVfoBW, BlockH);
  PanelTopVfoB.Visible := True;
  PanelVfoB.SetBounds(0, 0, TopVfoBW, BlockH);
  BtnTopVfoBSelect.SetBounds(VFO_B_LEFT_PAD, CenterY - BtnTopVfoBSelect.Height - 1,
                             BtnTopVfoBSelect.Width, BtnTopVfoBSelect.Height);
  BtnTopVfoBTX.SetBounds(VFO_B_LEFT_PAD, CenterY + 1,
                         BtnTopVfoBTX.Width, BtnTopVfoBTX.Height);
  FreqTop := CenterY - (TOP_VFO_FREQ_H div 2);
  FreqDispB.SetBounds(FreqDispB.Left, FreqTop,
    FreqTextW + VFO_FREQ_LEFT_PAD + VFO_FREQ_RIGHT_PAD, TOP_VFO_FREQ_H);

end;

procedure TMainForm.ResizeSMeter;
// PanelSMeterRight на главной форме (Parent=Self).
// По Y: от самого верха формы до низа тулбара.
var
  FW:           Integer;
  RightW:       Integer;   // ширина PanelRight
  SMW:          Integer;   // ширина S-метра
  SMLeft:       Integer;
  SMTop:        Integer;
  SMBot:        Integer;
begin
  if (PanelSMeterRight = nil) or (PanelToolbar = nil) or (PanelLeft = nil) then Exit;

  LayoutTopVfoBlock;

  FW := ClientWidth;
  // Ширина зоны спектра (PanelRight)
  RightW := FW - PanelLeft.Width;
  if RightW < 200 then Exit;

  // Ширина S-метра = 32% от PanelRight
  SMW := Round(RightW * SMETER_WIDTH_RATIO);

  // X: прижат к правому краю PanelToolbar
  SMLeft := FW - SMW - SMETER_MARGIN;

  SMTop := SMETER_MARGIN;
  SMBot := PanelToolbar.Height - SMETER_MARGIN - 9;

  PanelSMeterRight.SetBounds(SMLeft, SMTop, SMW, SMBot - SMTop);
end;

function TMainForm.UpdateWidebandFrequencyView: Boolean;
var
  E: TXvtrEntry;
  SrcStart, SrcEnd: Double;
  MarkerChanged, ViewChanged: Boolean;
begin
  Result := False;
  if FWidebandView = nil then Exit;
  if FController.FActiveVfo = 0 then
    MarkerChanged := FWidebandView.SetMarkerHz(FController.FVfoA)
  else
    MarkerChanged := FWidebandView.SetMarkerHz(FController.FVfoB);
  if (FController.FCurrentXvtr >= 0) and (FController.FCurrentXvtr < CFG_XVTR_COUNT) and
     FController.FXvtrSettings.Entries[FController.FCurrentXvtr].Enabled then
  begin
    E := FController.FXvtrSettings.Entries[FController.FCurrentXvtr];
    SrcStart := E.FreqBegin - E.LOOffset + E.LOError;
    SrcEnd   := E.FreqEnd   - E.LOOffset + E.LOError;
    ViewChanged := FWidebandView.SetFrequencyView(E.FreqBegin, E.FreqEnd,
      SrcStart, SrcEnd);
  end
  else
    ViewChanged := FWidebandView.SetFrequencyView(0.0, 61440000.0, 0.0,
      61440000.0);
  Result := MarkerChanged or ViewChanged;
end;

procedure TMainForm.ResizeSpectrumPanels;
const
  SPLITTER_H = 5;
  WB_H       = 118;
  MIN_SH     = 60;   // минимальная высота спектра
  MIN_WH     = 40;   // минимальная высота водопада
var
  RW, RH, SH, WH, TopOff, WideH, WideSpecH: Integer;
  AvailH: Integer;
  RULER_H: Integer;
begin
  RULER_H := MulDiv(18, Screen.PixelsPerInch, 96);
  if PanelRight = nil then Exit;
  if Assigned(FSpecView) then SyncSpecViewFreq;
  RW     := PanelRight.ClientWidth;
  RH     := PanelRight.ClientHeight;
  UpdateWidebandFrequencyView;
  WideH  := 0;
  WideSpecH := 0;
  if FShowWideband then
    WideH := Min(MulDiv(WB_H, Screen.PixelsPerInch, 96), Max(40, RH div 2));
  if WideH > 0 then
    WideSpecH := Max(1, WideH - RULER_H);
  TopOff := WideH;
  if PbWideband <> nil then
  begin
    PbWideband.SetBounds(0, 0, RW, WideSpecH);
    PbWideband.Visible := FShowWideband and (WideSpecH > 0);
    if FShowWideband and (RW > 0) and (WideSpecH > 0) then
    begin
      FWidebandView.SetBitmapSize(RW, WideSpecH);
      PbWideband.Invalidate;
    end;
  end;
  if PbWidebandRuler <> nil then
  begin
    PbWidebandRuler.SetBounds(0, WideSpecH, RW, IfThen(FShowWideband and (WideH > 0), RULER_H, 0));
    PbWidebandRuler.Visible := FShowWideband and (WideH > 0);
    if FShowWideband and (RW > 0) and (WideH > 0) then
    begin
      FWidebandView.SetRulerSize(RW, RULER_H);
      PbWidebandRuler.Invalidate;
    end;
  end;
  // S-метр позиционируется отдельно в ResizeSMeter
  ResizeSMeter;

  // ---- Оба скрыты ----
  if (not FShowSpectrum) and (not FShowWaterfall) then
  begin
    PbSpectrum.Visible    := False;
    PbRuler.Visible       := False;
    PanelSplitter.Visible := False;
    PbWaterfall.Visible   := False;
    FSpectrumWidth   := RW;
    FSpectrumHeight  := 0;
    FWaterfallHeight := 0;
    PositionSampleRateOverlay;
    Exit;
  end;

  // ---- Только спектр (водопад скрыт) ----
  if FShowSpectrum and (not FShowWaterfall) then
  begin
    AvailH := RH - TopOff - RULER_H;
    if AvailH < MIN_SH then AvailH := MIN_SH;
    SH := AvailH;
    WH := 0;
    PbSpectrum.SetBounds(0, TopOff, RW, SH);
    PbSpectrum.Visible    := True;
    PbRuler.SetBounds(0, TopOff + SH, RW, RULER_H);
    PbRuler.Visible       := True;
    PanelSplitter.Visible := False;
    PbWaterfall.Visible   := False;
    PositionSampleRateOverlay;
    FSpectrumWidth   := RW;
    FSpectrumHeight  := SH;
    FWaterfallHeight := 0;
    if RW > 0 then
    begin
      FSpecView.SetSpectrumBitmapSize(RW, SH);
      FSpecView.SetWaterfallBitmapSize(1, 1);
      FSpecView.SetRulerSize(RW, RULER_H);
      FSpecView.DrawSpectrum;
      PbSpectrum.Invalidate;
      PbRuler.Invalidate;
    end;
    Exit;
  end;

  // ---- Только водопад (спектр скрыт): линейка под водопадом ----
  if (not FShowSpectrum) and FShowWaterfall then
  begin
    AvailH := RH - TopOff - RULER_H;
    if AvailH < MIN_WH then AvailH := MIN_WH;
    SH := 0;
    WH := AvailH;
    PbSpectrum.Visible    := False;
    PanelSplitter.Visible := False;
    PbWaterfall.SetBounds(0, TopOff, RW, WH);
    PbWaterfall.Visible   := True;
    PbRuler.SetBounds(0, TopOff + WH, RW, RULER_H);
    PbRuler.Visible       := True;
    PositionSampleRateOverlay;
    FSpectrumWidth   := RW;
    FSpectrumHeight  := 0;
    FWaterfallHeight := WH;
    if RW > 0 then
    begin
      FSpecView.SetSpectrumBitmapSize(1, 1);
      FSpecView.SetWaterfallBitmapSize(RW, WH);
      FSpecView.SetRulerSize(RW, RULER_H);
      FSpecView.DrawWaterfall;
      PbWaterfall.Invalidate;
      PbRuler.Invalidate;
    end;
    Exit;
  end;

  // ---- Оба видимы: стандартный режим со сплиттером ----
  PbSpectrum.Visible    := True;
  PbRuler.Visible       := True;
  PanelSplitter.Visible := True;
  PbWaterfall.Visible   := True;

  AvailH := RH - TopOff - RULER_H - SPLITTER_H;
  if AvailH < (MIN_SH + MIN_WH) then Exit;

  // Ограничиваем соотношение, чтобы каждая зона имела минимальный размер
  if FSplitterRatio < MIN_SH / AvailH then
    FSplitterRatio := MIN_SH / AvailH;
  if FSplitterRatio > 1.0 - MIN_WH / AvailH then
    FSplitterRatio := 1.0 - MIN_WH / AvailH;

  SH := Round(AvailH * FSplitterRatio);
  WH := AvailH - SH;
  if WH < MIN_WH then WH := MIN_WH;

  // Спектр
  PbSpectrum.SetBounds(0, TopOff, RW, SH);
  // Линейка частот — всегда прижата к низу спектра
  PbRuler.SetBounds(0, TopOff + SH, RW, RULER_H);
  // Сплиттер — между линейкой и водопадом
  PanelSplitter.SetBounds(0, TopOff + SH + RULER_H, RW, SPLITTER_H);
  // Водопад — под сплиттером
  PbWaterfall.SetBounds(0, TopOff + SH + RULER_H + SPLITTER_H, RW, WH);

  PositionSampleRateOverlay;

  // Обновляем переменные размеров
  FSpectrumWidth   := RW;
  FSpectrumHeight  := SH;
  FWaterfallHeight := WH;

  // Пересоздаём bitmap точно под новый размер
  if RW > 0 then
  begin
    if SH > 0 then FSpecView.SetSpectrumBitmapSize(RW, SH);
    if WH > 0 then FSpecView.SetWaterfallBitmapSize(RW, WH);
    FSpecView.SetRulerSize(RW, RULER_H);
    FSpecView.DrawSpectrum;
    PbSpectrum.Invalidate;
    if PbRuler <> nil then PbRuler.Invalidate;
    PbWaterfall.Invalidate;
  end;
end;

// ===========================================================================
// Dark Theme
// ===========================================================================

procedure TMainForm.ApplyDarkTheme;
var T: TAppTheme; i: Integer;
  procedure DP(P: TPanel);
  begin P.Color := T.Panel; P.Font.Color := T.Text; end;
  procedure DL(L: TLabel; Dim: Boolean = True);
  begin if Dim then L.Font.Color := T.TextDim else L.Font.Color := T.Text; end;
  procedure DS(S: TFlatSlider);
  begin
    S.SetThemeColors(T.SliderBG, T.SliderTrackEmpty, T.SliderTrackFill,
      T.SliderThumbNorm, T.SliderThumbHot, T.SliderThumbDrag, T.SliderThumbBdr);
  end;
begin
  if FLightTheme then T := LightTheme else T := DarkTheme;

  Color := T.BG; Font.Color := T.Text;
  DP(PanelToolbar); DP(PanelLeft);
  DP(PanelTopVfoGroup); DP(PanelTopVfoA); DP(PanelTopVfoB);
  DP(PanelVfoA); DP(PanelVfoB); DP(PanelVfoButtons);
  DP(PanelBands); DP(PanelMode); DP(PanelFilter);
  if PanelFMSQ <> nil then DP(PanelFMSQ);
  if PanelFMCTCSS <> nil then DP(PanelFMCTCSS);
  if PanelFMStep <> nil then DP(PanelFMStep);
  if PanelFMRpt <> nil then DP(PanelFMRpt);
  if EdFMRptOffset <> nil then EdFMRptOffset.SetAppTheme(T);
  DP(PanelRXBlock); DP(PanelAGC); DP(PanelTX);
  DP(PanelRight);

  if PbSpectrum is TPaintBox then
    TPaintBox(PbSpectrum).Color := T.BG;
  if PbWaterfall is TPaintBox then
    TPaintBox(PbWaterfall).Color := T.BG;
  if PanelSMeterRight <> nil then PanelSMeterRight.Color := T.Panel;
  if PbSMeterRight <> nil then PbSMeterRight.Color := T.Panel;
  ApplyStatusTheme(T);

  // Метки
  DL(LblAGCTop, False);
  DL(LblDrv);
  if LblFMSQ <> nil then DL(LblFMSQ, False);

  // Кнопки тулбара
  BtnDiscover.ClrNorm    := T.TbDiscoverNorm;
  BtnDiscover.ClrHot     := T.TbDiscoverHot;
  BtnDiscover.ClrActive  := T.TbDiscoverHot;
  BtnDiscover.ClrBorder  := T.TbDiscoverBorder;
  BtnDiscover.ClrText    := T.TbDiscoverText;
  BtnDiscover.ClrTextAct := T.TbDiscoverText;
  BtnDiscover.Invalidate;
  BtnStartStop.ClrNorm   := T.TbStartNorm;
  BtnStartStop.ClrBorder := T.TbStartBorder;
  BtnStartStop.ClrText   := T.TbStartText;
  BtnStartStop.Invalidate;

  // Обычные кнопки — все массивы
  for i := 0 to BAND_COUNT-1  do StyleButton(BtnBand[i],   BtnBand[i].Active);
  for i := 0 to MODE_COUNT-1  do StyleButton(BtnMode[i],   BtnMode[i].Active);
  for i := 0 to FILT_COUNT-1  do StyleButton(BtnFilter[i], BtnFilter[i].Active);
  for i := 0 to 4             do StyleButton(BtnAGCMode[i],BtnAGCMode[i].Active);
  StyleButton(BtnCTun,  BtnCTun.Active);
  StyleButton(BtnDUP,   BtnDUP.Active);
  if BtnFMSQ <> nil then StyleButton(BtnFMSQ, BtnFMSQ.Active);
  if TrkFMSQ <> nil then DS(TrkFMSQ);
  if BtnFMCTCSS <> nil then StyleButton(BtnFMCTCSS, BtnFMCTCSS.Active);
  if BtnFMCTCSSTone <> nil then StyleButton(BtnFMCTCSSTone, False);
  if BtnFMRptMinus <> nil then StyleButton(BtnFMRptMinus, BtnFMRptMinus.Active);
  if BtnFMRptPlus  <> nil then StyleButton(BtnFMRptPlus,  BtnFMRptPlus.Active);
  if FCTCSSDropDown <> nil then FCTCSSDropDown.ApplyStyle(StyleButton, T.Panel);
  if FStepDropDown  <> nil then FStepDropDown.ApplyStyle(StyleButton, T.Panel);
  if BtnChannels      <> nil then StyleButton(BtnChannels, False);
  if FChannelsDropDown <> nil then FChannelsDropDown.ApplyStyle(StyleButton, T.Panel);
  StyleButton(BtnNR,    BtnNR.Active);
  StyleButton(BtnNB,    BtnNB.Active);
  StyleButton(BtnSNB,   BtnSNB.Active);
  StyleButton(BtnANF,   BtnANF.Active);
  StyleButton(BtnMute,  BtnMute.Active);
  StyleButton(BtnMOX,   BtnMOX.Active);
  StyleButton(BtnTUN,   BtnTUN.Active);

  // VFO-copy
  StyleSpanButton(BtnVfoACopyB, False);
  StyleSpanButton(BtnVfoSwap,   False);
  StyleSpanButton(BtnVfoBCopyA, False);

  // Ползунки
  DS(TrkAGC); DS(TrkVolume); DS(TrkDrive);

  // Спектр
  FSpecView.SetTheme(T);
  if Assigned(FWidebandView) then FWidebandView.SetTheme(T);

  // VFO display — вызывает UpdateVfoDisplay, который использует тему
  UpdateVfoDisplay;
end;

procedure TMainForm.SetStatusText(Index: Integer; const Text: string);
begin
  if StatusPanel = nil then Exit;
  StatusPanel.SetStatusText(Index, Text);
end;

procedure TMainForm.SetRadioOfflineStatus(const ConnText: string; ClearDevice: Boolean);
begin
  SetStatusText(2, ConnText);
  SetStatusText(3, 'Supply --');
  SetStatusText(4, 'RX idle');
  SetStatusText(5, 'TX idle');
  SetStatusText(6, 'SEQ --');
  SetStatusText(7, 'PLL --');
  if FSpecView <> nil then
    FSpecView.ADCOverloadVisible := False;
  if PbSpectrum <> nil then
    PbSpectrum.Invalidate;
  FillChar(FDDCSeqValid, SizeOf(FDDCSeqValid), 0);
  FSeqErrorCount     := 0;
  FLastSeqErrorDDC   := -1;
  FLastSeqErrorDelta := 0;
  FSeqOkStreak       := 0;
  if ClearDevice then
  begin
    SetStatusText(0, 'Board --');
    SetStatusText(1, 'IP --');
  end;
end;

procedure TMainForm.ApplyStatusTheme(const T: TAppTheme);
begin
  if StatusPanel = nil then Exit;
  StatusPanel.ApplyTheme(T);
end;

procedure TMainForm.SetLightTheme(V: Boolean);
begin
  if FLightTheme = V then Exit;
  FLightTheme := V;
  ApplyDarkTheme;
  FController.FSettings.SaveTheme(V);
  FController.FSettings.Save;
  if (FSettingsForm <> nil) and TSettingsForm(FSettingsForm).Visible then
    TSettingsForm(FSettingsForm).LoadLightTheme(V);
end;

procedure TMainForm.StyleButton(B: TFlatButton; Active: Boolean);
var T: TAppTheme;
begin
  if FLightTheme then T := LightTheme else T := DarkTheme;
  B.Active     := Active;
  B.ClrNorm    := T.BtnNorm;
  B.ClrActive  := T.BtnActive;
  B.ClrHot     := T.BtnHot;
  if Active then B.ClrBorder := T.BtnBorderActive
  else           B.ClrBorder := T.BtnBorderNorm;
  B.ClrText    := T.BtnText;
  B.ClrTextAct := T.BtnTextActive;
  B.Font.Name  := 'Courier New';
  B.Font.Size  := 8;
end;

procedure TMainForm.StyleSpanButton(B: TFlatButton; Active: Boolean);
var T: TAppTheme;
begin
  if FLightTheme then T := LightTheme else T := DarkTheme;
  B.Active     := Active;
  B.ClrNorm    := T.SpanNorm;
  B.ClrActive  := T.SpanActive;
  B.ClrHot     := T.SpanActive;
  B.ClrBorder  := T.SpanBorder;
  B.ClrText    := T.SpanText;
  B.ClrTextAct := T.SpanText;
  B.Font.Name  := 'Courier New';
  B.Font.Size  := 7;
end;

procedure TMainForm.PositionSampleRateOverlay;
const
  MARGIN_X = 34;
  MARGIN_Y = 4;
begin
  if (FSampleRateOverlay = nil) or (PbSpectrum = nil) then Exit;
  FSampleRateOverlay.SetBounds(
    MARGIN_X,
    MARGIN_Y,
    FSampleRateOverlay.Width,
    FSampleRateOverlay.Height);
end;

procedure TMainForm.OnSampleRateOverlayInvalidate(Sender: TObject);
begin
  FSpectrumDirty := True;
  if Assigned(FSpecView) then
  begin
    FSpecView.SpectrumDirty := True;
    FSpecView.InvalidateOverlayCache;
  end;
  if Assigned(PbSpectrum) then PbSpectrum.Invalidate;
end;

// ===========================================================================
// VFO
// ===========================================================================

function TMainForm.FormatFreq(Hz: Double): string;
var
  Mhz: Int64;
  KHz, Rest: Integer;
begin
  Mhz  := Trunc(Hz / 1000000);
  KHz  := Trunc((Hz - Mhz * 1000000) / 1000);
  Rest := Trunc(Hz) mod 1000;
  Result := Format('%d.%3.3d.%3.3d', [Mhz, KHz, Rest]);
end;

procedure TMainForm.UpdateVfoDisplay;
const
  TOP_VFO_FREQ_H = 47;
var T: TAppTheme;
begin
  if FLightTheme then T := LightTheme else T := DarkTheme;

  FreqDispA.Frequency := Round(FController.FVfoA);
  FreqDispB.Frequency := Round(FController.FVfoB);
  FreqDispA.FontSize := TOP_VFO_FONT;
  FreqDispB.FontSize := TOP_VFO_FONT;
  FreqDispA.Height := TOP_VFO_FREQ_H;
  FreqDispB.Height := TOP_VFO_FREQ_H;

  FreqDispA.ColorHover   := T.FreqHover;
  FreqDispA.ColorHoverBG := T.FreqHoverBG;
  FreqDispA.ColorDim     := T.FreqDimDot;
  FreqDispB.ColorHover   := T.FreqHover;
  FreqDispB.ColorHoverBG := T.FreqHoverBG;
  FreqDispB.ColorDim     := T.FreqDimDot;

  if FController.FActiveVfo = 0 then
  begin
    FreqDispA.ColorNormal := T.FreqActive;
    FreqDispB.ColorNormal := T.FreqDim;
    LblVfoALabel.Font.Color := T.FreqActive;
    LblVfoBLabel.Font.Color := T.TextDim;
    if BtnTopVfoASelect <> nil then begin
      BtnTopVfoASelect.ClrNorm    := T.VfoSelActNorm;
      BtnTopVfoASelect.ClrActive  := T.VfoSelActNorm;
      BtnTopVfoASelect.ClrHot     := T.VfoSelActNorm;
      BtnTopVfoASelect.ClrBorder  := T.VfoSelActBorder;
      BtnTopVfoASelect.ClrText    := T.VfoSelActText;
      BtnTopVfoASelect.ClrTextAct := T.VfoSelActTextAct;
      BtnTopVfoASelect.Active := True;
      BtnTopVfoASelect.Invalidate;
    end;
    if BtnTopVfoBSelect <> nil then begin
      BtnTopVfoBSelect.ClrNorm    := T.VfoSelInaNorm;
      BtnTopVfoBSelect.ClrActive  := T.VfoSelInaNorm;
      BtnTopVfoBSelect.ClrHot     := T.VfoSelInaNorm;
      BtnTopVfoBSelect.ClrBorder  := T.VfoSelInaBorder;
      BtnTopVfoBSelect.ClrText    := T.VfoSelInaText;
      BtnTopVfoBSelect.ClrTextAct := T.VfoSelInaTextAct;
      BtnTopVfoBSelect.Active := False;
      BtnTopVfoBSelect.Invalidate;
    end;
  end else begin
    FreqDispA.ColorNormal := T.FreqDim;
    FreqDispB.ColorNormal := T.FreqActive;
    LblVfoALabel.Font.Color := T.TextDim;
    LblVfoBLabel.Font.Color := T.FreqActive;
    if BtnTopVfoASelect <> nil then begin
      BtnTopVfoASelect.ClrNorm    := T.VfoSelInaNorm;
      BtnTopVfoASelect.ClrActive  := T.VfoSelInaNorm;
      BtnTopVfoASelect.ClrHot     := T.VfoSelInaNorm;
      BtnTopVfoASelect.ClrBorder  := T.VfoSelInaBorder;
      BtnTopVfoASelect.ClrText    := T.VfoSelInaText;
      BtnTopVfoASelect.ClrTextAct := T.VfoSelInaTextAct;
      BtnTopVfoASelect.Active := False;
      BtnTopVfoASelect.Invalidate;
    end;
    if BtnTopVfoBSelect <> nil then begin
      BtnTopVfoBSelect.ClrNorm    := T.VfoSelActNorm;
      BtnTopVfoBSelect.ClrActive  := T.VfoSelActNorm;
      BtnTopVfoBSelect.ClrHot     := T.VfoSelActNorm;
      BtnTopVfoBSelect.ClrBorder  := T.VfoSelActBorder;
      BtnTopVfoBSelect.ClrText    := T.VfoSelActText;
      BtnTopVfoBSelect.ClrTextAct := T.VfoSelActTextAct;
      BtnTopVfoBSelect.Active := True;
      BtnTopVfoBSelect.Invalidate;
    end;
  end;
  if BtnTopVfoATX <> nil then
  begin
    if not FController.FSplitTxB then
    begin
      BtnTopVfoATX.ClrNorm    := T.VfoTxActNorm;
      BtnTopVfoATX.ClrHot     := T.VfoTxActNorm;
      BtnTopVfoATX.ClrActive  := T.VfoTxActNorm;
      BtnTopVfoATX.ClrBorder  := T.VfoTxActBorder;
      BtnTopVfoATX.ClrText    := T.VfoTxActText;
      BtnTopVfoATX.ClrTextAct := T.VfoTxActText;
      BtnTopVfoATX.Active := True;
    end
    else
    begin
      BtnTopVfoATX.ClrNorm    := T.VfoTxInaNorm;
      BtnTopVfoATX.ClrHot     := T.VfoTxInaNorm;
      BtnTopVfoATX.ClrActive  := T.VfoTxInaNorm;
      BtnTopVfoATX.ClrBorder  := T.VfoTxInaBorder;
      BtnTopVfoATX.ClrText    := T.VfoTxInaText;
      BtnTopVfoATX.ClrTextAct := T.VfoTxInaText;
      BtnTopVfoATX.Active := False;
    end;
    BtnTopVfoATX.Invalidate;
  end;
  if BtnTopVfoBTX <> nil then
  begin
    if FController.FSplitTxB then
    begin
      BtnTopVfoBTX.ClrNorm    := T.VfoTxActNorm;
      BtnTopVfoBTX.ClrHot     := T.VfoTxActNorm;
      BtnTopVfoBTX.ClrActive  := T.VfoTxActNorm;
      BtnTopVfoBTX.ClrBorder  := T.VfoTxActBorder;
      BtnTopVfoBTX.ClrText    := T.VfoTxActText;
      BtnTopVfoBTX.ClrTextAct := T.VfoTxActText;
      BtnTopVfoBTX.Active := True;
    end
    else
    begin
      BtnTopVfoBTX.ClrNorm    := T.VfoTxInaNorm;
      BtnTopVfoBTX.ClrHot     := T.VfoTxInaNorm;
      BtnTopVfoBTX.ClrActive  := T.VfoTxInaNorm;
      BtnTopVfoBTX.ClrBorder  := T.VfoTxInaBorder;
      BtnTopVfoBTX.ClrText    := T.VfoTxInaText;
      BtnTopVfoBTX.ClrTextAct := T.VfoTxInaText;
      BtnTopVfoBTX.Active := False;
    end;
    BtnTopVfoBTX.Invalidate;
  end;
  FreqDispA.Invalidate;
  FreqDispB.Invalidate;
  LblVfoALabel.Invalidate;
  LblVfoBLabel.Invalidate;
end;

// ===========================================================================
// Rendering helpers — delegated to TSpectrumView
// ===========================================================================

procedure TMainForm.InvalidateGridCache;
begin
  FSpecView.InvalidateGridCache;
end;

procedure TMainForm.SyncSpecViewFreq;
begin
  FSpecView.VfoA      := FController.FVfoA;
  FSpecView.VfoB      := FController.FVfoB;
  FSpecView.ActiveVfo := FController.FActiveVfo;
  FSpecView.CenterFreq := FController.FCenterFreq;
  FSpecView.SpanHz    := FController.FSpanHz;
  FSpecView.Mode      := FController.FMode;
  FSpecView.FilterBW  := FController.FFilterBW;
  if FController.FMode = MODE_FM then
    FSpecView.FMGridStepHz := FM_STEP_HZ[FController.FFMStepIdx]
  else
    FSpecView.FMGridStepHz := 0;
  // TX overlay следует за активной TX-частотой (учитывает split). При CTUN
  // спектр TX будет отрисован на VFO, а не в центре дисплея.
  FSpecView.TXFreq    := ActiveTXFreqHz;
  // Какой VFO привязан к TX (для split-полос фильтра): 1=B при FController.FSplitTxB, иначе FController.FActiveVfo.
  if FController.FSplitTxB then FSpecView.TXVfoIndex := 1
  else              FSpecView.TXVfoIndex := FController.FActiveVfo;
  if UpdateWidebandFrequencyView and FShowWideband and (PbWideband <> nil) then
    PbWideband.Invalidate;
end;


// ===========================================================================
// Timers
// ===========================================================================

procedure TMainForm.MeterTimerTick(Sender: TObject);
const
  AVG_ALPHA     = 0.25;   // EMA для сглаженного среднего уровня
  ZONE_DB       = 6.0;    // полуширина зоны вокруг среднего (дБ)
  // Адаптивный alpha: базовый + ускорение при большой разнице
  PEAK_BASE     = 0.06;   // базовая скорость пика
  MIN_BASE      = 0.06;   // базовая скорость минимума
  ACCEL_FACTOR  = 0.018;  // ускорение на каждый dB разницы
  MAX_ALPHA     = 0.85;   // максимальная скорость (не мгновенно)
var
  PeakTarget, MinTarget: Double;
  PeakDiff,  MinDiff:    Double;
  PeakAlpha, MinAlpha:   Double;
begin
  if not FController.FRunning then
  begin
    if PbSMeterRight <> nil then PbSMeterRight.Invalidate;
    Exit;
  end;


  // Сглаженное среднее (EMA)
  FSMeterAvg := FSMeterAvg * (1.0 - AVG_ALPHA) + FLastSMeter * AVG_ALPHA;

  // Цели для Peak и Min
  PeakTarget := Max(FLastSMeter, FSMeterAvg + ZONE_DB);
  MinTarget  := Min(FLastSMeter, FSMeterAvg - ZONE_DB);

  // Адаптивный alpha: чем дальше от цели — тем быстрее догоняем
  PeakDiff  := Abs(PeakTarget - FSMeterPeak);
  MinDiff   := Abs(MinTarget  - FSMeterMin);
  PeakAlpha := Min(MAX_ALPHA, PEAK_BASE + PeakDiff * ACCEL_FACTOR);
  MinAlpha  := Min(MAX_ALPHA, MIN_BASE  + MinDiff  * ACCEL_FACTOR);

  FSMeterPeak := FSMeterPeak + PeakAlpha * (PeakTarget - FSMeterPeak);
  FSMeterMin  := FSMeterMin  + MinAlpha  * (MinTarget  - FSMeterMin);

  FSpecView.LastSMeter  := FLastSMeter;
  FSpecView.SMeterPeak  := FSMeterPeak;
  FSpecView.SMeterMin   := FSMeterMin;
  FSpecView.LastFwdW    := FLastFwdW;
  FSpecView.LastSWR     := FLastSWR;
  FSpecView.Transmitting := FController.FTransmitting;

  if PbSMeterRight <> nil then PbSMeterRight.Invalidate;
  // PWR/SWR метки и бары обновляем здесь (10 Гц), а не в DoUpdateStatus —
  // HP Status пакеты идут ~150 раз/с, при таком rate цифры дёргаются.
  UpdateTXMeters;

  if FController.FWDSPReady then
  begin
    if FController.FTransmitting then
    begin
      if FController.FDSPEngine.TXMicSource = txmsSoundCard then
        SetStatusText(5, 'TX sound card')
      else
        SetStatusText(5, 'TX radio mic');
    end
    else
      SetStatusText(5, 'TX idle');
  end;
end;

procedure TMainForm.SpectrumTimerTick(Sender: TObject);
var
  NowTick:     QWord;
  ElapsedTick: QWord;
  WebStatusText, WebBoardText, WebIPText, WebSupplyText: string;
  WebPLLText, WebRXText, WebTXText, WebSeqText: string;
begin
  // Синхронизируем размеры если размер панели изменился (после ресайза).
  // Важно: проверяем только видимые панели — когда спектр скрыт,
  // PbSpectrum.Height может быть ненулевым при FSpectrumHeight=0,
  // что вызвало бы сброс буфера водопада на каждом тике.
  if (FSpectrumWidth = 0) or
     (FShowWideband and (PbWideband.Width <> FSpectrumWidth)) or
     (FShowSpectrum and ((PbSpectrum.Width <> FSpectrumWidth) or
                         (PbSpectrum.Height <> FSpectrumHeight))) or
     (FShowWaterfall and not FShowSpectrum and
                        ((PbWaterfall.Width <> FSpectrumWidth) or
                         (PbWaterfall.Height <> FWaterfallHeight))) then
    ResizeSpectrumPanels;
  if FSpectrumWidth <= 0 then Exit;
  SyncSpecViewFreq;

  // Проверка таймаута — трансивер не отвечает
  if FController.FRunning and FController.FNetwork.Running then
  begin
    NowTick     := GetTickCount64;
    ElapsedTick := NowTick - FRXStartTime;
    // Ждём первый пакет 5 секунд после старта
    if (FRXLastPktTime = 0) and (ElapsedTick > 5000) then
    begin
      BtnStartStop.Caption := 'START';
      StyleButton(BtnStartStop, False);
      FController.FRunning := False;
      UninhibitSleep;
      FController.FNetwork.SetRunAndFreq(False, XvtrTranslate(FController.FCenterFreq), XvtrTranslate(FController.FCenterFreq), 0);
      SetRadioOfflineStatus('Hardware timeout');
      BtnMOX.Enabled := False;
      Exit;
    end;
    // После первого пакета — следим чтобы поток не прерывался более 3 сек
    if (FRXLastPktTime > 0) and ((NowTick - FRXLastPktTime) > 3000) then
    begin
      BtnStartStop.Caption := 'START';
      StyleButton(BtnStartStop, False);
      FController.FRunning := False;
      UninhibitSleep;
      FController.FNetwork.SetRunAndFreq(False, XvtrTranslate(FController.FCenterFreq), XvtrTranslate(FController.FCenterFreq), 0);
      SetRadioOfflineStatus('Connection lost');
      BtnMOX.Enabled := False;
      Exit;
    end;
  end;

  if FController.FRunning then
  begin
    // Обновляем статус из таймера — без Synchronize в сетевом потоке.
    SetStatusText(4, 'RX running');
    if FSeqErrorCount > 0 then
      SetStatusText(6, Format('SEQ ERR DDC%d', [FLastSeqErrorDDC]))
    else
      SetStatusText(6, 'SEQ OK');

    if FController.FWDSPReady then
    begin
      FLastSMeter := FController.FDSPEngine.GetSMeterDBm;
      FController.FDSPEngine.SetSpectrumWidth(FSpectrumWidth);
      Inc(FAgcLineCounter);
      if FAgcLineCounter >= 6 then
      begin
        FController.FDSPEngine.UpdateAGCLines(FSpectrumWidth);
        FSpecView.AGCThresh    := FController.FDSPEngine.AGCThresh;
        FSpecView.AGCHangLevel := FController.FDSPEngine.AGCHangLevel;
        FAgcLineCounter := 0;
      end;
    end;
    FSpecView.WDSPReady := FController.FWDSPReady;
    // Обновляем оверлей если видим
    if Assigned(FVfoOverlay) and FVfoOverlay.Visible then
    begin
      FVfoOverlay.UpdateSMeter(FLastSMeter);
      FVfoOverlay.UpdateVfo(FController.FVfoA);
      PositionVfoOverlay;
    end;
    // Панорама и водопад теперь рисуются независимо по факту новых кадров.
    // Это лучше совпадает с Thetis: маленький FFT даёт новые строки чаще.
    if FSpectrumDirty then
    begin
      FSpecView.DrawSpectrum;
      PbSpectrum.Invalidate;
      if FSpecView.NeedsRulerRedraw then
        PbRuler.Invalidate;
      FSpectrumDirty := False;
    end;
    if FWaterfallDirty then
    begin
      FSpecView.DrawWaterfall;
      PbWaterfall.Invalidate;
      FWaterfallDirty := False;
    end;
    if FWidebandDirty and FShowWideband then
    begin
      PbWideband.Invalidate;
      FWidebandDirty := False;
    end;
  end
  else
  begin
    // Не подключены — показываем чистый экран (без анимации)
    if not FController.FNetwork.Connected then
    begin
      // При первом старте показываем демо-спектр
      if FSpecView.SpectrumBitmapWidth <> FSpectrumWidth then
      begin
        FSpecView.SetSpectrumBitmapSize(FSpectrumWidth, FSpectrumHeight);
        FSpecView.ResetSpectrumBuf;
        FSpecView.DrawSpectrum;
        PbSpectrum.Invalidate;
      end;
    end
    else
    begin
      // Подключены, но не запущены — статичный спектр (шум)
      FSpecView.DrawSpectrum;
      PbSpectrum.Invalidate;
      FSpectrumDirty := False;
    end;
  end;

  // Если не в режиме RUN, но есть "грязный" кадр (drag/marker/UI), перерисуем.
  if (FSpectrumDirty or FWaterfallDirty) and (not FController.FRunning) then
  begin
    if FSpectrumDirty then
      FSpecView.DrawSpectrum;
    if FWaterfallDirty then
      FSpecView.DrawWaterfall;
    if FSpectrumDirty then
      PbSpectrum.Invalidate;
    if FSpecView.NeedsRulerRedraw then
      PbRuler.Invalidate;
    if FWaterfallDirty then
      PbWaterfall.Invalidate;
    FWaterfallDirty := False;
    FSpectrumDirty := False;
  end;

  // Пушим текущее состояние в веб-клиенты (если есть)
  if Assigned(FWebServer) then
  begin
    if FController.FNetwork.Connected then
    begin
      if FController.FRunning then
        WebStatusText := 'Running'
      else
        WebStatusText := 'Connected';
      WebBoardText := 'Board: ' + BoardTypeName(FController.FNetwork.Device.BoardType);
      WebIPText    := 'IP: ' + FController.FNetwork.Device.IPAddress;
    end
    else
    begin
      WebStatusText := 'Disconnected';
      WebBoardText  := 'Board --';
      WebIPText     := 'IP --';
    end;

    if not FController.FRunning then
    begin
      WebSupplyText := 'Supply --';
      WebPLLText    := 'PLL --';
    end
    else
    begin
      if FLastPLLLock then
        WebPLLText := 'PLL OK'
      else
        WebPLLText := 'PLL?';

      if not BoardSupportsSupplyVoltage(FController.FNetwork.Device.BoardType) then
        WebSupplyText := 'Supply n/a'
      else if FLastSupplyV >= 0 then
      begin
        if FLastSupplyA >= 0 then
          WebSupplyText := Format('Supply %.1fV %.1fA', [FLastSupplyV, FLastSupplyA])
        else
          WebSupplyText := Format('Supply %.1fV', [FLastSupplyV]);
      end
      else
        WebSupplyText := 'Supply --';
    end;

    if FController.FRunning then
      WebRXText := 'RX running'
    else
      WebRXText := 'RX idle';

    if FController.FTuning then
      WebTXText := 'TX tune'
    else if FController.FTransmitting then
      WebTXText := 'TX active'
    else
      WebTXText := 'TX idle';

    if FSeqErrorCount > 0 then
      WebSeqText := Format('SEQ ERR DDC%d', [FLastSeqErrorDDC])
    else if FController.FRunning then
      WebSeqText := 'SEQ OK'
    else
      WebSeqText := 'SEQ --';

    FWebServer.PushSpectrum(
      FSpectrumBuf, 1024,
      FWaterfallBuf,
      FLastSMeter,
      FController.FVfoA, FController.FMode, FController.FFilterBW, FController.FAGCMode, FController.FAGCTop,
      FController.FSpanHz, FController.FVolume,
      FController.FWfAGCEnabled, FController.FWfNFEnabled,
      FController.FCurrentBand,
      FController.FRunning and FController.FNetwork.Connected,
      FController.FRunning, FController.FMuted, FController.FCTun,
      FController.FNRMode, FController.FNBMode, FController.FSNB, FController.FANF,
      FController.FCenterFreq, FController.FFilter,
      FController.FVfoB, FController.FActiveVfo,
      FController.FTransmitting, FController.FDrivePercent,
      FController.FAtten, FController.FTuning, FController.FDisplayDuplex,
      FLastFwdW, FLastSWR, FController.FPAMaxPower,
      WebStatusText, WebBoardText, WebIPText, WebSupplyText,
      WebPLLText, WebRXText, WebTXText, WebSeqText);
  end;
end;

// ===========================================================================
// Network callbacks → sync helpers
// ===========================================================================

procedure TMainForm.OnDeviceFound(const Dev: THPSDRDevice);
var
  BoardName, Entry: string;
  Sync: TDeviceFoundSync;
  M: TThreadMethod;
begin
  BoardName := BoardTypeName(Dev.BoardType);
  Entry := Format('%s  %s  FW:%d  DDC:%d',
                  [Dev.IPAddress, BoardName,
                   Dev.FirmwareVersion, Dev.NumDDCs]);
  Sync := TDeviceFoundSync.Create(DoAddDevice, Dev, Entry);
  try
    M := Sync.Execute;
    TThread.Synchronize(nil, M);
  finally
    Sync.Free;
  end;
end;

procedure TMainForm.DoAddDevice(const Dev: THPSDRDevice; const Entry: string);
var
  Idx: Integer;
begin
  Idx := Length(FDevices);
  SetLength(FDevices, Idx + 1);
  FDevices[Idx].Dev     := Dev;
  FDevices[Idx].Display := Entry;
  Inc(FDeviceCount);

  if Assigned(FDeviceDialog) then
    FDeviceDialog.AddDiscovered(Dev.IPAddress, Entry, Dev.BoardType);

    SetStatusText(2, Format('Found: %s', [Dev.IPAddress]));
end;

procedure TMainForm.OnHPStatusCB(const Status: THighPriorityStatus);
const
  // Порог fwd-мощности (W), ниже которого SWR не вычисляем — там всё ADC-шум.
  SWR_MIN_FWD_W = 1.0;
  // Верхняя граница SWR (выше — это всё равно «беда»).
  SWR_CAP       = 9.9;
var
  ExcPwr, FwdPwr, RevPwr: Word;
  SupplyV, SupplyA, FwdW, SWRV, Rho: Double;
  IsTx: Boolean;
  Sync: TStatusUISync;
  M: TThreadMethod;
begin
  ExcPwr  := (Status.ExciterPwr0Hi shl 8) or Status.ExciterPwr0Lo;
  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): voltage on UserADC0 (bytes 57-58),
  // current on UserADC1 (bytes 55-56) — matching Thetis behaviour.
  // Other supported boards report voltage in SupplyVolts bytes and no current.
  if BoardSupportsSupplyVoltage(FController.FNetwork.Device.BoardType) then
  begin
    if FController.FNetwork.Device.BoardType = 5 then
      SupplyV := ADCToSupplyVolts(
                   (Status.UserADC0Hi shl 8) or Status.UserADC0Lo,
                   FController.FNetwork.Device.BoardType)
    else
      SupplyV := ADCToSupplyVolts(
                   (Status.SupplyVoltsHi shl 8) or Status.SupplyVoltsLo,
                   FController.FNetwork.Device.BoardType);

    if BoardSupportsSupplyCurrent(FController.FNetwork.Device.BoardType) then
      SupplyA := ADCToSupplyCurrent(
                   (Status.UserADC1Hi shl 8) or Status.UserADC1Lo,
                   FController.FNetwork.Device.BoardType);
  end;
  // FLastSMeter обновляется только из WDSP (GetSMeterDBm) в SpectrumTimerTick —
  // ExciterPwr это мощность TX, не уровень принятого сигнала.
  FwdW := ADCToWatts100(FwdPwr);
  // FController.FTransmitting — наш программный флаг (MOX/TUN/HW-PTT через ApplyMOX).
  // HPS_PTT-бит в StatusBits — это вход аппаратного PTT (footswitch/mic),
  // а не «трансивер сейчас передаёт», поэтому только на него полагаться нельзя.
  IsTx := FController.FTransmitting or ((Status.StatusBits and HPS_PTT) <> 0);
  // SWR: правильная формула (1+ρ)/(1-ρ), считаем только когда есть значимая
  // прямая мощность — иначе ADC-шум RevPwr даёт случайные «прыжки» SWR.
  // Не на передаче — SWR не показываем (фиксируем 1.0), Fwd принудительно 0.
  if IsTx and (FwdW >= SWR_MIN_FWD_W) and (FwdPwr > 0) then
  begin
    // ρ = sqrt(Pr/Pf). Поскольку P=V²/R и V пропорционально ADC, то
    // sqrt(Pr/Pf) = adc_r/adc_f напрямую — лишнего sqrt не нужно.
    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;
  Sync := TStatusUISync.Create(DoUpdateStatus, FwdW, SWRV, SupplyV, SupplyA,
            (Status.StatusBits and HPS_PLL_LOCKED) <> 0,
            (Status.StatusBits and HPS_PTT)         <> 0,
            Status.ADCOverload);
  try
    M := Sync.Execute;
    TThread.Synchronize(nil, M);
  finally
    Sync.Free;
  end;
end;

procedure TMainForm.DoUpdateStatus(FwdW, SWRV, SupplyV, SupplyA: Double; PLLLock, HWPTT: Boolean;
  ADCOverload: Byte);
const
  // Thetis/piHPSDR PEP-style баллистика для FwdW и SWR: атака мгновенная
  // (peak-hold), спад медленный — стрелка «висит» на пике и плавно опускается.
  // Это маскирует огибающую SSB-голоса, которая на ADC прыгает на 10-20 дБ
  // внутри слога. На rate ~150 пакетов/с эти α дают τ_decay ≈ 350 мс / 500 мс.
  DECAY_FWD     = 0.02;
  DECAY_SWR     = 0.015;
  // Напряжение питания меняется медленно — обычное симметричное EMA.
  ALPHA_SUPPLY  = 0.05;
begin
  // Запоминаем значения; вывод в UI делает MeterTimerTick на 10 Гц.
  // Сама обработка здесь идёт на UI-потоке через Synchronize.
  // FwdW=0 / SWRV=1 — это «не на передаче»: снапим без decay,
  // чтобы метры моментально падали при отпускании PTT.
  if FwdW <= 0 then
    FLastFwdW := 0
  else if FwdW > FLastFwdW then
    FLastFwdW := FwdW                                     // attack: instant
  else
    FLastFwdW := DECAY_FWD * FwdW + (1.0 - DECAY_FWD) * FLastFwdW;
  if SWRV <= 1.0 then
    FLastSWR := 1.0
  else if SWRV > FLastSWR then
    FLastSWR := SWRV                                      // attack: instant
  else
    FLastSWR := DECAY_SWR * SWRV + (1.0 - DECAY_SWR) * FLastSWR;
  if SupplyV >= 0 then
  begin
    if FLastSupplyV < 0 then
      FLastSupplyV := SupplyV   // первое значение — без сглаживания
    else
      FLastSupplyV := ALPHA_SUPPLY * SupplyV + (1.0 - ALPHA_SUPPLY) * FLastSupplyV;
  end else
    FLastSupplyV := -1.0;

  if SupplyA >= 0 then
  begin
    if FLastSupplyA < 0 then
      FLastSupplyA := SupplyA
    else
      FLastSupplyA := ALPHA_SUPPLY * SupplyA + (1.0 - ALPHA_SUPPLY) * FLastSupplyA;
  end else
    FLastSupplyA := -1.0;
  FLastPLLLock := PLLLock;

  if (FSpecView <> nil) and (FSpecView.ADCOverloadVisible <> (ADCOverload <> 0)) then
  begin
    FSpecView.ADCOverloadVisible := ADCOverload <> 0;
    if PbSpectrum <> nil then
      PbSpectrum.Invalidate;
  end;

  // Hardware PTT (foot switch / mic PTT) — обнаружение фронта (нужно делать
  // на rate HP Status, а не таймера, чтобы не пропустить короткое нажатие).
  if HWPTT <> FController.FHWPTTActive then
  begin
    FController.FHWPTTActive := HWPTT;
    if not FController.FTuning then
    begin
      if HWPTT then
      begin
        // Press: не перекрываем ручной MOX; запоминаем что мы включили TX.
        if not BtnMOX.Active then
        begin
          ApplyMOX(True);
          FController.FHWPTTStartedTX := True;
        end;
      end else
      begin
        // Release: выключаем TX только если он был включён через HWPTT.
        // Нельзя проверять BtnMOX.Active — ApplyMOX(True) сам его выставляет.
        if FController.FHWPTTStartedTX then
        begin
          FController.FHWPTTStartedTX := False;
          ApplyMOX(False);
        end;
      end;
    end;
  end;
end;

procedure TMainForm.UpdateTXMeters;
// Вызывается из MeterTimerTick (10 Гц). Использует FLastFwdW/FLastSWR/
// FLastSupplyV/FLastSupplyA обновляются на rate HP Status (~150/с)
// в DoUpdateStatus.
begin
  if not FController.FRunning then
  begin
    SetStatusText(3, 'Supply --');
    SetStatusText(7, 'PLL --');
    Exit;
  end;

  if FLastPLLLock then
    SetStatusText(7, 'PLL OK')
  else
    SetStatusText(7, 'PLL?');

  if not BoardSupportsSupplyVoltage(FController.FNetwork.Device.BoardType) then
  begin
    SetStatusText(3, 'Supply n/a');
    Exit;
  end;

  if FLastSupplyV >= 0 then
  begin
    if FLastSupplyA >= 0 then
      SetStatusText(3,
        Format('Supply %.1fV %.1fA', [FLastSupplyV, FLastSupplyA]))
    else
      SetStatusText(3,
        Format('Supply %.1fV', [FLastSupplyV]));
  end else
    SetStatusText(3, 'Supply --');
end;

procedure TMainForm.OnDDCIQCB(DDCIndex: Integer; const Data: TDDCIQPacket);
var
  SamplesPerFrame: Integer;
  Seq, ExpectedSeq: LongWord;
  Delta: Int64;
begin
  // Вызывается из сетевого потока — UI не трогаем напрямую

  // Счётчик принятых пакетов
  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;

  // 1. Подаём IQ данные в DSP — только с активного DDC
  if (DDCIndex = FActiveDDC) and FController.FWDSPReady then
  begin
    SamplesPerFrame := (Integer(Data.SamplesPerFrame[0]) shl 8) or
                        Integer(Data.SamplesPerFrame[1]);
    if SamplesPerFrame <= 0 then SamplesPerFrame := 238;  // fallback
    FController.FDSPEngine.PushDDCPacket(Data.IQData, 0, SamplesPerFrame);
  end;

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

procedure TMainForm.OnWidebandCB(ADCIndex: Integer;
  const Samples: array of SmallInt; Count: Integer);
begin
  if (not FShowWideband) or (FWidebandView = nil) then Exit;
  FWidebandView.SetSamples(Samples, Count);
  FWidebandDirty := True;
end;

procedure TMainForm.DoUpdateDDCSeq(DDCIdx: Integer; Seq: LongWord);
begin
  SetStatusText(4, 'RX running');
end;

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

// ---------------------------------------------------------------------------
// TX settings — применение к WDSP, сборка DUC Specific, sound-card pull
// ---------------------------------------------------------------------------

function TMainForm.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 FController.FTXSettings.MicLineIn then
    Result := Result or $01               // bit 0: Line In
  else if FController.FTXSettings.MicBoost then
    Result := Result or $02;              // bit 1: Boost только при Mic In
  if not FController.FTXSettings.MicPTTEnabled then Result := Result or $04;  // bit 2: инверсия
  if FController.FTXSettings.MicTipRing         then Result := Result or $08;  // bit 3: Ring
  if FController.FTXSettings.MicBias             then Result := Result or $10;  // bit 4: bias
end;

procedure TMainForm.SendDUCSpecificFromSettings;
var
  DUCPkt: TDUCSpecificPacket;
  RateKsps: Word;
begin
  if not (FController.FNetwork.Connected and FController.FNetwork.Running) then Exit;
  FillChar(DUCPkt, SizeOf(DUCPkt), 0);
  // DUC0 rate в ksps берём из WDSP-движка (фиксированный TX rate, не RX).
  RateKsps := FController.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((FController.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(FController.FTXSettings.AttOnTX, 0, 31));
  DUCPkt.StepAtten1 := DUCPkt.StepAtten0;
  DUCPkt.StepAtten2 := DUCPkt.StepAtten0;
  FController.FNetwork.SendDUCSpecific(DUCPkt);
end;

function TMainForm.DefaultMicSource: TTXMicSource;
// Mic source для MOX-кнопки и Web-TX: если настроена звуковая карта — берём с неё,
// иначе используем HW mic-стрим от трансивера.
// HW PTT всегда переключает на txmsRadio непосредственно в ApplyMOX.
begin
  if FController.FAudioInDevName <> '' then Result := txmsSoundCard
  else                           Result := txmsRadio;
end;

procedure TMainForm.ApplyTXSettingsToDSP;
begin
  if not FController.FWDSPReady then Exit;
  // Mic source — выставляем значение по умолчанию для MOX-кнопки;
  // при реальном PTT ApplyMOX скорректирует источник.
  FController.FDSPEngine.SetTXMicSource(DefaultMicSource);
  FController.FDSPEngine.SetMicGain(FController.FTXSettings.MicGainDB);
  FController.FDSPEngine.SetTXFilterFull(FController.FTXSettings.FilterLow, FController.FTXSettings.FilterHigh,
                              FController.FTXSettings.FilterNC, FController.FTXSettings.FilterMP,
                              FController.FTXSettings.FilterWindow);
  FController.FDSPEngine.SetTXCompressor(FController.FTXSettings.CompressorOn, FController.FTXSettings.CompressorGain);
  FController.FDSPEngine.SetTXLeveler(FController.FTXSettings.LevelerOn, FController.FTXSettings.LevelerTop,
                           FController.FTXSettings.LevelerDecay);
  FController.FDSPEngine.SetTXALC(FController.FTXSettings.ALCOn, FController.FTXSettings.ALCMaxGain,
                       FController.FTXSettings.ALCDecay);
  FController.FDSPEngine.SetTXPhaseRot(FController.FTXSettings.PhaseRotOn, FController.FTXSettings.PhaseRotStages,
                            FController.FTXSettings.PhaseRotFreq);
  FController.FDSPEngine.SetTXEQ(FController.FTXSettings.EQOn, FController.FTXSettings.EQNumBands,
                      FController.FTXSettings.EQGains, FController.FTXSettings.EQFreqs);
  FController.FDSPEngine.SetTXAMCarrierLevel(FController.FTXSettings.AMCarrierLevel);
  FController.FDSPEngine.SetTXFMParams(FController.FTXSettings.FMDeviation, FController.FTXSettings.FMLowCut,
                            FController.FTXSettings.FMHighCut, FController.FTXSettings.FMEmphPosition);
  FController.FDSPEngine.SetTXCTCSS(FController.FTXSettings.CTCSSOn, FController.FTXSettings.CTCSSFreq);
  // TX display
  FController.FDSPEngine.SetTXFFTParams(FController.FTXSettings.TXFFTSize, FController.FTXSettings.TXWindowType);
  FController.FDSPEngine.SetTXSpectrumDisplay(FController.FTXSettings.TXSpecDetector,
                                   FController.FTXSettings.TXSpecAvgMode,
                                   FController.FTXSettings.TXSpecAvgTimeMS);
  FController.FDSPEngine.SetTXWaterfallDisplay(FController.FTXSettings.TXWfDetector,
                                    FController.FTXSettings.TXWfAvgMode,
                                    FController.FTXSettings.TXWfAvgTimeMS);
end;

function TMainForm.PullSoundCardMic(MaxN: Integer): Integer;
// Колбэк, который TTXDSPThread зовёт при TXMicSource=txmsSoundCard.
// Тянем только реально доступные сэмплы из FController.FAudioIn — иначе мы пушили бы
// нули и mic-цепь в WDSP получала бы тишину вместо реального сигнала.
// Читаем пачкой в локальный буфер и пушим одним вызовом — экономим
// будилки семафора и предотвращаем подмену TX-блока тишиной.
var
  i, n: Integer;
  Buf:  array[0..2047] of Double;
begin
  Result := 0;
  if (FController.FAudioIn = nil) or (not FController.FAudioIn.IsOpen) then Exit;
  n := FController.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] := FController.FAudioIn.ReadSample;
  if n > 0 then
    FController.FDSPEngine.PushTXMicSamplesD(Buf, n);
  Result := n;
end;

// ===========================================================================
// Button handlers
// ===========================================================================

{ Поток открытия — отдельный класс, без анонимных процедур }
// Открываем WDSP в фоновом потоке чтобы не блокировать UI при нажатии START
type
  TWDSPOpenThread = class(TThread)
  private
    FForm:   TMainForm;
    FResult: Boolean;
    procedure SyncDone;
  protected
    procedure Execute; override;
  public
    constructor Create(AForm: TMainForm);
  end;

constructor TWDSPOpenThread.Create(AForm: TMainForm);
begin
  inherited Create(False);
  FForm            := AForm;
  FResult          := False;
  FreeOnTerminate  := True;
end;

procedure TWDSPOpenThread.Execute;
var
  M: TThreadMethod;
begin
  try
    FResult := FForm.FController.FDSPEngine.Open;
  except
    FResult := False;
  end;
  M := SyncDone;
  TThread.Synchronize(nil, M);
end;

procedure TWDSPOpenThread.SyncDone;
begin
  FForm.OnWDSPOpenDone(FResult);
end;

type
  TDiscoverThread = class(TThread)
  private
    FNet:  THPSDRNetwork;
    FForm: TMainForm;
  protected
    procedure Execute; override;
  public
    constructor Create(ANet: THPSDRNetwork; AForm: TMainForm);
  end;

constructor TDiscoverThread.Create(ANet: THPSDRNetwork; AForm: TMainForm);
begin
  FNet  := ANet;
  FForm := AForm;
  FreeOnTerminate := True;
  inherited Create(False);
end;

procedure TDiscoverThread.Execute;
var
  Devs: THPSDRDeviceArray;
  Sync: TDDCSeqSync;
  M: TThreadMethod;
begin
  Devs := FNet.Discover(2000);
  if Length(Devs) = 0 then
  begin
    // DDCIdx = -1 is the sentinel for "no device found"
    Sync := TDDCSeqSync.Create(FForm.DoUpdateDDCSeqOrNoDevice, -1, 0);
    try
      M := Sync.Execute;
      TThread.Synchronize(nil, M);
    finally
      Sync.Free;
    end;
  end;
  // Devices found are signalled via OnDeviceFound callback during Discover()
end;

{ Перегруженный DoUpdateDDCSeq принимает -1 как «нет устройств» }
procedure TMainForm.DoUpdateDDCSeqOrNoDevice(DDCIdx: Integer; Seq: LongWord);
begin
  if DDCIdx = -1 then
  begin
    if Assigned(FDeviceDialog) then
    begin
      FDeviceDialog.ClearDiscovered;
      FDeviceDialog.AddDiscovered('', '-- no device found --');
    end;
    SetStatusText(2, 'No hardware found');
  end
  else
    SetStatusText(4, 'RX running');
end;

procedure TMainForm.RecreateDSPEngine(ASampleRate: Integer);
begin
  if ASampleRate <= 0 then Exit;
  if Assigned(FController.FDSPEngine) then
  begin
    FController.FDSPEngine.Close;
    FreeAndNil(FController.FDSPEngine);
  end;
  FController.FWDSPReady := False;
  FController.FDSPEngine := TWDSPEngine.Create(ASampleRate, 48000, 512);
  FController.FDSPEngine.OnAudio     := OnAudioReady;
  FController.FDSPEngine.OnSpectrum  := OnSpectrumReady;
  FController.FDSPEngine.OnWaterfall := OnWaterfallReady;
  FController.FDSPEngine.OnTXIQ      := OnTXIQReady;
end;

function TMainForm.EnsureWDSPWisdom: Boolean;
var
  WisdomDir:  string;
  WisdomPath: string;
  DirA:       AnsiString;
  BuildThread: TWisdomBuildThread;
  Dlg:         TWisdomProgressDialog;
begin
  Result := True;

  if not Assigned(@WDSPwisdom) then Exit;

  WisdomDir  := GetAppCfgDir;
  WisdomPath := WisdomDir + WDSP_WISDOM_FILE;

  if FileExists(WisdomPath) then
  begin
    // По рекомендации WDSP вызываем функцию на каждом старте:
    // если файл уже есть, библиотека просто импортирует wisdom.
    DirA := AnsiString(WisdomDir);
    WDSPwisdom(PAnsiChar(DirA));
    Exit;
  end;

  BtnStartStop.Enabled := False;
  SetStatusText(2, 'Creating WDSP wisdom...');

  BuildThread := TWisdomBuildThread.Create(WisdomDir);
  try
    if FLightTheme then
      Dlg := TWisdomProgressDialog.Create(Self, BuildThread, LightTheme)
    else
      Dlg := TWisdomProgressDialog.Create(Self, BuildThread, DarkTheme);
    try
      Dlg.ShowModal;
    finally
      Dlg.Free;
    end;

    BuildThread.WaitFor;

    if BuildThread.ErrorText <> '' then
    begin
      ShowMessage('Failed to create WDSP wisdom.' + LineEnding +
        BuildThread.ErrorText);
      Result := False;
    end
    else if not FileExists(WisdomPath) then
    begin
      ShowMessage('WDSP wisdom was not created.' + LineEnding +
        'Expected file: ' + WisdomPath);
      Result := False;
    end;
  finally
    BuildThread.Free;
    BtnStartStop.Enabled := True;
  end;

  if Result then
    SetStatusText(2, 'DSP ready')
  else
    SetStatusText(2, 'WDSP wisdom missing');
end;

procedure TMainForm.BtnDiscoverClick(Sender: TObject);
begin
  // Открываем диалог выбора устройства
  if not Assigned(FDeviceDialog) then
    FDeviceDialog := TDeviceDialog.Create(Self);
  FDeviceDialog.OnDiscover := BtnDiscoverFromDialog;
  if FLightTheme then FDeviceDialog.SetTheme(LightTheme)
  else                FDeviceDialog.SetTheme(DarkTheme);
  FDeviceDialog.ClearResult;  // сбрасываем старый выбор перед открытием
  FDeviceDialog.ShowModal;
  // Если пользователь нажал CONNECT — результат хранится в DialogResult,
  // START его заберёт и использует с приоритетом над autostart
end;

procedure TMainForm.BtnDiscoverFromDialog(Sender: TObject);
// Запускается когда пользователь нажимает DISCOVER внутри диалога
begin
  SetLength(FDevices, 0);
  FDeviceCount := 0;
  SetStatusText(2, 'Discovering...');
  FController.FNetwork.DirectIP := '';
  TDiscoverThread.Create(FController.FNetwork, Self);
end;

procedure TMainForm.BtnStartStopClick(Sender: TObject);
var
  Idx, i: Integer;
  Dev: THPSDRDevice;
  GenPkt: TGeneralPacket;
  G_Settings: TGlobalSettings;
  PreG: TGlobalSettings;
  PreBands: array[0..CFG_BAND_COUNT-1] of TBandSettings;
  PreloadRate: Integer;
  FoundByIP: Boolean;
  AutoIP: string;
begin
  if FController.FNetwork.Connected then
  begin
    // --- STOP ---
    if FController.FRunning then
    begin
      FController.FNetwork.UpdateState(XvtrTranslate(FController.FCenterFreq), XvtrTranslate(ActiveTXFreqHz), 0, False, True, True);
      FController.FNetwork.SetRunAndFreq(False, XvtrTranslate(FController.FCenterFreq), XvtrTranslate(FController.FCenterFreq), 0);
      FController.FRunning := False;
      UninhibitSleep;
      FController.FTransmitting := False;
      if FController.FWDSPReady then FController.FDSPEngine.SetTXRun(False);
      StyleButton(BtnMOX, False);
    end;
    FRXPacketCount := 0;
    FActiveDDC     := 0;
    // Сохраняем текущую частоту/настройки чтобы следующий START восстановил их
    if FController.FDevConnected then
    begin
      SaveCurrentBand;
      FController.FSettings.SaveGlobal(FController.FDevMAC, MakeGlobalSettings);
      FController.FSettings.SaveAlex(FController.FDevMAC, FController.FAlexSettings);
      // XVTR LastFreq → актуальная видимая частота в момент остановки
      if (FController.FCurrentXvtr >= 0) and (FController.FCurrentXvtr < CFG_XVTR_COUNT) then
        FController.FXvtrSettings.Entries[FController.FCurrentXvtr].LastFreq := FController.FVfoA;
      FController.FSettings.SaveXvtr(FController.FDevMAC, FController.FXvtrSettings);
    end;
    FController.FSettings.SaveStartupPreview(FController.FVfoA, FController.FVfoB, FController.FSampleRate);
    FController.FSettings.Save;
    FSpecView.ResetSpectrumBuf;
    FSpecView.DrawSpectrum;
    PbSpectrum.Invalidate;
    FController.FNetwork.Disconnect;
    BtnStartStop.Caption     := 'START';
    StyleButton(BtnStartStop, False);
    SetRadioOfflineStatus('Disconnected', True);
    BtnMOX.Enabled := False;
    Exit;
  end;

  // --- START ---
  AutoIP := '';
  FPendingBoardType := 0;

  // Явный выбор через CONNECT в диалоге имеет приоритет над autostart
  if Assigned(FDeviceDialog) and FDeviceDialog.DialogResult.Accepted then
  begin
    FPendingIP        := FDeviceDialog.DialogResult.IPAddress;
    FPendingBoardType := FDeviceDialog.GetSavedBoardType(
                           FDeviceDialog.DialogResult.SavedIdx);
    FDeviceDialog.ClearResult;  // consumed — следующий START снова спросит
  end
  else
  begin
    if Assigned(FDeviceDialog) then
      AutoIP := FDeviceDialog.GetAutoStartIP;

    if AutoIP <> '' then
    begin
      FPendingIP        := AutoIP;
      FPendingBoardType := FDeviceDialog.GetAutoStartBoardType;
    end
    else
    begin
      if not Assigned(FDeviceDialog) then
        FDeviceDialog := TDeviceDialog.Create(Self);
      FDeviceDialog.OnDiscover := BtnDiscoverFromDialog;
      if FLightTheme then FDeviceDialog.SetTheme(LightTheme)
      else                FDeviceDialog.SetTheme(DarkTheme);
      if FDeviceDialog.ShowModal <> mrOk then Exit;
      if not FDeviceDialog.DialogResult.Accepted then Exit;
      FPendingIP := FDeviceDialog.DialogResult.IPAddress;
      FPendingBoardType := FDeviceDialog.GetSavedBoardType(
                             FDeviceDialog.DialogResult.SavedIdx);
      FDeviceDialog.ClearResult;
    end;
  end;

  // Создаём запись устройства из IP + BoardType из диалога если есть
  FillChar(Dev, SizeOf(Dev), 0);
  Dev.IPAddress := FPendingIP;
  Dev.BoardType := FPendingBoardType;

  // Подбираем sample rate до открытия WDSP, чтобы не делать двойной Open:
  // 1) Open на 192k, 2) сразу ChangeSampleRate+Open ещё раз.
  // Если устройство уже было обнаружено (есть MAC) и есть сохранённый профиль,
  // создаём WDSPEngine сразу с нужной частотой.
  PreloadRate := FController.FSampleRate;
  FoundByIP := False;
  for i := 0 to High(FDevices) do
    if SameText(FDevices[i].Dev.IPAddress, Dev.IPAddress) then
    begin
      FoundByIP := True;
      if FController.FSettings.LoadDevice(FDevices[i].Dev.MAC, PreG, PreBands) and (PreG.SampleRate > 0) then
        PreloadRate := PreG.SampleRate;
      Break;
    end;
  if FoundByIP and (PreloadRate > 0) then
  begin
    FController.FSampleRate := PreloadRate;
    FController.FSpanHz := PreloadRate;
    if (not FController.FWDSPReady) and (Assigned(FController.FDSPEngine)) and (FController.FDSPEngine.SampleRate <> PreloadRate) then
      RecreateDSPEngine(PreloadRate);
  end;

  // --- Открываем WDSP асинхронно чтобы не блокировать UI ---
  FPendingDev := Dev;
  BtnStartStop.Enabled := False;
  SetStatusText(2, 'Opening DSP...');
  if FController.FWDSPReady then
    // WDSP уже открыт — сразу подключаемся
    DoConnectDevice(Dev)
  else
    // Открываем в фоновом потоке
    TWDSPOpenThread.Create(Self);
end;

procedure TMainForm.OnWDSPOpenDone(Success: Boolean);
begin
  // Вызывается из TWDSPOpenThread.SyncDone — уже в главном потоке
  FController.FWDSPReady := Success;
  if not Success then
  begin
    SetStatusText(2, 'DSP failed');
    BtnStartStop.Enabled := True;
    BtnStartStop.Caption := 'START';
    StyleButton(BtnStartStop, False);
    ShowMessage('WDSP not loaded.' + LineEnding +
      IfThen(FController.FDSPEngine.LastError <> '', FController.FDSPEngine.LastError, 'Unknown WDSP init error.') + LineEnding +
      'Put wdsp.dll рядом с ewsdr.exe (той же разрядности, что и приложение).');
    Exit;
  end;
  DoConnectDevice(FPendingDev);
end;

procedure TMainForm.DoConnectDevice(const Dev: THPSDRDevice);
var
  GenPkt:   TGeneralPacket;
  G_Settings: TGlobalSettings;
  i:        Integer;
begin
  BtnStartStop.Enabled := True;

  if not FController.FNetwork.Connect(Dev) then
  begin
    ShowMessage('Failed to connect to ' + Dev.IPAddress +
      IfThen(FController.FNetwork.LastError <> '', ': ' + FController.FNetwork.LastError, ''));
    Exit;
  end;

  // --- Загружаем настройки устройства по MAC ---
  Move(FController.FNetwork.Device.MAC[0], FController.FDevMAC[0], 6);
  FController.FDevConnected := True;
  FController.FSettings.LoadDevice(FController.FDevMAC, G_Settings, FController.FBandCache);
  FCATLastGlobal := G_Settings;
  CATApplySettings(G_Settings);
  // TX-настройки per-device — отдельная JSON-секция "tx"
  FController.FSettings.LoadTX(FController.FDevMAC, FController.FTXSettings);
  // Alex-настройки per-device (антенны/маршрутизация)
  FController.FSettings.LoadAlex(FController.FDevMAC, FController.FAlexSettings);
  FController.FNetwork.SetAlexConfig(FController.FAlexSettings);
  // XVTR-настройки per-device (трансвертеры)
  FController.FSettings.LoadXvtr(FController.FDevMAC, FController.FXvtrSettings);
  FController.FCurrentXvtr := -1;
  RebuildXvtrButtons;
  ApplyXvtrToNetwork;
  PushXvtrToWeb;
  FTXSpecRefLevel := FController.FTXSettings.TXSpecRefLevel;
  FTXSpecRange    := FController.FTXSettings.TXSpecRange;
  if FController.FTXSettings.TXSpecGridStep > 0 then
    FTXSpecGridStep := FController.FTXSettings.TXSpecGridStep;
  FController.FVolume      := G_Settings.Volume;
  FController.FActiveVfo   := G_Settings.ActiveVfo;
  // PA settings
  FController.FPAMaxPower  := G_Settings.PAMaxPower;
  for i := 0 to BAND_COUNT - 1 do
    FController.FPABandCal[i] := G_Settings.PABandCal[i];
  for i := 0 to CFG_XVTR_COUNT - 1 do
    FController.FVHFBandCal[i] := G_Settings.VHFBandCal[i];
  // Slider position (0..100) and calibrated drive byte
  TrkDrive.Position := EnsureRange(G_Settings.DriveLevel, 0, 100);
  FController.FCurrentBand := G_Settings.LastBand;
  FController.FDriveLevel  := CalcDriveByte;
  // SampleRate — глобальный, загружаем до RestoreBand
  if G_Settings.SampleRate > 0 then
  begin
    FController.FSampleRate := G_Settings.SampleRate;
    FController.FSpanHz     := FController.FSampleRate;
    if Assigned(FSampleRateOverlay) then
      FSampleRateOverlay.SetCurrentRate(FController.FSampleRate);
  end;
  FController.FNRMode  := EnsureRange(G_Settings.NRMode, 0, 4); UpdateNRButton;
  FController.FNBMode  := EnsureRange(G_Settings.NBMode, 0, 2); UpdateNBButton;
  FController.FSNB := G_Settings.SNBEnabled; UpdateSNBButton;
  FController.FANF := G_Settings.ANFEnabled; UpdateANFButton;
  FController.FWfAGCEnabled  := G_Settings.WfAGCEnabled;
  FController.FWfNFEnabled   := G_Settings.WfNFEnabled;
  FController.FDitherEnabled := G_Settings.DitherEnabled;
  FController.FRandomEnabled := G_Settings.RandomEnabled;
  FController.FSendAudioToRadio := G_Settings.SendAudioToRadio;
  FWfManualHigh := G_Settings.WfManualHigh;
  FWfManualLow  := G_Settings.WfManualLow;
  FWfAGCOffset  := G_Settings.WfAGCOffset;
  FSpecView.WfAGCEnabled := FController.FWfAGCEnabled;
  FSpecView.WfNFEnabled  := FController.FWfNFEnabled;
  FSpecView.WfManualHigh := FWfManualHigh;
  FSpecView.WfManualLow  := FWfManualLow;
  FSpecView.WfAGCOffset  := FWfAGCOffset;
  FSpecView.ResetWfAvgBuf;
  FShowSpectrum  := G_Settings.ShowSpectrum;
  FShowWaterfall := G_Settings.ShowWaterfall;
  FShowWideband  := G_Settings.ShowWideband;
  FWidebandFill  := G_Settings.WidebandFill;
  if Assigned(FWidebandView) then
    FWidebandView.SetFillSpectrum(FWidebandFill);
  FSpectrumFill  := G_Settings.SpectrumFill;
  FSpecView.FillSpectrum := FSpectrumFill;
  FController.FDisplayDuplex := G_Settings.DisplayDuplex;
  if BtnDUP <> nil then StyleButton(BtnDUP, FController.FDisplayDuplex);
  if G_Settings.DisplayFPS > 0 then
    ApplyFPS(G_Settings.DisplayFPS);
  ApplyFreqMhzDigits(EnsureRange(G_Settings.FreqMhzDigits, 3, 5));
  // Настройки дисплея и сетки
  if G_Settings.FFTSize > 0 then
  begin
    FSpecRefLevel := G_Settings.SpecRefLevel;
    FSpecRange    := G_Settings.SpecRange;
    if G_Settings.SpecGridStep > 0 then FSpecGridStep := G_Settings.SpecGridStep;
    FSpecView.SpecRefLevel := FSpecRefLevel;
    FSpecView.SpecRange    := FSpecRange;
    FSpecView.SpecGridStep := FSpecGridStep;
    InvalidateGridCache;
    if FController.FWDSPReady then
    begin
      FController.FDSPEngine.SetFFTParams(G_Settings.FFTSize, G_Settings.WindowType);
      FController.FDSPEngine.SetSpectrumDisplay(G_Settings.SpecDetector, G_Settings.SpecAvgMode,
        G_Settings.SpecAvgTimeMS);
      FController.FDSPEngine.SetWaterfallDisplay(G_Settings.WfDetector, G_Settings.WfAvgMode,
        G_Settings.WfAvgTimeMS);
    end;
    // Аудио устройство вывода
    FController.FAudioOut.Close;
    if G_Settings.AudioSampleRate > 0 then
      FController.FAudioOut.SampleRate := G_Settings.AudioSampleRate;
    if G_Settings.AudioOutDevice <> '' then
    begin
      FController.FAudioOut.DeviceIndex := FController.FAudioOut.FindDeviceByName(G_Settings.AudioOutDevice);
      FController.FAudioOutDevName := G_Settings.AudioOutDevice;
    end
    else
    begin
      FController.FAudioOut.DeviceIndex := -1;
      FController.FAudioOutDevName := '';
    end;
    FController.FAudioOut.Open;
    // Аудио устройство ввода (TX mic). MicSource (Radio/SoundCard) определяется
    // автоматически в ApplyMOX по источнику PTT — здесь только открываем устройство.
    if G_Settings.AudioInDevice <> '' then
    begin
      FController.FAudioIn.DeviceIndex := FController.FAudioIn.FindDeviceByName(G_Settings.AudioInDevice);
      FController.FAudioInDevName := G_Settings.AudioInDevice;
      FController.FAudioIn.Open;
    end
    else
    begin
      FController.FAudioIn.DeviceIndex := -1;
      FController.FAudioInDevName := '';
    end;
  end;
  RestoreBand(FController.FCurrentBand);

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

  SetStatusText(1, 'IP: ' + FController.FNetwork.Device.IPAddress);
  SetStatusText(0, 'Board: ' + BoardTypeName(FController.FNetwork.Device.BoardType));

  // 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 FController.FNetwork.Device.BoardType = 5 then
    GenPkt.AlexEnable := $03
  else
    GenPkt.AlexEnable := $01;
  FController.FNetwork.SendGeneralPacket(GenPkt);
  FController.FNetwork.ConfigureWideband(0, FShowWideband, 512, 16, 70, 32);

  if FController.FNetwork.Device.BoardType in [3, 4, 5] then
    FActiveDDC := 2
  else
    FActiveDDC := 0;

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

  if FController.FWDSPReady then
  begin
    FController.FDSPEngine.SetMode(FController.FMode);
    FController.FDSPEngine.SetVolume(FController.FVolume / 100.0);
    ApplyModeFilter;
    ApplyNoiseFilterButtonsToDSP;
    // ChangeSampleRate пересоздаёт WDSP-канал — восстанавливаем все настройки
    FController.FDSPEngine.SetAGCTop(FController.FAGCTop);
    FController.FDSPEngine.SetAGC(TWDSPAGCMode(FController.FAGCMode), 50.0);
  end;
  FSpecView.AGCTop := FController.FAGCTop;

  // Сначала отправляем Run=1 — эмулятор/железо создают ddc_specific_thread
  // (порт 1025) и rx_thread (порт 1035+) только после получения HP Run=1.
  // DDC Specific и DUC Specific отправляем ПОСЛЕ, иначе порты ещё не открыты.
  FController.FNetwork.UpdateState(XvtrTranslate(FController.FCenterFreq), XvtrTranslate(ActiveTXFreqHz), FController.FDriveLevel, False, True, True);
  FController.FNetwork.SetRunAndFreq(True, XvtrTranslate(FController.FCenterFreq), XvtrTranslate(FController.FCenterFreq), FController.FDriveLevel);
  FController.FRunning       := True;
  InhibitSleep;
  FRXStartTime   := GetTickCount64;
  FRXLastPktTime := 0;
  FRXPacketCount := 0;
  FillChar(FDDCSeqValid, SizeOf(FDDCSeqValid), 0);
  FSeqErrorCount     := 0;
  FLastSeqErrorDDC   := -1;
  FLastSeqErrorDelta := 0;
  FSeqOkStreak       := 0;
  SetStatusText(6, 'SEQ OK');

  // DDC и DUC Specific — теперь потоки эмулятора слушают на своих портах.
  // DUC Specific содержит mic-конфигурацию (boost/bias/line/PTT) из FController.FTXSettings.
  FController.FNetwork.ConfigureDDCs(1, FController.FSampleRate div 1000, 0, FController.FDitherEnabled, FController.FRandomEnabled);
  // Применяем сохранённое состояние динамика/отправки аудио (мьют byte 1400 bit1).
  FController.FNetwork.SetSpeakerAudio(FController.FSendAudioToRadio);
  SendDUCSpecificFromSettings;
  // Применяем TX-цепь (фильтр, mic gain, EQ, leveler, ALC, comp, ...).
  ApplyTXSettingsToDSP;
  ApplyModeFilter;

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

  BtnStartStop.Caption  := 'STOP';
  StyleButton(BtnStartStop, True);
  SetStatusText(2, 'Running');
  BtnMOX.Enabled := True;
  ResizeSpectrumPanels;
end;


// ---------------------------------------------------------------------------
// ActivateVfo — переключаем активный VFO и перестраиваем приёмник.
// Idx=0 → VFO-A активен, Idx=1 → VFO-B активен.
// Перестройка всегда происходит на частоту активного VFO (не на оффсет).
// CTUN при этом сбрасывается чтобы не было путаницы с оффсетом.

// ---------------------------------------------------------------------------
// ActiveVfoFreq — частота активного VFO
// ---------------------------------------------------------------------------
function TMainForm.ActiveVfoFreq: Int64;
begin
  if FController.FActiveVfo = 0 then Result := Round(FController.FVfoA)
  else                    Result := Round(FController.FVfoB);
end;

// ---------------------------------------------------------------------------
// ActivateVfo — переключаем активный VFO и перестраиваем приёмник.
// Idx=0 → VFO-A активен, Idx=1 → VFO-B активен.
// Перестройка на частоту выбранного VFO. CTUN не трогаем.
// ---------------------------------------------------------------------------
procedure TMainForm.ActivateVfo(Idx: Integer);
var
  ActiveFreq: Int64;
  NewBand:    Integer;
  Offset:     Double;
  HalfSpan:   Double;
  FreqVisible: Boolean;
begin
  FController.FActiveVfo := Idx;

  // Частота нового активного VFO
  if FController.FActiveVfo = 0 then
    ActiveFreq := Round(FController.FVfoA)
  else
    ActiveFreq := Round(FController.FVfoB);

  // Обновляем визуальный дисплей
  UpdateVfoDisplay;

  if FController.FCTun then
  begin
    // CTUN включён: проверяем, видна ли новая частота на текущем спектре
    HalfSpan   := FController.FSpanHz / 2;
    Offset     := ActiveFreq - FController.FCenterFreq;
    FreqVisible := (Offset > -HalfSpan) and (Offset < HalfSpan);

    if FreqVisible then
    begin
      // Частота видна в окне — просто меняем shift, DDC не трогаем
      if FController.FWDSPReady then FController.FDSPEngine.SetShift(Offset);
      // Сеть не трогаем — DDC (FController.FCenterFreq) остаётся прежним
    end
    else
    begin
      // Частота за пределами спектра — центрируем окно и сбрасываем CTUN
      FController.FCTun := False;
      StyleButton(BtnCTun, False);
      FController.FBandCache[FController.FCurrentBand].CTun := False;
      FController.FCenterFreq := ActiveFreq;
      if FController.FWDSPReady then FController.FDSPEngine.SetShift(0.0);
      if FController.FNetwork.Connected and FController.FNetwork.Running then
      begin
        FController.FNetwork.UpdateState(XvtrTranslate(FController.FCenterFreq), XvtrTranslate(ActiveTXFreqHz), FController.FDriveLevel, FController.FTransmitting, True, True);
        FController.FNetwork.SendFullHP;
      end;
    end;
  end
  else
  begin
    // CTUN выключен: перестраиваем DDC на новую частоту
    FController.FCenterFreq := ActiveFreq;
    if FController.FWDSPReady then FController.FDSPEngine.SetShift(0.0);
    if FController.FNetwork.Connected and FController.FNetwork.Running then
    begin
      FController.FNetwork.UpdateState(XvtrTranslate(FController.FCenterFreq), XvtrTranslate(ActiveTXFreqHz), FController.FDriveLevel, FController.FTransmitting, True, True);
      FController.FNetwork.SendFullHP;
    end;
  end;

  // Переключаем кнопку диапазона по новой частоте
  NewBand := FreqToBandIdx(ActiveFreq);
  if (NewBand >= 0) and (NewBand <> FController.FCurrentBand) then
  begin
    StyleButton(BtnBand[FController.FCurrentBand], False);
    FController.FCurrentBand := NewBand;
    StyleButton(BtnBand[FController.FCurrentBand], True);
  end;

  FSpecView.InvalidateRulerCache;
  SyncSpecViewFreq;
  FSpecView.DrawSpectrum;
  PbSpectrum.Invalidate;
  FSpecView.DrawWaterfall;
  PbWaterfall.Invalidate;
  if PbRuler <> nil then PbRuler.Invalidate;
end;

procedure TMainForm.FreqDispAClick(Sender: TObject);
begin
  // Клик на VFO-A → активируем A (перестройка на FController.FVfoA)
  if FController.FActiveVfo <> 0 then
    ActivateVfo(0);
end;

procedure TMainForm.FreqDispBClick(Sender: TObject);
begin
  // Клик на VFO-B → активируем B (перестройка на FController.FVfoB)
  if FController.FActiveVfo <> 1 then
    ActivateVfo(1);
end;

procedure TMainForm.BtnTopVfoASelectClick(Sender: TObject);
begin
  if FController.FActiveVfo <> 0 then
    ActivateVfo(0);
end;

procedure TMainForm.BtnTopVfoATXClick(Sender: TObject);
begin
  FController.FSplitTxB := False;
  UpdateVfoDisplay;

  if FController.FNetwork.Connected and FController.FNetwork.Running then
  begin
    FController.FNetwork.UpdateState(XvtrTranslate(FController.FCenterFreq), XvtrTranslate(ActiveTXFreqHz), FController.FDriveLevel,
                         FController.FTransmitting, True, True);
    FController.FNetwork.SendFullHP;
  end;
end;

procedure TMainForm.BtnTopVfoBSelectClick(Sender: TObject);
begin
  if FController.FActiveVfo <> 1 then
    ActivateVfo(1);
end;

procedure TMainForm.BtnTopVfoBTXClick(Sender: TObject);
begin
  FController.FSplitTxB := True;
  if FController.FSplitTxB and (FController.FActiveVfo <> 0) then
    ActivateVfo(0)
  else
    UpdateVfoDisplay;

  if FController.FNetwork.Connected and FController.FNetwork.Running then
  begin
    FController.FNetwork.UpdateState(XvtrTranslate(FController.FCenterFreq), XvtrTranslate(ActiveTXFreqHz), FController.FDriveLevel,
                         FController.FTransmitting, True, True);
    FController.FNetwork.SendFullHP;
  end;
end;

procedure TMainForm.FreqDispAChanged(Sender: TObject; NewFreq: Int64);
begin
  if FController.FActiveVfo = 0 then
    ApplyVfoA(NewFreq)
  else
    FController.FVfoA := NewFreq;  // просто сохраняем, приёмник не перестраиваем
end;

procedure TMainForm.FreqDispBChanged(Sender: TObject; NewFreq: Int64);
var
  BandIdx: Integer;
begin
  FController.FVfoB := NewFreq;
  if FController.FActiveVfo = 1 then
  begin
    // VFO-B активен: перестраиваем приёмник
    FController.FCenterFreq := FController.FVfoB;
    if FController.FWDSPReady then FController.FDSPEngine.SetShift(0.0);
    if FController.FNetwork.Connected and FController.FNetwork.Running then
    begin
      FController.FNetwork.UpdateState(XvtrTranslate(FController.FCenterFreq), XvtrTranslate(ActiveTXFreqHz), FController.FDriveLevel, FController.FTransmitting, True, True);
      FController.FNetwork.SendFullHP;
    end;
    // Обновляем диапазон
    BandIdx := FreqToBandIdx(FController.FVfoB);
    if (BandIdx >= 0) and (BandIdx <> FController.FCurrentBand) then
    begin
      StyleButton(BtnBand[FController.FCurrentBand], False);
      FController.FCurrentBand := BandIdx;
      StyleButton(BtnBand[FController.FCurrentBand], True);
      FController.FDriveLevel := CalcDriveByte;
    end;
    FSpecView.InvalidateRulerCache;
    SyncSpecViewFreq;
    FSpecView.DrawSpectrum;
    PbSpectrum.Invalidate;
    if PbRuler <> nil then PbRuler.Invalidate;
  end;
end;

// ---------------------------------------------------------------------------
// UpdateFilterButtons — обновляет подписи кнопок фильтра под текущий режим
// ---------------------------------------------------------------------------
procedure TMainForm.UpdateFilterButtons;
var
  i:    Integer;
  Names: array[0..FILT_COUNT-1] of string;
  DefIdx: Integer;
begin
  if FController.FMode = MODE_FM then
  begin
    // FM: show only NFM and WFM buttons, resize them to fill the row
    CloseCTCSSPopup;
    if FController.FFilter > 1 then FController.FFilter := FILT_FM_DEF;   // sanitize, keep NFM/WFM as-is
    FController.FFilterBW    := FILT_FM_BW[FController.FFilter];
    FController.FFMDeviation := FILT_FM_DEV[FController.FFilter];
    BtnFilter[0].Caption := FILT_FM_NAMES[0];
    BtnFilter[0].Left    := LeftPanelButtonLeft(PanelFilter.Width, 2, 0);
    BtnFilter[0].Width   := LeftPanelButtonWidth(PanelFilter.Width, 2, 0);
    BtnFilter[0].Top     := 16;
    BtnFilter[0].Visible := True;
    StyleButton(BtnFilter[0], FController.FFilter = 0);
    BtnFilter[1].Caption := FILT_FM_NAMES[1];
    BtnFilter[1].Left    := LeftPanelButtonLeft(PanelFilter.Width, 2, 1);
    BtnFilter[1].Width   := LeftPanelButtonWidth(PanelFilter.Width, 2, 1);
    BtnFilter[1].Top     := 16;
    BtnFilter[1].Visible := True;
    StyleButton(BtnFilter[1], FController.FFilter = 1);
    for i := 2 to FILT_COUNT - 1 do BtnFilter[i].Visible := False;
    // Shrink panel to fit label + 1 button row so SQL/CTCSS sit right below
    PanelFilter.Height := BtnFilter[0].Top + BtnFilter[0].Height + 4;
    if PanelFMSQ <> nil then
    begin
      PanelFMSQ.Visible := True;
      StyleButton(BtnFMSQ, FController.FFMSQOn);
      TrkFMSQ.Position := FController.FFMSQLevel;
      LblFMSQ.Caption  := IntToStr(FController.FFMSQLevel);
    end;
    if PanelFMCTCSS <> nil then PanelFMCTCSS.Visible := True;
    if PanelFMStep <> nil then
    begin
      StyleButton(BtnFMStep, FController.FFMStepOn);
      if FStepDropDown <> nil then FStepDropDown.SetItemIndex(FController.FFMStepIdx);
      PanelFMStep.Visible := True;
    end;
    if PanelFMRpt <> nil then
    begin
      StyleButton(BtnFMRptMinus, FController.FFMRptDir = RPT_MINUS);
      StyleButton(BtnFMRptPlus,  FController.FFMRptDir = RPT_PLUS);
      EdFMRptOffset.Text := RptFormatOffset(FController.FFMRptOffsetHz);
      PanelFMRpt.Visible := True;
    end;
    RelayoutBelowBands;
    Exit;
  end;

  // Non-FM: restore full 2-row height and 5-column layout, hide FM panels
  PanelFilter.Height := 74;
  if PanelFMSQ <> nil then PanelFMSQ.Visible := False;
  if PanelFMCTCSS <> nil then PanelFMCTCSS.Visible := False;
  if PanelFMStep <> nil then begin CloseFMStepPopup; PanelFMStep.Visible := False; end;
  if PanelFMRpt <> nil then PanelFMRpt.Visible := False;
  for i := 0 to FILT_COUNT - 1 do
  begin
    BtnFilter[i].Left    := LeftPanelButtonLeft(PanelFilter.Width, 5, i mod 5);
    BtnFilter[i].Top     := 16 + (i div 5) * 27;
    BtnFilter[i].Width   := LeftPanelButtonWidth(PanelFilter.Width, 5, i mod 5);
    BtnFilter[i].Visible := True;
  end;

  case FController.FMode of
    0, 1: begin
            for i := 0 to FILT_COUNT-1 do Names[i] := FILT_SSB_NAMES[i];
            DefIdx := FILT_SSB_DEF;
          end;
    2:    begin
            for i := 0 to FILT_COUNT-1 do Names[i] := FILT_DSB_NAMES[i];
            DefIdx := FILT_DSB_DEF;
          end;
    3, 4: begin
            for i := 0 to FILT_COUNT-1 do Names[i] := FILT_CW_NAMES[i];
            DefIdx := FILT_CW_DEF;
          end;
  else    begin
            for i := 0 to FILT_COUNT-1 do Names[i] := FILT_AM_NAMES[i];
            DefIdx := FILT_AM_DEF;
          end;
  end;

  FController.FFilter := DefIdx;
  case FController.FMode of
    0, 1:    FController.FFilterBW := FILT_SSB_BW[DefIdx];
    2:       FController.FFilterBW := FILT_DSB_BW[DefIdx];
    3, 4:    FController.FFilterBW := FILT_CW_BW[DefIdx];
  else       FController.FFilterBW := FILT_AM_BW[DefIdx];
  end;

  for i := 0 to FILT_COUNT-1 do
  begin
    BtnFilter[i].Caption := Names[i];
    StyleButton(BtnFilter[i], i = FController.FFilter);
  end;
  RelayoutBelowBands;
end;

// ---------------------------------------------------------------------------
// ApplyVfoA — единая точка смены частоты VFO A
//
// CTUN ВЫКЛ (классический режим):
//   FController.FCenterFreq = FController.FVfoA — спектр центрирован на VFO, маркер всегда в центре
//
// CTUN ВКЛ (piHPSDR/Thetis режим):
//   FController.FCenterFreq статичен — спектр/водопад не двигаются
//   FController.FVfoA движется по дисплею (маркер гуляет)
//   Перецентрирование — только когда КРАЙ ПОЛОСЫ ФИЛЬТРА выходит за край дисплея
//   (по образцу OpenHPSDR/PowerSDR: "re-centering occurs as the edge of the passband
//    hits the edge of the display")
// ---------------------------------------------------------------------------
procedure TMainForm.ApplyVfoA(NewFreq: Int64);
var
  Offset:   Double;
  FiltLo:   Double;
  FiltHi:   Double;
  HalfSpan: Double;
  Scrolled: Boolean;
  BandIdx:  Integer;
begin
  // Клэмп в пределах XVTR-band — не даём VFO уйти за FreqBegin/FreqEnd
  if (FController.FCurrentXvtr >= 0) and (FController.FCurrentXvtr < CFG_XVTR_COUNT) and
     FController.FXvtrSettings.Entries[FController.FCurrentXvtr].Enabled then
  begin
    if NewFreq < FController.FXvtrSettings.Entries[FController.FCurrentXvtr].FreqBegin then
      NewFreq := Round(FController.FXvtrSettings.Entries[FController.FCurrentXvtr].FreqBegin);
    if NewFreq > FController.FXvtrSettings.Entries[FController.FCurrentXvtr].FreqEnd then
      NewFreq := Round(FController.FXvtrSettings.Entries[FController.FCurrentXvtr].FreqEnd);
  end;
  FController.FVfoA    := NewFreq;
  Scrolled := False;

  if not FController.FCTun then
  begin
    // ---- CTUN OFF ----
    // DDC = VFO, сдвига нет
    FController.FCenterFreq := FController.FVfoA;
    if FController.FWDSPReady then
      FController.FDSPEngine.SetShift(0.0);
  end
  else
  begin
    // ---- CTUN ON ----
    // DDC (FController.FCenterFreq) стоит на месте.
    // SetRXAShiftFreq сдвигает спектр внутри WDSP так чтобы
    // демодулятор принимал сигнал на FController.FVfoA, а не на FController.FCenterFreq.
    // Shift = FController.FVfoA - FController.FCenterFreq  (в Гц)
    Offset := FController.FVfoA - FController.FCenterFreq;
    if FController.FWDSPReady then
      FController.FDSPEngine.SetShift(Offset);

    // Проверяем не вышла ли полоса фильтра за край дисплея
    HalfSpan := FController.FSpanHz / 2;
    case FController.FMode of
      0: begin FiltLo := Offset - FController.FFilterBW; FiltHi := Offset - 100; end;
      1: begin FiltLo := Offset + 100;       FiltHi := Offset + FController.FFilterBW; end;
    else begin FiltLo := Offset - FController.FFilterBW/2; FiltHi := Offset + FController.FFilterBW/2; end;
    end;

    if (FiltHi > HalfSpan) or (FiltLo < -HalfSpan) then
    begin
      // Полоса вышла за край → прокручиваем центр
      if FiltHi > HalfSpan then
        FController.FCenterFreq := FController.FVfoA - HalfSpan * 0.5
      else
        FController.FCenterFreq := FController.FVfoA + HalfSpan * 0.5;
      // Пересчитываем сдвиг после прокрутки
      if FController.FWDSPReady then
        FController.FDSPEngine.SetShift(FController.FVfoA - FController.FCenterFreq);
      // DDC перестраивается на новый FController.FCenterFreq
      Scrolled := True;
    end;
  end;

  // Обновляем VFO дисплей
  FreqDispA.Frequency := Round(FController.FVfoA);

  // Обновляем кнопку диапазона если частота перешла в другой диапазон.
  // В XVTR-режиме HF band-кнопки не подсвечиваем (мы клэмпим VFO внутри
  // XVTR-диапазона — FreqToBandIdx всегда вернёт -1 для VHF/UHF).
  if FController.FCurrentXvtr < 0 then
  begin
    BandIdx := FreqToBandIdx(FController.FVfoA);
    if (BandIdx >= 0) and (BandIdx <> FController.FCurrentBand) then
    begin
      StyleButton(BtnBand[FController.FCurrentBand], False);
      FController.FCurrentBand := BandIdx;
      StyleButton(BtnBand[FController.FCurrentBand], True);
      FController.FDriveLevel := CalcDriveByte;
    end;
  end;

  // Если нет активного канала, но CTCSS/RPT были установлены каналом (авто-флаги),
  // сбрасываем их при первом же изменении частоты
  if FActiveChannelIdx < 0 then
  begin
    if FController.FFMCTCSSAutoActive then
    begin
      FController.FFMCTCSSAutoActive := False;
      FController.FFMCTCSSOn := False;
      if BtnFMCTCSS <> nil then StyleButton(BtnFMCTCSS, False);
      if FController.FWDSPReady then FController.FDSPEngine.SetTXCTCSS(False, CTCSS_TONES[FController.FFMCTCSSToneIdx]);
    end;
    if FController.FFMRptAutoActive then
    begin
      FController.FFMRptDir        := RPT_NONE;
      FController.FFMRptAutoActive := False;
      if BtnFMRptMinus <> nil then StyleButton(BtnFMRptMinus, False);
      if BtnFMRptPlus  <> nil then StyleButton(BtnFMRptPlus,  False);
    end;
    if FPreChannelPower >= 0 then
    begin
      TrkDrive.Position := FPreChannelPower;
      FPreChannelPower  := -1;
      FController.FDriveLevel := CalcDriveByte;
      if FController.FWDSPReady then
        FController.FDSPEngine.SetDriveLevel(FController.FDrivePercent / 100.0);
    end;
  end;

  // Auto-enable repeater minus for 2 m repeater input segment
  if FController.FMode = MODE_FM then UpdateRptAutoState;

  // DDC: передаём FController.FCenterFreq (при CTUN OFF = FController.FVfoA, при CTUN ON = фиксирован)
  if FController.FNetwork.Connected and FController.FNetwork.Running then
  begin
    FController.FNetwork.UpdateState(XvtrTranslate(FController.FCenterFreq), XvtrTranslate(ActiveTXFreqHz), FController.FDriveLevel, FController.FTransmitting, True, True);
    FController.FNetwork.SendFullHP;
  end;

  // Не рисуем синхронно из wheel/drag path: частые события мыши иначе
  // забивают UI-поток и мешают аудио. Таймер подхватит ближайший кадр.
  SyncSpecViewFreq;
  FSpectrumDirty := True;
  PbSpectrum.Invalidate;
  if Scrolled then
  begin
    FWaterfallDirty := True;
    PbWaterfall.Invalidate;
  end;

  CheckChannelActive;
end;

// ---------------------------------------------------------------------------
// CTUN toggle
// ---------------------------------------------------------------------------
procedure TMainForm.BtnCTunClick(Sender: TObject);
begin
  FController.FCTun := not FController.FCTun;
  StyleButton(BtnCTun, FController.FCTun);
  FController.FBandCache[FController.FCurrentBand].CTun := FController.FCTun;
  if not FController.FCTun then
  begin
    // Выключили CTUN: центрируемся на VFO, shift=0
    FController.FCenterFreq := FController.FVfoA;
    if FController.FWDSPReady then
      FController.FDSPEngine.SetShift(0.0);
    if FController.FNetwork.Connected and FController.FNetwork.Running then
    begin
      FController.FNetwork.UpdateState(XvtrTranslate(FController.FCenterFreq), XvtrTranslate(ActiveTXFreqHz), FController.FDriveLevel, FController.FTransmitting, True, True);
      FController.FNetwork.SendFullHP;
    end;
  end
  else
  begin
    // Включили CTUN: фиксируем текущее положение дисплея
    // FController.FCenterFreq остаётся как есть — спектр не прыгает
  end;
  SyncSpecViewFreq;
  FSpecView.DrawSpectrum;
  FSpecView.DrawWaterfall;
  PbSpectrum.Invalidate;
  PbWaterfall.Invalidate;
end;

// ---------------------------------------------------------------------------
// DUP toggle (Thetis-style local display mode — без аппаратной команды)
// ---------------------------------------------------------------------------
procedure TMainForm.ApplyDUP(Active: Boolean);
begin
  FController.FDisplayDuplex := Active;
  StyleButton(BtnDUP, FController.FDisplayDuplex);
  // Если сейчас идёт передача — переключаем источник пикселей и сбрасываем
  // буферы, чтобы старые TX/RX bin'ы не оставались на следующем кадре.
  if FController.FTransmitting then
  begin
    if FController.FWDSPReady then
      FController.FDSPEngine.SetDisplaySourceTX(not FController.FDisplayDuplex);
    FSpecView.TXMode := FController.FTransmitting and not FController.FDisplayDuplex;
    FSpecView.ResetSpectrumBuf;
    FSpecView.ResetWfAvgBuf;
    ApplySpecViewGridFromState;
    FSpecView.DrawSpectrum;
    FSpecView.DrawWaterfall;
    PbSpectrum.Invalidate;
    PbWaterfall.Invalidate;
  end;
end;

procedure TMainForm.BtnDUPClick(Sender: TObject);
begin
  ApplyDUP(not FController.FDisplayDuplex);
end;

// ---------------------------------------------------------------------------
// TUN — Thetis-style тон: WDSP TXA PostGen + MOX с отдельным уровнем Drive
// ---------------------------------------------------------------------------
procedure TMainForm.ApplyTUN(Active: Boolean);
begin
  if Active = FController.FTuning then Exit;
  // Safety: те же блокировки что в ApplyMOX. TUN — это TX через WDSP PostGen,
  // он тоже должен молчать на бэндах с DoNotTx или на RX-only XVTR.
  if Active and FController.FDevConnected and (FController.FCurrentXvtr < 0)
     and FController.FAlexSettings.DoNotTx[EnsureRange(FController.FCurrentBand, 0, 10)] then
    Exit;
  if Active and (FController.FCurrentXvtr >= 0) and (FController.FCurrentXvtr < CFG_XVTR_COUNT)
     and FController.FXvtrSettings.Entries[FController.FCurrentXvtr].RXOnly then
    Exit;
  if Active then
  begin
    // Включаем тон ДО запуска TX-аудио, чтобы первые сэмплы уже шли с тоном
    if FController.FWDSPReady then
      FController.FDSPEngine.SetTXTone(True, FController.FTXSettings.TUNFreq, 1.0);
    FController.FTuning := True;
    StyleButton(BtnTUN, True);
    // Drive байт пересчитываем (CalcDriveByte увидит FController.FTuning=True)
    FController.FDriveLevel := CalcDriveByte;
    if FController.FWDSPReady then
      FController.FDSPEngine.SetDriveLevel(FController.FTXSettings.TUNLevel / 100.0);
    // Активируем TX (то же что MOX — PTT, run TXA-канал)
    if not FController.FTransmitting then ApplyMOX(True);
  end else
  begin
    // Выключаем TX, потом тон, потом восстанавливаем drive
    if FController.FTransmitting then ApplyMOX(False);
    if FController.FWDSPReady then
      FController.FDSPEngine.SetTXTone(False, 0, 0);
    FController.FTuning := False;
    StyleButton(BtnTUN, False);
    FController.FDriveLevel := CalcDriveByte;
    if FController.FWDSPReady then
      FController.FDSPEngine.SetDriveLevel(FController.FDrivePercent / 100.0);
    if FController.FNetwork.Connected and FController.FNetwork.Running then
    begin
      FController.FNetwork.UpdateState(XvtrTranslate(FController.FCenterFreq), XvtrTranslate(ActiveTXFreqHz), FController.FDriveLevel,
                           FController.FTransmitting, True, True);
      FController.FNetwork.SendFullHP;
    end;
  end;
end;

procedure TMainForm.BtnTUNClick(Sender: TObject);
begin
  ApplyTUN(not FController.FTuning);
end;

// ---------------------------------------------------------------------------
// Клик/драг по спектру/водопаду
// ---------------------------------------------------------------------------

// Перевод X пикселя в частоту
function PixelToFreq(PixelX, PanelWidth: Integer;
                     CenterFreq, SpanHz: Double): Double;
begin
  Result := CenterFreq + (PixelX / PanelWidth - 0.5) * SpanHz;
end;

procedure TMainForm.DoSpectrumClick(PixelX: Integer; PanelWidth: Integer);
var
  ClickFreq: Int64;
  StepHz:    Int64;
  BandIdx:   Integer;
begin
  if PanelWidth <= 0 then Exit;
  if (FController.FMode = MODE_FM) and FController.FFMStepOn then
    StepHz := FM_STEP_HZ[FController.FFMStepIdx]
  else
    StepHz := 100;
  ClickFreq := Round(FController.FCenterFreq + (PixelX / PanelWidth - 0.5) * FController.FSpanHz);
  ClickFreq := ((ClickFreq + StepHz div 2) div StepHz) * StepHz;
  if FController.FActiveVfo = 0 then
    ApplyVfoA(ClickFreq)
  else
  begin
    FController.FVfoB := ClickFreq;
    FreqDispB.Frequency := Round(FController.FVfoB);
    FController.FCenterFreq := ClickFreq;
    if FController.FWDSPReady then FController.FDSPEngine.SetShift(0.0);
    if FController.FNetwork.Connected and FController.FNetwork.Running then
    begin
      FController.FNetwork.UpdateState(XvtrTranslate(FController.FCenterFreq), XvtrTranslate(ActiveTXFreqHz), FController.FDriveLevel, FController.FTransmitting, True, True);
      FController.FNetwork.SendFullHP;
    end;
    BandIdx := FreqToBandIdx(FController.FVfoB);
    if (BandIdx >= 0) and (BandIdx <> FController.FCurrentBand) then
    begin
      StyleButton(BtnBand[FController.FCurrentBand], False);
      FController.FCurrentBand := BandIdx;
      StyleButton(BtnBand[FController.FCurrentBand], True);
    end;
    FSpecView.InvalidateRulerCache;
    SyncSpecViewFreq;
    FSpecView.DrawSpectrum;
    PbSpectrum.Invalidate;
    if PbRuler <> nil then PbRuler.Invalidate;
  end;
end;

procedure TMainForm.DoWidebandClick(PixelX: Integer; PanelWidth: Integer);
var
  FreqHz: Double;
  ClickFreq: Int64;
  StepHz: Int64;
  BandIdx: Integer;
  WasCTun: Boolean;
begin
  if (FWidebandView = nil) or
     (not FWidebandView.TryPixelToFrequency(PixelX, PanelWidth, FreqHz)) then Exit;

  if (FController.FMode = MODE_FM) and FController.FFMStepOn then
    StepHz := FM_STEP_HZ[FController.FFMStepIdx]
  else
    StepHz := 100;
  ClickFreq := Round(FreqHz);
  ClickFreq := ((ClickFreq + StepHz div 2) div StepHz) * StepHz;

  if (FController.FCurrentXvtr >= 0) and (FController.FCurrentXvtr < CFG_XVTR_COUNT) and
     FController.FXvtrSettings.Entries[FController.FCurrentXvtr].Enabled then
  begin
    if ClickFreq < FController.FXvtrSettings.Entries[FController.FCurrentXvtr].FreqBegin then
      ClickFreq := Round(FController.FXvtrSettings.Entries[FController.FCurrentXvtr].FreqBegin);
    if ClickFreq > FController.FXvtrSettings.Entries[FController.FCurrentXvtr].FreqEnd then
      ClickFreq := Round(FController.FXvtrSettings.Entries[FController.FCurrentXvtr].FreqEnd);
  end;

  if FController.FCurrentXvtr < 0 then
  begin
    BandIdx := FreqToBandIdx(ClickFreq);
    if (BandIdx >= 0) and (BandIdx <> FController.FCurrentBand) then
    begin
      if FController.FDevConnected then
      begin
        SaveCurrentBand;
        FController.FSettings.Save;
      end;
      FController.FCurrentBand := BandIdx;
      RestoreBand(FController.FCurrentBand);
    end;
  end;

  if FController.FActiveVfo = 0 then
  begin
    WasCTun := FController.FCTun;
    FController.FCTun := False;
    ApplyVfoA(ClickFreq);
    FController.FCTun := WasCTun;
    if WasCTun and FController.FWDSPReady then
      FController.FDSPEngine.SetShift(0.0);
  end
  else
  begin
    FController.FVfoB := ClickFreq;
    FreqDispB.Frequency := Round(FController.FVfoB);
    FController.FCenterFreq := ClickFreq;
    if FController.FWDSPReady then FController.FDSPEngine.SetShift(0.0);
    if FController.FNetwork.Connected and FController.FNetwork.Running then
    begin
      FController.FNetwork.UpdateState(XvtrTranslate(FController.FCenterFreq),
        XvtrTranslate(ActiveTXFreqHz), FController.FDriveLevel, FController.FTransmitting, True, True);
      FController.FNetwork.SendFullHP;
    end;
    if FController.FCurrentXvtr < 0 then
    begin
      BandIdx := FreqToBandIdx(FController.FVfoB);
      if (BandIdx >= 0) and (BandIdx <> FController.FCurrentBand) then
      begin
        if FController.FCurrentBand >= 0 then
          StyleButton(BtnBand[FController.FCurrentBand], False);
        FController.FCurrentBand := BandIdx;
        StyleButton(BtnBand[FController.FCurrentBand], True);
      end;
    end;
    UpdateVfoDisplay;
    SyncSpecViewFreq;
    FSpectrumDirty := True;
    PbSpectrum.Invalidate;
  end;

  FSpecView.InvalidateRulerCache;
  if PbRuler <> nil then PbRuler.Invalidate;
  FWaterfallDirty := True;
  if PbWaterfall <> nil then PbWaterfall.Invalidate;
  if UpdateWidebandFrequencyView and (PbWideband <> nil) then
    PbWideband.Invalidate;
end;

procedure TMainForm.DoSpectrumDrag(PixelX: Integer; PanelWidth: Integer);
var
  dPix:  Integer;
  dFreq: Double;
begin
  if PanelWidth <= 0 then Exit;
  dPix  := PixelX - FSpecDragX0;
  dFreq := dPix / PanelWidth * FController.FSpanHz;
  if FController.FCTun then
  begin
    // CTUN ON: drag двигает окно просмотра (FController.FCenterFreq/DDC)
    // VFO остаётся, shift обновляется = VFO - новый FController.FCenterFreq
    FController.FCenterFreq := FSpecDragFreq - dFreq;
    if FController.FWDSPReady then
    begin
      if FController.FActiveVfo = 0 then
        FController.FDSPEngine.SetShift(FController.FVfoA - FController.FCenterFreq)
      else
        FController.FDSPEngine.SetShift(FController.FVfoB - FController.FCenterFreq);
    end;
    if FController.FNetwork.Connected and FController.FNetwork.Running then
    begin
      FController.FNetwork.UpdateState(XvtrTranslate(FController.FCenterFreq), XvtrTranslate(ActiveTXFreqHz), FController.FDriveLevel, FController.FTransmitting, True, True);
      FController.FNetwork.SendFullHP;
    end;
    FSpectrumDirty := True;
  end
  else
  begin
    // CTUN OFF: drag двигает VFO, FController.FCenterFreq следует через Apply
    if FController.FActiveVfo = 0 then
      ApplyVfoA(Round(FSpecDragFreq - dFreq))
    else
    begin
      FController.FVfoB := FSpecDragFreq - dFreq;
      FreqDispB.Frequency := Round(FController.FVfoB);
      FController.FCenterFreq := FController.FVfoB;
      if FController.FWDSPReady then FController.FDSPEngine.SetShift(0.0);
      if FController.FNetwork.Connected and FController.FNetwork.Running then
      begin
        FController.FNetwork.UpdateState(XvtrTranslate(FController.FCenterFreq), XvtrTranslate(ActiveTXFreqHz), FController.FDriveLevel, FController.FTransmitting, True, True);
        FController.FNetwork.SendFullHP;
      end;
      FSpectrumDirty := True;
    end;
  end;
end;

// Спектр
procedure TMainForm.PbSpectrumMouseDown(Sender: TObject; Button: TMouseButton;
  Shift: TShiftState; X, Y: Integer);
begin
  if Assigned(FVfoOverlay) and
     FVfoOverlay.HandleMouseDown(Button, X, Y) then Exit;
  if Assigned(FSampleRateOverlay) and
     FSampleRateOverlay.HandleMouseDown(Button, X, Y) then Exit;

  if Button = mbLeft then
  begin
    // Левая кнопка: сбрасываем маркер если активен
    if FSpecView.MarkerActive then
    begin
      FSpecView.MarkerActive := False;
      PbSpectrum.Invalidate;
      PbWaterfall.Invalidate;
    end;
    FSpecDrag   := True;
    FSpecDragX0 := X;
    if FController.FCTun then FSpecDragFreq := FController.FCenterFreq
    else begin
      if FController.FActiveVfo = 0 then FSpecDragFreq := FController.FVfoA
      else                   FSpecDragFreq := FController.FVfoB;
    end;
    PbSpectrum.Cursor := crSizeWE;
  end
  else if Button = mbRight then
  begin
    // Правая кнопка: toggle маркера
    if FSpecView.MarkerActive and (Abs(Round(X / PbSpectrum.Width * 1000) - FSpecView.MarkerX) < 8) then
    begin
      // Клик рядом с текущим маркером — убираем
      FSpecView.MarkerActive := False;
    end
    else
    begin
      // Ставим маркер в новое место
      FSpecView.MarkerActive := True;
      FSpecView.MarkerX      := Round(X / PbSpectrum.Width * 1000);
    end;
    PbSpectrum.Invalidate;
    PbWaterfall.Invalidate;
  end;
end;

procedure TMainForm.PbWidebandMouseDown(Sender: TObject; Button: TMouseButton;
  Shift: TShiftState; X, Y: Integer);
begin
  if Button = mbLeft then
    DoWidebandClick(X, TControl(Sender).Width);
end;

procedure TMainForm.PbSpectrumMouseMove(Sender: TObject; Shift: TShiftState;
  X, Y: Integer);
begin
  if Assigned(FSampleRateOverlay) then
  begin
    FSampleRateOverlay.HandleMouseMove(X, Y);
  end;
  if Assigned(FVfoOverlay) then
    FVfoOverlay.HandleMouseMove(X, Y);
  if not FSpecDrag then
  begin
    if (Assigned(FVfoOverlay) and (FVfoOverlay.HotIdx >= 0)) or
       (Assigned(FSampleRateOverlay) and (FSampleRateOverlay.HotIdx >= 0)) then
      PbSpectrum.Cursor := crHandPoint
    else
      PbSpectrum.Cursor := crDefault;
  end;

  if FSpecDrag and (ssLeft in Shift) then
    DoSpectrumDrag(X, PbSpectrum.Width);
  // Маркер следует за курсором — обновление произойдёт в ближайшем тике таймера (50ms)
  if FSpecView.MarkerActive then
  begin
    FSpecView.MarkerX := Round(X / PbSpectrum.Width * 1000);
    FSpectrumDirty := True;
  end;
end;

procedure TMainForm.PbSpectrumMouseUp(Sender: TObject; Button: TMouseButton;
  Shift: TShiftState; X, Y: Integer);
begin
  if Button = mbLeft then
  begin
    if (Abs(X - FSpecDragX0) < 4) then
      DoSpectrumClick(X, PbSpectrum.Width);  // это был клик, не драг
    FSpecDrag := False;
    PbSpectrum.Cursor := crDefault;
  end;
end;

procedure TMainForm.PbSpectrumMouseLeave(Sender: TObject);
begin
  if Assigned(FSampleRateOverlay) then
    FSampleRateOverlay.HandleMouseLeave;
  if Assigned(FVfoOverlay) then
    FVfoOverlay.HandleMouseLeave;
  if not FSpecDrag then PbSpectrum.Cursor := crDefault;
end;

// Водопад (аналогично)
procedure TMainForm.PbWaterfallMouseDown(Sender: TObject; Button: TMouseButton;
  Shift: TShiftState; X, Y: Integer);
begin
  if Button = mbLeft then
  begin
    if FSpecView.MarkerActive then
    begin
      FSpecView.MarkerActive := False;
      PbSpectrum.Invalidate;
      PbWaterfall.Invalidate;
    end;
    FSpecDrag   := True;
    FSpecDragX0 := X;
    if FController.FCTun then FSpecDragFreq := FController.FCenterFreq
    else begin
      if FController.FActiveVfo = 0 then FSpecDragFreq := FController.FVfoA
      else                   FSpecDragFreq := FController.FVfoB;
    end;
    PbWaterfall.Cursor := crSizeWE;
  end
  else if Button = mbRight then
  begin
    if FSpecView.MarkerActive and (Abs(Round(X / PbWaterfall.Width * 1000) - FSpecView.MarkerX) < 8) then
      FSpecView.MarkerActive := False
    else
    begin
      FSpecView.MarkerActive := True;
      FSpecView.MarkerX      := Round(X / PbWaterfall.Width * 1000);
    end;
    PbSpectrum.Invalidate;
    PbWaterfall.Invalidate;
  end;
end;

procedure TMainForm.PbWaterfallMouseMove(Sender: TObject; Shift: TShiftState;
  X, Y: Integer);
begin
  if FSpecDrag and (ssLeft in Shift) then
    DoSpectrumDrag(X, PbWaterfall.Width);
  if FSpecView.MarkerActive then
  begin
    FSpecView.MarkerX := Round(X / PbWaterfall.Width * 1000);
    FSpectrumDirty := True;
  end;
end;

procedure TMainForm.PbWaterfallMouseUp(Sender: TObject; Button: TMouseButton;
  Shift: TShiftState; X, Y: Integer);
begin
  if Button = mbLeft then
  begin
    if (Abs(X - FSpecDragX0) < 4) then
      DoSpectrumClick(X, PbWaterfall.Width);
    FSpecDrag := False;
    PbWaterfall.Cursor := crDefault;
  end;
end;

procedure TMainForm.BtnBandClick(Sender: TObject);
var
  NewBand: Integer;
begin
  NewBand := (Sender as TFlatButton).Tag;
  if (NewBand < 0) or (NewBand >= BAND_COUNT) then Exit;
  // Если в XVTR-режиме — DeactivateXvtr вернёт HF-состояние (RestoreBand
  // на сохранённом FController.FCurrentBand). Если NewBand совпадает с восстановленным
  // FController.FCurrentBand, дальше делать ничего не нужно.
  if FController.FCurrentXvtr >= 0 then
  begin
    DeactivateXvtr;
    if NewBand = FController.FCurrentBand then Exit;
  end
  else if NewBand = FController.FCurrentBand then
    Exit;

  if FController.FDevConnected then
  begin
    SaveCurrentBand;
    FController.FSettings.Save;
  end;
  FController.FCurrentBand := NewBand;
  RestoreBand(FController.FCurrentBand);
end;

procedure TMainForm.ApplyModeFilter;
var
  Lo, Hi, Half: Integer;
begin
  Half := FController.FFilterBW div 2;
  case FController.FMode of
    0: begin Lo := -FController.FFilterBW; Hi := -100; end;  // LSB
    1: begin Lo :=  100; Hi := FController.FFilterBW; end;   // USB
    2: begin Lo := -Half; Hi := Half; end;        // DSB
    3: begin Lo := -Half; Hi := Half; end;        // CWL
    4: begin Lo := -Half; Hi := Half; end;        // CWU
    5: begin Lo := -Half; Hi := Half; end;        // FM
    6: begin Lo := -Half; Hi := Half; end;        // AM
    7: begin Lo := -Half; Hi := Half; end;        // SAM
  else  Lo := -Half; Hi := Half;
  end;
  if FController.FWDSPReady then
  begin
    FController.FDSPEngine.SetFilter(Lo, Hi);
    FController.FDSPEngine.SetTXFilter(Lo, Hi);
    if FController.FMode = MODE_FM then
    begin
      FController.FDSPEngine.SetRXFMDeviation(FController.FFMDeviation);
      FController.FDSPEngine.SetTXFMParams(FController.FFMDeviation, FController.FTXSettings.FMLowCut,
                               FController.FTXSettings.FMHighCut, FController.FTXSettings.FMEmphPosition);
      FController.FDSPEngine.SetFMSquelch(FController.FFMSQOn, FController.FFMSQLevel);
    end
    else
      FController.FDSPEngine.SetFMSquelch(False, 0);
  end;
end;

procedure TMainForm.BtnModeClick(Sender: TObject);
var
  i, N: Integer;
begin
  N := (Sender as TFlatButton).Tag;
  FController.FMode := N;
  for i := 0 to MODE_COUNT - 1 do StyleButton(BtnMode[i], i = FController.FMode);
  if FController.FWDSPReady then
  begin
    FController.FDSPEngine.SetMode(N);
    // Если TUN активен — пересчитываем знак частоты под новый режим
    if FController.FTuning then
      FController.FDSPEngine.SetTXTone(True, FController.FTXSettings.TUNFreq, 1.0);
  end;
  UpdateFilterButtons;   // обновляем подписи и дефолт фильтра под новый режим
  ApplyModeFilter;
  SyncSpecViewFreq;      // FSpecView.Mode/FilterBW — иначе полоса рисуется под старый режим
end;

procedure TMainForm.BtnFilterClick(Sender: TObject);
var
  i, N: Integer;
begin
  N := (Sender as TFlatButton).Tag;
  FController.FFilter := N;
  case FController.FMode of
    0, 1:  FController.FFilterBW := FILT_SSB_BW[N];
    2:     FController.FFilterBW := FILT_DSB_BW[N];
    3, 4:  FController.FFilterBW := FILT_CW_BW[N];
    5:     begin
             FController.FFilterBW    := FILT_FM_BW[N];
             FController.FFMDeviation := FILT_FM_DEV[N];
           end;
  else     FController.FFilterBW := FILT_AM_BW[N];
  end;
  for i := 0 to FILT_COUNT - 1 do StyleButton(BtnFilter[i], i = FController.FFilter);
  ApplyModeFilter;
  SyncSpecViewFreq;
end;

// ---------------------------------------------------------------------------
// FM CTCSS helpers
// ---------------------------------------------------------------------------

procedure TMainForm.ApplyFMSquelch;
begin
  if FController.FWDSPReady then
    FController.FDSPEngine.SetFMSquelch(FController.FFMSQOn and (FController.FMode = MODE_FM), FController.FFMSQLevel);
end;

procedure TMainForm.BtnFMSQClick(Sender: TObject);
begin
  FController.FFMSQOn := not FController.FFMSQOn;
  StyleButton(BtnFMSQ, FController.FFMSQOn);
  BtnFMSQ.Repaint;
  ApplyFMSquelch;
  SaveCurrentBand;
end;

procedure TMainForm.TrkFMSQChange(Sender: TObject);
begin
  FController.FFMSQLevel := TrkFMSQ.Position;
  if LblFMSQ <> nil then LblFMSQ.Caption := IntToStr(FController.FFMSQLevel);
  ApplyFMSquelch;
  SaveCurrentBand;
end;

procedure TMainForm.CloseCTCSSPopup;
begin
  if FCTCSSDropDown <> nil then FCTCSSDropDown.ClosePopup;
  CloseFMStepPopup;
  if FChannelsDropDown <> nil then FChannelsDropDown.ClosePopup;
end;

procedure TMainForm.BtnFMRptMinusClick(Sender: TObject);
begin
  if FController.FFMRptDir = RPT_MINUS then
    FController.FFMRptDir := RPT_NONE
  else
    FController.FFMRptDir := RPT_MINUS;
  FController.FFMRptAutoActive := False;  // ручное действие снимает авто-флаг
  StyleButton(BtnFMRptMinus, FController.FFMRptDir = RPT_MINUS);
  StyleButton(BtnFMRptPlus,  FController.FFMRptDir = RPT_PLUS);
  BtnFMRptMinus.Repaint;
  BtnFMRptPlus.Repaint;
  ApplyFMRpt;
  SaveCurrentBand;
end;

procedure TMainForm.BtnFMRptPlusClick(Sender: TObject);
begin
  if FController.FFMRptDir = RPT_PLUS then
    FController.FFMRptDir := RPT_NONE
  else
    FController.FFMRptDir := RPT_PLUS;
  FController.FFMRptAutoActive := False;  // ручное действие снимает авто-флаг
  StyleButton(BtnFMRptMinus, FController.FFMRptDir = RPT_MINUS);
  StyleButton(BtnFMRptPlus,  FController.FFMRptDir = RPT_PLUS);
  BtnFMRptMinus.Repaint;
  BtnFMRptPlus.Repaint;
  ApplyFMRpt;
  SaveCurrentBand;
end;

procedure TMainForm.EdFMRptOffsetChange(Sender: TObject);
var Hz: Double;
begin
  Hz := RptParseOffset(EdFMRptOffset.Text);
  if Hz > 0.0 then
  begin
    FController.FFMRptOffsetHz := Hz;
    if FController.FFMRptDir <> RPT_NONE then
    begin
      ApplyFMRpt;
      SaveCurrentBand;
    end;
  end;
end;

procedure TMainForm.ApplyFMRpt;
begin
  if FController.FNetwork.Connected and FController.FNetwork.Running then
  begin
    FController.FNetwork.UpdateState(XvtrTranslate(FController.FCenterFreq), XvtrTranslate(ActiveTXFreqHz),
                         FController.FDriveLevel, FController.FTransmitting, True, True);
    FController.FNetwork.SendFullHP;
  end;
end;

procedure TMainForm.UpdateRptAutoState;
var InRange: Boolean;
begin
  InRange := RptAutoDir(FController.FVfoA) = RPT_MINUS;
  if InRange then
  begin
    // Auto-enable minus only when currently off.
    if FController.FFMRptDir = RPT_NONE then
    begin
      FController.FFMRptDir        := RPT_MINUS;
      FController.FFMRptAutoActive := True;
      if BtnFMRptMinus <> nil then
      begin
        StyleButton(BtnFMRptMinus, True);
        BtnFMRptMinus.Repaint;
      end;
    end;
  end
  else
  begin
    // Auto-disable only if this minus was set automatically.
    if (FController.FFMRptDir = RPT_MINUS) and FController.FFMRptAutoActive then
    begin
      FController.FFMRptDir        := RPT_NONE;
      FController.FFMRptAutoActive := False;
      if BtnFMRptMinus <> nil then
      begin
        StyleButton(BtnFMRptMinus, False);
        BtnFMRptMinus.Repaint;
      end;
    end;
  end;
end;

procedure TMainForm.CloseFMStepPopup;
begin
  if FStepDropDown     <> nil then FStepDropDown.ClosePopup;
  if FChannelsDropDown <> nil then FChannelsDropDown.ClosePopup;
end;

procedure TMainForm.SetFMStep(Idx: Integer);
begin
  FController.FFMStepIdx := Idx;
  if FStepDropDown <> nil then FStepDropDown.SetItemIndex(Idx);
  if FWebServer <> nil then FWebServer.FMStepIdx := Idx;
  if (FController.FMode = MODE_FM) and (FSpecView <> nil) then
    FSpecView.FMGridStepHz := FM_STEP_HZ[Idx];
end;

procedure TMainForm.BtnFMStepClick(Sender: TObject);
begin
  FController.FFMStepOn := not FController.FFMStepOn;
  StyleButton(BtnFMStep, FController.FFMStepOn);
  BtnFMStep.Repaint;
  SaveCurrentBand;
end;

procedure TMainForm.BtnFMStepSelClick(Sender: TObject);
begin
  FCTCSSDropDown.ClosePopup;
  FStepDropDown.Toggle;
end;

procedure TMainForm.OnStepDropDownSelect(Sender: TObject; Idx: Integer);
begin
  SetFMStep(Idx);
  SaveCurrentBand;
end;

// ---------------------------------------------------------------------------
// Channels dropdown / store
// ---------------------------------------------------------------------------

procedure TMainForm.RefreshChannelsDropDown;
var
  names: array of string;
  i:     Integer;
begin
  if (FController.FChannelStore = nil) or (FChannelsDropDown = nil) then Exit;
  SetLength(names, FController.FChannelStore.Count);
  for i := 0 to FController.FChannelStore.Count - 1 do
    names[i] := FController.FChannelStore.GetChannel(i).Name;
  FChannelsDropDown.SetItems(names);
  if (FActiveChannelIdx >= 0) and (FActiveChannelIdx < FController.FChannelStore.Count) then
    FChannelsDropDown.SetItemIndex(FActiveChannelIdx)
  else if BtnChannels <> nil then
    BtnChannels.Caption := 'Channel';
end;

procedure TMainForm.OnChannelStoreChanged(Sender: TObject);
begin
  RefreshChannelsDropDown;
end;

procedure TMainForm.BtnChannelsClick(Sender: TObject);
begin
  if FController.FChannelStore.Count = 0 then Exit;
  FCTCSSDropDown.ClosePopup;
  FStepDropDown.ClosePopup;
  FChannelsDropDown.Toggle;
end;

procedure TMainForm.BtnChannelsMouseDown(Sender: TObject; Button: TMouseButton;
  Shift: TShiftState; X, Y: Integer);
var
  Def: TChannel;
begin
  if Button <> mbRight then Exit;
  FChannelsDropDown.ClosePopup;
  if FChannelsForm = nil then
    FChannelsForm := TChannelsForm.Create(Self, FController.FChannelStore, OnChannelStoreChanged);
  // Передаём текущее состояние радио как значения по умолчанию для нового канала
  TChannelStore.DefaultChannel(Def);
  Def.RXFreq       := FController.FVfoA;
  Def.TXFreq       := ActiveTXFreqHz;
  Def.Mode         := FController.FMode;
  Def.RptDir       := FController.FFMRptDir;
  Def.RptOffsetHz  := FController.FFMRptOffsetHz;
  Def.CTCSSOn      := FController.FFMCTCSSOn;
  Def.CTCSSToneIdx := FController.FFMCTCSSToneIdx;
  Def.Power        := 100;
  Def.Name         := '';
  TChannelsForm(FChannelsForm).SetCurrentDefaults(Def);
  TChannelsForm(FChannelsForm).ApplyTheme(DarkTheme);
  TChannelsForm(FChannelsForm).Show;
end;

procedure TMainForm.OnChannelsDropDownSelect(Sender: TObject; Idx: Integer);
var Ch: TChannel;
begin
  if (FController.FChannelStore = nil) or (Idx < 0) or (Idx >= FController.FChannelStore.Count) then Exit;
  Ch := FController.FChannelStore.GetChannel(Idx);
  FActiveChannelIdx := Idx;
  StyleButton(BtnChannels, True);
  BtnChannels.Caption := Ch.Name;
  BtnChannels.Repaint;
  ApplyChannel(Ch);
end;

procedure TMainForm.ApplyChannel(const Ch: TChannel);
var
  i, NewBand, XvtrIdx: Integer;
begin
  // Сохраняем текущее состояние до любых изменений
  SaveCurrentBand;

  // Ищем подходящий XVTR-слот по частоте канала
  XvtrIdx := -1;
  for i := 0 to CFG_XVTR_COUNT - 1 do
    if FController.FXvtrSettings.Entries[i].Enabled and
       (Ch.RXFreq >= FController.FXvtrSettings.Entries[i].FreqBegin) and
       (Ch.RXFreq <= FController.FXvtrSettings.Entries[i].FreqEnd) then
    begin
      XvtrIdx := i;
      Break;
    end;

  NewBand := -1;

  if XvtrIdx >= 0 then
  begin
    // Канал на трансвертерном диапазоне — активируем XVTR если не активен
    if FController.FCurrentXvtr <> XvtrIdx then
      ActivateXvtrBand(XvtrIdx);
  end
  else
  begin
    // Канал на HF (или неизвестная частота)
    // Если были на XVTR — выходим из него, иначе ApplyVfoA заклэмпит частоту
    if FController.FCurrentXvtr >= 0 then
      DeactivateXvtr;
    NewBand := FreqToBandIdx(Ch.RXFreq);
    if (NewBand >= 0) and (NewBand <> FController.FCurrentBand) then
    begin
      StyleButton(BtnBand[FController.FCurrentBand], False);
      FController.FCurrentBand := NewBand;
      StyleButton(BtnBand[FController.FCurrentBand], True);
      FController.FDriveLevel := CalcDriveByte;
    end;
  end;

  // Режим (после ActivateXvtrBand/DeactivateXvtr FController.FMode обновился — сравниваем снова)
  if FController.FMode <> Ch.Mode then
  begin
    FController.FMode := Ch.Mode;
    for i := 0 to MODE_COUNT - 1 do StyleButton(BtnMode[i], i = FController.FMode);
    if FController.FWDSPReady then
    begin
      FController.FDSPEngine.SetMode(FController.FMode);
      if FController.FTuning then FController.FDSPEngine.SetTXTone(True, FController.FTXSettings.TUNFreq, 1.0);
    end;
    UpdateFilterButtons;
    ApplyModeFilter;
    SyncSpecViewFreq;
  end;

  // FM-параметры
  if FController.FMode = MODE_FM then
  begin
    FController.FFMCTCSSOn         := Ch.CTCSSOn;
    FController.FFMCTCSSAutoActive := Ch.CTCSSOn;  // CTCSS из канала — авто, сбросится при уходе с частоты
    SetFMCTCSSTone(Ch.CTCSSToneIdx);
    if BtnFMCTCSS <> nil then
    begin
      StyleButton(BtnFMCTCSS, FController.FFMCTCSSOn);
      BtnFMCTCSS.Repaint;
    end;
    if FController.FWDSPReady then
      FController.FDSPEngine.SetTXCTCSS(FController.FFMCTCSSOn, CTCSS_TONES[FController.FFMCTCSSToneIdx]);
    FController.FFMRptDir         := Ch.RptDir;
    FController.FFMRptOffsetHz    := Ch.RptOffsetHz;
    FController.FFMRptAutoActive  := Ch.RptDir <> RPT_NONE;  // RPT из канала — авто, сбросится при уходе
    if BtnFMRptMinus <> nil then StyleButton(BtnFMRptMinus, FController.FFMRptDir = RPT_MINUS);
    if BtnFMRptPlus  <> nil then StyleButton(BtnFMRptPlus,  FController.FFMRptDir = RPT_PLUS);
    if EdFMRptOffset <> nil then EdFMRptOffset.Text := RptFormatOffset(FController.FFMRptOffsetHz);
  end;

  // Мощность из канала.
  // TrkDrive.Position := ... синхронно вызывает TrkDriveChange → FPreChannelPower := -1,
  // поэтому сохраняем оригинал в локальную переменную и восстанавливаем после.
  if Ch.Power >= 0 then
  begin
    i := FController.FDrivePercent;                          // запомнить до срабатывания OnChange
    TrkDrive.Position := EnsureRange(Ch.Power, 0, 100); // TrkDriveChange обновит FController.FDriveLevel и WDSP
    if FPreChannelPower < 0 then
      FPreChannelPower := i;                         // установить после (TrkDriveChange уже сбросил в -1)
  end;

  // Частота VFO A
  ApplyVfoA(Round(Ch.RXFreq));

  // Сохраняем состояние
  if XvtrIdx >= 0 then
    SaveCurrentBand   // сохраняем в XVTR-слот (CTCSS/RPT/Mode из канала)
  else if NewBand >= 0 then
    SaveCurrentBand;  // сохраняем в HF band-кэш
end;

procedure TMainForm.CheckChannelActive;
var Ch: TChannel;
begin
  if FActiveChannelIdx < 0 then Exit;
  if (FController.FChannelStore = nil) or (FActiveChannelIdx >= FController.FChannelStore.Count) then
  begin
    FActiveChannelIdx := -1;
    Exit;
  end;
  Ch := FController.FChannelStore.GetChannel(FActiveChannelIdx);
  if Round(FController.FVfoA) <> Round(Ch.RXFreq) then
  begin
    FActiveChannelIdx := -1;
    StyleButton(BtnChannels, False);
    BtnChannels.Caption := 'Channel';
    BtnChannels.Repaint;

    // Сбрасываем CTCSS если он был включён каналом (не вручную)
    if FController.FFMCTCSSAutoActive then
    begin
      FController.FFMCTCSSAutoActive := False;
      FController.FFMCTCSSOn := False;
      if BtnFMCTCSS <> nil then
      begin
        StyleButton(BtnFMCTCSS, False);
        BtnFMCTCSS.Repaint;
      end;
      if FController.FWDSPReady then
        FController.FDSPEngine.SetTXCTCSS(False, CTCSS_TONES[FController.FFMCTCSSToneIdx]);
    end;

    // Сбрасываем RPT если он был включён каналом (не вручную), затем
    // даём UpdateRptAutoState переоценить авто-MINUS для текущей частоты
    if FController.FFMRptAutoActive then
    begin
      FController.FFMRptDir        := RPT_NONE;
      FController.FFMRptAutoActive := False;
      if BtnFMRptMinus <> nil then StyleButton(BtnFMRptMinus, False);
      if BtnFMRptPlus  <> nil then StyleButton(BtnFMRptPlus,  False);
      UpdateRptAutoState;
    end;

    // Восстанавливаем мощность которая была до применения канала
    if FPreChannelPower >= 0 then
    begin
      TrkDrive.Position := FPreChannelPower;
      FPreChannelPower  := -1;
      FController.FDriveLevel := CalcDriveByte;
      if FController.FWDSPReady then
        FController.FDSPEngine.SetDriveLevel(FController.FDrivePercent / 100.0);
    end;
  end;
end;

procedure TMainForm.SetFMCTCSSTone(Idx: Integer);
begin
  FController.FFMCTCSSToneIdx := Idx;
  if FCTCSSDropDown <> nil then FCTCSSDropDown.SetItemIndex(Idx);
  if FController.FWDSPReady then
    FController.FDSPEngine.SetTXCTCSS(FController.FFMCTCSSOn, CTCSS_TONES[Idx]);
end;

procedure TMainForm.BtnFMCTCSSClick(Sender: TObject);
begin
  FController.FFMCTCSSOn         := not FController.FFMCTCSSOn;
  FController.FFMCTCSSAutoActive := False;  // ручное действие снимает авто-флаг
  StyleButton(BtnFMCTCSS, FController.FFMCTCSSOn);
  BtnFMCTCSS.Repaint;  // Flush visual state before WDSP call
  if FController.FWDSPReady then
    FController.FDSPEngine.SetTXCTCSS(FController.FFMCTCSSOn, CTCSS_TONES[FController.FFMCTCSSToneIdx]);
end;

procedure TMainForm.BtnFMCTCSSToneClick(Sender: TObject);
begin
  FStepDropDown.ClosePopup;
  FCTCSSDropDown.Toggle;
end;

procedure TMainForm.OnCTCSSDropDownSelect(Sender: TObject; Idx: Integer);
begin
  SetFMCTCSSTone(Idx);
end;

procedure TMainForm.BtnVfoSwapClick(Sender: TObject);
var
  Tmp:       Double;
  BandIdx:   Integer;
  ActiveFreq: Int64;
begin
  // Меняем частоты местами
  Tmp := FController.FVfoA; FController.FVfoA := FController.FVfoB; FController.FVfoB := Tmp;

  // Определяем новую частоту активного VFO
  if FController.FActiveVfo = 0 then ActiveFreq := Round(FController.FVfoA)
  else                    ActiveFreq := Round(FController.FVfoB);

  // Перестраиваем приёмник
  FController.FCenterFreq := ActiveFreq;
  if FController.FWDSPReady then FController.FDSPEngine.SetShift(0.0);
  if FController.FNetwork.Connected and FController.FNetwork.Running then
  begin
    FController.FNetwork.UpdateState(XvtrTranslate(FController.FCenterFreq), XvtrTranslate(ActiveTXFreqHz), FController.FDriveLevel, FController.FTransmitting, True, True);
    FController.FNetwork.SendFullHP;
  end;

  // Обновляем кнопку диапазона
  BandIdx := FreqToBandIdx(ActiveFreq);
  if (BandIdx >= 0) and (BandIdx <> FController.FCurrentBand) then
  begin
    StyleButton(BtnBand[FController.FCurrentBand], False);
    FController.FCurrentBand := BandIdx;
    StyleButton(BtnBand[FController.FCurrentBand], True);
  end;

  FSpecView.InvalidateRulerCache;
  UpdateVfoDisplay;
  SyncSpecViewFreq;
  FSpecView.DrawSpectrum;
  PbSpectrum.Invalidate;
  if PbRuler <> nil then PbRuler.Invalidate;
end;

procedure TMainForm.BtnVfoACopyBClick(Sender: TObject);
begin
  // A>B: копируем частоту A в B (VFO-B получает частоту VFO-A)
  FController.FVfoB := FController.FVfoA;
  if FController.FActiveVfo = 1 then
  begin
    // B активен — перестраиваем приёмник на новую частоту B
    FController.FCenterFreq := Round(FController.FVfoB);
    if FController.FWDSPReady then FController.FDSPEngine.SetShift(0.0);
    if FController.FNetwork.Connected and FController.FNetwork.Running then
    begin
      FController.FNetwork.UpdateState(XvtrTranslate(FController.FCenterFreq), XvtrTranslate(ActiveTXFreqHz), FController.FDriveLevel, FController.FTransmitting, True, True);
      FController.FNetwork.SendFullHP;
    end;
    FSpecView.InvalidateRulerCache;
    SyncSpecViewFreq;
    FSpecView.DrawSpectrum; PbSpectrum.Invalidate;
    if PbRuler <> nil then PbRuler.Invalidate;
  end;
  FreqDispB.Frequency := Round(FController.FVfoB);
  UpdateVfoDisplay;
end;

procedure TMainForm.BtnVfoBCopyAClick(Sender: TObject);
begin
  // B>A: копируем частоту B в A (VFO-A получает частоту VFO-B)
  FController.FVfoA := FController.FVfoB;
  if FController.FActiveVfo = 0 then
    ApplyVfoA(Round(FController.FVfoA))  // A активен — перестраиваем приёмник
  else
  begin
    FreqDispA.Frequency := Round(FController.FVfoA);
    UpdateVfoDisplay;
  end;
end;

function TMainForm.ActiveTXFreqHz: Double;
begin
  if FController.FSplitTxB then Result := FController.FVfoB
  else if FController.FActiveVfo = 0 then Result := FController.FVfoA
  else Result := FController.FVfoB;
  if FController.FMode = MODE_FM then
    case FController.FFMRptDir of
      RPT_MINUS: Result := Result - FController.FFMRptOffsetHz;
      RPT_PLUS:  Result := Result + FController.FFMRptOffsetHz;
    end;
end;

procedure TMainForm.ApplyMOX(Active: Boolean);
var
  WasTransmitting: Boolean;
begin
  // Safety: block TX on bands marked DoNotTx in Alex config
  if Active and FController.FDevConnected and (FController.FCurrentXvtr < 0)
     and FController.FAlexSettings.DoNotTx[EnsureRange(FController.FCurrentBand, 0, 10)] then
    Exit;
  // Safety: block TX on RX-only transverter
  if Active and (FController.FCurrentXvtr >= 0) and (FController.FCurrentXvtr < CFG_XVTR_COUNT)
     and FController.FXvtrSettings.Entries[FController.FCurrentXvtr].RXOnly then
    Exit;
  WasTransmitting := FController.FTransmitting;
  FController.FTransmitting := Active;
  if Active then FController.FDriveLevel := CalcDriveByte;
  FDUCPendingCount := 0;
  if FController.FTransmitting then
  begin
    BtnMOX.ClrNorm    := TColor($00000044);
    BtnMOX.ClrText    := TColor($000000FF);
    BtnMOX.ClrTextAct := TColor($000000FF);
    BtnMOX.Active     := True;
  end else
    StyleButton(BtnMOX, False);
  // TX-overlay в SpectrumView: фиксируем актуальную TX-частоту/VFO-индекс и режим
  FSpecView.TXFreq := ActiveTXFreqHz;
  if FController.FSplitTxB then FSpecView.TXVfoIndex := 1
  else              FSpecView.TXVfoIndex := FController.FActiveVfo;
  // DUP: данные остаются от RX-анализатора (TXMode=False), но TX-полоса
  // фильтра рисуется поверх через TXOverlay. Без DUP при TX оба флага True.
  FSpecView.TXMode    := FController.FTransmitting and not FController.FDisplayDuplex;
  FSpecView.TXOverlay := FController.FTransmitting;
  // Перерисовываем сетку спектра под активный тракт (Thetis-style).
  ApplySpecViewGridFromState;
  if FController.FTransmitting and FController.FNetwork.Connected and FController.FNetwork.Running then
  begin
    FController.FNetwork.ClearDUCIQQueue;
    FController.FNetwork.UpdateState(XvtrTranslate(FController.FCenterFreq), XvtrTranslate(ActiveTXFreqHz), FController.FDriveLevel,
                         True, True, True);
    FController.FNetwork.SendFullHP;    // 1й пакет: Alex bit27=1 (T/R relay), drive — без PTT
    FController.FNetwork.SendPTT(True); // 2й пакет: PTT=1 после переключения реле
  end;
  if FController.FWDSPReady then
  begin
    // Mic source — определяем автоматически по источнику TX:
    //   Web TX           → txmsWeb
    //   HW PTT (FController.FHWPTTActive при входе в ApplyMOX уже True) → txmsRadio
    //   MOX-кнопка/CAT  → sound card если настроена, иначе txmsRadio
    if FController.FTransmitting then
    begin
      if Assigned(FWebServer) and FWebServer.WebClientActive then
      begin
        FWebMicActive := True;
        FController.FDSPEngine.SetTXMicSource(txmsWeb);
      end
      else if FController.FHWPTTActive then
        FController.FDSPEngine.SetTXMicSource(txmsRadio)
      else
        FController.FDSPEngine.SetTXMicSource(DefaultMicSource);
    end
    else if FWebMicActive then
    begin
      FWebMicActive := False;
      FController.FDSPEngine.SetTXMicSource(DefaultMicSource);
    end;
    // В non-DUP RX-IQ пакеты дропаются на входе DSP-потока во время TX,
    // чтобы TX leakage не накапливался в RXA pipeline и FFT-истории
    // RX-анализатора. В DUP RX-тракт работает как обычно.
    FController.FDSPEngine.KeepRXDuringTX := FController.FDisplayDuplex;
    FController.FDSPEngine.SetTXRun(FController.FTransmitting);
    if WasTransmitting and (not FController.FTransmitting) and (not FController.FDisplayDuplex) then
    begin
      FController.FDSPEngine.FlushRX;
      if Assigned(FController.FAudioOut) then
        FController.FAudioOut.Clear;
      {$IFDEF WINDOWS}
      // Windows: на TX→RX дропаем И RX-IQ пакеты, И аудио-выход на это окно.
      // Источник «хвоста» — radio FPGA TX-buffer + PA slew-down: после MOX-off
      // радио дотравливает буфер передатчика, RX1 ловит это как leak, и WDSP
      // отдаёт это и в водопад, и в звук. На Linux наблюдается окно <30мс,
      // на Windows может тянуться 200–500мс. Подобрать значение можно здесь
      // без перекомпиляции engine. На Linux код-путь не активируется.
      FController.FDSPEngine.BeginPostTXMute(250);
      {$ENDIF}
    end;
    // SetTXRun переключает FActiveDisplayID на TX_DISP_ID; при DUP возвращаем
    // источник на RX, чтобы видеть приём во время передачи.
    if FController.FTransmitting and FController.FDisplayDuplex then
      FController.FDSPEngine.SetDisplaySourceTX(False);
  end;
  if (not FController.FTransmitting) and FController.FNetwork.Connected and FController.FNetwork.Running then
  begin
    FController.FNetwork.SendPTT(False); // 1й пакет: PTT=0 — сначала снимаем PTT
    FController.FNetwork.UpdateState(XvtrTranslate(FController.FCenterFreq), XvtrTranslate(ActiveTXFreqHz), FController.FDriveLevel,
                         False, True, True);
    FController.FNetwork.ClearDUCIQQueue;
    FController.FNetwork.SendFullHP;     // 2й пакет: Alex bit27=0 (T/R relay RX), drive=0
  end;
end;

procedure TMainForm.BtnMOXClick(Sender: TObject);
begin
  // Если идёт tune — клик MOX просто выходит из tune (без перехода на голос).
  if FController.FTuning then
  begin
    ApplyTUN(False);
    Exit;
  end;
  ApplyMOX(not FController.FTransmitting);
end;

procedure TMainForm.BtnMuteClick(Sender: TObject);
begin
  if FSyncingFromController then Exit;
  FController.ToggleMute;   // логика (движок) + OnControllerState обновит кнопку
end;

procedure TMainForm.UpdateNRButton;
begin
  case FController.FNRMode of
    1: BtnNR.Caption := 'NR';
    2: BtnNR.Caption := 'NR2';
    3: BtnNR.Caption := 'NR3';
    4: BtnNR.Caption := 'NR4';
  else
    BtnNR.Caption := 'NR';
  end;
  StyleButton(BtnNR, FController.FNRMode <> 0);
  BtnNR.Invalidate;
end;

procedure TMainForm.UpdateNBButton;
begin
  case FController.FNBMode of
    1: BtnNB.Caption := 'NB';
    2: BtnNB.Caption := 'NB2';
  else
    BtnNB.Caption := 'NB';
  end;
  StyleButton(BtnNB, FController.FNBMode <> 0);
  BtnNB.Invalidate;
end;

procedure TMainForm.UpdateSNBButton;
begin
  BtnSNB.Caption := 'SNB';
  StyleButton(BtnSNB, FController.FSNB);
  BtnSNB.Invalidate;
end;

procedure TMainForm.UpdateANFButton;
begin
  BtnANF.Caption := 'ANF';
  StyleButton(BtnANF, FController.FANF);
  BtnANF.Invalidate;
end;

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

procedure TMainForm.BtnNRClick(Sender: TObject);
begin
  if FSyncingFromController then Exit;
  FController.SetNR((FController.FNRMode + 1) mod 5);
end;

procedure TMainForm.BtnNBClick(Sender: TObject);
begin
  if FSyncingFromController then Exit;
  FController.SetNB((FController.FNBMode + 1) mod 3);
end;

procedure TMainForm.BtnSNBClick(Sender: TObject);
begin
  if FSyncingFromController then Exit;
  FController.SetSNB(not FController.FSNB);
end;

procedure TMainForm.BtnANFClick(Sender: TObject);
begin
  if FSyncingFromController then Exit;
  FController.SetANF(not FController.FANF);
end;


procedure TMainForm.ApplyWfAGCNF(WfAGC, WfNF: Boolean);
begin
  FController.FWfAGCEnabled := WfAGC;
  FController.FWfNFEnabled  := WfNF;
  FSpecView.WfAGCEnabled := FController.FWfAGCEnabled;
  FSpecView.WfNFEnabled  := FController.FWfNFEnabled;
end;

procedure TMainForm.ApplyADCSettings(Dither, Random: Boolean);
begin
  FController.FDitherEnabled := Dither;
  FController.FRandomEnabled := Random;
  if FController.FNetwork.Connected and FController.FRunning then
    FController.FNetwork.ConfigureDDCs(1, FController.FSampleRate div 1000, 0, FController.FDitherEnabled, FController.FRandomEnabled);
  if FController.FDevConnected then
    FController.FSettings.SaveGlobal(FController.FDevMAC, MakeGlobalSettings);
end;

procedure TMainForm.ApplyWebSettings(Enabled: Boolean; Port: Integer;
  const BindAddr, User, Pass: string);
var W: TWebSettings;
begin
  FWebEnabled  := Enabled;
  FWebPort     := Port;
  FWebBindAddr := BindAddr;
  FWebUser     := User;
  FWebPass     := Pass;
  FWebServer.Reconfigure(User, Pass, BindAddr, Word(Port));
  if Enabled then FWebServer.Start;
  W.Enabled  := Enabled;
  W.Port     := Port;
  W.BindAddr := BindAddr;
  W.User     := User;
  W.Pass     := Pass;
  FController.FSettings.SaveWebSettings(W);
end;

procedure TMainForm.OnSampleRateHidePanel;
begin
  FPanelHidden := not FPanelHidden;
  if FPanelHidden then
  begin
    FLeftPanelW := PanelLeft.Width;  // сохраняем реальную ширину (учитывает DPI на Win32)
    PanelLeft.Width := 0;
    FVfoOverlay.Width   := 260;
    FVfoOverlay.Height  := 136;  // OVL_H_NORM — расширяется сам при открытии AGC-пикера
    FVfoOverlay.SetState(FController.FMode, FController.FFilterBW, FController.FVfoA, FLastSMeter);
    FVfoOverlay.SetDSPState(FController.FNRMode, FController.FNBMode, FController.FSNB, FController.FANF, FController.FAGCMode);
    FVfoOverlay.Visible := True;
    PositionVfoOverlay;
  end
  else
  begin
    PanelLeft.Width := FLeftPanelW;  // восстанавливаем сохранённую ширину
    FVfoOverlay.HandleMouseLeave;
    FVfoOverlay.Visible := False;
  end;
  if Assigned(FSampleRateOverlay) then
    FSampleRateOverlay.SetPanelHidden(FPanelHidden);
  ResizeSpectrumPanels;
  ResizeSMeter;
end;

procedure TMainForm.PositionVfoOverlay;
var
  VfoX, FilterEndX, OW, NewLeft, NewTop: Integer;
  HiHz: Double;
begin
  if not Assigned(FVfoOverlay) or not FVfoOverlay.Visible then Exit;
  OW    := FVfoOverlay.Width;

  if (PbSpectrum.Width > 0) and (FController.FSpanHz > 0) then
  begin
    VfoX := Round((FController.FVfoA - FController.FCenterFreq + FController.FSpanHz / 2) / FController.FSpanHz * PbSpectrum.Width)
  end
  else
    VfoX := PbSpectrum.Width div 2;

  case FController.FMode of
    MODE_LSB: HiHz := -100;
    MODE_USB: HiHz := FController.FFilterBW;
  else
    HiHz := FController.FFilterBW / 2;
  end;
  if (PbSpectrum.Width > 0) and (FController.FSpanHz > 0) then
    FilterEndX := VfoX + Round(HiHz / FController.FSpanHz * PbSpectrum.Width)
  else
    FilterEndX := VfoX;
  FilterEndX := Max(0, Min(PbSpectrum.Width - 1, FilterEndX));

  // Всегда справа от правого края полосы фильтра.
  NewLeft := Max(4, Min(PbSpectrum.Width - OW - 4, FilterEndX + 6));
  NewTop := 16;
  if (FVfoOverlay.Left <> NewLeft) or (FVfoOverlay.Top <> NewTop) then
  begin
    FVfoOverlay.Left := NewLeft;
    FVfoOverlay.Top := NewTop;
    OnVfoOverlayInvalidate(FVfoOverlay);
  end;
end;

procedure TMainForm.PbSpectrumDblClick(Sender: TObject);
begin
  // Двойной клик — ничего не делаем (оверлей всегда виден при скрытой панели)
end;

procedure TMainForm.OnModeFilterSelect(Mode: Integer; FilterBW: Integer);
var
  Idx: Integer;
begin
  FController.FMode     := Mode;
  FController.FFilterBW := FilterBW;
  if Mode = MODE_FM then
  begin
    FController.FFilter := FILT_FM_DEF;
    for Idx := 0 to FILT_FM_COUNT - 1 do
      if FILT_FM_BW[Idx] = FilterBW then
      begin
        FController.FFilter      := Idx;
        FController.FFMDeviation := FILT_FM_DEV[Idx];
        Break;
      end;
  end;
  if FController.FWDSPReady then
  begin
    FController.FDSPEngine.SetMode(FController.FMode);
    ApplyModeFilter;
  end;
  SyncSpecViewFreq;
  if Assigned(FVfoOverlay) and FVfoOverlay.Visible then
  begin
    FVfoOverlay.SetState(FController.FMode, FController.FFilterBW, FController.FVfoA, FLastSMeter);
    FVfoOverlay.SetDSPState(FController.FNRMode, FController.FNBMode, FController.FSNB, FController.FANF, FController.FAGCMode);
    PositionVfoOverlay;  // перепозиционируем при смене LSB↔USB
  end;
end;

procedure TMainForm.OnVfoOverlayDSPChange(NRMode, NBMode: Integer; SNBOn, ANFOn: Boolean);
begin
  // Команды контроллера применяют к DSP и через OnControllerState обновляют кнопки.
  FController.SetNR(NRMode);
  FController.SetNB(NBMode);
  FController.SetSNB(SNBOn);
  FController.SetANF(ANFOn);
end;

procedure TMainForm.OnVfoOverlayAGCChange(AGCMode: Integer);
begin
  FController.SetAGCMode(AGCMode);
end;

procedure TMainForm.OnVfoOverlayInvalidate(Sender: TObject);
begin
  FSpectrumDirty := True;
  if Assigned(FSpecView) then
  begin
    FSpecView.SpectrumDirty := True;
    FSpecView.InvalidateOverlayCache;
  end;
  if Assigned(PbSpectrum) then PbSpectrum.Invalidate;
end;

procedure TMainForm.OnSampleRateSelect(SampleRate: Integer);
var
  NewRate: Integer;
  DDCRate: Word;
begin
  NewRate := SampleRate;
  if NewRate = FController.FSampleRate then Exit;

  FController.FSampleRate := NewRate;
  FController.FSpanHz     := NewRate;
  DDCRate     := NewRate div 1000;

  if Assigned(FSampleRateOverlay) then
    FSampleRateOverlay.SetCurrentRate(NewRate);
  Application.ProcessMessages;

  FController.FBandCache[FController.FCurrentBand].SpanHz := FController.FSpanHz;  // для обратной совместимости
  // SampleRate — глобальный: сохраняем в global settings
  if FController.FDevConnected then
    FController.FSettings.SaveGlobal(FController.FDevMAC, MakeGlobalSettings);

  // 1. Останавливаем WDSP и сбрасываем очередь старых пакетов
  if FController.FWDSPReady then
  begin
    FController.FWDSPReady := False;
    FController.FDSPEngine.ChangeSampleRate(NewRate);  // Close + FlushQueue + Open
  end;

  // 2. Отправляем новый rate трансиверу
  //    (после остановки DSP — новые пакеты сразу идут с правильным rate)
  if FController.FNetwork.Connected then
    FController.FNetwork.ConfigureDDCs(1, DDCRate, 0, FController.FDitherEnabled, FController.FRandomEnabled);

  // 3. Восстанавливаем настройки DSP
  FController.FWDSPReady := FController.FDSPEngine.Initialized;
  if FController.FWDSPReady then
  begin
    FController.FDSPEngine.SetMode(FController.FMode);
    FController.FDSPEngine.SetVolume(FController.FVolume / 100.0);
    ApplyModeFilter;
    ApplyNoiseFilterButtonsToDSP;
    // ChangeSampleRate пересоздаёт WDSP-канал — восстанавливаем AGC
    FController.FDSPEngine.SetAGCTop(FController.FAGCTop);
    FController.FDSPEngine.SetAGC(TWDSPAGCMode(FController.FAGCMode), 50.0);
    FController.FDSPEngine.UpdateAGCLines(FSpectrumWidth);
    if FController.FNetwork.Connected then
      FController.FDSPEngine.SetShift(FController.FVfoA - FController.FCenterFreq);
  end;
end;

// ===========================================================================
// Веб-интерфейс: callbacks от TWebServer (вызываются из WS-потока)
// Все изменения состояния выполняем через TThread.Synchronize чтобы
// не трогать UI и WDSP из чужого потока.
// ===========================================================================

procedure TMainForm.WebOnFreq(Hz: Double);
begin
  FWebSyncFreq := Hz;
  FWebSyncM := SyncWebFreq;
  TThread.Synchronize(nil, FWebSyncM);
end;

procedure TMainForm.SyncWebFreq;
var BandIdx: Integer;
begin
  // Веб шлёт "freq" для активного VFO
  if FController.FActiveVfo = 0 then
    ApplyVfoA(Round(FWebSyncFreq))
  else
  begin
    FController.FVfoB := FWebSyncFreq;
    FreqDispB.Frequency := Round(FController.FVfoB);
    FController.FCenterFreq := FController.FVfoB;
    if FController.FWDSPReady then FController.FDSPEngine.SetShift(0.0);
    if FController.FRunning then
      FController.FNetwork.SetRunAndFreq(True, XvtrTranslate(FController.FCenterFreq), XvtrTranslate(FController.FCenterFreq), FController.FDriveLevel);
    BandIdx := FreqToBandIdx(FController.FVfoB);
    if (BandIdx >= 0) and (BandIdx <> FController.FCurrentBand) then
    begin
      StyleButton(BtnBand[FController.FCurrentBand], False);
      FController.FCurrentBand := BandIdx;
      StyleButton(BtnBand[FController.FCurrentBand], True);
    end;
    FSpecView.InvalidateRulerCache;
    SyncSpecViewFreq;
    FSpecView.DrawSpectrum; PbSpectrum.Invalidate;
    if PbRuler <> nil then PbRuler.Invalidate;
    UpdateVfoDisplay;
  end;
end;

procedure TMainForm.WebOnMode(Mode: Integer);
begin
  FWebSyncInt := Mode;
  FWebSyncM := SyncWebMode;
  TThread.Synchronize(nil, FWebSyncM);
end;

procedure TMainForm.SyncWebMode;
var i: Integer;
begin
  if (FWebSyncInt < 0) or (FWebSyncInt >= MODE_COUNT) then Exit;
  FController.FMode := FWebSyncInt;
  for i := 0 to MODE_COUNT - 1 do StyleButton(BtnMode[i], i = FController.FMode);
  if FController.FWDSPReady then FController.FDSPEngine.SetMode(FController.FMode);
  UpdateFilterButtons;
  ApplyModeFilter;
  SyncSpecViewFreq;
  FController.FBandCache[FController.FCurrentBand].Mode := FController.FMode;
end;

procedure TMainForm.WebOnFilter(BW: Integer);
begin
  FWebSyncInt := BW;
  FWebSyncM := SyncWebFilter;
  TThread.Synchronize(nil, FWebSyncM);
end;

procedure TMainForm.SyncWebFilter;
var
  best, dist, i, j: Integer;
  BW_arr: array[0..FILT_COUNT-1] of Integer;
  Idx: Integer;
begin
  // Negative value = -(idx+1) means filter index was passed directly
  if FWebSyncInt < 0 then
  begin
    Idx := (-FWebSyncInt) - 1;
    if (Idx >= 0) and (Idx < FILT_COUNT) then
    begin
      FController.FFilter := Idx;
      case FController.FMode of
        0,1: FController.FFilterBW := FILT_SSB_BW[Idx];
        2:   FController.FFilterBW := FILT_DSB_BW[Idx];
        3,4: FController.FFilterBW := FILT_CW_BW[Idx];
        5:   if Idx < FILT_FM_COUNT then
             begin
               FController.FFilterBW    := FILT_FM_BW[Idx];
               FController.FFMDeviation := FILT_FM_DEV[Idx];
             end;
        else FController.FFilterBW := FILT_AM_BW[Idx];
      end;
      for j := 0 to FILT_COUNT-1 do StyleButton(BtnFilter[j], j = FController.FFilter);
      ApplyModeFilter;
      SyncSpecViewFreq;
      FController.FBandCache[FController.FCurrentBand].FilterBW := FController.FFilterBW;
    end;
    Exit;
  end;
  // Positive value = bandwidth in Hz, find closest filter
  FController.FFilterBW := FWebSyncInt;
  for i := 0 to FILT_COUNT-1 do BW_arr[i] := MaxInt;
  case FController.FMode of
    0,1: for i := 0 to FILT_COUNT-1 do BW_arr[i] := FILT_SSB_BW[i];
    2:   for i := 0 to FILT_COUNT-1 do BW_arr[i] := FILT_DSB_BW[i];
    3,4: for i := 0 to FILT_COUNT-1 do BW_arr[i] := FILT_CW_BW[i];
    5:   for i := 0 to FILT_FM_COUNT-1 do BW_arr[i] := FILT_FM_BW[i];
    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] - FWebSyncInt) < dist then
    begin
      dist := Abs(BW_arr[i] - FWebSyncInt);
      best := i;
    end;
  FController.FFilter := best;
  if FController.FMode = 5 then FController.FFMDeviation := FILT_FM_DEV[best];
  for j := 0 to FILT_COUNT-1 do StyleButton(BtnFilter[j], j = FController.FFilter);
  ApplyModeFilter;
  SyncSpecViewFreq;
  FController.FBandCache[FController.FCurrentBand].FilterBW := FController.FFilterBW;
end;

procedure TMainForm.WebOnAGC(Mode: Integer);
begin
  FWebSyncInt := Mode;
  FWebSyncM := SyncWebAGC;
  TThread.Synchronize(nil, FWebSyncM);
end;

procedure TMainForm.SyncWebAGC;
begin
  FController.SetAGCMode(FWebSyncInt);   // единый маппинг (исправляет старый web-баг)
end;

procedure TMainForm.WebOnAGCTop(DB: Integer);
begin
  FWebSyncInt := DB;
  FWebSyncM := SyncWebAGCTop;
  TThread.Synchronize(nil, FWebSyncM);
end;

procedure TMainForm.SyncWebAGCTop;
begin
  FController.FAGCTop := Max(20, Min(120, FWebSyncInt));
  TrkAGC.Position := FController.FAGCTop;
  LblAGCTop.Caption := Format('%ddB', [FController.FAGCTop]);
  if FController.FWDSPReady then
  begin
    FController.FDSPEngine.SetAGCTop(FController.FAGCTop);
    FController.FDSPEngine.SetAGC(TWDSPAGCMode(FController.FAGCMode), 50.0);
  end;
  FController.FBandCache[FController.FCurrentBand].AGCTop := FController.FAGCTop;
end;

procedure TMainForm.WebOnBand(Idx: Integer);
begin
  FWebSyncInt := Idx;
  FWebSyncM := SyncWebBand;
  TThread.Synchronize(nil, FWebSyncM);
end;

procedure TMainForm.SyncWebBand;
begin
  if (FWebSyncInt < 0) or (FWebSyncInt >= BAND_COUNT) then Exit;
  // Выйти из XVTR при переходе на HF из web. DeactivateXvtr вернёт HF-состояние.
  if FController.FCurrentXvtr >= 0 then
  begin
    DeactivateXvtr;
    if FWebSyncInt = FController.FCurrentBand then Exit;
  end
  else if FWebSyncInt = FController.FCurrentBand then
    Exit;
  if FController.FDevConnected then begin SaveCurrentBand; FController.FSettings.Save; end;
  FController.FCurrentBand := FWebSyncInt;
  RestoreBand(FController.FCurrentBand);
end;

procedure TMainForm.WebOnXvtrBand(Idx: Integer);
begin
  FWebSyncInt := Idx;
  FWebSyncM := SyncWebXvtrBand;
  TThread.Synchronize(nil, FWebSyncM);
end;

procedure TMainForm.SyncWebXvtrBand;
begin
  if FWebSyncInt < 0 then
  begin
    DeactivateXvtr;
    Exit;
  end;
  if (FWebSyncInt >= CFG_XVTR_COUNT) then Exit;
  if not FController.FXvtrSettings.Entries[FWebSyncInt].Enabled then Exit;
  if FWebSyncInt = FController.FCurrentXvtr then Exit;
  if FController.FDevConnected then begin SaveCurrentBand; FController.FSettings.Save; end;
  ActivateXvtrBand(FWebSyncInt);
end;

procedure TMainForm.WebOnSpan(Hz: Integer);
begin
  FWebSyncInt := Hz;
  FWebSyncM := SyncWebSpan;
  TThread.Synchronize(nil, FWebSyncM);
end;

procedure TMainForm.SyncWebSpan;
const
  ValidSpans: array[0..5] of Integer = (48000, 96000, 192000, 384000, 768000, 1536000);
var
  i: Integer;
begin
  for i := 0 to High(ValidSpans) do
    if ValidSpans[i] = FWebSyncInt then
    begin
      OnSampleRateSelect(FWebSyncInt);
      Exit;
    end;
end;

procedure TMainForm.WebOnVolume(V: Integer);
begin
  FWebSyncInt := V;
  FWebSyncM := SyncWebVolume;
  TThread.Synchronize(nil, FWebSyncM);
end;

procedure TMainForm.SyncWebVolume;
begin
  FController.FVolume := Max(0, Min(100, FWebSyncInt));
  TrkVolume.Position := FController.FVolume;
  if FController.FWDSPReady then FController.FDSPEngine.SetVolume(FController.FVolume / 100.0);
end;

procedure TMainForm.WebOnWfAGC(On_: Boolean);
begin
  FWebSyncBool := On_;
  FWebSyncM := SyncWebWfAGC;
  TThread.Synchronize(nil, FWebSyncM);
end;

procedure TMainForm.SyncWebWfAGC;
begin
  FController.FWfAGCEnabled := FWebSyncBool;
  FSpecView.WfAGCEnabled := FController.FWfAGCEnabled;
end;

procedure TMainForm.WebOnWfNF(On_: Boolean);
begin
  FWebSyncBool := On_;
  FWebSyncM := SyncWebWfNF;
  TThread.Synchronize(nil, FWebSyncM);
end;

procedure TMainForm.SyncWebWfNF;
begin
  FController.FWfNFEnabled := FWebSyncBool;
  FSpecView.WfNFEnabled := FController.FWfNFEnabled;
end;

// ── Run ────────────────────────────────────────────────────────────────────

procedure TMainForm.WebOnRun(On_: Boolean);
begin
  FWebSyncBool := On_;
  FWebSyncM := SyncWebRun;
  TThread.Synchronize(nil, FWebSyncM);
end;

procedure TMainForm.SyncWebRun;
begin
  // Toggle only if state differs
  if FWebSyncBool <> FController.FRunning then
    BtnStartStopClick(nil);
end;

// ── Mute ───────────────────────────────────────────────────────────────────

procedure TMainForm.WebOnMute(On_: Boolean);
begin
  FWebSyncBool := On_;
  FWebSyncM := SyncWebMute;
  TThread.Synchronize(nil, FWebSyncM);
end;

procedure TMainForm.SyncWebMute;
begin
  FController.SetMute(FWebSyncBool);   // идемпотентно; OnControllerState обновит UI
end;

// ── CTUN ──────────────────────────────────────────────────────────────────

procedure TMainForm.WebOnCtun(On_: Boolean);
begin
  FWebSyncBool := On_;
  FWebSyncM := SyncWebCtun;
  TThread.Synchronize(nil, FWebSyncM);
end;

procedure TMainForm.SyncWebCtun;
begin
  if FWebSyncBool <> FController.FCTun then
    BtnCTunClick(nil);
end;

// ── NR ────────────────────────────────────────────────────────────────────

procedure TMainForm.WebOnNR(Mode: Integer);
begin
  FWebSyncInt := Mode;
  FWebSyncM := SyncWebNR;
  TThread.Synchronize(nil, FWebSyncM);
end;

procedure TMainForm.SyncWebNR;
begin
  FController.SetNR(FWebSyncInt);   // клампит и применяет; OnControllerState обновит кнопку
end;

// ── NB ────────────────────────────────────────────────────────────────────

procedure TMainForm.WebOnNB(Mode: Integer);
begin
  FWebSyncInt := Mode;
  FWebSyncM := SyncWebNB;
  TThread.Synchronize(nil, FWebSyncM);
end;

procedure TMainForm.SyncWebNB;
begin
  FController.SetNB(FWebSyncInt);   // клампит и применяет; OnControllerState обновит кнопку
end;

procedure TMainForm.WebOnSNB(On_: Boolean);
begin
  FWebSyncBool := On_;
  FWebSyncM := SyncWebSNB;
  TThread.Synchronize(nil, FWebSyncM);
end;

procedure TMainForm.SyncWebSNB;
begin
  FController.SetSNB(FWebSyncBool);
end;

// ── ANF ───────────────────────────────────────────────────────────────────

procedure TMainForm.WebOnANF(On_: Boolean);
begin
  FWebSyncBool := On_;
  FWebSyncM := SyncWebANF;
  TThread.Synchronize(nil, FWebSyncM);
end;

procedure TMainForm.SyncWebANF;
begin
  FController.SetANF(FWebSyncBool);
end;

// ── VFO-B freq (из веба) ─────────────────────────────────────────────────

procedure TMainForm.WebOnFreqB(Hz: Double);
begin
  FWebSyncFreq := Hz;
  FWebSyncM    := SyncWebFreqB;
  TThread.Synchronize(nil, FWebSyncM);
end;

procedure TMainForm.SyncWebFreqB;
var BandIdx: Integer;
begin
  FController.FVfoB := FWebSyncFreq;
  FreqDispB.Frequency := Round(FController.FVfoB);
  if FController.FActiveVfo = 1 then
  begin
    FController.FCenterFreq := FController.FVfoB;
    if not FController.FCTun then
      if FController.FWDSPReady then FController.FDSPEngine.SetShift(0.0);
    if FController.FRunning then
      FController.FNetwork.SetRunAndFreq(True, XvtrTranslate(FController.FCenterFreq), XvtrTranslate(FController.FCenterFreq), FController.FDriveLevel);
    BandIdx := FreqToBandIdx(FController.FVfoB);
    if (BandIdx >= 0) and (BandIdx <> FController.FCurrentBand) then
    begin
      StyleButton(BtnBand[FController.FCurrentBand], False);
      FController.FCurrentBand := BandIdx;
      StyleButton(BtnBand[FController.FCurrentBand], True);
    end;
    FSpecView.InvalidateRulerCache;
    SyncSpecViewFreq;
    FSpecView.DrawSpectrum; PbSpectrum.Invalidate;
    if PbRuler <> nil then PbRuler.Invalidate;
  end;
  UpdateVfoDisplay;
end;

// ── Активный VFO (из веба) ───────────────────────────────────────────────

procedure TMainForm.WebOnActiveVfo(Idx: Integer);
begin
  FWebSyncInt := Idx;
  FWebSyncM   := SyncWebActiveVfo;
  TThread.Synchronize(nil, FWebSyncM);
end;

procedure TMainForm.SyncWebActiveVfo;
begin
  if (FWebSyncInt < 0) or (FWebSyncInt > 1) then Exit;
  if FWebSyncInt = FController.FActiveVfo then Exit;
  ActivateVfo(FWebSyncInt);
end;

// ── CTUN drag center scroll (из веба) ────────────────────────────────────

procedure TMainForm.WebOnCenter(Hz: Double);
begin
  FWebSyncFreq := Hz;
  FWebSyncM    := SyncWebCenter;
  TThread.Synchronize(nil, FWebSyncM);
end;

procedure TMainForm.SyncWebCenter;
begin
  // Сдвигаем DDC-центр без смены VFO (аналог CTUN-drag в десктопе)
  FController.FCenterFreq := FWebSyncFreq;
  if FController.FWDSPReady then
  begin
    if FController.FActiveVfo = 0 then FController.FDSPEngine.SetShift(FController.FVfoA - FController.FCenterFreq)
    else                    FController.FDSPEngine.SetShift(FController.FVfoB - FController.FCenterFreq);
  end;
  if FController.FNetwork.Connected and FController.FNetwork.Running then
    FController.FNetwork.UpdateState(XvtrTranslate(FController.FCenterFreq), XvtrTranslate(ActiveTXFreqHz), FController.FDriveLevel, FController.FTransmitting, True, True);
  FSpecView.InvalidateRulerCache;
  FSpectrumDirty := True;
end;

procedure TMainForm.WebOnMOX(On_: Boolean);
begin
  FWebSyncBool := On_;
  FWebSyncM    := SyncWebMOX;
  TThread.Synchronize(nil, FWebSyncM);
end;

procedure TMainForm.SyncWebMOX;
begin
  ApplyMOX(FWebSyncBool);
end;

procedure TMainForm.WebOnDrive(V: Integer);
begin
  FWebSyncInt := V;
  FWebSyncM   := SyncWebDrive;
  TThread.Synchronize(nil, FWebSyncM);
end;

procedure TMainForm.SyncWebDrive;
begin
  // Установка Position вызывает TrkDriveChange через OnChange автоматически
  TrkDrive.Position := FWebSyncInt;
end;

procedure TMainForm.WebOnFreqA(Hz: Double);
begin
  FWebSyncFreq := Hz;
  FWebSyncM    := SyncWebFreqA;
  TThread.Synchronize(nil, FWebSyncM);
end;

procedure TMainForm.SyncWebFreqA;
begin
  ApplyVfoA(Round(FWebSyncFreq));
end;

procedure TMainForm.WebOnAttn(Idx: Integer);
begin
  FWebSyncInt := Idx;
  FWebSyncM   := SyncWebAttn;
  TThread.Synchronize(nil, FWebSyncM);
end;

procedure TMainForm.SyncWebAttn;
begin
  FController.FAtten := Max(0, Min(2, FWebSyncInt));
  FController.FNetwork.SetStepAtten(FController.FAtten * 10);
end;

procedure TMainForm.WebOnFMStep(Idx: Integer);
begin
  FWebSyncInt := Idx;
  FWebSyncM   := SyncWebFMStep;
  TThread.Synchronize(nil, FWebSyncM);
end;

procedure TMainForm.SyncWebFMStep;
begin
  SetFMStep(Max(0, Min(FM_STEP_COUNT - 1, FWebSyncInt)));
  SaveCurrentBand;
end;

procedure TMainForm.WebOnTun(On_: Boolean);
begin
  FWebSyncBool := On_;
  FWebSyncM    := SyncWebTun;
  TThread.Synchronize(nil, FWebSyncM);
end;

procedure TMainForm.SyncWebTun;
begin
  ApplyTUN(FWebSyncBool);
end;

procedure TMainForm.WebOnMic(Samples: PSingle; Count: Integer);
var
  Buf: array[0..5759] of Double;
  SP:  PSingle;
  i, n: Integer;
begin
  if not FController.FWDSPReady then Exit;
  n := Count;
  if n > Length(Buf) then n := Length(Buf);
  SP := Samples;
  for i := 0 to n - 1 do
  begin
    Buf[i] := SP^;
    Inc(SP);
  end;
  FController.FDSPEngine.PushTXMicSamplesD(Buf, n);
end;

procedure TMainForm.TrkDriveChange(Sender: TObject);
begin
  FController.FDrivePercent := TrkDrive.Position;  // слайдер — ввод; контроллер — истина
  FPreChannelPower := -1;  // ручное изменение снимает сохранённую мощность канала
  FController.FDriveLevel := CalcDriveByte;
  if FController.FWDSPReady then
    FController.FDSPEngine.SetDriveLevel(FController.FDrivePercent / 100.0);
  if FController.FNetwork.Connected and FController.FNetwork.Running then
  begin
    FController.FNetwork.UpdateState(XvtrTranslate(FController.FCenterFreq), XvtrTranslate(ActiveTXFreqHz), FController.FDriveLevel, FController.FTransmitting, True, True);
    FController.FNetwork.SendFullHP;
  end;
end;

function TMainForm.CalcDriveByte: Byte;
// Thetis-compatible formula:
//   drive_byte = int( min(target_volts/0.8, 1.0) * 1.02 * 255 )
// where:
//   target_volts = sqrt( 10^((target_dBm)/10) * 0.05 )   [E=sqrt(P*R), R=50, P in mW]
//   target_dBm   = 10*log10(pwr_W * 1000) - Cal
//   pwr_W        = Pos/100 * FController.FPAMaxPower
//   Cal          = per-band PA gain in dB (HF: ~38.8-41.3, VHF default: ~56.2)
var
  Cal:          Double;
  Pos:          Integer;
  XvtrActive:   Boolean;
  pwr_W:        Double;
  target_dBm:   Double;
  target_volts: Double;
  audio_vol:    Double;
begin
  XvtrActive := (FController.FCurrentXvtr >= 0) and (FController.FCurrentXvtr < CFG_XVTR_COUNT) and
                FController.FXvtrSettings.Entries[FController.FCurrentXvtr].Enabled;

  if XvtrActive then
    Cal := FController.FVHFBandCal[FController.FCurrentXvtr]
  else if (FController.FCurrentBand >= 0) and (FController.FCurrentBand < BAND_COUNT) then
    Cal := FController.FPABandCal[FController.FCurrentBand]
  else
    Cal := 38.8;

  // Pos — итоговый процент драйва 0..100
  if FController.FTuning then
  begin
    if XvtrActive and FController.FXvtrSettings.UseXVTRTunePower then
      Pos := EnsureRange(FController.FXvtrSettings.Entries[FController.FCurrentXvtr].TXPower, 0, 100)
    else
      Pos := EnsureRange(FController.FTXSettings.TUNLevel, 0, 100);
  end
  else
  begin
    if XvtrActive then
      Pos := Round(FController.FDrivePercent * FController.FXvtrSettings.Entries[FController.FCurrentXvtr].TXPower / 100.0)
    else
      Pos := FController.FDrivePercent;
  end;

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

  pwr_W        := Pos / 100.0 * FController.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 TMainForm.OnTXIQReady(const Buf: array of Double; Count: Integer);
// FTXOutBufSize не обязан быть кратен 240, поэтому копим хвост между
// вызовами и шлём только полные DUC-пакеты. Иначе зануление хвоста
// последнего пакета давало периодический провал на каждом TX-блоке
// (≈47 Гц гул при 192k IQ rate). Также клампим в 24-bit чтобы пики
// от компрессора/EQ (значение чуть >1.0) не оборачивались через wrap.
  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 FController.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
      FController.FNetwork.SendDUCIQ(FDUCPendingI, FDUCPendingQ);
      FDUCPendingCount := 0;
    end;
  end;
end;

procedure TMainForm.OnPASettingsChange(MaxPower: Double;
  const BandCal: array of Double);
var i: Integer;
begin
  FController.FPAMaxPower := MaxPower;
  if FSpecView <> nil then FSpecView.PAMaxPower := MaxPower;
  for i := 0 to BAND_COUNT - 1 do
  begin
    if i <= High(BandCal) then
      FController.FPABandCal[i] := BandCal[i]
    else
      FController.FPABandCal[i] := 100.0;
  end;
  FController.FDriveLevel := CalcDriveByte;
  if FController.FNetwork.Connected and FController.FNetwork.Running then
    FController.FNetwork.UpdateState(XvtrTranslate(FController.FCenterFreq), XvtrTranslate(ActiveTXFreqHz), FController.FDriveLevel, FController.FTransmitting, True, True);
end;

procedure TMainForm.OnVHFCalSettingsChange(const VHFCal: array of Double);
var i: Integer;
begin
  for i := 0 to CFG_XVTR_COUNT - 1 do
  begin
    if i <= High(VHFCal) then
      FController.FVHFBandCal[i] := VHFCal[i]
    else
      FController.FVHFBandCal[i] := CFG_VHF_CAL_DEFAULT;
  end;
  FController.FDriveLevel := CalcDriveByte;
  if FController.FNetwork.Connected and FController.FNetwork.Running then
    FController.FNetwork.UpdateState(XvtrTranslate(FController.FCenterFreq), XvtrTranslate(ActiveTXFreqHz), FController.FDriveLevel, FController.FTransmitting, True, True);
end;

procedure TMainForm.OnTXSettingsChange(const T: TTXSettings);
// Любая правка в Settings→Transmit летит сюда: обновляем FController.FTXSettings,
// перешиваем WDSP TX-цепь и (если поменялись mic-биты) перепосылаем DUC Specific.
var
  OldMicByte, NewMicByte: Byte;
  OldAtt, OldTUNLevel, OldTUNFreq: Integer;
  GridChanged: Boolean;
begin
  OldMicByte  := BuildMicLineSelectByte;
  OldAtt      := FController.FTXSettings.AttOnTX;
  OldTUNLevel := FController.FTXSettings.TUNLevel;
  OldTUNFreq  := FController.FTXSettings.TUNFreq;
  GridChanged := (FTXSpecRefLevel <> T.TXSpecRefLevel)
              or (FTXSpecRange    <> T.TXSpecRange)
              or (FTXSpecGridStep <> T.TXSpecGridStep);
  FController.FTXSettings := T;
  NewMicByte := BuildMicLineSelectByte;
  // TX-grid в момент правки — обновляем рабочие поля и (если на передаче) пушим в FSpecView
  FTXSpecRefLevel := FController.FTXSettings.TXSpecRefLevel;
  FTXSpecRange    := FController.FTXSettings.TXSpecRange;
  if FController.FTXSettings.TXSpecGridStep > 0 then
    FTXSpecGridStep := FController.FTXSettings.TXSpecGridStep;
  if GridChanged and FController.FTransmitting then
    ApplySpecViewGridFromState;
  ApplyTXSettingsToDSP;
  if (OldMicByte <> NewMicByte) or (OldAtt <> FController.FTXSettings.AttOnTX) then
    SendDUCSpecificFromSettings;
  // TUN-параметры live: если идёт tune — обновить тон/drive, иначе просто запомнить.
  if FController.FTuning then
  begin
    if (OldTUNFreq <> FController.FTXSettings.TUNFreq) and FController.FWDSPReady then
      FController.FDSPEngine.SetTXTone(True, FController.FTXSettings.TUNFreq, 1.0);
    if OldTUNLevel <> FController.FTXSettings.TUNLevel then
    begin
      FController.FDriveLevel := CalcDriveByte;
      if FController.FWDSPReady then
        FController.FDSPEngine.SetDriveLevel(FController.FTXSettings.TUNLevel / 100.0);
      if FController.FNetwork.Connected and FController.FNetwork.Running then
      begin
        FController.FNetwork.UpdateState(XvtrTranslate(FController.FCenterFreq), XvtrTranslate(ActiveTXFreqHz), FController.FDriveLevel,
                             FController.FTransmitting, True, True);
        FController.FNetwork.SendFullHP;
      end;
    end;
  end;
  // Сохраняем сразу — настройки per-device, попадают в hpsdr_settings.json
  if FController.FDevConnected then
  begin
    FController.FSettings.SaveTX(FController.FDevMAC, FController.FTXSettings);
    FController.FSettings.Save;
  end;
end;

procedure TMainForm.OnAlexSettingsChange(const A: TAlexSettings);
begin
  FController.FAlexSettings := A;
  if FController.FNetwork.Connected then
    FController.FNetwork.SetAlexConfig(A);
  if FController.FDevConnected then
  begin
    FController.FSettings.SaveAlex(FController.FDevMAC, A);
    FController.FSettings.Save;
  end;
end;

// ===========================================================================
// XVTR (Transverter) integration
// ===========================================================================

// Ищет XVTR слот, в чьё [FreqBegin..FreqEnd] попадает данная видимая частота.
// Возвращает индекс 0..CFG_XVTR_COUNT-1 или -1.
function TMainForm.FindXvtrIdxForFreq(FreqHz: Double): Integer;
var i: Integer;
begin
  Result := -1;
  for i := 0 to CFG_XVTR_COUNT - 1 do
    with FController.FXvtrSettings.Entries[i] do
      if Enabled and (FreqHz >= FreqBegin) and (FreqHz <= FreqEnd) then
        Exit(i);
end;

// Транслирует видимую частоту в IF (то, что слышит/передаёт трансивер).
// Если активный XVTR не задан — возвращает входную частоту без изменений.
function TMainForm.XvtrTranslate(VisibleHz: Double): Double;
begin
  Result := VisibleHz;
  if (FController.FCurrentXvtr < 0) or (FController.FCurrentXvtr >= CFG_XVTR_COUNT) then Exit;
  if not FController.FXvtrSettings.Entries[FController.FCurrentXvtr].Enabled then Exit;
  // f_IF = f_visible - LOOffset + LOError
  Result := VisibleHz
            - FController.FXvtrSettings.Entries[FController.FCurrentXvtr].LOOffset
            + FController.FXvtrSettings.Entries[FController.FCurrentXvtr].LOError;
end;

// Передаёт XVTR-режим в сетевой слой. Вызывается при смене XVTR или его
// настроек. Управляет XVTR Enable bit, T/R relay suppression.
// XVTR DDC In (Alex bits 8+11) управляется через галочку XVTR в Antenna/Alex
// tab для IF-диапазона (Alex.RxOnly[IF_band]=3) — аналогично Thetis.
procedure TMainForm.ApplyXvtrToNetwork;
var
  En: Boolean;
  DisablePA: Boolean;
  RxAnt: Byte;
begin
  if (FController.FCurrentXvtr >= 0) and (FController.FCurrentXvtr < CFG_XVTR_COUNT) and
     FController.FXvtrSettings.Entries[FController.FCurrentXvtr].Enabled then
  begin
    En        := True;
    DisablePA := FController.FXvtrSettings.Entries[FController.FCurrentXvtr].DisablePA;
    RxAnt     := FController.FXvtrSettings.Entries[FController.FCurrentXvtr].RXAntenna;
  end
  else
  begin
    En := False; DisablePA := False; RxAnt := 0;
  end;
  if FController.FNetwork.Connected then
    FController.FNetwork.SetXvtrMode(En, DisablePA, RxAnt);
end;

// Перестраивает динамические XVTR-кнопки в PanelBands. Удаляет старые,
// создаёт новые для enabled слотов в 3-м и 4-м ряду PanelBands.
procedure TMainForm.RelayoutBelowBands;
var
  Y: Integer;
begin
  if PanelBands = nil then Exit;
  Y := PanelBands.Top + PanelBands.Height + 2;
  PanelMode.Top   := Y; Y := Y + PanelMode.Height + 2;
  PanelFilter.Top := Y; Y := Y + PanelFilter.Height + 2;
  if (PanelFMStep <> nil) and PanelFMStep.Visible then
  begin
    PanelFMStep.Top := Y; Y := Y + PanelFMStep.Height + 2;
  end;
  if (PanelFMSQ <> nil) and PanelFMSQ.Visible then
  begin
    PanelFMSQ.Top := Y; Y := Y + PanelFMSQ.Height + 2;
  end;
  if (PanelFMCTCSS <> nil) and PanelFMCTCSS.Visible then
  begin
    PanelFMCTCSS.Top := Y; Y := Y + PanelFMCTCSS.Height + 2;
  end;
  if (PanelFMRpt <> nil) and PanelFMRpt.Visible then
  begin
    PanelFMRpt.Top := Y; Y := Y + PanelFMRpt.Height + 2;
  end;
end;

procedure TMainForm.RebuildXvtrButtons;
var
  i, Pos, XvtrRows: Integer;
  BtnH, RowH, MaxBottom: Integer;
  B:      TFlatButton;
  Cap:    string;
begin
  // Уничтожаем все старые
  for i := 0 to CFG_XVTR_COUNT - 1 do
    if BtnXvtrBand[i] <> nil then
    begin
      BtnXvtrBand[i].Free;
      BtnXvtrBand[i] := nil;
    end;
  if PanelBands = nil then Exit;

  // Измеряем фактический bottom последнего ряда обычных кнопок BAND
  // (уже отмасштабированы LCL при показе формы)
  MaxBottom := 0;
  BtnH := 0;
  for i := 0 to BAND_COUNT - 1 do
    if BtnBand[i] <> nil then
    begin
      if BtnBand[i].Top + BtnBand[i].Height > MaxBottom then
        MaxBottom := BtnBand[i].Top + BtnBand[i].Height;
      BtnH := BtnBand[i].Height;
    end;
  if BtnH = 0 then BtnH := MulDiv(24, Screen.PixelsPerInch, 96);
  RowH := BtnH + 3;  // шаг ряда: высота кнопки + 3px зазор (как 27=24+3 при 1x)

  Pos := 0;   // позиция среди enabled слотов
  for i := 0 to CFG_XVTR_COUNT - 1 do
    if FController.FXvtrSettings.Entries[i].Enabled then
    begin
      if Pos >= 12 then Break;  // 2 ряда по 6 кнопок
      Cap := FController.FXvtrSettings.Entries[i].ButtonText;
      if Cap = '' then Cap := 'X' + IntToStr(i + 1);
      B := TFlatButton.Create(Self);
      B.Parent  := PanelBands;
      B.Caption := Cap;
      B.Left    := LeftPanelButtonLeft(PanelBands.Width, 6, Pos mod 6);
      B.Top     := MaxBottom + 4 + (Pos div 6) * RowH;
      B.Width   := LeftPanelButtonWidth(PanelBands.Width, 6, Pos mod 6);
      B.Height  := BtnH;
      B.OnClick := BtnXvtrBandClick;
      B.Tag     := -(i + 1);   // отрицательное значение = XVTR индекс
      BtnXvtrBand[i] := B;
      StyleButton(B, i = FController.FCurrentXvtr);
      Inc(Pos);
    end;
  // Динамически подгоняем высоту PanelBands под реальное содержимое
  if Pos = 0 then XvtrRows := 0
  else if Pos <= 6 then XvtrRows := 1
  else XvtrRows := 2;
  if XvtrRows = 0 then
    PanelBands.Height := MaxBottom + 6
  else
    PanelBands.Height := MaxBottom + 4 + XvtrRows * RowH + 6;
  RelayoutBelowBands;
end;

// Обработчик клика по XVTR-кнопке. Tag = -(idx+1).
procedure TMainForm.BtnXvtrBandClick(Sender: TObject);
var
  TagVal, Idx: Integer;
begin
  TagVal := (Sender as TFlatButton).Tag;
  Idx := -TagVal - 1;
  if (Idx < 0) or (Idx >= CFG_XVTR_COUNT) then Exit;
  if not FController.FXvtrSettings.Entries[Idx].Enabled then Exit;
  if FController.FCurrentXvtr = Idx then Exit;  // уже на этом XVTR
  // Сохраняем текущий band перед сменой (как BtnBandClick)
  if FController.FDevConnected then
  begin
    SaveCurrentBand;
    FController.FSettings.Save;
  end;
  ActivateXvtrBand(Idx);
end;

// Активирует XVTR-band: устанавливает видимую частоту по LastFreq или
// середине [FreqBegin..FreqEnd], применяет network XVTR-режим.
procedure TMainForm.ActivateXvtrBand(Idx: Integer);
var
  Vis: Double;
  i:   Integer;
  E:   TXvtrEntry;
begin
  if (Idx < 0) or (Idx >= CFG_XVTR_COUNT) then Exit;
  if not FController.FXvtrSettings.Entries[Idx].Enabled then Exit;
  FController.FCurrentXvtr := Idx;
  E := FController.FXvtrSettings.Entries[Idx];
  // Выбор начальной частоты: LastFreq если в диапазоне, иначе середина
  Vis := E.LastFreq;
  if (Vis < E.FreqBegin) or (Vis > E.FreqEnd) then
    Vis := (E.FreqBegin + E.FreqEnd) / 2.0;
  // Отключаем кнопки HF band
  for i := 0 to BAND_COUNT - 1 do
    StyleButton(BtnBand[i], False);
  // Подсвечиваем активную XVTR-кнопку
  for i := 0 to CFG_XVTR_COUNT - 1 do
    if BtnXvtrBand[i] <> nil then
      StyleButton(BtnXvtrBand[i], i = Idx);
  // Применяем XVTR режим к сети (XVTR enable bit + DDC IN + DisablePA + RX ant)
  ApplyXvtrToNetwork;
  // Восстанавливаем Mode + FM Squelch + CTCSS для этого XVTR-слота
  FController.FFMSQOn    := E.LastFMSQOn;
  FController.FFMSQLevel := E.LastFMSQLevel;
  FController.FMode      := E.LastMode;
  FController.FFilter    := E.LastFilterIdx;
  for i := 0 to MODE_COUNT - 1 do StyleButton(BtnMode[i], i = FController.FMode);
  if FController.FWDSPReady then FController.FDSPEngine.SetMode(FController.FMode);
  UpdateFilterButtons;   // показывает/скрывает FM-панели, обновляет SQL UI
  ApplyModeFilter;
  FController.FFMCTCSSOn         := E.LastCTCSSOn;
  FController.FFMCTCSSAutoActive := E.LastCTCSSAutoActive;
  if BtnFMCTCSS <> nil then StyleButton(BtnFMCTCSS, FController.FFMCTCSSOn);
  SetFMCTCSSTone(E.LastCTCSSToneIdx);
  ApplyFMSquelch;
  FController.FFMStepOn := E.LastFMStepOn;
  SetFMStep(E.LastFMStepIdx);
  if BtnFMStep <> nil then StyleButton(BtnFMStep, FController.FFMStepOn);
  FController.FFMRptDir         := E.LastFMRptDir;
  FController.FFMRptAutoActive  := E.LastFMRptAutoActive;
  FController.FFMRptOffsetHz    := E.LastFMRptOffsetHz;
  if FController.FFMRptOffsetHz <= 0 then
    FController.FFMRptOffsetHz := RptDefaultOffsetHz(E.FreqBegin);
  if BtnFMRptMinus <> nil then StyleButton(BtnFMRptMinus, FController.FFMRptDir = RPT_MINUS);
  if BtnFMRptPlus  <> nil then StyleButton(BtnFMRptPlus,  FController.FFMRptDir = RPT_PLUS);
  if EdFMRptOffset <> nil then EdFMRptOffset.Text := RptFormatOffset(FController.FFMRptOffsetHz);
  // AGC
  FController.FAGCMode := E.LastAGCMode;
  FController.FAGCTop  := E.LastAGCTop;
  for i := 0 to 4 do StyleButton(BtnAGCMode[i], i = FController.FAGCMode);
  TrkAGC.Position := FController.FAGCTop;
  LblAGCTop.Caption := Format('%ddB', [FController.FAGCTop]);
  if FController.FWDSPReady then
  begin
    FController.FDSPEngine.SetAGCTop(FController.FAGCTop);
    FController.FDSPEngine.SetAGC(TWDSPAGCMode(FController.FAGCMode), 50.0);
  end;
  FSpecView.AGCTop := FController.FAGCTop;
  // CTUN
  FController.FCTun := E.LastCTun;
  StyleButton(BtnCTun, FController.FCTun);
  // VFO на видимую частоту; FController.FCenterFreq тоже visible
  FController.FVfoA := Vis;
  FController.FCenterFreq := Vis;
  FreqDispA.Frequency := Round(FController.FVfoA);
  if FController.FWDSPReady then FController.FDSPEngine.SetShift(0.0);
  // Пересчитываем drive byte для нового XVTR слота и отправляем в сеть
  FController.FDriveLevel := CalcDriveByte;
  if FController.FNetwork.Connected and FController.FNetwork.Running then
  begin
    FController.FNetwork.UpdateState(XvtrTranslate(FController.FCenterFreq), XvtrTranslate(ActiveTXFreqHz),
                         FController.FDriveLevel, FController.FTransmitting, True, True);
    FController.FNetwork.SendFullHP;
  end;
  SyncSpecViewFreq;
  FSpecView.DrawSpectrum;
  PbSpectrum.Invalidate;
  FSpecView.DrawWaterfall;
  PbWaterfall.Invalidate;
  if PbRuler <> nil then PbRuler.Invalidate;
  UpdateWidebandFrequencyView;
  if PbWideband <> nil then PbWideband.Invalidate;
  if PbWidebandRuler <> nil then PbWidebandRuler.Invalidate;
  PushXvtrToWeb;
end;

// Деактивирует XVTR — возвращает к HF-режиму.
// FController.FCurrentBand был сохранён неизменным во время XVTR (см. ActivateXvtrBand,
// ApplyVfoA, SaveCurrentBand) — он указывает на HF-диапазон, с которого
// пользователь зашёл в трансвертер. RestoreBand вернёт VFO/Mode/Filter/AGC
// /CTUN/Span этого диапазона и обновит подсветку HF band-кнопки.
procedure TMainForm.DeactivateXvtr;
var i: Integer;
begin
  if FController.FCurrentXvtr < 0 then Exit;
  // Сохраняем полное состояние XVTR-слота (раньше сохранялся только LastFreq)
  if FController.FCurrentXvtr < CFG_XVTR_COUNT then
  begin
    FController.FXvtrSettings.Entries[FController.FCurrentXvtr].LastFreq         := FController.FVfoA;
    FController.FXvtrSettings.Entries[FController.FCurrentXvtr].LastMode         := FController.FMode;
    FController.FXvtrSettings.Entries[FController.FCurrentXvtr].LastFilterIdx    := FController.FFilter;
    FController.FXvtrSettings.Entries[FController.FCurrentXvtr].LastFMSQOn       := FController.FFMSQOn;
    FController.FXvtrSettings.Entries[FController.FCurrentXvtr].LastFMSQLevel    := FController.FFMSQLevel;
    FController.FXvtrSettings.Entries[FController.FCurrentXvtr].LastCTCSSOn      := FController.FFMCTCSSOn;
    FController.FXvtrSettings.Entries[FController.FCurrentXvtr].LastCTCSSToneIdx := FController.FFMCTCSSToneIdx;
    FController.FXvtrSettings.Entries[FController.FCurrentXvtr].LastFMStepOn     := FController.FFMStepOn;
    FController.FXvtrSettings.Entries[FController.FCurrentXvtr].LastFMStepIdx    := FController.FFMStepIdx;
    FController.FXvtrSettings.Entries[FController.FCurrentXvtr].LastFMRptDir        := FController.FFMRptDir;
    FController.FXvtrSettings.Entries[FController.FCurrentXvtr].LastFMRptOffsetHz   := FController.FFMRptOffsetHz;
    FController.FXvtrSettings.Entries[FController.FCurrentXvtr].LastCTun            := FController.FCTun;
    FController.FXvtrSettings.Entries[FController.FCurrentXvtr].LastAGCMode         := FController.FAGCMode;
    FController.FXvtrSettings.Entries[FController.FCurrentXvtr].LastAGCTop          := FController.FAGCTop;
    FController.FXvtrSettings.Entries[FController.FCurrentXvtr].LastCTCSSAutoActive := FController.FFMCTCSSAutoActive;
    FController.FXvtrSettings.Entries[FController.FCurrentXvtr].LastFMRptAutoActive := FController.FFMRptAutoActive;
    if FController.FDevConnected then
    begin
      FController.FSettings.SaveXvtr(FController.FDevMAC, FController.FXvtrSettings);
      FController.FSettings.Save;
    end;
  end;
  FController.FCurrentXvtr := -1;
  // Сбрасываем подсветку XVTR-кнопок
  for i := 0 to CFG_XVTR_COUNT - 1 do
    if BtnXvtrBand[i] <> nil then
      StyleButton(BtnXvtrBand[i], False);
  ApplyXvtrToNetwork;
  // Восстанавливаем HF band: VFO/Mode/Filter/AGC/CTUN/Span из кэша.
  // RestoreBand сам обновит подсветку HF band-кнопки и сетевое состояние.
  if (FController.FCurrentBand >= 0) and (FController.FCurrentBand < CFG_BAND_COUNT) then
    RestoreBand(FController.FCurrentBand);
  UpdateWidebandFrequencyView;
  if PbWideband <> nil then PbWideband.Invalidate;
  if PbWidebandRuler <> nil then PbWidebandRuler.Invalidate;
  PushXvtrToWeb;
end;

procedure TMainForm.OnXvtrSettingsChange(const X: TXvtrSettings);
begin
  FController.FXvtrSettings := X;
  RebuildXvtrButtons;
  // Если активный XVTR стал disabled — выйти из XVTR режима
  if (FController.FCurrentXvtr >= 0) and
     ((FController.FCurrentXvtr >= CFG_XVTR_COUNT) or
      (not FController.FXvtrSettings.Entries[FController.FCurrentXvtr].Enabled)) then
    DeactivateXvtr;
  // Перепрошить XVTR в сеть (если параметры текущего изменились)
  ApplyXvtrToNetwork;
  UpdateWidebandFrequencyView;
  if PbWideband <> nil then PbWideband.Invalidate;
  if PbWidebandRuler <> nil then PbWidebandRuler.Invalidate;
  PushXvtrToWeb;
  if FController.FDevConnected then
  begin
    FController.FSettings.SaveXvtr(FController.FDevMAC, FController.FXvtrSettings);
    FController.FSettings.Save;
  end;
end;

procedure TMainForm.PushXvtrToWeb;
var
  Arr: TWebXvtrArray;
  i, Cnt: Integer;
begin
  if FWebServer = nil then Exit;
  Cnt := 0;
  for i := 0 to CFG_XVTR_COUNT - 1 do
    if FController.FXvtrSettings.Entries[i].Enabled then Inc(Cnt);
  SetLength(Arr, Cnt);
  Cnt := 0;
  for i := 0 to CFG_XVTR_COUNT - 1 do
    if FController.FXvtrSettings.Entries[i].Enabled then
    begin
      Arr[Cnt].Idx  := i;
      Arr[Cnt].Name := FController.FXvtrSettings.Entries[i].ButtonText;
      Inc(Cnt);
    end;
  FWebServer.SetXvtrBands(Arr, FController.FCurrentXvtr);
end;

procedure TMainForm.TrkVolumeChange(Sender: TObject);
begin
  FController.FVolume := TrkVolume.Position;
  if FController.FWDSPReady then
    FController.FDSPEngine.SetVolume(FController.FVolume / 100.0);
end;

// ===========================================================================
// Mouse wheel — перестройка VFO
// ===========================================================================

procedure TMainForm.FormMouseWheel(Sender: TObject; Shift: TShiftState;
  WheelDelta: Integer; MousePos: TPoint; var Handled: Boolean);
// Колёсико на форме/спектре/водопаде — меняет VFO с квантованием
// Шаг зависит от модификаторов: Ctrl+Shift=1МГц, Ctrl=1кГц, Shift=10Гц, иначе 100Гц
// Квантование: результат выровнен до кратного шагу (как в FreqDisplay)
// CTUN логика: через ApplyVfoA
var
  Step:    Int64;
  Delta:   Int64;
  Base:    Int64;
  NewFreq: Int64;
begin
  if (FController.FMode = MODE_FM) and FController.FFMStepOn and
     not ((ssCtrl in Shift) or (ssShift in Shift)) then
    Step := FM_STEP_HZ[FController.FFMStepIdx]
  else if (ssCtrl in Shift) and (ssShift in Shift) then Step := 1000000
  else if ssCtrl in Shift then Step := 1000
  else if ssShift in Shift then Step := 10
  else Step := 100;

  Delta := 1;
  if WheelDelta < 0 then Delta := -1;

  // Квантование (та же логика что в FreqDisplay.ChangeByDigit)
  Base := (Round(FController.FVfoA) div Step) * Step;
  if Delta > 0 then
    NewFreq := Base + Step
  else
  begin
    if Round(FController.FVfoA) = Base then
      NewFreq := Base - Step
    else
      NewFreq := Base;   // снэп к нижней границе
  end;

  // В XVTR-режиме HF-клэмп не применяем: ApplyVfoA сам ограничит по FreqBegin/FreqEnd
  if (FController.FCurrentXvtr >= 0) and (FController.FCurrentXvtr < CFG_XVTR_COUNT) and
     FController.FXvtrSettings.Entries[FController.FCurrentXvtr].Enabled then
    NewFreq := Max(0, NewFreq)
  else
    NewFreq := Max(30000, Min(60000000, NewFreq));
  ApplyVfoA(NewFreq);

  Handled := True;
end;


procedure TMainForm.BtnAGCModeClick(Sender: TObject);
begin
  if FSyncingFromController then Exit;
  FController.SetAGCMode((Sender as TFlatButton).Tag);
end;

procedure TMainForm.TrkAGCChange(Sender: TObject);
begin
  FController.FAGCTop := TrkAGC.Position;
  LblAGCTop.Caption := Format('%ddB', [FController.FAGCTop]);
  if FController.FWDSPReady then
  begin
    FController.FDSPEngine.SetAGCTop(FController.FAGCTop);
    FController.FDSPEngine.SetAGC(TWDSPAGCMode(FController.FAGCMode), 50.0);
  end;
  FController.FBandCache[FController.FCurrentBand].AGCTop := FController.FAGCTop;
  FSpecView.AGCTop := FController.FAGCTop;
  FSpecView.DrawSpectrum;
  PbSpectrum.Invalidate;
end;

// ===========================================================================
// DSP/Audio callbacks — вызываются из рабочих потоков
// ===========================================================================

procedure TMainForm.OnAudioReady(const Left, Right: array of Single;
  Count: Integer);
var
  MonoBuf: array[0..1023] of Single;
  i: Integer;
begin
  // Дублируем RX-аудио во встроенный динамик трансивера (DDC Audio, port 1028).
  if FController.FSendAudioToRadio then
    FController.FNetwork.SendSpeakerAudio(Left, Right, Count);
  if Assigned(FWebServer) and FWebServer.WebClientActive then
  begin
    for i := 0 to Count - 1 do
      MonoBuf[i] := (Left[i] + Right[i]) * 0.5;
    FWebServer.PushAudio(@MonoBuf[0], Count);
    Exit;
  end;
  FController.FAudioOut.Write(Left, Right, Count);
end;

procedure TMainForm.OnSpectrumReady(const Pixels: array of Single;
  Count: Integer);
var
  i, N: Integer;
begin
  N := Min(Count, 1024);
  for i := 0 to N - 1 do
    FSpectrumBuf[i] := Pixels[i];   // local copy for WebServer
  FSpectrumBufCount := N;
  FSpecView.SetSpectrumData(Pixels, Count);
  FSpectrumDirty := True;
end;

procedure TMainForm.OnWaterfallReady(const Pixels: array of Single;
  Count: Integer);
var
  i, N: Integer;
begin
  N := Min(Count, 1024);
  for i := 0 to N - 1 do
    FWaterfallBuf[i] := Pixels[i];  // local copy for WebServer
  FWaterfallBufCount := N;
  Inc(FWaterfallFrameCounter);
  if FWaterfallFrameCounter >= Max(1, FWaterfallFrameInterval) then
  begin
    FWaterfallFrameCounter := 0;
    FSpecView.SetWaterfallData(Pixels, Count);
    FWaterfallDirty := True;
  end;
end;

// ===========================================================================
// Settings apply methods (вызываются из TSettingsForm)
// ===========================================================================

procedure TMainForm.ApplyDisplayParams(FFTSize, WinType, SpecDet, SpecAvgMode: Integer;
  SpecAvgTimeMS: Double);
begin
  if FController.FWDSPReady then
    FController.FDSPEngine.SetSpectrumDisplay(SpecDet, SpecAvgMode, SpecAvgTimeMS);
  if FController.FWDSPReady then
    FController.FDSPEngine.SetFFTParams(FFTSize, WinType);
  FSpectrumDirty := True;
end;

procedure TMainForm.ApplyWaterfallParams(WfDet, WfAvgMode: Integer;
  WfAvgTimeMS, WfHigh, WfLow, WfAGCOffset: Double);
begin
  if FController.FWDSPReady then
    FController.FDSPEngine.SetWaterfallDisplay(WfDet, WfAvgMode, WfAvgTimeMS);
  FWfManualHigh := WfHigh;
  FWfManualLow  := WfLow;
  FWfAGCOffset  := WfAGCOffset;
  FSpecView.WfManualHigh := FWfManualHigh;
  FSpecView.WfManualLow  := FWfManualLow;
  FSpecView.WfAGCOffset  := FWfAGCOffset;
  FSpecView.ResetWfAvgBuf;
  FSpectrumDirty := True;
end;

procedure TMainForm.ApplyGridParams(RefLevel, Range, GridStep: Double);
begin
  FSpecRefLevel := RefLevel;
  FSpecRange    := Range;
  if GridStep > 0 then FSpecGridStep := GridStep;
  // На передаче FSpecView показывает TX-сетку — RX-правка не должна её перетирать.
  if not FController.FTransmitting then ApplySpecViewGridFromState;
end;

procedure TMainForm.ApplySpecViewGridFromState;
begin
  // При DUP во время TX данные приходят от RX-анализатора → используем RX-сетку.
  if FController.FTransmitting and not FController.FDisplayDuplex then
  begin
    FSpecView.SpecRefLevel := FTXSpecRefLevel;
    FSpecView.SpecRange    := FTXSpecRange;
    FSpecView.SpecGridStep := FTXSpecGridStep;
  end else
  begin
    FSpecView.SpecRefLevel := FSpecRefLevel;
    FSpecView.SpecRange    := FSpecRange;
    FSpecView.SpecGridStep := FSpecGridStep;
  end;
  InvalidateGridCache;
  FSpectrumDirty := True;
end;

procedure TMainForm.ApplyAudioDevice(DevIndex: Integer; const DevName: string);
begin
  if FController.FAudioOut.IsOpen then
    FController.FAudioOut.Close;
  FController.FAudioOut.DeviceIndex := DevIndex;
  FController.FAudioOutDevName := DevName;
  try
    FController.FAudioOut.Open;
  except
  end;
end;

procedure TMainForm.ApplyAudioInputDevice(DevIndex: Integer; const DevName: string);
begin
  if FController.FAudioIn.IsOpen then
    FController.FAudioIn.Close;
  FController.FAudioIn.DeviceIndex := DevIndex;
  FController.FAudioInDevName := DevName;
  if DevIndex >= 0 then
  begin
    try
      FController.FAudioIn.Open;
    except
    end;
  end;
end;

procedure TMainForm.ApplyAudioBufferSize(BufferSize: Integer);
var
  WasOpen: Boolean;
begin
  if BufferSize <= 128 then BufferSize := 128
  else if BufferSize <= 256 then BufferSize := 256
  else BufferSize := 512;
  if FController.FAudioOut.OutputBufferSize = BufferSize then Exit;

  WasOpen := FController.FAudioOut.IsOpen;
  if WasOpen then FController.FAudioOut.Close;
  FController.FAudioOut.OutputBufferSize := BufferSize;
  if WasOpen then
  begin
    try
      FController.FAudioOut.Open;
    except
    end;
  end;
  FController.FSettings.SaveAudioBufferSize(BufferSize);
  FController.FSettings.Save;
end;

procedure TMainForm.ApplySendAudioToRadio(Enabled: Boolean);
begin
  FController.FSendAudioToRadio := Enabled;
  // Один переключатель: вкл = слать поток + снять мьют динамика,
  // выкл = замьютить динамик (byte 1400 bit1) и не слать поток.
  FController.FNetwork.SetSpeakerAudio(Enabled);
  if FController.FDevConnected then
    FController.FSettings.SaveGlobal(FController.FDevMAC, MakeGlobalSettings);
end;

procedure TMainForm.ApplyVisibility(ShowSpectrum, ShowWaterfall,
  ShowWideband, WidebandFill, SpectrumFill: Boolean);
begin
  FShowSpectrum  := ShowSpectrum;
  FShowWaterfall := ShowWaterfall;
  FShowWideband  := ShowWideband;
  FWidebandFill  := WidebandFill;
  FSpectrumFill  := SpectrumFill;
  if Assigned(FWidebandView) then
    FWidebandView.SetFillSpectrum(FWidebandFill);
  FSpecView.FillSpectrum := FSpectrumFill;
  if Assigned(FController.FNetwork) and FController.FNetwork.Connected then
    FController.FNetwork.ConfigureWideband(0, FShowWideband, 512, 16, 70, 32);
  ResizeSpectrumPanels;
end;

procedure TMainForm.ApplyFPS(FPS: Integer);
begin
  if FPS < 1  then FPS := 1;
  if FPS > 100 then FPS := 100;
  FDisplayFPS := FPS;
  FSpectrumTimer.Interval := 1000 div FPS;
  // CPU path keeps the historical every-other-frame waterfall update.
  // OpenGL uploads only one texture row per frame, so it can match display FPS.
  if FUseOpenGLSpectrum then
    FWaterfallFrameInterval := 1
  else
    FWaterfallFrameInterval := 2;
  FWaterfallFrameCounter := 0;
  FWaterfallDirty := True;
  FSpecView.WfFrameInterval := FWaterfallFrameInterval;
  if FController.FWDSPReady then
    FController.FDSPEngine.SetDisplayFPS(FPS);
end;

procedure TMainForm.ApplyFreqMhzDigits(Digits: Integer);
const
  MaxFreqs: array[3..5] of Int64 = (999999999, 9999999999, 99999999999);
begin
  Digits := EnsureRange(Digits, 3, 5);
  FFreqMhzDigits := Digits;
  FreqDispA.MinMhzDigits := Digits;
  FreqDispB.MinMhzDigits := Digits;
  FreqDispA.MaxFreq := MaxFreqs[Digits];
  FreqDispB.MaxFreq := MaxFreqs[Digits];
  if Assigned(FWebServer) then
    FWebServer.FreqMhzDigits := Digits;
  LayoutTopVfoBlock;
end;

procedure TMainForm.BtnSettingsClick(Sender: TObject);
var
  SF: TSettingsForm;
begin
  if not Assigned(FSettingsForm) then
  begin
    SF := TSettingsForm.Create(Self);
    FSettingsForm := SF;
    SF.OnDisplayChange    := ApplyDisplayParams;
    SF.OnWaterfallChange  := ApplyWaterfallParams;
    SF.OnGridChange       := ApplyGridParams;
    SF.OnAudioDevChange   := ApplyAudioDevice;
    SF.OnAudioInDevChange := ApplyAudioInputDevice;
    SF.OnAudioBufferChange := ApplyAudioBufferSize;
    SF.OnSendAudioToRadioChange := ApplySendAudioToRadio;
    SF.OnVisibilityChange := ApplyVisibility;
    SF.OnFPSChange        := ApplyFPS;
    SF.OnPAChange         := OnPASettingsChange;
    SF.OnVHFCalChange     := OnVHFCalSettingsChange;
    SF.OnCATChange        := OnCATSettingsChange;
    SF.OnThemeChange           := SetLightTheme;
    SF.OnFreqMhzDigitsChange   := ApplyFreqMhzDigits;
    SF.OnTXChange              := OnTXSettingsChange;
    SF.OnAlexChange       := OnAlexSettingsChange;
    SF.OnXvtrChange       := OnXvtrSettingsChange;
    SF.OnWfAGCNFChange    := ApplyWfAGCNF;
    SF.OnADCChange            := ApplyADCSettings;
    SF.OnWebSettingsChange    := ApplyWebSettings;
  end;
  SF := TSettingsForm(FSettingsForm);
  SF.LoadTXSettings(FController.FTXSettings,
    IfThen(FController.FNetwork.Connected, FController.FNetwork.Device.BoardType, FPendingBoardType));
  SF.LoadAlexSettings(FController.FAlexSettings,
    IfThen(FController.FNetwork.Connected, FController.FNetwork.Device.BoardType, FPendingBoardType));
  SF.LoadXvtrSettings(FController.FXvtrSettings);
  SF.LoadPASettings(FController.FPAMaxPower, FController.FPABandCal);
  SF.LoadVHFCalSettings(FController.FVHFBandCal);
  SF.LoadCATSettings(
    FCATLastGlobal.CATSerialEnabled,
    FCATLastGlobal.CATSerialPort,
    FCATLastGlobal.CATSerialBaud,
    FCATLastGlobal.CATSerialDataBits,
    FCATLastGlobal.CATSerialStopBits,
    FCATLastGlobal.CATSerialParity,
    FCATLastGlobal.CATTcpEnabled,
    FCATLastGlobal.CATTcpPort);
  // Перечисляем PA устройства
  SF.RefreshAudioDevices(FController.FAudioOut, FController.FAudioIn);
  SF.LoadVisibility(FShowSpectrum, FShowWaterfall, FShowWideband, FWidebandFill,
    FSpectrumFill);
  SF.LoadWfAGCNF(FController.FWfAGCEnabled, FController.FWfNFEnabled);
  SF.LoadADCSettings(FController.FDitherEnabled, FController.FRandomEnabled);
  SF.LoadWebSettings(FWebEnabled, FWebPort, FWebBindAddr, FWebUser, FWebPass);
  SF.LoadFPS(FDisplayFPS);
  SF.LoadLightTheme(FLightTheme);
  SF.LoadFreqMhzDigits(FFreqMhzDigits);
  SF.LoadAudioBufferSize(FController.FAudioOut.OutputBufferSize);
  SF.LoadSendAudioToRadio(FController.FSendAudioToRadio);
  // Загружаем текущие значения
  if FController.FWDSPReady then
    SF.LoadValues(
      FController.FDSPEngine.FFTSize,
      FController.FDSPEngine.WindowType,
      FController.FDSPEngine.SpecDetector,
      FController.FDSPEngine.SpecAvgMode,
      FController.FDSPEngine.SpecAvgTimeMS,
      FController.FDSPEngine.WfDetector,
      FController.FDSPEngine.WfAvgMode,
      FController.FDSPEngine.WfAvgTimeMS,
      FWfManualHigh,
      FWfManualLow,
      FWfAGCOffset,
      FSpecRefLevel, FSpecRange, FSpecGridStep,
      FController.FAudioOutDevName, FController.FAudioInDevName)
  else
    SF.LoadValues(
      131072, 2, 0, 3, 30.0,
      0,    3, 120.0, FWfManualHigh, FWfManualLow, FWfAGCOffset,
      FSpecRefLevel, FSpecRange, FSpecGridStep,
      FController.FAudioOutDevName, FController.FAudioInDevName);
  SF.Show;
end;

procedure TMainForm.AfterShowTick(Sender: TObject);
begin
  // Вызывается один раз через 200 мс после старта формы
  FAfterShowTimer.Enabled := False;

  // Повторно применяем сохранённую позицию: к этому моменту WM уже декорировал
  // окно и Qt знает frame extents, поэтому move() ставит рамку точно.
  if FHasPendingRestore then
  begin
    SetBounds(FRestoreL, FRestoreT, Width, Height);
    FHasPendingRestore := False;
  end;

  try
    FController.FAudioOut.Open;
  except
  end;

  EnsureWDSPWisdom;
end;

// ===========================================================================
// CAT subsystem
// ===========================================================================

procedure TMainForm.InitCATEngine;
var
  Ctx: TCATContext;
begin
  FillChar(Ctx, SizeOf(Ctx), 0);
  Ctx.GetVfoA         := CATGetVfoA;
  Ctx.GetVfoB         := CATGetVfoB;
  Ctx.GetMode         := CATGetMode;
  Ctx.GetActiveVfo    := CATGetActiveVfo;
  Ctx.GetAGCMode      := CATGetAGCMode;
  Ctx.GetVolume       := CATGetVolume;
  Ctx.GetDriveLevel   := CATGetDriveLevel;
  Ctx.GetFilterIdx    := CATGetFilterIdx;
  Ctx.GetFilterBW     := CATGetFilterBW;
  Ctx.GetNRMode       := CATGetNRMode;
  Ctx.GetNBMode       := CATGetNBMode;
  Ctx.GetSNBEnabled   := CATGetSNB;
  Ctx.GetANFEnabled   := CATGetANF;
  Ctx.GetTransmitting := CATGetTX;
  Ctx.GetRunning      := CATGetRunning;
  Ctx.GetSMeter       := CATGetSMeter;
  Ctx.GetCurrentBand  := CATGetBand;
  Ctx.SetVfoA         := CATSetVfoA;
  Ctx.SetVfoB         := CATSetVfoB;
  Ctx.SetMode         := WebOnMode;
  Ctx.SetActiveVfo    := WebOnActiveVfo;
  Ctx.SetAGCMode      := WebOnAGC;
  Ctx.SetVolume       := WebOnVolume;
  Ctx.SetDriveLevel   := WebOnDrive;
  Ctx.SetFilterIdx    := CATSetFilterIdx;
  Ctx.SetNRMode       := WebOnNR;
  Ctx.SetNBMode       := WebOnNB;
  Ctx.SetSNBEnabled   := WebOnSNB;
  Ctx.SetANFEnabled   := WebOnANF;
  Ctx.SetTransmitting := WebOnMOX;
  Ctx.DoBandUp        := CATDoBandUp;
  Ctx.DoBandDown      := CATDoBandDown;
  Ctx.DoTuneUp        := CATDoTuneUp;
  Ctx.DoTuneDown      := CATDoTuneDown;
  Ctx.DoBandByIndex   := WebOnBand;
  FCATEngine := TCATEngine.Create(Ctx);
  FCATSerial := TCATSerialManager.Create(FCATEngine);
  FCATTcp    := TCATTcpServer.Create(FCATEngine);
end;

procedure TMainForm.CATApplySettings(const G: TGlobalSettings);
var
  Cfgs: array[0..3] of TCATSerialConfig;
  i: Integer;
begin
  for i := 0 to 3 do
  begin
    Cfgs[i].Enabled  := G.CATSerialEnabled[i];
    Cfgs[i].PortName := G.CATSerialPort[i];
    Cfgs[i].BaudRate := G.CATSerialBaud[i];
    Cfgs[i].DataBits := G.CATSerialDataBits[i];
    Cfgs[i].StopBits := G.CATSerialStopBits[i];
    case G.CATSerialParity[i] of
      1: Cfgs[i].Parity := cspOdd;
      2: Cfgs[i].Parity := cspEven;
    else Cfgs[i].Parity := cspNone;
    end;
  end;
  FCATSerial.ApplyConfig(Cfgs);
  FCATTcp.Stop;
  if G.CATTcpEnabled then
  begin
    FCATTcp.Port := G.CATTcpPort;
    FCATTcp.Start;
  end;
end;

procedure TMainForm.OnCATSettingsChange(
  const SerEnabled: array of Boolean;
  const SerPort: array of string;
  const SerBaud, SerDataBits, SerStopBits, SerParity: array of Integer;
  TcpEnabled: Boolean; TcpPort: Integer);
var i: Integer;
begin
  for i := 0 to 3 do
  begin
    if i <= High(SerEnabled)  then FCATLastGlobal.CATSerialEnabled[i]  := SerEnabled[i];
    if i <= High(SerPort)     then FCATLastGlobal.CATSerialPort[i]     := SerPort[i];
    if i <= High(SerBaud)     then FCATLastGlobal.CATSerialBaud[i]     := SerBaud[i];
    if i <= High(SerDataBits) then FCATLastGlobal.CATSerialDataBits[i] := SerDataBits[i];
    if i <= High(SerStopBits) then FCATLastGlobal.CATSerialStopBits[i] := SerStopBits[i];
    if i <= High(SerParity)   then FCATLastGlobal.CATSerialParity[i]   := SerParity[i];
  end;
  FCATLastGlobal.CATTcpEnabled := TcpEnabled;
  FCATLastGlobal.CATTcpPort    := TcpPort;
  CATApplySettings(FCATLastGlobal);
  FController.FSettings.SaveCATSettings(FCATLastGlobal);
  if FController.FDevConnected then
    FController.FSettings.SaveGlobal(FController.FDevMAC, MakeGlobalSettings);
  FController.FSettings.Save;
end;

// --- Getters (called from CAT thread — read only, no sync needed) -----------

function TMainForm.CATGetVfoA: Double;       begin Result := FController.FVfoA; end;
function TMainForm.CATGetVfoB: Double;       begin Result := FController.FVfoB; end;
function TMainForm.CATGetMode: Integer;      begin Result := FController.FMode; end;
function TMainForm.CATGetActiveVfo: Integer; begin Result := FController.FActiveVfo; end;
function TMainForm.CATGetAGCMode: Integer;   begin Result := FController.FAGCMode; end;
function TMainForm.CATGetVolume: Integer;    begin Result := FController.FVolume; end;
function TMainForm.CATGetDriveLevel: Integer;begin Result := FController.FDrivePercent; end;
function TMainForm.CATGetFilterIdx: Integer; begin Result := FController.FFilter; end;
function TMainForm.CATGetFilterBW: Integer;  begin Result := FController.FFilterBW; end;
function TMainForm.CATGetNRMode: Integer;    begin Result := FController.FNRMode; end;
function TMainForm.CATGetNBMode: Integer;    begin Result := FController.FNBMode; end;
function TMainForm.CATGetSNB: Boolean;       begin Result := FController.FSNB; end;
function TMainForm.CATGetANF: Boolean;       begin Result := FController.FANF; end;
function TMainForm.CATGetTX: Boolean;        begin Result := FController.FTransmitting; end;
function TMainForm.CATGetRunning: Boolean;   begin Result := FController.FRunning; end;
function TMainForm.CATGetSMeter: Double;     begin Result := FLastSMeter; end;
function TMainForm.CATGetBand: Integer;      begin Result := FController.FCurrentBand; end;

// --- Setters ----------------------------------------------------------------

procedure TMainForm.CATSetVfoA(V: Double);
begin
  FCATSyncFreq := V;
  TThread.Synchronize(nil, SyncCATVfoA);
end;

procedure TMainForm.CATSetVfoB(V: Double);
begin
  FCATSyncFreq := V;
  TThread.Synchronize(nil, SyncCATVfoB);
end;

procedure TMainForm.CATSetFilterIdx(V: Integer);
begin
  // WebOnFilter: negative value encodes 0-based index as -(idx+1)
  WebOnFilter(-(V + 1));
end;

procedure TMainForm.CATDoBandUp;
begin TThread.Synchronize(nil, SyncCATBandUp); end;

procedure TMainForm.CATDoBandDown;
begin TThread.Synchronize(nil, SyncCATBandDown); end;

procedure TMainForm.CATDoTuneUp;
begin TThread.Synchronize(nil, SyncCATTuneUp); end;

procedure TMainForm.CATDoTuneDown;
begin TThread.Synchronize(nil, SyncCATTuneDown); end;

// --- Sync methods (run in main thread) --------------------------------------

procedure TMainForm.SyncCATVfoA;
var BandIdx: Integer;
begin
  FController.FVfoA := FCATSyncFreq;
  if FController.FActiveVfo = 0 then
  begin
    ApplyVfoA(Round(FController.FVfoA));
  end
  else
  begin
    // VFO A is not active — just update display and band indicator
    FreqDispA.Frequency := Round(FController.FVfoA);
    BandIdx := FreqToBandIdx(FController.FVfoA);
    if (BandIdx >= 0) and (BandIdx <> FController.FCurrentBand) then
    begin
      StyleButton(BtnBand[FController.FCurrentBand], False);
      FController.FCurrentBand := BandIdx;
      StyleButton(BtnBand[FController.FCurrentBand], True);
    end;
  end;
end;

procedure TMainForm.SyncCATVfoB;
var BandIdx: Integer;
begin
  FController.FVfoB := FCATSyncFreq;
  FreqDispB.Frequency := Round(FController.FVfoB);
  if FController.FActiveVfo = 1 then
  begin
    FController.FCenterFreq := FController.FVfoB;
    if FController.FWDSPReady then FController.FDSPEngine.SetShift(0.0);
    if FController.FRunning then
      FController.FNetwork.SetRunAndFreq(True, XvtrTranslate(FController.FCenterFreq), XvtrTranslate(FController.FCenterFreq), FController.FDriveLevel);
    BandIdx := FreqToBandIdx(FController.FVfoB);
    if (BandIdx >= 0) and (BandIdx <> FController.FCurrentBand) then
    begin
      StyleButton(BtnBand[FController.FCurrentBand], False);
      FController.FCurrentBand := BandIdx;
      StyleButton(BtnBand[FController.FCurrentBand], True);
    end;
    FSpecView.InvalidateRulerCache;
    SyncSpecViewFreq;
    FSpecView.DrawSpectrum; PbSpectrum.Invalidate;
    if PbRuler <> nil then PbRuler.Invalidate;
    UpdateVfoDisplay;
  end;
end;

procedure TMainForm.SyncCATBandUp;
begin
  if FController.FCurrentBand < BAND_COUNT - 1 then
    WebOnBand(FController.FCurrentBand + 1);
end;

procedure TMainForm.SyncCATBandDown;
begin
  if FController.FCurrentBand > 0 then
    WebOnBand(FController.FCurrentBand - 1);
end;

procedure TMainForm.SyncCATTuneUp;
begin
  if FController.FActiveVfo = 0 then
    ApplyVfoA(Round(FController.FVfoA) + 10)
  else
  begin
    FCATSyncFreq := FController.FVfoB + 10;
    SyncCATVfoB;
  end;
end;

procedure TMainForm.SyncCATTuneDown;
begin
  if FController.FActiveVfo = 0 then
    ApplyVfoA(Round(FController.FVfoA) - 10)
  else
  begin
    FCATSyncFreq := FController.FVfoB - 10;
    SyncCATVfoB;
  end;
end;

end.
