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, FlatButton, Forms, Controls, Graphics, Dialogs,
  StdCtrls, ExtCtrls, ComCtrls, Buttons, Menus, Math, Types,
  LCLIntf, LCLType, GraphType,
  HPSDRProtocol, HPSDRNetwork,
  WDSP, WDSPEngine, AudioOutput, AudioInput,
  Settings,
  WebServer,
  CATEngine, CATSerial, CATTcp,
  SpectrumView;

const
  CLR_BG       = TColor($00101010);
  CLR_PANEL    = TColor($00181818);
  CLR_BORDER   = TColor($00303030);
  CLR_FREQ     = TColor($0000FF00);
  CLR_FREQ_DIM = TColor($00008000);
  CLR_AMBER    = TColor($0000AAFF);
  CLR_ACTIVE   = TColor($00003300);
  CLR_INACTIVE = TColor($00202020);
  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','750','600','500','400','250','100','50','25');
  FILT_CW_BW:    array[0..FILT_COUNT-1] of Integer =
    (1000, 800, 750, 600, 500, 400, 250, 100, 50, 25);
  FILT_CW_DEF = 4;    // 500

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

  // FM
  FILT_FM_NAMES: array[0..FILT_COUNT-1] of string =
    ('20k','15k','12k','10k','8k','6k','5k','4k','3k','2k');
  FILT_FM_BW:    array[0..FILT_COUNT-1] of Integer =
    (20000, 15000, 12000, 10000, 8000, 6000, 5000, 4000, 3000, 2000);
  FILT_FM_DEF = 0;    // 20k

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

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

  // Синхронизирующий объект для OnDeviceFound
  TDeviceFoundSync = class
  private
    FForm:  TObject;   // TMainForm, через forward ref
    FDev:   THPSDRDevice;
    FEntry: string;
  public
    constructor Create(AForm: TObject; const D: THPSDRDevice; const E: string);
    procedure Execute;
  end;

  // Синхронизирующий объект для HP Status
  TStatusUISync = class
  private
    FForm:    TObject;
    FFwdW:    Double;
    FSWRV:    Double;
    FSupplyV: Double;
    FPLLLock: Boolean;
    FHWPTT:   Boolean;
  public
    constructor Create(AForm: TObject; FW, SW, SV: Double; PLL, HWPTT: Boolean);
    procedure Execute;
  end;

  // Синхронизирующий объект для DDC IQ
  TDDCSeqSync = class
  private
    FForm:    TObject;
    FDDCIdx:  Integer;
    FSeq:     LongWord;
  public
    constructor Create(AForm: TObject; Idx: Integer; Seq: LongWord);
    procedure Execute;
  end;

  { TMainForm }
  TMainForm = class(TForm)
  private
    // ---- Network ----
    FNetwork:     THPSDRNetwork;
    FDevices:     array of TDeviceItem;
    FDeviceCount:  Integer;
    FDeviceDialog: TDeviceDialog;
    FPendingIP:        string;   // IP устройства для подключения
    FVfoOverlay:   TVfoOverlay;  // накладка SmartSDR-стиль на спектре
    FPanelHidden:  Boolean;      // True = левая панель скрыта
    FPendingBoardType: Integer;  // BoardType устройства для подключения

    // ---- DSP + Audio ----
    FDSPEngine:      TWDSPEngine;
    FAudioOut:       TAudioOutput;
    FAudioIn:        TAudioInput;
    FAudioOutDevName: string;   // текущее имя выходного устройства
    FAudioInDevName:  string;   // текущее имя входного устройства (TX mic)
    FWDSPReady:      Boolean;   // True когда WDSP открыт и работает

    // ---- State ----
    FVfoA:         Double;
    FVfoB:         Double;
    FActiveVfo:    Integer;
    FMode:         Integer;
    FFilter:       Integer;
    FAGCMode:      Integer;   // 0=FAST 1=MED 2=SLOW 3=LONG 4=OFF
    FAGCTop:       Integer;   // AGC level dBm, −20..−120
    FCTun:         Boolean;  // Center Tune: True=спектр стоит, маркер двигается
    FFilterBW:     Integer;  // текущая полоса фильтра в Гц
    // мышь на спектре/водопаде
    FSpecDrag:      Boolean;
    FSpecDragX0:    Integer;  // X при нажатии
    FSpecDragFreq:  Double;   // FCenterFreq при нажатии
    FSpectrumDirty: Boolean;  // таймер должен перерисовать спектр/водопад
    FDriveLevel:   Byte;
    FPAMaxPower:   Double;
    FPABandCal:    array[0..BAND_COUNT-1] of Double;  // калибровка 38.8..100.0 на диапазон
    FRunning:      Boolean;
    FTransmitting: Boolean;
    FHWPTTActive:  Boolean;  // True = hardware PTT нажата
    FMuted:        Boolean;
    FVolume:       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
    FLastFwdW:     Double;
    FLastSWR:      Double;

    // ---- Spectrum / Waterfall view ----
    FSpecView:       TSpectrumView;
    FSpectrumWidth:  Integer;   // актуальная ширина для FDSPEngine и resize-детектора
    FSpectrumHeight: Integer;
    FWaterfallHeight:Integer;
    FShowSpectrum:   Boolean;
    FShowWaterfall:  Boolean;
    FDisplayFPS:     Integer;
    FWaterfallDirty: Boolean;
    // Waterfall settings (kept here for MakeGlobalSettings, buttons, settings dialog)
    FWfAGCEnabled:  Boolean;
    FWfNFEnabled:   Boolean;
    FWfManualHigh:  Double;
    FWfManualLow:   Double;
    FWfAGCOffset:   Double;
    // Spectrum display settings (for settings save/load)
    FSpecRefLevel:  Double;
    FSpecRange:     Double;
    FSpecGridStep:  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;
    // ---- Splitter ----
    FSplitterDrag:   Boolean;
    FSplitterDragY0: Integer;
    FSplitterSH0:    Integer;
    FSplitterRatio:  Double;
    // --- Settings ---
    FSettings:       TSettingsManager;
    FWebServer:      TWebServer;          // веб-интерфейс (порт 8080)
    // --- CAT ---
    FCATEngine:         TCATEngine;
    FCATSerial:         TCATSerialManager;
    FCATTcp:            TCATTcpServer;
    FCATSyncFreq:       Double;
    FCATLastGlobal:     TGlobalSettings; // текущие CAT-настройки (для сохранения)
    // Временные поля для передачи параметров в Synchronize-методы
    FWebSyncFreq:    Double;
    FWebSyncInt:     Integer;
    FWebSyncBool:    Boolean;
    FWebSyncM:       TThreadMethod;
    FCurrentBand:    Integer;            // текущий активный диапазон 0..10
    FSettingsForm:   TObject;            // TSettingsForm (cast при использовании)
    FDevMAC:         array[0..5] of Byte; // MAC подключённого трансивера
    FPendingDev:     THPSDRDevice;        // устройство ожидающее открытия WDSP
    FBandCache:      array[0..CFG_BAND_COUNT-1] of TBandSettings; // кэш диапазонов
    FDevConnected:   Boolean;            // True после первого подключения
    FCenterFreq:     Double;   // центр спектра = LO (DDC). При CTUN ON не меняется при кручении VFO
    FSpanHz:         Double;
    FSampleRate:     Integer;   // текущий DDC sample rate (Hz)

    // ---- Timers ----
    FMeterTimer:    TTimer;
    FSpectrumTimer: TTimer;
    FAfterShowTimer:TTimer;   // однократный таймер для пост-инициализации

    // ---- Toolbar ----
    PanelToolbar:  TPanel;
      BtnDiscover:  TFlatButton;
      BtnStartStop: TFlatButton;   // START / STOP (connect+run в одном)
      BtnSettings:  TFlatButton;   // открыть диалог настроек

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

    PanelVfoA:    TPanel;
      LblVfoALabel: TLabel;
      FreqDispA:    TFreqDisplay;

    PanelVfoB:    TPanel;
      LblVfoBLabel: TLabel;
      FreqDispB:    TFreqDisplay;

    PanelVfoButtons: TPanel;
      BtnVfoSwap: TFlatButton;
      BtnVfoACopyB: TFlatButton;
      BtnVfoBCopyA: TFlatButton;

    PanelBands:   TPanel;
      BtnBand:    array[0..BAND_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;

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

    PanelSMeterRight: TPanel;
    PbSMeterRight:    TPaintBox;

    PanelTX:      TPanel;
      LblDrv:     TLabel;
      TrkDrive:   TTrackBar;
      BtnMOX: TFlatButton;
      PbFwdPower: TPaintBox;
      PbSWR:      TPaintBox;
      LblFwdPwr:  TLabel;
      LblSWRVal:  TLabel;

    // ---- Right panel ----
    PanelRight:       TPanel;
    PanelSpanButtons: TPanel;
      LblSpan:        TLabel;
      BtnSpan48k: TFlatButton;
      BtnSpan96k: TFlatButton;
      BtnSpan192k: TFlatButton;
      BtnSpan384k: TFlatButton;
      BtnSpan768k: TFlatButton;
      BtnSpan1536k: TFlatButton;
      BtnHidePanel: TFlatButton;   // скрыть/показать левую панель
    PbSpectrum:   TPaintBox;
    PbRuler:      TPaintBox;   // полоса частотных меток между спектром и водопадом
    PanelSplitter: TPanel;     // перетаскиваемый разделитель спектр/водопад
    PbWaterfall:  TPaintBox;

    // ---- Status bar ----
    StatusBar1: TStatusBar;

    // ---- Helpers ----
    procedure BuildUI;
    procedure ApplyDarkTheme;
    procedure StyleButton(B: TFlatButton; Active: Boolean = False);
    procedure StyleSpanButton(B: TFlatButton; Active: Boolean = False);
    procedure PositionSpanOverlayButtons;
    procedure UpdateVfoDisplay;
    function  FormatFreq(Hz: Double): string;

    procedure ResizeSMeter;
    procedure ResizeSpectrumPanels;
    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 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: Double; PLLLock, HWPTT: Boolean);
    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 ActivateVfo(Idx: Integer);
    function  ActiveVfoFreq: Int64;
    // Настройки
    procedure SaveCurrentBand;
    procedure RestoreBand(BandIdx: Integer);
    procedure SaveAllAndExit;
    procedure RestoreWindowBounds;
    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 UpdateFilterButtons;
    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 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 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);
    procedure BtnSpanClick(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);
    // Synchronize-обёртки (выполняются в UI-потоке)
    procedure SyncWebFreq;
    procedure SyncWebMode;
    procedure SyncWebFilter;
    procedure SyncWebAGC;
    procedure SyncWebAGCTop;
    procedure SyncWebBand;
    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;
    // --- 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 BtnWfAGCClick(Sender: TObject);
    procedure BtnWfNFClick(Sender: TObject);
    procedure BtnHidePanelClick(Sender: TObject);
    procedure BtnSettingsClick(Sender: TObject);
    procedure PositionVfoOverlay;
    procedure OnModeFilterSelect(Mode: Integer; FilterBW: Integer);
    procedure PbSpectrumDblClick(Sender: TObject);
    procedure TrkDriveChange(Sender: TObject);
    procedure TrkVolumeChange(Sender: TObject);
    function  CalcDriveByte: Byte;
    function  ActiveTXFreqHz: Double;
    procedure ApplyMOX(Active: Boolean);
    procedure OnTXIQReady(const Buf: array of Double; Count: Integer);
    procedure OnPASettingsChange(MaxPower: Double; const BandCal: array of Double);
    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 ApplyGridParams(RefLevel, Range, GridStep: Double);
    procedure ApplyAudioDevice(DevIndex: Integer; const DevName: string);
    procedure ApplyAudioInputDevice(DevIndex: Integer; const DevName: string);
    procedure ApplyVisibility(ShowSpectrum, ShowWaterfall: Boolean);
    procedure ApplyFPS(FPS: 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;

  TWisdomBuildThread = class(TThread)
  private
    FDirectory: string;
    FError:     string;
  protected
    procedure Execute; override;
  public
    constructor Create(const ADirectory: string);
    property ErrorText: string read FError;
  end;

  TWisdomProgressDialog = class(TForm)
  private
    FInfoLabel: TLabel;
    FProgress:  TProgressBar;
    FTimer:     TTimer;
    FThread:    TWisdomBuildThread;
    FPhase:     Integer;
    procedure TimerTick(Sender: TObject);
  public
    constructor Create(AOwner: TComponent; AThread: TWisdomBuildThread); reintroduce;
  end;

var
  MainForm: TMainForm;

implementation

uses SettingsForm;

{$R *.lfm}

constructor TWisdomBuildThread.Create(const ADirectory: string);
begin
  inherited Create(False);
  FreeOnTerminate := False;
  FDirectory      := ADirectory;
  FError          := '';
end;

procedure TWisdomBuildThread.Execute;
var
  DirA: AnsiString;
begin
  try
    if not Assigned(@WDSPwisdom) then Exit;
    DirA := AnsiString(FDirectory);
    WDSPwisdom(PAnsiChar(DirA));
  except
    on E: Exception do
      FError := E.ClassName + ': ' + E.Message;
  end;
end;

constructor TWisdomProgressDialog.Create(AOwner: TComponent;
  AThread: TWisdomBuildThread);
begin
  inherited CreateNew(AOwner);
  FThread := AThread;
  FPhase  := 0;

  Caption     := 'WDSP Wisdom';
  Width       := 520;
  Height      := 140;
  Position    := poScreenCenter;
  BorderStyle := bsDialog;
  BorderIcons := [];
  Color       := CLR_BG;
  Font.Name   := 'Courier New';
  Font.Size   := 9;
  Font.Color  := CLR_TEXT;

  FInfoLabel := TLabel.Create(Self);
  FInfoLabel.Parent := Self;
  FInfoLabel.SetBounds(16, 16, 480, 40);
  FInfoLabel.AutoSize := False;
  FInfoLabel.WordWrap := True;
  FInfoLabel.Font.Color := CLR_TEXT;
  FInfoLabel.Caption :=
    'Creating FFTW wisdom for WDSP. This is done once and may take a while on the first run.';

  FProgress := TProgressBar.Create(Self);
  FProgress.Parent := Self;
  FProgress.SetBounds(16, 72, 480, 22);
  FProgress.Min := 0;
  FProgress.Max := 100;
  FProgress.Position := 0;

  FTimer := TTimer.Create(Self);
  FTimer.Interval := 250;
  FTimer.OnTimer  := TimerTick;
  FTimer.Enabled  := True;
end;

procedure TWisdomProgressDialog.TimerTick(Sender: TObject);
var
  P: PAnsiChar;
  S: string;
begin
  FPhase := (FPhase + 7) mod 101;
  FProgress.Position := FPhase;

  if Assigned(@wisdom_get_status) then
  begin
    P := wisdom_get_status;
    if P <> nil then
    begin
      S := Trim(string(AnsiString(P)));
      if S <> '' then
        FInfoLabel.Caption := S;
    end;
  end;

  if Assigned(FThread) and FThread.Finished then
  begin
    FTimer.Enabled := False;
    ModalResult := mrOk;
  end;
end;

// ===========================================================================
// Общая функция декодирования типа платы — используется везде
// ===========================================================================

function BoardTypeName(BoardType: Integer): string;
begin
  case BoardType of
    1:  Result := 'HERMES (ANAN-10/100)';
    2:  Result := 'HERMES-E (ANAN-10E/100B)';
    3:  Result := 'ANGELIA (ANAN-100D)';
    4:  Result := 'ORION (ANAN-200D)';
    5:  Result := 'ORION MkII (ANAN-7000/8000)';
    6:  Result := 'HERMES-LITE 2';
    10: Result := 'SATURN (G2)';
    else Result := Format('Unknown Board #%d', [BoardType]);
  end;
end;

// ===========================================================================
// Sync helpers
// ===========================================================================

constructor TDeviceFoundSync.Create(AForm: TObject;
  const D: THPSDRDevice; const E: string);
begin
  inherited Create;
  FForm  := AForm;
  FDev   := D;
  FEntry := E;
end;

procedure TDeviceFoundSync.Execute;
begin
  TMainForm(FForm).DoAddDevice(FDev, FEntry);
end;

constructor TStatusUISync.Create(AForm: TObject;
  FW, SW, SV: Double; PLL, HWPTT: Boolean);
begin
  inherited Create;
  FForm    := AForm;
  FFwdW    := FW;
  FSWRV    := SW;
  FSupplyV := SV;
  FPLLLock := PLL;
  FHWPTT   := HWPTT;
end;

procedure TStatusUISync.Execute;
begin
  TMainForm(FForm).DoUpdateStatus(FFwdW, FSWRV, FSupplyV, FPLLLock, FHWPTT);
end;

constructor TDDCSeqSync.Create(AForm: TObject; Idx: Integer; Seq: LongWord);
begin
  inherited Create;
  FForm   := AForm;
  FDDCIdx := Idx;
  FSeq    := Seq;
end;

procedure TDDCSeqSync.Execute;
begin
  TMainForm(FForm).DoUpdateDDCSeqOrNoDevice(FDDCIdx, FSeq);
end;

// ===========================================================================
// FormCreate / FormDestroy
// ===========================================================================


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

procedure TMainForm.RestoreWindowBounds;
var
  L, T, Wd, Ht: Integer;
begin
  FSettings.LoadWindowBounds(L, T, Wd, Ht);
  if (Wd > 400) and (Ht > 300) then
  begin
    Left   := L;
    Top    := T;
    Width  := Wd;
    Height := Ht;
  end;
end;

function TMainForm.MakeBandSettings: TBandSettings;
begin
  Result.VfoA      := FVfoA;
  Result.VfoB      := FVfoB;
  Result.Mode      := FMode;
  Result.FilterIdx := FFilter;
  Result.FilterBW  := FFilterBW;
  Result.AGCMode   := FAGCMode;
  Result.AGCTop    := FAGCTop;
  Result.CTun      := FCTun;
  Result.SpanHz    := FSpanHz;
  // Waterfall AGC/NF — глобальные (не диапазонные)
end;

function TMainForm.MakeGlobalSettings: TGlobalSettings;
var i: Integer;
begin
  Result.Volume           := FVolume;
  Result.DriveLevel       := TrkDrive.Position;
  Result.PAMaxPower       := FPAMaxPower;
  for i := 0 to BAND_COUNT - 1 do
    Result.PABandCal[i]   := FPABandCal[i];
  Result.ActiveVfo        := FActiveVfo;
  Result.NRMode           := BtnNR.Tag;
  Result.NBMode           := BtnNB.Tag;
  Result.SNBEnabled       := BtnSNB.Tag <> 0;
  Result.ANFEnabled       := BtnANF.Tag <> 0;
  Result.AGCSlope         := 0;
  Result.AGCHangThreshold := 100;
  Result.WfAGCEnabled     := FWfAGCEnabled;
  Result.WfNFEnabled      := FWfNFEnabled;
  Result.LastBand         := FCurrentBand;
  Result.SampleRate       := FSampleRate;
  // Display settings
  if FWDSPReady then
  begin
    Result.FFTSize      := FDSPEngine.FFTSize;
    Result.WindowType   := FDSPEngine.WindowType;
    Result.SpecDetector := FDSPEngine.SpecDetector;
    Result.SpecAvgMode  := FDSPEngine.SpecAvgMode;
    Result.SpecAvgTimeMS := FDSPEngine.SpecAvgTimeMS;
    Result.WfDetector   := FDSPEngine.WfDetector;
    Result.WfAvgMode    := FDSPEngine.WfAvgMode;
    Result.WfAvgTimeMS  := FDSPEngine.WfAvgTimeMS;
  end
  else
  begin
    Result.FFTSize      := 131072;
    Result.WindowType   := 2;
    Result.SpecDetector := 0;
    Result.SpecAvgMode  := 3;
    Result.SpecAvgTimeMS := 30.0;
    Result.WfDetector   := 0;
    Result.WfAvgMode    := 3;
    Result.WfAvgTimeMS  := 120.0;
  end;
  Result.WfManualHigh   := FWfManualHigh;
  Result.WfManualLow    := FWfManualLow;
  Result.WfAGCOffset    := FWfAGCOffset;
  Result.SpecRefLevel    := FSpecRefLevel;
  Result.SpecRange       := FSpecRange;
  Result.SpecGridStep    := FSpecGridStep;
  Result.AudioSampleRate := FAudioOut.SampleRate;
  Result.ShowSpectrum    := FShowSpectrum;
  Result.ShowWaterfall   := FShowWaterfall;
  Result.DisplayFPS      := FDisplayFPS;
  // Audio device names
  Result.AudioOutDevice := FAudioOutDevName;
  Result.AudioInDevice  := FAudioInDevName;
  // 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 FDevConnected then Exit;
  FBandCache[FCurrentBand] := MakeBandSettings;
  FSettings.SaveBand(FDevMAC, FCurrentBand, FBandCache[FCurrentBand]);
end;

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

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

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

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

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

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

  // --- Sample Rate / Span ---
  // SampleRate — глобальный, не per-band. Не восстанавливаем из диапазона.
  // Обновляем только FSpanHz для отображения (берём текущий FSampleRate)
  FSpanHz := FSampleRate;
  StyleSpanButton(BtnSpan48k,   FSampleRate =   48000);
  StyleSpanButton(BtnSpan96k,   FSampleRate =   96000);
  StyleSpanButton(BtnSpan192k,  FSampleRate =  192000);
  StyleSpanButton(BtnSpan384k,  FSampleRate =  384000);
  StyleSpanButton(BtnSpan768k,  FSampleRate =  768000);
  StyleSpanButton(BtnSpan1536k, FSampleRate = 1536000);

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

  // ApplyVfoA обновит сеть, FreqDisp, shift, перерисует
  ApplyVfoA(Round(B.VfoA));
end;

procedure TMainForm.SaveAllAndExit;
begin
  if FDevConnected then
  begin
    SaveCurrentBand;
    FSettings.SaveGlobal(FDevMAC, MakeGlobalSettings);
    FSettings.Save;
  end;
end;

procedure TMainForm.FormCreate(Sender: TObject);
var
  bi_, StartupRate, i: Integer;
  StartupVfoA, StartupVfoB: Double;
begin
  FVfoA      := 14200000;
  FVfoB      := 7100000;
  FActiveVfo := 0;
  FMode      := 1;
  FFilter    := 5;
  FAGCMode   := 1;   // MEDIUM
  FAGCTop    := 90;  // −90 dBm
   // default: зависит от режима, будет сброшен в UpdateFilterButtons
  FCTun      := False;
  FFilterBW  := 2700;
  FSpecDrag  := False;
  FDriveLevel    := 0;
  FPAMaxPower    := 100.0;
  for i := 0 to BAND_COUNT - 1 do FPABandCal[i] := 100.0;
  FRunning       := False;
  FTransmitting  := False;
  FMuted     := False;
  FVolume    := 70;
  FCenterFreq    := FVfoA;
  FSpanHz        := 192000;
  FSampleRate    := 192000;
  FCurrentBand   := 5;  // 20m по умолчанию
  // Waterfall normalization
  FWfManualHigh := -80.0;
  FWfManualLow  := -130.0;
  FWfAGCOffset  := 0.0;
  FWfAGCEnabled := True;
  FWfNFEnabled  := False;
  FSpectrumBufCount := 1024;
  FWaterfallBufCount := 1024;
  FWaterfallFrameInterval := 2;
  FWaterfallFrameCounter := 0;
  FDevConnected  := False;
  // Spectrum grid settings defaults
  FSpecRefLevel  := -20.0;
  FSpecRange     := 110.0;
  FSpecGridStep  := 10.0;
  FSettingsForm  := nil;
  FillChar(FDevMAC, SizeOf(FDevMAC), 0);

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

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

  // Создаём веб-сервер (логин: admin, пароль: hpsdr — менять здесь)
  FWebServer := TWebServer.Create('admin', 'hpsdr');
  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.Start;
  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;
  FSettings.LoadCATSettings(FCATLastGlobal);
  InitCATEngine;
  CATApplySettings(FCATLastGlobal);
  FSMeterPeak    := -130;
  FSMeterMin     := -130;
  FSMeterAvg     := -130;
  FRXPacketCount := 0;
  FActiveDDC     := 0;
  FLastDDCSeq    := 0;
  FLastDDCIndex  := 0;
  FLastFwdW   := 0;
  FLastSWR    := 1;
  FDeviceCount  := 0;
  FDeviceDialog := TDeviceDialog.Create(Self);
  FDeviceDialog.OnDiscover := BtnDiscoverFromDialog;
  FVfoOverlay := TVfoOverlay.Create(Self);
  FVfoOverlay.OnSelect := OnModeFilterSelect;
  FVfoOverlay.Visible := False;
  FPanelHidden  := False;
  FShowSpectrum  := True;
  FShowWaterfall := True;
  FDisplayFPS    := 60;
  FSplitterRatio := 0.40;
  FSplitterDrag  := False;

  FSpecView := TSpectrumView.Create;
  FSpecView.VfoA           := FVfoA;
  FSpecView.VfoB           := FVfoB;
  FSpecView.ActiveVfo      := FActiveVfo;
  FSpecView.CenterFreq     := FCenterFreq;
  FSpecView.SpanHz         := FSpanHz;
  FSpecView.Mode           := FMode;
  FSpecView.FilterBW       := FFilterBW;
  FSpecView.AGCTop         := FAGCTop;
  FSpecView.WfAGCEnabled   := FWfAGCEnabled;
  FSpecView.WfNFEnabled    := FWfNFEnabled;
  FSpecView.WfManualHigh   := FWfManualHigh;
  FSpecView.WfManualLow    := FWfManualLow;
  FSpecView.WfAGCOffset    := FWfAGCOffset;
  FSpecView.SpecRefLevel   := FSpecRefLevel;
  FSpecView.SpecRange      := FSpecRange;
  FSpecView.SpecGridStep   := FSpecGridStep;
  FSpecView.WfFrameInterval := FWaterfallFrameInterval;

  FNetwork := THPSDRNetwork.Create;
  FNetwork.OnDeviceFound := OnDeviceFound;
  FNetwork.OnHPStatus    := OnHPStatusCB;
  FNetwork.OnDDCIQ       := OnDDCIQCB;
  FNetwork.OnMicPacket   := OnMicPacketCB;

  BuildUI;
  StyleSpanButton(BtnSpan48k,   FSampleRate =   48000);
  StyleSpanButton(BtnSpan96k,   FSampleRate =   96000);
  StyleSpanButton(BtnSpan192k,  FSampleRate =  192000);
  StyleSpanButton(BtnSpan384k,  FSampleRate =  384000);
  StyleSpanButton(BtnSpan768k,  FSampleRate =  768000);
  StyleSpanButton(BtnSpan1536k, FSampleRate = 1536000);
  ResizeSMeter;
  ApplyDarkTheme;
  UpdateVfoDisplay;
  UpdateFilterButtons;

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

  // Audio output/input — создаём объекты сейчас, открываем после показа формы
  // (Pa_Initialize на Linux пишет в stderr до перехвата сигналов FPC)
  FAudioOut := TAudioOutput.Create(48000);
  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 FNetwork.Running then
    FNetwork.SetRunAndFreq(False, FCenterFreq, FCenterFreq, 0);
  FNetwork.Disconnect;
  FNetwork.Free;
  FAudioOut.Close;
  FAudioOut.Free;
  FAudioIn.Close;
  FAudioIn.Free;
  FDSPEngine.Close;
  FDSPEngine.Free;
  FreeAndNil(FSpecView);

  // Сохраняем настройки при закрытии
  if FDevConnected then
  begin
    SaveCurrentBand;
    FSettings.SaveGlobal(FDevMAC, MakeGlobalSettings);
  end;
  // Размер окна сохраняем всегда (не зависит от подключения)
  FSettings.SaveStartupPreview(FVfoA, FVfoB, FSampleRate);
  FSettings.SaveWindowBounds(Left, Top, Width, Height);
  FSettings.Save;
  FSettings.Free;
  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);
end;

procedure TMainForm.FormClose(Sender: TObject; var CloseAction: TCloseAction);
begin
  // Останавливаем трансивер как при нажатии STOP
  if FNetwork.Connected then
  begin
    if FRunning then
    begin
      FNetwork.UpdateState(FCenterFreq, FCenterFreq, 0, False, True, True);
      FNetwork.SetRunAndFreq(False, FCenterFreq, FCenterFreq, 0);
      FRunning      := False;
      FTransmitting := False;
      if FWDSPReady then FDSPEngine.SetTXRun(False);
    end;
    FNetwork.Disconnect;
  end;
  CloseAction := caFree;
end;

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

procedure TMainForm.BuildUI;
const
  BTN_H  = 26;
  BTN_SM = 24;
  LEFT_W = 232;
var
  i, X, Y, W: Integer;
  B: TFlatButton;

  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     := 36;
  PanelToolbar.BevelOuter := bvNone;

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

  // ---- Status bar ----
  StatusBar1 := TStatusBar.Create(Self);
  StatusBar1.Parent      := Self;
  StatusBar1.Align       := alBottom;
  StatusBar1.SimplePanel := False;
  with StatusBar1.Panels do
  begin
    Add; Items[0].Width := 150; Items[0].Text := 'IP: --';
    Add; Items[1].Width := 120; Items[1].Text := 'Board: --';
    Add; Items[2].Width := 320; Items[2].Text := 'Supply: -- | FWD: -- | SWR: --';
    Add; Items[3].Width := 250; Items[3].Text := 'RX: waiting...';
    Add; Items[4].Width := 200; Items[4].Text := 'Not connected';
  end;

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

  Y := 2;

  // VFO A
  PanelVfoA := TPanel.Create(Self);
  PanelVfoA.Parent := PanelLeft;
  PanelVfoA.SetBounds(0, Y, LEFT_W, 56);
  PanelVfoA.BevelOuter := bvNone;

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

  FreqDispA := TFreqDisplay.Create(Self);
  FreqDispA.Parent      := PanelVfoA;
  FreqDispA.SetBounds(0, 14, LEFT_W, 36);
  FreqDispA.FontSize    := 20;
  FreqDispA.FontName    := 'Courier New';
  FreqDispA.Frequency   := Round(FVfoA);
  FreqDispA.ColorNormal := CLR_FREQ;
  FreqDispA.ColorHover  := TColor($0040DDFF);
  FreqDispA.ColorDim    := CLR_TEXTDIM;
  FreqDispA.OnChange    := FreqDispAChanged;
  FreqDispA.OnClick     := FreqDispAClick;

  Inc(Y, 58);

  // VFO B
  PanelVfoB := TPanel.Create(Self);
  PanelVfoB.Parent := PanelLeft;
  PanelVfoB.SetBounds(0, Y, LEFT_W, 46);
  PanelVfoB.BevelOuter := bvNone;

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

  FreqDispB := TFreqDisplay.Create(Self);
  FreqDispB.Parent      := PanelVfoB;
  FreqDispB.SetBounds(0, 14, LEFT_W, 28);
  FreqDispB.FontSize    := 14;
  FreqDispB.FontName    := 'Courier New';
  FreqDispB.Frequency   := Round(FVfoB);
  FreqDispB.ColorNormal := CLR_FREQ_DIM;
  FreqDispB.ColorHover  := TColor($0040DDFF);
  FreqDispB.ColorDim    := CLR_TEXTDIM;
  FreqDispB.OnChange    := FreqDispBChanged;
  FreqDispB.OnClick     := FreqDispBClick;

  Inc(Y, 50);

  // VFO buttons
  PanelVfoButtons := TPanel.Create(Self);
  PanelVfoButtons.Parent := PanelLeft;
  PanelVfoButtons.SetBounds(0, Y, LEFT_W, BTN_H + 4);
  PanelVfoButtons.BevelOuter := bvNone;

  BtnVfoSwap   := MakeBtn(PanelVfoButtons, 'A<>B',  2, 2, 72, BTN_H, BtnVfoSwapClick);
  BtnVfoACopyB := MakeBtn(PanelVfoButtons, 'A>B',  78, 2, 72, BTN_H, BtnVfoACopyBClick);
  BtnVfoBCopyA := MakeBtn(PanelVfoButtons, 'B>A', 154, 2, 72, BTN_H, BtnVfoBCopyAClick);

  Inc(Y, BTN_H + 8);

  // Band selector
  PanelBands := TPanel.Create(Self);
  PanelBands.Parent := PanelLeft;
  PanelBands.SetBounds(0, Y, LEFT_W, 74);
  PanelBands.BevelOuter := bvNone;
  MakeLbl(PanelBands, 'BAND', 4, 2);
  W := (LEFT_W - 6) div 6;
  for i := 0 to BAND_COUNT - 1 do
  begin
    B := MakeBtn(PanelBands, BAND_NAMES[i],
                 2 + (i mod 6) * W,
                 16 + (i div 6) * 27,
                 W - 2, BTN_SM, BtnBandClick);
    B.Tag := i;
    BtnBand[i] := B;
  end;

  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);
  W := (LEFT_W - 6) div 4;
  for i := 0 to MODE_COUNT - 1 do
  begin
    B := MakeBtn(PanelMode, MODE_NAMES[i],
                 2 + (i mod 4) * W,
                 16 + (i div 4) * 27,
                 W - 2, BTN_SM, BtnModeClick);
    B.Tag := i;
    BtnMode[i] := B;
    StyleButton(B, i = 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);
  W := (LEFT_W - 6) div 5;
  for i := 0 to FILT_COUNT - 1 do
  begin
    B := MakeBtn(PanelFilter, '---',
                 2 + (i mod 5) * W,
                 16 + (i div 5) * 27,
                 W - 2, BTN_SM, BtnFilterClick);
    B.Tag := i;
    BtnFilter[i] := B;
  end;

  Inc(Y, 76);

  // CTUN button
  BtnCTun := MakeBtn(PanelLeft, 'CTUN', 2, Y, LEFT_W div 3 - 2, BTN_H, BtnCTunClick);
  BtnCTun.Tag := 0;
  StyleButton(BtnCTun, FCTun);

  Inc(Y, BTN_H + 4);

  // RX controls
  PanelRX := TPanel.Create(Self);
  PanelRX.Parent := PanelLeft;
  PanelRX.SetBounds(0, Y, LEFT_W, 150);
  PanelRX.BevelOuter := bvNone;

  // AGC mode — 5 кнопок в ряд
  MakeLbl(PanelRX, 'AGC', 4, 4);
  W := (LEFT_W - 10) div 5;
  BtnAGCMode[0] := MakeBtn(PanelRX,'FAST',  2,       22, W, BTN_SM, BtnAGCModeClick); BtnAGCMode[0].Tag:=0;
  BtnAGCMode[1] := MakeBtn(PanelRX,'MED',   W+4,     22, W, BTN_SM, BtnAGCModeClick); BtnAGCMode[1].Tag:=1;
  BtnAGCMode[2] := MakeBtn(PanelRX,'SLOW',  2*W+6,   22, W, BTN_SM, BtnAGCModeClick); BtnAGCMode[2].Tag:=2;
  BtnAGCMode[3] := MakeBtn(PanelRX,'LONG',  3*W+8,   22, W, BTN_SM, BtnAGCModeClick); BtnAGCMode[3].Tag:=3;
  BtnAGCMode[4] := MakeBtn(PanelRX,'OFF',   4*W+10,  22, W, BTN_SM, BtnAGCModeClick); BtnAGCMode[4].Tag:=4;
  for i := 0 to 4 do StyleButton(BtnAGCMode[i], i = FAGCMode);

  // AGC level slider
  MakeLbl(PanelRX, 'THRESH', 4, 50);
  TrkAGC := TTrackBar.Create(Self);
  TrkAGC.Parent := PanelRX; TrkAGC.Left := 56; TrkAGC.Top := 46;
  TrkAGC.Width := LEFT_W - 96; TrkAGC.Height := 20;
  TrkAGC.Min := 20; TrkAGC.Max := 120;   // 20..120 → −20..-120 dBm
  TrkAGC.Position := FAGCTop;
  TrkAGC.TickStyle := tsNone; TrkAGC.Reversed := True;
  TrkAGC.OnChange := TrkAGCChange;

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

  // VOL
  MakeLbl(PanelRX, 'VOL', 4, 74);
  TrkVolume := TTrackBar.Create(Self);
  TrkVolume.Parent := PanelRX; TrkVolume.Left := 34; TrkVolume.Top := 70;
  TrkVolume.Width := LEFT_W - 38; TrkVolume.Height := 20;
  TrkVolume.Min := 0; TrkVolume.Max := 100;
  TrkVolume.Position := FVolume; TrkVolume.TickStyle := tsNone;
  TrkVolume.OnChange := TrkVolumeChange;

  // NR NB SNB ANF MUTE
  W := (LEFT_W - 10) div 5;
  BtnNR  := MakeBtn(PanelRX, 'NR',    2,  96, W, BTN_SM, BtnNRClick);
  BtnNB  := MakeBtn(PanelRX, 'NB',   W+4,    96, W, BTN_SM, BtnNBClick);
  BtnSNB := MakeBtn(PanelRX, 'SNB',  2*W+6,  96, W, BTN_SM, BtnSNBClick);
  BtnANF := MakeBtn(PanelRX, 'ANF',  3*W+8,  96, W, BTN_SM, BtnANFClick);
  BtnMute:= MakeBtn(PanelRX, 'MUTE', 4*W+10, 96, W, BTN_SM, BtnMuteClick);
  BtnWfAGC := MakeBtn(PanelRX, 'WF AGC', 2, 122, (LEFT_W - 6) div 2, BTN_SM, BtnWfAGCClick);
  BtnWfNF  := MakeBtn(PanelRX, 'WF NF',  4 + (LEFT_W - 6) div 2, 122, (LEFT_W - 6) div 2, BTN_SM, BtnWfNFClick);

  Inc(Y, 154);

  // S-Meter

  Inc(Y, 52);

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

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

  TrkDrive := TTrackBar.Create(Self);
  TrkDrive.Parent := PanelTX; TrkDrive.Left := 34; TrkDrive.Top := 14;
  TrkDrive.Width := 150; TrkDrive.Height := 22;
  TrkDrive.Min := 0; TrkDrive.Max := 100; TrkDrive.Position := 50;
  TrkDrive.TickStyle := tsNone; TrkDrive.OnChange := TrkDriveChange;

  BtnMOX := MakeBtn(PanelTX, 'MOX', 2, 38, 70, 32, BtnMOXClick);
  BtnMOX.Font.Size := 12; BtnMOX.Font.Bold := True;

  LblFwdPwr := TLabel.Create(Self);
  LblFwdPwr.Parent := PanelTX; LblFwdPwr.Left := 80; LblFwdPwr.Top := 42;
  LblFwdPwr.Caption := '0W'; LblFwdPwr.Font.Color := CLR_METER_ON;
  LblFwdPwr.Font.Name := 'Courier New'; LblFwdPwr.Font.Size := 8;

  LblSWRVal := TLabel.Create(Self);
  LblSWRVal.Parent := PanelTX; LblSWRVal.Left := 140; LblSWRVal.Top := 42;
  LblSWRVal.Caption := 'SWR:1.0'; LblSWRVal.Font.Color := CLR_TEXTDIM;
  LblSWRVal.Font.Name := 'Courier New'; LblSWRVal.Font.Size := 8;

  MakeLbl(PanelTX, 'PWR', 4, 76);
  PbFwdPower := TPaintBox.Create(Self);
  PbFwdPower.Parent := PanelTX;
  PbFwdPower.SetBounds(34, 74, LEFT_W - 38, 12);
  PbFwdPower.OnPaint := FSpecView.PaintFwdPower;

  MakeLbl(PanelTX, 'SWR', 4, 92);
  PbSWR := TPaintBox.Create(Self);
  PbSWR.Parent := PanelTX;
  PbSWR.SetBounds(34, 90, LEFT_W - 38, 12);
  PbSWR.OnPaint := FSpecView.PaintSWR;

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

  PanelSpanButtons := TPanel.Create(Self);
  PanelSpanButtons.Parent     := PanelRight;
  PanelSpanButtons.Align      := alTop;
  PanelSpanButtons.Height     := 30;
  PanelSpanButtons.BevelOuter := bvNone;

  LblSpan := TLabel.Create(Self);
  LblSpan.Parent := PanelSpanButtons; LblSpan.Left := 4; LblSpan.Top := 8;
  LblSpan.Caption := 'SPAN:'; LblSpan.Font.Color := CLR_TEXTDIM;
  LblSpan.Font.Name := 'Courier New'; LblSpan.Font.Size := 7;

  BtnSpan48k   := MakeBtn(PanelSpanButtons, '48k',    50, 3, 46, BTN_SM, BtnSpanClick);
  BtnSpan96k   := MakeBtn(PanelSpanButtons, '96k',    99, 3, 46, BTN_SM, BtnSpanClick);
  BtnSpan192k  := MakeBtn(PanelSpanButtons, '192k',  148, 3, 52, BTN_SM, BtnSpanClick);
  BtnSpan384k  := MakeBtn(PanelSpanButtons, '384k',  203, 3, 52, BTN_SM, BtnSpanClick);
  BtnSpan768k  := MakeBtn(PanelSpanButtons, '768k',  258, 3, 52, BTN_SM, BtnSpanClick);
  BtnSpan1536k := MakeBtn(PanelSpanButtons, '1536k', 313, 3, 58, BTN_SM, BtnSpanClick);
  BtnSpan48k.Tag   := 48000;
  BtnSpan96k.Tag   := 96000;
  BtnSpan192k.Tag  := 192000;
  BtnSpan384k.Tag  := 384000;
  BtnSpan768k.Tag  := 768000;
  BtnSpan1536k.Tag := 1536000;
  StyleSpanButton(BtnSpan48k,   False);
  StyleSpanButton(BtnSpan96k,   False);
  StyleSpanButton(BtnSpan192k,  True);
  StyleSpanButton(BtnSpan384k,  False);
  StyleSpanButton(BtnSpan768k,  False);
  StyleSpanButton(BtnSpan1536k, False);

  BtnHidePanel := MakeBtn(PanelRight, '◀', 0, 0, 28, 28, BtnHidePanelClick);
  BtnHidePanel.Font.Size := 10;
  BtnHidePanel.ClrNorm := TColor($00543F18);
  BtnHidePanel.ClrHot := TColor($00624B1D);
  BtnHidePanel.ClrActive := TColor($00624B1D);
  BtnHidePanel.ClrBorder := TColor($00685624);
  BtnHidePanel.ClrText := TColor($00D8E6EE);
  BtnHidePanel.ClrTextAct := TColor($00F0FAFF);
  BtnHidePanel.BringToFront;

  // Большой S-метр — на главной форме поверх двух панелей
  // S-метр: Parent=Self, перекрывает Toolbar+SpanButtons вертикально
  PanelSMeterRight := TPanel.Create(Self);
  PanelSMeterRight.Parent     := Self;
  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;

  PbSpectrum := TPaintBox.Create(Self);
  PbSpectrum.Parent      := PanelRight;
  PbSpectrum.OnPaint     := FSpecView.PaintSpectrum;
  PbSpectrum.OnMouseDown := PbSpectrumMouseDown;
  PbSpectrum.OnDblClick  := PbSpectrumDblClick;
  PbSpectrum.OnMouseMove := PbSpectrumMouseMove;
  PbSpectrum.OnMouseUp   := PbSpectrumMouseUp;

  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;

  PbWaterfall := TPaintBox.Create(Self);
  PbWaterfall.Parent     := PanelRight;
  PbWaterfall.OnPaint    := FSpecView.PaintWaterfall;
  PbWaterfall.OnMouseDown := PbWaterfallMouseDown;
  PbWaterfall.OnMouseMove := PbWaterfallMouseMove;
  PbWaterfall.OnMouseUp   := PbWaterfallMouseUp;

  // SPAN кнопки как оверлей на спектре (в стиле web)
  BtnSpan48k.Parent   := PanelRight;
  BtnSpan96k.Parent   := PanelRight;
  BtnSpan192k.Parent  := PanelRight;
  BtnSpan384k.Parent  := PanelRight;
  BtnSpan768k.Parent  := PanelRight;
  BtnSpan1536k.Parent := PanelRight;
  LblSpan.Visible := False;
  PositionSpanOverlayButtons;

  // 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;
const
  RULER_H    = 18;
  SPLITTER_H = 5;
  MIN_SH     = 60;
  MIN_WH     = 40;
begin
  if not FSplitterDrag then Exit;
  P := PanelRight.ScreenToClient(PanelSplitter.ClientToScreen(Point(X, Y)));
  DeltaY := P.Y - FSplitterDragY0;

  TopOff := PanelSpanButtons.Height;
  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;
const
  RULER_H    = 18;
  SPLITTER_H = 5;
  MIN_SH     = 60;
  MIN_WH     = 40;
begin
  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 := PanelSpanButtons.Height;
  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.ResizeSMeter;
// PanelSMeterRight на главной форме (Parent=Self).
// По Y: от самого верха формы до низа PanelSpanButtons (Toolbar+Span = 66px).
// По X: 40% ширины PanelRight, прижат к правому краю.
const
  MARGIN = 3;
var
  FW, FH:       Integer;
  RightW:       Integer;   // ширина PanelRight
  SMW:          Integer;   // ширина S-метра
  SMLeft:       Integer;
  SMTop:        Integer;
  SMBot:        Integer;
begin
  if (PanelSMeterRight = nil) or (PanelToolbar = nil) or
     (PanelSpanButtons = nil) or (PanelLeft = nil) then Exit;

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

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

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

  // Y: от верха формы+margin до низа PanelSpanButtons-margin
  SMTop := MARGIN;
  SMBot := PanelToolbar.Height + PanelSpanButtons.Height - MARGIN;

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

procedure TMainForm.ResizeSpectrumPanels;
const
  RULER_H    = 18;
  SPLITTER_H = 5;
  MIN_SH     = 60;   // минимальная высота спектра
  MIN_WH     = 40;   // минимальная высота водопада
var
  RW, RH, SH, WH, TopOff: Integer;
  AvailH: Integer;
begin
  if PanelRight = nil then Exit;
  if Assigned(FSpecView) then SyncSpecViewFreq;
  RW     := PanelRight.ClientWidth;
  RH     := PanelRight.ClientHeight;
  TopOff := PanelSpanButtons.Height;
  // 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;
    PositionSpanOverlayButtons;
    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;
    PositionSpanOverlayButtons;
    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;
    PositionSpanOverlayButtons;
    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);

  PositionSpanOverlayButtons;

  // Обновляем переменные размеров
  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;
  procedure DP(P: TPanel);
  begin
    P.Color := CLR_PANEL; P.Font.Color := CLR_TEXT;
  end;
begin
  Color := CLR_BG; Font.Color := CLR_TEXT;
  DP(PanelToolbar); DP(PanelLeft);
  DP(PanelVfoA); DP(PanelVfoB); DP(PanelVfoButtons);
  DP(PanelBands); DP(PanelMode); DP(PanelFilter);
  DP(PanelRX); DP(PanelTX);
  DP(PanelRight); DP(PanelSpanButtons);


  PbSpectrum.Color  := CLR_BG;
  PbWaterfall.Color := CLR_BG;
  if PanelSMeterRight <> nil then PanelSMeterRight.Color := CLR_PANEL;
  if PbSMeterRight <> nil then PbSMeterRight.Color := CLR_PANEL;
  PbFwdPower.Color  := CLR_BG;
  PbSWR.Color       := CLR_BG;
end;

procedure TMainForm.StyleButton(B: TFlatButton; Active: Boolean);
begin
  B.Active     := Active;
  B.ClrNorm    := CLR_INACTIVE;
  B.ClrActive  := CLR_ACTIVE;
  B.ClrHot     := TColor($00323232);
  B.ClrBorder  := TColor($00505050);
  B.ClrText    := CLR_TEXT;
  B.ClrTextAct := TColor($0000FF88);
  B.Font.Name  := 'Courier New';
  B.Font.Size  := 8;
end;

procedure TMainForm.StyleSpanButton(B: TFlatButton; Active: Boolean);
begin
  B.Active     := Active;
  B.ClrNorm    := TColor($00543F18);
  B.ClrActive  := TColor($00624B1D);
  B.ClrHot     := TColor($00624B1D);
  B.ClrBorder  := TColor($00685624);
  B.ClrText    := TColor($00D8E6EE);
  B.ClrTextAct := TColor($00F0FAFF);
  B.Font.Name  := 'Courier New';
  B.Font.Size  := 7;
end;

procedure TMainForm.PositionSpanOverlayButtons;
const
  BTN_TOP = 6;
  GAP     = 3;
  W48     = 40;
  W96     = 40;
  W192    = 44;
  W384    = 44;
  W768    = 44;
  W1536   = 50;
  HBTN    = 18;
var
  X, NeedW, BaseX, BaseY: Integer;
begin
  if (PbSpectrum = nil) or (BtnSpan48k = nil) then Exit;
  NeedW := W48 + W96 + W192 + W384 + W768 + W1536 + GAP * 5 + 8;
  if PbSpectrum.Width < NeedW then
  begin
    BtnSpan48k.Visible := False;
    BtnSpan96k.Visible := False;
    BtnSpan192k.Visible := False;
    BtnSpan384k.Visible := False;
    BtnSpan768k.Visible := False;
    BtnSpan1536k.Visible := False;
    Exit;
  end;

  BaseX := PbSpectrum.Left;
  BaseY := PbSpectrum.Top;
  X := BaseX + 56;
  BtnSpan48k.SetBounds(X, BaseY + BTN_TOP, W48, HBTN);   Inc(X, W48 + GAP);
  BtnSpan96k.SetBounds(X, BaseY + BTN_TOP, W96, HBTN);   Inc(X, W96 + GAP);
  BtnSpan192k.SetBounds(X, BaseY + BTN_TOP, W192, HBTN); Inc(X, W192 + GAP);
  BtnSpan384k.SetBounds(X, BaseY + BTN_TOP, W384, HBTN); Inc(X, W384 + GAP);
  BtnSpan768k.SetBounds(X, BaseY + BTN_TOP, W768, HBTN); Inc(X, W768 + GAP);
  BtnSpan1536k.SetBounds(X, BaseY + BTN_TOP, W1536, HBTN);
  if BtnHidePanel <> nil then
    BtnHidePanel.SetBounds(BaseX, BaseY + BTN_TOP, 28, HBTN);

  BtnSpan48k.Visible := True;
  BtnSpan96k.Visible := True;
  BtnSpan192k.Visible := True;
  BtnSpan384k.Visible := True;
  BtnSpan768k.Visible := True;
  BtnSpan1536k.Visible := True;

  BtnSpan48k.BringToFront;
  BtnSpan96k.BringToFront;
  BtnSpan192k.BringToFront;
  BtnSpan384k.BringToFront;
  BtnSpan768k.BringToFront;
  BtnSpan1536k.BringToFront;
  if BtnHidePanel <> nil then BtnHidePanel.BringToFront;
end;

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

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

procedure TMainForm.UpdateVfoDisplay;
begin
  FreqDispA.Frequency := Round(FVfoA);
  FreqDispB.Frequency := Round(FVfoB);
  if FActiveVfo = 0 then
  begin
    // VFO-A активен: яркий зелёный, крупный
    FreqDispA.ColorNormal := CLR_FREQ;     FreqDispA.FontSize := 20;
    FreqDispB.ColorNormal := CLR_FREQ_DIM; FreqDispB.FontSize := 14;
    FreqDispA.Height := 36;
    FreqDispB.Height := 28;
    LblVfoALabel.Font.Color := CLR_FREQ;
    LblVfoBLabel.Font.Color := CLR_TEXTDIM;
  end else begin
    // VFO-B активен: яркий зелёный, крупный
    FreqDispA.ColorNormal := CLR_FREQ_DIM; FreqDispA.FontSize := 14;
    FreqDispB.ColorNormal := CLR_FREQ;     FreqDispB.FontSize := 20;
    FreqDispA.Height := 28;
    FreqDispB.Height := 36;
    LblVfoALabel.Font.Color := CLR_TEXTDIM;
    LblVfoBLabel.Font.Color := CLR_FREQ;
  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      := FVfoA;
  FSpecView.VfoB      := FVfoB;
  FSpecView.ActiveVfo := FActiveVfo;
  FSpecView.CenterFreq := FCenterFreq;
  FSpecView.SpanHz    := FSpanHz;
  FSpecView.Mode      := FMode;
  FSpecView.FilterBW  := FFilterBW;
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 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;

  if PbSMeterRight <> nil then PbSMeterRight.Invalidate;
end;

procedure TMainForm.SpectrumTimerTick(Sender: TObject);
var
  NowTick:     QWord;
  ElapsedTick: QWord;
begin
  // Синхронизируем размеры если размер панели изменился (после ресайза).
  // Важно: проверяем только видимые панели — когда спектр скрыт,
  // PbSpectrum.Height может быть ненулевым при FSpectrumHeight=0,
  // что вызвало бы сброс буфера водопада на каждом тике.
  if (FSpectrumWidth = 0) 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;
  ResizeSMeter;
  if FSpectrumWidth <= 0 then Exit;
  SyncSpecViewFreq;

  // Проверка таймаута — трансивер не отвечает
  if FRunning and FNetwork.Running then
  begin
    NowTick     := GetTickCount64;
    ElapsedTick := NowTick - FRXStartTime;
    // Ждём первый пакет 5 секунд после старта
    if (FRXLastPktTime = 0) and (ElapsedTick > 5000) then
    begin
      BtnStartStop.Caption := 'START';
      StyleButton(BtnStartStop, False);
      FRunning := False;
      FNetwork.SetRunAndFreq(False, FCenterFreq, FCenterFreq, 0);
      StatusBar1.Panels[4].Text := 'ТРАНСИВЕР НЕДОСТУПЕН';
      BtnMOX.Enabled := False;
      Exit;
    end;
    // После первого пакета — следим чтобы поток не прерывался более 3 сек
    if (FRXLastPktTime > 0) and ((NowTick - FRXLastPktTime) > 3000) then
    begin
      BtnStartStop.Caption := 'START';
      StyleButton(BtnStartStop, False);
      FRunning := False;
      FNetwork.SetRunAndFreq(False, FCenterFreq, FCenterFreq, 0);
      StatusBar1.Panels[4].Text := 'ПОТЕРЯ СВЯЗИ С ТРАНСИВЕРОМ';
      BtnMOX.Enabled := False;
      Exit;
    end;
  end;

  if FRunning then
  begin
    // Обновляем StatusBar из таймера — без Synchronize в сетевом потоке
    StatusBar1.Panels[3].Text :=
      Format('RX DDC%d | seq=%d | pkts=%d',
             [FLastDDCIndex, FLastDDCSeq, FRXPacketCount]);

    if FWDSPReady then
    begin
      FLastSMeter := FDSPEngine.GetSMeterDBm;
      FDSPEngine.SetSpectrumWidth(FSpectrumWidth);
      FDSPEngine.UpdateAGCLines(FSpectrumWidth);
      FSpecView.AGCThresh    := FDSPEngine.AGCThresh;
      FSpecView.AGCHangLevel := FDSPEngine.AGCHangLevel;
    end;
    FSpecView.WDSPReady := FWDSPReady;
    // Обновляем оверлей если видим
    if Assigned(FVfoOverlay) and FVfoOverlay.Visible then
    begin
      FVfoOverlay.UpdateSMeter(FLastSMeter);
      FVfoOverlay.UpdateVfo(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;
  end
  else
  begin
    // Не подключены — показываем чистый экран (без анимации)
    if not 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 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
    FWebServer.PushSpectrum(
      FSpectrumBuf, 1024,
      FWaterfallBuf,
      FLastSMeter,
      FVfoA, FMode, FFilterBW, FAGCMode, FAGCTop,
      FSpanHz, FVolume,
      FWfAGCEnabled, FWfNFEnabled,
      FCurrentBand,
      FRunning and FNetwork.Connected,
      FRunning, FMuted, FCTun,
      BtnNR.Tag, BtnNB.Tag, BtnSNB.Tag <> 0, BtnANF.Tag <> 0,
      FCenterFreq, FFilter,
      FVfoB, FActiveVfo,
      FTransmitting, TrkDrive.Position);
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(Self, 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);

    StatusBar1.Panels[4].Text := Format('Found: %s', [Dev.IPAddress]);
end;

procedure TMainForm.OnHPStatusCB(const Status: THighPriorityStatus);
var
  ExcPwr, FwdPwr, RevPwr: Word;
  SupplyV, FwdW, SWRV: Double;
  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 := ADCToSupplyVolts((Status.SupplyVoltsHi shl 8) or Status.SupplyVoltsLo);
  // FLastSMeter обновляется только из WDSP (GetSMeterDBm) в SpectrumTimerTick —
  // ExciterPwr это мощность TX, не уровень принятого сигнала.
  FwdW := ADCToWatts100(FwdPwr);
  if FwdW > 0 then
    SWRV := 1 + Sqrt(RevPwr / FwdPwr)
  else
    SWRV := 1.0;
  Sync := TStatusUISync.Create(Self, FwdW, SWRV, SupplyV,
            (Status.StatusBits and HPS_PLL_LOCKED) <> 0,
            (Status.StatusBits and HPS_PTT)         <> 0);
  try
    M := Sync.Execute;
    TThread.Synchronize(nil, M);
  finally
    Sync.Free;
  end;
end;

procedure TMainForm.DoUpdateStatus(FwdW, SWRV, SupplyV: Double; PLLLock, HWPTT: Boolean);
var
  PLLStr: string;
begin
  FLastFwdW := FwdW;
  FLastSWR  := SWRV;
  LblFwdPwr.Caption := Format('%.0fW', [FwdW]);
  LblSWRVal.Caption := Format('SWR:%.1f', [SWRV]);
  if PLLLock then PLLStr := 'PLL OK' else PLLStr := 'PLL?';
  StatusBar1.Panels[2].Text :=
    Format('Supply: %.1fV | FWD: %.0fW | SWR: %.1f | %s',
           [SupplyV, FwdW, SWRV, PLLStr]);

  // Hardware PTT (foot switch / mic PTT) — обнаружение фронта
  if HWPTT <> FHWPTTActive then
  begin
    FHWPTTActive := HWPTT;
    // Hardware PTT включает/выключает передачу только если BtnMOX не нажата
    if not BtnMOX.Active then
      ApplyMOX(HWPTT);
  end;
end;

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

  // Счётчик принятых пакетов
  Inc(FRXPacketCount);
  FRXLastPktTime := GetTickCount64;  // фиксируем время последнего пакета

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

  // 2. Запоминаем последний seq — StatusBar обновит таймер (без Synchronize в горячем пути)
  FLastDDCSeq   := (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]);
  FLastDDCIndex := DDCIndex;
end;

procedure TMainForm.DoUpdateDDCSeq(DDCIdx: Integer; Seq: LongWord);
begin
  StatusBar1.Panels[3].Text :=
    Format('RX DDC%d | seq=%d | pkts=%d', [DDCIdx, Seq, FRXPacketCount]);
end;

procedure TMainForm.OnMicPacketCB(const Data: TMicDataPacket);
begin
  if FTransmitting and FWDSPReady then
    FDSPEngine.PushTXMicSamples16(Data.Samples, 64);
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.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, -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;
    StatusBar1.Panels[4].Text := 'No hardware found';
    end
  else
    StatusBar1.Panels[3].Text := Format('DDC%d seq=%d', [DDCIdx, Seq]);
end;

procedure TMainForm.RecreateDSPEngine(ASampleRate: Integer);
begin
  if ASampleRate <= 0 then Exit;
  if Assigned(FDSPEngine) then
  begin
    FDSPEngine.Close;
    FreeAndNil(FDSPEngine);
  end;
  FWDSPReady := False;
  FDSPEngine := TWDSPEngine.Create(ASampleRate, 48000, 512);
  FDSPEngine.OnAudio     := OnAudioReady;
  FDSPEngine.OnSpectrum  := OnSpectrumReady;
  FDSPEngine.OnWaterfall := OnWaterfallReady;
  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  := IncludeTrailingPathDelimiter(ExtractFilePath(ParamStr(0)));
  WisdomPath := WisdomDir + WDSP_WISDOM_FILE;

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

  BtnStartStop.Enabled := False;
  StatusBar1.Panels[3].Text := 'Creating WDSP wisdom...';

  BuildThread := TWisdomBuildThread.Create(WisdomDir);
  try
    Dlg := TWisdomProgressDialog.Create(Self, BuildThread);
    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
    StatusBar1.Panels[3].Text := 'WDSP wisdom ready'
  else
    StatusBar1.Panels[3].Text := 'WDSP wisdom missing';
end;

procedure TMainForm.BtnDiscoverClick(Sender: TObject);
begin
  // Открываем диалог выбора устройства
  if not Assigned(FDeviceDialog) then
    FDeviceDialog := TDeviceDialog.Create(Self);
  FDeviceDialog.OnDiscover := BtnDiscoverFromDialog;
  FDeviceDialog.ShowModal;
  // Результат диалога не обрабатываем здесь — START сам спросит если нужно
end;

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

procedure TMainForm.BtnStartStopClick(Sender: TObject);
var
  Idx, i: Integer;
  Dev: THPSDRDevice;
  GenPkt: TGeneralPacket;
  DUCPkt: TDUCSpecificPacket;
  G_Settings: TGlobalSettings;
  PreG: TGlobalSettings;
  PreBands: array[0..CFG_BAND_COUNT-1] of TBandSettings;
  PreloadRate: Integer;
  FoundByIP: Boolean;
  AutoIP: string;
begin
  if FNetwork.Connected then
  begin
    // --- STOP ---
    if FRunning then
    begin
      FNetwork.UpdateState(FCenterFreq, FCenterFreq, 0, False, True, True);
      FNetwork.SetRunAndFreq(False, FCenterFreq, FCenterFreq, 0);
      FRunning := False;
      FTransmitting := False;
      if FWDSPReady then FDSPEngine.SetTXRun(False);
      StyleButton(BtnMOX, False);
    end;
    FRXPacketCount := 0;
    FActiveDDC     := 0;
    StatusBar1.Panels[3].Text := 'RX: waiting...';
    // Сохраняем текущую частоту/настройки чтобы следующий START восстановил их
    if FDevConnected then
    begin
      SaveCurrentBand;
      FSettings.SaveGlobal(FDevMAC, MakeGlobalSettings);
    end;
    FSettings.SaveStartupPreview(FVfoA, FVfoB, FSampleRate);
    FSettings.Save;
    FSpecView.ResetSpectrumBuf;
    FSpecView.DrawSpectrum;
    PbSpectrum.Invalidate;
    FNetwork.Disconnect;
    BtnStartStop.Caption     := 'START';
    StyleButton(BtnStartStop, False);
    StatusBar1.Panels[4].Text := 'Not connected';
      StatusBar1.Panels[0].Text := 'IP: --';
    StatusBar1.Panels[1].Text := 'Board: --';
    BtnMOX.Enabled := False;
    Exit;
  end;

  // --- START ---
  AutoIP := '';
  FPendingBoardType := 0;
  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 FDeviceDialog.ShowModal <> mrOk then Exit;
    if not FDeviceDialog.DialogResult.Accepted then Exit;
    FPendingIP := FDeviceDialog.DialogResult.IPAddress;
    FPendingBoardType := FDeviceDialog.GetSavedBoardType(
                           FDeviceDialog.DialogResult.SavedIdx);
  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 := FSampleRate;
  FoundByIP := False;
  for i := 0 to High(FDevices) do
    if SameText(FDevices[i].Dev.IPAddress, Dev.IPAddress) then
    begin
      FoundByIP := True;
      if 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
    FSampleRate := PreloadRate;
    FSpanHz := PreloadRate;
    if (not FWDSPReady) and (Assigned(FDSPEngine)) and (FDSPEngine.SampleRate <> PreloadRate) then
      RecreateDSPEngine(PreloadRate);
  end;

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

procedure TMainForm.OnWDSPOpenDone(Success: Boolean);
begin
  // Вызывается из TWDSPOpenThread.SyncDone — уже в главном потоке
  FWDSPReady := Success;
  if not Success then
  begin
    StatusBar1.Panels[3].Text := 'WDSP open failed';
    BtnStartStop.Enabled := True;
    BtnStartStop.Caption := 'START';
    StyleButton(BtnStartStop, False);
    ShowMessage('WDSP not loaded.' + LineEnding +
      IfThen(FDSPEngine.LastError <> '', 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;
  DUCPkt:   TDUCSpecificPacket;
  G_Settings: TGlobalSettings;
  i:        Integer;
begin
  BtnStartStop.Enabled := True;

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

  // --- Загружаем настройки устройства по MAC ---
  Move(FNetwork.Device.MAC[0], FDevMAC[0], 6);
  FDevConnected := True;
  FSettings.LoadDevice(FDevMAC, G_Settings, FBandCache);
  FCATLastGlobal := G_Settings;
  CATApplySettings(G_Settings);
  FVolume      := G_Settings.Volume;
  FActiveVfo   := G_Settings.ActiveVfo;
  // PA settings
  FPAMaxPower  := G_Settings.PAMaxPower;
  for i := 0 to BAND_COUNT - 1 do
    FPABandCal[i] := G_Settings.PABandCal[i];
  // Slider position (0..100) and calibrated drive byte
  TrkDrive.Position := EnsureRange(G_Settings.DriveLevel, 0, 100);
  FDriveLevel  := CalcDriveByte;
  FCurrentBand := G_Settings.LastBand;
  // SampleRate — глобальный, загружаем до RestoreBand
  if G_Settings.SampleRate > 0 then
  begin
    FSampleRate := G_Settings.SampleRate;
    FSpanHz     := FSampleRate;
  end;
  BtnNR.Tag  := EnsureRange(G_Settings.NRMode, 0, 4); UpdateNRButton;
  BtnNB.Tag  := EnsureRange(G_Settings.NBMode, 0, 2); UpdateNBButton;
  BtnSNB.Tag := Ord(G_Settings.SNBEnabled); UpdateSNBButton;
  BtnANF.Tag := Ord(G_Settings.ANFEnabled); UpdateANFButton;
  FWfAGCEnabled := G_Settings.WfAGCEnabled; StyleButton(BtnWfAGC, FWfAGCEnabled);
  FWfNFEnabled  := G_Settings.WfNFEnabled;  StyleButton(BtnWfNF,  FWfNFEnabled);
  FWfManualHigh := G_Settings.WfManualHigh;
  FWfManualLow  := G_Settings.WfManualLow;
  FWfAGCOffset  := G_Settings.WfAGCOffset;
  FSpecView.WfAGCEnabled := FWfAGCEnabled;
  FSpecView.WfNFEnabled  := FWfNFEnabled;
  FSpecView.WfManualHigh := FWfManualHigh;
  FSpecView.WfManualLow  := FWfManualLow;
  FSpecView.WfAGCOffset  := FWfAGCOffset;
  FSpecView.ResetWfAvgBuf;
  FShowSpectrum  := G_Settings.ShowSpectrum;
  FShowWaterfall := G_Settings.ShowWaterfall;
  if G_Settings.DisplayFPS > 0 then
    ApplyFPS(G_Settings.DisplayFPS);
  // Настройки дисплея и сетки
  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 FWDSPReady then
    begin
      FDSPEngine.SetFFTParams(G_Settings.FFTSize, G_Settings.WindowType);
      FDSPEngine.SetSpectrumDisplay(G_Settings.SpecDetector, G_Settings.SpecAvgMode,
        G_Settings.SpecAvgTimeMS);
      FDSPEngine.SetWaterfallDisplay(G_Settings.WfDetector, G_Settings.WfAvgMode,
        G_Settings.WfAvgTimeMS);
    end;
    // Аудио устройство вывода
    FAudioOut.Close;
    if G_Settings.AudioSampleRate > 0 then
      FAudioOut.SampleRate := G_Settings.AudioSampleRate;
    if G_Settings.AudioOutDevice <> '' then
    begin
      FAudioOut.DeviceIndex := FAudioOut.FindDeviceByName(G_Settings.AudioOutDevice);
      FAudioOutDevName := G_Settings.AudioOutDevice;
    end
    else
    begin
      FAudioOut.DeviceIndex := -1;
      FAudioOutDevName := '';
    end;
    FAudioOut.Open;
    // Аудио устройство ввода (TX mic)
    if G_Settings.AudioInDevice <> '' then
    begin
      FAudioIn.DeviceIndex := FAudioIn.FindDeviceByName(G_Settings.AudioInDevice);
      FAudioInDevName := G_Settings.AudioInDevice;
      FAudioIn.Open;
    end
    else
    begin
      FAudioIn.DeviceIndex := -1;
      FAudioInDevName := '';
    end;
  end;
  RestoreBand(FCurrentBand);

  StatusBar1.Panels[0].Text := 'IP: ' + FNetwork.Device.IPAddress;
  StatusBar1.Panels[1].Text := 'Board: ' + BoardTypeName(FNetwork.Device.BoardType);

  // General packet
  FillChar(GenPkt, SizeOf(GenPkt), 0);
  GenPkt.Command  := CMD_GENERAL;
  GenPkt.Flags37  := $08;
  GenPkt.Flags38  := $01;
  GenPkt.PAConfig := $01;
  if FNetwork.Device.BoardType = 5 then
    GenPkt.AlexEnable := $03
  else
    GenPkt.AlexEnable := $01;
  FNetwork.SendGeneralPacket(GenPkt);

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

  FNetwork.ConfigureDDCs(1, FSampleRate div 1000, 0);

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

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

  // DUC specific
  FillChar(DUCPkt, SizeOf(DUCPkt), 0);
  DUCPkt.NumDACs        := 1;
  DUCPkt.SidetoneLevel  := 50;
  DUCPkt.SidetoneFreqHi := Hi(600);
  DUCPkt.SidetoneFreqLo := Lo(600);
  DUCPkt.KeyerSpeed     := 20;
  DUCPkt.KeyerWeight    := 50;
  DUCPkt.DUC0RateHi     := Hi(192);
  DUCPkt.DUC0RateLo     := Lo(192);
  DUCPkt.DUC0Bits       := 24;
  FNetwork.SendDUCSpecific(DUCPkt);

  FNetwork.UpdateState(FCenterFreq, FCenterFreq, FDriveLevel, False, True, True);
  FNetwork.SetRunAndFreq(True, FCenterFreq, FCenterFreq, FDriveLevel);
  FRunning       := True;
  FRXStartTime   := GetTickCount64;
  FRXLastPktTime := 0;

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

  BtnStartStop.Caption  := 'STOP';
  StyleButton(BtnStartStop, True);
  StatusBar1.Panels[4].Text := 'RUNNING  ' + FormatFreq(FVfoA);
  BtnMOX.Enabled := True;
end;


// ---------------------------------------------------------------------------
// ActivateVfo — переключаем активный VFO и перестраиваем приёмник.
// Idx=0 → VFO-A активен, Idx=1 → VFO-B активен.
// Перестройка всегда происходит на частоту активного VFO (не на оффсет).
// CTUN при этом сбрасывается чтобы не было путаницы с оффсетом.
// ---------------------------------------------------------------------------
// ---------------------------------------------------------------------------
// FreqToBandIdx — возвращает индекс диапазона для частоты Hz, или -1
// ---------------------------------------------------------------------------
function FreqToBandIdx(Hz: Double): Integer;
const
  // Границы диапазонов [Low, High] в Гц (приблизительные)
  BAND_LO: array[0..10] of Double = (
    1800000,  3500000,  5330000,  7000000,  10100000,
    14000000, 18068000, 21000000, 24890000, 28000000, 50000000);
  BAND_HI: array[0..10] of Double = (
    2000000,  4000000,  5410000,  7300000,  10150000,
    14350000, 18168000, 21450000, 24990000, 29700000, 54000000);
var
  i: Integer;
begin
  Result := -1;
  for i := 0 to 10 do
    if (Hz >= BAND_LO[i]) and (Hz <= BAND_HI[i]) then
    begin
      Result := i;
      Exit;
    end;
end;

// ---------------------------------------------------------------------------
// ActiveVfoFreq — частота активного VFO
// ---------------------------------------------------------------------------
function TMainForm.ActiveVfoFreq: Int64;
begin
  if FActiveVfo = 0 then Result := Round(FVfoA)
  else                    Result := Round(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
  FActiveVfo := Idx;

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

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

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

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

  // Переключаем кнопку диапазона по новой частоте
  NewBand := FreqToBandIdx(ActiveFreq);
  if (NewBand >= 0) and (NewBand <> FCurrentBand) then
  begin
    StyleButton(BtnBand[FCurrentBand], False);
    FCurrentBand := NewBand;
    StyleButton(BtnBand[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 (перестройка на FVfoA)
  if FActiveVfo <> 0 then
    ActivateVfo(0);
end;

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

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

procedure TMainForm.FreqDispBChanged(Sender: TObject; NewFreq: Int64);
var
  BandIdx: Integer;
begin
  FVfoB := NewFreq;
  if FActiveVfo = 1 then
  begin
    // VFO-B активен: перестраиваем приёмник
    FCenterFreq := FVfoB;
    if FWDSPReady then FDSPEngine.SetShift(0.0);
    if FNetwork.Connected and FNetwork.Running then
    begin
      FNetwork.UpdateState(FCenterFreq, FCenterFreq, FDriveLevel, FTransmitting, True, True);
      FNetwork.SendFullHP;
    end;
    // Обновляем диапазон
    BandIdx := FreqToBandIdx(FVfoB);
    if (BandIdx >= 0) and (BandIdx <> FCurrentBand) then
    begin
      StyleButton(BtnBand[FCurrentBand], False);
      FCurrentBand := BandIdx;
      StyleButton(BtnBand[FCurrentBand], True);
    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
  case FMode of
    0, 1: begin
            for i := 0 to FILT_COUNT-1 do Names[i] := FILT_SSB_NAMES[i];
            DefIdx := FILT_SSB_DEF;
          end;
    3, 4: begin
            for i := 0 to FILT_COUNT-1 do Names[i] := FILT_CW_NAMES[i];
            DefIdx := FILT_CW_DEF;
          end;
    5:    begin
            for i := 0 to FILT_COUNT-1 do Names[i] := FILT_FM_NAMES[i];
            DefIdx := FILT_FM_DEF;
          end;
  else    begin
            for i := 0 to FILT_COUNT-1 do Names[i] := FILT_AM_NAMES[i];
            DefIdx := FILT_AM_DEF;
          end;
  end;

  FFilter := DefIdx;
  case FMode of
    0, 1:    FFilterBW := FILT_SSB_BW[DefIdx];
    3, 4:    FFilterBW := FILT_CW_BW[DefIdx];
    5:       FFilterBW := FILT_FM_BW[DefIdx];
  else       FFilterBW := FILT_AM_BW[DefIdx];
  end;

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

// ---------------------------------------------------------------------------
// ApplyVfoA — единая точка смены частоты VFO A
//
// CTUN ВЫКЛ (классический режим):
//   FCenterFreq = FVfoA — спектр центрирован на VFO, маркер всегда в центре
//
// CTUN ВКЛ (piHPSDR/Thetis режим):
//   FCenterFreq статичен — спектр/водопад не двигаются
//   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
  FVfoA    := NewFreq;
  Scrolled := False;

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

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

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

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

  // Обновляем кнопку диапазона если частота перешла в другой диапазон
  BandIdx := FreqToBandIdx(FVfoA);
  if (BandIdx >= 0) and (BandIdx <> FCurrentBand) then
  begin
    StyleButton(BtnBand[FCurrentBand], False);
    FCurrentBand := BandIdx;
    StyleButton(BtnBand[FCurrentBand], True);
  end;

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

  // Перерисовываем спектр всегда (маркер и полоса двигаются)
  // Если идёт drag — не вызываем Draw напрямую, таймер подхватит
  SyncSpecViewFreq;
  if FSpecDrag then
    FSpectrumDirty := True
  else
  begin
    FSpecView.DrawSpectrum;
    PbSpectrum.Invalidate;
    if Scrolled then
    begin
      FSpecView.DrawWaterfall;
      PbWaterfall.Invalidate;
    end;
  end;
end;

// ---------------------------------------------------------------------------
// CTUN toggle
// ---------------------------------------------------------------------------
procedure TMainForm.BtnCTunClick(Sender: TObject);
begin
  FCTun := not FCTun;
  StyleButton(BtnCTun, FCTun);
  FBandCache[FCurrentBand].CTun := FCTun;
  if not FCTun then
  begin
    // Выключили CTUN: центрируемся на VFO, shift=0
    FCenterFreq := FVfoA;
    if FWDSPReady then
      FDSPEngine.SetShift(0.0);
    if FNetwork.Connected and FNetwork.Running then
    begin
      FNetwork.UpdateState(FCenterFreq, FCenterFreq, FDriveLevel, FTransmitting, True, True);
      FNetwork.SendFullHP;
    end;
  end
  else
  begin
    // Включили CTUN: фиксируем текущее положение дисплея
    // FCenterFreq остаётся как есть — спектр не прыгает
  end;
  SyncSpecViewFreq;
  FSpecView.DrawSpectrum;
  FSpecView.DrawWaterfall;
  PbSpectrum.Invalidate;
  PbWaterfall.Invalidate;
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;
  StepHz    := 100;
  ClickFreq := Round(FCenterFreq + (PixelX / PanelWidth - 0.5) * FSpanHz);
  ClickFreq := (ClickFreq div StepHz) * StepHz;
  if FActiveVfo = 0 then
    ApplyVfoA(ClickFreq)
  else
  begin
    FVfoB := ClickFreq;
    FreqDispB.Frequency := Round(FVfoB);
    FCenterFreq := ClickFreq;
    if FWDSPReady then FDSPEngine.SetShift(0.0);
    if FNetwork.Connected and FNetwork.Running then
    begin
      FNetwork.UpdateState(FCenterFreq, FCenterFreq, FDriveLevel, FTransmitting, True, True);
      FNetwork.SendFullHP;
    end;
    BandIdx := FreqToBandIdx(FVfoB);
    if (BandIdx >= 0) and (BandIdx <> FCurrentBand) then
    begin
      StyleButton(BtnBand[FCurrentBand], False);
      FCurrentBand := BandIdx;
      StyleButton(BtnBand[FCurrentBand], True);
    end;
    FSpecView.InvalidateRulerCache;
    SyncSpecViewFreq;
    FSpecView.DrawSpectrum;
    PbSpectrum.Invalidate;
    if PbRuler <> nil then PbRuler.Invalidate;
  end;
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 * FSpanHz;
  if FCTun then
  begin
    // CTUN ON: drag двигает окно просмотра (FCenterFreq/DDC)
    // VFO остаётся, shift обновляется = VFO - новый FCenterFreq
    FCenterFreq := FSpecDragFreq - dFreq;
    if FWDSPReady then
    begin
      if FActiveVfo = 0 then
        FDSPEngine.SetShift(FVfoA - FCenterFreq)
      else
        FDSPEngine.SetShift(FVfoB - FCenterFreq);
    end;
    if FNetwork.Connected and FNetwork.Running then
    begin
      FNetwork.UpdateState(FCenterFreq, FCenterFreq, FDriveLevel, FTransmitting, True, True);
      FNetwork.SendFullHP;
    end;
    FSpectrumDirty := True;
  end
  else
  begin
    // CTUN OFF: drag двигает VFO, FCenterFreq следует через Apply
    if FActiveVfo = 0 then
      ApplyVfoA(Round(FSpecDragFreq - dFreq))
    else
    begin
      FVfoB := FSpecDragFreq - dFreq;
      FreqDispB.Frequency := Round(FVfoB);
      FCenterFreq := FVfoB;
      if FWDSPReady then FDSPEngine.SetShift(0.0);
      if FNetwork.Connected and FNetwork.Running then
      begin
        FNetwork.UpdateState(FCenterFreq, FCenterFreq, FDriveLevel, FTransmitting, True, True);
        FNetwork.SendFullHP;
      end;
      FSpectrumDirty := True;
    end;
  end;
end;

// Спектр
procedure TMainForm.PbSpectrumMouseDown(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 FCTun then FSpecDragFreq := FCenterFreq
    else begin
      if FActiveVfo = 0 then FSpecDragFreq := FVfoA
      else                   FSpecDragFreq := 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.PbSpectrumMouseMove(Sender: TObject; Shift: TShiftState;
  X, Y: Integer);
begin
  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.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 FCTun then FSpecDragFreq := FCenterFreq
    else begin
      if FActiveVfo = 0 then FSpecDragFreq := FVfoA
      else                   FSpecDragFreq := 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;
  if NewBand = FCurrentBand then Exit;  // уже на этом диапазоне

  // Сохраняем настройки текущего диапазона перед сменой
  if FDevConnected then
  begin
    SaveCurrentBand;
    FSettings.Save;
  end;

  // Переключаем диапазон
  FCurrentBand := NewBand;

  // Восстанавливаем настройки нового диапазона
  RestoreBand(FCurrentBand);
end;

procedure TMainForm.ApplyModeFilter;
var
  Lo, Hi, Half: Integer;
begin
  Half := FFilterBW div 2;
  case FMode of
    0: begin Lo := -FFilterBW; Hi := -100; end;  // LSB: верхний срез -100 Гц
    1: begin Lo :=  100; Hi := 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 FWDSPReady then
    FDSPEngine.SetFilter(Lo, Hi);
end;

procedure TMainForm.BtnModeClick(Sender: TObject);
var
  i, N: Integer;
begin
  N := (Sender as TFlatButton).Tag;
  FMode := N;
  for i := 0 to MODE_COUNT - 1 do StyleButton(BtnMode[i], i = FMode);
  if FWDSPReady then
    FDSPEngine.SetMode(N);
  UpdateFilterButtons;   // обновляем подписи и дефолт фильтра под новый режим
  ApplyModeFilter;
end;

procedure TMainForm.BtnFilterClick(Sender: TObject);
var
  i, N: Integer;
begin
  N := (Sender as TFlatButton).Tag;
  FFilter := N;
  // Устанавливаем полосу по текущему режиму
  case FMode of
    0, 1:       FFilterBW := FILT_SSB_BW[N];
    3, 4:       FFilterBW := FILT_CW_BW[N];
    5:          FFilterBW := FILT_FM_BW[N];
  else          FFilterBW := FILT_AM_BW[N];  // DSB, AM, SAM
  end;
  for i := 0 to FILT_COUNT - 1 do StyleButton(BtnFilter[i], i = FFilter);
  ApplyModeFilter;
end;

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

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

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

  // Обновляем кнопку диапазона
  BandIdx := FreqToBandIdx(ActiveFreq);
  if (BandIdx >= 0) and (BandIdx <> FCurrentBand) then
  begin
    StyleButton(BtnBand[FCurrentBand], False);
    FCurrentBand := BandIdx;
    StyleButton(BtnBand[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)
  FVfoB := FVfoA;
  if FActiveVfo = 1 then
  begin
    // B активен — перестраиваем приёмник на новую частоту B
    FCenterFreq := Round(FVfoB);
    if FWDSPReady then FDSPEngine.SetShift(0.0);
    if FNetwork.Connected and FNetwork.Running then
    begin
      FNetwork.UpdateState(FCenterFreq, FCenterFreq, FDriveLevel, FTransmitting, True, True);
      FNetwork.SendFullHP;
    end;
    FSpecView.InvalidateRulerCache;
    SyncSpecViewFreq;
    FSpecView.DrawSpectrum; PbSpectrum.Invalidate;
    if PbRuler <> nil then PbRuler.Invalidate;
  end;
  FreqDispB.Frequency := Round(FVfoB);
  UpdateVfoDisplay;
end;

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

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

procedure TMainForm.ApplyMOX(Active: Boolean);
begin
  FTransmitting := Active;
  if FTransmitting then
  begin
    BtnMOX.ClrNorm    := TColor($00000044);
    BtnMOX.ClrText    := TColor($000000FF);
    BtnMOX.ClrTextAct := TColor($000000FF);
    BtnMOX.Active     := True;
  end else
    StyleButton(BtnMOX, False);
  if FWDSPReady then
    FDSPEngine.SetTXRun(FTransmitting);
  if FNetwork.Connected and FNetwork.Running then
  begin
    FNetwork.UpdateState(FCenterFreq, ActiveTXFreqHz, FDriveLevel,
                         FTransmitting, True, True);
    FNetwork.SendFullHP;
  end;
end;

procedure TMainForm.BtnMOXClick(Sender: TObject);
begin
  ApplyMOX(not FTransmitting);
end;

procedure TMainForm.BtnMuteClick(Sender: TObject);
begin
  FMuted := not FMuted;
  StyleButton(BtnMute, FMuted);
  if FMuted then BtnMute.Caption := 'UNMUTE'
  else BtnMute.Caption := 'MUTE';
  if FWDSPReady then FDSPEngine.SetMute(FMuted);
end;

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

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

procedure TMainForm.UpdateSNBButton;
begin
  BtnSNB.Caption := 'SNB';
  StyleButton(BtnSNB, BtnSNB.Tag <> 0);
  BtnSNB.Invalidate;
end;

procedure TMainForm.UpdateANFButton;
begin
  BtnANF.Caption := 'ANF';
  StyleButton(BtnANF, BtnANF.Tag <> 0);
  BtnANF.Invalidate;
end;

procedure TMainForm.ApplyNoiseFilterButtonsToDSP;
begin
  if not FWDSPReady then Exit;
  FDSPEngine.SetNRMode(BtnNR.Tag);
  FDSPEngine.SetNBMode(BtnNB.Tag);
  FDSPEngine.SetSNB(BtnSNB.Tag <> 0);
  FDSPEngine.SetANF(BtnANF.Tag <> 0);
end;

procedure TMainForm.BtnNRClick(Sender: TObject);
begin
  BtnNR.Tag := (BtnNR.Tag + 1) mod 5;
  UpdateNRButton;
  if FWDSPReady then FDSPEngine.SetNRMode(BtnNR.Tag);
end;

procedure TMainForm.BtnNBClick(Sender: TObject);
begin
  BtnNB.Tag := (BtnNB.Tag + 1) mod 3;
  UpdateNBButton;
  if FWDSPReady then FDSPEngine.SetNBMode(BtnNB.Tag);
end;

procedure TMainForm.BtnSNBClick(Sender: TObject);
begin
  BtnSNB.Tag := 1 - BtnSNB.Tag;
  UpdateSNBButton;
  if FWDSPReady then FDSPEngine.SetSNB(BtnSNB.Tag <> 0);
end;

procedure TMainForm.BtnANFClick(Sender: TObject);
begin
  BtnANF.Tag := 1 - BtnANF.Tag;
  UpdateANFButton;
  if FWDSPReady then FDSPEngine.SetANF(BtnANF.Tag <> 0);
end;


procedure TMainForm.BtnWfAGCClick(Sender: TObject);
begin
  FWfAGCEnabled := not FWfAGCEnabled;
  StyleButton(BtnWfAGC, FWfAGCEnabled);
  FSpecView.WfAGCEnabled := FWfAGCEnabled;
end;

procedure TMainForm.BtnWfNFClick(Sender: TObject);
begin
  FWfNFEnabled := not FWfNFEnabled;
  StyleButton(BtnWfNF, FWfNFEnabled);
  FSpecView.WfNFEnabled := FWfNFEnabled;
end;

procedure TMainForm.BtnHidePanelClick(Sender: TObject);
begin
  FPanelHidden := not FPanelHidden;
  if FPanelHidden then
  begin
    PanelLeft.Width := 0;
    BtnHidePanel.Caption := '▶';
    FVfoOverlay.Parent  := PanelRight;
    FVfoOverlay.Width   := 260;
    FVfoOverlay.Height  := 130;
    FVfoOverlay.SetState(FMode, FFilterBW, FVfoA, FLastSMeter);
    FVfoOverlay.Visible := True;
    FVfoOverlay.BringToFront;
    PositionVfoOverlay;
  end
  else
  begin
    PanelLeft.Width := 232;
    BtnHidePanel.Caption := '◀';
    FVfoOverlay.Visible := False;
  end;
  ResizeSpectrumPanels;
  ResizeSMeter;
end;

procedure TMainForm.PositionVfoOverlay;
var
  VfoX, OW, SpTop: Integer;
begin
  if not Assigned(FVfoOverlay) or not FVfoOverlay.Visible then Exit;
  OW    := FVfoOverlay.Width;
  SpTop := PbSpectrum.Top;   // смещение PbSpectrum внутри PanelRight

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

  // Всегда справа от VFO-линии
  FVfoOverlay.Left := Min(PanelRight.Width - OW - 4, VfoX + 6);
  FVfoOverlay.Top  := SpTop + 6;
end;

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

procedure TMainForm.OnModeFilterSelect(Mode: Integer; FilterBW: Integer);
begin
  FMode     := Mode;
  FFilterBW := FilterBW;
  if FWDSPReady then
  begin
    FDSPEngine.SetMode(FMode);
    ApplyModeFilter;
  end;
  if Assigned(FVfoOverlay) and FVfoOverlay.Visible then
  begin
    FVfoOverlay.SetState(FMode, FFilterBW, FVfoA, FLastSMeter);
    PositionVfoOverlay;  // перепозиционируем при смене LSB↔USB
  end;
end;

procedure TMainForm.BtnSpanClick(Sender: TObject);
var
  NewRate: Integer;
  DDCRate: Word;
begin
  NewRate := (Sender as TFlatButton).Tag;
  if NewRate = FSampleRate then Exit;

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

  // Обновляем кнопки сразу — UI отзывчив
  StyleSpanButton(BtnSpan48k,   FSampleRate =   48000);
  StyleSpanButton(BtnSpan96k,   FSampleRate =   96000);
  StyleSpanButton(BtnSpan192k,  FSampleRate =  192000);
  StyleSpanButton(BtnSpan384k,  FSampleRate =  384000);
  StyleSpanButton(BtnSpan768k,  FSampleRate =  768000);
  StyleSpanButton(BtnSpan1536k, FSampleRate = 1536000);
  Application.ProcessMessages;

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

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

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

  // 3. Восстанавливаем настройки DSP
  FWDSPReady := FDSPEngine.Initialized;
  if FWDSPReady then
  begin
    FDSPEngine.SetMode(FMode);
    FDSPEngine.SetVolume(FVolume / 100.0);
    ApplyModeFilter;
    ApplyNoiseFilterButtonsToDSP;
    // ChangeSampleRate пересоздаёт WDSP-канал — восстанавливаем AGC
    FDSPEngine.SetAGCTop(FAGCTop);
    FDSPEngine.SetAGC(TWDSPAGCMode(FAGCMode), 50.0);
    FDSPEngine.UpdateAGCLines(FSpectrumWidth);
    if FNetwork.Connected then
      FDSPEngine.SetShift(FVfoA - 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 FActiveVfo = 0 then
    ApplyVfoA(Round(FWebSyncFreq))
  else
  begin
    FVfoB := FWebSyncFreq;
    FreqDispB.Frequency := Round(FVfoB);
    FCenterFreq := FVfoB;
    if FWDSPReady then FDSPEngine.SetShift(0.0);
    if FRunning then
      FNetwork.SetRunAndFreq(True, FCenterFreq, FCenterFreq, FDriveLevel);
    BandIdx := FreqToBandIdx(FVfoB);
    if (BandIdx >= 0) and (BandIdx <> FCurrentBand) then
    begin
      StyleButton(BtnBand[FCurrentBand], False);
      FCurrentBand := BandIdx;
      StyleButton(BtnBand[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;
  FMode := FWebSyncInt;
  for i := 0 to MODE_COUNT - 1 do StyleButton(BtnMode[i], i = FMode);
  if FWDSPReady then FDSPEngine.SetMode(FMode);
  UpdateFilterButtons;
  ApplyModeFilter;
  FBandCache[FCurrentBand].Mode := 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
      FFilter := Idx;
      case FMode of
        0,1: FFilterBW := FILT_SSB_BW[Idx];
        3,4: FFilterBW := FILT_CW_BW[Idx];
        5:   FFilterBW := FILT_FM_BW[Idx];
        else FFilterBW := FILT_AM_BW[Idx];
      end;
      for j := 0 to FILT_COUNT-1 do StyleButton(BtnFilter[j], j = FFilter);
      ApplyModeFilter;
      FBandCache[FCurrentBand].FilterBW := FFilterBW;
    end;
    Exit;
  end;
  // Positive value = bandwidth in Hz, find closest filter
  FFilterBW := FWebSyncInt;
  case FMode of
    0,1: for i := 0 to FILT_COUNT-1 do BW_arr[i] := FILT_SSB_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_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;
  FFilter := best;
  for j := 0 to FILT_COUNT-1 do StyleButton(BtnFilter[j], j = FFilter);
  ApplyModeFilter;
  FBandCache[FCurrentBand].FilterBW := FFilterBW;
end;

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

procedure TMainForm.SyncWebAGC;
var i: Integer;
begin
  if (FWebSyncInt < 0) or (FWebSyncInt > 4) then Exit;
  FAGCMode := FWebSyncInt;
  for i := 0 to 4 do StyleButton(BtnAGCMode[i], i = FAGCMode);
  if FWDSPReady then
    FDSPEngine.SetAGC(TWDSPAGCMode(FAGCMode), 50.0);
  FBandCache[FCurrentBand].AGCMode := FAGCMode;
end;

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

procedure TMainForm.SyncWebAGCTop;
begin
  FAGCTop := Max(20, Min(120, FWebSyncInt));
  TrkAGC.Position := FAGCTop;
  LblAGCTop.Caption := Format('%ddB', [FAGCTop]);
  if FWDSPReady then
  begin
    FDSPEngine.SetAGCTop(FAGCTop);
    FDSPEngine.SetAGC(TWDSPAGCMode(FAGCMode), 50.0);
  end;
  FBandCache[FCurrentBand].AGCTop := 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;
  if FWebSyncInt = FCurrentBand then Exit;
  if FDevConnected then begin SaveCurrentBand; FSettings.Save; end;
  FCurrentBand := FWebSyncInt;
  RestoreBand(FCurrentBand);
end;

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

procedure TMainForm.SyncWebSpan;
var
  ValidSpans: array[0..5] of Integer;
  Found: Boolean;
  i: Integer;
  Btn: TFlatButton;
begin
  ValidSpans[0]:=48000;  ValidSpans[1]:=96000;  ValidSpans[2]:=192000;
  ValidSpans[3]:=384000; ValidSpans[4]:=768000; ValidSpans[5]:=1536000;
  Found := False;
  for i := 0 to 5 do
    if ValidSpans[i] = FWebSyncInt then begin Found := True; Break; end;
  if not Found then Exit;
  if FWebSyncInt = FSampleRate then Exit;
  Btn := nil;
  if      FWebSyncInt = 48000   then Btn := BtnSpan48k
  else if FWebSyncInt = 96000   then Btn := BtnSpan96k
  else if FWebSyncInt = 192000  then Btn := BtnSpan192k
  else if FWebSyncInt = 384000  then Btn := BtnSpan384k
  else if FWebSyncInt = 768000  then Btn := BtnSpan768k
  else if FWebSyncInt = 1536000 then Btn := BtnSpan1536k;
  if Assigned(Btn) then BtnSpanClick(Btn);
end;

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

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

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

procedure TMainForm.SyncWebWfAGC;
begin
  FWfAGCEnabled := FWebSyncBool;
  StyleButton(BtnWfAGC, FWfAGCEnabled);
  FSpecView.WfAGCEnabled := FWfAGCEnabled;
end;

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

procedure TMainForm.SyncWebWfNF;
begin
  FWfNFEnabled := FWebSyncBool;
  StyleButton(BtnWfNF, FWfNFEnabled);
  FSpecView.WfNFEnabled := 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 <> 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
  if FWebSyncBool <> FMuted then
    BtnMuteClick(nil);
end;

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

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

procedure TMainForm.SyncWebCtun;
begin
  if FWebSyncBool <> 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
  if FWebSyncInt < 0 then FWebSyncInt := 0;
  if FWebSyncInt > 4 then FWebSyncInt := 4;
  if BtnNR.Tag <> FWebSyncInt then
  begin
    BtnNR.Tag := FWebSyncInt;
    UpdateNRButton;
    if FWDSPReady then FDSPEngine.SetNRMode(BtnNR.Tag);
  end;
end;

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

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

procedure TMainForm.SyncWebNB;
begin
  if FWebSyncInt < 0 then FWebSyncInt := 0;
  if FWebSyncInt > 2 then FWebSyncInt := 2;
  if BtnNB.Tag <> FWebSyncInt then
  begin
    BtnNB.Tag := FWebSyncInt;
    UpdateNBButton;
    if FWDSPReady then FDSPEngine.SetNBMode(BtnNB.Tag);
  end;
end;

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

procedure TMainForm.SyncWebSNB;
begin
  if FWebSyncBool <> (BtnSNB.Tag <> 0) then
    BtnSNBClick(BtnSNB);
end;

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

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

procedure TMainForm.SyncWebANF;
begin
  if FWebSyncBool <> (BtnANF.Tag <> 0) then
    BtnANFClick(BtnANF);
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
  FVfoB := FWebSyncFreq;
  FreqDispB.Frequency := Round(FVfoB);
  if FActiveVfo = 1 then
  begin
    FCenterFreq := FVfoB;
    if not FCTun then
      if FWDSPReady then FDSPEngine.SetShift(0.0);
    if FRunning then
      FNetwork.SetRunAndFreq(True, FCenterFreq, FCenterFreq, FDriveLevel);
    BandIdx := FreqToBandIdx(FVfoB);
    if (BandIdx >= 0) and (BandIdx <> FCurrentBand) then
    begin
      StyleButton(BtnBand[FCurrentBand], False);
      FCurrentBand := BandIdx;
      StyleButton(BtnBand[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 = 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 в десктопе)
  FCenterFreq := FWebSyncFreq;
  if FWDSPReady then
  begin
    if FActiveVfo = 0 then FDSPEngine.SetShift(FVfoA - FCenterFreq)
    else                    FDSPEngine.SetShift(FVfoB - FCenterFreq);
  end;
  if FNetwork.Connected and FNetwork.Running then
    FNetwork.UpdateState(FCenterFreq, FCenterFreq, FDriveLevel, 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.TrkDriveChange(Sender: TObject);
begin
  FDriveLevel := CalcDriveByte;
  if FWDSPReady then
    FDSPEngine.SetDriveLevel(TrkDrive.Position / 100.0);
  if FNetwork.Connected and FNetwork.Running then
  begin
    FNetwork.UpdateState(FCenterFreq, ActiveTXFreqHz, FDriveLevel, FTransmitting, True, True);
    FNetwork.SendFullHP;
  end;
end;

function TMainForm.CalcDriveByte: Byte;
var Cal: Double;
begin
  if (FCurrentBand >= 0) and (FCurrentBand < BAND_COUNT) and (FPABandCal[FCurrentBand] > 0.0) then
    Cal := FPABandCal[FCurrentBand]
  else
    Cal := 100.0;
  Result := Round(TrkDrive.Position / 100.0 * Cal / 100.0 * 255.0);
end;

procedure TMainForm.OnTXIQReady(const Buf: array of Double; Count: Integer);
var
  IData:     array[0..239] of Integer;
  QData:     array[0..239] of Integer;
  Offset, n, i: Integer;
begin
  if not FNetwork.Connected then Exit;
  Offset := 0;
  while Offset < Count do
  begin
    FillChar(IData, SizeOf(IData), 0);
    FillChar(QData, SizeOf(QData), 0);
    n := Count - Offset;
    if n > 240 then n := 240;
    for i := 0 to n - 1 do
    begin
      IData[i] := Round(Buf[(Offset + i) * 2]     * 8388607.0);
      QData[i] := Round(Buf[(Offset + i) * 2 + 1] * 8388607.0);
    end;
    FNetwork.SendDUCIQ(IData, QData);
    Inc(Offset, 240);
  end;
end;

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

procedure TMainForm.TrkVolumeChange(Sender: TObject);
begin
  FVolume := TrkVolume.Position;
  if FWDSPReady then
    FDSPEngine.SetVolume(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 (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(FVfoA) div Step) * Step;
  if Delta > 0 then
    NewFreq := Base + Step
  else
  begin
    if Round(FVfoA) = Base then
      NewFreq := Base - Step
    else
      NewFreq := Base;   // снэп к нижней границе
  end;

  NewFreq := Max(30000, Min(60000000, NewFreq));
  ApplyVfoA(NewFreq);

  Handled := True;
end;


procedure TMainForm.BtnAGCModeClick(Sender: TObject);
const
  AGCModes: array[0..4] of TWDSPAGCMode = (
    agcFast, agcMedium, agcSlow, agcLong, agcOff);
var
  i, N: Integer;
begin
  N := (Sender as TFlatButton).Tag;
  FAGCMode := N;
  for i := 0 to 4 do StyleButton(BtnAGCMode[i], i = N);
  if FWDSPReady then
    FDSPEngine.SetAGC(AGCModes[N], 50.0);
  FBandCache[FCurrentBand].AGCMode := FAGCMode;
end;

procedure TMainForm.TrkAGCChange(Sender: TObject);
begin
  FAGCTop := TrkAGC.Position;
  LblAGCTop.Caption := Format('%ddB', [FAGCTop]);
  if FWDSPReady then
  begin
    FDSPEngine.SetAGCTop(FAGCTop);
    FDSPEngine.SetAGC(TWDSPAGCMode(FAGCMode), 50.0);
  end;
  FBandCache[FCurrentBand].AGCTop := FAGCTop;
  FSpecView.AGCTop := 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
  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;
  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 FWDSPReady then
    FDSPEngine.SetSpectrumDisplay(SpecDet, SpecAvgMode, SpecAvgTimeMS);
  if FWDSPReady then
    FDSPEngine.SetFFTParams(FFTSize, WinType);
  FSpectrumDirty := True;
end;

procedure TMainForm.ApplyWaterfallParams(WfDet, WfAvgMode: Integer;
  WfAvgTimeMS, WfHigh, WfLow, WfAGCOffset: Double);
begin
  if FWDSPReady then
    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.SpecRefLevel := FSpecRefLevel;
  FSpecView.SpecRange    := FSpecRange;
  FSpecView.SpecGridStep := FSpecGridStep;
  InvalidateGridCache;
end;

procedure TMainForm.ApplyAudioDevice(DevIndex: Integer; const DevName: string);
begin
  if FAudioOut.IsOpen then
    FAudioOut.Close;
  FAudioOut.DeviceIndex := DevIndex;
  FAudioOutDevName := DevName;
  try
    if not FAudioOut.Open then
      StatusBar1.Panels[3].Text := 'Audio out: ' + FAudioOut.LastError
    else
      StatusBar1.Panels[3].Text := 'Audio out: ' + IfThen(DevName = '', 'Default', DevName);
  except
    on E: Exception do
      StatusBar1.Panels[3].Text := 'Audio out err: ' + E.Message;
  end;
end;

procedure TMainForm.ApplyAudioInputDevice(DevIndex: Integer; const DevName: string);
begin
  if FAudioIn.IsOpen then
    FAudioIn.Close;
  FAudioIn.DeviceIndex := DevIndex;
  FAudioInDevName := DevName;
  if DevIndex >= 0 then
  begin
    try
      if not FAudioIn.Open then
        StatusBar1.Panels[3].Text := 'Mic: ' + FAudioIn.LastError
      else
        StatusBar1.Panels[3].Text := 'Mic: ' + DevName;
    except
      on E: Exception do
        StatusBar1.Panels[3].Text := 'Mic err: ' + E.Message;
    end;
  end
  else
    StatusBar1.Panels[3].Text := 'Mic: none';
end;

procedure TMainForm.ApplyVisibility(ShowSpectrum, ShowWaterfall: Boolean);
begin
  FShowSpectrum  := ShowSpectrum;
  FShowWaterfall := ShowWaterfall;
  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;
  // Thetis по умолчанию обновляет водопад через один display frame.
  FWaterfallFrameInterval := 2;
  FWaterfallFrameCounter := 0;
  FWaterfallDirty := True;
  FSpecView.WfFrameInterval := FWaterfallFrameInterval;
  if FWDSPReady then
    FDSPEngine.SetDisplayFPS(FPS);
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.OnVisibilityChange := ApplyVisibility;
    SF.OnFPSChange        := ApplyFPS;
    SF.OnPAChange         := OnPASettingsChange;
    SF.OnCATChange        := OnCATSettingsChange;
  end;
  SF := TSettingsForm(FSettingsForm);
  SF.LoadPASettings(FPAMaxPower, FPABandCal);
  SF.LoadCATSettings(
    FCATLastGlobal.CATSerialEnabled,
    FCATLastGlobal.CATSerialPort,
    FCATLastGlobal.CATSerialBaud,
    FCATLastGlobal.CATSerialDataBits,
    FCATLastGlobal.CATSerialStopBits,
    FCATLastGlobal.CATSerialParity,
    FCATLastGlobal.CATTcpEnabled,
    FCATLastGlobal.CATTcpPort);
  // Перечисляем PA устройства
  SF.RefreshAudioDevices(FAudioOut, FAudioIn);
  SF.LoadVisibility(FShowSpectrum, FShowWaterfall);
  SF.LoadFPS(FDisplayFPS);
  // Загружаем текущие значения
  if FWDSPReady then
    SF.LoadValues(
      FDSPEngine.FFTSize,
      FDSPEngine.WindowType,
      FDSPEngine.SpecDetector,
      FDSPEngine.SpecAvgMode,
      FDSPEngine.SpecAvgTimeMS,
      FDSPEngine.WfDetector,
      FDSPEngine.WfAvgMode,
      FDSPEngine.WfAvgTimeMS,
      FWfManualHigh,
      FWfManualLow,
      FWfAGCOffset,
      FSpecRefLevel, FSpecRange, FSpecGridStep,
      FAudioOutDevName, FAudioInDevName)
  else
    SF.LoadValues(
      131072, 2, 0, 3, 30.0,
      0,    3, 120.0, FWfManualHigh, FWfManualLow, FWfAGCOffset,
      FSpecRefLevel, FSpecRange, FSpecGridStep,
      FAudioOutDevName, FAudioInDevName);
  SF.Show;
end;

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

  // Открываем PortAudio — теперь FPC уже перехватывает сигналы
  try
    if not FAudioOut.Open then
      StatusBar1.Panels[3].Text := 'Audio: ' + FAudioOut.LastError
    else
      StatusBar1.Panels[3].Text := 'Audio OK (PortAudio)';
  except
    on E: Exception do
      StatusBar1.Panels[3].Text := 'Audio: ' + E.Message;
  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);
  FSettings.SaveCATSettings(FCATLastGlobal);
  if FDevConnected then
    FSettings.SaveGlobal(FDevMAC, MakeGlobalSettings);
  FSettings.Save;
end;

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

function TMainForm.CATGetVfoA: Double;       begin Result := FVfoA; end;
function TMainForm.CATGetVfoB: Double;       begin Result := FVfoB; end;
function TMainForm.CATGetMode: Integer;      begin Result := FMode; end;
function TMainForm.CATGetActiveVfo: Integer; begin Result := FActiveVfo; end;
function TMainForm.CATGetAGCMode: Integer;   begin Result := FAGCMode; end;
function TMainForm.CATGetVolume: Integer;    begin Result := FVolume; end;
function TMainForm.CATGetDriveLevel: Integer;begin Result := TrkDrive.Position; end;
function TMainForm.CATGetFilterIdx: Integer; begin Result := FFilter; end;
function TMainForm.CATGetFilterBW: Integer;  begin Result := FFilterBW; end;
function TMainForm.CATGetNRMode: Integer;    begin Result := BtnNR.Tag; end;
function TMainForm.CATGetNBMode: Integer;    begin Result := BtnNB.Tag; end;
function TMainForm.CATGetSNB: Boolean;       begin Result := BtnSNB.Tag <> 0; end;
function TMainForm.CATGetANF: Boolean;       begin Result := BtnANF.Tag <> 0; end;
function TMainForm.CATGetTX: Boolean;        begin Result := FTransmitting; end;
function TMainForm.CATGetRunning: Boolean;   begin Result := FRunning; end;
function TMainForm.CATGetSMeter: Double;     begin Result := FLastSMeter; end;
function TMainForm.CATGetBand: Integer;      begin Result := 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
  FVfoA := FCATSyncFreq;
  if FActiveVfo = 0 then
  begin
    ApplyVfoA(Round(FVfoA));
  end
  else
  begin
    // VFO A is not active — just update display and band indicator
    FreqDispA.Frequency := Round(FVfoA);
    BandIdx := FreqToBandIdx(FVfoA);
    if (BandIdx >= 0) and (BandIdx <> FCurrentBand) then
    begin
      StyleButton(BtnBand[FCurrentBand], False);
      FCurrentBand := BandIdx;
      StyleButton(BtnBand[FCurrentBand], True);
    end;
  end;
end;

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

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

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

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

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

end.
