Files
ewsdr/MainForm.pas
T
ew8bakandClaude Fable 5 b3edf3a4ad fix(slices): XVTR-трансляция частот панов N
Частота пана хранится ВИДИМОЙ (как FVfoA/FCenterFreq) и транслируется в
IF через XvtrTranslate только на границе с железом (AddPanDDC,
SetPanDDCFreq, SetPanDDCRate, SetPanADC) — раньше DDC пана получал
видимую частоту сырьём, а drag водопада пана N клампился пределами АЦП
(~61 МГц) вместо диапазона трансвертера.

Новое: controller.VisibleFreqBounds (диапазон активного XVTR | пределы
железа) для UI-клампа драга; RefreshPanDDCs — на входе/выходе XVTR и
правке LO/LOError клампит видимые центры живых панов, перешивает
freq-слова DDC и прижимает выпавшие слайсы к краям полосы захвата.

Подтверждено юзером на трансвертере.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-12 16:02:54 +03:00

7529 lines
308 KiB
ObjectPascal
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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, BandPlanOverlay,
FlatButton, FlatSlider, FlatDropDown, AppTheme, Forms, Controls, Graphics, Dialogs,
StdCtrls, ExtCtrls, Buttons, Menus, Math, Types, SyncObjs,
OpenGLContextEx,
LCLIntf, LCLType, GraphType,
HPSDRProtocol, HPSDRNetwork,
WDSP, WDSPEngine, AudioOutput, AudioInput,
Settings,
WebServer, WebAdapter,
CATAdapter,
SpectrumView, SpectrumViewOpengl,
PanZoomBar, PanafallPanel,
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 = 12; // макс. слотов среди планов (Pluto VHF/UHF = 12, HF = 11)
MODE_COUNT = 11;
// Полосы/дефолты фильтров (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_DIGI_NAMES: array[0..FILT_COUNT-1] of string =
('3.0k','2.7k','2.4k','2.1k','1.8k','1.5k','1.2k','900','600','300');
FILT_FM_NAMES: array[0..1] of string = ('NFM', 'FM');
FILT_WFM_NAMES: array[0..FILT_WFM_COUNT-1] of string = ('200k', '160k', '120k');
// 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-кнопок (HF-план); RelabelBands переписывает под
// активный бэкенд. 12-й слот пуст — в HF-плане кнопка прячется.
BAND_NAMES: array[0..BAND_COUNT-1] of string = (
'160m','80m','60m','40m','30m','20m','17m','15m','12m','10m','6m','');
MODE_NAMES: array[0..MODE_COUNT-1] of string = (
'LSB','USB','DSB','CWL','CWU','FM','AM','SAM','DIGU','DIGL','WFM');
MODE_ROWS = 3; // 4 колонки; последний ряд неполный
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 устройства для подключения
// Флаг главного (слайс A): создаётся здесь (проводка событий), живёт в
// раскладке/диспетчере FPan (AttachMainFlag). Флаги B+ — целиком в FPan.
FVfoOverlay: TVfoOverlay;
FActiveSliceId: Integer; // выбранный активный слайс (0 = главный)
FSliceDrag: Boolean; // тащим маркер несущей слайса по спектру
FSliceDragId: Integer; // какой слайс тащим
FSampleRateOverlay: TSampleRateOverlay; // оверлей span/hide слева вверху спектра
FBandPlanOverlay: TBandPlanOverlay; // полоска бэндплана QO-100 внизу спектра
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 ----
// Панадаптер (спектр+линейка+сплиттер+водопад+пан/зум) — composite-объект
// (Этап 3.0 мультислайсов). FSpecView и Pb*-поля ниже — алиасы его членов
// (исторические имена; постепенная миграция на FPan.*). Размеры областей —
// FPan.SpectrumWidth/SpectrumHeight/WaterfallHeight.
FPan: TPanafallPanel;
// ---- Стек панов (этап 3.3): FPans[0] = FPan, FPans[N>0] — доп. паны на
// своих DDC (nil = не создан). Сплиттер FPanSplitters[i] стоит НАД паном i.
FPans: array[0..MAX_PANS-1] of TPanafallPanel;
FPanSplitters: array[1..MAX_PANS-1] of TPanel;
FPanShare: array[0..MAX_PANS-1] of Double; // доля высоты стека
FPanSpecDirty: array[0..MAX_PANS-1] of Boolean; // кадр пана готов к рендеру
FPanWfDirty: array[0..MAX_PANS-1] of Boolean;
FPanCbLock: SyncObjs.TCriticalSection; // жизнь FPans[i] vs пиксельные колбэки
// Мышь на пане N>0: drag слайса или drag центра пана (ретюн DDC).
FPanSliceDrag: Boolean;
FPanSliceDragId: Integer;
FPanCenterDrag: Boolean;
FPanCenterMoved: Boolean; // был реальный сдвиг (иначе up = клик-тюн)
FPanCenterDragX0: Integer;
FPanCenterDragF0: Double;
FPanMousePan: TPanafallPanel; // пан, в котором идёт drag
// Сплиттер между панами (индекс пана, чей верхний сплиттер тащим; 0 = нет).
// Во время drag сплиттер двигается «призраком» (клампы в FPanSplitMin/MaxTop),
// пересчёт долей — по отпусканию.
FPanSplitDrag: Integer;
FPanSplitY0: Integer;
FPanSplitTop0: Integer;
FPanSplitMinTop: Integer;
FPanSplitMaxTop: Integer;
// Грид-раскладка: пан 0 сверху во всю ширину, доп. паны колонками снизу.
// Активна при FPanGridMode и >=2 доп. панах в стеке (иначе обычный стек).
FPanGridMode: Boolean;
FPanGridRowShare: Double; // доля высоты верхнего ряда (пан 0), 0.15..0.85
FPanColShare: array[1..MAX_PANS-1] of Double; // доли ширины колонок
FPanVSplitters: array[1..MAX_PANS-1] of TPanel; // слева от колонки пана i
// Drag вертикального сплиттера (индекс пана справа; 0 = нет).
FPanVSplitDrag: Integer;
FPanVSplitX0: Integer;
FPanVSplitLeft0: Integer;
FPanVSplitMinLeft: Integer;
FPanVSplitMaxLeft: Integer;
FPanWideH: Integer; // wideband-высота последнего LayoutPanStack (для PanMinHeight)
FPendingPanClose: Integer; // «×» шапки: PanId к закрытию в тике (0 = нет)
// Меню rate пана («NNN kHz» в шапке панов N; создаётся лениво).
FPanRateMenu: TPopupMenu;
FPanRateMenuPanId: Integer;
// Pop-out (3.5): пан N в отдельном OS-окне. nil = пан в стеке.
FPanFloatForm: array[1..MAX_PANS-1] of TForm;
FSpecView: TSpectrumView;
FWidebandView: TWidebandView;
FUseOpenGLSpectrum: Boolean;
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-демону.
FAgcLineCounter: Integer;
// --- 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;
BtnRxMute: TFlatButton; // QO-100 self-monitor: RX-аудио на TX (видим только Pluto+full-duplex)
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;
// Контролы панадаптера — алиасы членов FPan (владелец FPan; см. выше).
PbSpectrum: TControl;
PbWideband: TControl;
PbWidebandRuler: TPaintBox;
PbRuler: TPaintBox; // полоса частотных меток между спектром и водопадом
PanelSplitter: TPanel; // перетаскиваемый разделитель спектр/водопад
PbWaterfall: TControl;
// ---- Панель пана/зума под водопадом ----
PbPanZoom: TPaintBox; // полоса-ползунок пана зума
FPanZoomBar: TPanZoomBar;
BtnZoomIn: TFlatButton;
BtnZoomDef: TFlatButton;
BtnZoomOut: TFlatButton;
// ---- 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 BtnRxMuteClick(Sender: TObject);
procedure UpdateRxMuteButton; // видимость (Pluto+full-duplex) + стиль кнопки RX MUTE
procedure UpdateBandPlanOverlay; // вкл/выкл полоски бэндплана QO-100 по InQO100
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);
// ---- Пан/зум (мышь полосы/сплиттера — внутри TPanafallPanel) ----
function PanOfZoomButton(Sender: TObject): TPanafallPanel;
procedure OnPanZoomWindow(Sender: TObject; AZoom, APan: Double);
procedure BtnZoomInClick(Sender: TObject);
procedure BtnZoomDefClick(Sender: TObject);
procedure BtnZoomOutClick(Sender: TObject);
procedure UpdatePanZoomBar;
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;
const SerAndromeda: array of Boolean;
TcpEnabled: Boolean; TcpPort: Integer);
procedure OnAlexSettingsChange(const A: TAlexSettings);
procedure OnXvtrSettingsChange(const X: TXvtrSettings);
procedure PushXvtrToWeb;
procedure PushRatePresetsToWeb; // backend sample-rate пресеты → web
procedure PushBandsToWeb; // backend band-план → web
// 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);
// Семантические обработчики событий флагов (роутинг по FPan.CurSliceId).
// Коллекция/раскладка/диспетчер флагов — в TPanafallPanel.
procedure OnModeFilterSelect(Mode: Integer; FilterBW: Integer);
procedure OnVfoOverlayDSPChange(NRMode, NBMode: Integer; SNBOn, ANFOn: Boolean);
procedure OnVfoOverlaySquelchChange(SQLOn: Boolean; SQLLevel: Integer);
procedure OnVfoOverlayAGCChange(AGCMode: Integer);
procedure OnVfoOverlayVolume(Vol: Integer);
procedure OnVfoOverlayMute(Sender: TObject);
procedure OnVfoOverlayAudioDevice(DevIndex: Integer; const DevName: string);
procedure OnVfoOverlaySplit(Sender: TObject);
procedure OnVfoOverlayTxSelect(Sender: TObject);
procedure OnVfoOverlaySliceSelect(Sender: TObject); // выбрать слайс активным
procedure OnVfoOverlayClose(Sender: TObject); // × — удалить слайс
procedure OnVfoOverlayInvalidate(Sender: TObject);
procedure OnPanSliceClosed(SliceId: Integer); // FPan закрыл слайс
// --- Мультислайсы (UI) ---
function CurSliceId: Integer; // SliceId обрабатываемого сейчас флага
procedure WireOverlayEvents(O: TVfoOverlay);
procedure AddSliceAtFreq(Hz: Double);
// --- Стек панов (этап 3.3) ---
function PanCount: Integer; // активных панов (>=1)
function PanFromSender(Sender: TObject): TPanafallPanel;
function PanOfOverlay(Sender: TObject): TPanafallPanel;
procedure PushFlagStateAllPans(SliceId: Integer);
procedure LayoutFlagsAllPans;
procedure OnAddPanClick(Sender: TObject); // «⊞» пана 0
procedure OnPanHeaderClose(Sender: TObject); // «×» шапки пана
procedure ClosePanadapter(PanId: Integer);
procedure DestroyAllExtraPans; // stop/выход
procedure UpdatePanHeaders; // текст+видимость шапок
procedure ApplyPanHeaderTheme(P: TPanafallPanel); // цвета шапки пана
procedure UpdateAddPanButton;
procedure OnPanRateClick(Sender: TObject); // «NNN kHz» шапки пана N
procedure PanRateMenuItemClick(Sender: TObject);
procedure OnPanADCClick(Sender: TObject); // бейдж «RX1/RX2» (3.4)
// Pop-out (3.5): «⧉» выносит пан в отдельное окно, [×] окна возвращает в стек.
procedure OnPanPopOutClick(Sender: TObject);
procedure PopOutPan(PanId: Integer);
procedure DockBackPan(PanId: Integer);
procedure PanFloatClose(Sender: TObject; var CloseAction: TCloseAction);
procedure PanFloatResize(Sender: TObject);
function PanFloating(PanId: Integer): Boolean;
procedure ResetPanGLCaches(P: TPanafallPanel); // после смены родителя
procedure AddSliceAtFreqPan(P: TPanafallPanel; Hz: Double);
procedure PanClickTune(P: TPanafallPanel; X, W: Integer); // клик по фону пана N
procedure PanViewWindow(P: TPanafallPanel; out VC, VS: Double);
procedure SyncPanZoomUI(P: TPanafallPanel); // вьюха+зум-бар ← контроллер
procedure RefreshPanZoom(P: TPanafallPanel); // + флаги/инвалидции
procedure SyncPanViews; // центр/span вьюх панов
procedure MarkPanDirty(P: TPanafallPanel); // репейнт пана N
// Мышь панов N>0 (у пана 0 семантика ниже, PbSpectrum*).
procedure PanNSpectrumMouseDown(Sender: TObject; Button: TMouseButton;
Shift: TShiftState; X, Y: Integer);
procedure PanNSpectrumMouseMove(Sender: TObject; Shift: TShiftState; X, Y: Integer);
procedure PanNSpectrumMouseUp(Sender: TObject; Button: TMouseButton;
Shift: TShiftState; X, Y: Integer);
procedure PanNSpectrumMouseLeave(Sender: TObject);
procedure PanNSpectrumDblClick(Sender: TObject);
// Пиксели панов из display-потока движка.
procedure OnPanSpectrumReady(PanId: Integer; const Pixels: array of Single;
Count: Integer);
procedure OnPanWaterfallReady(PanId: Integer; const Pixels: array of Single;
Count: Integer);
// Сплиттеры между панами.
procedure PanSplitMouseDown(Sender: TObject; Button: TMouseButton;
Shift: TShiftState; X, Y: Integer);
procedure PanSplitMouseMove(Sender: TObject; Shift: TShiftState; X, Y: Integer);
procedure OnPanGridToggle(Sender: TObject); // «▦/▤» в ряду зума пана 0
function PanGridActive: Boolean; // грид реально применён
procedure PanVSplitMouseDown(Sender: TObject; Button: TMouseButton;
Shift: TShiftState; X, Y: Integer);
procedure PanVSplitMouseMove(Sender: TObject; Shift: TShiftState; X, Y: Integer);
procedure PanVSplitMouseUp(Sender: TObject; Button: TMouseButton;
Shift: TShiftState; X, Y: Integer);
procedure PanSplitMouseUp(Sender: TObject; Button: TMouseButton;
Shift: TShiftState; X, Y: Integer);
procedure LayoutPanStack(AWideH: Integer); // регионы панов + Layout каждого
function PanMinHeight(PanId: Integer): Integer; // мин. высота региона пана
function Pan0HeaderH: Integer; // высота шапки пана 0 (0 без стека)
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);
public
// Методы для SettingsForm — немедленное применение настроек
procedure ApplyDisplayParams(FFTSize, WinType, SpecDet, SpecAvgMode: Integer;
SpecAvgTimeMS: Double);
procedure ApplyWaterfallParams(WfDet, WfAvgMode: Integer;
WfAvgTimeMS, WfHigh, WfLow, WfAGCOffset: Double);
procedure ApplyWfRenderParams(Palette: Integer; Gamma: Double;
ResLimit, SpeedDiv: Integer);
procedure ApplySpecMSAA(Samples: Integer);
function EffectiveWfInterval: Integer;
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;
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.OnBeacon := FWebAdapter.OnBeacon;
FWebServer.OnBeaconSeed := FWebAdapter.OnBeaconSeed;
FWebServer.OnRxGainMode := FWebAdapter.OnRxGainMode;
FWebServer.OnRxGain := FWebAdapter.OnRxGain;
FWebServer.OnRxMute := FWebAdapter.OnRxMute;
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
PushRatePresetsToWeb; // дефолтный бэкенд до connect; перезапишется на смене
PushBandsToWeb;
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);
WireOverlayEvents(FVfoOverlay);
FVfoOverlay.SetSliceInfo(0, 'A', True); // главный приёмник = слайс A
FVfoOverlay.Visible := False;
FActiveSliceId := 0;
FSliceDrag := False;
FSliceDragId := 0;
FPanelHidden := False;
FController.FShowSpectrum := True;
FController.FShowWaterfall := True;
FController.FShowWideband := False;
FController.FWidebandFill := False;
FController.FSpectrumFill := True;
FDisplayFPS := 60;
FFreqMhzDigits := 3;
// OpenGLContextEx (QOpenGLWidget-бэкенд) работает и в нативном Wayland,
// старая X11-заглушка больше не нужна
FUseOpenGLSpectrum := HasOpenGLSpectrumSwitch;
// Панадаптер: владеет view/зум-баром/контролами спектра; контролы создаст
// FPan.Build в BuildUI. Owner=nil — освобождаем сами в FormDestroy.
FPan := TPanafallPanel.Create(nil, FUseOpenGLSpectrum);
FSpecView := FPan.View; // алиас (историческое имя)
FPanZoomBar := FPan.ZoomBar; // алиас
// Флаги: контроллер, проводка событий новых флагов, обратные вызовы.
FPan.Controller := FController;
FPan.OnWireFlag := WireOverlayEvents;
FPan.OnSliceClosed := OnPanSliceClosed;
FPan.OnFlagsInvalidate := OnVfoOverlayInvalidate;
FPan.AttachMainFlag(FVfoOverlay); // внутри: FSpecView.VfoOverlay := флаг A
// Стек панов (этап 3.3): пан 0 — в общем массиве; «⊞» добавляет пан.
FPan.PanId := 0;
FPan.OnAddPan := OnAddPanClick;
FPan.OnADCClick := OnPanADCClick; // бейдж RX1/RX2 (3.4, платы с 2 АЦП)
FPans[0] := FPan;
FPanShare[0] := 1.0;
FPanGridMode := False;
FPanGridRowShare := 0.5; // грид: пан 0 — верхняя половина
FPanCbLock := SyncObjs.TCriticalSection.Create;
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.WfPalette := FController.FWfPalette;
FSpecView.WfGamma := FController.FWfGamma;
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 := EffectiveWfInterval;
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;
// Пиксели доп. панов (этап 3.3) — прямо в UI (display-поток; см. FPanCbLock).
FController.FDSPEngine.OnPanSpectrum := OnPanSpectrumReady;
FController.FDSPEngine.OnPanWaterfall := OnPanWaterfallReady;
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;
// Панадаптер освобождает view, зум-бар и свои контролы; алиасы обнуляем.
FSpecView := nil;
FPanZoomBar := nil;
PbSpectrum := nil;
PbRuler := nil;
PanelSplitter := nil;
PbWaterfall := nil;
PbPanZoom := nil;
DestroyAllExtraPans; // доп. паны (DDC/движок/панели) — до пана 0
FreeAndNil(FPan);
FPans[0] := nil;
FreeAndNil(FPanCbLock);
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 давал GL-кадр на КАЖДОЕ событие мыши (при
// драге рендер упирался в vsync и обходил FPS-гейт — на AMD APU это
// трещало в HDMI-звуке того же GPU). Кадр отрисует ближайший тик
// FSpectrumTimer по FSpectrumDirty. Линейка дешёвая — её сразу.
SyncSpecViewFreq;
FSpectrumDirty := True;
if PbRuler <> nil then PbRuler.Invalidate;
if FController.FLastVfoScrolled then
FWaterfallDirty := True;
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 шлёт много событий,
// кадр отрисует таймер (FPS-гейт, см. rfVfoA).
FSpecView.InvalidateRulerCache;
SyncSpecViewFreq;
FSpectrumDirty := True;
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; // кадр — по таймеру (FPS-гейт, см. rfVfoA)
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;
UpdateAddPanButton; // «⊞» доступна только в Run
ResizeSpectrumPanels;
end
else
begin
UninhibitSleep;
BtnStartStop.Caption := 'START';
StyleButton(BtnStartStop, False);
BtnMOX.Enabled := False;
// Доп. паны живут только в Run-сессии (их DDC уже погашены
// контроллером в StopRunning) — убираем и UI.
DestroyAllExtraPans;
UpdateAddPanButton;
ResizeSpectrumPanels;
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
// Только Pluto + full-duplex транспондер (QO-100) — beacon-lock завязан
// на дрейф LNB, в обычных диапазонах/трансвертерах смысла нет.
BtnBeacon.Visible := FController.InQO100;
UpdateBeaconButton;
end;
UpdateRxMuteButton; // RX MUTE — только Pluto + full-duplex (QO-100)
UpdateBandPlanOverlay; // полоска бэндплана QO-100
// 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.ActiveVolume; // RX или TX-монитор
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);
UpdateRxMuteButton; // активность RX MUTE зависит от DUP
// На передаче переключаем источник пикселей и сбрасываем буферы, чтобы
// старые 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;
rfRxMuteOnTx:
UpdateRxMuteButton; // QO-100 self-monitor toggle: подсветка кнопки RX MUTE
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(FPan.SpectrumWidth);
UpdatePanZoomBar;
end;
rfZoom:
begin
// Зум/пан изменились: окно просмотра, шаг линейки и полоса фильтра
// следуют за span → пересинк + полная перерисовка спектра и водопада.
FSpecView.InvalidateRulerCache;
SyncSpecViewFreq;
UpdatePanZoomBar;
FPan.LayoutFlags;
FSpecView.DrawSpectrum;
PbSpectrum.Invalidate;
FSpecView.DrawWaterfall;
if PbWaterfall <> nil then PbWaterfall.Invalidate;
if PbRuler <> nil then PbRuler.Invalidate;
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%
TrkVolume.Position := FController.ActiveVolume; // слайдер ← загруженная громкость (был баг: не синхронизировался)
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.WfPalette := FController.FWfPalette;
FSpecView.WfGamma := FController.FWfGamma;
FSpecView.WfFrameInterval := EffectiveWfInterval;
ApplySpecMSAA(FController.FSpecMSAA);
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;
// Если флаг A виден при ОТКРЫТОЙ панели (есть слайсы B+), держим его в
// синхроне с DSP/режимом/полосой/AGC, пришедшими с ЛЕВОЙ панели. При скрытой
// панели флаг правит себя сам (DoHit), синк не нужен. Только дискретные поля —
// SetState/SetDSPState безусловно инвалидируют (не звать на потоковых rfVfoA).
if Assigned(FVfoOverlay) and FVfoOverlay.Visible and (not FPanelHidden) and
(Field in [rfMode, rfFilter, rfNR, rfNB, rfSNB, rfANF, rfAGCMode,
rfFMSQ, rfFMSQLevel]) then
begin
FVfoOverlay.SetState(FController.FMode, FController.FFilterBW, FController.FVfoA, FController.FLastSMeter);
FVfoOverlay.SetDSPState(FController.FNRMode, FController.FNBMode, FController.FSNB, FController.FANF, FController.FAGCMode);
FVfoOverlay.SetSquelchState(FController.FFMSQOn, FController.FFMSQLevel);
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;
// Заголовок (16) + MODE_ROWS рядов с шагом 27 + нижний отступ.
MODE_PANEL_H = 16 + (MODE_ROWS - 1) * 27 + BTN_SM + 7;
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);
// Self-monitor RX-аудио на TX: рядом с DUP, col1. Видна на любом бэкенде,
// активна только в DUP (см. UpdateRxMuteButton). ON (подсвечена, умолч.) =
// RX-аудио глушится на TX; OFF = слышим свой downlink во время передачи.
BtnRxMute := MakeBtn(PanelTX, 'RX MUTE',
LeftPanelButtonLeft(LEFT_W, 4, 1), 44,
LeftPanelButtonWidth(LEFT_W, 4, 1), BTN_SM, BtnRxMuteClick);
StyleButton(BtnRxMute, FController.FRxMuteOnTx);
UpdateRxMuteButton; // видимость/активность по DUP
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;
// Пустой стартовый слот (12-й в HF-плане) прячем до RelabelBands.
if BAND_NAMES[i] = '' then B.Visible := False;
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 — MODE_ROWS рядов по 4 кнопки (последний неполный).
PanelMode := TPanel.Create(Self);
PanelMode.Parent := PanelLeft;
PanelMode.SetBounds(0, Y, LEFT_W, MODE_PANEL_H);
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, MODE_PANEL_H + 2);
// 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;
// Бэндплан QO-100 — полоска внизу спектра; видимость по InQO100.
FBandPlanOverlay := TBandPlanOverlay.Create(Self);
FBandPlanOverlay.SetView(FController.FCenterFreq, FController.FSpanHz);
FSpecView.BandPlanOverlay := FBandPlanOverlay;
// 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;
// ---- Панадаптер: контролы спектра/линейки/сплиттера/водопада/пан-зума ----
// Создаёт и раскладывает TPanafallPanel; семантика мыши остаётся здесь —
// обработчики подвешиваются ДО Build.
FPan.SpectrumMouseDown := PbSpectrumMouseDown;
FPan.SpectrumMouseMove := PbSpectrumMouseMove;
FPan.SpectrumMouseUp := PbSpectrumMouseUp;
FPan.SpectrumMouseLeave := PbSpectrumMouseLeave;
FPan.SpectrumDblClick := PbSpectrumDblClick;
FPan.WaterfallMouseDown := PbWaterfallMouseDown;
FPan.WaterfallMouseMove := PbWaterfallMouseMove;
FPan.WaterfallMouseUp := PbWaterfallMouseUp;
FPan.ZoomInClick := BtnZoomInClick;
FPan.ZoomDefClick := BtnZoomDefClick;
FPan.ZoomOutClick := BtnZoomOutClick;
FPan.GridClick := OnPanGridToggle; // «▦/▤» грид-раскладка панов
FPan.OnSplitterMoved := RightPanelResize;
FPan.Build(PanelRight, Max(1, FController.FSpecMSAA));
// Алиасы контролов (исторические имена; постепенная миграция на FPan.*)
PbSpectrum := FPan.PbSpectrum;
PbRuler := FPan.PbRuler;
PanelSplitter := FPan.Splitter;
PbWaterfall := FPan.PbWaterfall;
PbPanZoom := FPan.PbPanZoom;
BtnZoomIn := FPan.BtnZoomIn;
BtnZoomDef := FPan.BtnZoomDef;
BtnZoomOut := FPan.BtnZoomOut;
FPanZoomBar.OnZoomPan := OnPanZoomWindow;
// Initial layout
ResizeSpectrumPanels;
UpdatePanZoomBar;
end;
// ===========================================================================
// Resize handler (normal method instead of anonymous procedure)
// Сплиттер спектр/водопад живёт в TPanafallPanel; по OnSplitterMoved панель
// зовёт RightPanelResize → полный пересчёт.
// ===========================================================================
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
WB_H = 118;
var
RW, RH, WideH, WideSpecH: Integer;
RULER_H: Integer;
PANZOOM_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;
// Низ занят панелью пана/зума (позиционирует FPan.Layout той же формулой) —
// wideband-блок делит оставшуюся высоту.
PANZOOM_H := MulDiv(22, Screen.PixelsPerInch, 96);
if FController.FShowSpectrum or FController.FShowWaterfall then
RH := RH - PANZOOM_H;
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);
if PbWideband <> nil then
begin
// При стеке панов над wideband стоит шапка пана 0.
PbWideband.SetBounds(0, Pan0HeaderH, 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, Pan0HeaderH + 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;
// Стек панов: регионы по долям, каждый пан раскладывает свой стек
// спектр/линейка/сплиттер/водопад/пан-зум внутри своего региона.
LayoutPanStack(WideH);
PositionSampleRateOverlay;
end;
function TMainForm.Pan0HeaderH: Integer;
begin
if PanCount > 1 then
Result := MulDiv(20, Screen.PixelsPerInch, 96) // = HEADER_H панели
else
Result := 0;
end;
function TMainForm.PanGridActive: Boolean;
// Грид реально применён: включён юзером и в стеке >=2 доп. панов
// (с одним доп. паном грид совпадает со стеком).
var i, N: Integer;
begin
N := 0;
for i := 1 to MAX_PANS - 1 do
if (FPans[i] <> nil) and not PanFloating(i) then Inc(N);
Result := FPanGridMode and (N >= 2);
end;
function TMainForm.PanMinHeight(PanId: Integer): Integer;
// Мин. высота региона пана в стеке/гриде: ниже неё Layout панели клампит
// спектр/водопад к минимумам и виджеты вылезают за регион (паны наезжают).
begin
if FPans[PanId] = nil then Exit(0);
Result := FPans[PanId].MinLayoutHeight(
IfThen(PanId = 0, FPanWideH, 0),
FController.FShowSpectrum, FController.FShowWaterfall);
end;
procedure TMainForm.LayoutPanStack(AWideH: Integer);
const
PAN_SPLIT_H = 5;
var
ActiveIds: array[0..MAX_PANS-1] of Integer;
MinH: array[0..MAX_PANS-1] of Integer;
N, i, k, TotalH, TotalW, UsableH, UsableW, Y, X, Hi, Wi: Integer;
Row0H, RowTop, RowH, M, MinRest, RemainH, BotMin: Integer;
ShareSum: Double;
Grid: Boolean;
begin
// Плавающие (pop-out) паны в стеке не участвуют — у них своё окно.
N := 0;
for i := 0 to MAX_PANS - 1 do
if (FPans[i] <> nil) and not PanFloating(i) then
begin ActiveIds[N] := i; Inc(N); end;
if N = 0 then Exit;
FPanWideH := AWideH; // для PanMinHeight (в т.ч. из сплиттер-драга)
TotalH := PanelRight.ClientHeight;
TotalW := PanelRight.ClientWidth;
Grid := PanGridActive; // пан 0 + >=2 доп. панов колонками
// Тумблер «▦/▤» в ряду зума пана 0: есть смысл при >=2 доп. панах в стеке.
if FPans[0] <> nil then
begin
// Виден при >=2 доп. панах; при включённом гриде — всегда (выключить можно
// даже когда паны закрылись и раскладка сама откатилась в стек).
FPans[0].GridBtnEnabled := FPanGridMode or (N >= 3);
if FPans[0].BtnGrid <> nil then
begin
if FPanGridMode then FPans[0].BtnGrid.Caption := '▤'
else FPans[0].BtnGrid.Caption := '▦';
end;
end;
if not Grid then
begin
// ---- Вертикальный стек (как раньше) ----
for i := 1 to MAX_PANS - 1 do
if FPanVSplitters[i] <> nil then FPanVSplitters[i].Visible := False;
UsableH := TotalH - (N - 1) * PAN_SPLIT_H;
ShareSum := 0;
for k := 0 to N - 1 do ShareSum := ShareSum + FPanShare[ActiveIds[k]];
if ShareSum <= 0 then ShareSum := 1;
// Видимость шапки — по ОБЩЕМУ числу панов (включая плавающие), как
// Pan0HeaderH/UpdatePanHeaders: иначе при pop-out пана N шапка пана 0
// прячется, а wideband остаётся сдвинутым на Pan0HeaderH — спектр
// накрывает линейку частот wideband. Ставим ДО расчёта минимумов
// (MinLayoutHeight учитывает шапку).
MinRest := 0;
for k := 0 to N - 1 do
begin
i := ActiveIds[k];
FPans[i].HeaderVisible := PanCount > 1;
MinH[k] := PanMinHeight(i);
Inc(MinRest, MinH[k]);
end;
Y := 0;
RemainH := UsableH;
for k := 0 to N - 1 do
begin
i := ActiveIds[k];
Dec(MinRest, MinH[k]); // теперь = сумма минимумов панов ПОСЛЕ текущего
if k > 0 then
begin
FPanSplitters[i].Visible := True; // грид мог спрятать
FPanSplitters[i].SetBounds(0, Y, TotalW, PAN_SPLIT_H);
Inc(Y, PAN_SPLIT_H);
end;
if k = N - 1 then
Hi := RemainH // последний пан добирает остаток (округления долей)
else
begin
Hi := Round(UsableH * FPanShare[i] / ShareSum);
// Доли не смеют ужать пан ниже минимума содержимого (виджеты вылезут
// за регион и паны наедут друг на друга), а раздутый минимумом пан —
// отобрать у оставшихся их минимумы.
if Hi < MinH[k] then Hi := MinH[k];
if Hi > RemainH - MinRest then Hi := RemainH - MinRest;
if Hi < 1 then Hi := 1; // окно меньше суммы минимумов — деградируем без наезда выше
end;
Dec(RemainH, Hi);
FPans[i].StackTop := Y;
FPans[i].StackHeight := Hi;
FPans[i].StackLeft := 0;
FPans[i].StackWidth := 0; // вся ширина
if i = 0 then
FPans[i].Layout(AWideH, FController.FShowSpectrum, FController.FShowWaterfall)
else
FPans[i].Layout(0, FController.FShowSpectrum, FController.FShowWaterfall);
Inc(Y, Hi);
end;
Exit;
end;
// ---- Грид: пан 0 сверху во всю ширину, доп. паны колонками снизу ----
FPanGridRowShare := EnsureRange(FPanGridRowShare, 0.15, 0.85);
Row0H := Round((TotalH - PAN_SPLIT_H) * FPanGridRowShare);
// Кламп ряда минимумами содержимого (шапки — до расчёта минимумов):
// ни пан 0, ни колонки нижнего ряда не смеют стать ниже своих минимумов.
FPans[0].HeaderVisible := PanCount > 1;
BotMin := 0;
for k := 1 to N - 1 do
begin
i := ActiveIds[k];
FPans[i].HeaderVisible := True;
BotMin := Max(BotMin, PanMinHeight(i));
end;
if Row0H > TotalH - PAN_SPLIT_H - BotMin then
Row0H := TotalH - PAN_SPLIT_H - BotMin;
if Row0H < PanMinHeight(0) then Row0H := PanMinHeight(0);
FPans[0].StackTop := 0;
FPans[0].StackHeight := Row0H;
FPans[0].StackLeft := 0;
FPans[0].StackWidth := 0;
FPans[0].Layout(AWideH, FController.FShowSpectrum, FController.FShowWaterfall);
// Горизонтальный сплиттер ряда — сплиттер ПЕРВОЙ колонки (во всю ширину);
// сплиттеры остальных колонок прячем (между колонками — вертикальные).
M := N - 1; // колонок в нижнем ряду
FPanSplitters[ActiveIds[1]].Visible := True;
FPanSplitters[ActiveIds[1]].SetBounds(0, Row0H, TotalW, PAN_SPLIT_H);
for k := 2 to N - 1 do
FPanSplitters[ActiveIds[k]].Visible := False;
RowTop := Row0H + PAN_SPLIT_H;
RowH := TotalH - RowTop;
UsableW := TotalW - (M - 1) * PAN_SPLIT_H;
ShareSum := 0;
for k := 1 to N - 1 do ShareSum := ShareSum + FPanColShare[ActiveIds[k]];
if ShareSum <= 0 then ShareSum := 1;
X := 0;
for k := 1 to N - 1 do
begin
i := ActiveIds[k];
if k > 1 then
begin
FPanVSplitters[i].Visible := True;
FPanVSplitters[i].SetBounds(X, RowTop, PAN_SPLIT_H, RowH);
Inc(X, PAN_SPLIT_H);
end
else if FPanVSplitters[i] <> nil then
FPanVSplitters[i].Visible := False; // у первой колонки левого сплиттера нет
if k = N - 1 then
Wi := TotalW - X
else
Wi := Round(UsableW * FPanColShare[i] / ShareSum);
FPans[i].StackTop := RowTop;
FPans[i].StackHeight := RowH;
FPans[i].StackLeft := X;
FPans[i].StackWidth := Wi;
FPans[i].HeaderVisible := True;
FPans[i].Layout(0, FController.FShowSpectrum, FController.FShowWaterfall);
Inc(X, Wi);
end;
// Вертикальные сплиттеры панов вне нижнего ряда (плавающие) — спрятать.
for i := 1 to MAX_PANS - 1 do
if (FPanVSplitters[i] <> nil) and ((FPans[i] = nil) or PanFloating(i)) then
FPanVSplitters[i].Visible := False;
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 BtnZoomIn <> nil then StyleButton(BtnZoomIn, False);
if BtnZoomDef <> nil then StyleButton(BtnZoomDef, False);
if BtnZoomOut <> nil then StyleButton(BtnZoomOut, False);
// Стек панов: шапки/кнопки всех панелей (пан 0 — «⊞», паны N — «×»).
for i := 0 to MAX_PANS - 1 do
if FPans[i] <> nil then
begin
if FPans[i].BtnAddPan <> nil then StyleButton(FPans[i].BtnAddPan, False);
if FPans[i].BtnGrid <> nil then StyleButton(FPans[i].BtnGrid, False);
if FPans[i].BtnPopPan <> nil then StyleButton(FPans[i].BtnPopPan, False);
if FPans[i].BtnClosePan <> nil then StyleButton(FPans[i].BtnClosePan, False);
ApplyPanHeaderTheme(FPans[i]);
if i > 0 then
begin
FPans[i].SetFlagsTheme(T);
if FPanSplitters[i] <> nil then FPanSplitters[i].Color := T.Border;
if FPanVSplitters[i] <> nil then FPanVSplitters[i].Color := T.Border;
if FPanFloatForm[i] <> nil then FPanFloatForm[i].Color := T.BG;
// Зум-бар пана N (пан 0 — через алиас FPanZoomBar ниже).
if FPans[i].ZoomBar <> nil then FPans[i].ZoomBar.SetTheme(T);
if FPans[i].PbPanZoom <> nil then FPans[i].PbPanZoom.Color := T.SliderBG;
end;
end;
if FPanZoomBar <> nil then
begin
FPanZoomBar.SetTheme(T);
if PbPanZoom <> nil then PbPanZoom.Color := T.SliderBG;
end;
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);
// Флаги слайсов (оверлеи) — цвета кнопок из темы, как левая панель.
if FPan <> nil then FPan.SetFlagsTheme(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;
var ViewCenter, ViewSpan: Double;
begin
// Видимое окно с учётом зума (центр+span). Без зума == FCenterFreq/FSpanHz.
FController.GetViewWindow(ViewCenter, ViewSpan);
FSpecView.VfoA := FController.FVfoA;
FSpecView.VfoB := FController.FVfoB;
FSpecView.ActiveVfo := FController.FActiveVfo;
FSpecView.CenterFreq := ViewCenter;
FSpecView.SpanHz := ViewSpan;
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;
// Бэндплан QO-100 следует за видом спектра (downlink-домен). SetView сам
// отсекает неизменное → пересборки кэша на каждый вызов нет.
if Assigned(FBandPlanOverlay) then
FBandPlanOverlay.SetView(ViewCenter, ViewSpan);
if UpdateWidebandFrequencyView and FController.FShowWideband and (PbWideband <> nil) then
PbWideband.Invalidate;
UpdatePanZoomBar; // линейка пана следует за частотой/зумом (no-op если без изменений)
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;
I: Integer;
begin
// «×» шапки пана: закрытие отложено сюда (нельзя free панель из её кнопки).
if FPendingPanClose <> 0 then
begin
I := FPendingPanClose;
FPendingPanClose := 0;
ClosePanadapter(I);
end;
// Синхронизируем размеры если размер панели изменился (после ресайза).
// Важно: проверяем только видимые панели — когда спектр скрыт,
// PbSpectrum.Height может быть ненулевым при FPan.SpectrumHeight=0,
// что вызвало бы сброс буфера водопада на каждом тике.
if (FPan.SpectrumWidth = 0) or
(FController.FShowWideband and (PbWideband.Width <> FPan.SpectrumWidth)) or
(FController.FShowSpectrum and ((PbSpectrum.Width <> FPan.SpectrumWidth) or
(PbSpectrum.Height <> FPan.SpectrumHeight))) or
(FController.FShowWaterfall and not FController.FShowSpectrum and
((PbWaterfall.Width <> FPan.SpectrumWidth) or
(PbWaterfall.Height <> FPan.WaterfallHeight))) then
ResizeSpectrumPanels;
if FPan.SpectrumWidth <= 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(FPan.SpectrumWidth);
Inc(FAgcLineCounter);
if FAgcLineCounter >= 6 then
begin
FController.FDSPEngine.UpdateAGCLines(FPan.SpectrumWidth);
FSpecView.AGCThresh := FController.FDSPEngine.AGCThresh;
FSpecView.AGCHangLevel := FController.FDSPEngine.AGCHangLevel;
FAgcLineCounter := 0;
end;
end;
FSpecView.WDSPReady := FController.FWDSPReady;
// Флаги: метр/частота главного, позиции, троттленный S-метр слайсов.
FPan.TickFlags;
// Доп. паны: синк вьюх + тик флагов + рендер готовых кадров.
SyncPanViews;
for I := 1 to MAX_PANS - 1 do
if FPans[I] <> nil then
begin
FPans[I].TickFlags;
if FPanSpecDirty[I] then
begin
FPans[I].View.DrawSpectrum;
FPans[I].PbSpectrum.Invalidate;
if FPans[I].View.NeedsRulerRedraw then
FPans[I].PbRuler.Invalidate;
FPanSpecDirty[I] := False;
end;
if FPanWfDirty[I] then
begin
FPans[I].View.DrawWaterfall;
FPans[I].PbWaterfall.Invalidate;
FPanWfDirty[I] := False;
end;
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 <> FPan.SpectrumWidth then
begin
FSpecView.SetSpectrumBitmapSize(FPan.SpectrumWidth, FPan.SpectrumHeight);
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 := '';
// Temp X/YC: X — трансивер AD9361, Y — Zynq SoC/FPGA (xadc).
if (FController.FLastPlutoTempC > TELEMETRY_NONE)
and (FController.FLastPlutoSocTempC > TELEMETRY_NONE) then
S := Format('Temp %.0f/%.0fC', [FController.FLastPlutoTempC, FController.FLastPlutoSocTempC])
else if FController.FLastPlutoTempC > TELEMETRY_NONE then
S := Format('Temp %.0fC', [FController.FLastPlutoTempC])
else if FController.FLastPlutoSocTempC > TELEMETRY_NONE then
S := Format('Temp %.0fC', [FController.FLastPlutoSocTempC]);
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;
// Пиксели доп. панов (этап 3.3) — прямо в UI (display-поток; см. FPanCbLock).
FController.FDSPEngine.OnPanSpectrum := OnPanSpectrumReady;
FController.FDSPEngine.OnPanWaterfall := OnPanWaterfallReady;
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
begin
BtnBand[i].Caption := BandName(i, pl);
// Пустой слот плана (HF: 12-й) — прячем кнопку.
BtnBand[i].Visible := BtnBand[i].Caption <> '';
end;
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
// Единый источник — контроллер (caps бэкенда). UI больше не владеет списком.
FSampleRateOverlay.SetRatePresets(FController.SampleRatePresets);
FSampleRateOverlay.SetCurrentRate(FController.FSampleRate);
end;
PushRatePresetsToWeb; // тот же источник зеркалим в web-морду
PushBandsToWeb; // band-план бэкенда → web (HF ↔ VHF/UHF)
// Отдельный Pluto RF-AGC блок под AGC: показываем и сдвигаем панели ниже.
LayoutLeftPanel(Caps.HasHWGain);
if Caps.HasHWGain then UpdateRxGainUI;
UpdateRxMuteButton; // RX MUTE — видимость по Pluto + full-duplex (если QO-100 уже активен)
UpdateBandPlanOverlay; // полоска бэндплана QO-100
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, FPan.SpectrumWidth) 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;
if FController.FMode = MODE_WFM then
begin
// WFM: полосы вещательного канала — три кнопки в один ряд.
CloseCTCSSPopup;
if FController.FFilter > FILT_WFM_COUNT - 1 then FController.FFilter := FILT_WFM_DEF;
FController.FFilterBW := FILT_WFM_BW[FController.FFilter];
for i := 0 to FILT_WFM_COUNT - 1 do
begin
BtnFilter[i].Caption := FILT_WFM_NAMES[i];
BtnFilter[i].Left := LeftPanelButtonLeft(PanelFilter.Width, FILT_WFM_COUNT, i);
BtnFilter[i].Width := LeftPanelButtonWidth(PanelFilter.Width, FILT_WFM_COUNT, i);
BtnFilter[i].Top := 16;
BtnFilter[i].Visible := True;
StyleButton(BtnFilter[i], i = FController.FFilter);
end;
for i := FILT_WFM_COUNT to FILT_COUNT - 1 do BtnFilter[i].Visible := False;
PanelFilter.Height := BtnFilter[0].Top + BtnFilter[0].Height + 4;
RelayoutBelowBands;
Exit;
end;
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
MODE_LSB, MODE_USB:
begin
for i := 0 to FILT_COUNT-1 do Names[i] := FILT_SSB_NAMES[i];
DefIdx := FILT_SSB_DEF;
end;
MODE_DIGU, MODE_DIGL:
begin
for i := 0 to FILT_COUNT-1 do Names[i] := FILT_DIGI_NAMES[i];
DefIdx := FILT_DIGI_DEF;
end;
MODE_DSB:
begin
for i := 0 to FILT_COUNT-1 do Names[i] := FILT_DSB_NAMES[i];
DefIdx := FILT_DSB_DEF;
end;
MODE_CWL, MODE_CWU:
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
MODE_LSB, MODE_USB:
FController.FFilterBW := FILT_SSB_BW[DefIdx];
MODE_DIGU, MODE_DIGL:
FController.FFilterBW := FILT_DIGI_BW[DefIdx];
MODE_DSB:
FController.FFilterBW := FILT_DSB_BW[DefIdx];
MODE_CWL, MODE_CWU:
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;
procedure TMainForm.BtnRxMuteClick(Sender: TObject);
begin
FController.SetRxMuteOnTx(not FController.FRxMuteOnTx); // рендер через rfRxMuteOnTx
end;
procedure TMainForm.UpdateRxMuteButton;
// Кнопка RX MUTE видна на ЛЮБОМ бэкенде, но АКТИВНА только в DUP (self-monitor
// осмыслен лишь в full-duplex; без DUP звук на TX и так не проходит — движок
// гейтит по FKeepRXDuringTX=FDisplayDuplex). ON (подсвечена) = RX-аудио глушится
// на TX; OFF = слышим свой downlink во время передачи.
begin
if BtnRxMute = nil then Exit;
BtnRxMute.Visible := True;
BtnRxMute.Enabled := FController.DisplayDuplex;
StyleButton(BtnRxMute, FController.FRxMuteOnTx);
end;
procedure TMainForm.UpdateBandPlanOverlay;
// Полоска бэндплана видна только в QO-100 (Pluto + full-duplex транспондер).
begin
if FBandPlanOverlay = nil then Exit;
FBandPlanOverlay.SetEnabled(FController.InQO100);
FSpectrumDirty := True; // моментальная перерисовка (не ждём таймер)
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;
ViewCenter, ViewSpan: Double;
begin
if PanelWidth <= 0 then Exit;
if (FController.FMode = MODE_FM) and FController.FFMStepOn then
StepHz := FM_STEP_HZ[FController.FFMStepIdx]
else
StepHz := 100;
FController.GetViewWindow(ViewCenter, ViewSpan);
ClickFreq := Round(ViewCenter + (PixelX / PanelWidth - 0.5) * ViewSpan);
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;
ViewCenter, ViewSpan: Double;
begin
if PanelWidth <= 0 then Exit;
FController.GetViewWindow(ViewCenter, ViewSpan);
dPix := PixelX - FSpecDragX0;
dFreq := dPix / PanelWidth * ViewSpan;
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.UpdatePanZoomBar;
begin
if FPanZoomBar <> nil then
begin
// Полоса — линейка ПОЛНОГО диапазона (базовые центр/span), стекло-ползунок
// показывает видимое окно (zoom/pan).
FPanZoomBar.SetFreq(FController.FCenterFreq, FController.FSpanHz);
FPanZoomBar.SetState(FController.ZoomFactor, FController.PanSlider);
end;
end;
procedure TMainForm.OnPanZoomWindow(Sender: TObject; AZoom, APan: Double);
var i: Integer;
begin
// Чей бар? Паны N — свой зум (пан-локальный, без band-персиста);
// иначе пан 0 — контроллерный зум (rfZoom обновит UI).
for i := 1 to MAX_PANS - 1 do
if (FPans[i] <> nil) and (TObject(FPans[i].ZoomBar) = Sender) then
begin
FController.SetPanZoomPan(i, AZoom, APan);
RefreshPanZoom(FPans[i]);
Exit;
end;
// Окно-селектор задаёт и зум, и пан напрямую (без якоря на VFO).
FController.ApplyZoom(AZoom, APan);
end;
function TMainForm.PanOfZoomButton(Sender: TObject): TPanafallPanel;
// Зум-кнопки создаются MakeFlatBtn с Owner = контейнер (PanelRight/окно), а не
// панель — PanFromSender их НЕ находит (клик уходил в зум главного пана).
// Матчим по идентичности контролов.
var i: Integer;
begin
Result := nil;
for i := 0 to MAX_PANS - 1 do
if FPans[i] <> nil then
if (Sender = TObject(FPans[i].BtnZoomIn)) or
(Sender = TObject(FPans[i].BtnZoomDef)) or
(Sender = TObject(FPans[i].BtnZoomOut)) then
Exit(FPans[i]);
end;
procedure TMainForm.BtnZoomInClick(Sender: TObject);
var P: TPanafallPanel;
begin
P := PanOfZoomButton(Sender);
if (P <> nil) and (P.PanId > 0) then
begin
FController.PanZoomIn(P.PanId);
RefreshPanZoom(P);
end
else
FController.ZoomIn;
end;
procedure TMainForm.BtnZoomDefClick(Sender: TObject);
var P: TPanafallPanel;
begin
P := PanOfZoomButton(Sender);
if (P <> nil) and (P.PanId > 0) then
begin
FController.PanZoomDefault(P.PanId);
RefreshPanZoom(P);
end
else
FController.ZoomDefault;
end;
procedure TMainForm.BtnZoomOutClick(Sender: TObject);
var P: TPanafallPanel;
begin
P := PanOfZoomButton(Sender);
if (P <> nil) and (P.PanId > 0) then
begin
FController.PanZoomOut(P.PanId);
RefreshPanZoom(P);
end
else
FController.ZoomOut;
end;
// Спектр
procedure TMainForm.PbSpectrumMouseDown(Sender: TObject; Button: TMouseButton;
Shift: TShiftState; X, Y: Integer);
var VC, VS: Double;
begin
if FPan.DispatchFlagsMouseDown(Button, X, Y) then
begin
FPan.ProcessPendingSliceClose; // × мог пометить слайс к удалению
Exit;
end;
if Assigned(FSampleRateOverlay) and
FSampleRateOverlay.HandleMouseDown(Button, X, Y) then Exit;
// Ctrl+ЛКМ по спектру — добавить софт-слайс на этой частоте (в пределах захвата).
if (Button = mbLeft) and (ssCtrl in Shift) and (PbSpectrum.Width > 0) then
begin
FController.GetViewWindow(VC, VS);
AddSliceAtFreq(PixelToFreq(X, PbSpectrum.Width, VC, VS));
Exit;
end;
if Button = mbLeft then
begin
// Наведение маяка: ЛКМ указывает на маяк (после включения BEACON «вооружено»,
// либо Shift+ЛКМ в любой момент при включённом локе для перенаведения). Декодер
// наводится на эту частоту, дальше сам ведёт несущую. Не трогает VFO.
if FController.BeaconLockEnabled and (PbSpectrum.Width > 0) and
(FBeaconArming or (ssShift in Shift)) then
begin
FController.GetViewWindow(VC, VS);
FController.BeaconSeedAtHz(
PixelToFreq(X, PbSpectrum.Width, VC, VS));
FBeaconArming := False;
UpdateBeaconButton;
Exit;
end;
// ЛКМ по маркеру несущей слайса — начинаем его перетаскивание (перетюн).
if (PbSpectrum.Width > 0) then
begin
FSliceDragId := FPan.SliceAtPixel(X);
if FSliceDragId <> 0 then
begin
FSliceDrag := True;
FActiveSliceId := FSliceDragId;
PbSpectrum.Cursor := crSizeWE;
Exit;
end;
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);
var
VC, VS, NewHz, LoHz, HiHz: Double;
begin
// Перетаскивание маркера несущей слайса — перетюн в пределах захваченной полосы.
if FSliceDrag then
begin
if (ssLeft in Shift) and (PbSpectrum.Width > 0) then
begin
FController.GetViewWindow(VC, VS);
NewHz := PixelToFreq(X, PbSpectrum.Width, VC, VS);
FController.CapturedSpan(LoHz, HiHz);
if NewHz < LoHz then NewHz := LoHz;
if NewHz > HiHz then NewHz := HiHz;
FController.SetSliceTarget(FSliceDragId, NewHz);
FPan.PushSliceFlagState(FSliceDragId); // обновить частоту во флаге
FPan.LayoutFlags;
FSpectrumDirty := True; // кадр — по таймеру (FPS-гейт, как при drag VFO)
end;
Exit;
end;
if Assigned(FSampleRateOverlay) then
begin
FSampleRateOverlay.HandleMouseMove(X, Y);
end;
FPan.DispatchFlagsMouseMove(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 if FPan.SliceAtPixel(X) <> 0 then
PbSpectrum.Cursor := crSizeWE // над маркером слайса — его можно тащить
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 FSliceDrag then
begin
FSliceDrag := False;
FSliceDragId := 0;
PbSpectrum.Cursor := crDefault;
Exit;
end;
if FPan.DispatchFlagsMouseUp(X, Y) then
begin
FSpecDrag := False;
PbSpectrum.Cursor := crDefault;
Exit; // клик/drag по накладке — не пускаем в спектр
end;
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;
FPan.DispatchFlagsMouseLeave;
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;
end
else
PanelLeft.Width := FLeftPanelW; // восстанавливаем сохранённую ширину
// Видимость флага A решает общий инвариант (панель скрыта ИЛИ есть слайсы).
FPan.MainFlagPinned := FPanelHidden;
FPan.UpdateMainFlagVisibility;
if Assigned(FSampleRateOverlay) then
FSampleRateOverlay.SetPanelHidden(FPanelHidden);
ResizeSpectrumPanels;
ResizeSMeter;
end;
procedure TMainForm.PbSpectrumDblClick(Sender: TObject);
begin
// Двойной клик — ничего не делаем (оверлей всегда виден при скрытой панели)
end;
procedure TMainForm.OnModeFilterSelect(Mode: Integer; FilterBW: Integer);
var
Idx: Integer;
begin
// Событие пришло от флага слайса (B+) — маршрутизируем в slice-API, главный не трогаем.
if CurSliceId <> 0 then
begin
FController.SetSliceModeBW(CurSliceId, Mode, FilterBW);
PushFlagStateAllPans(CurSliceId);
LayoutFlagsAllPans;
Exit;
end;
FController.FMode := Mode;
FController.FFilterBW := FilterBW;
case Mode of
MODE_FM:
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;
MODE_WFM:
begin
FController.FFilter := FILT_WFM_DEF;
for Idx := 0 to FILT_WFM_COUNT - 1 do
if FILT_WFM_BW[Idx] = FilterBW then
begin
FController.FFilter := Idx;
Break;
end;
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);
FVfoOverlay.SetSquelchState(FController.FFMSQOn, FController.FFMSQLevel);
FPan.LayoutFlags; // перепозиционируем при смене LSB↔USB
end;
end;
procedure TMainForm.OnVfoOverlayDSPChange(NRMode, NBMode: Integer; SNBOn, ANFOn: Boolean);
begin
if CurSliceId <> 0 then
begin
FController.SetSliceDSP(CurSliceId, NRMode, NBMode, SNBOn, ANFOn);
Exit;
end;
// Команды контроллера применяют к DSP и через OnControllerState обновляют кнопки.
FController.SetNR(NRMode);
FController.SetNB(NBMode);
FController.SetSNB(SNBOn);
FController.SetANF(ANFOn);
end;
procedure TMainForm.OnVfoOverlaySquelchChange(SQLOn: Boolean; SQLLevel: Integer);
begin
if CurSliceId <> 0 then
begin
FController.SetSliceFMSquelch(CurSliceId, SQLOn, SQLLevel);
Exit;
end;
FController.SetFMSquelchLevel(SQLLevel);
FController.SetFMSquelch(SQLOn);
end;
procedure TMainForm.OnVfoOverlayAGCChange(AGCMode: Integer);
begin
if CurSliceId <> 0 then
begin
FController.SetSliceAGCMode(CurSliceId, TRadioController.AGCModeFromUI(AGCMode));
Exit;
end;
FController.SetAGCMode(AGCMode);
end;
procedure TMainForm.OnVfoOverlayVolume(Vol: Integer);
begin
if CurSliceId <> 0 then
begin
FController.SetSliceVolume(CurSliceId, Vol / 100.0);
Exit;
end;
FController.SetVolume(Vol);
end;
procedure TMainForm.OnVfoOverlayMute(Sender: TObject);
var S: TCtrlSlice;
begin
if CurSliceId <> 0 then
begin
if FController.GetSlice(CurSliceId, S) then
FController.SetSliceMute(CurSliceId, not S.Muted);
Exit;
end;
FController.ToggleMute;
end;
procedure TMainForm.OnVfoOverlaySplit(Sender: TObject);
begin
if CurSliceId <> 0 then Exit; // SPLIT — только главный (слайсы не передают)
FController.SetSplit(not FController.FSplitTxB);
FPan.PushMainFlagExtState;
end;
procedure TMainForm.OnVfoOverlayTxSelect(Sender: TObject);
begin
// TODO(слайсы): выбор TX-слайса при полноценной мультислайс-передаче.
// Пока передаёт только главный канал — клик по TX неглавного игнорируем.
if CurSliceId <> 0 then Exit;
FPan.PushMainFlagExtState;
end;
procedure TMainForm.OnVfoOverlayAudioDevice(DevIndex: Integer; const DevName: string);
begin
if CurSliceId <> 0 then
begin
FController.SetSliceDevice(CurSliceId, DevIndex, DevName);
PushFlagStateAllPans(CurSliceId); // обновить подсветку текущего устройства слайса
Exit;
end;
ApplyAudioDevice(DevIndex, DevName);
FPan.PushMainFlagAudioDevices; // обновить подсветку текущего устройства
end;
procedure TMainForm.OnVfoOverlayInvalidate(Sender: TObject);
var P: TPanafallPanel;
begin
// Флаг/панель доп. пана → грязним и репейнтим ЕГО канву, пан 0 не трогаем.
P := PanOfOverlay(Sender);
if (P <> nil) and (P <> FPan) then
begin
MarkPanDirty(P);
Exit;
end;
FSpectrumDirty := True;
if Assigned(FSpecView) then
begin
FSpecView.SpectrumDirty := True;
// Полная инвалидация кэша текстур оверлеев (band/samplerate/главный VFO) нужна
// ТОЛЬКО когда у главного оверлея изменился хром (CacheDirty). Если сдвинулся
// лишь S-метр (MeterDirty) — перезаливаем только текстуру главного флага, не
// дёргая band/samplerate/слайсы. Слайсы перезаливают свою текстуру сами.
if Sender = TObject(FVfoOverlay) then
begin
if FVfoOverlay.CacheDirty then
FSpecView.InvalidateOverlayCache
else
FSpecView.InvalidateVfoOverlay;
end;
end;
if Assigned(PbSpectrum) then
begin
PbSpectrum.Invalidate;
end;
end;
// ===========================================================================
// Мультислайсы (UI): коллекция флагов, роутинг событий по SliceId
// ===========================================================================
// SliceId флага, чьё событие сейчас обрабатывается (0 = главный приёмник / слайс A).
function TMainForm.CurSliceId: Integer;
// Диспетчер флагов синхронный — ненулевой CurSliceId сейчас максимум у одной
// панели (той, чей флаг обрабатывает событие).
var i: Integer;
begin
Result := 0;
for i := 0 to MAX_PANS - 1 do
if (FPans[i] <> nil) and (FPans[i].CurSliceId <> 0) then
Exit(FPans[i].CurSliceId);
end;
// Единая проводка событий флага (главного и слайсов) на общие обработчики.
procedure TMainForm.WireOverlayEvents(O: TVfoOverlay);
begin
O.OnSelect := OnModeFilterSelect;
O.OnDSPChange := OnVfoOverlayDSPChange;
O.OnSquelchChange := OnVfoOverlaySquelchChange;
O.OnAGCChange := OnVfoOverlayAGCChange;
O.OnVolume := OnVfoOverlayVolume;
O.OnMute := OnVfoOverlayMute;
O.OnAudioDevice := OnVfoOverlayAudioDevice;
O.OnSplit := OnVfoOverlaySplit;
O.OnTxSelect := OnVfoOverlayTxSelect;
O.OnSliceSelect := OnVfoOverlaySliceSelect;
O.OnClose := OnVfoOverlayClose;
O.OnInvalidate := OnVfoOverlayInvalidate;
// Актуальная тема (цвета кнопок флага = как на левой панели).
if FLightTheme then O.SetTheme(LightTheme) else O.SetTheme(DarkTheme);
end;
// Добавить софт-слайс на частоте Hz (режим/полоса копируются с главного).
procedure TMainForm.AddSliceAtFreq(Hz: Double);
var
Lo, Hi, Id: Integer;
begin
if not FController.SliceFitsCapture(Hz) then Exit; // вне захваченной полосы (см. Этап 3)
TRadioController.FilterEdgesFor(FController.FMode, FController.FFilterBW, Lo, Hi);
Id := FController.AddSlice(Hz, FController.FMode, Lo, Hi,
TRadioController.AGCModeFromUI(FController.FAGCMode), 0.7, -1, '');
if Id < 0 then Exit;
FPan.CreateSliceFlag(Id);
FActiveSliceId := Id;
end;
// ===========================================================================
// Стек панадаптеров (этап 3.3): доп. паны на своих DDC
// ===========================================================================
function TMainForm.PanCount: Integer;
var i: Integer;
begin
Result := 0;
for i := 0 to MAX_PANS - 1 do
if FPans[i] <> nil then Inc(Result);
end;
function TMainForm.PanFromSender(Sender: TObject): TPanafallPanel;
// Контролы панели создаются с Owner=панель (Build) — восстановление пана
// из Sender мышиного события.
begin
Result := nil;
if (Sender is TComponent) and (TComponent(Sender).Owner is TPanafallPanel) then
Result := TPanafallPanel(TComponent(Sender).Owner);
end;
function TMainForm.PanOfOverlay(Sender: TObject): TPanafallPanel;
// Панель, которой принадлежит источник события: сама панель (Sender=панель),
// её флаг (Owner=панель) или главный флаг/nil → пан 0.
begin
Result := FPan;
if Sender is TPanafallPanel then Exit(TPanafallPanel(Sender));
if (Sender is TComponent) and (TComponent(Sender).Owner is TPanafallPanel) then
Result := TPanafallPanel(TComponent(Sender).Owner);
end;
procedure TMainForm.PushFlagStateAllPans(SliceId: Integer);
// Флаг слайса живёт ровно на одной панели; у остальных PushSliceFlagState
// не найдёт флаг и выйдет.
var i: Integer;
begin
for i := 0 to MAX_PANS - 1 do
if FPans[i] <> nil then FPans[i].PushSliceFlagState(SliceId);
end;
procedure TMainForm.LayoutFlagsAllPans;
var i: Integer;
begin
for i := 0 to MAX_PANS - 1 do
if FPans[i] <> nil then FPans[i].LayoutFlags;
end;
procedure TMainForm.MarkPanDirty(P: TPanafallPanel);
begin
if P = nil then Exit;
if P = FPan then
begin
FSpectrumDirty := True;
if Assigned(FSpecView) then FSpecView.SpectrumDirty := True;
if Assigned(PbSpectrum) then PbSpectrum.Invalidate;
Exit;
end;
FPanSpecDirty[P.PanId] := True;
P.View.SpectrumDirty := True;
if P.PbSpectrum <> nil then P.PbSpectrum.Invalidate;
end;
procedure TMainForm.PanViewWindow(P: TPanafallPanel; out VC, VS: Double);
// Видимое окно доп. пана (с учётом его пан-зума).
begin
FController.GetPanViewWindow(P.PanId, VC, VS);
if VS <= 0 then VS := 384000;
end;
procedure TMainForm.SyncPanZoomUI(P: TPanafallPanel);
// Вьюха и зум-бар пана ← контроллер (после смены зума/центра/rate пана).
var VC, VS: Double;
begin
if (P = nil) or (P.PanId = 0) then Exit;
FController.GetPanViewWindow(P.PanId, VC, VS);
P.View.CenterFreq := VC;
P.View.SpanHz := VS;
P.ZoomBar.SetFreq(FController.PanDDCFreq(P.PanId),
FController.PanDDCRateKHz(P.PanId) * 1000.0);
P.ZoomBar.SetState(FController.PanZoomFactor(P.PanId),
FController.PanPanSlider(P.PanId));
// Инвалидация бара — внутри Set{Freq,State} и ТОЛЬКО при реальной смене:
// нас зовут из тика на каждый пан каждый кадр, безусловный Invalidate =
// полный софт-рендер бара + Qt-рекомпозит на частоте кадров (жор CPU).
end;
procedure TMainForm.RefreshPanZoom(P: TPanafallPanel);
// Полное обновление пана после смены зума: окно вьюхи, бар, флаги, спектр.
begin
if P = nil then Exit;
SyncPanZoomUI(P);
P.LayoutFlags;
MarkPanDirty(P);
if P.PbWaterfall <> nil then P.PbWaterfall.Invalidate;
if P.PbPanZoom <> nil then P.PbPanZoom.Invalidate; // событийный путь — можно
end;
procedure TMainForm.AddSliceAtFreqPan(P: TPanafallPanel; Hz: Double);
// Слайс в доп. пане (режим/полоса копируются с главного, звуковуха default).
var Lo, Hi, Id: Integer;
begin
if not FController.SliceFitsCapture(Hz, P.PanId) then Exit;
TRadioController.FilterEdgesFor(FController.FMode, FController.FFilterBW, Lo, Hi);
Id := FController.AddSlice(Hz, FController.FMode, Lo, Hi,
TRadioController.AGCModeFromUI(FController.FAGCMode), 0.7, -1, '',
P.PanId);
if Id < 0 then Exit;
P.CreateSliceFlag(Id);
FActiveSliceId := Id;
end;
procedure TMainForm.OnPanRateClick(Sender: TObject);
// Клик по «NNN kHz» в шапке пана N: меню с полной сеткой rate бэкенда
// (фильтр PanRateAllowed — кратные 48 кГц). Текущий rate отмечен галкой.
var
P: TPanafallPanel;
MI: TMenuItem;
i, kHz: Integer;
begin
if not (Sender is TPanafallPanel) then Exit;
P := TPanafallPanel(Sender);
if (P.PanId < 1) or not FController.PanDDCActive(P.PanId) then Exit;
if FPanRateMenu = nil then
FPanRateMenu := TPopupMenu.Create(Self);
FPanRateMenu.Items.Clear;
for i := 0 to High(FController.BackendCaps.RatePresets) do
begin
kHz := FController.BackendCaps.RatePresets[i] div 1000;
if not FController.PanRateAllowed(Word(kHz)) then Continue;
MI := TMenuItem.Create(FPanRateMenu);
MI.Caption := Format('%d kHz', [kHz]);
MI.Tag := kHz;
MI.Checked := kHz = FController.PanDDCRateKHz(P.PanId);
MI.OnClick := PanRateMenuItemClick;
FPanRateMenu.Items.Add(MI);
end;
if FPanRateMenu.Items.Count = 0 then Exit;
FPanRateMenuPanId := P.PanId;
FPanRateMenu.PopUp; // в позиции курсора
end;
procedure TMainForm.PanRateMenuItemClick(Sender: TObject);
var
P: TPanafallPanel;
PanId: Integer;
begin
PanId := FPanRateMenuPanId;
FPanRateMenuPanId := 0;
if (PanId < 1) or (PanId >= MAX_PANS) then Exit;
P := FPans[PanId];
if P = nil then Exit;
if not FController.SetPanDDCRate(PanId, Word(TMenuItem(Sender).Tag)) then Exit;
// Вьюха/шапка/флаги под новый span (слайсы могли клампнуться к краям).
SyncPanViews;
P.PushAllSliceFlagStates;
P.LayoutFlags;
MarkPanDirty(P);
end;
procedure TMainForm.OnPanADCClick(Sender: TObject);
// Бейдж «RX1/RX2»: тумблер ADC-источника пана (двух-АЦПшные платы, 3.4).
var
P: TPanafallPanel;
N: Integer;
begin
if not (Sender is TPanafallPanel) then Exit;
P := TPanafallPanel(Sender);
N := FController.BackendCaps.NumADCs;
if N < 2 then Exit;
if not FController.SetPanADC(P.PanId, (FController.PanADC(P.PanId) + 1) mod N) then
Exit;
UpdatePanHeaders;
end;
// ---- Pop-out (3.5): пан N в отдельном OS-окне ----
function TMainForm.PanFloating(PanId: Integer): Boolean;
begin
Result := (PanId >= 1) and (PanId < MAX_PANS) and (FPanFloatForm[PanId] <> nil);
end;
procedure TMainForm.ResetPanGLCaches(P: TPanafallPanel);
// Смена родителя пересоздаёт хэндлы GL-канв → старые контексты (и все
// текстуры) мертвы; кэши вьюхи обнуляем БЕЗ glDelete (как при смене MSAA).
begin
if FUseOpenGLSpectrum and (P.View is TSpectrumViewOpenGL) then
TSpectrumViewOpenGL(P.View).ResetGLCache;
end;
procedure TMainForm.OnPanPopOutClick(Sender: TObject);
begin
if Sender is TPanafallPanel then
PopOutPan(TPanafallPanel(Sender).PanId);
end;
procedure TMainForm.PopOutPan(PanId: Integer);
var
P: TPanafallPanel;
F: TForm;
T: TAppTheme;
begin
if (PanId < 1) or (PanId >= MAX_PANS) then Exit;
P := FPans[PanId];
if (P = nil) or PanFloating(PanId) then Exit;
if FLightTheme then T := LightTheme else T := DarkTheme;
F := TForm.CreateNew(Self);
F.Caption := Format('EWSDR · PAN %d', [PanId]);
F.Color := T.BG;
F.Font.Assign(Font); // ParentFont-наследники (лейблы шапки) берут шрифт
// формы — без этого текст дефолтно-тёмный на тёмном
F.Position := poDefault;
F.Width := PanelRight.ClientWidth;
F.Height := Max(300, P.StackHeight);
F.Tag := PanId;
F.OnClose := PanFloatClose;
F.OnResize := PanFloatResize;
F.OnMouseWheel := FormMouseWheel; // колесо над флагами/фоном как в стеке
FPanFloatForm[PanId] := F;
P.ReparentTo(F);
ResetPanGLCaches(P);
P.StackTop := 0;
P.StackHeight := 0; // 0/0 = весь родитель (окно)
P.StackLeft := 0;
P.StackWidth := 0;
if FPanSplitters[PanId] <> nil then FPanSplitters[PanId].Visible := False;
if FPanVSplitters[PanId] <> nil then FPanVSplitters[PanId].Visible := False;
F.Show;
UpdatePanHeaders;
ResizeSpectrumPanels; // пере-стек оставшихся в главном окне
P.Layout(0, FController.FShowSpectrum, FController.FShowWaterfall);
P.LayoutFlags;
MarkPanDirty(P);
end;
procedure TMainForm.DockBackPan(PanId: Integer);
var
P: TPanafallPanel;
F: TForm;
begin
P := FPans[PanId];
F := FPanFloatForm[PanId];
if F = nil then Exit;
FPanFloatForm[PanId] := nil;
if P <> nil then
begin
P.ReparentTo(PanelRight);
ResetPanGLCaches(P);
end;
if FPanSplitters[PanId] <> nil then FPanSplitters[PanId].Visible := True;
F.Release; // безопасно из событий самого окна
UpdatePanHeaders;
ResizeSpectrumPanels;
if P <> nil then
begin
P.LayoutFlags;
MarkPanDirty(P);
end;
end;
procedure TMainForm.PanFloatClose(Sender: TObject; var CloseAction: TCloseAction);
// [×] окна = вернуть пан в стек (не закрыть пан). Release — в DockBackPan.
begin
CloseAction := caNone;
DockBackPan(TForm(Sender).Tag);
end;
procedure TMainForm.PanFloatResize(Sender: TObject);
var P: TPanafallPanel; PanId: Integer;
begin
PanId := TForm(Sender).Tag;
if (PanId < 1) or (PanId >= MAX_PANS) then Exit;
P := FPans[PanId];
if (P = nil) or (FPanFloatForm[PanId] <> Sender) then Exit;
P.Layout(0, FController.FShowSpectrum, FController.FShowWaterfall);
P.LayoutFlags;
MarkPanDirty(P);
end;
procedure TMainForm.PanClickTune(P: TPanafallPanel; X, W: Integer);
// Клик по фону доп. пана = перестроить его слайс на частоту клика (аналог
// DoSpectrumClick главного: снап к 100 Гц). Цель — активный слайс, если он
// живёт в этом пане, иначе первый слайс пана; слайсов нет — создать в точке.
const
StepHz = 100;
var
VC, VS, LoHz, HiHz: Double;
Hz: Int64;
Id: Integer;
S: TCtrlSlice;
begin
PanViewWindow(P, VC, VS);
if (W <= 0) or (VS <= 0) then Exit;
Hz := Round(VC + (X / W - 0.5) * VS);
Hz := ((Hz + StepHz div 2) div StepHz) * StepHz;
FController.CapturedSpan(LoHz, HiHz, P.PanId);
if Hz < LoHz then Hz := Round(LoHz);
if Hz > HiHz then Hz := Round(HiHz);
if (FActiveSliceId <> 0) and FController.GetSlice(FActiveSliceId, S) and
(S.PanId = P.PanId) then
Id := FActiveSliceId
else
Id := P.FirstSliceId;
if Id = 0 then
begin
AddSliceAtFreqPan(P, Hz);
Exit;
end;
FController.SetSliceTarget(Id, Hz);
FActiveSliceId := Id;
P.PushSliceFlagState(Id);
P.LayoutFlags;
MarkPanDirty(P);
end;
procedure TMainForm.ApplyPanHeaderTheme(P: TPanafallPanel);
// Явные цвета шапки пана. Зовётся и из ApplyDarkTheme, и из UpdatePanHeaders:
// лейблы без явного Font.Color наследуют шрифт формы-родителя (ParentFont),
// и в pop-out окне текст становился дефолтно-тёмным на тёмном фоне.
var T: TAppTheme;
begin
if (P = nil) or (P.HeaderPanel = nil) then Exit;
if FLightTheme then T := LightTheme else T := DarkTheme;
P.HeaderPanel.Color := T.Panel;
P.HeaderLabel.Font.Color := T.Text;
// Rate-лейбл: кликабельный (паны N) подсвечиваем акцентом селектора.
if P.RateClickable then
P.HeaderRateLabel.Font.Color := T.BtnTextActive
else
P.HeaderRateLabel.Font.Color := T.Text;
// ADC-бейдж кликабелен на 2-АЦПшных платах — тоже акцент.
if P.ADCClickable then
P.HeaderADCLabel.Font.Color := T.BtnTextActive
else
P.HeaderADCLabel.Font.Color := T.Text;
end;
procedure TMainForm.UpdatePanHeaders;
// Текст/видимость шапок: у одного пана шапки нет, у стека — у всех;
// у плавающего (pop-out) пана шапка всегда (там его rate/ADC/×).
var
i, N, NADC: Integer;
P: TPanafallPanel;
begin
N := PanCount;
NADC := FController.BackendCaps.NumADCs;
for i := 0 to MAX_PANS - 1 do
begin
P := FPans[i];
if P = nil then Continue;
P.HeaderVisible := (N > 1) or ((i > 0) and PanFloating(i));
if P.BtnClosePan <> nil then P.BtnClosePan.Visible := (N > 1) and (i > 0);
if P.BtnPopPan <> nil then
P.BtnPopPan.Visible := (N > 1) and (i > 0) and not PanFloating(i);
if i = 0 then
begin
P.SetHeaderText(Format('PAN 0 · %.6f MHz ·',
[FController.FCenterFreq / 1e6]));
P.SetHeaderRate(Format('%d kHz', [FController.FSampleRate div 1000]));
end
else
begin
P.SetHeaderText(Format('PAN %d · %.6f MHz ·',
[i, FController.PanDDCFreq(i) / 1e6]));
P.SetHeaderRate(Format('%d kHz', [FController.PanDDCRateKHz(i)]));
end;
// Бейдж АЦП: только на двух-АЦПшных платах; клик = тумблер.
if NADC > 1 then
begin
P.SetHeaderADC(Format('RX%d', [FController.PanADC(i) + 1]));
P.ADCClickable := True;
end
else
begin
P.SetHeaderADC('');
P.ADCClickable := False;
end;
ApplyPanHeaderTheme(P); // клик-статусы могли смениться + свежесозданные паны
end;
end;
procedure TMainForm.UpdateAddPanButton;
var HasSlot: Boolean; i: Integer;
begin
HasSlot := False;
for i := 1 to MAX_PANS - 1 do
if FPans[i] = nil then begin HasSlot := True; Break; end;
FPan.AddPanEnabled := (FController.BackendCaps.MaxPans > 1) and
FController.FRunning and HasSlot;
end;
procedure TMainForm.OnAddPanClick(Sender: TObject);
// «⊞»: новый пан на своём DDC. Центр = частота активного VFO, rate 384к,
// ADC 0; в центре автоматически создаётся первый слайс.
var
PanId, i: Integer;
RateKHz: Word;
Freq: Double;
P: TPanafallPanel;
Split: TPanel;
begin
if not FController.FRunning then Exit;
PanId := -1;
for i := 1 to MAX_PANS - 1 do
if FPans[i] = nil then begin PanId := i; Break; end;
if PanId < 0 then Exit;
// Rate наследуется от главного пана с клампом сверху (768к — потолок доп.
// пана по Ethernet/CPU; 48/96/192к наследуются как есть). Не из сетки
// (напр. Pluto-подобные) — контроллер откатит на 384к. Дальше rate пана
// независим: селектор «NNN kHz» в шапке, за сменой rate главного НЕ следует.
RateKHz := FController.FSampleRate div 1000;
if RateKHz > 768 then RateKHz := 768;
Freq := ActiveVfoFreq;
if not FController.AddPanDDC(PanId, Freq, RateKHz, 0) then Exit;
P := TPanafallPanel.Create(nil, FUseOpenGLSpectrum);
P.PanId := PanId;
P.Controller := FController;
P.ShowZoomRow := True; // пан-зум пана: свой бар/кнопки (диспетчер по Sender)
P.ZoomInClick := BtnZoomInClick; // до Build — кнопки создаются в Build
P.ZoomDefClick := BtnZoomDefClick;
P.ZoomOutClick := BtnZoomOutClick;
P.OnWireFlag := WireOverlayEvents;
P.OnSliceClosed := OnPanSliceClosed;
P.OnFlagsInvalidate := OnVfoOverlayInvalidate;
P.OnPanClose := OnPanHeaderClose;
P.OnSplitterMoved := RightPanelResize; // ratio обновлён → полный re-layout
P.OnRateClick := OnPanRateClick; // «NNN kHz» в шапке → меню rate
P.RateClickable := True;
P.OnADCClick := OnPanADCClick; // «RX1/RX2» тумблер АЦП (3.4)
P.OnPanPopOut := OnPanPopOutClick; // «⧉» в отдельное окно (3.5)
P.SpectrumMouseDown := PanNSpectrumMouseDown;
P.SpectrumMouseMove := PanNSpectrumMouseMove;
P.SpectrumMouseUp := PanNSpectrumMouseUp;
P.SpectrumMouseLeave := PanNSpectrumMouseLeave;
P.SpectrumDblClick := PanNSpectrumDblClick;
P.WaterfallMouseDown := PanNSpectrumMouseDown; // X→частота одинаково
P.WaterfallMouseMove := PanNSpectrumMouseMove;
P.WaterfallMouseUp := PanNSpectrumMouseUp;
P.Build(PanelRight, Max(1, FController.FSpecMSAA));
P.ZoomBar.OnZoomPan := OnPanZoomWindow; // drag окна-селектора
P.ZoomBar.SetFreq(Freq, FController.PanDDCRateKHz(PanId) * 1000.0);
P.ZoomBar.SetState(0.0, 0.5);
// Параметры отображения — с главного view (пан-персональные — позже).
P.View.WfAGCEnabled := FController.FWfAGCEnabled;
P.View.WfNFEnabled := FController.FWfNFEnabled;
P.View.WfManualHigh := FController.FWfManualHigh;
P.View.WfManualLow := FController.FWfManualLow;
P.View.WfAGCOffset := FController.FWfAGCOffset;
P.View.WfPalette := FController.FWfPalette;
P.View.WfGamma := FController.FWfGamma;
P.View.SpecRefLevel := FController.FSpecRefLevel;
P.View.SpecRange := FController.FSpecRange;
P.View.SpecGridStep := FController.FSpecGridStep;
P.View.WfFrameInterval := EffectiveWfInterval;
P.View.PAMaxPower := FController.FPAMaxPower;
P.View.CenterFreq := Freq;
P.View.SpanHz := FController.PanDDCRateKHz(PanId) * 1000.0;
// VFO/TX-маркеры главного на доп. пане не рисуем — уводим за пределы окна.
P.View.VfoA := -1e15;
P.View.VfoB := -1e15;
P.View.TXFreq := -1e15;
// Сплиттер над паном.
Split := TPanel.Create(Self);
Split.Parent := PanelRight;
Split.BevelOuter := bvNone;
Split.Color := TColor($00303030);
Split.Cursor := crVSplit;
Split.Tag := PanId;
Split.OnMouseDown := PanSplitMouseDown;
Split.OnMouseMove := PanSplitMouseMove;
Split.OnMouseUp := PanSplitMouseUp;
FPanSplitters[PanId] := Split;
// Вертикальный сплиттер слева от колонки пана (грид-раскладка).
Split := TPanel.Create(Self);
Split.Parent := PanelRight;
Split.BevelOuter := bvNone;
Split.Color := TColor($00303030);
Split.Cursor := crHSplit;
Split.Tag := PanId;
Split.Visible := False; // включает LayoutPanStack в грид-режиме
Split.OnMouseDown := PanVSplitMouseDown;
Split.OnMouseMove := PanVSplitMouseMove;
Split.OnMouseUp := PanVSplitMouseUp;
FPanVSplitters[PanId] := Split;
FPanColShare[PanId] := 1.0;
// Публикуем пан для пиксельных колбэков (display-поток) и делим высоту поровну.
FPanCbLock.Enter;
try
FPans[PanId] := P;
finally
FPanCbLock.Leave;
end;
for i := 0 to MAX_PANS - 1 do
if FPans[i] <> nil then FPanShare[i] := 1.0 / PanCount;
AddSliceAtFreqPan(P, Freq);
ApplyDarkTheme; // темизация шапки/кнопок нового пана
UpdatePanHeaders;
UpdateAddPanButton;
ResizeSpectrumPanels;
end;
procedure TMainForm.OnPanHeaderClose(Sender: TObject);
// «×» шапки: нельзя free панель из обработчика её же кнопки — откладываем
// в тик (FPendingPanClose).
begin
if Sender is TPanafallPanel then
FPendingPanClose := TPanafallPanel(Sender).PanId;
end;
procedure TMainForm.ClosePanadapter(PanId: Integer);
var
P: TPanafallPanel;
S: TCtrlSlice;
i: Integer;
begin
if (PanId < 1) or (PanId >= MAX_PANS) then Exit;
P := FPans[PanId];
if P = nil then Exit;
// Отписываем от пиксельных колбэков ДО удаления DDC/панели.
FPanCbLock.Enter;
try
FPans[PanId] := nil;
finally
FPanCbLock.Leave;
end;
// Слайсы пана (со звуковухами) + движок + DDC.
FController.RemovePanDDC(PanId);
if (FActiveSliceId <> 0) and not FController.GetSlice(FActiveSliceId, S) then
FActiveSliceId := 0;
FreeAndNil(FPanSplitters[PanId]);
FreeAndNil(FPanVSplitters[PanId]);
P.Free;
// Пан был в отдельном окне — окно больше не нужно. Release (не Free):
// сюда можно попасть из событий контролов этого же окна («×» шапки → тик).
if FPanFloatForm[PanId] <> nil then
begin
FPanFloatForm[PanId].Release;
FPanFloatForm[PanId] := nil;
end;
for i := 0 to MAX_PANS - 1 do
if FPans[i] <> nil then FPanShare[i] := 1.0 / PanCount;
UpdatePanHeaders;
UpdateAddPanButton;
ResizeSpectrumPanels;
end;
procedure TMainForm.DestroyAllExtraPans;
var i: Integer;
begin
for i := 1 to MAX_PANS - 1 do
if FPans[i] <> nil then ClosePanadapter(i);
end;
procedure TMainForm.SyncPanViews;
// Центр/span вьюх доп. панов (аналог SyncSpecViewFreq главного, но без
// VFO/TX-маркеров — они уведены за окно при создании).
var i: Integer; P: TPanafallPanel;
begin
for i := 1 to MAX_PANS - 1 do
begin
P := FPans[i];
if P = nil then Continue;
SyncPanZoomUI(P); // центр/span вьюхи (с пан-зумом) + зум-бар
P.View.WDSPReady := FController.FWDSPReady;
end;
// Шапки следуют за частотами (Caption-сеттер — no-op при том же тексте).
if PanCount > 1 then UpdatePanHeaders;
end;
procedure TMainForm.OnPanSpectrumReady(PanId: Integer;
const Pixels: array of Single; Count: Integer);
// Display-поток движка. Лок закрывает гонку с Close/AddPanadapter (UI).
begin
FPanCbLock.Enter;
try
if (PanId >= 1) and (PanId < MAX_PANS) and (FPans[PanId] <> nil) then
begin
FPans[PanId].View.SetSpectrumData(Pixels, Count);
FPanSpecDirty[PanId] := True;
end;
finally
FPanCbLock.Leave;
end;
end;
procedure TMainForm.OnPanWaterfallReady(PanId: Integer;
const Pixels: array of Single; Count: Integer);
begin
FPanCbLock.Enter;
try
if (PanId >= 1) and (PanId < MAX_PANS) and (FPans[PanId] <> nil) then
begin
FPans[PanId].View.SetWaterfallData(Pixels, Count);
if FPans[PanId].View.WaterfallDirty then FPanWfDirty[PanId] := True;
end;
finally
FPanCbLock.Leave;
end;
end;
// ---- Мышь доп. панов: тюн слайсов + drag центра (ретюн DDC). Семантика
// главного пана (VFO/CTUN/маяк/оверлеи) сюда не переносится. ----
procedure TMainForm.PanNSpectrumMouseDown(Sender: TObject; Button: TMouseButton;
Shift: TShiftState; X, Y: Integer);
var
P: TPanafallPanel;
VC, VS: Double;
Id, W: Integer;
IsSpec: Boolean;
begin
P := PanFromSender(Sender);
if P = nil then Exit;
// Флаги и маркеры несущих живут ТОЛЬКО на спектре: координаты водопада в
// диспетчер флагов отдавать нельзя (его прямоугольники — в системе спектра).
IsSpec := TControl(Sender) = P.PbSpectrum;
if IsSpec and P.DispatchFlagsMouseDown(Button, X, Y) then
begin
P.ProcessPendingSliceClose;
Exit;
end;
W := TControl(Sender).Width;
if W <= 0 then Exit;
if (Button = mbLeft) and (ssCtrl in Shift) then
begin
PanViewWindow(P, VC, VS);
AddSliceAtFreqPan(P, PixelToFreq(X, W, VC, VS));
Exit;
end;
if Button = mbLeft then
begin
if IsSpec then
begin
Id := P.SliceAtPixel(X);
if Id <> 0 then
begin
FPanSliceDrag := True;
FPanSliceDragId := Id;
FPanMousePan := P;
FActiveSliceId := Id;
TControl(Sender).Cursor := crSizeWE;
Exit;
end;
end;
// Пустое место: армируем drag ЦЕНТРА пана (ретюн его DDC; слайсы стоят на
// своих абсолютных частотах). Клик без движения = тюн слайса пана (MouseUp).
// Курсор не трогаем до реального сдвига.
FPanCenterDrag := True;
FPanCenterMoved := False;
FPanMousePan := P;
FPanCenterDragX0 := X;
FPanCenterDragF0 := FController.PanDDCFreq(P.PanId);
end;
end;
procedure TMainForm.PanNSpectrumMouseMove(Sender: TObject; Shift: TShiftState;
X, Y: Integer);
var
P: TPanafallPanel;
VC, VS, NewHz, LoHz, HiHz: Double;
W: Integer;
begin
P := PanFromSender(Sender);
if P = nil then Exit;
W := TControl(Sender).Width;
if FPanSliceDrag and (FPanMousePan = P) then
begin
if (ssLeft in Shift) and (W > 0) then
begin
PanViewWindow(P, VC, VS);
NewHz := PixelToFreq(X, W, VC, VS);
FController.CapturedSpan(LoHz, HiHz, P.PanId);
if NewHz < LoHz then NewHz := LoHz;
if NewHz > HiHz then NewHz := HiHz;
FController.SetSliceTarget(FPanSliceDragId, NewHz);
P.PushSliceFlagState(FPanSliceDragId);
P.LayoutFlags;
MarkPanDirty(P);
end;
Exit;
end;
if FPanCenterDrag and (FPanMousePan = P) then
begin
if (ssLeft in Shift) and (W > 0) then
begin
// Гистерезис 4px: до него это ещё «клик» (тюн по MouseUp), не drag.
if (not FPanCenterMoved) and (Abs(X - FPanCenterDragX0) < 4) then Exit;
if not FPanCenterMoved then
begin
FPanCenterMoved := True;
TControl(Sender).Cursor := crSizeWE;
end;
PanViewWindow(P, VC, VS);
NewHz := FPanCenterDragF0 - (X - FPanCenterDragX0) * VS / W;
// Кламп в ВИДИМЫХ частотах: при активном XVTR — его диапазон (иначе
// 1296 МГц «падала» на MaxFreqHz железа ~61 МГц), без XVTR — железо.
FController.VisibleFreqBounds(LoHz, HiHz);
NewHz := Max(LoHz, Min(HiHz, NewHz));
FController.SetPanDDCFreq(P.PanId, NewHz);
SyncPanZoomUI(P); // окно вьюхи с учётом зума + freq зум-бара
P.LayoutFlags;
UpdatePanHeaders;
MarkPanDirty(P);
end;
Exit;
end;
// Hover-логика (флаги, маркеры несущих) — только на спектре; на водопаде
// курсор остаётся обычным, как у главного пана.
if TControl(Sender) = P.PbSpectrum then
begin
P.DispatchFlagsMouseMove(X, Y);
if P.SliceAtPixel(X) <> 0 then
TControl(Sender).Cursor := crSizeWE
else
TControl(Sender).Cursor := crArrow; // crDefault «снимает» курсор — см. Build
end;
end;
procedure TMainForm.PanNSpectrumMouseUp(Sender: TObject; Button: TMouseButton;
Shift: TShiftState; X, Y: Integer);
var P: TPanafallPanel; W: Integer;
begin
P := PanFromSender(Sender);
if P = nil then Exit;
if FPanSliceDrag and (FPanMousePan = P) then
begin
FPanSliceDrag := False;
FPanMousePan := nil;
TControl(Sender).Cursor := crArrow;
Exit;
end;
if FPanCenterDrag and (FPanMousePan = P) then
begin
FPanCenterDrag := False;
FPanMousePan := nil;
TControl(Sender).Cursor := crArrow;
// Пресс без движения = клик: перестроить слайс пана на частоту клика.
W := TControl(Sender).Width;
if (Button = mbLeft) and (not FPanCenterMoved) and (W > 0) then
PanClickTune(P, X, W);
Exit;
end;
if (TControl(Sender) = P.PbSpectrum) and P.DispatchFlagsMouseUp(X, Y) then
P.ProcessPendingSliceClose;
end;
procedure TMainForm.PanNSpectrumMouseLeave(Sender: TObject);
var P: TPanafallPanel;
begin
P := PanFromSender(Sender);
if P <> nil then P.DispatchFlagsMouseLeave;
end;
procedure TMainForm.PanNSpectrumDblClick(Sender: TObject);
begin
// Дабл-клик на доп. пане пока без действия.
end;
// ---- Сплиттеры между панами: пересчёт долей по отпусканию мыши. ----
procedure TMainForm.PanSplitMouseDown(Sender: TObject; Button: TMouseButton;
Shift: TShiftState; X, Y: Integer);
const
PAN_SPLIT_H = 5;
var
BelowId, AboveId, i, TotalH, MinPx, BotMin: Integer;
Split: TPanel;
begin
if Button <> mbLeft then Exit;
Split := TPanel(Sender);
BelowId := Split.Tag;
TotalH := PanelRight.ClientHeight;
if TotalH <= 0 then Exit;
MinPx := Round(0.15 * TotalH);
if PanGridActive then
begin
// Грид: этот сплиттер — граница ряда пан 0 / нижний ряд колонок.
// Минимум — 15% ИЛИ минимум содержимого (что больше): ниже минимума
// виджеты пана вылезают за регион и паны наезжают друг на друга.
FPanSplitMinTop := Max(MinPx, PanMinHeight(0));
BotMin := MinPx; // минимум нижнего ряда — макс. по его колонкам
for i := 1 to MAX_PANS - 1 do
if (FPans[i] <> nil) and not PanFloating(i) then
BotMin := Max(BotMin, PanMinHeight(i));
FPanSplitMaxTop := TotalH - BotMin - PAN_SPLIT_H;
end
else
begin
// Пан над сплиттером — ближайший активный с меньшим PanId.
AboveId := -1;
for i := BelowId - 1 downto 0 do
if FPans[i] <> nil then begin AboveId := i; Break; end;
if (AboveId < 0) or (FPans[BelowId] = nil) then Exit;
// Границы призрака: каждому пану — минимум 15% стека ИЛИ минимум его
// содержимого (что больше; = кламп раздачи в LayoutPanStack).
FPanSplitMinTop := FPans[AboveId].StackTop
+ Max(MinPx, PanMinHeight(AboveId));
FPanSplitMaxTop := FPans[BelowId].StackTop + FPans[BelowId].StackHeight
- Max(MinPx, PanMinHeight(BelowId)) - PAN_SPLIT_H;
end;
if FPanSplitMaxTop < FPanSplitMinTop then FPanSplitMaxTop := FPanSplitMinTop;
FPanSplitDrag := BelowId;
FPanSplitY0 := Mouse.CursorPos.Y;
FPanSplitTop0 := Split.Top;
Split.Color := TColor($005050A0); // подсветка при захвате (как у внутрипанного)
end;
procedure TMainForm.PanSplitMouseMove(Sender: TObject; Shift: TShiftState;
X, Y: Integer);
var NewTop: Integer;
begin
// Живой призрак: двигаем сам сплиттер, пересчёт регионов — по MouseUp.
if (FPanSplitDrag = 0) or (TPanel(Sender).Tag <> FPanSplitDrag) then Exit;
NewTop := FPanSplitTop0 + (Mouse.CursorPos.Y - FPanSplitY0);
if NewTop < FPanSplitMinTop then NewTop := FPanSplitMinTop;
if NewTop > FPanSplitMaxTop then NewTop := FPanSplitMaxTop;
TPanel(Sender).Top := NewTop;
end;
procedure TMainForm.PanSplitMouseUp(Sender: TObject; Button: TMouseButton;
Shift: TShiftState; X, Y: Integer);
var
BelowId, AboveId, i, DeltaY, TotalH: Integer;
DShare, MinShare: Double;
begin
if FPanSplitDrag = 0 then Exit;
BelowId := FPanSplitDrag;
FPanSplitDrag := 0;
TPanel(Sender).Color := TColor($00303030);
TotalH := PanelRight.ClientHeight;
if TotalH <= 0 then Exit;
if PanGridActive then
begin
// Грид: сплиттер задаёт долю высоты верхнего ряда (пан 0) напрямую.
FPanGridRowShare := EnsureRange(TPanel(Sender).Top / TotalH, 0.15, 0.85);
ResizeSpectrumPanels;
Exit;
end;
// Пан над сплиттером — ближайший активный с меньшим PanId.
AboveId := -1;
for i := BelowId - 1 downto 0 do
if FPans[i] <> nil then begin AboveId := i; Break; end;
if (AboveId < 0) or (FPans[BelowId] = nil) then Exit;
// Дельта — по финальному положению призрака (он уже клампился в Move).
DeltaY := TPanel(Sender).Top - FPanSplitTop0;
DShare := DeltaY / TotalH;
MinShare := 0.15;
if FPanShare[AboveId] + DShare < MinShare then
DShare := MinShare - FPanShare[AboveId];
if FPanShare[BelowId] - DShare < MinShare then
DShare := FPanShare[BelowId] - MinShare;
FPanShare[AboveId] := FPanShare[AboveId] + DShare;
FPanShare[BelowId] := FPanShare[BelowId] - DShare;
ResizeSpectrumPanels;
end;
procedure TMainForm.OnPanGridToggle(Sender: TObject);
begin
FPanGridMode := not FPanGridMode;
ResizeSpectrumPanels;
end;
procedure TMainForm.PanVSplitMouseDown(Sender: TObject; Button: TMouseButton;
Shift: TShiftState; X, Y: Integer);
const
PAN_SPLIT_W = 5;
var
RightId, LeftId, i, TotalW, MinPx: Integer;
Split: TPanel;
begin
if (Button <> mbLeft) or not PanGridActive then Exit;
Split := TPanel(Sender);
RightId := Split.Tag;
// Колонка слева — ближайший застекованный доп. пан с меньшим PanId.
LeftId := -1;
for i := RightId - 1 downto 1 do
if (FPans[i] <> nil) and not PanFloating(i) then begin LeftId := i; Break; end;
if (LeftId < 0) or (FPans[RightId] = nil) then Exit;
TotalW := PanelRight.ClientWidth;
if TotalW <= 0 then Exit;
// Каждой колонке — минимум 15% ширины (= кламп долей в MouseUp).
MinPx := Round(0.15 * TotalW);
FPanVSplitMinLeft := FPans[LeftId].StackLeft + MinPx;
FPanVSplitMaxLeft := FPans[RightId].StackLeft + FPans[RightId].StackWidth
- MinPx - PAN_SPLIT_W;
FPanVSplitDrag := RightId;
FPanVSplitX0 := Mouse.CursorPos.X;
FPanVSplitLeft0 := Split.Left;
Split.Color := TColor($005050A0);
end;
procedure TMainForm.PanVSplitMouseMove(Sender: TObject; Shift: TShiftState;
X, Y: Integer);
var NewLeft: Integer;
begin
// Живой призрак: двигаем сам сплиттер, пересчёт долей — по MouseUp.
if (FPanVSplitDrag = 0) or (TPanel(Sender).Tag <> FPanVSplitDrag) then Exit;
NewLeft := FPanVSplitLeft0 + (Mouse.CursorPos.X - FPanVSplitX0);
if NewLeft < FPanVSplitMinLeft then NewLeft := FPanVSplitMinLeft;
if NewLeft > FPanVSplitMaxLeft then NewLeft := FPanVSplitMaxLeft;
TPanel(Sender).Left := NewLeft;
end;
procedure TMainForm.PanVSplitMouseUp(Sender: TObject; Button: TMouseButton;
Shift: TShiftState; X, Y: Integer);
var
RightId, LeftId, i, DeltaX, TotalW: Integer;
DShare, MinShare, ShareSum: Double;
begin
if FPanVSplitDrag = 0 then Exit;
RightId := FPanVSplitDrag;
FPanVSplitDrag := 0;
TPanel(Sender).Color := TColor($00303030);
LeftId := -1;
for i := RightId - 1 downto 1 do
if (FPans[i] <> nil) and not PanFloating(i) then begin LeftId := i; Break; end;
if (LeftId < 0) or (FPans[RightId] = nil) then Exit;
TotalW := PanelRight.ClientWidth;
if TotalW <= 0 then Exit;
// Доли колонок нормируются суммой (init 1.0 на пан) — пиксели → доли через неё.
ShareSum := 0;
for i := 1 to MAX_PANS - 1 do
if (FPans[i] <> nil) and not PanFloating(i) then
ShareSum := ShareSum + FPanColShare[i];
if ShareSum <= 0 then ShareSum := 1;
DeltaX := TPanel(Sender).Left - FPanVSplitLeft0;
DShare := DeltaX / TotalW * ShareSum;
MinShare := 0.15 * ShareSum;
if FPanColShare[LeftId] + DShare < MinShare then
DShare := MinShare - FPanColShare[LeftId];
if FPanColShare[RightId] - DShare < MinShare then
DShare := FPanColShare[RightId] - MinShare;
FPanColShare[LeftId] := FPanColShare[LeftId] + DShare;
FPanColShare[RightId] := FPanColShare[RightId] - DShare;
ResizeSpectrumPanels;
end;
procedure TMainForm.OnVfoOverlaySliceSelect(Sender: TObject);
begin
if Sender is TVfoOverlay then
FActiveSliceId := TVfoOverlay(Sender).SliceId;
end;
procedure TMainForm.OnVfoOverlayClose(Sender: TObject);
var P: TPanafallPanel;
begin
if not (Sender is TVfoOverlay) then Exit;
if TVfoOverlay(Sender).IsMain then Exit;
// Нельзя освобождать флаг внутри его же обработчика мыши — панель пометит
// и снимет его после возврата из диспетчера (ProcessPendingSliceClose).
P := PanOfOverlay(Sender);
if P <> nil then P.RequestSliceClose(TVfoOverlay(Sender).SliceId);
end;
// FPan закрыл слайс («×») — сбрасываем активный, если это был он.
procedure TMainForm.OnPanSliceClosed(SliceId: Integer);
begin
if FActiveSliceId = SliceId then FActiveSliceId := 0;
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;
begin
if FController.FNetwork.Connected then Exit; // уже подключены
// Восстанавливаем Kind/URI/Serial/BoardType/MAC из стора (discovered/saved) по
// IP или URI — как десктоп-путь и демон (ConnectTo(ResolveDevice(...))). Без этого
// saved Pluto коннектился с Kind=0 (=bkHPSDR) → UDP discovery → Hardware Timeout,
// хотя web оптимистично показывал "connected / Unknown Board".
Dev := FController.ResolveDevice(FWebSyncStr1);
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;
// Правка LO/LOError при живых панах: freq-слова их DDC — через свежую трансляцию.
FController.RefreshPanDDCs;
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.PushRatePresetsToWeb;
// Пресеты sample-rate бэкенда → web (HPSDR/Pluto разные). Единый источник —
// FController.SampleRatePresets, как и для desktop-оверлея.
begin
if FWebServer = nil then Exit;
FWebServer.SetRatePresets(FController.SampleRatePresets);
end;
procedure TMainForm.PushBandsToWeb;
// Band-план бэкенда → web (HF / VHF-UHF). Единый источник — FController.BandPlan,
// тот же BoardUtils-план, что и для desktop-кнопок (RelabelBands).
var
Plan: TBandPlanArray;
Names: array of string;
Freqs: array of Double;
i: Integer;
begin
if FWebServer = nil then Exit;
Plan := FController.BandPlan;
SetLength(Names, Length(Plan));
SetLength(Freqs, Length(Plan));
for i := 0 to High(Plan) do
begin
Names[i] := Plan[i].Name;
Freqs[i] := Plan[i].FreqHz;
end;
FWebServer.SetBands(Names, Freqs);
with FController.BackendCaps do FWebServer.SetFreqRange(MinFreqHz, MaxFreqHz);
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;
SliceId, i: Integer;
SLo, SHi, SNew: Double;
Sl: TCtrlSlice;
SlicePan: TPanafallPanel;
begin
// Над дисплеем VFO (A или B) колесо обрабатывает сам TFreqDisplay поразрядно
// (включая неактивный VFO). Не дублируем здесь, иначе менялись бы обе частоты:
// виджет крутил бы B, а этот хендлер — активный A.
if OverFreqDisp(FreqDispA) or OverFreqDisp(FreqDispB) then Exit;
// Колесо над маркером слайса (любого пана) — двигаем частоту ЭТОГО слайса.
SliceId := 0;
SlicePan := nil;
for i := 0 to MAX_PANS - 1 do
if FPans[i] <> nil then
begin
SliceId := FPans[i].SliceUnderCursor;
if SliceId <> 0 then begin SlicePan := FPans[i]; Break; end;
end;
if (SliceId <> 0) and FController.GetSlice(SliceId, Sl) then
begin
if (ssCtrl in Shift) and (ssShift in Shift) then Step := 1000000
else if ssCtrl in Shift then Step := 1000
else if ssShift in Shift then Step := 10
else Step := 100;
Delta := 1;
if WheelDelta < 0 then Delta := -1;
// Квантование к сетке шага + шаг в сторону колеса.
Base := (Round(Sl.TargetHz) div Step) * Step;
if Delta > 0 then SNew := Base + Step
else if Round(Sl.TargetHz) = Base then SNew := Base - Step
else SNew := Base;
FController.CapturedSpan(SLo, SHi, Sl.PanId); // полоса ЕГО пана
if SNew < SLo then SNew := SLo;
if SNew > SHi then SNew := SHi;
FController.SetSliceTarget(SliceId, SNew);
SlicePan.PushSliceFlagState(SliceId);
SlicePan.LayoutFlags;
MarkPanDirty(SlicePan);
FActiveSliceId := SliceId;
Handled := True;
Exit;
end;
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. Каждый кадр уходит во view —
// замедление/накопление (max-hold) делает сам TWaterfallView по
// WfFrameInterval; рендер дёргаем только когда view собрал строку.
begin
FSpecView.SetWaterfallData(Pixels, Count);
if FSpecView.WaterfallDirty then FWaterfallDirty := True;
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.ApplyWfRenderParams(Palette: Integer; Gamma: Double;
ResLimit, SpeedDiv: Integer);
begin
FController.FWfPalette := Palette;
FController.FWfGamma := Gamma;
FController.FDisplayPixels := ResLimit;
FController.FWfSpeedDiv := SpeedDiv;
FSpecView.WfPalette := Palette;
FSpecView.WfGamma := Gamma;
FSpecView.WfFrameInterval := EffectiveWfInterval;
if FController.FWDSPReady then
FController.FDSPEngine.SetDisplayResLimit(ResLimit);
FWaterfallDirty := True;
FSpectrumDirty := True;
end;
procedure TMainForm.ApplySpecMSAA(Samples: Integer);
begin
Samples := EnsureRange(Samples, 1, 8);
FController.FSpecMSAA := Samples;
if FUseOpenGLSpectrum and (PbSpectrum is TOpenGLControl) and
(TOpenGLControl(PbSpectrum).MultiSampling <> Cardinal(Samples)) then
begin
// Смена MSAA пересоздаёт хэндл → старый GL-контекст (и все текстуры)
// умирает; кэши view нужно обнулить БЕЗ glDelete.
TOpenGLControl(PbSpectrum).MultiSampling := Samples;
if FSpecView is TSpectrumViewOpenGL then
TSpectrumViewOpenGL(FSpecView).ResetGLCache;
FSpectrumDirty := True;
end;
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;
FWaterfallDirty := True;
FSpecView.WfFrameInterval := EffectiveWfInterval;
if FController.FWDSPReady then
FController.FDSPEngine.SetDisplayFPS(FPS);
end;
function TMainForm.EffectiveWfInterval: Integer;
// Делитель скорости водопада: пользовательский Speed (max-hold накопление
// в TWaterfallView); CPU-путь дополнительно не быстрее полукадровой частоты
// (исторически — экономия на полной перерисовке bitmap).
begin
Result := Max(1, FController.FWfSpeedDiv);
if not FUseOpenGLSpectrum then Result := Max(Result, 2);
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.OnWfRenderChange := ApplyWfRenderParams;
SF.OnSpecMSAAChange := ApplySpecMSAA;
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.CATSerialAndromeda,
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.LoadWfRender(FController.FWfPalette, FController.FWfGamma,
FController.FDisplayPixels, FController.FWfSpeedDiv);
SF.LoadSpecMSAA(FController.FSpecMSAA);
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;
const SerAndromeda: array of Boolean;
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];
if i <= High(SerAndromeda)then FCATLastGlobal.CATSerialAndromeda[i]:= SerAndromeda[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.CATSerialAndromeda := FCATLastGlobal.CATSerialAndromeda;
FController.FLoadedGlobal.CATTcpEnabled := FCATLastGlobal.CATTcpEnabled;
FController.FLoadedGlobal.CATTcpPort := FCATLastGlobal.CATTcpPort;
FCATAdapter.ApplySettings(FCATLastGlobal);
FController.FSettings.SaveCATSettings(FCATLastGlobal);
FController.SaveGlobalSettings;
FController.FSettings.Save;
end;
end.