unit MainForm; { OpenHPSDR Transceiver - Main Form Lazarus / FPC 3.2+ (нет inline var, нет анонимных процедур) ИСПРАВЛЕНИЯ: - (Web NR/NB/ANF fix) BtnNRClick, BtnNBClick, BtnANFClick переписаны без использования «Sender as TFlatButton» — теперь обращаются напрямую к BtnNR / BtnNB / BtnANF. Это устраняет EAccessViolation / EInvalidCast при вызове из SyncWebNR/SyncWebNB/SyncWebANF через TThread.Synchronize, где Sender передавался как nil. Исключение в UI-потоке приводило к разрыву WebSocket-соединения и «отключению» веб-клиента. - SyncWebNR / SyncWebNB / SyncWebANF: передают реальную кнопку (BtnNR / BtnNB / BtnANF) вместо nil — защита на случай изменения обработчиков. } {$IFDEF FPC} {$MODE Delphi} {$ENDIF} interface uses Classes, SysUtils, StrUtils, FreqDisplay, DeviceForm, VfoOverlay, SampleRateOverlay, FlatButton, FlatSlider, FlatDropDown, AppTheme, Forms, Controls, Graphics, Dialogs, StdCtrls, ExtCtrls, Buttons, Menus, Math, Types, OpenGLContext, LCLIntf, LCLType, GraphType, HPSDRProtocol, HPSDRNetwork, WDSP, WDSPEngine, AudioOutput, AudioInput, Settings, WebServer, WebAdapter, CATAdapter, SpectrumView, SpectrumViewOpengl, WidebandView, StatusBar, PlatformUtils, WinFirewall, BoardUtils, WisdomBuilder, UISync, FlatEdit, FMRepeater, ChannelStore, ChannelsForm, BeaconScopeForm, PowerInhibit, DeviceStore, RadioBackend, PlutoBackend, RadioController, ChannelController; const CLR_BG = TColor($00101010); CLR_PANEL = TColor($00181818); CLR_BORDER = TColor($00303030); CLR_FREQ = TColor($0000FF00); CLR_FREQ_DIM = TColor($00007800); CLR_AMBER = TColor($0000AAFF); CLR_ACTIVE = TColor($000C3010); CLR_ACTIVE_BDR = TColor($00387838); CLR_INACTIVE = TColor($001A1A1A); CLR_TEXT = TColor($00CCCCCC); CLR_TEXTDIM = TColor($00666666); CLR_METER_ON = TColor($0000CC44); CLR_METER_OVR = TColor($000000CC); CLR_SPECTRUM = TColor($0044FF44); WDSP_WISDOM_FILE = 'wdspWisdom00'; BAND_COUNT = 11; MODE_COUNT = 8; // Полосы/дефолты фильтров (FILT_COUNT, FILT_*_BW, FILT_*_DEF, FILT_FM_*) // перенесены в Settings.pas — нужны TRadioController. Здесь остаются только // UI-подписи кнопок (NAMES). 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_CW_NAMES: array[0..FILT_COUNT-1] of string = ('1.0k','800','600','500','400','250','150','100','50','25'); FILT_DSB_NAMES: array[0..FILT_COUNT-1] of string = ('16k','12k','10k','8.0k','6.6k','5.2k','4.0k','3.1k','2.9k','2.4k'); FILT_AM_NAMES: array[0..FILT_COUNT-1] of string = ('20k','18k','16k','12k','10k','9.0k','8.0k','7.0k','6.0k','5.0k'); FILT_FM_NAMES: array[0..1] of string = ('NFM', 'FM'); // CTCSS tones (38 standard tones, no None — toggle via CTCSS button) // CTCSS_COUNT и CTCSS_TONES перенесены в FMRepeater (нужны TRadioController). CTCSS_NAMES: array[0..37] of string = ( '67.0', '71.9', '74.4', '77.0', '79.7', '82.5', '85.4', '88.5', '91.5', '94.8', '97.4', '100.0', '103.5', '107.2', '110.9', '114.8', '118.8', '123.0', '127.3', '131.8', '136.5', '141.3', '146.2', '151.4', '156.7', '162.2', '167.9', '173.8', '179.9', '186.2', '192.8', '203.5', '210.7', '218.1', '225.7', '233.6', '241.8', '250.3'); // FM Step tuning FM_STEP_COUNT = 4; FM_STEP_HZ: array[0..3] of Integer = (6250, 12500, 20000, 25000); FM_STEP_NAMES: array[0..3] of string = ('6.25k', '12.5k', '20k', '25k'); FM_STEP_DEF = 3; // 25 kHz BAND_NAMES: array[0..BAND_COUNT-1] of string = ( '160m','80m','60m','40m','30m','20m','17m','15m','12m','10m','6m'); BAND_FREQ: array[0..BAND_COUNT-1] of Double = ( 1900000, 3750000, 5357000, 7100000, 10125000, 14200000, 18120000, 21200000, 24940000, 28500000, 50150000); MODE_NAMES: array[0..MODE_COUNT-1] of string = ( 'LSB','USB','DSB','CWL','CWU','FM','AM','SAM'); SMETER_WIDTH_RATIO = 0.32; SMETER_MARGIN = 3; TOP_SMETER_GAP = 8; TOP_VFO_FONT = 25; LEFT_PANEL_BTN_PAD = 3; LEFT_PANEL_BTN_GAP = 2; type { TMainForm } TMainForm = class(TForm) private // ---- Headless ядро (состояние + движки). Фаза 1 миграции: см. // RadioController.pas. Поля состояния/движков теперь живут в // FController; обращения идут как FController.FXxx. ---- FController: TRadioController; // True пока OnControllerState обновляет виджеты — чтобы программное // изменение слайдера/кнопки не вызывало повторно команду из OnChange. FSyncingFromController: Boolean; // ---- Network ---- // Список устройств (saved+discovered) → FController.FDeviceStore (общий с web). FDeviceDialog: TDeviceDialog; FPendingIP: string; // IP устройства для подключения FVfoOverlay: TVfoOverlay; // накладка SmartSDR-стиль на спектре FSampleRateOverlay: TSampleRateOverlay; // оверлей span/hide слева вверху спектра FPanelHidden: Boolean; // True = левая панель скрыта FLeftPanelW: Integer; // ширина панели до скрытия (DPI-safe restore) FPendingBoardType: Integer; // BoardType устройства для подключения // ---- UI-local state (мышь на спектре/водопаде) ---- FSpecDrag: Boolean; FSpecDragX0: Integer; // X при нажатии FSpecDragFreq: Double; // FCenterFreq при нажатии FBeaconArming: Boolean; // ждём клик по маяку (после включения BEACON) FSpectrumDirty: Boolean; // таймер должен перерисовать спектр/водопад // TX DUC-очередь (FDUCPending*) переехала в TRadioController (OnTXIQ). FSMeterPeak: Double; // верхняя граница светлой зоны FSMeterMin: Double; // нижняя граница светлой зоны FSMeterAvg: Double; // сглаженное среднее (EMA) // RX/seq + HP-телеметрия (FController.FLastSMeter/FwdW/SWR/SupplyV/SupplyA/PLLLock) // живут в TRadioController. // ---- Spectrum / Waterfall view ---- FSpecView: TSpectrumView; FWidebandView: TWidebandView; FUseOpenGLSpectrum: Boolean; FSpectrumWidth: Integer; // актуальная ширина для FDSPEngine и resize-детектора FSpectrumHeight: Integer; FWaterfallHeight:Integer; FDisplayFPS: Integer; FWaterfallDirty: Boolean; FWidebandDirty: Boolean; // Display state (FShow*/FSpec*/FWf*/FTXSpec*/FController.FWidebandFill/FController.FSpectrumFill) // переехало в TRadioController (нужно headless-load в DoConnectDevice). // Снимок последнего кадра спектра/водопада для web переехал в TRadioController // (FSpectrumBuf/FWaterfallBuf) — нужен headless-демону. // Waterfall frame timing FWaterfallFrameInterval: Integer; FWaterfallFrameCounter: Integer; FAgcLineCounter: Integer; // ---- Splitter ---- FSplitterDrag: Boolean; FSplitterDragY0: Integer; FSplitterSH0: Integer; FSplitterRatio: Double; // --- Settings --- FWebServer: TWebServer; FWebAdapter: TWebAdapter; // мост web-сервер ↔ контроллер (вынесенные WebOnXxx) FChannelController: TChannelController; // адаптер каналов памяти поверх контроллера FWebEnabled: Boolean; FWebPort: Integer; FWebBindAddr: string; FWebUser: string; FWebPass: string; // --- CAT --- FCATAdapter: TCATAdapter; // адаптер CAT поверх контроллера (движок+транспорты+контекст) FCATLastGlobal: TGlobalSettings; // текущие CAT-настройки (UI/persist; ApplySettings → адаптер) // Временные поля для передачи параметров в Synchronize-методы FWebSyncFreq: Double; FWebSyncInt: Integer; FWebSyncBool: Boolean; FWebSyncStr1: string; // для web-команд с текстом (IP/имя) FWebSyncStr2: string; FWebSyncM: TThreadMethod; FSettingsForm: TObject; // TSettingsForm (cast при использовании) FPendingDev: THPSDRDevice; // устройство ожидающее открытия WDSP FLightTheme: Boolean; // True = светлая тема FFreqMhzDigits: Integer; // 3=999MHz, 4=9.999GHz, 5=99.999GHz // ---- Timers ---- FMeterTimer: TTimer; FSpectrumTimer: TTimer; FAfterShowTimer:TTimer; // однократный таймер для пост-инициализации FRestoreL, FRestoreT: Integer; // позиция для отложенного восстановления FHasPendingRestore: Boolean; // ---- Top panel (toolbar + VFO group + S-meter) ---- PanelToolbar: TPanel; BtnDiscover: TFlatButton; BtnStartStop: TFlatButton; BtnSettings: TFlatButton; // ---- Left panel ---- PanelLeft: TPanel; PanelVfoA: TPanel; LblVfoALabel: TLabel; FreqDispA: TFreqDisplay; BtnTopVfoASelect: TFlatButton; BtnTopVfoATX: TFlatButton; PanelVfoB: TPanel; LblVfoBLabel: TLabel; FreqDispB: TFreqDisplay; BtnTopVfoBSelect: TFlatButton; BtnTopVfoBTX: TFlatButton; PanelVfoButtons: TPanel; BtnVfoSwap: TFlatButton; BtnVfoACopyB: TFlatButton; BtnVfoBCopyA: TFlatButton; PanelTopVfoGroup: TPanel; PanelTopVfoA: TPanel; PanelTopVfoB: TPanel; PanelBands: TPanel; BtnBand: array[0..BAND_COUNT-1] of TFlatButton; // Динамические кнопки XVTR (создаются/удаляются по мере того, // как пользователь enabled/disabled слоты в Settings → Transverter). BtnXvtrBand: array[0..CFG_XVTR_COUNT-1] of TFlatButton; PanelMode: TPanel; BtnMode: array[0..MODE_COUNT-1] of TFlatButton; PanelFilter: TPanel; BtnFilter: array[0..FILT_COUNT-1] of TFlatButton; BtnCTun: TFlatButton; BtnBeacon: TFlatButton; // QO-100 beacon lock (видим только на Pluto в трансвертере) BtnDUP: TFlatButton; PanelFMSQ: TPanel; BtnFMSQ: TFlatButton; TrkFMSQ: TFlatSlider; LblFMSQ: TLabel; PanelFMCTCSS: TPanel; BtnFMCTCSS: TFlatButton; BtnFMCTCSSTone: TFlatButton; FCTCSSDropDown: TFlatDropDown; PanelFMStep: TPanel; BtnFMStep: TFlatButton; BtnFMStepSel: TFlatButton; FStepDropDown: TFlatDropDown; PanelFMRpt: TPanel; BtnFMRptMinus: TFlatButton; BtnFMRptPlus: TFlatButton; EdFMRptOffset: TFlatEdit; PanelAGC: TPanel; BtnAGCMode: array[0..4] of TFlatButton; // FAST MED SLOW LONG OFF LblAGCTop: TLabel; // показывает значение уровня TrkAGC: TFlatSlider; // RX hw-gain (Pluto) — ОТДЕЛЬНЫЙ блок под AGC (WDSP). Виден только для Pluto. PanelPlutoAGC: TPanel; BtnPlutoGain: array[0..3] of TFlatButton; // FAST/SLOW/HYB/MAN (Tag=режим) TrkRxGain: TFlatSlider; // manual gain 0..73 dB LblRxGain: TLabel; // значение усиления TrkVolume: TFlatSlider; BtnNR: TFlatButton; BtnNB: TFlatButton; BtnSNB: TFlatButton; BtnANF: TFlatButton; BtnMute: TFlatButton; PanelSMeterRight: TPanel; PbSMeterRight: TPaintBox; PanelTX: TPanel; LblDrv: TLabel; LblDrvVal: TLabel; // значение drive: % (openHPSDR) или дБ (Pluto) TrkDrive: TFlatSlider; BtnMOX: TFlatButton; BtnTUN: TFlatButton; PanelRXBlock: TPanel; BtnChannels: TFlatButton; FChannelsDropDown: TFlatDropDown; FChannelsForm: TObject; // TChannelsForm (cast при использовании) FBeaconScopeForm: TBeaconScopeForm; // окно констелляции маяка (ПКМ по BEACON) // ---- Right panel ---- PanelRight: TPanel; PbSpectrum: TControl; PbWideband: TControl; PbWidebandRuler: TPaintBox; PbRuler: TPaintBox; // полоса частотных меток между спектром и водопадом PanelSplitter: TPanel; // перетаскиваемый разделитель спектр/водопад PbWaterfall: TControl; // ---- Status bar ---- StatusPanel: TMainStatusBar; // ---- Helpers ---- procedure DoInvoke(M: TThreadMethod); // маршалинг команд контроллера в UI-поток procedure OnControllerState(Sender: TObject; Field: TRadioField); // контроллер → UI procedure BuildUI; function LeftPanelButtonLeft(PanelWidth, ColCount, ColIndex: Integer): Integer; function LeftPanelButtonWidth(PanelWidth, ColCount, ColIndex: Integer): Integer; procedure ApplyDarkTheme; procedure SetLightTheme(V: Boolean); procedure StyleButton(B: TFlatButton; Active: Boolean = False); procedure StyleSpanButton(B: TFlatButton; Active: Boolean = False); procedure SetStatusText(Index: Integer; const Text: string); procedure SetRadioOfflineStatus(const ConnText: string; ClearDevice: Boolean = False); procedure ApplyStatusTheme(const T: TAppTheme); procedure PositionSampleRateOverlay; procedure OnSampleRateOverlayInvalidate(Sender: TObject); procedure OnSampleRateSelect(SampleRate: Integer); procedure OnSampleRateHidePanel; procedure UpdateVfoDisplay; function FormatFreq(Hz: Double): string; procedure LayoutTopVfoBlock; procedure ResizeSMeter; procedure ResizeSpectrumPanels; function UpdateWidebandFrequencyView: Boolean; procedure InvalidateGridCache; procedure SyncSpecViewFreq; procedure RecreateDSPEngine(ASampleRate: Integer); function EnsureWDSPWisdom: Boolean; // Network callbacks procedure OnWidebandCB(ADCIndex: Integer; const Samples: array of SmallInt; Count: Integer); procedure OnWDSPOpenDone(Success: Boolean); function ResolveDevice(const IP: string): THPSDRDevice; procedure RelabelBands; // подписи band-кнопок под план бэкенда procedure ApplyBackendCapsToUI; // sample-rate пресеты + гейтинг полей по Caps procedure UpdateRxGainUI; // рендер RX hw-gain (Pluto) procedure LayoutLeftPanel(Pluto: Boolean); // показать/скрыть Pluto-блок + reflow procedure BtnPlutoGainClick(Sender: TObject); procedure DoConnectDevice(const Dev: THPSDRDevice); // Старт с уже выбранным устройством (preload rate + WDSP open + connect). // Переиспользуется десктоп-диалогом и web-оверлеем — без модального диалога. procedure StartWithDevice(const Dev: THPSDRDevice); // STOP-teardown: persistence + graceful Run=0 + Disconnect + offline-статус. // Общий для десктоп-STOP и web-disconnect. procedure DoStopAndDisconnect; // DSP/Audio callbacks (вызываются из рабочих потоков) 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 UpdateTXMeters; procedure UpdateStatusField7; // поле 7: PLL (HPSDR) или beacon (Pluto) procedure UpdateStatusField3; // поле 3: Supply (HPSDR) или Temp/RSSI (Pluto) function BeaconStatusText: string; // компактный статус QO-100 beacon lock procedure DoUpdateDDCSeq(DDCIdx: Integer; Seq: LongWord); // Event handlers procedure BtnDiscoverClick(Sender: TObject); procedure BtnDiscoverFromDialog(Sender: TObject); procedure BtnStartStopClick(Sender: TObject); procedure FreqDispAChanged(Sender: TObject; NewFreq: Int64); procedure ApplyVfoA(NewFreq: Int64); procedure FreqDispAClick(Sender: TObject); procedure FreqDispBClick(Sender: TObject); procedure BtnTopVfoASelectClick(Sender: TObject); procedure BtnTopVfoATXClick(Sender: TObject); procedure BtnTopVfoBSelectClick(Sender: TObject); procedure BtnTopVfoBTXClick(Sender: TObject); procedure ActivateVfo(Idx: Integer); function ActiveVfoFreq: Int64; // Настройки procedure SaveCurrentBand; procedure RestoreBand(BandIdx: Integer); procedure SaveAllAndExit; procedure RestoreWindowBounds; procedure SaveWindowBounds; procedure FreqDispBChanged(Sender: TObject; NewFreq: Int64); procedure BtnBandClick(Sender: TObject); procedure BtnModeClick(Sender: TObject); procedure BtnFilterClick(Sender: TObject); procedure BtnCTunClick(Sender: TObject); procedure BtnBeaconClick(Sender: TObject); procedure BtnBeaconMouseDown(Sender: TObject; Button: TMouseButton; Shift: TShiftState; X, Y: Integer); procedure UpdateBeaconButton; procedure BtnDUPClick(Sender: TObject); procedure ApplyDUP(Active: Boolean); procedure BtnTUNClick(Sender: TObject); procedure ApplyTUN(Active: Boolean); procedure UpdateFilterButtons; procedure BtnFMSQClick(Sender: TObject); procedure TrkFMSQChange(Sender: TObject); procedure ApplyFMSquelch; procedure BtnFMCTCSSClick(Sender: TObject); procedure BtnFMCTCSSToneClick(Sender: TObject); procedure SetFMCTCSSTone(Idx: Integer); procedure CloseCTCSSPopup; procedure BtnFMRptMinusClick(Sender: TObject); procedure BtnFMRptPlusClick(Sender: TObject); procedure EdFMRptOffsetChange(Sender: TObject); procedure UpdateRptAutoState; procedure SetFMStep(Idx: Integer); procedure CloseFMStepPopup; procedure BtnFMStepClick(Sender: TObject); procedure BtnFMStepSelClick(Sender: TObject); procedure OnCTCSSDropDownSelect(Sender: TObject; Idx: Integer); procedure OnStepDropDownSelect(Sender: TObject; Idx: Integer); procedure BtnChannelsClick(Sender: TObject); procedure BtnChannelsMouseDown(Sender: TObject; Button: TMouseButton; Shift: TShiftState; X, Y: Integer); procedure OnChannelsDropDownSelect(Sender: TObject; Idx: Integer); procedure RefreshChannelsDropDown; procedure OnChannelStoreChanged(Sender: TObject); procedure OnChannelActiveChanged(Idx: Integer; const Name: string; Active: Boolean); procedure PbSpectrumMouseDown(Sender: TObject; Button: TMouseButton; Shift: TShiftState; X, Y: Integer); procedure PbSpectrumMouseMove(Sender: TObject; Shift: TShiftState; X, Y: Integer); procedure PbSpectrumMouseUp(Sender: TObject; Button: TMouseButton; Shift: TShiftState; X, Y: Integer); procedure PbSpectrumMouseLeave(Sender: TObject); procedure PbWidebandMouseDown(Sender: TObject; Button: TMouseButton; Shift: TShiftState; X, Y: Integer); procedure PbWaterfallMouseDown(Sender: TObject; Button: TMouseButton; Shift: TShiftState; X, Y: Integer); procedure PbWaterfallMouseMove(Sender: TObject; Shift: TShiftState; X, Y: Integer); procedure PbWaterfallMouseUp(Sender: TObject; Button: TMouseButton; Shift: TShiftState; X, Y: Integer); procedure DoSpectrumClick(PixelX: Integer; PanelWidth: Integer); procedure DoSpectrumDrag(PixelX: Integer; PanelWidth: Integer); procedure DoWidebandClick(PixelX: Integer; PanelWidth: Integer); procedure BtnVfoSwapClick(Sender: TObject); procedure BtnVfoACopyBClick(Sender: TObject); procedure BtnVfoBCopyAClick(Sender: TObject); procedure BtnMOXClick(Sender: TObject); procedure BtnMuteClick(Sender: TObject); procedure UpdateNRButton; procedure UpdateNBButton; procedure UpdateSNBButton; procedure UpdateANFButton; procedure BtnNRClick(Sender: TObject); procedure BtnNBClick(Sender: TObject); procedure BtnSNBClick(Sender: TObject); procedure BtnANFClick(Sender: TObject); // Веб-интерфейс: host-зависимые callbacks от TWebServer (UI-обёртки). // Чистые-контроллерные перенесены в TWebAdapter (Mode/Filter/AGC/AGCTop/Volume/ // WfAGC/WfNF/Mute/NR/NB/SNB/ANF/FreqB/Center/Attn/ActiveVfo/Ctun/MOX/Tun/FMStep/ // ClientActive). Здесь только то, что дёргает UI-обёртки MainForm. procedure WebOnRun(On_: Boolean); // Discovery / connect / saved-CRUD из web (маршалятся в UI) procedure WebOnConnect(const IP: string); procedure WebOnDiscover; procedure WebOnDisconnect; procedure WebOnDevAdd(const AName, IP: string); procedure WebOnDevRemove(Idx: Integer); procedure WebOnDevAutoStart(Idx: Integer); procedure SyncWebConnect; procedure SyncWebDiscover; procedure SyncWebDisconnect; procedure SyncWebDevAdd; procedure SyncWebDevRemove; procedure SyncWebDevAutoStart; procedure PushDeviceListToWeb; // store → FWebServer.SetDeviceList // Synchronize-обёртки (выполняются в UI-потоке) — только host-зависимые procedure SyncWebRun; // --- CAT settings (UI/persist; контекст+движок — в TCATAdapter) ---------- procedure OnCATSettingsChange( const SerEnabled: array of Boolean; const SerPort: array of string; const SerBaud, SerDataBits, SerStopBits, SerParity: array of Integer; TcpEnabled: Boolean; TcpPort: Integer); procedure OnAlexSettingsChange(const A: TAlexSettings); procedure OnXvtrSettingsChange(const X: TXvtrSettings); procedure PushXvtrToWeb; // XVTR helpers function FindXvtrIdxForFreq(FreqHz: Double): Integer; function XvtrTranslate(VisibleHz: Double): Double; function XvtrTranslateTX(VisibleHz: Double): Double; procedure ApplyXvtrToNetwork; procedure RebuildXvtrButtons; procedure RelayoutBelowBands; procedure BtnXvtrBandClick(Sender: TObject); procedure ActivateXvtrBand(Idx: Integer); procedure DeactivateXvtr; // ------------------------------------------------------------------------- procedure BtnSettingsClick(Sender: TObject); procedure PositionVfoOverlay; procedure OnModeFilterSelect(Mode: Integer; FilterBW: Integer); procedure OnVfoOverlayDSPChange(NRMode, NBMode: Integer; SNBOn, ANFOn: Boolean); procedure OnVfoOverlayAGCChange(AGCMode: Integer); procedure OnVfoOverlayInvalidate(Sender: TObject); procedure PbSpectrumDblClick(Sender: TObject); procedure TrkDriveChange(Sender: TObject); procedure UpdateDriveLabel; // % (openHPSDR) / дБ (Pluto) рядом с ползунком drive procedure TrkVolumeChange(Sender: TObject); function CalcDriveByte: Byte; function ActiveTXFreqHz: Double; function DefaultMicSource: TTXMicSource; procedure ApplyMOX(Active: Boolean); procedure OnPASettingsChange(MaxPower: Double; const BandCal: array of Double); procedure OnPlutoTxMaxAttChange(MaxAttDb: Double); procedure OnVHFCalSettingsChange(const VHFCal: array of Double); procedure OnTXSettingsChange(const T: TTXSettings); procedure BtnAGCModeClick(Sender: TObject); procedure TrkAGCChange(Sender: TObject); procedure TrkRxGainChange(Sender: TObject); // (BtnPlutoGainClick объявлен выше) procedure MeterTimerTick(Sender: TObject); procedure SpectrumTimerTick(Sender: TObject); procedure RightPanelResize(Sender: TObject); procedure AfterShowTick(Sender: TObject); // Splitter handlers procedure SplitterMouseDown(Sender: TObject; Button: TMouseButton; Shift: TShiftState; X, Y: Integer); procedure SplitterMouseMove(Sender: TObject; Shift: TShiftState; X, Y: Integer); procedure SplitterMouseUp(Sender: TObject; Button: TMouseButton; Shift: TShiftState; X, Y: Integer); public // Методы для SettingsForm — немедленное применение настроек procedure ApplyDisplayParams(FFTSize, WinType, SpecDet, SpecAvgMode: Integer; SpecAvgTimeMS: Double); procedure ApplyWaterfallParams(WfDet, WfAvgMode: Integer; WfAvgTimeMS, WfHigh, WfLow, WfAGCOffset: Double); procedure ApplyWfAGCNF(WfAGC, WfNF: Boolean); procedure ApplyADCSettings(Dither, Random: Boolean); procedure ApplyWebSettings(Enabled: Boolean; Port: Integer; const BindAddr, User, Pass: string); procedure ApplyGridParams(RefLevel, Range, GridStep: Double); // Пушит активные grid-параметры (RX или TX в зависимости от FTransmitting) // в FSpecView и сбрасывает кэш сетки. Вызывается при смене RX↔TX и при // правке любого из соответствующих полей (FSpec*, FTXSpec*). procedure ApplySpecViewGridFromState; procedure ApplyAudioDevice(DevIndex: Integer; const DevName: string); procedure ApplyAudioInputDevice(DevIndex: Integer; const DevName: string); procedure ApplyAudioBufferSize(BufferSize: Integer); procedure ApplySendAudioToRadio(Enabled: Boolean); // TX settings (ApplyTXSettingsToDSP/SendDUCSpecificFromSettings/ // BuildMicLineSelectByte) переехали в TRadioController. procedure ApplyVisibility(ShowSpectrum, ShowWaterfall, ShowWideband, WidebandFill, SpectrumFill: Boolean); procedure ApplyFPS(FPS: Integer); procedure ApplyFreqMhzDigits(Digits: Integer); procedure UpdateFreqDisplayMax; // верхняя граница FreqDisplay (XVTR/Pluto/HF) published // Обработчики событий формы — должны быть в published для RTTI/LFM procedure FormCreate(Sender: TObject); procedure FormDestroy(Sender: TObject); procedure FormClose(Sender: TObject; var CloseAction: TCloseAction); procedure FormMouseWheel(Sender: TObject; Shift: TShiftState; WheelDelta: Integer; MousePos: TPoint; var Handled: Boolean); end; var MainForm: TMainForm; implementation uses SettingsForm; {$R *.lfm} function TMainForm.LeftPanelButtonWidth(PanelWidth, ColCount, ColIndex: Integer): Integer; var InnerW, BaseW, Extra: Integer; begin if ColCount <= 0 then Exit(0); InnerW := PanelWidth - 2 * LEFT_PANEL_BTN_PAD - (ColCount - 1) * LEFT_PANEL_BTN_GAP; BaseW := InnerW div ColCount; Extra := InnerW mod ColCount; Result := BaseW; if ColIndex < Extra then Inc(Result); end; function TMainForm.LeftPanelButtonLeft(PanelWidth, ColCount, ColIndex: Integer): Integer; var i: Integer; begin Result := LEFT_PANEL_BTN_PAD; for i := 0 to ColIndex - 1 do Inc(Result, LeftPanelButtonWidth(PanelWidth, ColCount, i) + LEFT_PANEL_BTN_GAP); end; // =========================================================================== // Settings helpers // =========================================================================== procedure TMainForm.RestoreWindowBounds; var L, T, Wd, Ht, SavedDPI, CurDPI: Integer; Maximized: Boolean; R, WA: TRect; Mon: TMonitor; begin FController.FSettings.LoadWindowBounds(L, T, Wd, Ht, SavedDPI, Maximized); if (Wd > 400) and (Ht > 300) then begin CurDPI := CurrentScreenDPI; if SavedDPI <= 0 then SavedDPI := CurDPI; if SavedDPI <> CurDPI then begin // Позицию не масштабируем: на LCLWin32 координаты физические и не зависят // от DPI-фактора; на Qt6/GTK/Cocoa — логические (device-independent), тоже // инвариантны. Выход за рабочую область поймает EnsureRange ниже. // // Размер масштабируем, чтобы окно выглядело одинаково при смене DPI: // LCLWin32: физические пиксели → Wd × CurDPI/SavedDPI // Qt6/GTK/Cocoa: логические пиксели → Wd × SavedDPI/CurDPI (обратное) {$IFDEF LCLWin32} Wd := MulDiv(Wd, CurDPI, SavedDPI); Ht := MulDiv(Ht, CurDPI, SavedDPI); {$ELSE} Wd := MulDiv(Wd, SavedDPI, CurDPI); Ht := MulDiv(Ht, SavedDPI, CurDPI); {$ENDIF} end; // Минимальный размер: // LCLWin32 — в физических пикселях, масштабируется с DPI. // Qt6/GTK/Cocoa — в логических пикселях, device-independent, фиксированный. {$IFDEF LCLWin32} Wd := Max(MulDiv(900, CurDPI, 96), Wd); Ht := Max(MulDiv(560, CurDPI, 96), Ht); {$ELSE} Wd := Max(900, Wd); Ht := Max(560, Ht); {$ENDIF} R := Bounds(L, T, Wd, Ht); Mon := Screen.MonitorFromRect(R); if Mon <> nil then WA := Mon.WorkareaRect else WA := Screen.PrimaryMonitor.WorkareaRect; if Wd > WA.Right - WA.Left then Wd := WA.Right - WA.Left; if Ht > WA.Bottom - WA.Top then Ht := WA.Bottom - WA.Top; L := EnsureRange(L, WA.Left, Max(WA.Left, WA.Right - Wd)); T := EnsureRange(T, WA.Top, Max(WA.Top, WA.Bottom - Ht)); SetBounds(L, T, Wd, Ht); if Maximized then WindowState := wsMaximized else begin // На Qt6/X11 frame extents ещё не известны Qt во время FormCreate, // поэтому move() может поставить контент туда, куда мы хотели рамку. // Повторно применим позицию в AfterShowTick, когда WM уже декорировал окно. FRestoreL := L; FRestoreT := T; FHasPendingRestore := True; end; end; end; procedure TMainForm.SaveWindowBounds; var L, T, Wd, Ht, DPI: Integer; Maximized: Boolean; begin Maximized := WindowState = wsMaximized; if WindowState = wsNormal then begin L := Left; T := Top; Wd := Width; Ht := Height; end else begin L := RestoredLeft; T := RestoredTop; Wd := RestoredWidth; Ht := RestoredHeight; end; // RestoredLeft/Top могут вернуть мусор на Qt6 при максимизации; // ставим разумные запасные значения if (L < -32000) or (T < -32000) then begin L := 80; T := 80; end; if (Wd <= 400) or (Ht <= 300) then Exit; DPI := CurrentScreenDPI; FController.FSettings.SaveWindowBounds(L, T, Wd, Ht, DPI, Maximized); end; procedure TMainForm.SaveCurrentBand; begin // Логика сохранения (band-cache/XVTR-слот + конфиг) — в контроллере. FController.SaveCurrentBand; end; procedure TMainForm.RestoreBand(BandIdx: Integer); begin // Тонкий делегат: состояние+DSP+финальная перестройка+рендер — в команде // контроллера; сброс Wf-avg буфера — в OnControllerState(rfBandRestore). FController.RestoreBand(BandIdx); end; procedure TMainForm.SaveAllAndExit; begin if FController.FDevConnected then begin SaveCurrentBand; FController.SaveGlobalSettings; if FController.FDevConnected then FController.FSettings.SaveTX(FController.FDevMAC, FController.FTXSettings); // Запомнить LastFreq A/B XVTR для следующего запуска if (FController.FCurrentXvtr >= 0) and (FController.FCurrentXvtr < CFG_XVTR_COUNT) then begin FController.FXvtrSettings.Entries[FController.FCurrentXvtr].LastFreq := FController.FVfoA; FController.FXvtrSettings.Entries[FController.FCurrentXvtr].LastFreqB := FController.FVfoB; end; FController.FSettings.SaveXvtr(FController.FDevMAC, FController.FXvtrSettings); FController.FSettings.Save; end; end; procedure TMainForm.FormCreate(Sender: TObject); var bi_, StartupRate, i: Integer; WebCfg: TWebSettings; StartupVfoA, StartupVfoB: Double; begin SetExceptionMask(GetExceptionMask + [exZeroDivide, exInvalidOp]); // Headless ядро: владеет состоянием и движками. Создаём первым — все // присваивания FController.FXxx ниже зависят от него. Движки создаются/ // освобождаются здесь же (в полях FController) — Фаза 1 миграции. FController := TRadioController.Create; FController.OnInvoke := DoInvoke; FController.OnStateChanged := OnControllerState; // Адаптер каналов поверх контроллера: оркеструет команды, держит активный // канал/pre-channel-drive. Рендер кнопки Channel — через OnActiveChanged; // XVTR-вход/выход — через UI-обёртки (wideband/web живут в них). FChannelController := TChannelController.Create(FController); FChannelController.OnActiveChanged := OnChannelActiveChanged; // Канал-оркестрация фаером из контроллера в конце SetVfoA (любой путь тюнинга). FController.OnAfterTune := FChannelController.OnVfoTuned; FController.FVfoA := 14200000; FController.FVfoB := 7100000; FController.FActiveVfo := 0; FController.FMode := 1; FController.FFilter := 5; FController.FAGCMode := 1; // MEDIUM FController.FAGCTop := 90; // −90 dBm // default: зависит от режима, будет сброшен в UpdateFilterButtons FController.FCTun := False; FController.FFilterBW := 2700; FSpecDrag := False; FController.FDriveLevel := 0; FController.FPAMaxPower := 100.0; for i := 0 to BAND_COUNT - 1 do FController.FPABandCal[i] := CFG_PA_BAND_CAL_DEFAULT[i]; for i := 0 to CFG_XVTR_COUNT - 1 do FController.FVHFBandCal[i] := CFG_VHF_CAL_DEFAULT; FController.FRunning := False; FController.FTransmitting := False; FController.FSplitTxB := False; FController.FDisplayDuplex := False; FController.FTuning := False; FController.FMuted := False; FController.FVolume := 70; FController.FCenterFreq := FController.FVfoA; FController.FSpanHz := 192000; FController.FSampleRate := 192000; FController.FCurrentBand := 5; // 20m по умолчанию // Waterfall normalization FController.FWfManualHigh := -80.0; FController.FWfManualLow := -130.0; FController.FWfAGCOffset := 0.0; FController.FWfAGCEnabled := True; FController.FWfNFEnabled := False; FController.FDitherEnabled := True; FController.FRandomEnabled := True; FController.FFMDeviation := FILT_FM_DEV[FILT_FM_DEF]; FController.FFMCTCSSOn := False; FController.FFMCTCSSToneIdx := 0; FController.FFMSQOn := False; FController.FFMSQLevel := 30; FController.FFMStepOn := True; FController.FFMStepIdx := FM_STEP_DEF; FController.FFMRptDir := RPT_NONE; FController.FFMRptOffsetHz := RPT_DEFAULT_2M; FController.FFMRptAutoActive := False; FController.FFMCTCSSAutoActive := False; FWaterfallFrameInterval := 2; FWaterfallFrameCounter := 0; FController.FDevConnected := False; // Spectrum grid settings defaults FController.FSpecRefLevel := -20.0; FController.FSpecRange := 110.0; FController.FSpecGridStep := 10.0; // TX grid defaults (синхронно с TSettingsManager.DefaultTX) FController.FTXSpecRefLevel := 0.0; FController.FTXSpecRange := 80.0; FController.FTXSpecGridStep := 10.0; FSettingsForm := nil; FChannelsForm := nil; FBeaconScopeForm := nil; FillChar(FController.FDevMAC, SizeOf(FController.FDevMAC), 0); // Инициализируем кэш диапазонов умолчаниями for bi_ := 0 to CFG_BAND_COUNT - 1 do TSettingsManager.DefaultBand(bi_, FController.FBandCache[bi_]); FController.FCurrentBand := 5; // Загружаем JSON настройки (FSettings создаётся в TRadioController.Create) FController.FSettings.Load; // Загружаем список каналов FController.FChannelStore := TChannelStore.Create(CHANNELS_FILE); FController.FChannelStore.Load; if FController.FSettings.LoadStartupPreview(StartupVfoA, StartupVfoB, StartupRate) then begin FController.FVfoA := StartupVfoA; FController.FVfoB := StartupVfoB; FController.FCenterFreq := FController.FVfoA; if StartupRate > 0 then begin FController.FSampleRate := StartupRate; FController.FSpanHz := StartupRate; end; end; // Восстанавливаем размер/позицию окна при старте (до подключения устройства) RestoreWindowBounds; // S-метр позиционируем после рестора размера окна // (будет пересчитан в первом тике SpectrumTimerTick) FController.FLastSMeter := -130; // Загружаем настройки веб-сервера из JSON и создаём сервер FController.FSettings.LoadWebSettings(WebCfg); FWebEnabled := WebCfg.Enabled; FWebPort := WebCfg.Port; FWebBindAddr := WebCfg.BindAddr; FWebUser := WebCfg.User; FWebPass := WebCfg.Pass; FWebServer := TWebServer.Create(FWebUser, FWebPass, Word(FWebPort), FWebBindAddr); // Вынесенные в TWebAdapter чистые-контроллерные команды (host=nil: их хендлеры // ещё не нужны хосту; host-зависимые WebOnXxx пока на MainForm — переедут в WA2). FWebAdapter := TWebAdapter.Create(FController, FWebServer, nil); FWebServer.OnMode := FWebAdapter.OnMode; FWebServer.OnFilter := FWebAdapter.OnFilter; FWebServer.OnAGC := FWebAdapter.OnAGC; FWebServer.OnAGCTop := FWebAdapter.OnAGCTop; FWebServer.OnVolume := FWebAdapter.OnVolume; FWebServer.OnWfAGC := FWebAdapter.OnWfAGC; FWebServer.OnWfNF := FWebAdapter.OnWfNF; FWebServer.OnMute := FWebAdapter.OnMute; FWebServer.OnNR := FWebAdapter.OnNR; FWebServer.OnNB := FWebAdapter.OnNB; FWebServer.OnSNB := FWebAdapter.OnSNB; FWebServer.OnANF := FWebAdapter.OnANF; FWebServer.OnFreqB := FWebAdapter.OnFreqB; FWebServer.OnCenter := FWebAdapter.OnCenter; FWebServer.OnAttn := FWebAdapter.OnAttn; // Web-слой держит FController.FWebClientActive актуальным (выбор mic-source // при TX, в т.ч. для HWPTT из контроллера) — больше не зеркалим лениво. FWebServer.OnClientActiveChanged := FWebAdapter.OnClientActiveChanged; // Host-зависимые (UI-обёртки) и исходящий push — пока на MainForm: FWebServer.OnCtun := FWebAdapter.OnCtun; FWebServer.OnActiveVfo := FWebAdapter.OnActiveVfo; FWebServer.OnMOX := FWebAdapter.OnMOX; FWebServer.OnTun := FWebAdapter.OnTun; FWebServer.OnFMStep := FWebAdapter.OnFMStep; // Host-зависимые (UI-обёртки) — пока на MainForm: FWebServer.OnFreq := FWebAdapter.OnFreq; FWebServer.OnBand := FWebAdapter.OnBand; FWebServer.OnSpan := FWebAdapter.OnSpan; FWebServer.OnRun := WebOnRun; FWebServer.OnDrive := FWebAdapter.OnDrive; FWebServer.OnFreqA := FWebAdapter.OnFreqA; FWebServer.OnWebMic := FWebAdapter.OnMic; FWebServer.OnXvtrBand := FWebAdapter.OnXvtrBand; FWebServer.OnConnect := WebOnConnect; FWebServer.OnDiscoverDev := WebOnDiscover; FWebServer.OnDisconnectDev := WebOnDisconnect; FWebServer.OnDevAdd := WebOnDevAdd; FWebServer.OnDevRemove := WebOnDevRemove; FWebServer.OnDevAutoStart := WebOnDevAutoStart; if FWebEnabled then FWebServer.Start; PushXvtrToWeb; PushDeviceListToWeb; // saved-устройства сразу видны web-overlay FillChar(FCATLastGlobal, SizeOf(FCATLastGlobal), 0); FCATLastGlobal.CATTcpPort := 19090; for i := 0 to 3 do begin FCATLastGlobal.CATSerialBaud[i] := 9600; FCATLastGlobal.CATSerialDataBits[i] := 8; FCATLastGlobal.CATSerialStopBits[i] := 1; {$IFDEF MSWINDOWS} FCATLastGlobal.CATSerialPort[i] := 'COM' + IntToStr(i + 1); {$ELSE} FCATLastGlobal.CATSerialPort[i] := '/dev/ttyS' + IntToStr(i); {$ENDIF} end; FController.FSettings.LoadCATSettings(FCATLastGlobal); FLightTheme := FController.FSettings.LoadTheme; FCATAdapter := TCATAdapter.Create(FController); FCATAdapter.ApplySettings(FCATLastGlobal); FSMeterPeak := -130; FSMeterMin := -130; FSMeterAvg := -130; FController.FRXPacketCount := 0; FController.FActiveDDC := 0; FController.FLastDDCSeq := 0; FController.FLastDDCIndex := 0; FillChar(FController.FDDCLastSeq, SizeOf(FController.FDDCLastSeq), 0); FillChar(FController.FDDCSeqValid, SizeOf(FController.FDDCSeqValid), 0); FController.FSeqErrorCount := 0; FController.FLastSeqErrorDDC := -1; FController.FLastSeqErrorDelta:= 0; FController.FSeqOkStreak := 0; FController.FLastFwdW := 0; FController.FLastSWR := 1; FController.FLastSupplyV:= -1; FController.FLastSupplyA:= -1; FController.FLastPLLLock:= False; FDeviceDialog := TDeviceDialog.Create(Self); FDeviceDialog.Store := FController.FDeviceStore; FDeviceDialog.OnDiscover := BtnDiscoverFromDialog; FVfoOverlay := TVfoOverlay.Create(Self); FVfoOverlay.OnSelect := OnModeFilterSelect; FVfoOverlay.OnDSPChange := OnVfoOverlayDSPChange; FVfoOverlay.OnAGCChange := OnVfoOverlayAGCChange; FVfoOverlay.OnInvalidate := OnVfoOverlayInvalidate; FVfoOverlay.Visible := False; FPanelHidden := False; FController.FShowSpectrum := True; FController.FShowWaterfall := True; FController.FShowWideband := False; FController.FWidebandFill := False; FController.FSpectrumFill := True; FDisplayFPS := 60; FFreqMhzDigits := 3; FSplitterRatio := 0.40; FSplitterDrag := False; FUseOpenGLSpectrum := HasOpenGLSpectrumSwitch; if FUseOpenGLSpectrum and (not QtPlatformAllowsOpenGLControls) then begin MessageDlg('OpenGL rendering disabled', 'OpenGL rendering is not available in the current Qt Wayland mode.' + LineEnding + LineEnding + 'The Lazarus/LCL Qt6 OpenGL control used by EWSDR requires an X11 display handle, ' + 'but the native Wayland backend does not provide one. Using it here can cause an access violation during startup.' + LineEnding + LineEnding + 'To run EWSDR with OpenGL rendering, start it through XWayland:' + LineEnding + 'QT_QPA_PLATFORM=xcb ./ewsdr --opengl' + LineEnding + LineEnding + 'OpenGL rendering will be disabled for this session.', mtWarning, [mbOK], 0); FUseOpenGLSpectrum := False; end; if FUseOpenGLSpectrum then FWaterfallFrameInterval := 1; if FUseOpenGLSpectrum then FSpecView := TSpectrumViewOpenGL.Create else FSpecView := TSpectrumView.Create; FSpecView.VfoOverlay := FVfoOverlay; FSpecView.VfoA := FController.FVfoA; FSpecView.VfoB := FController.FVfoB; FSpecView.ActiveVfo := FController.FActiveVfo; FSpecView.CenterFreq := FController.FCenterFreq; FSpecView.SpanHz := FController.FSpanHz; FSpecView.Mode := FController.FMode; FSpecView.FilterBW := FController.FFilterBW; FSpecView.AGCTop := FController.FAGCTop; FSpecView.WfAGCEnabled := FController.FWfAGCEnabled; FSpecView.WfNFEnabled := FController.FWfNFEnabled; FSpecView.WfManualHigh := FController.FWfManualHigh; FSpecView.WfManualLow := FController.FWfManualLow; FSpecView.WfAGCOffset := FController.FWfAGCOffset; FSpecView.SpecRefLevel := FController.FSpecRefLevel; FSpecView.SpecRange := FController.FSpecRange; FSpecView.SpecGridStep := FController.FSpecGridStep; // TX overlay: ширина окна = TX_SAMPLE_RATE из движка (фикс 192k) FSpecView.TXSpanHz := WDSPEngine.TX_SAMPLE_RATE; FSpecView.WfFrameInterval := FWaterfallFrameInterval; FSpecView.PAMaxPower := FController.FPAMaxPower; FWidebandView := TWidebandView.Create; // Движки создаёт контроллер (владелец lifecycle — нужно для демона); здесь // лишь навешиваем колбэки на методы MainForm. FSampleRate уже загружен из // startup-preview выше, поэтому FDSPEngine рождается с нужной частотой. FController.CreateEngines(FController.FSampleRate); FController.FNetwork.OnDeviceFound := FController.OnDeviceFound; FController.FNetwork.OnHPStatus := FController.OnHPStatus; FController.FNetwork.OnDDCIQ := FController.OnDDCIQ; FController.FNetwork.OnMicPacket := FController.OnMicPacket; FController.FNetwork.OnWideband := OnWidebandCB; // Дефолты TX, Alex и XVTR (на случай если устройство ещё не выбрано). TSettingsManager.DefaultTX(FController.FTXSettings); TSettingsManager.DefaultAlex(FController.FAlexSettings); TSettingsManager.DefaultXvtr(FController.FXvtrSettings); FController.FCurrentXvtr := -1; BuildUI; ResizeSMeter; ApplyDarkTheme; UpdateVfoDisplay; UpdateFilterButtons; // DSP Engine.Open (загрузка libwdsp, ~1-2 сек) вызывается при нажатии START. FController.FWDSPReady := False; FController.FDSPEngine.OnAudio := FController.OnAudioReady; FController.FDSPEngine.OnSpectrum := FController.OnSpectrumReady; FController.FDSPEngine.OnWaterfall := FController.OnWaterfallReady; FController.FDSPEngine.OnTXIQ := FController.OnTXIQ; // Web-клиент перехватывает RX-аудио (моно-микс) у контроллера, если подключён. FController.OnAudioConsume := FWebAdapter.AudioConsume; // Сырьё спектра/водопада из контроллера → рендер FSpecView (UI-подписчик). FController.OnSpectrumData := OnSpectrumReady; FController.OnWaterfallData := OnWaterfallReady; // Sound-card mic путь: WDSP TX-thread дёргает этот колбэк перед каждым // тиком, если выбран источник SoundCard. Для Radio колбэк не вызывается. FController.FDSPEngine.OnPullMicSamples := FController.PullSoundCardMic; 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; // Грациозный стоп радио (Run=0) до освобождения; сами движки закрывает и // освобождает TRadioController.Destroy (FreeEngines) ниже. if FController.FNetwork.Running then FController.FNetwork.SetRunAndFreq(False, XvtrTranslate(FController.FCenterFreq), XvtrTranslate(FController.FCenterFreq), 0); FController.FNetwork.Disconnect; FreeAndNil(FSpecView); FreeAndNil(FWidebandView); // Сохраняем настройки при закрытии if FController.FDevConnected then begin SaveCurrentBand; FController.SaveGlobalSettings; if FController.FDevConnected then FController.FSettings.SaveTX(FController.FDevMAC, FController.FTXSettings); end; // Размер окна сохраняем всегда (не зависит от подключения) FController.FSettings.SaveStartupPreview(FController.FVfoA, FController.FVfoB, FController.FSampleRate); FController.FSettings.Save; FreeAndNil(FChannelController); // адаптер каналов (не владеет store/контроллером) FreeAndNil(FController.FChannelStore); FWebServer.Stop; FreeAndNil(FWebAdapter); // адаптер не владеет сервером — освобождаем после Stop FWebServer.Free; FreeAndNil(FCATAdapter); // его Destroy останавливает+освобождает CAT движок/транспорты // Ядро освобождаем последним — его Destroy закрывает и освобождает движки // (FreeEngines) и FSettings. FreeAndNil(FController); end; // Маршалинг команд контроллера в UI-поток (фронтенды зовут FController.Invoke). // В режиме демона хозяин подставит другой механизм (очередь/прямой вызов). procedure TMainForm.DoInvoke(M: TThreadMethod); begin TThread.Synchronize(nil, M); end; // Контроллер изменил состояние — обновляем соответствующие виджеты. // Выполняется в UI-потоке (команды контроллера вызываются из UI-потока либо // маршалятся адаптерами). FSyncingFromController защищает от рекурсии, когда // программное изменение виджета триггерит его OnChange. procedure TMainForm.OnControllerState(Sender: TObject; Field: TRadioField); var i: Integer; SavedFilter, SavedFilterBW: Integer; begin FSyncingFromController := True; try case Field of rfVfoA: begin FreqDispA.Frequency := Round(FController.FVfoA); // Не рисуем синхронно из wheel/drag: частые события забивают UI-поток. // Invalidate лишь планирует; DrawSpectrum откладываем (FSpectrumDirty). // Линейку дёргаем сразу — её рендер дешёвый, иначе отстаёт от спектра. SyncSpecViewFreq; FSpectrumDirty := True; PbSpectrum.Invalidate; if PbRuler <> nil then PbRuler.Invalidate; if FController.FLastVfoScrolled then begin FWaterfallDirty := True; if PbWaterfall <> nil then PbWaterfall.Invalidate; end; end; rfActiveVfo: begin UpdateVfoDisplay; // дисплеи A/B + подсветка RX/TX-кнопок FSpecView.InvalidateRulerCache; SyncSpecViewFreq; FSpecView.DrawSpectrum; PbSpectrum.Invalidate; FSpecView.DrawWaterfall; PbWaterfall.Invalidate; if PbRuler <> nil then PbRuler.Invalidate; end; rfCenterFreq: begin // CTUN-скролл центра (drag/web): ruler-метки сдвинулись → инвалидируем // кэш; спектр откладываем (FSpectrumDirty) — drag шлёт много событий. FSpecView.InvalidateRulerCache; SyncSpecViewFreq; FSpectrumDirty := True; PbSpectrum.Invalidate; if PbRuler <> nil then PbRuler.Invalidate; end; rfVfoB: begin FreqDispB.Frequency := Round(FController.FVfoB); if FController.FActiveVfo = 1 then begin FSpecView.InvalidateRulerCache; SyncSpecViewFreq; FSpectrumDirty := True; PbSpectrum.Invalidate; if PbRuler <> nil then PbRuler.Invalidate; end; end; rfTransmitting: begin if FController.FTransmitting then begin BtnMOX.ClrNorm := TColor($00000044); BtnMOX.ClrText := TColor($000000FF); BtnMOX.ClrTextAct := TColor($000000FF); BtnMOX.Active := True; end else StyleButton(BtnMOX, False); // TX-overlay в SpectrumView: TX-частота/VFO-индекс (по селектору // FSplitTxB, независимо от активного RX-VFO) и режим. FSpecView.TXFreq := ActiveTXFreqHz; if FController.FSplitTxB then FSpecView.TXVfoIndex := 1 else FSpecView.TXVfoIndex := 0; // DUP: данные от RX-анализатора (TXMode=False), TX-полоса рисуется // поверх через TXOverlay. Без DUP при TX оба флага True. FSpecView.TXMode := FController.FTransmitting and not FController.FDisplayDuplex; FSpecView.TXOverlay := FController.FTransmitting; ApplySpecViewGridFromState; // сетка спектра под активный тракт end; // (DUC-очередь сбрасывает SetMOX) rfTuning: StyleButton(BtnTUN, FController.FTuning); rfRunning: // Run-состояние: кнопка START/STOP, доступность MOX, запрет сна. // Текст offline-статуса контекстно-зависим (Disconnected / Connection // lost / …) — его ставит вызывающий, не общий рендер. if FController.FRunning then begin InhibitSleep; BtnStartStop.Caption := 'STOP'; StyleButton(BtnStartStop, True); BtnMOX.Enabled := True; end else begin UninhibitSleep; BtnStartStop.Caption := 'START'; StyleButton(BtnStartStop, False); BtnMOX.Enabled := False; end; rfADCOverload: if FSpecView <> nil then begin FSpecView.ADCOverloadVisible := FController.FADCOverloadActive; if PbSpectrum <> nil then PbSpectrum.Invalidate; end; rfBand: // В XVTR-режиме HF-кнопки гасим (активна XVTR-кнопка, см. rfXvtr). for i := 0 to BAND_COUNT - 1 do StyleButton(BtnBand[i], (FController.FCurrentXvtr < 0) and (i = FController.FCurrentBand)); rfXvtr: begin for i := 0 to CFG_XVTR_COUNT - 1 do if BtnXvtrBand[i] <> nil then StyleButton(BtnXvtrBand[i], i = FController.FCurrentXvtr); UpdateFreqDisplayMax; // трансвертер может быть >6 ГГц (QO-100) // BEACON-кнопка — только Pluto в трансвертере (QO-100 lock); для // openHPSDR смысла не имеет (нет дрейфующего LNB → прячем). if BtnBeacon <> nil then begin BtnBeacon.Visible := FController.IsPluto and (FController.FCurrentXvtr >= 0); UpdateBeaconButton; end; // Wideband-вид + web-зеркало xvtr (ранее в обёртках Activate/Deactivate). // rfXvtr эмитят ТОЛЬКО ActivateXvtr/DeactivateXvtr — состояние финальное. UpdateWidebandFrequencyView; if PbWideband <> nil then PbWideband.Invalidate; if PbWidebandRuler <> nil then PbWidebandRuler.Invalidate; PushXvtrToWeb; end; rfBandRestore: // Явная смена диапазона: сброс порогов водопада (не на каждом тюне). FSpecView.ResetWfAvgBuf; rfMute: begin StyleButton(BtnMute, FController.FMuted); if FController.FMuted then BtnMute.Caption := 'UNMUTE' else BtnMute.Caption := 'MUTE'; BtnMute.Invalidate; end; rfNR: UpdateNRButton; rfNB: UpdateNBButton; rfSNB: UpdateSNBButton; rfANF: UpdateANFButton; rfMode: begin for i := 0 to MODE_COUNT - 1 do StyleButton(BtnMode[i], i = FController.FMode); // UpdateFilterButtons (для не-FM) мутирует FFilter → сбрасывает на дефолт // режима. Это поведение нужно при СМЕНЕ режима (web/channel), но не при // пассивном рендере: RestoreBand/ActivateXvtr уже задали фильтр из кэша. // На реальной смене режима контроллер выставляет дефолт через // ApplyModeDefaults ДО rfMode, так что сохраняется именно он — поведение // не меняется. Последующий rfFilter стилит кнопки под восстановленный фильтр. SavedFilter := FController.FFilter; SavedFilterBW := FController.FFilterBW; UpdateFilterButtons; // подписи + FM-панели под режим FController.FFilter := SavedFilter; FController.FFilterBW := SavedFilterBW; for i := 0 to FILT_COUNT - 1 do StyleButton(BtnFilter[i], i = FController.FFilter); SyncSpecViewFreq; // иначе полоса фильтра рисуется под старый режим end; rfFilter: begin for i := 0 to FILT_COUNT - 1 do StyleButton(BtnFilter[i], i = FController.FFilter); SyncSpecViewFreq; end; rfAGCMode: for i := 0 to 4 do StyleButton(BtnAGCMode[i], i = FController.FAGCMode); rfVolume: TrkVolume.Position := FController.FVolume; rfRxGain: UpdateRxGainUI; rfDrive: begin TrkDrive.Position := FController.FDrivePercent; // слайдер ← drive% UpdateDriveLabel; // % / дБ рядом с ползунком FChannelController.OnDriveChanged; // внешнее изменение снимает память канала end; rfAGCTop: begin TrkAGC.Position := FController.FAGCTop; LblAGCTop.Caption := Format('%ddB', [FController.FAGCTop]); FSpecView.AGCTop := FController.FAGCTop; FSpecView.DrawSpectrum; PbSpectrum.Invalidate; end; rfBeaconLock: UpdateBeaconButton; // статус/коррекция QO-100 beacon lock rfCTun: begin StyleButton(BtnCTun, FController.FCTun); FSpecView.InvalidateRulerCache; SyncSpecViewFreq; FSpecView.DrawSpectrum; FSpecView.DrawWaterfall; PbSpectrum.Invalidate; PbWaterfall.Invalidate; if PbRuler <> nil then PbRuler.Invalidate; end; rfDuplex: begin // Единая точка рендера DUP — для кнопки И для авто-вкл в QO-100 (оба идут // через SetDuplex → rfDuplex). Раньше QO-100 включал DUP, но кнопку не // подсвечивал → визуал расходился с состоянием. if BtnDUP <> nil then StyleButton(BtnDUP, FController.FDisplayDuplex); // На передаче переключаем источник пикселей и сбрасываем буферы, чтобы // старые TX/RX bin'ы не оставались на следующем кадре. if FController.FTransmitting then begin if FController.FWDSPReady then FController.FDSPEngine.SetDisplaySourceTX(not FController.FDisplayDuplex); FSpecView.TXMode := FController.FTransmitting and not FController.FDisplayDuplex; FSpecView.ResetSpectrumBuf; FSpecView.ResetWfAvgBuf; ApplySpecViewGridFromState; FSpecView.DrawSpectrum; FSpecView.DrawWaterfall; PbSpectrum.Invalidate; PbWaterfall.Invalidate; end; end; rfWfAGC: FSpecView.WfAGCEnabled := FController.FWfAGCEnabled; rfWfNF: FSpecView.WfNFEnabled := FController.FWfNFEnabled; rfFMStepIdx: begin if FStepDropDown <> nil then FStepDropDown.SetItemIndex(FController.FFMStepIdx); if FWebServer <> nil then FWebServer.FMStepIdx := FController.FFMStepIdx; if (FController.FMode = MODE_FM) and (FSpecView <> nil) then FSpecView.FMGridStepHz := FM_STEP_HZ[FController.FFMStepIdx]; end; rfFMStep: if BtnFMStep <> nil then begin StyleButton(BtnFMStep, FController.FFMStepOn); BtnFMStep.Repaint; end; rfFMSQ: begin StyleButton(BtnFMSQ, FController.FFMSQOn); BtnFMSQ.Repaint; end; rfFMSQLevel: begin TrkFMSQ.Position := FController.FFMSQLevel; if LblFMSQ <> nil then LblFMSQ.Caption := IntToStr(FController.FFMSQLevel); end; rfFMCTCSS: begin StyleButton(BtnFMCTCSS, FController.FFMCTCSSOn); BtnFMCTCSS.Repaint; end; rfFMCTCSSTone: if FCTCSSDropDown <> nil then FCTCSSDropDown.SetItemIndex(FController.FFMCTCSSToneIdx); rfSampleRate: begin if Assigned(FSampleRateOverlay) then FSampleRateOverlay.SetCurrentRate(FController.FSampleRate); // ChangeSampleRate пересоздал WDSP-канал — пересчёт AGC-линий под // актуальную ширину спектра (UI-зависимая величина). if FController.FWDSPReady then FController.FDSPEngine.UpdateAGCLines(FSpectrumWidth); end; rfFMRpt: begin if BtnFMRptMinus <> nil then begin StyleButton(BtnFMRptMinus, FController.FFMRptDir = RPT_MINUS); BtnFMRptMinus.Repaint; end; if BtnFMRptPlus <> nil then begin StyleButton(BtnFMRptPlus, FController.FFMRptDir = RPT_PLUS); BtnFMRptPlus.Repaint; end; // Текст оффсета не трогаем, пока пользователь печатает в поле. if (EdFMRptOffset <> nil) and not EdFMRptOffset.Focused then EdFMRptOffset.Text := RptFormatOffset(FController.FFMRptOffsetHz); end; rfDevice: begin // Полная синхронизация device-виджетов после ConnectDevice (load-by-MAC). // Состояние уже в контроллере; здесь только рендер + UI/CAT/web-побочки. FCATLastGlobal := FController.FLoadedGlobal; // Тема — app-global (своё хранилище LoadTheme/SaveTheme), не из device-блоба: // возвращаем живое значение в persist-буфер, чтобы blob нёс актуальную тему. FController.FLoadedGlobal.LightTheme := FLightTheme; FCATAdapter.ApplySettings(FController.FLoadedGlobal); RelabelBands; // HF ↔ VHF/UHF подписи под тип бэкенда ApplyBackendCapsToUI; // пресеты sample rate + гейтинг полей RebuildXvtrButtons; PushXvtrToWeb; TrkDrive.Position := FController.FDrivePercent; // слайдер ← drive% UpdateDriveLabel; // переключаем единицу %/дБ под бэкенд if Assigned(FSampleRateOverlay) then FSampleRateOverlay.SetCurrentRate(FController.FSampleRate); UpdateNRButton; UpdateNBButton; UpdateSNBButton; UpdateANFButton; FSpecView.WfAGCEnabled := FController.FWfAGCEnabled; FSpecView.WfNFEnabled := FController.FWfNFEnabled; FSpecView.WfManualHigh := FController.FWfManualHigh; FSpecView.WfManualLow := FController.FWfManualLow; FSpecView.WfAGCOffset := FController.FWfAGCOffset; FSpecView.ResetWfAvgBuf; if Assigned(FWidebandView) then FWidebandView.SetFillSpectrum(FController.FWidebandFill); FSpecView.FillSpectrum := FController.FSpectrumFill; if BtnDUP <> nil then StyleButton(BtnDUP, FController.FDisplayDuplex); if FController.FLoadedGlobal.DisplayFPS > 0 then ApplyFPS(FController.FLoadedGlobal.DisplayFPS); ApplyFreqMhzDigits(EnsureRange(FController.FLoadedGlobal.FreqMhzDigits, 3, 5)); if FController.FLoadedGlobal.FFTSize > 0 then begin FSpecView.SpecRefLevel := FController.FSpecRefLevel; FSpecView.SpecRange := FController.FSpecRange; FSpecView.SpecGridStep := FController.FSpecGridStep; InvalidateGridCache; end; end; rfDeviceList: begin // Discovery обновил список устройств (или завершился без них). if Assigned(FDeviceDialog) then begin if FController.FDiscoverNoneFound then begin FDeviceDialog.NoDevicesFound; SetStatusText(2, 'No hardware found'); end else begin FDeviceDialog.RefreshFound; if FController.FDeviceStore.DiscoveredCount > 0 then SetStatusText(2, Format('Found: %s', [FController.FDeviceStore.Discovered( FController.FDeviceStore.DiscoveredCount - 1).IPAddress])); end; end; PushDeviceListToWeb; // web-overlay тоже получает обновлённый список end; end; finally FSyncingFromController := False; end; end; procedure TMainForm.FormClose(Sender: TObject; var CloseAction: TCloseAction); begin // Сохраняем позицию здесь — окно ещё полностью видимо и стабильно. // В FormDestroy Qt-виджет может быть уже в нестабильном состоянии. SaveWindowBounds; // Останавливаем трансивер как при нажатии STOP if FController.FNetwork.Connected then begin FController.StopRunning; FController.FNetwork.Disconnect; end; CloseAction := caFree; end; // =========================================================================== // Build UI (без анонимных процедур и inline var) // =========================================================================== procedure TMainForm.BuildUI; const BTN_H = 26; BTN_SM = 24; LEFT_W = 232; PLUTO_AGC_H = 64; // высота отдельного Pluto RF-AGC блока (под AGC) TOP_VFO_W = 244; TOP_VFO_FREQ_H = 47; var i, X, Y, W: Integer; B: TFlatButton; SepLine: TPanel; function MakeBtn(Parent: TWinControl; const Cap: string; ALeft, ATop, AW, AH: Integer; Handler: TNotifyEvent): TFlatButton; begin Result := TFlatButton.Create(Self); Result.Parent := Parent; Result.Caption := Cap; Result.Left := ALeft; Result.Top := ATop; Result.Width := AW; Result.Height := AH; Result.OnClick := Handler; StyleButton(Result, False); end; procedure MakeLbl(Parent: TWinControl; const Cap: string; ALeft, ATop: Integer); var L: TLabel; begin L := TLabel.Create(Self); L.Parent := Parent; L.Caption := Cap; L.Left := ALeft; L.Top := ATop; L.Font.Color := CLR_TEXTDIM; L.Font.Name := 'Courier New'; L.Font.Size := 7; end; begin // ---- Toolbar ---- PanelToolbar := TPanel.Create(Self); PanelToolbar.Parent := Self; PanelToolbar.Align := alTop; PanelToolbar.Height := 72; PanelToolbar.BevelOuter := bvNone; PanelToolbar.Color := CLR_PANEL; X := 4; BtnDiscover := MakeBtn(PanelToolbar, 'DISCOVER', X, 3, 80, BTN_H, BtnDiscoverClick); BtnStartStop := MakeBtn(PanelToolbar, 'START', X+84, 3, 76, BTN_H, BtnStartStopClick); BtnSettings := MakeBtn(PanelToolbar, 'SETUP', X+164, 3, 66, BTN_H, BtnSettingsClick); // DISCOVER — синеватый акцент BtnDiscover.ClrNorm := TColor($00101828); BtnDiscover.ClrHot := TColor($00182038); BtnDiscover.ClrActive := TColor($00182038); BtnDiscover.ClrBorder := TColor($00304860); BtnDiscover.ClrText := TColor($0090B8D0); BtnDiscover.ClrTextAct := TColor($00B0D0F0); // START/STOP — зелёный тон в обоих состояниях BtnStartStop.ClrNorm := TColor($00101810); BtnStartStop.ClrBorder := TColor($00304830); BtnStartStop.ClrText := TColor($0090C090); // Линия-разделитель внутри PanelToolbar, прижата к его нижнему краю SepLine := TPanel.Create(Self); SepLine.Parent := PanelToolbar; SepLine.BevelOuter := bvNone; SepLine.Color := TColor($00606060); SepLine.SetBounds(16, PanelToolbar.Height - 10, PanelToolbar.Width - 32, 2); SepLine.Anchors := [akLeft, akRight, akBottom]; // ---- Status bar ---- StatusPanel := TMainStatusBar.Create(Self); StatusPanel.Parent := Self; // ---- Left panel ---- PanelLeft := TPanel.Create(Self); PanelLeft.Parent := Self; PanelLeft.Align := alLeft; PanelLeft.Width := LEFT_W; PanelLeft.BevelOuter := bvNone; Y := 0; // Compact top VFO group PanelTopVfoGroup := TPanel.Create(Self); PanelTopVfoGroup.Parent := PanelToolbar; PanelTopVfoGroup.SetBounds(0, 0, TOP_VFO_W * 2 + 64, 60); PanelTopVfoGroup.BevelOuter := bvNone; PanelTopVfoGroup.Color := TColor($00181818); // VFO A PanelTopVfoA := TPanel.Create(Self); PanelTopVfoA.Parent := PanelTopVfoGroup; PanelTopVfoA.SetBounds(0, 0, TOP_VFO_W, 60); PanelTopVfoA.BevelOuter := bvNone; PanelTopVfoA.Color := CLR_PANEL; PanelVfoA := TPanel.Create(Self); PanelVfoA.Parent := PanelTopVfoA; PanelVfoA.SetBounds(0, 0, TOP_VFO_W, TOP_VFO_FREQ_H); PanelVfoA.BevelOuter := bvNone; BtnTopVfoASelect := MakeBtn(PanelVfoA, 'A', 0, 5, 20, 18, BtnTopVfoASelectClick); BtnTopVfoASelect.Font.Name := 'Courier New'; BtnTopVfoASelect.Font.Size := 7; BtnTopVfoASelect.Font.Bold := True; BtnTopVfoASelect.ClrNorm := TColor($00303030); BtnTopVfoASelect.ClrHot := TColor($00404040); BtnTopVfoASelect.ClrActive := TColor($00404040); BtnTopVfoASelect.ClrBorder := TColor($00505050); BtnTopVfoASelect.ClrText := CLR_TEXTDIM; BtnTopVfoASelect.ClrTextAct := CLR_TEXT; BtnTopVfoATX := MakeBtn(PanelVfoA, 'TX', 0, 25, 20, 18, BtnTopVfoATXClick); BtnTopVfoATX.Font.Name := 'Courier New'; BtnTopVfoATX.Font.Size := 7; BtnTopVfoATX.Font.Bold := True; BtnTopVfoATX.ClrNorm := TColor($00303030); BtnTopVfoATX.ClrHot := TColor($00404040); BtnTopVfoATX.ClrActive := TColor($00404040); BtnTopVfoATX.ClrBorder := TColor($00505050); BtnTopVfoATX.ClrText := CLR_TEXTDIM; BtnTopVfoATX.ClrTextAct := CLR_TEXT; LblVfoALabel := TLabel.Create(Self); LblVfoALabel.Parent := PanelVfoA; LblVfoALabel.Caption := 'VFO-A'; LblVfoALabel.Left := 0; LblVfoALabel.Top := 0; LblVfoALabel.Font.Name := 'Courier New'; LblVfoALabel.Font.Size := 7; LblVfoALabel.Font.Color := CLR_TEXTDIM; LblVfoALabel.Visible := False; FreqDispA := TFreqDisplay.Create(Self); FreqDispA.Parent := PanelVfoA; FreqDispA.SetBounds(20, -2, TOP_VFO_W - 20, TOP_VFO_FREQ_H); FreqDispA.FontSize := TOP_VFO_FONT; FreqDispA.FontName := 'Courier New'; FreqDispA.Frequency := Round(FController.FVfoA); FreqDispA.ColorNormal := CLR_FREQ; FreqDispA.ColorHover := TColor($0040DDFF); FreqDispA.ColorDim := CLR_TEXTDIM; FreqDispA.MinMhzDigits := 3; FreqDispA.DimLeadingZeros := True; FreqDispA.CenterText := False; FreqDispA.OnChange := FreqDispAChanged; FreqDispA.OnClick := FreqDispAClick; // VFO B PanelTopVfoB := TPanel.Create(Self); PanelTopVfoB.Parent := PanelTopVfoGroup; PanelTopVfoB.SetBounds(0, 0, TOP_VFO_W, 60); PanelTopVfoB.BevelOuter := bvNone; PanelTopVfoB.Color := CLR_PANEL; PanelVfoB := TPanel.Create(Self); PanelVfoB.Parent := PanelTopVfoB; PanelVfoB.SetBounds(0, 0, TOP_VFO_W, TOP_VFO_FREQ_H); PanelVfoB.BevelOuter := bvNone; BtnTopVfoBSelect := MakeBtn(PanelVfoB, 'B', 0, 5, 20, 18, BtnTopVfoBSelectClick); BtnTopVfoBSelect.Font.Name := 'Courier New'; BtnTopVfoBSelect.Font.Size := 7; BtnTopVfoBSelect.Font.Bold := True; BtnTopVfoBSelect.ClrNorm := TColor($00303030); BtnTopVfoBSelect.ClrHot := TColor($00404040); BtnTopVfoBSelect.ClrActive := TColor($00404040); BtnTopVfoBSelect.ClrBorder := TColor($00505050); BtnTopVfoBSelect.ClrText := CLR_TEXTDIM; BtnTopVfoBSelect.ClrTextAct := CLR_TEXT; BtnTopVfoBTX := MakeBtn(PanelVfoB, 'TX', 0, 25, 20, 18, BtnTopVfoBTXClick); BtnTopVfoBTX.Font.Name := 'Courier New'; BtnTopVfoBTX.Font.Size := 7; BtnTopVfoBTX.Font.Bold := True; BtnTopVfoBTX.ClrNorm := TColor($00303030); BtnTopVfoBTX.ClrHot := TColor($00404040); BtnTopVfoBTX.ClrActive := TColor($00404040); BtnTopVfoBTX.ClrBorder := TColor($00505050); BtnTopVfoBTX.ClrText := CLR_TEXTDIM; BtnTopVfoBTX.ClrTextAct := CLR_TEXT; LblVfoBLabel := TLabel.Create(Self); LblVfoBLabel.Parent := PanelVfoB; LblVfoBLabel.Caption := 'VFO-B'; LblVfoBLabel.Left := 0; LblVfoBLabel.Top := 0; LblVfoBLabel.Font.Name := 'Courier New'; LblVfoBLabel.Font.Size := 7; LblVfoBLabel.Font.Color := CLR_TEXTDIM; LblVfoBLabel.Visible := False; FreqDispB := TFreqDisplay.Create(Self); FreqDispB.Parent := PanelVfoB; FreqDispB.SetBounds(20, -2, TOP_VFO_W - 20, TOP_VFO_FREQ_H); FreqDispB.FontSize := TOP_VFO_FONT; FreqDispB.FontName := 'Courier New'; FreqDispB.Frequency := Round(FController.FVfoB); FreqDispB.ColorNormal := CLR_FREQ_DIM; FreqDispB.ColorHover := TColor($0040DDFF); FreqDispB.ColorDim := CLR_TEXTDIM; FreqDispB.MinMhzDigits := 3; FreqDispB.DimLeadingZeros := True; FreqDispB.CenterText := False; FreqDispB.OnChange := FreqDispBChanged; FreqDispB.OnClick := FreqDispBClick; // VFO transfer buttons — горизонтальный ряд по центру PanelVfoButtons := TPanel.Create(Self); PanelVfoButtons.Parent := PanelTopVfoGroup; PanelVfoButtons.SetBounds(0, 0, 140, 20); PanelVfoButtons.BevelOuter := bvNone; PanelVfoButtons.Color := TColor($00181818); BtnVfoACopyB := MakeBtn(PanelVfoButtons, 'A>B', 0, 0, 44, 20, BtnVfoACopyBClick); BtnVfoSwap := MakeBtn(PanelVfoButtons, 'A<>B', 0, 0, 44, 20, BtnVfoSwapClick); BtnVfoBCopyA := MakeBtn(PanelVfoButtons, 'B>A', 0, 0, 44, 20, BtnVfoBCopyAClick); // Янтарный стиль, шрифт под размер кнопок StyleSpanButton(BtnVfoACopyB, False); StyleSpanButton(BtnVfoSwap, False); StyleSpanButton(BtnVfoBCopyA, False); BtnVfoACopyB.Font.Size := 7; BtnVfoSwap.Font.Size := 7; BtnVfoBCopyA.Font.Size := 7; LayoutTopVfoBlock; // TX controls PanelTX := TPanel.Create(Self); PanelTX.Parent := PanelLeft; PanelTX.SetBounds(0, Y, LEFT_W, 96); PanelTX.BevelOuter := bvNone; MakeLbl(PanelTX, 'TX', 4, 2); BtnMOX := MakeBtn(PanelTX, 'MOX', LeftPanelButtonLeft(LEFT_W, 4, 0), 16, LeftPanelButtonWidth(LEFT_W, 4, 0), BTN_SM, BtnMOXClick); BtnTUN := MakeBtn(PanelTX, 'TUN', LeftPanelButtonLeft(LEFT_W, 4, 1), 16, LeftPanelButtonWidth(LEFT_W, 4, 1), BTN_SM, BtnTUNClick); BtnDUP := MakeBtn(PanelTX, 'DUP', LeftPanelButtonLeft(LEFT_W, 4, 0), 44, LeftPanelButtonWidth(LEFT_W, 4, 0), BTN_SM, BtnDUPClick); BtnDUP.Tag := 0; StyleButton(BtnDUP, FController.FDisplayDuplex); LblDrv := TLabel.Create(Self); LblDrv.Parent := PanelTX; LblDrv.Left := 4; LblDrv.Top := 77; LblDrv.Caption := 'DRV'; LblDrv.Font.Color := CLR_TEXTDIM; LblDrv.Font.Name := 'Courier New'; LblDrv.Font.Size := 7; TrkDrive := TFlatSlider.Create(Self); TrkDrive.Parent := PanelTX; TrkDrive.Left := 34; TrkDrive.Top := 74; TrkDrive.Width := 120; TrkDrive.Height := 16; TrkDrive.Min := 0; TrkDrive.Max := 100; TrkDrive.Position := 50; TrkDrive.OnChange := TrkDriveChange; LblDrvVal := TLabel.Create(Self); LblDrvVal.Parent := PanelTX; LblDrvVal.Left := 158; LblDrvVal.Top := 77; LblDrvVal.Font.Color := CLR_TEXT; LblDrvVal.Font.Name := 'Courier New'; LblDrvVal.Font.Size := 7; UpdateDriveLabel; Inc(Y, 100); // RX block: CTUN + VOL + NR/NB/SNB/ANF/MUTE PanelRXBlock := TPanel.Create(Self); PanelRXBlock.Parent := PanelLeft; PanelRXBlock.SetBounds(0, Y, LEFT_W, 96); PanelRXBlock.BevelOuter := bvNone; MakeLbl(PanelRXBlock, 'RX', 4, 2); BtnCTun := MakeBtn(PanelRXBlock, 'CTUN', LeftPanelButtonLeft(LEFT_W, 3, 0), 16, LeftPanelButtonWidth(LEFT_W, 3, 0), BTN_H, BtnCTunClick); BtnCTun.Tag := 0; StyleButton(BtnCTun, FController.FCTun); BtnChannels := MakeBtn(PanelRXBlock, 'Channel', LeftPanelButtonLeft(LEFT_W, 3, 1), 16, LeftPanelButtonWidth(LEFT_W, 3, 1), BTN_H, BtnChannelsClick); BtnChannels.OnMouseDown := BtnChannelsMouseDown; StyleButton(BtnChannels, False); FChannelsDropDown := TFlatDropDown.Create(BtnChannels, PanelLeft, 1, BTN_SM); FChannelsDropDown.OnSelect := OnChannelsDropDownSelect; RefreshChannelsDropDown; // QO-100 beacon lock — 3-я колонка ряда. Видима только в XVTR-режиме // (см. рендер rfXvtr ниже). Подпись/подсветка обновляются по rfBeaconLock. BtnBeacon := MakeBtn(PanelRXBlock, 'BEACON', LeftPanelButtonLeft(LEFT_W, 3, 2), 16, LeftPanelButtonWidth(LEFT_W, 3, 2), BTN_H, BtnBeaconClick); BtnBeacon.Visible := False; BtnBeacon.OnMouseDown := BtnBeaconMouseDown; // ПКМ → декодер + окно констелляции StyleButton(BtnBeacon, False); MakeLbl(PanelRXBlock, 'VOL', 4, 50); TrkVolume := TFlatSlider.Create(Self); TrkVolume.Parent := PanelRXBlock; TrkVolume.Left := 34; TrkVolume.Top := 46; TrkVolume.Width := LEFT_W - 38; TrkVolume.Height := 16; TrkVolume.Min := 0; TrkVolume.Max := 100; TrkVolume.Position := FController.FVolume; TrkVolume.OnChange := TrkVolumeChange; BtnNR := MakeBtn(PanelRXBlock, 'NR', LeftPanelButtonLeft(LEFT_W, 5, 0), 70, LeftPanelButtonWidth(LEFT_W, 5, 0), BTN_SM, BtnNRClick); BtnNB := MakeBtn(PanelRXBlock, 'NB', LeftPanelButtonLeft(LEFT_W, 5, 1), 70, LeftPanelButtonWidth(LEFT_W, 5, 1), BTN_SM, BtnNBClick); BtnSNB := MakeBtn(PanelRXBlock, 'SNB', LeftPanelButtonLeft(LEFT_W, 5, 2), 70, LeftPanelButtonWidth(LEFT_W, 5, 2), BTN_SM, BtnSNBClick); BtnANF := MakeBtn(PanelRXBlock, 'ANF', LeftPanelButtonLeft(LEFT_W, 5, 3), 70, LeftPanelButtonWidth(LEFT_W, 5, 3), BTN_SM, BtnANFClick); BtnMute := MakeBtn(PanelRXBlock, 'MUTE', LeftPanelButtonLeft(LEFT_W, 5, 4), 70, LeftPanelButtonWidth(LEFT_W, 5, 4), BTN_SM, BtnMuteClick); Inc(Y, 100); // AGC controls PanelAGC := TPanel.Create(Self); PanelAGC.Parent := PanelLeft; PanelAGC.SetBounds(0, Y, LEFT_W, 70); PanelAGC.BevelOuter := bvNone; // AGC mode — 5 кнопок в ряд MakeLbl(PanelAGC, 'AGC', 4, 2); BtnAGCMode[0] := MakeBtn(PanelAGC, 'FAST', LeftPanelButtonLeft(LEFT_W, 5, 0), 16, LeftPanelButtonWidth(LEFT_W, 5, 0), BTN_SM, BtnAGCModeClick); BtnAGCMode[0].Tag := 0; BtnAGCMode[1] := MakeBtn(PanelAGC, 'MED', LeftPanelButtonLeft(LEFT_W, 5, 1), 16, LeftPanelButtonWidth(LEFT_W, 5, 1), BTN_SM, BtnAGCModeClick); BtnAGCMode[1].Tag := 1; BtnAGCMode[2] := MakeBtn(PanelAGC, 'SLOW', LeftPanelButtonLeft(LEFT_W, 5, 2), 16, LeftPanelButtonWidth(LEFT_W, 5, 2), BTN_SM, BtnAGCModeClick); BtnAGCMode[2].Tag := 2; BtnAGCMode[3] := MakeBtn(PanelAGC, 'LONG', LeftPanelButtonLeft(LEFT_W, 5, 3), 16, LeftPanelButtonWidth(LEFT_W, 5, 3), BTN_SM, BtnAGCModeClick); BtnAGCMode[3].Tag := 3; BtnAGCMode[4] := MakeBtn(PanelAGC, 'OFF', LeftPanelButtonLeft(LEFT_W, 5, 4), 16, LeftPanelButtonWidth(LEFT_W, 5, 4), BTN_SM, BtnAGCModeClick); BtnAGCMode[4].Tag := 4; for i := 0 to 4 do StyleButton(BtnAGCMode[i], i = FController.FAGCMode); // AGC level slider MakeLbl(PanelAGC, 'THRESH', 4, 44); TrkAGC := TFlatSlider.Create(Self); TrkAGC.Parent := PanelAGC; TrkAGC.Left := 56; TrkAGC.Top := 40; TrkAGC.Width := LEFT_W - 96; TrkAGC.Height := 16; TrkAGC.Min := 20; TrkAGC.Max := 120; // 20..120 → −20..-120 dBm TrkAGC.Position := FController.FAGCTop; TrkAGC.Reversed := True; TrkAGC.OnChange := TrkAGCChange; LblAGCTop := TLabel.Create(Self); LblAGCTop.Parent := PanelAGC; LblAGCTop.Left := LEFT_W - 38; LblAGCTop.Top := 44; LblAGCTop.Caption := Format('-%ddB', [FController.FAGCTop]); LblAGCTop.Font.Color := CLR_TEXT; LblAGCTop.Font.Name := 'Courier New'; LblAGCTop.Font.Size := 7; Inc(Y, 74); // --- Pluto RF AGC/gain — отдельный блок ПОД блоком AGC (WDSP). Виден только // когда подключён Pluto-бэкенд; раскладку панелей ниже сдвигает LayoutLeftPanel. PanelPlutoAGC := TPanel.Create(Self); PanelPlutoAGC.Parent := PanelLeft; PanelPlutoAGC.SetBounds(0, Y, LEFT_W, PLUTO_AGC_H); PanelPlutoAGC.BevelOuter := bvNone; PanelPlutoAGC.Visible := False; MakeLbl(PanelPlutoAGC, 'RF AGC', 4, 2); // Режимы AD936x: FAST/SLOW/HYB/MAN. Tag = индекс режима (1/2/3/0). BtnPlutoGain[0] := MakeBtn(PanelPlutoAGC, 'FAST', LeftPanelButtonLeft(LEFT_W, 4, 0), 16, LeftPanelButtonWidth(LEFT_W, 4, 0), BTN_SM, BtnPlutoGainClick); BtnPlutoGain[0].Tag := 1; BtnPlutoGain[1] := MakeBtn(PanelPlutoAGC, 'SLOW', LeftPanelButtonLeft(LEFT_W, 4, 1), 16, LeftPanelButtonWidth(LEFT_W, 4, 1), BTN_SM, BtnPlutoGainClick); BtnPlutoGain[1].Tag := 2; BtnPlutoGain[2] := MakeBtn(PanelPlutoAGC, 'HYB', LeftPanelButtonLeft(LEFT_W, 4, 2), 16, LeftPanelButtonWidth(LEFT_W, 4, 2), BTN_SM, BtnPlutoGainClick); BtnPlutoGain[2].Tag := 3; BtnPlutoGain[3] := MakeBtn(PanelPlutoAGC, 'MAN', LeftPanelButtonLeft(LEFT_W, 4, 3), 16, LeftPanelButtonWidth(LEFT_W, 4, 3), BTN_SM, BtnPlutoGainClick); BtnPlutoGain[3].Tag := 0; // Manual gain slider. MakeLbl(PanelPlutoAGC, 'GAIN', 4, 44); TrkRxGain := TFlatSlider.Create(Self); TrkRxGain.Parent := PanelPlutoAGC; TrkRxGain.Left := 56; TrkRxGain.Top := 40; TrkRxGain.Width := LEFT_W - 96; TrkRxGain.Height := 16; TrkRxGain.Min := 0; TrkRxGain.Max := 73; // AD936x hardwaregain dB TrkRxGain.Position := FController.FRxGainDb; TrkRxGain.OnChange := TrkRxGainChange; LblRxGain := TLabel.Create(Self); LblRxGain.Parent := PanelPlutoAGC; LblRxGain.Left := LEFT_W - 38; LblRxGain.Top := 44; LblRxGain.Caption := Format('%ddB', [FController.FRxGainDb]); LblRxGain.Font.Color := CLR_TEXT; LblRxGain.Font.Name := 'Courier New'; LblRxGain.Font.Size := 7; // НЕ Inc(Y): панель скрыта и наложена на позицию Bands; LayoutLeftPanel сдвинет. // Band selector. Высота резервирует 2 дополнительных ряда под XVTR-кнопки // (создаются динамически в RebuildXvtrButtons). В каждом ряду до 6 кнопок, // итого до 12 XVTR-кнопок (наш CFG_XVTR_COUNT = 8 умещается с запасом). PanelBands := TPanel.Create(Self); PanelBands.Parent := PanelLeft; PanelBands.SetBounds(0, Y, LEFT_W, 74); PanelBands.BevelOuter := bvNone; MakeLbl(PanelBands, 'BAND', 4, 2); for i := 0 to BAND_COUNT - 1 do begin B := MakeBtn(PanelBands, BAND_NAMES[i], LeftPanelButtonLeft(LEFT_W, 6, i mod 6), 16 + (i div 6) * 27, LeftPanelButtonWidth(LEFT_W, 6, i mod 6), BTN_SM, BtnBandClick); B.Tag := i; BtnBand[i] := B; end; // XVTR кнопки создаются позже (в RebuildXvtrButtons), их Tag = -(idx+1) for i := 0 to CFG_XVTR_COUNT - 1 do BtnXvtrBand[i] := nil; Inc(Y, 76); // Mode selector PanelMode := TPanel.Create(Self); PanelMode.Parent := PanelLeft; PanelMode.SetBounds(0, Y, LEFT_W, 74); PanelMode.BevelOuter := bvNone; MakeLbl(PanelMode, 'MODE', 4, 2); for i := 0 to MODE_COUNT - 1 do begin B := MakeBtn(PanelMode, MODE_NAMES[i], LeftPanelButtonLeft(LEFT_W, 4, i mod 4), 16 + (i div 4) * 27, LeftPanelButtonWidth(LEFT_W, 4, i mod 4), BTN_SM, BtnModeClick); B.Tag := i; BtnMode[i] := B; StyleButton(B, i = FController.FMode); end; Inc(Y, 76); // Filter selector (10 кнопок в 2 ряда по 5) PanelFilter := TPanel.Create(Self); PanelFilter.Parent := PanelLeft; PanelFilter.SetBounds(0, Y, LEFT_W, 74); PanelFilter.BevelOuter := bvNone; MakeLbl(PanelFilter, 'FILTER', 4, 2); for i := 0 to FILT_COUNT - 1 do begin B := MakeBtn(PanelFilter, '---', LeftPanelButtonLeft(LEFT_W, 5, i mod 5), 16 + (i div 5) * 27, LeftPanelButtonWidth(LEFT_W, 5, i mod 5), BTN_SM, BtnFilterClick); B.Tag := i; BtnFilter[i] := B; end; Inc(Y, 76); // FM Squelch panel (visible only in FM mode, above CTCSS) PanelFMSQ := TPanel.Create(Self); PanelFMSQ.Parent := PanelLeft; PanelFMSQ.SetBounds(0, Y, LEFT_W, 50); PanelFMSQ.BevelOuter := bvNone; PanelFMSQ.Visible := False; MakeLbl(PanelFMSQ, 'SQL', 4, 2); W := LeftPanelButtonWidth(LEFT_W, 4, 0); BtnFMSQ := MakeBtn(PanelFMSQ, 'SQL', LeftPanelButtonLeft(LEFT_W, 4, 0), 16, W, BTN_SM, BtnFMSQClick); StyleButton(BtnFMSQ, False); // Slider: vertically centred with button text (button top=16, h=24 → center=28) // Reserve 28px on the right for the numeric label TrkFMSQ := TFlatSlider.Create(Self); TrkFMSQ.Parent := PanelFMSQ; TrkFMSQ.Left := BtnFMSQ.Left + BtnFMSQ.Width + 3; TrkFMSQ.Top := 21; // 28 - 14/2 = 21 TrkFMSQ.Width := LEFT_W - TrkFMSQ.Left - 30 - 4; // leave 30px for label + margins TrkFMSQ.Height := 14; TrkFMSQ.Min := 0; TrkFMSQ.Max := 100; TrkFMSQ.Position := FController.FFMSQLevel; TrkFMSQ.OnChange := TrkFMSQChange; // Numeric value label to the right of the slider LblFMSQ := TLabel.Create(Self); LblFMSQ.Parent := PanelFMSQ; LblFMSQ.Left := TrkFMSQ.Left + TrkFMSQ.Width + 3; LblFMSQ.Top := 21; LblFMSQ.Width := 26; LblFMSQ.Caption := IntToStr(FController.FFMSQLevel); LblFMSQ.Font.Color := CLR_TEXT; LblFMSQ.Font.Name := 'Courier New'; LblFMSQ.Font.Size := 8; // FM CTCSS panel (visible only when FM mode is active) PanelFMCTCSS := TPanel.Create(Self); PanelFMCTCSS.Parent := PanelLeft; PanelFMCTCSS.SetBounds(0, Y, LEFT_W, 50); PanelFMCTCSS.BevelOuter := bvNone; PanelFMCTCSS.Visible := False; MakeLbl(PanelFMCTCSS, 'CTCSS', 4, 2); BtnFMCTCSS := MakeBtn(PanelFMCTCSS, 'CTCSS', LeftPanelButtonLeft(LEFT_W, 3, 0), 16, LeftPanelButtonWidth(LEFT_W, 3, 0), BTN_H, BtnFMCTCSSClick); StyleButton(BtnFMCTCSS, False); BtnFMCTCSSTone := MakeBtn(PanelFMCTCSS, CTCSS_NAMES[0], LeftPanelButtonLeft(LEFT_W, 3, 1), 16, LEFT_W - LEFT_PANEL_BTN_PAD - LeftPanelButtonLeft(LEFT_W, 3, 1), BTN_H, BtnFMCTCSSToneClick); StyleButton(BtnFMCTCSSTone, False); FCTCSSDropDown := TFlatDropDown.Create(BtnFMCTCSSTone, PanelLeft, 4, BTN_SM); FCTCSSDropDown.SetItems(CTCSS_NAMES); FCTCSSDropDown.SetItemIndex(FController.FFMCTCSSToneIdx); FCTCSSDropDown.OnSelect := OnCTCSSDropDownSelect; // FM Step panel (visible only in FM mode, between Filter and SQL) PanelFMStep := TPanel.Create(Self); PanelFMStep.Parent := PanelLeft; PanelFMStep.SetBounds(0, Y, LEFT_W, 50); PanelFMStep.BevelOuter := bvNone; PanelFMStep.Visible := False; MakeLbl(PanelFMStep, 'STEP', 4, 2); BtnFMStep := MakeBtn(PanelFMStep, 'STEP', LeftPanelButtonLeft(LEFT_W, 3, 0), 16, LeftPanelButtonWidth(LEFT_W, 3, 0), BTN_H, BtnFMStepClick); StyleButton(BtnFMStep, FController.FFMStepOn); BtnFMStepSel := MakeBtn(PanelFMStep, FM_STEP_NAMES[FController.FFMStepIdx], LeftPanelButtonLeft(LEFT_W, 3, 1), 16, LEFT_W - LEFT_PANEL_BTN_PAD - LeftPanelButtonLeft(LEFT_W, 3, 1), BTN_H, BtnFMStepSelClick); StyleButton(BtnFMStepSel, False); FStepDropDown := TFlatDropDown.Create(BtnFMStepSel, PanelLeft, FM_STEP_COUNT, BTN_SM); FStepDropDown.SetItems(FM_STEP_NAMES); FStepDropDown.SetItemIndex(FController.FFMStepIdx); FStepDropDown.OnSelect := OnStepDropDownSelect; // FM Repeater offset panel (visible only in FM mode, below CTCSS) PanelFMRpt := TPanel.Create(Self); PanelFMRpt.Parent := PanelLeft; PanelFMRpt.SetBounds(0, Y, LEFT_W, 50); PanelFMRpt.BevelOuter := bvNone; PanelFMRpt.Visible := False; MakeLbl(PanelFMRpt, 'RPT', 4, 2); BtnFMRptMinus := MakeBtn(PanelFMRpt, '-', LeftPanelButtonLeft(LEFT_W, 5, 0), 16, LeftPanelButtonWidth(LEFT_W, 5, 0), BTN_H, BtnFMRptMinusClick); StyleButton(BtnFMRptMinus, False); BtnFMRptPlus := MakeBtn(PanelFMRpt, '+', LeftPanelButtonLeft(LEFT_W, 5, 4), 16, LeftPanelButtonWidth(LEFT_W, 5, 4), BTN_H, BtnFMRptPlusClick); StyleButton(BtnFMRptPlus, False); W := LeftPanelButtonLeft(LEFT_W, 5, 1); EdFMRptOffset := TFlatEdit.Create(Self); EdFMRptOffset.Parent := PanelFMRpt; EdFMRptOffset.Left := W; EdFMRptOffset.Top := 18; EdFMRptOffset.Width := LeftPanelButtonLeft(LEFT_W, 5, 4) - W - LEFT_PANEL_BTN_GAP; EdFMRptOffset.Height := BTN_H - 4; EdFMRptOffset.Text := RptFormatOffset(FController.FFMRptOffsetHz); EdFMRptOffset.OnChange := EdFMRptOffsetChange; // ---- Right panel ---- PanelRight := TPanel.Create(Self); PanelRight.Parent := Self; PanelRight.Align := alClient; PanelRight.BevelOuter := bvNone; PanelRight.DoubleBuffered := True; PanelRight.OnResize := RightPanelResize; FSampleRateOverlay := TSampleRateOverlay.Create(Self); FSampleRateOverlay.OnSpanSelect := OnSampleRateSelect; FSampleRateOverlay.OnHidePanel := OnSampleRateHidePanel; FSampleRateOverlay.OnInvalidate := OnSampleRateOverlayInvalidate; FSampleRateOverlay.SetCurrentRate(FController.FSampleRate); FSpecView.SampleRateOverlay := FSampleRateOverlay; // S-метр — внутри PanelToolbar, справа PanelSMeterRight := TPanel.Create(Self); PanelSMeterRight.Parent := PanelToolbar; PanelSMeterRight.BevelOuter := bvNone; PanelSMeterRight.Color := CLR_PANEL; PbSMeterRight := TPaintBox.Create(Self); PbSMeterRight.Parent := PanelSMeterRight; PbSMeterRight.Align := alClient; PbSMeterRight.OnPaint := FSpecView.PaintSMeterRight; PbSMeterRight.Color := CLR_PANEL; if FUseOpenGLSpectrum then begin PbWideband := TOpenGLControl.Create(Self); TOpenGLControl(PbWideband).AutoResizeViewport := False; TOpenGLControl(PbWideband).DoubleBuffered := True; TOpenGLControl(PbWideband).OnPaint := FWidebandView.Paint; TOpenGLControl(PbWideband).OnMouseDown := PbWidebandMouseDown; end else begin PbWideband := TPaintBox.Create(Self); TPaintBox(PbWideband).OnPaint := FWidebandView.Paint; TPaintBox(PbWideband).OnMouseDown := PbWidebandMouseDown; end; PbWideband.Parent := PanelRight; PbWideband.Visible := False; PbWidebandRuler := TPaintBox.Create(Self); PbWidebandRuler.Parent := PanelRight; PbWidebandRuler.OnPaint := FWidebandView.PaintRuler; PbWidebandRuler.Cursor := crDefault; PbWidebandRuler.Visible := False; if FUseOpenGLSpectrum then begin PbSpectrum := TOpenGLControl.Create(Self); TOpenGLControl(PbSpectrum).AutoResizeViewport := False; TOpenGLControl(PbSpectrum).DoubleBuffered := True; TOpenGLControl(PbSpectrum).OnPaint := FSpecView.PaintSpectrum; TOpenGLControl(PbSpectrum).OnMouseDown := PbSpectrumMouseDown; TOpenGLControl(PbSpectrum).OnDblClick := PbSpectrumDblClick; TOpenGLControl(PbSpectrum).OnMouseMove := PbSpectrumMouseMove; TOpenGLControl(PbSpectrum).OnMouseUp := PbSpectrumMouseUp; TOpenGLControl(PbSpectrum).OnMouseLeave := PbSpectrumMouseLeave; TSpectrumViewOpenGL(FSpecView).AttachControl(TOpenGLControl(PbSpectrum)); end else begin PbSpectrum := TPaintBox.Create(Self); TPaintBox(PbSpectrum).OnPaint := FSpecView.PaintSpectrum; TPaintBox(PbSpectrum).OnMouseDown := PbSpectrumMouseDown; TPaintBox(PbSpectrum).OnDblClick := PbSpectrumDblClick; TPaintBox(PbSpectrum).OnMouseMove := PbSpectrumMouseMove; TPaintBox(PbSpectrum).OnMouseUp := PbSpectrumMouseUp; TPaintBox(PbSpectrum).OnMouseLeave := PbSpectrumMouseLeave; end; PbSpectrum.Parent := PanelRight; PbRuler := TPaintBox.Create(Self); PbRuler.Parent := PanelRight; PbRuler.OnPaint := FSpecView.PaintRuler; PbRuler.Cursor := crDefault; FSpecView.PbRuler := PbRuler; // ---- Splitter между спектром+линейкой и водопадом ---- PanelSplitter := TPanel.Create(Self); PanelSplitter.Parent := PanelRight; PanelSplitter.BevelOuter := bvNone; PanelSplitter.Color := TColor($00303030); PanelSplitter.Cursor := crVSplit; PanelSplitter.Height := 5; PanelSplitter.OnMouseDown := SplitterMouseDown; PanelSplitter.OnMouseMove := SplitterMouseMove; PanelSplitter.OnMouseUp := SplitterMouseUp; if FUseOpenGLSpectrum then begin PbWaterfall := TOpenGLControl.Create(Self); TOpenGLControl(PbWaterfall).AutoResizeViewport := False; TOpenGLControl(PbWaterfall).DoubleBuffered := True; TOpenGLControl(PbWaterfall).OnPaint := FSpecView.PaintWaterfall; TOpenGLControl(PbWaterfall).OnMouseDown := PbWaterfallMouseDown; TOpenGLControl(PbWaterfall).OnMouseMove := PbWaterfallMouseMove; TOpenGLControl(PbWaterfall).OnMouseUp := PbWaterfallMouseUp; TSpectrumViewOpenGL(FSpecView).AttachWaterfallControl(TOpenGLControl(PbWaterfall)); end else begin PbWaterfall := TPaintBox.Create(Self); TPaintBox(PbWaterfall).OnPaint := FSpecView.PaintWaterfall; TPaintBox(PbWaterfall).OnMouseDown := PbWaterfallMouseDown; TPaintBox(PbWaterfall).OnMouseMove := PbWaterfallMouseMove; TPaintBox(PbWaterfall).OnMouseUp := PbWaterfallMouseUp; end; PbWaterfall.Parent := PanelRight; // Initial layout ResizeSpectrumPanels; end; // =========================================================================== // Splitter — перетаскивание границы спектр/водопад // Обновление размеров происходит только при отпускании мыши (MouseUp), // чтобы исключить фризы во время перетаскивания. // Во время перетаскивания рисуем только призрак-линию на PanelSplitter. // =========================================================================== procedure TMainForm.SplitterMouseDown(Sender: TObject; Button: TMouseButton; Shift: TShiftState; X, Y: Integer); var P: TPoint; begin if Button <> mbLeft then Exit; FSplitterDrag := True; FSplitterSH0 := FSpectrumHeight; // Y в координатах PanelRight P := PanelRight.ScreenToClient(PanelSplitter.ClientToScreen(Point(X, Y))); FSplitterDragY0 := P.Y; PanelSplitter.Color := TColor($005050A0); // подсветка при захвате {$IFDEF WINDOWS} SetCapture(PanelSplitter.Handle); {$ENDIF} end; procedure TMainForm.SplitterMouseMove(Sender: TObject; Shift: TShiftState; X, Y: Integer); var P: TPoint; DeltaY: Integer; NewSH: Integer; TopOff, AvailH: Integer; RULER_H: Integer; const SPLITTER_H = 5; MIN_SH = 60; MIN_WH = 40; begin RULER_H := MulDiv(18, Screen.PixelsPerInch, 96); if not FSplitterDrag then Exit; P := PanelRight.ScreenToClient(PanelSplitter.ClientToScreen(Point(X, Y))); DeltaY := P.Y - FSplitterDragY0; TopOff := 0; AvailH := PanelRight.ClientHeight - TopOff - RULER_H - SPLITTER_H; if AvailH <= 0 then Exit; NewSH := FSplitterSH0 + DeltaY; if NewSH < MIN_SH then NewSH := MIN_SH; if NewSH > AvailH - MIN_WH then NewSH := AvailH - MIN_WH; // Только двигаем сплиттер визуально — без пересчёта битмапов PanelSplitter.Top := TopOff + NewSH + RULER_H; PbWaterfall.Top := TopOff + NewSH + RULER_H + SPLITTER_H; PbWaterfall.Height := AvailH - NewSH; end; procedure TMainForm.SplitterMouseUp(Sender: TObject; Button: TMouseButton; Shift: TShiftState; X, Y: Integer); var P: TPoint; DeltaY: Integer; NewSH: Integer; TopOff, AvailH: Integer; RULER_H: Integer; const SPLITTER_H = 5; MIN_SH = 60; MIN_WH = 40; begin RULER_H := MulDiv(18, Screen.PixelsPerInch, 96); if not FSplitterDrag then Exit; FSplitterDrag := False; {$IFDEF WINDOWS} ReleaseCapture; {$ENDIF} PanelSplitter.Color := TColor($00303030); // обычный цвет P := PanelRight.ScreenToClient(PanelSplitter.ClientToScreen(Point(X, Y))); DeltaY := P.Y - FSplitterDragY0; TopOff := 0; AvailH := PanelRight.ClientHeight - TopOff - RULER_H - SPLITTER_H; if AvailH <= 0 then Exit; NewSH := FSplitterSH0 + DeltaY; if NewSH < MIN_SH then NewSH := MIN_SH; if NewSH > AvailH - MIN_WH then NewSH := AvailH - MIN_WH; // Сохраняем новое соотношение и делаем полный пересчёт с перерисовкой FSplitterRatio := NewSH / AvailH; ResizeSpectrumPanels; end; // =========================================================================== // Resize handler (normal method instead of anonymous procedure) // =========================================================================== procedure TMainForm.RightPanelResize(Sender: TObject); begin ResizeSpectrumPanels; end; procedure TMainForm.LayoutTopVfoBlock; const MARGIN = 4; TOP_OFFSET = 2; TOP_VFO_FREQ_H = 47; VFO_BTN_FREQ_GAP = 4; VFO_FREQ_LEFT_PAD = 2; VFO_FREQ_RIGHT_PAD = 8; XFER_BTN_W = 44; XFER_BTN_H = 20; XFER_BTN_GAP = 4; XFER_PANEL_W = XFER_BTN_W * 3 + XFER_BTN_GAP * 2; // = 140 XFER_SIDE_GAP = 14; // минимальный зазор между VFO и кнопками трансфера SETTINGS_GAP = 8; MAX_SPREAD = 100; // максимальный дополнительный отступ с каждой стороны при растяжении // VFO-B получает левый отступ = правому паддингу VFO-A для симметрии зазоров VFO_B_LEFT_PAD = VFO_FREQ_LEFT_PAD + VFO_FREQ_RIGHT_PAD; // = 10 var BlockH, FreqTop, CenterY, SafeLeft, SafeRight, GroupLeft, GroupTop: Integer; RightW, SMW, SMLeft, AvailW, MinGroupW, ExtraPerSide, DynGap: Integer; TopVfoAW, TopVfoBW, NeedFreqAW, NeedFreqBW, FreqTextW, GroupW: Integer; XferX, XferY: Integer; begin if (PanelToolbar = nil) or (PanelTopVfoGroup = nil) or (PanelTopVfoA = nil) or (PanelTopVfoB = nil) then Exit; Canvas.Font.Name := FreqDispB.FontName; Canvas.Font.Size := FreqDispB.FontSize; Canvas.Font.Style := [fsBold]; FreqDispA.Left := BtnTopVfoASelect.Width + VFO_BTN_FREQ_GAP - VFO_FREQ_LEFT_PAD; FreqDispB.Left := VFO_B_LEFT_PAD + BtnTopVfoBSelect.Width + VFO_BTN_FREQ_GAP - VFO_FREQ_LEFT_PAD; FreqTextW := Canvas.TextWidth(StringOfChar('0', Max(3, FFreqMhzDigits)) + '.000.000'); NeedFreqAW := FreqDispA.Left + FreqTextW + VFO_FREQ_LEFT_PAD + VFO_FREQ_RIGHT_PAD; NeedFreqBW := FreqDispB.Left + FreqTextW + VFO_FREQ_LEFT_PAD + VFO_FREQ_RIGHT_PAD; TopVfoAW := NeedFreqAW; TopVfoBW := NeedFreqBW; // Минимальная ширина компактной группы MinGroupW := TopVfoAW + XFER_SIDE_GAP + XFER_PANEL_W + XFER_SIDE_GAP + TopVfoBW; SafeLeft := MARGIN; if BtnSettings <> nil then SafeLeft := BtnSettings.Left + BtnSettings.Width + SETTINGS_GAP; SafeRight := ClientWidth - MARGIN; if PanelLeft <> nil then begin RightW := ClientWidth - PanelLeft.Width; if RightW > 200 then begin SMW := Round(RightW * SMETER_WIDTH_RATIO); SMLeft := ClientWidth - SMW - SMETER_MARGIN; SafeRight := Min(SafeRight, SMLeft - TOP_SMETER_GAP); end; end; AvailW := SafeRight - SafeLeft; if AvailW < MinGroupW then begin PanelTopVfoGroup.Visible := False; Exit; end; // Динамический зазор: растёт с шириной окна, но не больше MAX_SPREAD ExtraPerSide := Min((AvailW - MinGroupW) div 2, MAX_SPREAD); DynGap := XFER_SIDE_GAP + ExtraPerSide; GroupW := TopVfoAW + DynGap + XFER_PANEL_W + DynGap + TopVfoBW; BlockH := PanelToolbar.Height - (MARGIN * 2) - 8; if BlockH < 56 then BlockH := 56; // Центрируем группу в доступном пространстве GroupLeft := SafeLeft + (AvailW - GroupW) div 2; GroupTop := TOP_OFFSET; CenterY := PanelToolbar.Height div 2 - GroupTop; PanelTopVfoGroup.SetBounds(GroupLeft, GroupTop, GroupW, BlockH); PanelTopVfoGroup.Visible := True; // VFO-A: левый край группы PanelTopVfoA.SetBounds(0, 0, TopVfoAW, BlockH); PanelTopVfoA.Visible := True; PanelVfoA.SetBounds(0, 0, TopVfoAW, BlockH); // A выше оси, TX ниже; 1px зазор с каждой стороны BtnTopVfoASelect.SetBounds(0, CenterY - BtnTopVfoASelect.Height - 1, BtnTopVfoASelect.Width, BtnTopVfoASelect.Height); BtnTopVfoATX.SetBounds(0, CenterY + 1, BtnTopVfoATX.Width, BtnTopVfoATX.Height); FreqTop := CenterY - (TOP_VFO_FREQ_H div 2); FreqDispA.SetBounds(FreqDispA.Left, FreqTop, FreqTextW + VFO_FREQ_LEFT_PAD + VFO_FREQ_RIGHT_PAD, TOP_VFO_FREQ_H); // Кнопки трансфера: горизонтальный ряд, вертикально по оси XferX := TopVfoAW + DynGap; XferY := CenterY - (XFER_BTN_H div 2); PanelVfoButtons.SetBounds(XferX, XferY, XFER_PANEL_W, XFER_BTN_H); BtnVfoACopyB.SetBounds(0, 0, XFER_BTN_W, XFER_BTN_H); BtnVfoSwap.SetBounds(XFER_BTN_W + XFER_BTN_GAP, 0, XFER_BTN_W, XFER_BTN_H); BtnVfoBCopyA.SetBounds((XFER_BTN_W + XFER_BTN_GAP) * 2, 0, XFER_BTN_W, XFER_BTN_H); // VFO-B: правый край группы PanelTopVfoB.SetBounds(TopVfoAW + DynGap + XFER_PANEL_W + DynGap, 0, TopVfoBW, BlockH); PanelTopVfoB.Visible := True; PanelVfoB.SetBounds(0, 0, TopVfoBW, BlockH); BtnTopVfoBSelect.SetBounds(VFO_B_LEFT_PAD, CenterY - BtnTopVfoBSelect.Height - 1, BtnTopVfoBSelect.Width, BtnTopVfoBSelect.Height); BtnTopVfoBTX.SetBounds(VFO_B_LEFT_PAD, CenterY + 1, BtnTopVfoBTX.Width, BtnTopVfoBTX.Height); FreqTop := CenterY - (TOP_VFO_FREQ_H div 2); FreqDispB.SetBounds(FreqDispB.Left, FreqTop, FreqTextW + VFO_FREQ_LEFT_PAD + VFO_FREQ_RIGHT_PAD, TOP_VFO_FREQ_H); end; procedure TMainForm.ResizeSMeter; // PanelSMeterRight на главной форме (Parent=Self). // По Y: от самого верха формы до низа тулбара. var FW: Integer; RightW: Integer; // ширина PanelRight SMW: Integer; // ширина S-метра SMLeft: Integer; SMTop: Integer; SMBot: Integer; begin if (PanelSMeterRight = nil) or (PanelToolbar = nil) or (PanelLeft = nil) then Exit; LayoutTopVfoBlock; FW := ClientWidth; // Ширина зоны спектра (PanelRight) RightW := FW - PanelLeft.Width; if RightW < 200 then Exit; // Ширина S-метра = 32% от PanelRight SMW := Round(RightW * SMETER_WIDTH_RATIO); // X: прижат к правому краю PanelToolbar SMLeft := FW - SMW - SMETER_MARGIN; SMTop := SMETER_MARGIN; SMBot := PanelToolbar.Height - SMETER_MARGIN - 9; PanelSMeterRight.SetBounds(SMLeft, SMTop, SMW, SMBot - SMTop); end; function TMainForm.UpdateWidebandFrequencyView: Boolean; var E: TXvtrEntry; SrcStart, SrcEnd: Double; MarkerChanged, ViewChanged: Boolean; begin Result := False; if FWidebandView = nil then Exit; if FController.FActiveVfo = 0 then MarkerChanged := FWidebandView.SetMarkerHz(FController.FVfoA) else MarkerChanged := FWidebandView.SetMarkerHz(FController.FVfoB); if (FController.FCurrentXvtr >= 0) and (FController.FCurrentXvtr < CFG_XVTR_COUNT) and FController.FXvtrSettings.Entries[FController.FCurrentXvtr].Enabled then begin E := FController.FXvtrSettings.Entries[FController.FCurrentXvtr]; SrcStart := E.FreqBegin - E.LOOffset + E.LOError; SrcEnd := E.FreqEnd - E.LOOffset + E.LOError; ViewChanged := FWidebandView.SetFrequencyView(E.FreqBegin, E.FreqEnd, SrcStart, SrcEnd); end else ViewChanged := FWidebandView.SetFrequencyView(0.0, 61440000.0, 0.0, 61440000.0); Result := MarkerChanged or ViewChanged; end; procedure TMainForm.ResizeSpectrumPanels; const SPLITTER_H = 5; WB_H = 118; MIN_SH = 60; // минимальная высота спектра MIN_WH = 40; // минимальная высота водопада var RW, RH, SH, WH, TopOff, WideH, WideSpecH: Integer; AvailH: Integer; RULER_H: Integer; begin RULER_H := MulDiv(18, Screen.PixelsPerInch, 96); if PanelRight = nil then Exit; if Assigned(FSpecView) then SyncSpecViewFreq; RW := PanelRight.ClientWidth; RH := PanelRight.ClientHeight; UpdateWidebandFrequencyView; WideH := 0; WideSpecH := 0; if FController.FShowWideband then WideH := Min(MulDiv(WB_H, Screen.PixelsPerInch, 96), Max(40, RH div 2)); if WideH > 0 then WideSpecH := Max(1, WideH - RULER_H); TopOff := WideH; if PbWideband <> nil then begin PbWideband.SetBounds(0, 0, RW, WideSpecH); PbWideband.Visible := FController.FShowWideband and (WideSpecH > 0); if FController.FShowWideband and (RW > 0) and (WideSpecH > 0) then begin FWidebandView.SetBitmapSize(RW, WideSpecH); PbWideband.Invalidate; end; end; if PbWidebandRuler <> nil then begin PbWidebandRuler.SetBounds(0, WideSpecH, RW, IfThen(FController.FShowWideband and (WideH > 0), RULER_H, 0)); PbWidebandRuler.Visible := FController.FShowWideband and (WideH > 0); if FController.FShowWideband and (RW > 0) and (WideH > 0) then begin FWidebandView.SetRulerSize(RW, RULER_H); PbWidebandRuler.Invalidate; end; end; // S-метр позиционируется отдельно в ResizeSMeter ResizeSMeter; // ---- Оба скрыты ---- if (not FController.FShowSpectrum) and (not FController.FShowWaterfall) then begin PbSpectrum.Visible := False; PbRuler.Visible := False; PanelSplitter.Visible := False; PbWaterfall.Visible := False; FSpectrumWidth := RW; FSpectrumHeight := 0; FWaterfallHeight := 0; PositionSampleRateOverlay; Exit; end; // ---- Только спектр (водопад скрыт) ---- if FController.FShowSpectrum and (not FController.FShowWaterfall) then begin AvailH := RH - TopOff - RULER_H; if AvailH < MIN_SH then AvailH := MIN_SH; SH := AvailH; WH := 0; PbSpectrum.SetBounds(0, TopOff, RW, SH); PbSpectrum.Visible := True; PbRuler.SetBounds(0, TopOff + SH, RW, RULER_H); PbRuler.Visible := True; PanelSplitter.Visible := False; PbWaterfall.Visible := False; PositionSampleRateOverlay; FSpectrumWidth := RW; FSpectrumHeight := SH; FWaterfallHeight := 0; if RW > 0 then begin FSpecView.SetSpectrumBitmapSize(RW, SH); FSpecView.SetWaterfallBitmapSize(1, 1); FSpecView.SetRulerSize(RW, RULER_H); FSpecView.DrawSpectrum; PbSpectrum.Invalidate; PbRuler.Invalidate; end; Exit; end; // ---- Только водопад (спектр скрыт): линейка под водопадом ---- if (not FController.FShowSpectrum) and FController.FShowWaterfall then begin AvailH := RH - TopOff - RULER_H; if AvailH < MIN_WH then AvailH := MIN_WH; SH := 0; WH := AvailH; PbSpectrum.Visible := False; PanelSplitter.Visible := False; PbWaterfall.SetBounds(0, TopOff, RW, WH); PbWaterfall.Visible := True; PbRuler.SetBounds(0, TopOff + WH, RW, RULER_H); PbRuler.Visible := True; PositionSampleRateOverlay; FSpectrumWidth := RW; FSpectrumHeight := 0; FWaterfallHeight := WH; if RW > 0 then begin FSpecView.SetSpectrumBitmapSize(1, 1); FSpecView.SetWaterfallBitmapSize(RW, WH); FSpecView.SetRulerSize(RW, RULER_H); FSpecView.DrawWaterfall; PbWaterfall.Invalidate; PbRuler.Invalidate; end; Exit; end; // ---- Оба видимы: стандартный режим со сплиттером ---- PbSpectrum.Visible := True; PbRuler.Visible := True; PanelSplitter.Visible := True; PbWaterfall.Visible := True; AvailH := RH - TopOff - RULER_H - SPLITTER_H; if AvailH < (MIN_SH + MIN_WH) then Exit; // Ограничиваем соотношение, чтобы каждая зона имела минимальный размер if FSplitterRatio < MIN_SH / AvailH then FSplitterRatio := MIN_SH / AvailH; if FSplitterRatio > 1.0 - MIN_WH / AvailH then FSplitterRatio := 1.0 - MIN_WH / AvailH; SH := Round(AvailH * FSplitterRatio); WH := AvailH - SH; if WH < MIN_WH then WH := MIN_WH; // Спектр PbSpectrum.SetBounds(0, TopOff, RW, SH); // Линейка частот — всегда прижата к низу спектра PbRuler.SetBounds(0, TopOff + SH, RW, RULER_H); // Сплиттер — между линейкой и водопадом PanelSplitter.SetBounds(0, TopOff + SH + RULER_H, RW, SPLITTER_H); // Водопад — под сплиттером PbWaterfall.SetBounds(0, TopOff + SH + RULER_H + SPLITTER_H, RW, WH); PositionSampleRateOverlay; // Обновляем переменные размеров FSpectrumWidth := RW; FSpectrumHeight := SH; FWaterfallHeight := WH; // Пересоздаём bitmap точно под новый размер if RW > 0 then begin if SH > 0 then FSpecView.SetSpectrumBitmapSize(RW, SH); if WH > 0 then FSpecView.SetWaterfallBitmapSize(RW, WH); FSpecView.SetRulerSize(RW, RULER_H); FSpecView.DrawSpectrum; PbSpectrum.Invalidate; if PbRuler <> nil then PbRuler.Invalidate; PbWaterfall.Invalidate; end; end; // =========================================================================== // Dark Theme // =========================================================================== procedure TMainForm.ApplyDarkTheme; var T: TAppTheme; i: Integer; procedure DP(P: TPanel); begin P.Color := T.Panel; P.Font.Color := T.Text; end; procedure DL(L: TLabel; Dim: Boolean = True); begin if Dim then L.Font.Color := T.TextDim else L.Font.Color := T.Text; end; procedure DS(S: TFlatSlider); begin S.SetThemeColors(T.SliderBG, T.SliderTrackEmpty, T.SliderTrackFill, T.SliderThumbNorm, T.SliderThumbHot, T.SliderThumbDrag, T.SliderThumbBdr); end; begin if FLightTheme then T := LightTheme else T := DarkTheme; Color := T.BG; Font.Color := T.Text; DP(PanelToolbar); DP(PanelLeft); DP(PanelTopVfoGroup); DP(PanelTopVfoA); DP(PanelTopVfoB); DP(PanelVfoA); DP(PanelVfoB); DP(PanelVfoButtons); DP(PanelBands); DP(PanelMode); DP(PanelFilter); if PanelFMSQ <> nil then DP(PanelFMSQ); if PanelFMCTCSS <> nil then DP(PanelFMCTCSS); if PanelFMStep <> nil then DP(PanelFMStep); if PanelFMRpt <> nil then DP(PanelFMRpt); if EdFMRptOffset <> nil then EdFMRptOffset.SetAppTheme(T); DP(PanelRXBlock); DP(PanelAGC); DP(PanelTX); DP(PanelRight); if PbSpectrum is TPaintBox then TPaintBox(PbSpectrum).Color := T.BG; if PbWaterfall is TPaintBox then TPaintBox(PbWaterfall).Color := T.BG; if PanelSMeterRight <> nil then PanelSMeterRight.Color := T.Panel; if PbSMeterRight <> nil then PbSMeterRight.Color := T.Panel; ApplyStatusTheme(T); // Метки DL(LblAGCTop, False); DL(LblDrv); if LblFMSQ <> nil then DL(LblFMSQ, False); // Кнопки тулбара BtnDiscover.ClrNorm := T.TbDiscoverNorm; BtnDiscover.ClrHot := T.TbDiscoverHot; BtnDiscover.ClrActive := T.TbDiscoverHot; BtnDiscover.ClrBorder := T.TbDiscoverBorder; BtnDiscover.ClrText := T.TbDiscoverText; BtnDiscover.ClrTextAct := T.TbDiscoverText; BtnDiscover.Invalidate; BtnStartStop.ClrNorm := T.TbStartNorm; BtnStartStop.ClrBorder := T.TbStartBorder; BtnStartStop.ClrText := T.TbStartText; BtnStartStop.Invalidate; // Обычные кнопки — все массивы for i := 0 to BAND_COUNT-1 do StyleButton(BtnBand[i], BtnBand[i].Active); for i := 0 to MODE_COUNT-1 do StyleButton(BtnMode[i], BtnMode[i].Active); for i := 0 to FILT_COUNT-1 do StyleButton(BtnFilter[i], BtnFilter[i].Active); for i := 0 to 4 do StyleButton(BtnAGCMode[i],BtnAGCMode[i].Active); StyleButton(BtnCTun, BtnCTun.Active); StyleButton(BtnDUP, BtnDUP.Active); if BtnFMSQ <> nil then StyleButton(BtnFMSQ, BtnFMSQ.Active); if TrkFMSQ <> nil then DS(TrkFMSQ); if BtnFMCTCSS <> nil then StyleButton(BtnFMCTCSS, BtnFMCTCSS.Active); if BtnFMCTCSSTone <> nil then StyleButton(BtnFMCTCSSTone, False); if BtnFMRptMinus <> nil then StyleButton(BtnFMRptMinus, BtnFMRptMinus.Active); if BtnFMRptPlus <> nil then StyleButton(BtnFMRptPlus, BtnFMRptPlus.Active); if FCTCSSDropDown <> nil then FCTCSSDropDown.ApplyStyle(StyleButton, T.Panel); if FStepDropDown <> nil then FStepDropDown.ApplyStyle(StyleButton, T.Panel); if BtnChannels <> nil then StyleButton(BtnChannels, False); if FChannelsDropDown <> nil then FChannelsDropDown.ApplyStyle(StyleButton, T.Panel); StyleButton(BtnNR, BtnNR.Active); StyleButton(BtnNB, BtnNB.Active); StyleButton(BtnSNB, BtnSNB.Active); StyleButton(BtnANF, BtnANF.Active); StyleButton(BtnMute, BtnMute.Active); StyleButton(BtnMOX, BtnMOX.Active); StyleButton(BtnTUN, BtnTUN.Active); // VFO-copy StyleSpanButton(BtnVfoACopyB, False); StyleSpanButton(BtnVfoSwap, False); StyleSpanButton(BtnVfoBCopyA, False); // Ползунки DS(TrkAGC); DS(TrkVolume); DS(TrkDrive); // Спектр FSpecView.SetTheme(T); if Assigned(FWidebandView) then FWidebandView.SetTheme(T); // VFO display — вызывает UpdateVfoDisplay, который использует тему UpdateVfoDisplay; end; procedure TMainForm.SetStatusText(Index: Integer; const Text: string); begin if StatusPanel = nil then Exit; StatusPanel.SetStatusText(Index, Text); end; procedure TMainForm.SetRadioOfflineStatus(const ConnText: string; ClearDevice: Boolean); begin SetStatusText(2, ConnText); SetStatusText(3, 'Supply --'); SetStatusText(4, 'RX idle'); SetStatusText(5, 'TX idle'); SetStatusText(6, 'SEQ --'); SetStatusText(7, 'PLL --'); if FSpecView <> nil then FSpecView.ADCOverloadVisible := False; if PbSpectrum <> nil then PbSpectrum.Invalidate; FillChar(FController.FDDCSeqValid, SizeOf(FController.FDDCSeqValid), 0); FController.FSeqErrorCount := 0; FController.FLastSeqErrorDDC := -1; FController.FLastSeqErrorDelta := 0; FController.FSeqOkStreak := 0; if ClearDevice then begin SetStatusText(0, 'Board --'); SetStatusText(1, 'IP --'); end; end; procedure TMainForm.ApplyStatusTheme(const T: TAppTheme); begin if StatusPanel = nil then Exit; StatusPanel.ApplyTheme(T); end; procedure TMainForm.SetLightTheme(V: Boolean); begin if FLightTheme = V then Exit; FLightTheme := V; FController.FLoadedGlobal.LightTheme := V; // зеркало в persist-буфер контроллера ApplyDarkTheme; FController.FSettings.SaveTheme(V); FController.FSettings.Save; if (FSettingsForm <> nil) and TSettingsForm(FSettingsForm).Visible then TSettingsForm(FSettingsForm).LoadLightTheme(V); end; procedure TMainForm.StyleButton(B: TFlatButton; Active: Boolean); var T: TAppTheme; begin if FLightTheme then T := LightTheme else T := DarkTheme; B.Active := Active; B.ClrNorm := T.BtnNorm; B.ClrActive := T.BtnActive; B.ClrHot := T.BtnHot; if Active then B.ClrBorder := T.BtnBorderActive else B.ClrBorder := T.BtnBorderNorm; B.ClrText := T.BtnText; B.ClrTextAct := T.BtnTextActive; B.Font.Name := 'Courier New'; B.Font.Size := 8; end; procedure TMainForm.StyleSpanButton(B: TFlatButton; Active: Boolean); var T: TAppTheme; begin if FLightTheme then T := LightTheme else T := DarkTheme; B.Active := Active; B.ClrNorm := T.SpanNorm; B.ClrActive := T.SpanActive; B.ClrHot := T.SpanActive; B.ClrBorder := T.SpanBorder; B.ClrText := T.SpanText; B.ClrTextAct := T.SpanText; B.Font.Name := 'Courier New'; B.Font.Size := 7; end; procedure TMainForm.PositionSampleRateOverlay; const MARGIN_X = 34; MARGIN_Y = 4; begin if (FSampleRateOverlay = nil) or (PbSpectrum = nil) then Exit; // Ширина = доступная ширина спектра справа от MARGIN_X (контент сам клампится // к этому пределу). Раньше фиксировалось 320px → 8 Pluto-кнопок не влезали. FSampleRateOverlay.SetBounds( MARGIN_X, MARGIN_Y, Max(320, PbSpectrum.Width - MARGIN_X * 2), FSampleRateOverlay.Height); end; procedure TMainForm.OnSampleRateOverlayInvalidate(Sender: TObject); begin FSpectrumDirty := True; if Assigned(FSpecView) then begin FSpecView.SpectrumDirty := True; FSpecView.InvalidateOverlayCache; end; if Assigned(PbSpectrum) then PbSpectrum.Invalidate; end; // =========================================================================== // VFO // =========================================================================== function TMainForm.FormatFreq(Hz: Double): string; var Mhz: Int64; KHz, Rest: Integer; begin Mhz := Trunc(Hz / 1000000); KHz := Trunc((Hz - Mhz * 1000000) / 1000); Rest := Trunc(Hz) mod 1000; Result := Format('%d.%3.3d.%3.3d', [Mhz, KHz, Rest]); end; procedure TMainForm.UpdateVfoDisplay; const TOP_VFO_FREQ_H = 47; var T: TAppTheme; begin if FLightTheme then T := LightTheme else T := DarkTheme; FreqDispA.Frequency := Round(FController.FVfoA); FreqDispB.Frequency := Round(FController.FVfoB); FreqDispA.FontSize := TOP_VFO_FONT; FreqDispB.FontSize := TOP_VFO_FONT; FreqDispA.Height := TOP_VFO_FREQ_H; FreqDispB.Height := TOP_VFO_FREQ_H; FreqDispA.ColorHover := T.FreqHover; FreqDispA.ColorHoverBG := T.FreqHoverBG; FreqDispA.ColorDim := T.FreqDimDot; FreqDispB.ColorHover := T.FreqHover; FreqDispB.ColorHoverBG := T.FreqHoverBG; FreqDispB.ColorDim := T.FreqDimDot; if FController.FActiveVfo = 0 then begin FreqDispA.ColorNormal := T.FreqActive; FreqDispB.ColorNormal := T.FreqDim; LblVfoALabel.Font.Color := T.FreqActive; LblVfoBLabel.Font.Color := T.TextDim; if BtnTopVfoASelect <> nil then begin BtnTopVfoASelect.ClrNorm := T.VfoSelActNorm; BtnTopVfoASelect.ClrActive := T.VfoSelActNorm; BtnTopVfoASelect.ClrHot := T.VfoSelActNorm; BtnTopVfoASelect.ClrBorder := T.VfoSelActBorder; BtnTopVfoASelect.ClrText := T.VfoSelActText; BtnTopVfoASelect.ClrTextAct := T.VfoSelActTextAct; BtnTopVfoASelect.Active := True; BtnTopVfoASelect.Invalidate; end; if BtnTopVfoBSelect <> nil then begin BtnTopVfoBSelect.ClrNorm := T.VfoSelInaNorm; BtnTopVfoBSelect.ClrActive := T.VfoSelInaNorm; BtnTopVfoBSelect.ClrHot := T.VfoSelInaNorm; BtnTopVfoBSelect.ClrBorder := T.VfoSelInaBorder; BtnTopVfoBSelect.ClrText := T.VfoSelInaText; BtnTopVfoBSelect.ClrTextAct := T.VfoSelInaTextAct; BtnTopVfoBSelect.Active := False; BtnTopVfoBSelect.Invalidate; end; end else begin FreqDispA.ColorNormal := T.FreqDim; FreqDispB.ColorNormal := T.FreqActive; LblVfoALabel.Font.Color := T.TextDim; LblVfoBLabel.Font.Color := T.FreqActive; if BtnTopVfoASelect <> nil then begin BtnTopVfoASelect.ClrNorm := T.VfoSelInaNorm; BtnTopVfoASelect.ClrActive := T.VfoSelInaNorm; BtnTopVfoASelect.ClrHot := T.VfoSelInaNorm; BtnTopVfoASelect.ClrBorder := T.VfoSelInaBorder; BtnTopVfoASelect.ClrText := T.VfoSelInaText; BtnTopVfoASelect.ClrTextAct := T.VfoSelInaTextAct; BtnTopVfoASelect.Active := False; BtnTopVfoASelect.Invalidate; end; if BtnTopVfoBSelect <> nil then begin BtnTopVfoBSelect.ClrNorm := T.VfoSelActNorm; BtnTopVfoBSelect.ClrActive := T.VfoSelActNorm; BtnTopVfoBSelect.ClrHot := T.VfoSelActNorm; BtnTopVfoBSelect.ClrBorder := T.VfoSelActBorder; BtnTopVfoBSelect.ClrText := T.VfoSelActText; BtnTopVfoBSelect.ClrTextAct := T.VfoSelActTextAct; BtnTopVfoBSelect.Active := True; BtnTopVfoBSelect.Invalidate; end; end; if BtnTopVfoATX <> nil then begin if not FController.FSplitTxB then begin BtnTopVfoATX.ClrNorm := T.VfoTxActNorm; BtnTopVfoATX.ClrHot := T.VfoTxActNorm; BtnTopVfoATX.ClrActive := T.VfoTxActNorm; BtnTopVfoATX.ClrBorder := T.VfoTxActBorder; BtnTopVfoATX.ClrText := T.VfoTxActText; BtnTopVfoATX.ClrTextAct := T.VfoTxActText; BtnTopVfoATX.Active := True; end else begin BtnTopVfoATX.ClrNorm := T.VfoTxInaNorm; BtnTopVfoATX.ClrHot := T.VfoTxInaNorm; BtnTopVfoATX.ClrActive := T.VfoTxInaNorm; BtnTopVfoATX.ClrBorder := T.VfoTxInaBorder; BtnTopVfoATX.ClrText := T.VfoTxInaText; BtnTopVfoATX.ClrTextAct := T.VfoTxInaText; BtnTopVfoATX.Active := False; end; BtnTopVfoATX.Invalidate; end; if BtnTopVfoBTX <> nil then begin if FController.FSplitTxB then begin BtnTopVfoBTX.ClrNorm := T.VfoTxActNorm; BtnTopVfoBTX.ClrHot := T.VfoTxActNorm; BtnTopVfoBTX.ClrActive := T.VfoTxActNorm; BtnTopVfoBTX.ClrBorder := T.VfoTxActBorder; BtnTopVfoBTX.ClrText := T.VfoTxActText; BtnTopVfoBTX.ClrTextAct := T.VfoTxActText; BtnTopVfoBTX.Active := True; end else begin BtnTopVfoBTX.ClrNorm := T.VfoTxInaNorm; BtnTopVfoBTX.ClrHot := T.VfoTxInaNorm; BtnTopVfoBTX.ClrActive := T.VfoTxInaNorm; BtnTopVfoBTX.ClrBorder := T.VfoTxInaBorder; BtnTopVfoBTX.ClrText := T.VfoTxInaText; BtnTopVfoBTX.ClrTextAct := T.VfoTxInaText; BtnTopVfoBTX.Active := False; end; BtnTopVfoBTX.Invalidate; end; FreqDispA.Invalidate; FreqDispB.Invalidate; LblVfoALabel.Invalidate; LblVfoBLabel.Invalidate; end; // =========================================================================== // Rendering helpers — delegated to TSpectrumView // =========================================================================== procedure TMainForm.InvalidateGridCache; begin FSpecView.InvalidateGridCache; end; procedure TMainForm.SyncSpecViewFreq; begin FSpecView.VfoA := FController.FVfoA; FSpecView.VfoB := FController.FVfoB; FSpecView.ActiveVfo := FController.FActiveVfo; FSpecView.CenterFreq := FController.FCenterFreq; FSpecView.SpanHz := FController.FSpanHz; FSpecView.Mode := FController.FMode; FSpecView.FilterBW := FController.FFilterBW; if FController.FMode = MODE_FM then FSpecView.FMGridStepHz := FM_STEP_HZ[FController.FFMStepIdx] else FSpecView.FMGridStepHz := 0; // TX overlay следует за активной TX-частотой (учитывает split). При CTUN // спектр TX будет отрисован на VFO, а не в центре дисплея. FSpecView.TXFreq := ActiveTXFreqHz; // Какой VFO привязан к TX (для split-полос фильтра): задаётся селектором // FSplitTxB (TX-A/TX-B), независимо от активного RX-VFO. if FController.FSplitTxB then FSpecView.TXVfoIndex := 1 else FSpecView.TXVfoIndex := 0; if UpdateWidebandFrequencyView and FController.FShowWideband and (PbWideband <> nil) then PbWideband.Invalidate; end; // =========================================================================== // Timers // =========================================================================== procedure TMainForm.MeterTimerTick(Sender: TObject); const AVG_ALPHA = 0.25; // EMA для сглаженного среднего уровня ZONE_DB = 6.0; // полуширина зоны вокруг среднего (дБ) // Адаптивный alpha: базовый + ускорение при большой разнице PEAK_BASE = 0.06; // базовая скорость пика MIN_BASE = 0.06; // базовая скорость минимума ACCEL_FACTOR = 0.018; // ускорение на каждый dB разницы MAX_ALPHA = 0.85; // максимальная скорость (не мгновенно) var PeakTarget, MinTarget: Double; PeakDiff, MinDiff: Double; PeakAlpha, MinAlpha: Double; begin if not FController.FRunning then begin if PbSMeterRight <> nil then PbSMeterRight.Invalidate; Exit; end; // QO-100 beacon lock: одна итерация контура (no-op если лок выключен). FController.ServiceBeaconLock; // Pluto: опрос температуры/RSSI (троттлинг внутри; no-op для HPSDR). FController.ServicePlutoTelemetry; // Сглаженное среднее (EMA) FSMeterAvg := FSMeterAvg * (1.0 - AVG_ALPHA) + FController.FLastSMeter * AVG_ALPHA; // Цели для Peak и Min PeakTarget := Max(FController.FLastSMeter, FSMeterAvg + ZONE_DB); MinTarget := Min(FController.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 := FController.FLastSMeter; FSpecView.SMeterPeak := FSMeterPeak; FSpecView.SMeterMin := FSMeterMin; FSpecView.LastFwdW := FController.FLastFwdW; FSpecView.LastSWR := FController.FLastSWR; FSpecView.Transmitting := FController.FTransmitting; if PbSMeterRight <> nil then PbSMeterRight.Invalidate; // PWR/SWR метки и бары обновляем здесь (10 Гц), а не в DoUpdateStatus — // HP Status пакеты идут ~150 раз/с, при таком rate цифры дёргаются. UpdateTXMeters; if FController.FWDSPReady then begin if FController.FTransmitting then begin case FController.FDSPEngine.TXMicSource of txmsSoundCard: SetStatusText(5, 'TX sound card'); txmsWeb: SetStatusText(5, 'TX web mic'); else SetStatusText(5, 'TX radio mic'); end; end else SetStatusText(5, 'TX idle'); end; end; procedure TMainForm.SpectrumTimerTick(Sender: TObject); var NowTick: QWord; ElapsedTick: QWord; begin // Синхронизируем размеры если размер панели изменился (после ресайза). // Важно: проверяем только видимые панели — когда спектр скрыт, // PbSpectrum.Height может быть ненулевым при FSpectrumHeight=0, // что вызвало бы сброс буфера водопада на каждом тике. if (FSpectrumWidth = 0) or (FController.FShowWideband and (PbWideband.Width <> FSpectrumWidth)) or (FController.FShowSpectrum and ((PbSpectrum.Width <> FSpectrumWidth) or (PbSpectrum.Height <> FSpectrumHeight))) or (FController.FShowWaterfall and not FController.FShowSpectrum and ((PbWaterfall.Width <> FSpectrumWidth) or (PbWaterfall.Height <> FWaterfallHeight))) then ResizeSpectrumPanels; if FSpectrumWidth <= 0 then Exit; SyncSpecViewFreq; // Проверка таймаута — трансивер не отвечает if FController.FRunning and FController.FNetwork.Running then begin NowTick := GetTickCount64; ElapsedTick := NowTick - FController.FRXStartTime; // Ждём первый пакет 5 секунд после старта if (FController.FRXLastPktTime = 0) and (ElapsedTick > 5000) then begin BtnStartStop.Caption := 'START'; StyleButton(BtnStartStop, False); FController.FRunning := False; UninhibitSleep; FController.FNetwork.SetRunAndFreq(False, XvtrTranslate(FController.FCenterFreq), XvtrTranslate(FController.FCenterFreq), 0); SetRadioOfflineStatus('Hardware timeout'); BtnMOX.Enabled := False; Exit; end; // После первого пакета — следим чтобы поток не прерывался более 3 сек if (FController.FRXLastPktTime > 0) and ((NowTick - FController.FRXLastPktTime) > 3000) then begin BtnStartStop.Caption := 'START'; StyleButton(BtnStartStop, False); FController.FRunning := False; UninhibitSleep; FController.FNetwork.SetRunAndFreq(False, XvtrTranslate(FController.FCenterFreq), XvtrTranslate(FController.FCenterFreq), 0); SetRadioOfflineStatus('Connection lost'); BtnMOX.Enabled := False; Exit; end; end; if FController.FRunning then begin // Обновляем статус из таймера — без Synchronize в сетевом потоке. SetStatusText(4, 'RX running'); if FController.FSeqErrorCount > 0 then SetStatusText(6, Format('SEQ ERR DDC%d', [FController.FLastSeqErrorDDC])) else SetStatusText(6, 'SEQ OK'); if FController.FWDSPReady then begin FController.FLastSMeter := FController.FDSPEngine.GetSMeterDBm; FController.FDSPEngine.SetSpectrumWidth(FSpectrumWidth); Inc(FAgcLineCounter); if FAgcLineCounter >= 6 then begin FController.FDSPEngine.UpdateAGCLines(FSpectrumWidth); FSpecView.AGCThresh := FController.FDSPEngine.AGCThresh; FSpecView.AGCHangLevel := FController.FDSPEngine.AGCHangLevel; FAgcLineCounter := 0; end; end; FSpecView.WDSPReady := FController.FWDSPReady; // Обновляем оверлей если видим if Assigned(FVfoOverlay) and FVfoOverlay.Visible then begin FVfoOverlay.UpdateSMeter(FController.FLastSMeter); FVfoOverlay.UpdateVfo(FController.FVfoA); PositionVfoOverlay; end; // Панорама и водопад теперь рисуются независимо по факту новых кадров. // Это лучше совпадает с Thetis: маленький FFT даёт новые строки чаще. if FSpectrumDirty then begin FSpecView.DrawSpectrum; PbSpectrum.Invalidate; if FSpecView.NeedsRulerRedraw then PbRuler.Invalidate; FSpectrumDirty := False; end; if FWaterfallDirty then begin FSpecView.DrawWaterfall; PbWaterfall.Invalidate; FWaterfallDirty := False; end; if FWidebandDirty and FController.FShowWideband then begin PbWideband.Invalidate; FWidebandDirty := False; end; end else begin // Не подключены — показываем чистый экран (без анимации) if not FController.FNetwork.Connected then begin // При первом старте показываем демо-спектр if FSpecView.SpectrumBitmapWidth <> FSpectrumWidth then begin FSpecView.SetSpectrumBitmapSize(FSpectrumWidth, FSpectrumHeight); FSpecView.ResetSpectrumBuf; FSpecView.DrawSpectrum; PbSpectrum.Invalidate; end; end else begin // Подключены, но не запущены — статичный спектр (шум) FSpecView.DrawSpectrum; PbSpectrum.Invalidate; FSpectrumDirty := False; end; end; // Если не в режиме RUN, но есть "грязный" кадр (drag/marker/UI), перерисуем. if (FSpectrumDirty or FWaterfallDirty) and (not FController.FRunning) then begin if FSpectrumDirty then FSpecView.DrawSpectrum; if FWaterfallDirty then FSpecView.DrawWaterfall; if FSpectrumDirty then PbSpectrum.Invalidate; if FSpecView.NeedsRulerRedraw then PbRuler.Invalidate; if FWaterfallDirty then PbWaterfall.Invalidate; FWaterfallDirty := False; FSpectrumDirty := False; end; // Исходящее зеркало состояния + спектр/водопад в web — в адаптере (всё из // FController, включая последний кадр пикселей). Сервер сам гейтит по FRunning. if Assigned(FWebAdapter) then FWebAdapter.PushState; end; // =========================================================================== // Network callbacks → sync helpers // =========================================================================== // OnDeviceFound / discovery теперь в TRadioController (сетевой поток → store + // rfDeviceList). UI слушает rfDeviceList в OnControllerState. procedure TMainForm.UpdateTXMeters; // Вызывается из MeterTimerTick (10 Гц). FController.FLastFwdW/FLastSWR/ // FLastSupplyV/FLastSupplyA обновляются на rate HP Status (~150/с) // в TRadioController.ApplyHPStatus. begin UpdateStatusField7; // PLL (openHPSDR) или статус beacon lock (Pluto) UpdateStatusField3; // Supply (openHPSDR) или Temp/RSSI (Pluto) end; procedure TMainForm.UpdateStatusField3; // Поле 3 статус-бара. openHPSDR — supply V/A (HP status). Pluto — температура // чипа и RSSI приёмника (нет PA → supply неприменим). Значения берём из // контроллера (FLastPlutoTempC/RSSIdB / FLastSupplyV/A) — логика опроса в ядре. var S: string; begin // Pluto: нет PA/supply — показываем аппаратную телеметрию AD936x. if FController.IsPluto then begin if not FController.FRunning then begin SetStatusText(3, 'Pluto --'); Exit; end; S := ''; if FController.FLastPlutoTempC > TELEMETRY_NONE then S := Format('Temp %.0fC', [FController.FLastPlutoTempC]); if FController.FLastPlutoRSSIdB > TELEMETRY_NONE then begin if S <> '' then S := S + ' '; S := S + Format('RSSI %.0fdB', [FController.FLastPlutoRSSIdB]); end; if S = '' then S := 'Pluto --'; SetStatusText(3, S); Exit; end; if not FController.FRunning then begin SetStatusText(3, 'Supply --'); Exit; end; if not BoardSupportsSupplyVoltage(FController.FNetwork.Device.BoardType) then begin SetStatusText(3, 'Supply n/a'); Exit; end; if FController.FLastSupplyV >= 0 then begin if FController.FLastSupplyA >= 0 then SetStatusText(3, Format('Supply %.1fV %.1fA', [FController.FLastSupplyV, FController.FLastSupplyA])) else SetStatusText(3, Format('Supply %.1fV', [FController.FLastSupplyV])); end else SetStatusText(3, 'Supply --'); end; function TMainForm.BeaconStatusText: string; // Компактный статус QO-100 beacon lock для поля 7 статус-бара (Pluto). Courier 8pt, // поле ~200px → держим ≤ ~24 символов. begin if not FController.BeaconLockEnabled then Exit('BCN: off'); if FController.BeaconDecodeFreqHz <= 0 then Exit('BCN: click beacon'); case FController.BeaconState of bsLock: Result := Format('BCN LOCK %.0fHz /%.0fdB', [FController.BeaconCorrectionHz, FController.BeaconSNRdB]); bsSearch: Result := Format('BCN sync /%.0fdB', [FController.BeaconSNRdB]); else Result := 'BCN --'; end; end; procedure TMainForm.UpdateStatusField7; // Поле 7 статус-бара. openHPSDR — PLL-lock (как прежде). Pluto в трансвертере — // статус QO-100 beacon lock (у Pluto нет PLL-телеметрии); Pluto без XVTR — пусто. begin if FController.IsPluto then begin if FController.FCurrentXvtr >= 0 then SetStatusText(7, BeaconStatusText) else SetStatusText(7, ''); Exit; end; if not FController.FRunning then SetStatusText(7, 'PLL --') else if FController.FLastPLLLock then SetStatusText(7, 'PLL OK') else SetStatusText(7, 'PLL?'); end; procedure TMainForm.OnWidebandCB(ADCIndex: Integer; const Samples: array of SmallInt; Count: Integer); begin if (not FController.FShowWideband) or (FWidebandView = nil) then Exit; FWidebandView.SetSamples(Samples, Count); FWidebandDirty := True; end; procedure TMainForm.DoUpdateDDCSeq(DDCIdx: Integer; Seq: LongWord); begin SetStatusText(4, 'RX running'); end; // --------------------------------------------------------------------------- // TX settings — применение к WDSP, сборка DUC Specific, sound-card pull // --------------------------------------------------------------------------- // BuildMicLineSelectByte / SendDUCSpecificFromSettings переехали в // TRadioController (чистые над FTXSettings/движком; нужны headless StartRunning). function TMainForm.DefaultMicSource: TTXMicSource; // Mic source для MOX-кнопки и Web-TX: если настроена звуковая карта — берём с неё, // иначе используем HW mic-стрим от трансивера. // HW PTT всегда переключает на txmsRadio непосредственно в ApplyMOX. begin if FController.FAudioInDevName <> '' then Result := txmsSoundCard else Result := txmsRadio; end; // ApplyTXSettingsToDSP переехал в TRadioController (вся TX-цепь над FTXSettings). // =========================================================================== // Button handlers // =========================================================================== { Поток открытия — отдельный класс, без анонимных процедур } // Открываем WDSP в фоновом потоке чтобы не блокировать UI при нажатии START type TWDSPOpenThread = class(TThread) private FForm: TMainForm; FResult: Boolean; procedure SyncDone; protected procedure Execute; override; public constructor Create(AForm: TMainForm); end; constructor TWDSPOpenThread.Create(AForm: TMainForm); begin inherited Create(False); FForm := AForm; FResult := False; FreeOnTerminate := True; end; procedure TWDSPOpenThread.Execute; var M: TThreadMethod; begin try FResult := FForm.FController.FDSPEngine.Open; except FResult := False; end; M := SyncDone; TThread.Synchronize(nil, M); end; procedure TWDSPOpenThread.SyncDone; begin FForm.OnWDSPOpenDone(FResult); end; // TDiscoverThread / no-device-сигнал переехали в TRadioController.Discover. procedure TMainForm.RecreateDSPEngine(ASampleRate: Integer); begin if ASampleRate <= 0 then Exit; if Assigned(FController.FDSPEngine) then begin FController.FDSPEngine.Close; FreeAndNil(FController.FDSPEngine); end; FController.FWDSPReady := False; FController.FDSPEngine := TWDSPEngine.Create(ASampleRate, 48000, 512); FController.FDSPEngine.OnAudio := FController.OnAudioReady; FController.FDSPEngine.OnSpectrum := FController.OnSpectrumReady; FController.FDSPEngine.OnWaterfall := FController.OnWaterfallReady; FController.FDSPEngine.OnTXIQ := FController.OnTXIQ; // Иначе sound-card mic путь теряется после смены sample rate (колбэк // навешивается в FormCreate, но новый движок про него не знал). FController.FDSPEngine.OnPullMicSamples := FController.PullSoundCardMic; end; function TMainForm.EnsureWDSPWisdom: Boolean; var WisdomDir: string; WisdomPath: string; DirA: AnsiString; BuildThread: TWisdomBuildThread; Dlg: TWisdomProgressDialog; begin Result := True; if not Assigned(@WDSPwisdom) then Exit; WisdomDir := GetAppCfgDir; WisdomPath := WisdomDir + WDSP_WISDOM_FILE; if FileExists(WisdomPath) then begin // По рекомендации WDSP вызываем функцию на каждом старте: // если файл уже есть, библиотека просто импортирует wisdom. DirA := AnsiString(WisdomDir); WDSPwisdom(PAnsiChar(DirA)); Exit; end; BtnStartStop.Enabled := False; SetStatusText(2, 'Creating WDSP wisdom...'); BuildThread := TWisdomBuildThread.Create(WisdomDir); try if FLightTheme then Dlg := TWisdomProgressDialog.Create(Self, BuildThread, LightTheme) else Dlg := TWisdomProgressDialog.Create(Self, BuildThread, DarkTheme); try Dlg.ShowModal; finally Dlg.Free; end; BuildThread.WaitFor; if BuildThread.ErrorText <> '' then begin ShowMessage('Failed to create WDSP wisdom.' + LineEnding + BuildThread.ErrorText); Result := False; end else if not FileExists(WisdomPath) then begin ShowMessage('WDSP wisdom was not created.' + LineEnding + 'Expected file: ' + WisdomPath); Result := False; end; finally BuildThread.Free; BtnStartStop.Enabled := True; end; if Result then SetStatusText(2, 'DSP ready') else SetStatusText(2, 'WDSP wisdom missing'); end; procedure TMainForm.BtnDiscoverClick(Sender: TObject); begin // Открываем диалог выбора устройства if not Assigned(FDeviceDialog) then FDeviceDialog := TDeviceDialog.Create(Self); FDeviceDialog.Store := FController.FDeviceStore; FDeviceDialog.OnDiscover := BtnDiscoverFromDialog; if FLightTheme then FDeviceDialog.SetTheme(LightTheme) else FDeviceDialog.SetTheme(DarkTheme); FDeviceDialog.ClearResult; // сбрасываем старый выбор перед открытием FDeviceDialog.ShowModal; // Если пользователь нажал CONNECT — результат хранится в DialogResult, // START его заберёт и использует с приоритетом над autostart end; procedure TMainForm.BtnDiscoverFromDialog(Sender: TObject); // Запускается когда пользователь нажимает DISCOVER внутри диалога. // Контроллер чистит список и стартует поток; UI слушает rfDeviceList. // Сетевые Pluto (без mDNS) не находятся scan'ом — собираем список URI для // прямого probe: сохранённые Pluto + ручной ввод IP в поле диалога. var URIs: array of string; i: Integer; ip: string; procedure AddURI(const U: string); var k: Integer; begin if U = '' then Exit; for k := 0 to High(URIs) do if SameText(URIs[k], U) then Exit; SetLength(URIs, Length(URIs) + 1); URIs[High(URIs)] := U; end; begin SetLength(URIs, 0); for i := 0 to FController.FDeviceStore.SavedCount - 1 do if FController.FDeviceStore.Saved(i).Kind = bkPluto then AddURI(FController.FDeviceStore.Saved(i).URI); if Assigned(FDeviceDialog) then begin ip := FDeviceDialog.ManualIP; if ip <> '' then begin if (Pos('ip:', ip) = 1) or (Pos('usb:', ip) = 1) or (Pos('local:', ip) = 1) then AddURI(ip) else AddURI('ip:' + ip); end; end; FController.SetPlutoProbeURIs(URIs); SetStatusText(2, 'Discovering...'); FController.Discover; end; procedure TMainForm.BtnStartStopClick(Sender: TObject); var Dev: THPSDRDevice; AutoIP: string; begin if FController.FNetwork.Connected then begin // --- STOP --- DoStopAndDisconnect; Exit; end; // --- START --- AutoIP := ''; FPendingBoardType := 0; // Явный выбор через CONNECT в диалоге имеет приоритет над autostart if Assigned(FDeviceDialog) and FDeviceDialog.DialogResult.Accepted then begin FPendingIP := FDeviceDialog.DialogResult.IPAddress; FPendingBoardType := FDeviceDialog.GetSavedBoardType( FDeviceDialog.DialogResult.SavedIdx); FDeviceDialog.ClearResult; // consumed — следующий START снова спросит end else begin if Assigned(FDeviceDialog) then AutoIP := FDeviceDialog.GetAutoStartIP; if AutoIP <> '' then begin FPendingIP := AutoIP; FPendingBoardType := FDeviceDialog.GetAutoStartBoardType; end else begin if not Assigned(FDeviceDialog) then FDeviceDialog := TDeviceDialog.Create(Self); FDeviceDialog.Store := FController.FDeviceStore; FDeviceDialog.OnDiscover := BtnDiscoverFromDialog; if FLightTheme then FDeviceDialog.SetTheme(LightTheme) else FDeviceDialog.SetTheme(DarkTheme); if FDeviceDialog.ShowModal <> mrOk then Exit; if not FDeviceDialog.DialogResult.Accepted then Exit; FPendingIP := FDeviceDialog.DialogResult.IPAddress; FPendingBoardType := FDeviceDialog.GetSavedBoardType( FDeviceDialog.DialogResult.SavedIdx); FDeviceDialog.ClearResult; end; end; // Создаём запись устройства. Если это известный (discovered/saved) Pluto — // восстанавливаем Kind/URI/Serial/MAC из store по IP; иначе HPSDR по IP. Dev := ResolveDevice(FPendingIP); if Dev.Kind = bkHPSDR then Dev.BoardType := FPendingBoardType; StartWithDevice(Dev); end; function TMainForm.ResolveDevice(const IP: string): THPSDRDevice; // По IP/URI ищет устройство среди discovered, затем saved, чтобы восстановить // Kind/URI/Serial/MAC (нужно для Pluto). Логика общая с демоном — в контроллере. begin Result := FController.ResolveDevice(IP); end; procedure TMainForm.DoStopAndDisconnect; // STOP-teardown, общий для десктоп-STOP и web-disconnect. Движок (graceful Run=0, // сброс TX/телеметрии) + персист настроек + Disconnect — в контроллере // (StopAndDisconnect); рендер run-кнопки/MOX/sleep — через rfRunning. Здесь — // только очистка спектра и контекстный offline-статус. begin FController.StopAndDisconnect; FSpecView.ResetSpectrumBuf; FSpecView.DrawSpectrum; PbSpectrum.Invalidate; SetRadioOfflineStatus('Disconnected', True); end; procedure TMainForm.StartWithDevice(const Dev: THPSDRDevice); // Запуск с уже выбранным устройством — общий путь для десктоп-диалога и // web-оверлея (без модального диалога). Подбор sample rate до WDSP, открытие // WDSP (async) и подключение. var PreloadRate: Integer; FoundByIP: Boolean; i: Integer; PreG: TGlobalSettings; PreBands: array[0..CFG_BAND_COUNT-1] of TBandSettings; begin // Подбираем sample rate до открытия WDSP, чтобы не делать двойной Open: // 1) Open на 192k, 2) сразу ChangeSampleRate+Open ещё раз. // Если устройство уже было обнаружено (есть MAC) и есть сохранённый профиль, // создаём WDSPEngine сразу с нужной частотой. PreloadRate := FController.FSampleRate; FoundByIP := False; i := FController.FDeviceStore.FindDiscoveredByIP(Dev.IPAddress); if i >= 0 then begin FoundByIP := True; if FController.FSettings.LoadDevice(FController.FDeviceStore.Discovered(i).MAC, PreG, PreBands) and (PreG.SampleRate > 0) then PreloadRate := PreG.SampleRate; end; if FoundByIP and (PreloadRate > 0) then begin FController.FSampleRate := PreloadRate; FController.FSpanHz := PreloadRate; if (not FController.FWDSPReady) and (Assigned(FController.FDSPEngine)) and (FController.FDSPEngine.SampleRate <> PreloadRate) then RecreateDSPEngine(PreloadRate); end; // --- Открываем WDSP асинхронно чтобы не блокировать UI --- FPendingDev := Dev; BtnStartStop.Enabled := False; SetStatusText(2, 'Opening DSP...'); if FController.FWDSPReady then // WDSP уже открыт — сразу подключаемся DoConnectDevice(Dev) else // Открываем в фоновом потоке TWDSPOpenThread.Create(Self); end; procedure TMainForm.OnWDSPOpenDone(Success: Boolean); begin // Вызывается из TWDSPOpenThread.SyncDone — уже в главном потоке FController.FWDSPReady := Success; if not Success then begin SetStatusText(2, 'DSP failed'); BtnStartStop.Enabled := True; BtnStartStop.Caption := 'START'; StyleButton(BtnStartStop, False); ShowMessage('WDSP not loaded.' + LineEnding + IfThen(FController.FDSPEngine.LastError <> '', FController.FDSPEngine.LastError, 'Unknown WDSP init error.') + LineEnding + 'Put wdsp.dll рядом с ewsdr.exe (той же разрядности, что и приложение).'); Exit; end; DoConnectDevice(FPendingDev); end; procedure TMainForm.RelabelBands; // Переписывает подписи band-кнопок под план активного бэкенда (HF ↔ VHF/UHF). var i: Integer; pl: Boolean; begin pl := FController.IsPluto; for i := 0 to BAND_COUNT - 1 do if BtnBand[i] <> nil then BtnBand[i].Caption := BandName(i, pl); end; procedure TMainForm.ApplyBackendCapsToUI; // Пресеты sample-rate оверлея из Caps (Pluto >560k) + (TODO) гейтинг полей, // которых нет у бэкенда (PA/SWR/Alex/Wideband). var Caps: TBackendCaps; begin Caps := FController.BackendCaps; if Assigned(FSampleRateOverlay) then begin if (Caps.SampleRateMode = srmContinuous) and (Length(Caps.RatePresets) > 0) then FSampleRateOverlay.SetRatePresets(Caps.RatePresets) else FSampleRateOverlay.SetRatePresetsDefault; // HPSDR 48k..1536k FSampleRateOverlay.SetCurrentRate(FController.FSampleRate); end; // Отдельный Pluto RF-AGC блок под AGC: показываем и сдвигаем панели ниже. LayoutLeftPanel(Caps.HasHWGain); if Caps.HasHWGain then UpdateRxGainUI; end; procedure TMainForm.DoConnectDevice(const Dev: THPSDRDevice); begin BtnStartStop.Enabled := True; // Connect + load-by-MAC + перестройка диапазона/XVTR/активного VFO + go-live — // вся оркестрация в контроллере (ConnectAndRun); рендер device/band/run- // виджетов идёт через события (rfDevice/rfBandRestore/rfXvtr/rfActiveVfo/ // rfRunning). Здесь — только UI-extras (статус-строки/AGCTop/resize). if not FController.ConnectAndRun(Dev, FSpectrumWidth) then begin ShowMessage('Failed to connect to ' + Dev.IPAddress + IfThen(FController.FNetwork.LastError <> '', ': ' + FController.FNetwork.LastError, '')); Exit; end; // Активный VFO=A: подсветку (рисовалась в FormCreate при FActiveVfo=0) освежаем // здесь — для B это сделал SetActiveVfo(1) внутри ConnectAndRun. if FController.FActiveVfo <> 1 then UpdateVfoDisplay; SetStatusText(1, 'IP: ' + FController.FNetwork.Device.IPAddress); SetStatusText(0, 'Board: ' + FController.BoardDisplayName); FSpecView.AGCTop := FController.FAGCTop; SetStatusText(6, 'SEQ OK'); SetStatusText(2, 'Running'); ResizeSpectrumPanels; end; // --------------------------------------------------------------------------- // ActivateVfo — переключаем активный VFO и перестраиваем приёмник. // Idx=0 → VFO-A активен, Idx=1 → VFO-B активен. // Перестройка всегда происходит на частоту активного VFO (не на оффсет). // CTUN при этом сбрасывается чтобы не было путаницы с оффсетом. // --------------------------------------------------------------------------- // ActiveVfoFreq — частота активного VFO // --------------------------------------------------------------------------- function TMainForm.ActiveVfoFreq: Int64; begin if FController.FActiveVfo = 0 then Result := Round(FController.FVfoA) else Result := Round(FController.FVfoB); end; // --------------------------------------------------------------------------- // ActivateVfo — переключаем активный VFO и перестраиваем приёмник. // Idx=0 → VFO-A активен, Idx=1 → VFO-B активен. // Перестройка на частоту выбранного VFO. CTUN не трогаем. // --------------------------------------------------------------------------- procedure TMainForm.ActivateVfo(Idx: Integer); begin // Движок (переключение RX-VFO, CTUN-логика, центр/сеть, диапазон) — в // контроллере; рендер — OnControllerState(rfActiveVfo/rfBand/rfCTun). FController.SetActiveVfo(Idx); end; procedure TMainForm.FreqDispAClick(Sender: TObject); begin // Клик на VFO-A → активируем A (перестройка на FController.FVfoA) if FController.FActiveVfo <> 0 then ActivateVfo(0); end; procedure TMainForm.FreqDispBClick(Sender: TObject); begin // Клик на VFO-B → активируем B (перестройка на FController.FVfoB) if FController.FActiveVfo <> 1 then ActivateVfo(1); end; procedure TMainForm.BtnTopVfoASelectClick(Sender: TObject); begin if FController.FActiveVfo <> 0 then ActivateVfo(0); end; procedure TMainForm.BtnTopVfoATXClick(Sender: TObject); begin FController.FSplitTxB := False; UpdateVfoDisplay; if FController.FNetwork.Connected and FController.FNetwork.Running then begin FController.FNetwork.UpdateState(XvtrTranslate(FController.FCenterFreq), XvtrTranslateTX(ActiveTXFreqHz), FController.FDriveLevel, FController.FTransmitting, True, True); FController.FNetwork.SendFullHP; end; end; procedure TMainForm.BtnTopVfoBSelectClick(Sender: TObject); begin if FController.FActiveVfo <> 1 then ActivateVfo(1); end; procedure TMainForm.BtnTopVfoBTXClick(Sender: TObject); begin FController.FSplitTxB := True; if FController.FSplitTxB and (FController.FActiveVfo <> 0) then ActivateVfo(0) else UpdateVfoDisplay; if FController.FNetwork.Connected and FController.FNetwork.Running then begin FController.FNetwork.UpdateState(XvtrTranslate(FController.FCenterFreq), XvtrTranslateTX(ActiveTXFreqHz), FController.FDriveLevel, FController.FTransmitting, True, True); FController.FNetwork.SendFullHP; end; end; procedure TMainForm.FreqDispAChanged(Sender: TObject; NewFreq: Int64); begin if FController.FActiveVfo = 0 then ApplyVfoA(NewFreq) else FController.FVfoA := NewFreq; // просто сохраняем, приёмник не перестраиваем end; procedure TMainForm.FreqDispBChanged(Sender: TObject; NewFreq: Int64); begin // Движок (сохранение B, перестройка при активном B, диапазон, сеть) — в // контроллере; рендер — OnControllerState(rfVfoB). FController.SetVfoB(NewFreq); end; // --------------------------------------------------------------------------- // UpdateFilterButtons — обновляет подписи кнопок фильтра под текущий режим // --------------------------------------------------------------------------- procedure TMainForm.UpdateFilterButtons; var i: Integer; Names: array[0..FILT_COUNT-1] of string; DefIdx: Integer; begin if FController.FMode = MODE_FM then begin // FM: show only NFM and WFM buttons, resize them to fill the row CloseCTCSSPopup; if FController.FFilter > 1 then FController.FFilter := FILT_FM_DEF; // sanitize, keep NFM/WFM as-is FController.FFilterBW := FILT_FM_BW[FController.FFilter]; FController.FFMDeviation := FILT_FM_DEV[FController.FFilter]; BtnFilter[0].Caption := FILT_FM_NAMES[0]; BtnFilter[0].Left := LeftPanelButtonLeft(PanelFilter.Width, 2, 0); BtnFilter[0].Width := LeftPanelButtonWidth(PanelFilter.Width, 2, 0); BtnFilter[0].Top := 16; BtnFilter[0].Visible := True; StyleButton(BtnFilter[0], FController.FFilter = 0); BtnFilter[1].Caption := FILT_FM_NAMES[1]; BtnFilter[1].Left := LeftPanelButtonLeft(PanelFilter.Width, 2, 1); BtnFilter[1].Width := LeftPanelButtonWidth(PanelFilter.Width, 2, 1); BtnFilter[1].Top := 16; BtnFilter[1].Visible := True; StyleButton(BtnFilter[1], FController.FFilter = 1); for i := 2 to FILT_COUNT - 1 do BtnFilter[i].Visible := False; // Shrink panel to fit label + 1 button row so SQL/CTCSS sit right below PanelFilter.Height := BtnFilter[0].Top + BtnFilter[0].Height + 4; if PanelFMSQ <> nil then begin PanelFMSQ.Visible := True; StyleButton(BtnFMSQ, FController.FFMSQOn); TrkFMSQ.Position := FController.FFMSQLevel; LblFMSQ.Caption := IntToStr(FController.FFMSQLevel); end; if PanelFMCTCSS <> nil then PanelFMCTCSS.Visible := True; if PanelFMStep <> nil then begin StyleButton(BtnFMStep, FController.FFMStepOn); if FStepDropDown <> nil then FStepDropDown.SetItemIndex(FController.FFMStepIdx); PanelFMStep.Visible := True; end; if PanelFMRpt <> nil then begin StyleButton(BtnFMRptMinus, FController.FFMRptDir = RPT_MINUS); StyleButton(BtnFMRptPlus, FController.FFMRptDir = RPT_PLUS); EdFMRptOffset.Text := RptFormatOffset(FController.FFMRptOffsetHz); PanelFMRpt.Visible := True; end; RelayoutBelowBands; Exit; end; // Non-FM: restore full 2-row height and 5-column layout, hide FM panels PanelFilter.Height := 74; if PanelFMSQ <> nil then PanelFMSQ.Visible := False; if PanelFMCTCSS <> nil then PanelFMCTCSS.Visible := False; if PanelFMStep <> nil then begin CloseFMStepPopup; PanelFMStep.Visible := False; end; if PanelFMRpt <> nil then PanelFMRpt.Visible := False; for i := 0 to FILT_COUNT - 1 do begin BtnFilter[i].Left := LeftPanelButtonLeft(PanelFilter.Width, 5, i mod 5); BtnFilter[i].Top := 16 + (i div 5) * 27; BtnFilter[i].Width := LeftPanelButtonWidth(PanelFilter.Width, 5, i mod 5); BtnFilter[i].Visible := True; end; case FController.FMode of 0, 1: begin for i := 0 to FILT_COUNT-1 do Names[i] := FILT_SSB_NAMES[i]; DefIdx := FILT_SSB_DEF; end; 2: begin for i := 0 to FILT_COUNT-1 do Names[i] := FILT_DSB_NAMES[i]; DefIdx := FILT_DSB_DEF; end; 3, 4: begin for i := 0 to FILT_COUNT-1 do Names[i] := FILT_CW_NAMES[i]; DefIdx := FILT_CW_DEF; end; else begin for i := 0 to FILT_COUNT-1 do Names[i] := FILT_AM_NAMES[i]; DefIdx := FILT_AM_DEF; end; end; FController.FFilter := DefIdx; case FController.FMode of 0, 1: FController.FFilterBW := FILT_SSB_BW[DefIdx]; 2: FController.FFilterBW := FILT_DSB_BW[DefIdx]; 3, 4: FController.FFilterBW := FILT_CW_BW[DefIdx]; else FController.FFilterBW := FILT_AM_BW[DefIdx]; end; for i := 0 to FILT_COUNT-1 do begin BtnFilter[i].Caption := Names[i]; StyleButton(BtnFilter[i], i = FController.FFilter); end; RelayoutBelowBands; end; // --------------------------------------------------------------------------- // ApplyVfoA — единая точка смены частоты VFO A // // CTUN ВЫКЛ (классический режим): // FController.FCenterFreq = FController.FVfoA — спектр центрирован на VFO, маркер всегда в центре // // CTUN ВКЛ (piHPSDR/Thetis режим): // FController.FCenterFreq статичен — спектр/водопад не двигаются // FController.FVfoA движется по дисплею (маркер гуляет) // Перецентрирование — только когда КРАЙ ПОЛОСЫ ФИЛЬТРА выходит за край дисплея // (по образцу OpenHPSDR/PowerSDR: "re-centering occurs as the edge of the passband // hits the edge of the display") // --------------------------------------------------------------------------- procedure TMainForm.ApplyVfoA(NewFreq: Int64); begin // Тонкий делегат: движок (XVTR-клэмп, CTUN, band-detect, FM авто-RPT, сеть) — // в SetVfoA; канальная оркестрация — в FController.OnAfterTune (хук в конце // SetVfoA → FChannelController.OnVfoTuned); рендер — OnControllerState. FController.SetVfoA(NewFreq); end; // --------------------------------------------------------------------------- // CTUN toggle // --------------------------------------------------------------------------- procedure TMainForm.BtnCTunClick(Sender: TObject); begin // Логика (band cache, center/shift, сеть) — в контроллере; рендер — в // OnControllerState(rfCTun). FController.SetCTun(not FController.FCTun); end; procedure TMainForm.BtnBeaconClick(Sender: TObject); // ЛКМ по BEACON: вкл/выкл лок маяка (= декодер + трим LOError). При включении // «вооружаем» клик (следующий ЛКМ по спектру наведёт декодер на маяк) и открываем // окно констелляции/бюллетеня. Декодер ведёт несущую, контур стекает дрейф в LOError. begin FController.SetBeaconLock(not FController.BeaconLockEnabled); FBeaconArming := FController.BeaconLockEnabled; if FController.BeaconLockEnabled then begin if FBeaconScopeForm = nil then FBeaconScopeForm := TBeaconScopeForm.CreateWith(Self, FController); FBeaconScopeForm.ApplyTheme(DarkTheme); FBeaconScopeForm.Show; end; UpdateBeaconButton; end; procedure TMainForm.BtnBeaconMouseDown(Sender: TObject; Button: TMouseButton; Shift: TShiftState; X, Y: Integer); // ПКМ по BEACON: показать/скрыть окно констелляции+бюллетеня (диагностика декодера). // Лок не трогает — он на ЛКМ. begin if Button <> mbRight then Exit; if not FController.BeaconLockEnabled then Exit; if FBeaconScopeForm = nil then FBeaconScopeForm := TBeaconScopeForm.CreateWith(Self, FController); FBeaconScopeForm.ApplyTheme(DarkTheme); if FBeaconScopeForm.Visible then FBeaconScopeForm.Hide else FBeaconScopeForm.Show; end; procedure TMainForm.UpdateBeaconButton; // Beacon-маркеры + кнопка BEACON. Кнопка видима только на Pluto в трансвертере // (см. rfXvtr) — это ПРОСТО кнопка: подпись 'BEACON', подсветка = лок включён. // Вся инфа (lock/коррекция/поиск/«click») — в статус-баре, поле 7 (вместо PLL), // см. UpdateStatusField7. На openHPSDR кнопка скрыта, beacon-маркеры неактивны. begin // Маркеры на спектре: опорная частота (зелёная) + отслеживаемый центроид (оранж). if FSpecView <> nil then begin FSpecView.SetBeaconMarkers(FController.BeaconLockEnabled, FController.BeaconRefHz, FController.BeaconTrackedFreqHz); // Узкий фильтр-маркер декодера (±450 Гц ≈ полоса BPSK-маяка). FSpecView.SetBeaconDecMarker( FController.BeaconLockEnabled and (FController.BeaconDecodeFreqHz > 0), FController.BeaconDecodeFreqHz, 450.0); end; UpdateStatusField7; // моментальный апдейт статус-бара (не ждём MeterTimer) if BtnBeacon = nil then Exit; BtnBeacon.Caption := 'BEACON'; StyleButton(BtnBeacon, FController.BeaconLockEnabled); BtnBeacon.Repaint; end; // --------------------------------------------------------------------------- // DUP toggle (Thetis-style local display mode — без аппаратной команды) // --------------------------------------------------------------------------- procedure TMainForm.ApplyDUP(Active: Boolean); begin // Единый путь: контроллер ставит FDisplayDuplex + KeepRXDuringTX (живьём) и шлёт // rfDuplex → там рендер кнопки/дисплея. Так тумблер и QO-100-авто согласованы // (раньше кнопка писала FDisplayDuplex напрямую, минуя KeepRXDuringTX и rfDuplex). FController.SetDuplex(Active); end; procedure TMainForm.BtnDUPClick(Sender: TObject); begin ApplyDUP(not FController.FDisplayDuplex); end; // --------------------------------------------------------------------------- // TUN — Thetis-style тон: WDSP TXA PostGen + MOX с отдельным уровнем Drive // --------------------------------------------------------------------------- procedure TMainForm.ApplyTUN(Active: Boolean); begin // FWebClientActive держит web-слой актуальным (OnClientActiveChanged). FController.SetTune(Active); // ядро TUN (safety/тон/drive/MOX) + рендер через rfTuning end; procedure TMainForm.BtnTUNClick(Sender: TObject); begin ApplyTUN(not FController.FTuning); end; // --------------------------------------------------------------------------- // Клик/драг по спектру/водопаду // --------------------------------------------------------------------------- // Перевод X пикселя в частоту function PixelToFreq(PixelX, PanelWidth: Integer; CenterFreq, SpanHz: Double): Double; begin Result := CenterFreq + (PixelX / PanelWidth - 0.5) * SpanHz; end; procedure TMainForm.DoSpectrumClick(PixelX: Integer; PanelWidth: Integer); var ClickFreq: Int64; StepHz: Int64; begin if PanelWidth <= 0 then Exit; if (FController.FMode = MODE_FM) and FController.FFMStepOn then StepHz := FM_STEP_HZ[FController.FFMStepIdx] else StepHz := 100; ClickFreq := Round(FController.FCenterFreq + (PixelX / PanelWidth - 0.5) * FController.FSpanHz); ClickFreq := ((ClickFreq + StepHz div 2) div StepHz) * StepHz; if FController.FActiveVfo = 0 then ApplyVfoA(ClickFreq) else FController.SetVfoB(ClickFreq); // движок + рендер через OnControllerState(rfVfoB) end; procedure TMainForm.DoWidebandClick(PixelX: Integer; PanelWidth: Integer); var FreqHz: Double; ClickFreq: Int64; StepHz: Int64; BandIdx: Integer; WasCTun: Boolean; begin if (FWidebandView = nil) or (not FWidebandView.TryPixelToFrequency(PixelX, PanelWidth, FreqHz)) then Exit; if (FController.FMode = MODE_FM) and FController.FFMStepOn then StepHz := FM_STEP_HZ[FController.FFMStepIdx] else StepHz := 100; ClickFreq := Round(FreqHz); ClickFreq := ((ClickFreq + StepHz div 2) div StepHz) * StepHz; if (FController.FCurrentXvtr >= 0) and (FController.FCurrentXvtr < CFG_XVTR_COUNT) and FController.FXvtrSettings.Entries[FController.FCurrentXvtr].Enabled then begin if ClickFreq < FController.FXvtrSettings.Entries[FController.FCurrentXvtr].FreqBegin then ClickFreq := Round(FController.FXvtrSettings.Entries[FController.FCurrentXvtr].FreqBegin); if ClickFreq > FController.FXvtrSettings.Entries[FController.FCurrentXvtr].FreqEnd then ClickFreq := Round(FController.FXvtrSettings.Entries[FController.FCurrentXvtr].FreqEnd); end; if FController.FCurrentXvtr < 0 then begin BandIdx := FreqToBandIdx(ClickFreq, FController.IsPluto); if (BandIdx >= 0) and (BandIdx <> FController.FCurrentBand) then begin if FController.FDevConnected then begin SaveCurrentBand; FController.FSettings.Save; end; FController.FCurrentBand := BandIdx; RestoreBand(FController.FCurrentBand); end; end; if FController.FActiveVfo = 0 then begin WasCTun := FController.FCTun; FController.FCTun := False; ApplyVfoA(ClickFreq); FController.FCTun := WasCTun; if WasCTun and FController.FWDSPReady then FController.FDSPEngine.SetShift(0.0); end else begin FController.FVfoB := ClickFreq; FreqDispB.Frequency := Round(FController.FVfoB); FController.FCenterFreq := ClickFreq; if FController.FWDSPReady then FController.FDSPEngine.SetShift(0.0); if FController.FNetwork.Connected and FController.FNetwork.Running then begin FController.FNetwork.UpdateState(XvtrTranslate(FController.FCenterFreq), XvtrTranslateTX(ActiveTXFreqHz), FController.FDriveLevel, FController.FTransmitting, True, True); FController.FNetwork.SendFullHP; end; if FController.FCurrentXvtr < 0 then begin BandIdx := FreqToBandIdx(FController.FVfoB, FController.IsPluto); if (BandIdx >= 0) and (BandIdx <> FController.FCurrentBand) then begin if FController.FCurrentBand >= 0 then StyleButton(BtnBand[FController.FCurrentBand], False); FController.FCurrentBand := BandIdx; StyleButton(BtnBand[FController.FCurrentBand], True); end; end; UpdateVfoDisplay; SyncSpecViewFreq; FSpectrumDirty := True; PbSpectrum.Invalidate; end; FSpecView.InvalidateRulerCache; if PbRuler <> nil then PbRuler.Invalidate; FWaterfallDirty := True; if PbWaterfall <> nil then PbWaterfall.Invalidate; if UpdateWidebandFrequencyView and (PbWideband <> nil) then PbWideband.Invalidate; end; procedure TMainForm.DoSpectrumDrag(PixelX: Integer; PanelWidth: Integer); var dPix: Integer; dFreq: Double; begin if PanelWidth <= 0 then Exit; dPix := PixelX - FSpecDragX0; dFreq := dPix / PanelWidth * FController.FSpanHz; if FController.FCTun then // CTUN ON: drag двигает окно просмотра (DDC-центр) без смены VFO; shift, // сеть и рендер — в контроллере SetCenter / OnControllerState(rfCenterFreq). FController.SetCenter(FSpecDragFreq - dFreq) else begin // CTUN OFF: drag двигает VFO, FController.FCenterFreq следует через Apply if FController.FActiveVfo = 0 then ApplyVfoA(Round(FSpecDragFreq - dFreq)) else FController.SetVfoB(FSpecDragFreq - dFreq); // движок + рендер (rfVfoB) end; end; // Спектр procedure TMainForm.PbSpectrumMouseDown(Sender: TObject; Button: TMouseButton; Shift: TShiftState; X, Y: Integer); begin if Assigned(FVfoOverlay) and FVfoOverlay.HandleMouseDown(Button, X, Y) then Exit; if Assigned(FSampleRateOverlay) and FSampleRateOverlay.HandleMouseDown(Button, X, Y) then Exit; if Button = mbLeft then begin // Наведение маяка: ЛКМ указывает на маяк (после включения BEACON «вооружено», // либо Shift+ЛКМ в любой момент при включённом локе для перенаведения). Декодер // наводится на эту частоту, дальше сам ведёт несущую. Не трогает VFO. if FController.BeaconLockEnabled and (PbSpectrum.Width > 0) and (FBeaconArming or (ssShift in Shift)) then begin FController.BeaconSeedAtHz( PixelToFreq(X, PbSpectrum.Width, FController.FCenterFreq, FController.FSpanHz)); FBeaconArming := False; UpdateBeaconButton; Exit; end; // Левая кнопка: сбрасываем маркер если активен if FSpecView.MarkerActive then begin FSpecView.MarkerActive := False; PbSpectrum.Invalidate; PbWaterfall.Invalidate; end; FSpecDrag := True; FSpecDragX0 := X; if FController.FCTun then FSpecDragFreq := FController.FCenterFreq else begin if FController.FActiveVfo = 0 then FSpecDragFreq := FController.FVfoA else FSpecDragFreq := FController.FVfoB; end; PbSpectrum.Cursor := crSizeWE; end else if Button = mbRight then begin // Правая кнопка: toggle маркера if FSpecView.MarkerActive and (Abs(Round(X / PbSpectrum.Width * 1000) - FSpecView.MarkerX) < 8) then begin // Клик рядом с текущим маркером — убираем FSpecView.MarkerActive := False; end else begin // Ставим маркер в новое место FSpecView.MarkerActive := True; FSpecView.MarkerX := Round(X / PbSpectrum.Width * 1000); end; PbSpectrum.Invalidate; PbWaterfall.Invalidate; end; end; procedure TMainForm.PbWidebandMouseDown(Sender: TObject; Button: TMouseButton; Shift: TShiftState; X, Y: Integer); begin if Button = mbLeft then DoWidebandClick(X, TControl(Sender).Width); end; procedure TMainForm.PbSpectrumMouseMove(Sender: TObject; Shift: TShiftState; X, Y: Integer); begin if Assigned(FSampleRateOverlay) then begin FSampleRateOverlay.HandleMouseMove(X, Y); end; if Assigned(FVfoOverlay) then FVfoOverlay.HandleMouseMove(X, Y); if not FSpecDrag then begin if (Assigned(FVfoOverlay) and (FVfoOverlay.HotIdx >= 0)) or (Assigned(FSampleRateOverlay) and (FSampleRateOverlay.HotIdx >= 0)) then PbSpectrum.Cursor := crHandPoint else PbSpectrum.Cursor := crDefault; end; if FSpecDrag and (ssLeft in Shift) then DoSpectrumDrag(X, PbSpectrum.Width); // Маркер следует за курсором — обновление произойдёт в ближайшем тике таймера (50ms) if FSpecView.MarkerActive then begin FSpecView.MarkerX := Round(X / PbSpectrum.Width * 1000); FSpectrumDirty := True; end; end; procedure TMainForm.PbSpectrumMouseUp(Sender: TObject; Button: TMouseButton; Shift: TShiftState; X, Y: Integer); begin if Button = mbLeft then begin if (Abs(X - FSpecDragX0) < 4) then DoSpectrumClick(X, PbSpectrum.Width); // это был клик, не драг FSpecDrag := False; PbSpectrum.Cursor := crDefault; end; end; procedure TMainForm.PbSpectrumMouseLeave(Sender: TObject); begin if Assigned(FSampleRateOverlay) then FSampleRateOverlay.HandleMouseLeave; if Assigned(FVfoOverlay) then FVfoOverlay.HandleMouseLeave; if not FSpecDrag then PbSpectrum.Cursor := crDefault; end; // Водопад (аналогично) procedure TMainForm.PbWaterfallMouseDown(Sender: TObject; Button: TMouseButton; Shift: TShiftState; X, Y: Integer); begin if Button = mbLeft then begin if FSpecView.MarkerActive then begin FSpecView.MarkerActive := False; PbSpectrum.Invalidate; PbWaterfall.Invalidate; end; FSpecDrag := True; FSpecDragX0 := X; if FController.FCTun then FSpecDragFreq := FController.FCenterFreq else begin if FController.FActiveVfo = 0 then FSpecDragFreq := FController.FVfoA else FSpecDragFreq := FController.FVfoB; end; PbWaterfall.Cursor := crSizeWE; end else if Button = mbRight then begin if FSpecView.MarkerActive and (Abs(Round(X / PbWaterfall.Width * 1000) - FSpecView.MarkerX) < 8) then FSpecView.MarkerActive := False else begin FSpecView.MarkerActive := True; FSpecView.MarkerX := Round(X / PbWaterfall.Width * 1000); end; PbSpectrum.Invalidate; PbWaterfall.Invalidate; end; end; procedure TMainForm.PbWaterfallMouseMove(Sender: TObject; Shift: TShiftState; X, Y: Integer); begin if FSpecDrag and (ssLeft in Shift) then DoSpectrumDrag(X, PbWaterfall.Width); if FSpecView.MarkerActive then begin FSpecView.MarkerX := Round(X / PbWaterfall.Width * 1000); FSpectrumDirty := True; end; end; procedure TMainForm.PbWaterfallMouseUp(Sender: TObject; Button: TMouseButton; Shift: TShiftState; X, Y: Integer); begin if Button = mbLeft then begin if (Abs(X - FSpecDragX0) < 4) then DoSpectrumClick(X, PbWaterfall.Width); FSpecDrag := False; PbWaterfall.Cursor := crDefault; end; end; procedure TMainForm.BtnBandClick(Sender: TObject); var NewBand: Integer; begin NewBand := (Sender as TFlatButton).Tag; if (NewBand < 0) or (NewBand >= BAND_COUNT) then Exit; // Если в XVTR-режиме — DeactivateXvtr вернёт HF-состояние (RestoreBand // на сохранённом FController.FCurrentBand). Если NewBand совпадает с восстановленным // FController.FCurrentBand, дальше делать ничего не нужно. if FController.FCurrentXvtr >= 0 then begin DeactivateXvtr; if NewBand = FController.FCurrentBand then Exit; end else if NewBand = FController.FCurrentBand then Exit; if FController.FDevConnected then begin SaveCurrentBand; FController.FSettings.Save; end; FController.FCurrentBand := NewBand; RestoreBand(FController.FCurrentBand); end; procedure TMainForm.BtnModeClick(Sender: TObject); begin // Логика (FMode, дефолтный фильтр, DSP SetMode/TUN/ApplyModeFilter, band cache) // — в контроллере; рендер кнопок/фильтра/спектра — в OnControllerState(rfMode). FController.SetMode((Sender as TFlatButton).Tag); end; procedure TMainForm.BtnFilterClick(Sender: TObject); begin // Логика (FFilter/FFilterBW по таблицам режима, ApplyModeFilter, band cache) // — в контроллере; рендер — в OnControllerState(rfFilter). FController.SetFilterIdx((Sender as TFlatButton).Tag); end; // --------------------------------------------------------------------------- // FM CTCSS helpers // --------------------------------------------------------------------------- procedure TMainForm.ApplyFMSquelch; begin if FController.FWDSPReady then FController.FDSPEngine.SetFMSquelch(FController.FFMSQOn and (FController.FMode = MODE_FM), FController.FFMSQLevel); end; procedure TMainForm.BtnFMSQClick(Sender: TObject); begin if FSyncingFromController then Exit; FController.SetFMSquelch(not FController.FFMSQOn); // DSP + OnControllerState SaveCurrentBand; end; procedure TMainForm.TrkFMSQChange(Sender: TObject); begin if FSyncingFromController then Exit; FController.SetFMSquelchLevel(TrkFMSQ.Position); SaveCurrentBand; end; procedure TMainForm.CloseCTCSSPopup; begin if FCTCSSDropDown <> nil then FCTCSSDropDown.ClosePopup; CloseFMStepPopup; if FChannelsDropDown <> nil then FChannelsDropDown.ClosePopup; end; procedure TMainForm.BtnFMRptMinusClick(Sender: TObject); begin if FSyncingFromController then Exit; // Toggle решает UI; команда несёт сетевой сдвиг + рендер кнопок (OnControllerState). if FController.FFMRptDir = RPT_MINUS then FController.SetFMRpt(RPT_NONE) else FController.SetFMRpt(RPT_MINUS); SaveCurrentBand; end; procedure TMainForm.BtnFMRptPlusClick(Sender: TObject); begin if FSyncingFromController then Exit; if FController.FFMRptDir = RPT_PLUS then FController.SetFMRpt(RPT_NONE) else FController.SetFMRpt(RPT_PLUS); SaveCurrentBand; end; procedure TMainForm.EdFMRptOffsetChange(Sender: TObject); var Hz: Double; begin if FSyncingFromController then Exit; Hz := RptParseOffset(EdFMRptOffset.Text); if Hz > 0.0 then begin FController.SetFMRptOffset(Hz); // применяет, если RPT вкл; OnControllerState if FController.FFMRptDir <> RPT_NONE then SaveCurrentBand; end; end; procedure TMainForm.UpdateRptAutoState; begin // Логика авто-MINUS — в контроллере; рендер кнопок — OnControllerState(rfFMRpt). FController.UpdateRptAutoState; end; procedure TMainForm.CloseFMStepPopup; begin if FStepDropDown <> nil then FStepDropDown.ClosePopup; if FChannelsDropDown <> nil then FChannelsDropDown.ClosePopup; end; procedure TMainForm.SetFMStep(Idx: Integer); begin // Тонкая обёртка: команда контроллера обновит UI/web/спецвью через // OnControllerState(rfFMStepIdx). Сохранена для существующих вызовов // (RestoreBand, dropdown-select, web-sync, XVTR-restore). FController.SetFMStep(Idx); end; procedure TMainForm.BtnFMStepClick(Sender: TObject); begin FController.FFMStepOn := not FController.FFMStepOn; StyleButton(BtnFMStep, FController.FFMStepOn); BtnFMStep.Repaint; SaveCurrentBand; end; procedure TMainForm.BtnFMStepSelClick(Sender: TObject); begin FCTCSSDropDown.ClosePopup; FStepDropDown.Toggle; end; procedure TMainForm.OnStepDropDownSelect(Sender: TObject; Idx: Integer); begin SetFMStep(Idx); SaveCurrentBand; end; // --------------------------------------------------------------------------- // Channels dropdown / store // --------------------------------------------------------------------------- procedure TMainForm.RefreshChannelsDropDown; var names: array of string; i: Integer; begin if (FController.FChannelStore = nil) or (FChannelsDropDown = nil) then Exit; SetLength(names, FController.FChannelStore.Count); for i := 0 to FController.FChannelStore.Count - 1 do names[i] := FController.FChannelStore.GetChannel(i).Name; FChannelsDropDown.SetItems(names); if (FChannelController.ActiveIdx >= 0) and (FChannelController.ActiveIdx < FController.FChannelStore.Count) then FChannelsDropDown.SetItemIndex(FChannelController.ActiveIdx) else if BtnChannels <> nil then BtnChannels.Caption := 'Channel'; end; procedure TMainForm.OnChannelStoreChanged(Sender: TObject); begin RefreshChannelsDropDown; end; procedure TMainForm.BtnChannelsClick(Sender: TObject); begin if FController.FChannelStore.Count = 0 then Exit; FCTCSSDropDown.ClosePopup; FStepDropDown.ClosePopup; FChannelsDropDown.Toggle; end; procedure TMainForm.BtnChannelsMouseDown(Sender: TObject; Button: TMouseButton; Shift: TShiftState; X, Y: Integer); var Def: TChannel; begin if Button <> mbRight then Exit; FChannelsDropDown.ClosePopup; if FChannelsForm = nil then FChannelsForm := TChannelsForm.Create(Self, FController.FChannelStore, OnChannelStoreChanged); // Передаём текущее состояние радио как значения по умолчанию для нового канала TChannelStore.DefaultChannel(Def); Def.RXFreq := FController.FVfoA; Def.TXFreq := ActiveTXFreqHz; Def.Mode := FController.FMode; Def.RptDir := FController.FFMRptDir; Def.RptOffsetHz := FController.FFMRptOffsetHz; Def.CTCSSOn := FController.FFMCTCSSOn; Def.CTCSSToneIdx := FController.FFMCTCSSToneIdx; Def.Power := 100; Def.Name := ''; TChannelsForm(FChannelsForm).SetCurrentDefaults(Def); TChannelsForm(FChannelsForm).ApplyTheme(DarkTheme); TChannelsForm(FChannelsForm).Show; end; procedure TMainForm.OnChannelsDropDownSelect(Sender: TObject; Idx: Integer); begin // Логика применения канала (band/XVTR/mode/FM/drive/freq) — в адаптере каналов; // рендер кнопки Channel — в OnChannelActiveChanged. FChannelController.Select(Idx); end; procedure TMainForm.OnChannelActiveChanged(Idx: Integer; const Name: string; Active: Boolean); begin // Рендер кнопки Channel по событию адаптера каналов (Active=True → подсветка+имя). if BtnChannels = nil then Exit; StyleButton(BtnChannels, Active); if Active then BtnChannels.Caption := Name else BtnChannels.Caption := 'Channel'; BtnChannels.Repaint; end; procedure TMainForm.SetFMCTCSSTone(Idx: Integer); begin // Тонкая обёртка: команда применяет к DSP, OnControllerState(rfFMCTCSSTone) // обновит выпадающий список. Сохранена для вызовов из restore/channel/XVTR. FController.SetFMCTCSSTone(Idx); end; procedure TMainForm.BtnFMCTCSSClick(Sender: TObject); begin if FSyncingFromController then Exit; FController.FFMCTCSSAutoActive := False; // ручное действие снимает авто-флаг FController.SetFMCTCSS(not FController.FFMCTCSSOn); end; procedure TMainForm.BtnFMCTCSSToneClick(Sender: TObject); begin FStepDropDown.ClosePopup; FCTCSSDropDown.Toggle; end; procedure TMainForm.OnCTCSSDropDownSelect(Sender: TObject; Idx: Integer); begin SetFMCTCSSTone(Idx); end; procedure TMainForm.BtnVfoSwapClick(Sender: TObject); begin // Своп + перестройка — в контроллере; рендер дисплеев/спектра/band — через // OnControllerState(rfVfoA/rfVfoB/rfBand). Кэш линейки сбрасываем здесь // (большой скачок частоты — позиции тиков меняются). FController.VfoSwap; FSpecView.InvalidateRulerCache; if PbRuler <> nil then PbRuler.Invalidate; end; procedure TMainForm.BtnVfoACopyBClick(Sender: TObject); begin // A>B: B получает частоту A. Команда сохраняет B, перестраивает если B // активен, рендерит через OnControllerState(rfVfoB). FController.VfoACopyB; end; procedure TMainForm.BtnVfoBCopyAClick(Sender: TObject); begin // B>A: A получает частоту B. При активном A идём через ApplyVfoA (сохраняем // канальную оркестрацию); иначе только сохраняем + рендер дисплея. FController.FVfoA := FController.FVfoB; if FController.FActiveVfo = 0 then ApplyVfoA(Round(FController.FVfoA)) else FreqDispA.Frequency := Round(FController.FVfoA); end; function TMainForm.ActiveTXFreqHz: Double; begin // Логика перенесена в контроллер; обёртка для существующих вызовов в UI. Result := FController.ActiveTXFreqHz; end; procedure TMainForm.ApplyMOX(Active: Boolean); begin // FWebClientActive держит web-слой актуальным (OnClientActiveChanged) — // определяет mic-source при TX-on. FController.SetMOX(Active); // ядро TX (safety/PTT/DSP) + рендер через rfTransmitting end; procedure TMainForm.BtnMOXClick(Sender: TObject); begin // Если идёт tune — клик MOX просто выходит из tune (без перехода на голос). if FController.FTuning then begin ApplyTUN(False); Exit; end; ApplyMOX(not FController.FTransmitting); end; procedure TMainForm.BtnMuteClick(Sender: TObject); begin if FSyncingFromController then Exit; FController.ToggleMute; // логика (движок) + OnControllerState обновит кнопку end; procedure TMainForm.UpdateNRButton; begin case FController.FNRMode of 1: BtnNR.Caption := 'NR'; 2: BtnNR.Caption := 'NR2'; 3: BtnNR.Caption := 'NR3'; 4: BtnNR.Caption := 'NR4'; else BtnNR.Caption := 'NR'; end; StyleButton(BtnNR, FController.FNRMode <> 0); BtnNR.Invalidate; end; procedure TMainForm.UpdateNBButton; begin case FController.FNBMode of 1: BtnNB.Caption := 'NB'; 2: BtnNB.Caption := 'NB2'; else BtnNB.Caption := 'NB'; end; StyleButton(BtnNB, FController.FNBMode <> 0); BtnNB.Invalidate; end; procedure TMainForm.UpdateSNBButton; begin BtnSNB.Caption := 'SNB'; StyleButton(BtnSNB, FController.FSNB); BtnSNB.Invalidate; end; procedure TMainForm.UpdateANFButton; begin BtnANF.Caption := 'ANF'; StyleButton(BtnANF, FController.FANF); BtnANF.Invalidate; end; // ApplyNoiseFilterButtonsToDSP переехал в TRadioController (NR/NB/SNB/ANF → WDSP). procedure TMainForm.BtnNRClick(Sender: TObject); begin if FSyncingFromController then Exit; FController.SetNR((FController.FNRMode + 1) mod 5); end; procedure TMainForm.BtnNBClick(Sender: TObject); begin if FSyncingFromController then Exit; FController.SetNB((FController.FNBMode + 1) mod 3); end; procedure TMainForm.BtnSNBClick(Sender: TObject); begin if FSyncingFromController then Exit; FController.SetSNB(not FController.FSNB); end; procedure TMainForm.BtnANFClick(Sender: TObject); begin if FSyncingFromController then Exit; FController.SetANF(not FController.FANF); end; procedure TMainForm.ApplyWfAGCNF(WfAGC, WfNF: Boolean); begin FController.SetWfAGC(WfAGC); FController.SetWfNF(WfNF); end; procedure TMainForm.ApplyADCSettings(Dither, Random: Boolean); begin FController.FDitherEnabled := Dither; FController.FRandomEnabled := Random; if FController.FNetwork.Connected and FController.FRunning then FController.FNetwork.ConfigureDDCs(1, FController.FSampleRate div 1000, 0, FController.FDitherEnabled, FController.FRandomEnabled); if FController.FDevConnected then FController.SaveGlobalSettings; end; procedure TMainForm.ApplyWebSettings(Enabled: Boolean; Port: Integer; const BindAddr, User, Pass: string); var W: TWebSettings; begin FWebEnabled := Enabled; FWebPort := Port; FWebBindAddr := BindAddr; FWebUser := User; FWebPass := Pass; FWebServer.Reconfigure(User, Pass, BindAddr, Word(Port)); if Enabled then FWebServer.Start; W.Enabled := Enabled; W.Port := Port; W.BindAddr := BindAddr; W.User := User; W.Pass := Pass; FController.FSettings.SaveWebSettings(W); end; procedure TMainForm.OnSampleRateHidePanel; begin FPanelHidden := not FPanelHidden; if FPanelHidden then begin FLeftPanelW := PanelLeft.Width; // сохраняем реальную ширину (учитывает DPI на Win32) PanelLeft.Width := 0; FVfoOverlay.Width := 260; FVfoOverlay.Height := 136; // OVL_H_NORM — расширяется сам при открытии AGC-пикера FVfoOverlay.SetState(FController.FMode, FController.FFilterBW, FController.FVfoA, FController.FLastSMeter); FVfoOverlay.SetDSPState(FController.FNRMode, FController.FNBMode, FController.FSNB, FController.FANF, FController.FAGCMode); FVfoOverlay.Visible := True; PositionVfoOverlay; end else begin PanelLeft.Width := FLeftPanelW; // восстанавливаем сохранённую ширину FVfoOverlay.HandleMouseLeave; FVfoOverlay.Visible := False; end; if Assigned(FSampleRateOverlay) then FSampleRateOverlay.SetPanelHidden(FPanelHidden); ResizeSpectrumPanels; ResizeSMeter; end; procedure TMainForm.PositionVfoOverlay; var VfoX, FilterEndX, OW, NewLeft, NewTop: Integer; HiHz: Double; begin if not Assigned(FVfoOverlay) or not FVfoOverlay.Visible then Exit; OW := FVfoOverlay.Width; if (PbSpectrum.Width > 0) and (FController.FSpanHz > 0) then begin VfoX := Round((FController.FVfoA - FController.FCenterFreq + FController.FSpanHz / 2) / FController.FSpanHz * PbSpectrum.Width) end else VfoX := PbSpectrum.Width div 2; case FController.FMode of MODE_LSB: HiHz := -100; MODE_USB: HiHz := FController.FFilterBW; else HiHz := FController.FFilterBW / 2; end; if (PbSpectrum.Width > 0) and (FController.FSpanHz > 0) then FilterEndX := VfoX + Round(HiHz / FController.FSpanHz * PbSpectrum.Width) else FilterEndX := VfoX; FilterEndX := Max(0, Min(PbSpectrum.Width - 1, FilterEndX)); // Всегда справа от правого края полосы фильтра. NewLeft := Max(4, Min(PbSpectrum.Width - OW - 4, FilterEndX + 6)); NewTop := 16; if (FVfoOverlay.Left <> NewLeft) or (FVfoOverlay.Top <> NewTop) then begin FVfoOverlay.Left := NewLeft; FVfoOverlay.Top := NewTop; OnVfoOverlayInvalidate(FVfoOverlay); end; end; procedure TMainForm.PbSpectrumDblClick(Sender: TObject); begin // Двойной клик — ничего не делаем (оверлей всегда виден при скрытой панели) end; procedure TMainForm.OnModeFilterSelect(Mode: Integer; FilterBW: Integer); var Idx: Integer; begin FController.FMode := Mode; FController.FFilterBW := FilterBW; if Mode = MODE_FM then begin FController.FFilter := FILT_FM_DEF; for Idx := 0 to FILT_FM_COUNT - 1 do if FILT_FM_BW[Idx] = FilterBW then begin FController.FFilter := Idx; FController.FFMDeviation := FILT_FM_DEV[Idx]; Break; end; end; if FController.FWDSPReady then begin FController.FDSPEngine.SetMode(FController.FMode); FController.ApplyModeFilter; end; SyncSpecViewFreq; if Assigned(FVfoOverlay) and FVfoOverlay.Visible then begin FVfoOverlay.SetState(FController.FMode, FController.FFilterBW, FController.FVfoA, FController.FLastSMeter); FVfoOverlay.SetDSPState(FController.FNRMode, FController.FNBMode, FController.FSNB, FController.FANF, FController.FAGCMode); PositionVfoOverlay; // перепозиционируем при смене LSB↔USB end; end; procedure TMainForm.OnVfoOverlayDSPChange(NRMode, NBMode: Integer; SNBOn, ANFOn: Boolean); begin // Команды контроллера применяют к DSP и через OnControllerState обновляют кнопки. FController.SetNR(NRMode); FController.SetNB(NBMode); FController.SetSNB(SNBOn); FController.SetANF(ANFOn); end; procedure TMainForm.OnVfoOverlayAGCChange(AGCMode: Integer); begin FController.SetAGCMode(AGCMode); end; procedure TMainForm.OnVfoOverlayInvalidate(Sender: TObject); begin FSpectrumDirty := True; if Assigned(FSpecView) then begin FSpecView.SpectrumDirty := True; FSpecView.InvalidateOverlayCache; end; if Assigned(PbSpectrum) then PbSpectrum.Invalidate; end; procedure TMainForm.OnSampleRateSelect(SampleRate: Integer); begin // Тонкая обёртка: движок (пересоздание WDSP-канала, DDC-rate, DSP-restore) + // персист — в команде SetSampleRate; рендер overlay/AGC — OnControllerState. FController.SetSampleRate(SampleRate); end; // =========================================================================== // Веб-интерфейс: callbacks от TWebServer (вызываются из WS-потока) // Все изменения состояния выполняем через TThread.Synchronize чтобы // не трогать UI и WDSP из чужого потока. // =========================================================================== // Mode/Filter/AGC/AGCTop/Volume/Drive/Span/Band/XvtrBand/Freq/FreqA/FreqB/Center/ // WfAGC/WfNF/Mute/NR/NB/SNB/ANF/Attn/ClientActiveChanged перенесены в TWebAdapter // (чистые команды контроллера). // ── Run ──────────────────────────────────────────────────────────────────── procedure TMainForm.WebOnRun(On_: Boolean); begin FWebSyncBool := On_; FWebSyncM := SyncWebRun; TThread.Synchronize(nil, FWebSyncM); end; procedure TMainForm.SyncWebRun; begin // set_run отвязан от десктопного модального диалога: web стартует через connect // (overlay), а set_run:true без выбранного устройства бессмысленно. Здесь только // STOP-путь (set_run:false), симметрично web-disconnect. if (not FWebSyncBool) and FController.FNetwork.Connected then DoStopAndDisconnect; end; // ── Web discovery / connect / disconnect / saved-CRUD ─────────────────────── procedure TMainForm.WebOnConnect(const IP: string); begin FWebSyncStr1 := IP; FWebSyncM := SyncWebConnect; TThread.Synchronize(nil, FWebSyncM); end; procedure TMainForm.SyncWebConnect; var Dev: THPSDRDevice; i: Integer; begin if FController.FNetwork.Connected then Exit; // уже подключены FillChar(Dev, SizeOf(Dev), 0); Dev.IPAddress := FWebSyncStr1; // BoardType (тип платы не определяется при Connect — только из discovery/saved): // сначала найденное устройство (есть и MAC для preload), иначе сохранённое по IP // — как десктоп берёт FPendingBoardType из GetSavedBoardType. i := FController.FDeviceStore.FindDiscoveredByIP(FWebSyncStr1); if i >= 0 then Dev.BoardType := FController.FDeviceStore.Discovered(i).BoardType else for i := 0 to FController.FDeviceStore.SavedCount - 1 do if FController.FDeviceStore.Saved(i).IPAddress = FWebSyncStr1 then begin Dev.BoardType := FController.FDeviceStore.Saved(i).BoardType; Break; end; StartWithDevice(Dev); end; procedure TMainForm.WebOnDiscover; begin FWebSyncM := SyncWebDiscover; TThread.Synchronize(nil, FWebSyncM); end; procedure TMainForm.SyncWebDiscover; begin SetStatusText(2, 'Discovering...'); FController.Discover; // rfDeviceList-рендер запушит обновлённый список в web end; procedure TMainForm.WebOnDisconnect; begin FWebSyncM := SyncWebDisconnect; TThread.Synchronize(nil, FWebSyncM); end; procedure TMainForm.SyncWebDisconnect; begin if FController.FNetwork.Connected then DoStopAndDisconnect; end; procedure TMainForm.WebOnDevAdd(const AName, IP: string); begin FWebSyncStr1 := AName; FWebSyncStr2 := IP; FWebSyncM := SyncWebDevAdd; TThread.Synchronize(nil, FWebSyncM); end; procedure TMainForm.SyncWebDevAdd; begin FController.AddSavedDevice(FWebSyncStr1, FWebSyncStr2); // Pluto по схеме URI PushDeviceListToWeb; end; procedure TMainForm.WebOnDevRemove(Idx: Integer); begin FWebSyncInt := Idx; FWebSyncM := SyncWebDevRemove; TThread.Synchronize(nil, FWebSyncM); end; procedure TMainForm.SyncWebDevRemove; begin FController.FDeviceStore.RemoveSaved(FWebSyncInt); PushDeviceListToWeb; end; procedure TMainForm.WebOnDevAutoStart(Idx: Integer); begin FWebSyncInt := Idx; FWebSyncM := SyncWebDevAutoStart; TThread.Synchronize(nil, FWebSyncM); end; procedure TMainForm.SyncWebDevAutoStart; begin FController.FDeviceStore.SetAutoStart(FWebSyncInt); PushDeviceListToWeb; end; procedure TMainForm.PushDeviceListToWeb; // store (saved+discovered) → FWebServer.SetDeviceList. Saved идут первыми, поэтому // их позиция в массиве == saved-индекс (web шлёт его в dev_remove/dev_autostart). var Arr: TWebDeviceArray; i, k: Integer; D: TDiscoveredDevice; Sv: TSavedDevice; begin if FWebServer = nil then Exit; SetLength(Arr, FController.FDeviceStore.SavedCount + FController.FDeviceStore.DiscoveredCount); k := 0; for i := 0 to FController.FDeviceStore.SavedCount - 1 do begin Sv := FController.FDeviceStore.Saved(i); Arr[k].Name := Sv.Name; Arr[k].IP := Sv.IPAddress; Arr[k].BoardType := Sv.BoardType; Arr[k].IsSaved := True; Arr[k].AutoStart := Sv.AutoStart; Inc(k); end; for i := 0 to FController.FDeviceStore.DiscoveredCount - 1 do begin D := FController.FDeviceStore.Discovered(i); Arr[k].Name := D.DisplayName; Arr[k].IP := D.IPAddress; Arr[k].BoardType := D.BoardType; Arr[k].IsSaved := False; Arr[k].AutoStart := False; Inc(k); end; FWebServer.SetDeviceList(Arr); end; // ── CTUN ────────────────────────────────────────────────────────────────── procedure TMainForm.TrkDriveChange(Sender: TObject); begin if FSyncingFromController then Exit; // программная установка из OnControllerState — не команда FController.SetDrive(TrkDrive.Position); // rfDrive-рендер снимет память канала (OnDriveChanged) end; procedure TMainForm.UpdateDriveLabel; // Значение рядом с ползунком drive: openHPSDR — проценты, Pluto — TX-аттенюация // в дБ (берём из контроллера; маппинг drive%→дБ — там же). begin if LblDrvVal = nil then Exit; if FController.IsPluto then LblDrvVal.Caption := Format('%.0fdB', [FController.PlutoTxAttForDrive]) else LblDrvVal.Caption := Format('%d%%', [FController.FDrivePercent]); end; function TMainForm.CalcDriveByte: Byte; begin // Формула — в контроллере (чистая над состоянием). Делегат для существующих // вызовов MainForm. Result := FController.CalcDriveByte; end; procedure TMainForm.OnPASettingsChange(MaxPower: Double; const BandCal: array of Double); var i: Integer; begin FController.FPAMaxPower := MaxPower; if FSpecView <> nil then FSpecView.PAMaxPower := MaxPower; for i := 0 to BAND_COUNT - 1 do begin if i <= High(BandCal) then FController.FPABandCal[i] := BandCal[i] else FController.FPABandCal[i] := 100.0; end; FController.FDriveLevel := CalcDriveByte; if FController.FNetwork.Connected and FController.FNetwork.Running then FController.FNetwork.UpdateState(XvtrTranslate(FController.FCenterFreq), XvtrTranslateTX(ActiveTXFreqHz), FController.FDriveLevel, FController.FTransmitting, True, True); end; procedure TMainForm.OnPlutoTxMaxAttChange(MaxAttDb: Double); begin // Логика (clamp/apply/persist) — в контроллере; UI лишь передаёт значение. FController.SetPlutoTxMaxAtt(MaxAttDb); UpdateDriveLabel; // изменился потолок → пересчитать дБ-подпись drive end; procedure TMainForm.OnVHFCalSettingsChange(const VHFCal: array of Double); var i: Integer; begin for i := 0 to CFG_XVTR_COUNT - 1 do begin if i <= High(VHFCal) then FController.FVHFBandCal[i] := VHFCal[i] else FController.FVHFBandCal[i] := CFG_VHF_CAL_DEFAULT; end; FController.FDriveLevel := CalcDriveByte; if FController.FNetwork.Connected and FController.FNetwork.Running then FController.FNetwork.UpdateState(XvtrTranslate(FController.FCenterFreq), XvtrTranslateTX(ActiveTXFreqHz), FController.FDriveLevel, FController.FTransmitting, True, True); end; procedure TMainForm.OnTXSettingsChange(const T: TTXSettings); // Любая правка в Settings→Transmit летит сюда: обновляем FController.FTXSettings, // перешиваем WDSP TX-цепь и (если поменялись mic-биты) перепосылаем DUC Specific. var OldMicByte, NewMicByte: Byte; OldAtt, OldTUNLevel, OldTUNFreq: Integer; GridChanged: Boolean; begin OldMicByte := FController.BuildMicLineSelectByte; OldAtt := FController.FTXSettings.AttOnTX; OldTUNLevel := FController.FTXSettings.TUNLevel; OldTUNFreq := FController.FTXSettings.TUNFreq; GridChanged := (FController.FTXSpecRefLevel <> T.TXSpecRefLevel) or (FController.FTXSpecRange <> T.TXSpecRange) or (FController.FTXSpecGridStep <> T.TXSpecGridStep); FController.FTXSettings := T; NewMicByte := FController.BuildMicLineSelectByte; // TX-grid в момент правки — обновляем рабочие поля и (если на передаче) пушим в FSpecView FController.FTXSpecRefLevel := FController.FTXSettings.TXSpecRefLevel; FController.FTXSpecRange := FController.FTXSettings.TXSpecRange; if FController.FTXSettings.TXSpecGridStep > 0 then FController.FTXSpecGridStep := FController.FTXSettings.TXSpecGridStep; if GridChanged and FController.FTransmitting then ApplySpecViewGridFromState; FController.ApplyTXSettingsToDSP; if (OldMicByte <> NewMicByte) or (OldAtt <> FController.FTXSettings.AttOnTX) then FController.SendDUCSpecificFromSettings; // TUN-параметры live: если идёт tune — обновить тон/drive, иначе просто запомнить. if FController.FTuning then begin if (OldTUNFreq <> FController.FTXSettings.TUNFreq) and FController.FWDSPReady then FController.FDSPEngine.SetTXTone(True, FController.FTXSettings.TUNFreq, 1.0); if OldTUNLevel <> FController.FTXSettings.TUNLevel then begin FController.FDriveLevel := CalcDriveByte; if FController.FWDSPReady then FController.FDSPEngine.SetDriveLevel(FController.FTXSettings.TUNLevel / 100.0); if FController.FNetwork.Connected and FController.FNetwork.Running then begin FController.FNetwork.UpdateState(XvtrTranslate(FController.FCenterFreq), XvtrTranslateTX(ActiveTXFreqHz), FController.FDriveLevel, FController.FTransmitting, True, True); FController.FNetwork.SendFullHP; end; end; end; // Сохраняем сразу — настройки per-device, попадают в hpsdr_settings.json if FController.FDevConnected then begin FController.FSettings.SaveTX(FController.FDevMAC, FController.FTXSettings); FController.FSettings.Save; end; end; procedure TMainForm.OnAlexSettingsChange(const A: TAlexSettings); begin FController.FAlexSettings := A; if FController.FNetwork.Connected then FController.FNetwork.SetAlexConfig(A); if FController.FDevConnected then begin FController.FSettings.SaveAlex(FController.FDevMAC, A); FController.FSettings.Save; end; end; // =========================================================================== // XVTR (Transverter) integration // =========================================================================== // Ищет XVTR слот, в чьё [FreqBegin..FreqEnd] попадает данная видимая частота. // Возвращает индекс 0..CFG_XVTR_COUNT-1 или -1. function TMainForm.FindXvtrIdxForFreq(FreqHz: Double): Integer; var i: Integer; begin Result := -1; for i := 0 to CFG_XVTR_COUNT - 1 do with FController.FXvtrSettings.Entries[i] do if Enabled and (FreqHz >= FreqBegin) and (FreqHz <= FreqEnd) then Exit(i); end; // Транслирует видимую частоту в IF (то, что слышит/передаёт трансивер). // Если активный XVTR не задан — возвращает входную частоту без изменений. function TMainForm.XvtrTranslate(VisibleHz: Double): Double; begin // Логика перенесена в контроллер; обёртка для существующих вызовов в UI. Result := FController.XvtrTranslate(VisibleHz); end; function TMainForm.XvtrTranslateTX(VisibleHz: Double): Double; begin // TX-трансляция (QO-100 split-LO); обёртка над контроллером. Result := FController.XvtrTranslateTX(VisibleHz); end; // Передаёт XVTR-режим в сетевой слой. Вызывается при смене XVTR или его // настроек. Управляет XVTR Enable bit, T/R relay suppression. // XVTR DDC In (Alex bits 8+11) управляется через галочку XVTR в Antenna/Alex // tab для IF-диапазона (Alex.RxOnly[IF_band]=3) — аналогично Thetis. procedure TMainForm.ApplyXvtrToNetwork; begin // Логика перенесена в контроллер; обёртка для существующих вызовов в UI. FController.ApplyXvtrToNetwork; end; // Перестраивает динамические XVTR-кнопки в PanelBands. Удаляет старые, // создаёт новые для enabled слотов в 3-м и 4-м ряду PanelBands. procedure TMainForm.RelayoutBelowBands; var Y: Integer; begin if PanelBands = nil then Exit; Y := PanelBands.Top + PanelBands.Height + 2; PanelMode.Top := Y; Y := Y + PanelMode.Height + 2; PanelFilter.Top := Y; Y := Y + PanelFilter.Height + 2; if (PanelFMStep <> nil) and PanelFMStep.Visible then begin PanelFMStep.Top := Y; Y := Y + PanelFMStep.Height + 2; end; if (PanelFMSQ <> nil) and PanelFMSQ.Visible then begin PanelFMSQ.Top := Y; Y := Y + PanelFMSQ.Height + 2; end; if (PanelFMCTCSS <> nil) and PanelFMCTCSS.Visible then begin PanelFMCTCSS.Top := Y; Y := Y + PanelFMCTCSS.Height + 2; end; if (PanelFMRpt <> nil) and PanelFMRpt.Visible then begin PanelFMRpt.Top := Y; Y := Y + PanelFMRpt.Height + 2; end; end; procedure TMainForm.RebuildXvtrButtons; var i, Pos, XvtrRows: Integer; BtnH, RowH, MaxBottom: Integer; B: TFlatButton; Cap: string; begin // Уничтожаем все старые for i := 0 to CFG_XVTR_COUNT - 1 do if BtnXvtrBand[i] <> nil then begin BtnXvtrBand[i].Free; BtnXvtrBand[i] := nil; end; if PanelBands = nil then Exit; // Измеряем фактический bottom последнего ряда обычных кнопок BAND // (уже отмасштабированы LCL при показе формы) MaxBottom := 0; BtnH := 0; for i := 0 to BAND_COUNT - 1 do if BtnBand[i] <> nil then begin if BtnBand[i].Top + BtnBand[i].Height > MaxBottom then MaxBottom := BtnBand[i].Top + BtnBand[i].Height; BtnH := BtnBand[i].Height; end; if BtnH = 0 then BtnH := MulDiv(24, Screen.PixelsPerInch, 96); RowH := BtnH + 3; // шаг ряда: высота кнопки + 3px зазор (как 27=24+3 при 1x) Pos := 0; // позиция среди enabled слотов for i := 0 to CFG_XVTR_COUNT - 1 do if FController.FXvtrSettings.Entries[i].Enabled then begin if Pos >= 12 then Break; // 2 ряда по 6 кнопок Cap := FController.FXvtrSettings.Entries[i].ButtonText; if Cap = '' then Cap := 'X' + IntToStr(i + 1); B := TFlatButton.Create(Self); B.Parent := PanelBands; B.Caption := Cap; B.Left := LeftPanelButtonLeft(PanelBands.Width, 6, Pos mod 6); B.Top := MaxBottom + 4 + (Pos div 6) * RowH; B.Width := LeftPanelButtonWidth(PanelBands.Width, 6, Pos mod 6); B.Height := BtnH; B.OnClick := BtnXvtrBandClick; B.Tag := -(i + 1); // отрицательное значение = XVTR индекс BtnXvtrBand[i] := B; StyleButton(B, i = FController.FCurrentXvtr); Inc(Pos); end; // Динамически подгоняем высоту PanelBands под реальное содержимое if Pos = 0 then XvtrRows := 0 else if Pos <= 6 then XvtrRows := 1 else XvtrRows := 2; if XvtrRows = 0 then PanelBands.Height := MaxBottom + 6 else PanelBands.Height := MaxBottom + 4 + XvtrRows * RowH + 6; RelayoutBelowBands; end; // Обработчик клика по XVTR-кнопке. Tag = -(idx+1). procedure TMainForm.BtnXvtrBandClick(Sender: TObject); var TagVal, Idx: Integer; begin TagVal := (Sender as TFlatButton).Tag; Idx := -TagVal - 1; if (Idx < 0) or (Idx >= CFG_XVTR_COUNT) then Exit; if not FController.FXvtrSettings.Entries[Idx].Enabled then Exit; if FController.FCurrentXvtr = Idx then Exit; // уже на этом XVTR // Сохраняем текущий band перед сменой (как BtnBandClick) if FController.FDevConnected then begin SaveCurrentBand; FController.FSettings.Save; end; ActivateXvtrBand(Idx); end; // Активирует XVTR-band: устанавливает видимую частоту по LastFreq или // середине [FreqBegin..FreqEnd], применяет network XVTR-режим. procedure TMainForm.ActivateXvtrBand(Idx: Integer); begin // Тонкий делегат: вход в XVTR-слот + рендер кнопок/частот/спектра/wideband/web — // всё в команде/событиях (rfXvtr/rfBand/rfActiveVfo). FController.ActivateXvtr(Idx); end; // Деактивирует XVTR — возвращает к HF-режиму. FController.FCurrentBand указывает // на HF-диапазон, с которого пользователь зашёл в трансвертер. RestoreBand вернёт // VFO/Mode/Filter/AGC/CTUN/Span этого диапазона и обновит подсветку HF band-кнопки. procedure TMainForm.DeactivateXvtr; begin // Тонкий делегат: выход в HF (SaveCurrentBand + HF-возврат RestoreBand + рендер // кнопок/wideband/web) — всё в команде/событиях. FController.DeactivateXvtr; end; procedure TMainForm.OnXvtrSettingsChange(const X: TXvtrSettings); begin FController.FXvtrSettings := X; RebuildXvtrButtons; // Если активный XVTR стал disabled — выйти из XVTR режима if (FController.FCurrentXvtr >= 0) and ((FController.FCurrentXvtr >= CFG_XVTR_COUNT) or (not FController.FXvtrSettings.Entries[FController.FCurrentXvtr].Enabled)) then DeactivateXvtr; // Перепрошить XVTR в сеть (если параметры текущего изменились) ApplyXvtrToNetwork; UpdateWidebandFrequencyView; if PbWideband <> nil then PbWideband.Invalidate; if PbWidebandRuler <> nil then PbWidebandRuler.Invalidate; PushXvtrToWeb; if FController.FDevConnected then begin FController.FSettings.SaveXvtr(FController.FDevMAC, FController.FXvtrSettings); FController.FSettings.Save; end; end; procedure TMainForm.PushXvtrToWeb; var Arr: TWebXvtrArray; i, Cnt: Integer; begin if FWebServer = nil then Exit; Cnt := 0; for i := 0 to CFG_XVTR_COUNT - 1 do if FController.FXvtrSettings.Entries[i].Enabled then Inc(Cnt); SetLength(Arr, Cnt); Cnt := 0; for i := 0 to CFG_XVTR_COUNT - 1 do if FController.FXvtrSettings.Entries[i].Enabled then begin Arr[Cnt].Idx := i; Arr[Cnt].Name := FController.FXvtrSettings.Entries[i].ButtonText; Inc(Cnt); end; FWebServer.SetXvtrBands(Arr, FController.FCurrentXvtr); end; procedure TMainForm.TrkVolumeChange(Sender: TObject); begin if FSyncingFromController then Exit; FController.SetVolume(TrkVolume.Position); 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 function OverFreqDisp(C: TControl): Boolean; var P: TPoint; begin Result := False; if (C = nil) or (not C.Visible) then Exit; // Mouse.CursorPos — надёжные экранные координаты (переданный MousePos на // Cocoa может быть не в экранной системе). P := C.ScreenToClient(Mouse.CursorPos); Result := (P.X >= 0) and (P.Y >= 0) and (P.X < C.Width) and (P.Y < C.Height); end; var Step: Int64; Delta: Int64; Base: Int64; NewFreq: Int64; begin // Над дисплеем VFO (A или B) колесо обрабатывает сам TFreqDisplay поразрядно // (включая неактивный VFO). Не дублируем здесь, иначе менялись бы обе частоты: // виджет крутил бы B, а этот хендлер — активный A. if OverFreqDisp(FreqDispA) or OverFreqDisp(FreqDispB) then Exit; if (FController.FMode = MODE_FM) and FController.FFMStepOn and not ((ssCtrl in Shift) or (ssShift in Shift)) then Step := FM_STEP_HZ[FController.FFMStepIdx] else if (ssCtrl in Shift) and (ssShift in Shift) then Step := 1000000 else if ssCtrl in Shift then Step := 1000 else if ssShift in Shift then Step := 10 else Step := 100; Delta := 1; if WheelDelta < 0 then Delta := -1; // Квантование (та же логика что в FreqDisplay.ChangeByDigit) — от частоты // АКТИВНОГО VFO, а не всегда VFO A. Base := (ActiveVfoFreq div Step) * Step; if Delta > 0 then NewFreq := Base + Step else begin if ActiveVfoFreq = Base then NewFreq := Base - Step else NewFreq := Base; // снэп к нижней границе end; // Клэмп диапазона перестройки. XVTR — ApplyVfoA сам ограничит по FreqBegin/End. // Pluto — по диапазону бэкенда (Caps). Иначе HF (30 кГц..60 МГц). if (FController.FCurrentXvtr >= 0) and (FController.FCurrentXvtr < CFG_XVTR_COUNT) and FController.FXvtrSettings.Entries[FController.FCurrentXvtr].Enabled then NewFreq := Max(0, NewFreq) else if FController.IsPluto then NewFreq := Max(Round(FController.BackendCaps.MinFreqHz), Min(Round(FController.BackendCaps.MaxFreqHz), NewFreq)) else NewFreq := Max(30000, Min(60000000, NewFreq)); if FController.FActiveVfo = 0 then ApplyVfoA(NewFreq) else FController.SetVfoB(NewFreq); // крутим активный VFO B Handled := True; end; procedure TMainForm.BtnAGCModeClick(Sender: TObject); begin if FSyncingFromController then Exit; FController.SetAGCMode((Sender as TFlatButton).Tag); end; procedure TMainForm.TrkAGCChange(Sender: TObject); begin if FSyncingFromController then Exit; FController.SetAGCTop(TrkAGC.Position); end; procedure TMainForm.TrkRxGainChange(Sender: TObject); begin if FSyncingFromController then Exit; FController.SetRxGain(TrkRxGain.Position); // переводит в manual end; procedure TMainForm.BtnPlutoGainClick(Sender: TObject); begin if FSyncingFromController then Exit; FController.SetRxGainMode((Sender as TFlatButton).Tag); // Tag = индекс режима end; procedure TMainForm.UpdateRxGainUI; // Рендер Pluto RF-AGC блока: подсветка режима + слайдер/значение manual gain. var i: Integer; begin if PanelPlutoAGC = nil then Exit; FSyncingFromController := True; try for i := 0 to 3 do if BtnPlutoGain[i] <> nil then StyleButton(BtnPlutoGain[i], BtnPlutoGain[i].Tag = FController.FRxGainMode); TrkRxGain.Position := FController.FRxGainDb; TrkRxGain.Enabled := (FController.FRxGainMode = 0); // активен только в manual if FController.FRxGainMode = 0 then LblRxGain.Caption := Format('%ddB', [FController.FRxGainDb]) else LblRxGain.Caption := 'AGC'; finally FSyncingFromController := False; end; end; procedure TMainForm.LayoutLeftPanel(Pluto: Boolean); // Показывает/скрывает Pluto RF-AGC блок под AGC и сдвигает панели ниже // (Bands и далее через RelayoutBelowBands). var baseTop: Integer; begin if (PanelPlutoAGC = nil) or (PanelAGC = nil) or (PanelBands = nil) then Exit; baseTop := PanelAGC.Top + PanelAGC.Height + 4; PanelPlutoAGC.Top := baseTop; PanelPlutoAGC.Visible := Pluto; if Pluto then PanelBands.Top := baseTop + PanelPlutoAGC.Height + 2 else PanelBands.Top := baseTop; RelayoutBelowBands; end; // =========================================================================== // DSP/Audio callbacks — вызываются из рабочих потоков // =========================================================================== procedure TMainForm.OnSpectrumReady(const Pixels: array of Single; Count: Integer); // Подписчик на FController.OnSpectrumData (сырьё из DSP-потока) — только рендер; // web-снимок держит контроллер (FSpectrumBuf). begin FSpecView.SetSpectrumData(Pixels, Count); FSpectrumDirty := True; end; procedure TMainForm.OnWaterfallReady(const Pixels: array of Single; Count: Integer); // Подписчик на FController.OnWaterfallData — рендер с децимацией скорости // прокрутки (FWaterfallFrameInterval). Web получает каждый кадр (в контроллере). begin Inc(FWaterfallFrameCounter); if FWaterfallFrameCounter >= Max(1, FWaterfallFrameInterval) then begin FWaterfallFrameCounter := 0; FSpecView.SetWaterfallData(Pixels, Count); FWaterfallDirty := True; end; end; // =========================================================================== // Settings apply methods (вызываются из TSettingsForm) // =========================================================================== procedure TMainForm.ApplyDisplayParams(FFTSize, WinType, SpecDet, SpecAvgMode: Integer; SpecAvgTimeMS: Double); begin if FController.FWDSPReady then FController.FDSPEngine.SetSpectrumDisplay(SpecDet, SpecAvgMode, SpecAvgTimeMS); if FController.FWDSPReady then FController.FDSPEngine.SetFFTParams(FFTSize, WinType); FSpectrumDirty := True; end; procedure TMainForm.ApplyWaterfallParams(WfDet, WfAvgMode: Integer; WfAvgTimeMS, WfHigh, WfLow, WfAGCOffset: Double); begin if FController.FWDSPReady then FController.FDSPEngine.SetWaterfallDisplay(WfDet, WfAvgMode, WfAvgTimeMS); FController.FWfManualHigh := WfHigh; FController.FWfManualLow := WfLow; FController.FWfAGCOffset := WfAGCOffset; FSpecView.WfManualHigh := FController.FWfManualHigh; FSpecView.WfManualLow := FController.FWfManualLow; FSpecView.WfAGCOffset := FController.FWfAGCOffset; FSpecView.ResetWfAvgBuf; FSpectrumDirty := True; end; procedure TMainForm.ApplyGridParams(RefLevel, Range, GridStep: Double); begin FController.FSpecRefLevel := RefLevel; FController.FSpecRange := Range; if GridStep > 0 then FController.FSpecGridStep := GridStep; // На передаче FSpecView показывает TX-сетку — RX-правка не должна её перетирать. if not FController.FTransmitting then ApplySpecViewGridFromState; end; procedure TMainForm.ApplySpecViewGridFromState; begin // При DUP во время TX данные приходят от RX-анализатора → используем RX-сетку. if FController.FTransmitting and not FController.FDisplayDuplex then begin FSpecView.SpecRefLevel := FController.FTXSpecRefLevel; FSpecView.SpecRange := FController.FTXSpecRange; FSpecView.SpecGridStep := FController.FTXSpecGridStep; end else begin FSpecView.SpecRefLevel := FController.FSpecRefLevel; FSpecView.SpecRange := FController.FSpecRange; FSpecView.SpecGridStep := FController.FSpecGridStep; end; InvalidateGridCache; FSpectrumDirty := True; end; procedure TMainForm.ApplyAudioDevice(DevIndex: Integer; const DevName: string); begin if FController.FAudioOut.IsOpen then FController.FAudioOut.Close; FController.FAudioOut.DeviceIndex := DevIndex; FController.FAudioOutDevName := DevName; try FController.FAudioOut.Open; except end; end; procedure TMainForm.ApplyAudioInputDevice(DevIndex: Integer; const DevName: string); begin if FController.FAudioIn.IsOpen then FController.FAudioIn.Close; FController.FAudioIn.DeviceIndex := DevIndex; FController.FAudioInDevName := DevName; if DevIndex >= 0 then begin try FController.FAudioIn.Open; except end; end; end; procedure TMainForm.ApplyAudioBufferSize(BufferSize: Integer); var WasOpen: Boolean; begin if BufferSize <= 128 then BufferSize := 128 else if BufferSize <= 256 then BufferSize := 256 else BufferSize := 512; if FController.FAudioOut.OutputBufferSize = BufferSize then Exit; WasOpen := FController.FAudioOut.IsOpen; if WasOpen then FController.FAudioOut.Close; FController.FAudioOut.OutputBufferSize := BufferSize; if WasOpen then begin try FController.FAudioOut.Open; except end; end; FController.FSettings.SaveAudioBufferSize(BufferSize); FController.FSettings.Save; end; procedure TMainForm.ApplySendAudioToRadio(Enabled: Boolean); begin FController.FSendAudioToRadio := Enabled; // Один переключатель: вкл = слать поток + снять мьют динамика, // выкл = замьютить динамик (byte 1400 bit1) и не слать поток. FController.FNetwork.SetSpeakerAudio(Enabled); if FController.FDevConnected then FController.SaveGlobalSettings; end; procedure TMainForm.ApplyVisibility(ShowSpectrum, ShowWaterfall, ShowWideband, WidebandFill, SpectrumFill: Boolean); begin FController.FShowSpectrum := ShowSpectrum; FController.FShowWaterfall := ShowWaterfall; FController.FShowWideband := ShowWideband; FController.FWidebandFill := WidebandFill; FController.FSpectrumFill := SpectrumFill; if Assigned(FWidebandView) then FWidebandView.SetFillSpectrum(FController.FWidebandFill); FSpecView.FillSpectrum := FController.FSpectrumFill; if Assigned(FController.FNetwork) and FController.FNetwork.Connected then FController.FNetwork.ConfigureWideband(0, FController.FShowWideband, 512, 16, 70, 32); ResizeSpectrumPanels; end; procedure TMainForm.ApplyFPS(FPS: Integer); begin if FPS < 1 then FPS := 1; if FPS > 100 then FPS := 100; FDisplayFPS := FPS; FController.FLoadedGlobal.DisplayFPS := FPS; // зеркало в persist-буфер контроллера FSpectrumTimer.Interval := 1000 div FPS; // CPU path keeps the historical every-other-frame waterfall update. // OpenGL uploads only one texture row per frame, so it can match display FPS. if FUseOpenGLSpectrum then FWaterfallFrameInterval := 1 else FWaterfallFrameInterval := 2; FWaterfallFrameCounter := 0; FWaterfallDirty := True; FSpecView.WfFrameInterval := FWaterfallFrameInterval; if FController.FWDSPReady then FController.FDSPEngine.SetDisplayFPS(FPS); end; procedure TMainForm.ApplyFreqMhzDigits(Digits: Integer); begin Digits := EnsureRange(Digits, 3, 5); FFreqMhzDigits := Digits; FController.FLoadedGlobal.FreqMhzDigits := Digits; // зеркало в persist-буфер контроллера FreqDispA.MinMhzDigits := Digits; FreqDispB.MinMhzDigits := Digits; UpdateFreqDisplayMax; // верхняя граница: активный XVTR / Pluto Caps / HF по цифрам if Assigned(FWebServer) then FWebServer.FreqMhzDigits := Digits; LayoutTopVfoBlock; end; procedure TMainForm.UpdateFreqDisplayMax; // Верхняя граница FreqDisplay. Активный трансвертер (XVTR/QO-100) может показывать // частоты выше диапазона железа (напр. QO-100 — 10.5 ГГц) → берём его FreqEnd. // Иначе Pluto — из Caps (до 6 ГГц), HPSDR — по числу MHz-цифр. const MaxFreqs: array[3..5] of Int64 = (999999999, 9999999999, 99999999999); var hi: Int64; xi: Integer; begin xi := FController.FCurrentXvtr; if (xi >= 0) and (xi < CFG_XVTR_COUNT) then hi := Round(FController.FXvtrSettings.Entries[xi].FreqEnd) + 1000000 // +1 МГц запас else if FController.IsPluto then hi := Round(FController.BackendCaps.MaxFreqHz) else hi := MaxFreqs[EnsureRange(FFreqMhzDigits, 3, 5)]; if hi < 1000000 then hi := MaxFreqs[3]; if FreqDispA <> nil then FreqDispA.MaxFreq := hi; if FreqDispB <> nil then FreqDispB.MaxFreq := hi; end; procedure TMainForm.BtnSettingsClick(Sender: TObject); var SF: TSettingsForm; begin if not Assigned(FSettingsForm) then begin SF := TSettingsForm.Create(Self); FSettingsForm := SF; SF.OnDisplayChange := ApplyDisplayParams; SF.OnWaterfallChange := ApplyWaterfallParams; SF.OnGridChange := ApplyGridParams; SF.OnAudioDevChange := ApplyAudioDevice; SF.OnAudioInDevChange := ApplyAudioInputDevice; SF.OnAudioBufferChange := ApplyAudioBufferSize; SF.OnSendAudioToRadioChange := ApplySendAudioToRadio; SF.OnVisibilityChange := ApplyVisibility; SF.OnFPSChange := ApplyFPS; SF.OnPAChange := OnPASettingsChange; SF.OnPlutoTxChange := OnPlutoTxMaxAttChange; SF.OnVHFCalChange := OnVHFCalSettingsChange; SF.OnCATChange := OnCATSettingsChange; SF.OnThemeChange := SetLightTheme; SF.OnFreqMhzDigitsChange := ApplyFreqMhzDigits; SF.OnTXChange := OnTXSettingsChange; SF.OnAlexChange := OnAlexSettingsChange; SF.OnXvtrChange := OnXvtrSettingsChange; SF.OnWfAGCNFChange := ApplyWfAGCNF; SF.OnADCChange := ApplyADCSettings; SF.OnWebSettingsChange := ApplyWebSettings; end; SF := TSettingsForm(FSettingsForm); SF.LoadTXSettings(FController.FTXSettings, IfThen(FController.FNetwork.Connected, FController.FNetwork.Device.BoardType, FPendingBoardType)); SF.LoadAlexSettings(FController.FAlexSettings, IfThen(FController.FNetwork.Connected, FController.FNetwork.Device.BoardType, FPendingBoardType)); SF.LoadXvtrSettings(FController.FXvtrSettings); SF.LoadPASettings(FController.FPAMaxPower, FController.FPABandCal); SF.LoadPlutoTxMaxAtt(FController.FPlutoTxMaxAttDb); SF.LoadVHFCalSettings(FController.FVHFBandCal); SF.LoadCATSettings( FCATLastGlobal.CATSerialEnabled, FCATLastGlobal.CATSerialPort, FCATLastGlobal.CATSerialBaud, FCATLastGlobal.CATSerialDataBits, FCATLastGlobal.CATSerialStopBits, FCATLastGlobal.CATSerialParity, FCATLastGlobal.CATTcpEnabled, FCATLastGlobal.CATTcpPort); // Перечисляем PA устройства SF.RefreshAudioDevices(FController.FAudioOut, FController.FAudioIn); SF.LoadVisibility(FController.FShowSpectrum, FController.FShowWaterfall, FController.FShowWideband, FController.FWidebandFill, FController.FSpectrumFill); SF.LoadWfAGCNF(FController.FWfAGCEnabled, FController.FWfNFEnabled); SF.LoadADCSettings(FController.FDitherEnabled, FController.FRandomEnabled); SF.LoadWebSettings(FWebEnabled, FWebPort, FWebBindAddr, FWebUser, FWebPass); SF.LoadFPS(FDisplayFPS); SF.LoadLightTheme(FLightTheme); SF.LoadFreqMhzDigits(FFreqMhzDigits); SF.LoadAudioBufferSize(FController.FAudioOut.OutputBufferSize); SF.LoadSendAudioToRadio(FController.FSendAudioToRadio); // Загружаем текущие значения if FController.FWDSPReady then SF.LoadValues( FController.FDSPEngine.FFTSize, FController.FDSPEngine.WindowType, FController.FDSPEngine.SpecDetector, FController.FDSPEngine.SpecAvgMode, FController.FDSPEngine.SpecAvgTimeMS, FController.FDSPEngine.WfDetector, FController.FDSPEngine.WfAvgMode, FController.FDSPEngine.WfAvgTimeMS, FController.FWfManualHigh, FController.FWfManualLow, FController.FWfAGCOffset, FController.FSpecRefLevel, FController.FSpecRange, FController.FSpecGridStep, FController.FAudioOutDevName, FController.FAudioInDevName) else SF.LoadValues( 131072, 2, 0, 3, 30.0, 0, 3, 120.0, FController.FWfManualHigh, FController.FWfManualLow, FController.FWfAGCOffset, FController.FSpecRefLevel, FController.FSpecRange, FController.FSpecGridStep, FController.FAudioOutDevName, FController.FAudioInDevName); SF.Show; end; procedure TMainForm.AfterShowTick(Sender: TObject); begin // Вызывается один раз через 200 мс после старта формы FAfterShowTimer.Enabled := False; // Повторно применяем сохранённую позицию: к этому моменту WM уже декорировал // окно и Qt знает frame extents, поэтому move() ставит рамку точно. if FHasPendingRestore then begin SetBounds(FRestoreL, FRestoreT, Width, Height); FHasPendingRestore := False; end; try FController.FAudioOut.Open; except end; EnsureWDSPWisdom; end; // =========================================================================== // CAT subsystem // =========================================================================== procedure TMainForm.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; // Зеркалим CAT-поля в persist-буфер контроллера (BuildGlobalSettings берёт их // сквозь из FLoadedGlobal; на connect они грузятся в FLoadedGlobal → FCATLastGlobal). FController.FLoadedGlobal.CATSerialEnabled := FCATLastGlobal.CATSerialEnabled; FController.FLoadedGlobal.CATSerialPort := FCATLastGlobal.CATSerialPort; FController.FLoadedGlobal.CATSerialBaud := FCATLastGlobal.CATSerialBaud; FController.FLoadedGlobal.CATSerialDataBits := FCATLastGlobal.CATSerialDataBits; FController.FLoadedGlobal.CATSerialStopBits := FCATLastGlobal.CATSerialStopBits; FController.FLoadedGlobal.CATSerialParity := FCATLastGlobal.CATSerialParity; FController.FLoadedGlobal.CATTcpEnabled := FCATLastGlobal.CATTcpEnabled; FController.FLoadedGlobal.CATTcpPort := FCATLastGlobal.CATTcpPort; FCATAdapter.ApplySettings(FCATLastGlobal); FController.FSettings.SaveCATSettings(FCATLastGlobal); FController.SaveGlobalSettings; FController.FSettings.Save; end; end.