unit MainForm; { OpenHPSDR Transceiver - Main Form Lazarus / FPC 3.2+ (нет inline var, нет анонимных процедур) ИСПРАВЛЕНИЯ: - (Web NR/NB/ANF fix) BtnNRClick, BtnNBClick, BtnANFClick переписаны без использования «Sender as TFlatButton» — теперь обращаются напрямую к BtnNR / BtnNB / BtnANF. Это устраняет EAccessViolation / EInvalidCast при вызове из SyncWebNR/SyncWebNB/SyncWebANF через TThread.Synchronize, где Sender передавался как nil. Исключение в UI-потоке приводило к разрыву WebSocket-соединения и «отключению» веб-клиента. - SyncWebNR / SyncWebNB / SyncWebANF: передают реальную кнопку (BtnNR / BtnNB / BtnANF) вместо nil — защита на случай изменения обработчиков. } {$IFDEF FPC} {$MODE Delphi} {$ENDIF} interface uses Classes, SysUtils, StrUtils, FreqDisplay, DeviceForm, VfoOverlay, FlatButton, WinFirewall, Forms, Controls, Graphics, Dialogs, StdCtrls, ExtCtrls, ComCtrls, Buttons, Menus, Math, Types, HPSDRProtocol, HPSDRNetwork, WDSPEngine, AudioOutput, IntfGraphics, FPImage, LCLIntf, LCLType, Settings, WebServer; const CLR_BG = TColor($00101010); CLR_PANEL = TColor($00181818); CLR_BORDER = TColor($00303030); CLR_FREQ = TColor($0000FF00); CLR_FREQ_DIM = TColor($00008000); CLR_AMBER = TColor($0000AAFF); CLR_ACTIVE = TColor($00003300); CLR_INACTIVE = TColor($00202020); CLR_TEXT = TColor($00CCCCCC); CLR_TEXTDIM = TColor($00666666); CLR_METER_ON = TColor($0000CC44); CLR_METER_OVR= TColor($000000CC); CLR_SPECTRUM = TColor($0044FF44); BAND_COUNT = 11; MODE_COUNT = 8; FILT_COUNT = 10; // Группы фильтров по типу модуляции // SSB: LSB/USB FILT_SSB_NAMES: array[0..FILT_COUNT-1] of string = ('5.0k','4.4k','3.8k','3.3k','2.9k','2.7k','2.4k','2.1k','1.8k','1.0k'); FILT_SSB_BW: array[0..FILT_COUNT-1] of Integer = (5000, 4400, 3800, 3300, 2900, 2700, 2400, 2100, 1800, 1000); FILT_SSB_DEF = 5; // 2.7k // CW: CWL/CWU FILT_CW_NAMES: array[0..FILT_COUNT-1] of string = ('1.0k','800','750','600','500','400','250','100','50','25'); FILT_CW_BW: array[0..FILT_COUNT-1] of Integer = (1000, 800, 750, 600, 500, 400, 250, 100, 50, 25); FILT_CW_DEF = 4; // 500 // AM/SAM/DSB FILT_AM_NAMES: array[0..FILT_COUNT-1] of string = ('16k','12k','10k','8k','6.6k','5.2k','4.0k','3.1k','2.9k','2.4k'); FILT_AM_BW: array[0..FILT_COUNT-1] of Integer = (16000, 12000, 10000, 8000, 6600, 5200, 4000, 3100, 2900, 2400); FILT_AM_DEF = 3; // 8k // FM FILT_FM_NAMES: array[0..FILT_COUNT-1] of string = ('20k','15k','12k','10k','8k','6k','5k','4k','3k','2k'); FILT_FM_BW: array[0..FILT_COUNT-1] of Integer = (20000, 15000, 12000, 10000, 8000, 6000, 5000, 4000, 3000, 2000); FILT_FM_DEF = 0; // 20k BAND_NAMES: array[0..BAND_COUNT-1] of string = ( '160m','80m','60m','40m','30m','20m','17m','15m','12m','10m','6m'); BAND_FREQ: array[0..BAND_COUNT-1] of Double = ( 1900000, 3750000, 5357000, 7100000, 10125000, 14200000, 18120000, 21200000, 24940000, 28500000, 50150000); MODE_NAMES: array[0..MODE_COUNT-1] of string = ( 'LSB','USB','DSB','CWL','CWU','FM','AM','SAM'); WFALL_PALETTE: array[0..255] of LongWord = ( $00000000, $00020000, $00050000, $00080000, $000A0000, $000D0000, $00100000, $00120000, $00150000, $00180000, $001A0000, $001D0000, $00200000, $00220000, $00250000, $00280000, $002A0000, $002D0000, $00300000, $00320000, $00350000, $00380000, $003A0000, $003D0000, $00400000, $00420000, $00450000, $00480000, $004A0000, $004D0000, $00500000, $00530000, $00560000, $005A0000, $005D0000, $00600000, $00640000, $00670000, $006A0000, $006E0000, $00710000, $00740000, $00780000, $007B0000, $007E0000, $00820000, $00850000, $00880000, $008C0000, $008F0000, $00920000, $00960000, $00990000, $009C0000, $00A00000, $00A30000, $00A60000, $00AA0000, $00AD0000, $00B00000, $00B40000, $00B60200, $00B90500, $00BB0700, $00BE0A00, $00C00C00, $00C30F00, $00C51100, $00C81400, $00CA1600, $00CD1900, $00CF1B00, $00D21E00, $00D42000, $00D72300, $00D92500, $00DC2800, $00DE2A00, $00E12D00, $00E32F00, $00E63200, $00E73700, $00E83C00, $00E94100, $00EB4600, $00EC4B00, $00ED5000, $00EE5500, $00F05A00, $00F15F00, $00F26400, $00F36900, $00F56E00, $00F67300, $00F77800, $00F87D00, $00FA8200, $00FB8700, $00FC8C00, $00FD9100, $00FF9600, $00FD9A00, $00FB9E00, $00F9A200, $00F8A600, $00F6AA00, $00F4AE00, $00F2B200, $00F1B600, $00EFBA00, $00EDBE00, $00EBC200, $00EAC600, $00E8CA00, $00E6CE00, $00E4D200, $00E3D600, $00E1DA00, $00DFDE00, $00DDE200, $00DCE600, $00D6E700, $00D0E800, $00CAE900, $00C4EB00, $00BEEC00, $00B8ED00, $00B2EE00, $00ACF000, $00A6F100, $00A0F200, $009AF300, $0094F500, $008EF600, $0088F700, $0082F800, $007CFA00, $0076FB00, $0070FC00, $006AFD00, $0064FF00, $005FFF04, $005AFF08, $0055FF0C, $0050FF10, $004BFF14, $0046FF18, $0041FF1C, $003CFF20, $0037FF24, $0032FF28, $002CFF2C, $0028FF30, $0023FF34, $001EFF38, $0019FF3C, $0014FF40, $000FFF44, $000AFF48, $0005FF4C, $0000FF50, $0000FF56, $0000FF5C, $0000FF62, $0000FF68, $0000FF6E, $0000FF74, $0000FF7A, $0000FF80, $0000FF86, $0000FF8C, $0000FF92, $0000FF98, $0000FF9E, $0000FFA4, $0000FFAA, $0000FFB0, $0000FFB6, $0000FFBC, $0000FFC2, $0000FFC8, $0000FDCA, $0000FBCD, $0000F9D0, $0000F8D3, $0000F6D5, $0000F4D8, $0000F2DB, $0000F1DE, $0000EFE0, $0000EDE3, $0000EBE6, $0000EAE9, $0000E8EB, $0000E6EE, $0000E4F1, $0000E3F4, $0000E1F6, $0000DFF9, $0000DDFC, $0000DCFF, $0000D7FF, $0000D2FF, $0000CDFF, $0000C8FF, $0000C3FF, $0000BEFF, $0000B9FF, $0000B4FF, $0000AFFF, $0000AAFF, $0000A5FF, $0000A0FF, $00009BFF, $000096FF, $000091FF, $00008CFF, $000087FF, $000082FF, $00007DFF, $000078FF, $000073FF, $00006FFF, $00006AFF, $000066FF, $000061FF, $00005DFF, $000058FF, $000054FF, $00004FFF, $00004BFF, $000046FF, $000042FF, $00003DFF, $000039FF, $000034FF, $000030FF, $00002BFF, $000027FF, $000022FF, $00001EFF, $00112DFF, $00223CFF, $00334BFF, $00445AFF, $005569FF, $006678FF, $007787FF, $008896FF, $0099A5FF, $00AAB4FF, $00BBC3FF, $00CCD2FF, $00DDE1FF, $00EEF0FF, $00FFFFFF ); type TDeviceItem = record Dev: THPSDRDevice; Display: string; end; // Синхронизирующий объект для OnDeviceFound TDeviceFoundSync = class private FForm: TObject; // TMainForm, через forward ref FDev: THPSDRDevice; FEntry: string; public constructor Create(AForm: TObject; const D: THPSDRDevice; const E: string); procedure Execute; end; // Синхронизирующий объект для HP Status TStatusUISync = class private FForm: TObject; FFwdW: Double; FSWRV: Double; FSupplyV: Double; FPLLLock: Boolean; public constructor Create(AForm: TObject; FW, SW, SV: Double; PLL: Boolean); procedure Execute; end; // Синхронизирующий объект для DDC IQ TDDCSeqSync = class private FForm: TObject; FDDCIdx: Integer; FSeq: LongWord; public constructor Create(AForm: TObject; Idx: Integer; Seq: LongWord); procedure Execute; end; { TMainForm } TMainForm = class(TForm) private // ---- Network ---- FNetwork: THPSDRNetwork; FDevices: array of TDeviceItem; FDeviceCount: Integer; FDeviceDialog: TDeviceDialog; FPendingIP: string; // IP устройства для подключения FVfoOverlay: TVfoOverlay; // накладка SmartSDR-стиль на спектре FPanelHidden: Boolean; // True = левая панель скрыта FPendingBoardType: Integer; // BoardType устройства для подключения // ---- DSP + Audio ---- FDSPEngine: TWDSPEngine; FAudioOut: TAudioOutput; FWDSPReady: Boolean; // True когда WDSP открыт и работает // ---- State ---- FVfoA: Double; FVfoB: Double; FActiveVfo: Integer; FMode: Integer; FFilter: Integer; FAGCMode: Integer; // 0=FAST 1=MED 2=SLOW 3=LONG 4=OFF FAGCTop: Integer; // AGC level dBm, −20..−120 FCTun: Boolean; // Center Tune: True=спектр стоит, маркер двигается FFilterBW: Integer; // текущая полоса фильтра в Гц // мышь на спектре/водопаде FSpecDrag: Boolean; FSpecDragX0: Integer; // X при нажатии FSpecDragFreq: Double; // FCenterFreq при нажатии FSpectrumDirty: Boolean; // таймер должен перерисовать спектр/водопад // Маркер правой кнопки: вертикальная линия на спектре и водопаде FMarkerActive: Boolean; // True = линия видима FMarkerX: Integer; // X в пикселях (относительно ширины панели) FDriveLevel: Byte; FRunning: Boolean; FTransmitting: Boolean; FMuted: Boolean; FVolume: Integer; FLastSMeter: Double; FSMeterPeak: Double; // верхняя граница светлой зоны FSMeterMin: Double; // нижняя граница светлой зоны FSMeterAvg: Double; // сглаженное среднее (EMA) FRXPacketCount: LongWord; // счётчик принятых IQ пакетов FRXStartTime: QWord; // GetTickCount64 момента нажатия START FRXLastPktTime: QWord; // GetTickCount64 последнего принятого пакета FActiveDDC: Integer; // DDC index для текущей платы (0 или 2) FLastDDCSeq: LongWord; // последний seq (пишется из сетевого потока) FLastDDCIndex: Integer; // последний DDC index FLastFwdW: Double; FLastSWR: Double; // ---- Spectrum ---- FSpectrumBuf: array[0..1023] of Single; FWfAvgBuf: array[0..1023] of Single; // EMA спектра для WF AGC/NF FSpectrumBitmap: TBitmap; // --- Waterfall display normalization --- FWfAGCEnabled: Boolean; // Waterfall AGC: авто подстройка верхней границы FWfNFEnabled: Boolean; // Noise Floor tracking: авто подстройка нижней границы FWfHigh: Double; // верхняя граница waterfall (dBm) — "белое" FWfLow: Double; // нижняя граница waterfall (dBm) — "чёрное" FWaterfallBitmap:TBitmap; // отображаемый буфер FWaterfallTemp: TBitmap; // рабочий буфер для скролла FSpectrumWidth: Integer; FSpectrumHeight: Integer; FWaterfallHeight:Integer; // --- Ruler bitmap cache --- // ОПТИМИЗАЦИЯ: Ruler перерисовывается только при изменении частоты/VFO, // а не каждые 50мс. Экономит TextOut + линии на каждом тике таймера. FRulerBitmap: TBitmap; // offscreen буфер линейки FRulerLastCenter: Double; // FCenterFreq при последней отрисовке FRulerLastVfo: Double; // FVfoA при последней отрисовке FRulerDirty: Boolean; // True = нужна перерисовка линейки // --- Settings --- FSettings: TSettingsManager; FWebServer: TWebServer; // веб-интерфейс (порт 8080) // Временные поля для передачи параметров в Synchronize-методы FWebSyncFreq: Double; FWebSyncInt: Integer; FWebSyncBool: Boolean; FWebSyncM: TThreadMethod; FCurrentBand: Integer; // текущий активный диапазон 0..10 FDevMAC: array[0..5] of Byte; // MAC подключённого трансивера FPendingDev: THPSDRDevice; // устройство ожидающее открытия WDSP FBandCache: array[0..CFG_BAND_COUNT-1] of TBandSettings; // кэш диапазонов FDevConnected: Boolean; // True после первого подключения FCenterFreq: Double; // центр спектра = LO (DDC). При CTUN ON не меняется при кручении VFO FSpanHz: Double; FSampleRate: Integer; // текущий DDC sample rate (Hz) // ---- Timers ---- FMeterTimer: TTimer; FSpectrumTimer: TTimer; FAfterShowTimer:TTimer; // однократный таймер для пост-инициализации // ---- Toolbar ---- PanelToolbar: TPanel; BtnDiscover: TFlatButton; BtnStartStop: TFlatButton; // START / STOP (connect+run в одном) // ---- Left panel ---- PanelLeft: TPanel; PanelVfoA: TPanel; LblVfoALabel: TLabel; FreqDispA: TFreqDisplay; PanelVfoB: TPanel; LblVfoBLabel: TLabel; FreqDispB: TFreqDisplay; PanelVfoButtons: TPanel; BtnVfoSwap: TFlatButton; BtnVfoACopyB: TFlatButton; BtnVfoBCopyA: TFlatButton; PanelBands: TPanel; BtnBand: array[0..BAND_COUNT-1] of TFlatButton; PanelMode: TPanel; BtnMode: array[0..MODE_COUNT-1] of TFlatButton; PanelFilter: TPanel; BtnFilter: array[0..FILT_COUNT-1] of TFlatButton; BtnCTun: TFlatButton; PanelRX: TPanel; LblAGC: TLabel; BtnAGCMode: array[0..4] of TFlatButton; // FAST MED SLOW LONG OFF LblAGCTop: TLabel; // показывает значение уровня TrkAGC: TTrackBar; // ползунок уровня AGC LblVol: TLabel; TrkVolume: TTrackBar; BtnNR: TFlatButton; BtnNB: TFlatButton; BtnANF: TFlatButton; BtnMute: TFlatButton; PanelSMeterRight: TPanel; // контейнер S-метра справа PbSMeterRight: TPaintBox;// новый большой S-метр справа FSmBitmap: TBitmap; // off-screen буфер для alpha-blend PanelTX: TPanel; LblDrv: TLabel; TrkDrive: TTrackBar; BtnMOX: TFlatButton; PbFwdPower: TPaintBox; PbSWR: TPaintBox; LblFwdPwr: TLabel; LblSWRVal: TLabel; // ---- Right panel ---- PanelRight: TPanel; PanelSpanButtons: TPanel; LblSpan: TLabel; BtnSpan48k: TFlatButton; BtnSpan96k: TFlatButton; BtnSpan192k: TFlatButton; BtnSpan384k: TFlatButton; BtnSpan768k: TFlatButton; BtnSpan1536k: TFlatButton; BtnWfAGC: TFlatButton; BtnWfNF: TFlatButton; BtnHidePanel: TFlatButton; // скрыть/показать левую панель PbSpectrum: TPaintBox; PbRuler: TPaintBox; // полоса частотных меток между спектром и водопадом PbWaterfall: TPaintBox; // ---- Status bar ---- StatusBar1: TStatusBar; // ---- Helpers ---- procedure BuildUI; procedure ApplyDarkTheme; procedure StyleButton(B: TFlatButton; Active: Boolean = False); procedure UpdateVfoDisplay; function FormatFreq(Hz: Double): string; function ScaleX(X, Total, Width: Integer): Integer; procedure DrawSMeterWide(ACanvas: TCanvas; R: TRect; Value, Peak, MinVal: Double; out ZX1, ZX2, ZY1, ZY2: Integer); procedure DrawSMeterZone(ACanvas: TCanvas; R: TRect; X1, X2, Y1, Y2: Integer); procedure PbSMeterRightPaint(Sender: TObject); procedure DrawBarMeter(ACanvas: TCanvas; R: TRect; Value, MaxVal: Double; BarColor: TColor); procedure DrawSpectrum; procedure DrawSpectrumGradient(const SpPts: array of TPoint; W, H: Integer); procedure DrawMarkerLine(C: TCanvas; W, H: Integer); procedure DrawWaterfall; procedure ResetWfAvgBuf; procedure ResizeSMeter; procedure FillDemoSpectrum; procedure ResetSpectrumBuf; procedure ResizeSpectrumPanels; // Network callbacks procedure OnDeviceFound(const Dev: THPSDRDevice); procedure OnHPStatusCB(const Status: THighPriorityStatus); procedure OnDDCIQCB(DDCIndex: Integer; const Data: TDDCIQPacket); procedure OnWDSPOpenDone(Success: Boolean); procedure DoConnectDevice(const Dev: THPSDRDevice); procedure OnMicPacketCB(const Data: TMicDataPacket); // DSP/Audio callbacks (вызываются из рабочих потоков) procedure OnAudioReady(const Left, Right: array of Single; Count: Integer); procedure OnSpectrumReady(const Pixels: array of Single; Count: Integer); // Public UI update (called from sync objects) procedure DoAddDevice(const Dev: THPSDRDevice; const Entry: string); procedure DoUpdateStatus(FwdW, SWRV, SupplyV: Double; PLLLock: Boolean); procedure DoUpdateDDCSeq(DDCIdx: Integer; Seq: LongWord); procedure DoUpdateDDCSeqOrNoDevice(DDCIdx: Integer; Seq: LongWord); // Event handlers procedure BtnDiscoverClick(Sender: TObject); procedure BtnDiscoverFromDialog(Sender: TObject); procedure BtnStartStopClick(Sender: TObject); procedure FreqDispAChanged(Sender: TObject; NewFreq: Int64); procedure ApplyVfoA(NewFreq: Int64); // Настройки procedure SaveCurrentBand; procedure RestoreBand(BandIdx: Integer); procedure SaveAllAndExit; procedure RestoreWindowBounds; function MakeGlobalSettings: TGlobalSettings; function MakeBandSettings: TBandSettings; procedure FreqDispBChanged(Sender: TObject; NewFreq: Int64); procedure BtnBandClick(Sender: TObject); procedure ApplyModeFilter; procedure BtnModeClick(Sender: TObject); procedure BtnFilterClick(Sender: TObject); procedure BtnCTunClick(Sender: TObject); procedure UpdateFilterButtons; procedure PbSpectrumMouseDown(Sender: TObject; Button: TMouseButton; Shift: TShiftState; X, Y: Integer); procedure PbSpectrumMouseMove(Sender: TObject; Shift: TShiftState; X, Y: Integer); procedure PbSpectrumMouseUp(Sender: TObject; Button: TMouseButton; Shift: TShiftState; X, Y: Integer); procedure DrawRuler; procedure PbWaterfallMouseDown(Sender: TObject; Button: TMouseButton; Shift: TShiftState; X, Y: Integer); procedure PbWaterfallMouseMove(Sender: TObject; Shift: TShiftState; X, Y: Integer); procedure PbWaterfallMouseUp(Sender: TObject; Button: TMouseButton; Shift: TShiftState; X, Y: Integer); procedure DoSpectrumClick(PixelX: Integer; PanelWidth: Integer); procedure DoSpectrumDrag(PixelX: Integer; PanelWidth: Integer); procedure BtnVfoSwapClick(Sender: TObject); procedure BtnVfoACopyBClick(Sender: TObject); procedure BtnVfoBCopyAClick(Sender: TObject); procedure BtnMOXClick(Sender: TObject); procedure BtnMuteClick(Sender: TObject); procedure BtnNRClick(Sender: TObject); procedure BtnNBClick(Sender: TObject); procedure BtnANFClick(Sender: TObject); procedure BtnSpanClick(Sender: TObject); // Веб-интерфейс: callbacks от TWebServer procedure WebOnFreq(Hz: Double); procedure WebOnMode(Mode: Integer); procedure WebOnFilter(BW: Integer); procedure WebOnAGC(Mode: Integer); procedure WebOnAGCTop(DB: Integer); procedure WebOnBand(Idx: Integer); procedure WebOnSpan(Hz: Integer); procedure WebOnVolume(V: Integer); procedure WebOnWfAGC(On_: Boolean); procedure WebOnWfNF(On_: Boolean); procedure WebOnRun(On_: Boolean); procedure WebOnMute(On_: Boolean); procedure WebOnCtun(On_: Boolean); procedure WebOnNR(On_: Boolean); procedure WebOnNB(On_: Boolean); procedure WebOnANF(On_: Boolean); // Synchronize-обёртки (выполняются в UI-потоке) procedure SyncWebFreq; procedure SyncWebMode; procedure SyncWebFilter; procedure SyncWebAGC; procedure SyncWebAGCTop; procedure SyncWebBand; procedure SyncWebSpan; procedure SyncWebVolume; procedure SyncWebWfAGC; procedure SyncWebWfNF; procedure SyncWebRun; procedure SyncWebMute; procedure SyncWebCtun; procedure SyncWebNR; procedure SyncWebNB; procedure SyncWebANF; procedure BtnWfAGCClick(Sender: TObject); procedure BtnWfNFClick(Sender: TObject); procedure BtnHidePanelClick(Sender: TObject); procedure PositionVfoOverlay; procedure OnModeFilterSelect(Mode: Integer; FilterBW: Integer); procedure PbSpectrumDblClick(Sender: TObject); procedure TrkDriveChange(Sender: TObject); procedure TrkVolumeChange(Sender: TObject); procedure BtnAGCModeClick(Sender: TObject); procedure TrkAGCChange(Sender: TObject); procedure PbFwdPowerPaint(Sender: TObject); procedure PbSWRPaint(Sender: TObject); procedure PbSpectrumPaint(Sender: TObject); procedure PbRulerPaint(Sender: TObject); procedure PbWaterfallPaint(Sender: TObject); procedure MeterTimerTick(Sender: TObject); procedure SpectrumTimerTick(Sender: TObject); procedure RightPanelResize(Sender: TObject); procedure AfterShowTick(Sender: TObject); 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 {$R *.lfm} // =========================================================================== // Общая функция декодирования типа платы — используется везде // =========================================================================== function BoardTypeName(BoardType: Integer): string; begin case BoardType of 1: Result := 'HERMES (ANAN-10/100)'; 2: Result := 'HERMES-E (ANAN-10E/100B)'; 3: Result := 'ANGELIA (ANAN-100D)'; 4: Result := 'ORION (ANAN-200D)'; 5: Result := 'ORION MkII (ANAN-7000/8000)'; 6: Result := 'HERMES-LITE 2'; 10: Result := 'SATURN (G2)'; else Result := Format('Unknown Board #%d', [BoardType]); end; end; // =========================================================================== // Sync helpers // =========================================================================== constructor TDeviceFoundSync.Create(AForm: TObject; const D: THPSDRDevice; const E: string); begin inherited Create; FForm := AForm; FDev := D; FEntry := E; end; procedure TDeviceFoundSync.Execute; begin TMainForm(FForm).DoAddDevice(FDev, FEntry); end; constructor TStatusUISync.Create(AForm: TObject; FW, SW, SV: Double; PLL: Boolean); begin inherited Create; FForm := AForm; FFwdW := FW; FSWRV := SW; FSupplyV := SV; FPLLLock := PLL; end; procedure TStatusUISync.Execute; begin TMainForm(FForm).DoUpdateStatus(FFwdW, FSWRV, FSupplyV, FPLLLock); end; constructor TDDCSeqSync.Create(AForm: TObject; Idx: Integer; Seq: LongWord); begin inherited Create; FForm := AForm; FDDCIdx := Idx; FSeq := Seq; end; procedure TDDCSeqSync.Execute; begin TMainForm(FForm).DoUpdateDDCSeqOrNoDevice(FDDCIdx, FSeq); end; // =========================================================================== // FormCreate / FormDestroy // =========================================================================== // =========================================================================== // Settings helpers // =========================================================================== procedure TMainForm.RestoreWindowBounds; var L, T, Wd, Ht: Integer; begin FSettings.LoadWindowBounds(L, T, Wd, Ht); if (Wd > 400) and (Ht > 300) then begin Left := L; Top := T; Width := Wd; Height := Ht; end; end; function TMainForm.MakeBandSettings: TBandSettings; begin Result.VfoA := FVfoA; Result.VfoB := FVfoB; Result.Mode := FMode; Result.FilterIdx := FFilter; Result.FilterBW := FFilterBW; Result.AGCMode := FAGCMode; Result.AGCTop := FAGCTop; Result.CTun := FCTun; Result.SpanHz := FSpanHz; // Waterfall AGC/NF — глобальные (не диапазонные) end; function TMainForm.MakeGlobalSettings: TGlobalSettings; begin Result.Volume := FVolume; Result.DriveLevel := FDriveLevel; Result.ActiveVfo := FActiveVfo; Result.NREnabled := BtnNR.Tag = 1; Result.NBEnabled := BtnNB.Tag = 1; Result.ANFEnabled := BtnANF.Tag = 1; Result.AGCSlope := 0; Result.AGCHangThreshold := 100; Result.WfAGCEnabled := FWfAGCEnabled; Result.WfNFEnabled := FWfNFEnabled; Result.LastBand := FCurrentBand; Result.SampleRate := FSampleRate; end; procedure TMainForm.SaveCurrentBand; begin if not FDevConnected then Exit; FBandCache[FCurrentBand] := MakeBandSettings; FSettings.SaveBand(FDevMAC, FCurrentBand, FBandCache[FCurrentBand]); end; procedure TMainForm.RestoreBand(BandIdx: Integer); var B: TBandSettings; i: Integer; begin if (BandIdx < 0) or (BandIdx >= CFG_BAND_COUNT) then Exit; B := FBandCache[BandIdx]; // --- Кнопки диапазонов --- for i := 0 to BAND_COUNT - 1 do StyleButton(BtnBand[i], i = BandIdx); // Сбрасываем waterfall AGC/NF при смене диапазона FWfHigh := -50.0; FWfLow := -120.0; // Сбрасываем EMA буфер — иначе старый спектр "просочится" в новый диапазон ResetWfAvgBuf; // --- Режим: сначала устанавливаем FMode, потом кнопки и WDSP --- FMode := B.Mode; for i := 0 to MODE_COUNT - 1 do StyleButton(BtnMode[i], i = FMode); if FWDSPReady then FDSPEngine.SetMode(FMode); // --- Фильтр --- FFilter := B.FilterIdx; FFilterBW := B.FilterBW; UpdateFilterButtons; // обновляет кнопки фильтра ApplyModeFilter; // применяет Lo/Hi в WDSP с учётом нового FMode // --- AGC --- FAGCMode := B.AGCMode; FAGCTop := B.AGCTop; for i := 0 to 4 do StyleButton(BtnAGCMode[i], i = FAGCMode); TrkAGC.Position := FAGCTop; LblAGCTop.Caption := Format('%ddB', [FAGCTop]); if FWDSPReady then begin FDSPEngine.SetAGCTop(FAGCTop); FDSPEngine.SetAGC(TWDSPAGCMode(FAGCMode), 50.0); end; // --- CTUN --- FCTun := B.CTun; StyleButton(BtnCTun, FCTun); if FWDSPReady and not FCTun then FDSPEngine.SetShift(0.0); // --- Sample Rate / Span --- // SampleRate — глобальный, не per-band. Не восстанавливаем из диапазона. // Обновляем только FSpanHz для отображения (берём текущий FSampleRate) FSpanHz := FSampleRate; StyleButton(BtnSpan48k, FSampleRate = 48000); StyleButton(BtnSpan96k, FSampleRate = 96000); StyleButton(BtnSpan192k, FSampleRate = 192000); StyleButton(BtnSpan384k, FSampleRate = 384000); StyleButton(BtnSpan768k, FSampleRate = 768000); StyleButton(BtnSpan1536k, FSampleRate = 1536000); // --- VFO A: центрируем на новой частоте, сбрасываем shift --- FCenterFreq := B.VfoA; // DDC = центр диапазона FVfoB := B.VfoB; FreqDispB.Frequency := Round(FVfoB); // ApplyVfoA обновит сеть, FreqDisp, shift, перерисует ApplyVfoA(Round(B.VfoA)); end; procedure TMainForm.SaveAllAndExit; begin if FDevConnected then begin SaveCurrentBand; FSettings.SaveGlobal(FDevMAC, MakeGlobalSettings); FSettings.Save; end; end; procedure TMainForm.FormCreate(Sender: TObject); var bi_: Integer; begin FVfoA := 14200000; FVfoB := 7100000; FActiveVfo := 0; FMode := 1; FFilter := 5; FAGCMode := 1; // MEDIUM FAGCTop := 90; // −90 dBm // default: зависит от режима, будет сброшен в UpdateFilterButtons FCTun := False; FFilterBW := 2700; FSpecDrag := False; FDriveLevel := 100; FRunning := False; FTransmitting := False; FMuted := False; FVolume := 70; FCenterFreq := FVfoA; FSpanHz := 192000; FSampleRate := 192000; FCurrentBand := 5; // 20m по умолчанию // Waterfall normalization FWfHigh := -50.0; // начальная верхняя граница dBm // WF avg buffer — инициализируем значением шумового пола // FWfAvgBuf инициализируется в ResetSpectrumBuf FWfLow := -120.0; // начальная нижняя граница dBm FWfAGCEnabled := False; FWfNFEnabled := False; FDevConnected := False; FillChar(FDevMAC, SizeOf(FDevMAC), 0); // Инициализируем кэш диапазонов умолчаниями for bi_ := 0 to CFG_BAND_COUNT - 1 do TSettingsManager.DefaultBand(bi_, FBandCache[bi_]); FCurrentBand := 5; // Загружаем JSON настройки FSettings := TSettingsManager.Create; FSettings.Load; // Восстанавливаем размер/позицию окна при старте (до подключения устройства) RestoreWindowBounds; // S-метр позиционируем после рестора размера окна // (будет пересчитан в первом тике SpectrumTimerTick) FLastSMeter := -130; // Создаём веб-сервер (логин: admin, пароль: hpsdr — менять здесь) FWebServer := TWebServer.Create('admin', 'hpsdr'); FWebServer.OnFreq := WebOnFreq; FWebServer.OnMode := WebOnMode; FWebServer.OnFilter := WebOnFilter; FWebServer.OnAGC := WebOnAGC; FWebServer.OnAGCTop := WebOnAGCTop; FWebServer.OnBand := WebOnBand; FWebServer.OnSpan := WebOnSpan; FWebServer.OnVolume := WebOnVolume; FWebServer.OnWfAGC := WebOnWfAGC; FWebServer.OnWfNF := WebOnWfNF; FWebServer.OnRun := WebOnRun; FWebServer.OnMute := WebOnMute; FWebServer.OnCtun := WebOnCtun; FWebServer.OnNR := WebOnNR; FWebServer.OnNB := WebOnNB; FWebServer.OnANF := WebOnANF; FWebServer.Start; FSMeterPeak := -130; FSMeterMin := -130; FSMeterAvg := -130; FRXPacketCount := 0; FActiveDDC := 0; FLastDDCSeq := 0; FLastDDCIndex := 0; FLastFwdW := 0; FLastSWR := 1; FDeviceCount := 0; FDeviceDialog := TDeviceDialog.Create(Self); FDeviceDialog.OnDiscover := BtnDiscoverFromDialog; FVfoOverlay := TVfoOverlay.Create(Self); FVfoOverlay.OnSelect := OnModeFilterSelect; FVfoOverlay.Visible := False; FPanelHidden := False; FSpectrumBitmap := TBitmap.Create; FWaterfallBitmap := TBitmap.Create; FWaterfallTemp := TBitmap.Create; // ОПТИМИЗАЦИЯ: pf32bit = прямой доступ через ScanLine без конвертации формата. // Обязательно до первого SetSize/Draw иначе Lazarus может выбрать pf24bit. FSpectrumBitmap.PixelFormat := pf32bit; FWaterfallBitmap.PixelFormat := pf32bit; FWaterfallTemp.PixelFormat := pf32bit; // Ruler bitmap cache FRulerBitmap := TBitmap.Create; FRulerBitmap.PixelFormat := pf32bit; FRulerLastCenter := -1.0; FRulerLastVfo := -1.0; FRulerDirty := True; ResetSpectrumBuf; FNetwork := THPSDRNetwork.Create; FNetwork.OnDeviceFound := OnDeviceFound; FNetwork.OnHPStatus := OnHPStatusCB; FNetwork.OnDDCIQ := OnDDCIQCB; FNetwork.OnMicPacket := OnMicPacketCB; BuildUI; ResizeSMeter; ApplyDarkTheme; UpdateVfoDisplay; UpdateFilterButtons; // DSP Engine — создаём объект, Open вызовется при нажатии START // (FDSPEngine.Open загружает libwdsp и занимает ~1-2 сек) FWDSPReady := False; FDSPEngine := TWDSPEngine.Create(192000, 48000, 1024); FDSPEngine.OnAudio := OnAudioReady; FDSPEngine.OnSpectrum := OnSpectrumReady; // Audio output — создаём объект сейчас, открываем после показа формы // (Pa_Initialize на Linux пишет в stderr до перехвата сигналов FPC) FAudioOut := TAudioOutput.Create(48000); FMeterTimer := TTimer.Create(Self); FMeterTimer.Interval := 100; FMeterTimer.OnTimer := MeterTimerTick; FMeterTimer.Enabled := True; FSpectrumTimer := TTimer.Create(Self); FSpectrumTimer.Interval := 50; FSpectrumTimer.OnTimer := SpectrumTimerTick; FSpectrumTimer.Enabled := True; // Однократный таймер — инициализация аудио после показа формы FAfterShowTimer := TTimer.Create(Self); FAfterShowTimer.Interval := 200; FAfterShowTimer.OnTimer := AfterShowTick; FAfterShowTimer.Enabled := True; OnMouseWheel := FormMouseWheel; // Проверяем и при необходимости добавляем исключение в Windows Firewall // (UDP входящий трафик для HPSDR Protocol 2) {$IFDEF WINDOWS} FirewallEnsureAllowed(ParamStr(0), 'OpenHPSDR Transceiver'); {$ENDIF} end; procedure TMainForm.FormDestroy(Sender: TObject); begin FMeterTimer.Enabled := False; FSpectrumTimer.Enabled := False; if FNetwork.Running then FNetwork.SetRunAndFreq(False, FCenterFreq, FCenterFreq, 0); FNetwork.Disconnect; FNetwork.Free; FAudioOut.Close; FAudioOut.Free; FDSPEngine.Close; FDSPEngine.Free; FSpectrumBitmap.Free; FWaterfallBitmap.Free; FWaterfallTemp.Free; FRulerBitmap.Free; // Сохраняем настройки при закрытии if FDevConnected then begin SaveCurrentBand; FSettings.SaveGlobal(FDevMAC, MakeGlobalSettings); end; // Размер окна сохраняем всегда (не зависит от подключения) FSettings.SaveWindowBounds(Left, Top, Width, Height); FSettings.Save; FSettings.Free; FWebServer.Stop; FWebServer.Free; end; procedure TMainForm.FormClose(Sender: TObject; var CloseAction: TCloseAction); begin // Останавливаем трансивер как при нажатии STOP if FNetwork.Connected then begin if FRunning then begin FNetwork.UpdateState(FCenterFreq, FCenterFreq, 0, False, True, True); FNetwork.SetRunAndFreq(False, FCenterFreq, FCenterFreq, 0); FRunning := False; FTransmitting := False; if FWDSPReady then FDSPEngine.SetTXRun(False); end; FNetwork.Disconnect; end; CloseAction := caFree; end; // =========================================================================== // Build UI (без анонимных процедур и inline var) // =========================================================================== procedure TMainForm.BuildUI; const BTN_H = 26; BTN_SM = 24; LEFT_W = 232; var i, X, Y, W: Integer; B: TFlatButton; function MakeBtn(Parent: TWinControl; const Cap: string; ALeft, ATop, AW, AH: Integer; Handler: TNotifyEvent): TFlatButton; begin Result := TFlatButton.Create(Self); Result.Parent := Parent; Result.Caption := Cap; Result.Left := ALeft; Result.Top := ATop; Result.Width := AW; Result.Height := AH; Result.OnClick := Handler; StyleButton(Result, False); end; procedure MakeLbl(Parent: TWinControl; const Cap: string; ALeft, ATop: Integer); var L: TLabel; begin L := TLabel.Create(Self); L.Parent := Parent; L.Caption := Cap; L.Left := ALeft; L.Top := ATop; L.Font.Color := CLR_TEXTDIM; L.Font.Name := 'Courier New'; L.Font.Size := 7; end; begin // ---- Toolbar ---- PanelToolbar := TPanel.Create(Self); PanelToolbar.Parent := Self; PanelToolbar.Align := alTop; PanelToolbar.Height := 36; PanelToolbar.BevelOuter := bvNone; X := 4; BtnDiscover := MakeBtn(PanelToolbar, 'DISCOVER', X, 3, 80, BTN_H, BtnDiscoverClick); BtnStartStop := MakeBtn(PanelToolbar, 'START', X+84, 3, 76, BTN_H, BtnStartStopClick); // ---- Status bar ---- StatusBar1 := TStatusBar.Create(Self); StatusBar1.Parent := Self; StatusBar1.Align := alBottom; StatusBar1.SimplePanel := False; with StatusBar1.Panels do begin Add; Items[0].Width := 150; Items[0].Text := 'IP: --'; Add; Items[1].Width := 120; Items[1].Text := 'Board: --'; Add; Items[2].Width := 320; Items[2].Text := 'Supply: -- | FWD: -- | SWR: --'; Add; Items[3].Width := 250; Items[3].Text := 'RX: waiting...'; Add; Items[4].Width := 200; Items[4].Text := 'Not connected'; end; // ---- Left panel ---- PanelLeft := TPanel.Create(Self); PanelLeft.Parent := Self; PanelLeft.Align := alLeft; PanelLeft.Width := LEFT_W; PanelLeft.BevelOuter := bvNone; Y := 2; // VFO A PanelVfoA := TPanel.Create(Self); PanelVfoA.Parent := PanelLeft; PanelVfoA.SetBounds(0, Y, LEFT_W, 56); PanelVfoA.BevelOuter := bvNone; LblVfoALabel := TLabel.Create(Self); LblVfoALabel.Parent := PanelVfoA; LblVfoALabel.Caption := 'VFO-A'; LblVfoALabel.Left := 4; LblVfoALabel.Top := 2; LblVfoALabel.Font.Name := 'Courier New'; LblVfoALabel.Font.Size := 7; LblVfoALabel.Font.Color := CLR_TEXTDIM; FreqDispA := TFreqDisplay.Create(Self); FreqDispA.Parent := PanelVfoA; FreqDispA.SetBounds(0, 14, LEFT_W, 36); FreqDispA.FontSize := 20; FreqDispA.FontName := 'Courier New'; FreqDispA.Frequency := Round(FVfoA); FreqDispA.ColorNormal := CLR_FREQ; FreqDispA.ColorHover := TColor($0040DDFF); FreqDispA.ColorDim := CLR_TEXTDIM; FreqDispA.OnChange := FreqDispAChanged; Inc(Y, 58); // VFO B PanelVfoB := TPanel.Create(Self); PanelVfoB.Parent := PanelLeft; PanelVfoB.SetBounds(0, Y, LEFT_W, 46); PanelVfoB.BevelOuter := bvNone; LblVfoBLabel := TLabel.Create(Self); LblVfoBLabel.Parent := PanelVfoB; LblVfoBLabel.Caption := 'VFO-B'; LblVfoBLabel.Left := 4; LblVfoBLabel.Top := 2; LblVfoBLabel.Font.Name := 'Courier New'; LblVfoBLabel.Font.Size := 7; LblVfoBLabel.Font.Color := CLR_TEXTDIM; FreqDispB := TFreqDisplay.Create(Self); FreqDispB.Parent := PanelVfoB; FreqDispB.SetBounds(0, 14, LEFT_W, 28); FreqDispB.FontSize := 14; FreqDispB.FontName := 'Courier New'; FreqDispB.Frequency := Round(FVfoB); FreqDispB.ColorNormal := CLR_FREQ_DIM; FreqDispB.ColorHover := TColor($0040DDFF); FreqDispB.ColorDim := CLR_TEXTDIM; FreqDispB.OnChange := FreqDispBChanged; Inc(Y, 50); // VFO buttons PanelVfoButtons := TPanel.Create(Self); PanelVfoButtons.Parent := PanelLeft; PanelVfoButtons.SetBounds(0, Y, LEFT_W, BTN_H + 4); PanelVfoButtons.BevelOuter := bvNone; BtnVfoSwap := MakeBtn(PanelVfoButtons, 'A<>B', 2, 2, 72, BTN_H, BtnVfoSwapClick); BtnVfoACopyB := MakeBtn(PanelVfoButtons, 'A>B', 78, 2, 72, BTN_H, BtnVfoACopyBClick); BtnVfoBCopyA := MakeBtn(PanelVfoButtons, 'B>A', 154, 2, 72, BTN_H, BtnVfoBCopyAClick); Inc(Y, BTN_H + 8); // Band selector PanelBands := TPanel.Create(Self); PanelBands.Parent := PanelLeft; PanelBands.SetBounds(0, Y, LEFT_W, 74); PanelBands.BevelOuter := bvNone; MakeLbl(PanelBands, 'BAND', 4, 2); W := (LEFT_W - 6) div 6; for i := 0 to BAND_COUNT - 1 do begin B := MakeBtn(PanelBands, BAND_NAMES[i], 2 + (i mod 6) * W, 16 + (i div 6) * 27, W - 2, BTN_SM, BtnBandClick); B.Tag := i; BtnBand[i] := B; end; Inc(Y, 76); // Mode selector PanelMode := TPanel.Create(Self); PanelMode.Parent := PanelLeft; PanelMode.SetBounds(0, Y, LEFT_W, 74); PanelMode.BevelOuter := bvNone; MakeLbl(PanelMode, 'MODE', 4, 2); W := (LEFT_W - 6) div 4; for i := 0 to MODE_COUNT - 1 do begin B := MakeBtn(PanelMode, MODE_NAMES[i], 2 + (i mod 4) * W, 16 + (i div 4) * 27, W - 2, BTN_SM, BtnModeClick); B.Tag := i; BtnMode[i] := B; StyleButton(B, i = FMode); end; Inc(Y, 76); // Filter selector (10 кнопок в 2 ряда по 5) PanelFilter := TPanel.Create(Self); PanelFilter.Parent := PanelLeft; PanelFilter.SetBounds(0, Y, LEFT_W, 74); PanelFilter.BevelOuter := bvNone; MakeLbl(PanelFilter, 'FILTER', 4, 2); W := (LEFT_W - 6) div 5; for i := 0 to FILT_COUNT - 1 do begin B := MakeBtn(PanelFilter, '---', 2 + (i mod 5) * W, 16 + (i div 5) * 27, W - 2, BTN_SM, BtnFilterClick); B.Tag := i; BtnFilter[i] := B; end; Inc(Y, 76); // CTUN button BtnCTun := MakeBtn(PanelLeft, 'CTUN', 2, Y, LEFT_W div 3 - 2, BTN_H, BtnCTunClick); BtnCTun.Tag := 0; StyleButton(BtnCTun, FCTun); Inc(Y, BTN_H + 4); // RX controls PanelRX := TPanel.Create(Self); PanelRX.Parent := PanelLeft; PanelRX.SetBounds(0, Y, LEFT_W, 120); PanelRX.BevelOuter := bvNone; MakeLbl(PanelRX, 'RX', 4, 2); // AGC mode — 5 кнопок в ряд MakeLbl(PanelRX, 'AGC', 4, 18); W := (LEFT_W - 36) div 5; BtnAGCMode[0] := MakeBtn(PanelRX,'FAST', 34+0*(W+1),14,W,BTN_SM,BtnAGCModeClick); BtnAGCMode[0].Tag:=0; BtnAGCMode[1] := MakeBtn(PanelRX,'MED', 34+1*(W+1),14,W,BTN_SM,BtnAGCModeClick); BtnAGCMode[1].Tag:=1; BtnAGCMode[2] := MakeBtn(PanelRX,'SLOW', 34+2*(W+1),14,W,BTN_SM,BtnAGCModeClick); BtnAGCMode[2].Tag:=2; BtnAGCMode[3] := MakeBtn(PanelRX,'LONG', 34+3*(W+1),14,W,BTN_SM,BtnAGCModeClick); BtnAGCMode[3].Tag:=3; BtnAGCMode[4] := MakeBtn(PanelRX,'OFF', 34+4*(W+1),14,W,BTN_SM,BtnAGCModeClick); BtnAGCMode[4].Tag:=4; for i := 0 to 4 do StyleButton(BtnAGCMode[i], i = FAGCMode); // AGC level slider MakeLbl(PanelRX, 'THRESH', 4, 41); TrkAGC := TTrackBar.Create(Self); TrkAGC.Parent := PanelRX; TrkAGC.Left := 56; TrkAGC.Top := 37; TrkAGC.Width := LEFT_W - 96; TrkAGC.Height := 20; TrkAGC.Min := 20; TrkAGC.Max := 120; // 20..120 → −20..-120 dBm TrkAGC.Position := FAGCTop; TrkAGC.TickStyle := tsNone; TrkAGC.Reversed := True; TrkAGC.OnChange := TrkAGCChange; LblAGCTop := TLabel.Create(Self); LblAGCTop.Parent := PanelRX; LblAGCTop.Left := LEFT_W - 38; LblAGCTop.Top := 41; LblAGCTop.Caption := Format('-%ddB', [FAGCTop]); LblAGCTop.Font.Color := CLR_TEXT; LblAGCTop.Font.Name := 'Courier New'; LblAGCTop.Font.Size := 7; // VOL MakeLbl(PanelRX, 'VOL', 4, 65); TrkVolume := TTrackBar.Create(Self); TrkVolume.Parent := PanelRX; TrkVolume.Left := 34; TrkVolume.Top := 61; TrkVolume.Width := LEFT_W - 38; TrkVolume.Height := 20; TrkVolume.Min := 0; TrkVolume.Max := 100; TrkVolume.Position := FVolume; TrkVolume.TickStyle := tsNone; TrkVolume.OnChange := TrkVolumeChange; // NR NB ANF MUTE W := (LEFT_W - 8) div 4; BtnNR := MakeBtn(PanelRX, 'NR', 2, 88, W, BTN_SM, BtnNRClick); BtnNB := MakeBtn(PanelRX, 'NB', W+4, 88, W, BTN_SM, BtnNBClick); BtnANF := MakeBtn(PanelRX, 'ANF',2*W+6, 88, W, BTN_SM, BtnANFClick); BtnMute:= MakeBtn(PanelRX, 'MUTE',3*W+8,88, W, BTN_SM, BtnMuteClick); Inc(Y, 124); // S-Meter Inc(Y, 52); // TX controls PanelTX := TPanel.Create(Self); PanelTX.Parent := PanelLeft; PanelTX.SetBounds(0, Y, LEFT_W, 104); PanelTX.BevelOuter := bvNone; MakeLbl(PanelTX, 'TX', 4, 2); LblDrv := TLabel.Create(Self); LblDrv.Parent := PanelTX; LblDrv.Left := 4; LblDrv.Top := 17; LblDrv.Caption := 'DRV'; LblDrv.Font.Color := CLR_TEXTDIM; LblDrv.Font.Name := 'Courier New'; LblDrv.Font.Size := 7; TrkDrive := TTrackBar.Create(Self); TrkDrive.Parent := PanelTX; TrkDrive.Left := 34; TrkDrive.Top := 14; TrkDrive.Width := 150; TrkDrive.Height := 22; TrkDrive.Min := 0; TrkDrive.Max := 100; TrkDrive.Position := 50; TrkDrive.TickStyle := tsNone; TrkDrive.OnChange := TrkDriveChange; BtnMOX := MakeBtn(PanelTX, 'MOX', 2, 38, 70, 32, BtnMOXClick); BtnMOX.Font.Size := 12; BtnMOX.Font.Bold := True; LblFwdPwr := TLabel.Create(Self); LblFwdPwr.Parent := PanelTX; LblFwdPwr.Left := 80; LblFwdPwr.Top := 42; LblFwdPwr.Caption := '0W'; LblFwdPwr.Font.Color := CLR_METER_ON; LblFwdPwr.Font.Name := 'Courier New'; LblFwdPwr.Font.Size := 8; LblSWRVal := TLabel.Create(Self); LblSWRVal.Parent := PanelTX; LblSWRVal.Left := 140; LblSWRVal.Top := 42; LblSWRVal.Caption := 'SWR:1.0'; LblSWRVal.Font.Color := CLR_TEXTDIM; LblSWRVal.Font.Name := 'Courier New'; LblSWRVal.Font.Size := 8; MakeLbl(PanelTX, 'PWR', 4, 76); PbFwdPower := TPaintBox.Create(Self); PbFwdPower.Parent := PanelTX; PbFwdPower.SetBounds(34, 74, LEFT_W - 38, 12); PbFwdPower.OnPaint := PbFwdPowerPaint; MakeLbl(PanelTX, 'SWR', 4, 92); PbSWR := TPaintBox.Create(Self); PbSWR.Parent := PanelTX; PbSWR.SetBounds(34, 90, LEFT_W - 38, 12); PbSWR.OnPaint := PbSWRPaint; // ---- Right panel ---- PanelRight := TPanel.Create(Self); PanelRight.Parent := Self; PanelRight.Align := alClient; PanelRight.BevelOuter := bvNone; PanelRight.OnResize := RightPanelResize; PanelSpanButtons := TPanel.Create(Self); PanelSpanButtons.Parent := PanelRight; PanelSpanButtons.Align := alTop; PanelSpanButtons.Height := 30; PanelSpanButtons.BevelOuter := bvNone; LblSpan := TLabel.Create(Self); LblSpan.Parent := PanelSpanButtons; LblSpan.Left := 4; LblSpan.Top := 8; LblSpan.Caption := 'SPAN:'; LblSpan.Font.Color := CLR_TEXTDIM; LblSpan.Font.Name := 'Courier New'; LblSpan.Font.Size := 7; BtnSpan48k := MakeBtn(PanelSpanButtons, '48k', 50, 3, 46, BTN_SM, BtnSpanClick); BtnSpan96k := MakeBtn(PanelSpanButtons, '96k', 99, 3, 46, BTN_SM, BtnSpanClick); BtnSpan192k := MakeBtn(PanelSpanButtons, '192k', 148, 3, 52, BTN_SM, BtnSpanClick); BtnSpan384k := MakeBtn(PanelSpanButtons, '384k', 203, 3, 52, BTN_SM, BtnSpanClick); BtnSpan768k := MakeBtn(PanelSpanButtons, '768k', 258, 3, 52, BTN_SM, BtnSpanClick); BtnSpan1536k := MakeBtn(PanelSpanButtons, '1536k', 313, 3, 58, BTN_SM, BtnSpanClick); BtnSpan48k.Tag := 48000; BtnSpan96k.Tag := 96000; BtnSpan192k.Tag := 192000; BtnSpan384k.Tag := 384000; BtnSpan768k.Tag := 768000; BtnSpan1536k.Tag := 1536000; StyleButton(BtnSpan192k, True); // Waterfall AGC + Noise Floor кнопки справа в той же панели BtnWfAGC := MakeBtn(PanelSpanButtons, 'WF AGC', 379, 3, 62, BTN_SM, BtnWfAGCClick); BtnWfNF := MakeBtn(PanelSpanButtons, 'WF NF', 445, 3, 58, BTN_SM, BtnWfNFClick); BtnHidePanel := MakeBtn(PanelSpanButtons, '◀ HIDE', 510, 3, 64, BTN_SM, BtnHidePanelClick); // Большой S-метр — на главной форме поверх двух панелей // S-метр: Parent=Self, перекрывает Toolbar+SpanButtons вертикально PanelSMeterRight := TPanel.Create(Self); PanelSMeterRight.Parent := Self; PanelSMeterRight.BevelOuter := bvNone; PanelSMeterRight.Color := CLR_PANEL; PbSMeterRight := TPaintBox.Create(Self); PbSMeterRight.Parent := PanelSMeterRight; PbSMeterRight.Align := alClient; PbSMeterRight.OnPaint := PbSMeterRightPaint; PbSMeterRight.Color := CLR_PANEL; PbSpectrum := TPaintBox.Create(Self); PbSpectrum.Parent := PanelRight; PbSpectrum.OnPaint := PbSpectrumPaint; PbSpectrum.OnMouseDown := PbSpectrumMouseDown; PbSpectrum.OnDblClick := PbSpectrumDblClick; PbSpectrum.OnMouseMove := PbSpectrumMouseMove; PbSpectrum.OnMouseUp := PbSpectrumMouseUp; PbRuler := TPaintBox.Create(Self); PbRuler.Parent := PanelRight; PbRuler.OnPaint := PbRulerPaint; PbRuler.Cursor := crDefault; PbWaterfall := TPaintBox.Create(Self); PbWaterfall.Parent := PanelRight; PbWaterfall.OnPaint := PbWaterfallPaint; PbWaterfall.OnMouseDown := PbWaterfallMouseDown; PbWaterfall.OnMouseMove := PbWaterfallMouseMove; PbWaterfall.OnMouseUp := PbWaterfallMouseUp; // Initial layout ResizeSpectrumPanels; end; // =========================================================================== // Resize handler (normal method instead of anonymous procedure) // =========================================================================== procedure TMainForm.RightPanelResize(Sender: TObject); begin ResizeSpectrumPanels; end; procedure TMainForm.ResizeSMeter; // PanelSMeterRight на главной форме (Parent=Self). // По Y: от самого верха формы до низа PanelSpanButtons (Toolbar+Span = 66px). // По X: 40% ширины PanelRight, прижат к правому краю. const LEFT_PANEL_W = 232; // ширина левой панели (LEFT_W) MARGIN = 3; var FW, FH: Integer; RightW: Integer; // ширина PanelRight SMW: Integer; // ширина S-метра SMLeft: Integer; SMTop: Integer; SMBot: Integer; begin if (PanelSMeterRight = nil) or (PanelToolbar = nil) or (PanelSpanButtons = nil) then Exit; FW := ClientWidth; // Ширина зоны спектра (PanelRight) RightW := FW - LEFT_PANEL_W; if RightW < 200 then Exit; // Ширина S-метра = 40% от PanelRight SMW := Round(RightW * 0.40); // X: прижат к правому краю формы SMLeft := FW - SMW - MARGIN; // Y: от верха формы+margin до низа PanelSpanButtons-margin SMTop := MARGIN; SMBot := PanelToolbar.Height + PanelSpanButtons.Height - MARGIN; PanelSMeterRight.SetBounds(SMLeft, SMTop, SMW, SMBot - SMTop); PanelSMeterRight.BringToFront; end; procedure TMainForm.ResizeSpectrumPanels; var RW, RH, SH, WH, TopOff: Integer; begin if PanelRight = nil then Exit; RW := PanelRight.ClientWidth; RH := PanelRight.ClientHeight; TopOff := PanelSpanButtons.Height; // S-метр позиционируется отдельно в ResizeSMeter ResizeSMeter; RH := RH - TopOff; if RH < 10 then Exit; SH := RH * 4 div 10; WH := RH - SH - 18; if WH < 10 then WH := 10; PbSpectrum.SetBounds(0, TopOff, RW, SH); PbRuler.SetBounds (0, TopOff + SH, RW, 18); PbWaterfall.SetBounds(0, TopOff + SH + 18, RW, WH); // Обновляем переменные размеров FSpectrumWidth := RW; FSpectrumHeight := SH; FWaterfallHeight := WH; // Пересоздаём bitmap точно под новый размер if (FSpectrumBitmap <> nil) and (RW > 0) and (SH > 0) then begin FSpectrumBitmap.PixelFormat := pf32bit; // сохраняем формат после SetSize FSpectrumBitmap.SetSize(RW, SH); // Заливаем фоном чтобы не было артефактов от старого содержимого FSpectrumBitmap.Canvas.Brush.Color := CLR_BG; FSpectrumBitmap.Canvas.FillRect(Rect(0, 0, RW, SH)); end; if (FWaterfallBitmap <> nil) and (RW > 0) and (WH > 0) then begin FWaterfallBitmap.PixelFormat := pf32bit; FWaterfallBitmap.SetSize(RW, WH); FWaterfallBitmap.Canvas.Brush.Color := clBlack; FWaterfallBitmap.Canvas.FillRect(Rect(0, 0, RW, WH)); FWaterfallTemp.PixelFormat := pf32bit; FWaterfallTemp.SetSize(RW, WH); FWaterfallTemp.Canvas.Brush.Color := clBlack; FWaterfallTemp.Canvas.FillRect(Rect(0, 0, RW, WH)); end; // Ruler bitmap: пересоздаём под новый размер, сбрасываем кэш if (FRulerBitmap <> nil) and (RW > 0) and (PbRuler <> nil) then begin FRulerBitmap.PixelFormat := pf32bit; FRulerBitmap.SetSize(RW, PbRuler.Height); FRulerLastCenter := -1.0; // принудительно перерисовать FRulerLastVfo := -1.0; FRulerDirty := True; end; // Немедленно перерисовываем с новыми размерами if RW > 0 then begin DrawSpectrum; PbSpectrum.Invalidate; if PbRuler <> nil then PbRuler.Invalidate; PbWaterfall.Invalidate; end; end; // =========================================================================== // Dark Theme // =========================================================================== procedure TMainForm.ApplyDarkTheme; procedure DP(P: TPanel); begin P.Color := CLR_PANEL; P.Font.Color := CLR_TEXT; end; begin Color := CLR_BG; Font.Color := CLR_TEXT; DP(PanelToolbar); DP(PanelLeft); DP(PanelVfoA); DP(PanelVfoB); DP(PanelVfoButtons); DP(PanelBands); DP(PanelMode); DP(PanelFilter); DP(PanelRX); DP(PanelTX); DP(PanelRight); DP(PanelSpanButtons); PbSpectrum.Color := CLR_BG; PbWaterfall.Color := CLR_BG; if PanelSMeterRight <> nil then PanelSMeterRight.Color := CLR_PANEL; if PbSMeterRight <> nil then PbSMeterRight.Color := CLR_PANEL; PbFwdPower.Color := CLR_BG; PbSWR.Color := CLR_BG; end; procedure TMainForm.StyleButton(B: TFlatButton; Active: Boolean); begin B.Active := Active; B.ClrNorm := CLR_INACTIVE; B.ClrActive := CLR_ACTIVE; B.ClrHot := TColor($00323232); B.ClrBorder := TColor($00505050); B.ClrText := CLR_TEXT; B.ClrTextAct := TColor($0000FF88); B.Font.Name := 'Courier New'; B.Font.Size := 8; end; // =========================================================================== // VFO // =========================================================================== function TMainForm.FormatFreq(Hz: Double): string; var Mhz, KHz, Rest: Integer; begin Mhz := Trunc(Hz / 1000000); KHz := Trunc((Hz - Mhz * 1000000) / 1000); Rest := Trunc(Hz) mod 1000; Result := Format('%3d.%3.3d.%3.3d', [Mhz, KHz, Rest]); end; procedure TMainForm.UpdateVfoDisplay; begin FreqDispA.Frequency := Round(FVfoA); FreqDispB.Frequency := Round(FVfoB); if FActiveVfo = 0 then begin FreqDispA.ColorNormal := CLR_FREQ; FreqDispA.FontSize := 20; FreqDispB.ColorNormal := CLR_FREQ_DIM; FreqDispB.FontSize := 14; FreqDispA.Height := 36; FreqDispB.Height := 28; end else begin FreqDispA.ColorNormal := CLR_FREQ_DIM; FreqDispA.FontSize := 14; FreqDispB.ColorNormal := CLR_FREQ; FreqDispB.FontSize := 20; FreqDispA.Height := 28; FreqDispB.Height := 36; end; // FCenterFreq обновляется через ApplyVfoA, здесь не трогаем // При изменении частоты — обновляем HP пакет с новыми частотами и ALEX if FNetwork.Connected and FNetwork.Running then begin FNetwork.UpdateState(FCenterFreq, FCenterFreq, FDriveLevel, FTransmitting, True, True); FNetwork.SendFullHP; end; // WDSP: частота NCO задаётся через HPSDR железо (DDC), не через WDSP напрямую end; // =========================================================================== // Helpers // =========================================================================== // Замена MulDiv (Windows API) — простая целочисленная пропорция function TMainForm.ScaleX(X, Total, Width: Integer): Integer; begin if Total = 0 then Result := 0 else Result := Round(X / Total * Width); end; // =========================================================================== // Drawing // =========================================================================== procedure TMainForm.DrawBarMeter(ACanvas: TCanvas; R: TRect; Value, MaxVal: Double; BarColor: TColor); var Pct, BarW: Integer; begin ACanvas.Brush.Color := TColor($00080808); ACanvas.FillRect(R); if MaxVal > 0 then Pct := Round(Max(0.0, Min(1.0, Value / MaxVal)) * (R.Right - R.Left - 2)) else Pct := 0; BarW := Pct; ACanvas.Brush.Color := BarColor; ACanvas.Pen.Color := BarColor; ACanvas.FillRect(Rect(R.Left+1, R.Top+1, R.Left+1+BarW, R.Bottom-1)); ACanvas.Brush.Style := bsClear; ACanvas.Pen.Color := CLR_BORDER; ACanvas.Rectangle(R); end; procedure TMainForm.ResetWfAvgBuf; var i: Integer; begin for i := 0 to 1023 do FWfAvgBuf[i] := -120.0; FWfHigh := -50.0; FWfLow := -120.0; end; procedure TMainForm.ResetSpectrumBuf; var i: Integer; begin for i := 0 to 1023 do FSpectrumBuf[i] := -130.0; for i := 0 to 1023 do FWfAvgBuf[i] := -120.0; FWfHigh := -50.0; FWfLow := -120.0; end; procedure TMainForm.FillDemoSpectrum; var i: Integer; FreqOff, Noise, Sig: Double; begin for i := 0 to FSpectrumWidth - 1 do begin if FSpectrumWidth > 1 then FreqOff := (i / (FSpectrumWidth - 1) - 0.5) * FSpanHz else FreqOff := 0; Noise := -110 + (Random - 0.5) * 6; if Abs(FreqOff) < 2000 then Sig := -50 - Abs(FreqOff) / 200 else Sig := -999; FSpectrumBuf[i mod 1024] := Max(Noise, Sig); end; end; procedure TMainForm.DrawMarkerLine(C: TCanvas; W, H: Integer); var MX: Integer; MarkerFreq: Double; MarkerLbl: string; begin MX := Round(FMarkerX / 1000.0 * W); if (MX < 0) or (MX >= W) then Exit; MarkerFreq := (FCenterFreq - FSpanHz / 2) + FMarkerX / 1000.0 * FSpanHz; MarkerLbl := FormatFreq(Round(MarkerFreq)); C.Pen.Color := TColor($004444FF); // красный (BGR формат) C.Pen.Width := 1; C.Pen.Style := psSolid; C.MoveTo(MX, 0); C.LineTo(MX, H); C.Font.Color := TColor($004444FF); C.Font.Size := 7; C.Font.Name := 'Courier New'; if MX + 4 + C.TextWidth(MarkerLbl) < W then C.TextOut(MX + 4, 4, MarkerLbl) else C.TextOut(MX - 4 - C.TextWidth(MarkerLbl), 4, MarkerLbl); end; procedure TMainForm.DrawSpectrumGradient(const SpPts: array of TPoint; W, H: Integer); // ОПТИМИЗАЦИЯ: Заменяем TLazIntfImage.Colors[x,y] на прямой доступ через ScanLine. // TLazIntfImage.Colors[] — медленный accessor с накладными расходами на каждый пиксель. // ScanLine[row] возвращает сырой указатель на строку пикселей в памяти bitmap. // При W=1920, H=300: было ~576 000 медленных вызовов Colors[], стало ~576 000 прямых // обращений к памяти через указатель. Ускорение: ~15-25x на полном экране. // Требует PixelFormat = pf32bit (установлен в FormCreate). var Row, Col: Integer; YcurveAt: Integer; GradH: Integer; T: Double; Alpha: Integer; // 0..200 — вес добавки зелёного/синего канала Ptr: PLongWord; RowBase: PLongWord; Pixel: LongWord; R, G, B: Byte; YCurve: array of Integer; begin if FSpectrumBitmap = nil then Exit; if FSpectrumBitmap.PixelFormat <> pf32bit then FSpectrumBitmap.PixelFormat := pf32bit; SetLength(YCurve, W); for Col := 0 to W - 1 do YCurve[Col] := SpPts[Col].Y; for Row := 0 to H - 1 do begin // ScanLine[Row] — один вызов на строку, возвращает указатель PLongWord RowBase := PLongWord(FSpectrumBitmap.ScanLine[Row]); Ptr := RowBase; for Col := 0 to W - 1 do begin YcurveAt := YCurve[Col]; if Row < YcurveAt then begin Inc(Ptr); Continue; end; GradH := H - YcurveAt; if GradH < 1 then GradH := 1; T := (Row - YcurveAt) / GradH; Alpha := Round((1.0 - T * T) * 200); // 0..200 if Alpha > 0 then begin // Читаем пиксель: формат pf32bit = 00RRGGBB (little-endian) Pixel := Ptr^; R := Byte(Pixel shr 16); G := Byte(Pixel shr 8); B := Byte(Pixel); // Добавляем зелёный (×2) и синий (×1) — Thetis-стиль зелёно-бирюзового G := Min(255, Integer(G) + Alpha * 2 shr 7); B := Min(255, Integer(B) + Alpha shr 7); Ptr^ := (Pixel and $FF000000) or (LongWord(R) shl 16) or (LongWord(G) shl 8) or B; end; Inc(Ptr); end; end; end; procedure TMainForm.DrawSpectrum; var C: TCanvas; i, X, Yp, W, H: Integer; DBmin, DBmax, FreqStart, FreqHz: Double; dB: Double; // filter band VfoX: Integer; Lo_Hz: Double; Hi_Hz: Double; Half: Double; X1, X2: Integer; // AGC lines AGCy: Integer; // порог AGC Threshold AGCHangY: Integer; // уровень AGC Hang // spectrum trace SrcF: Double; S0, S1: Integer; Frac: Double; dBv: Double; SpPts: array of TPoint; begin // Используем актуальные размеры bitmap (обновлены в ResizeSpectrumPanels) if (FSpectrumBitmap = nil) then Exit; W := FSpectrumBitmap.Width; H := FSpectrumBitmap.Height; if (W <= 0) or (H <= 0) then Exit; C := FSpectrumBitmap.Canvas; DBmin := -130.0; DBmax := -20.0; C.Brush.Color := TColor($00050505); C.FillRect(Rect(0, 0, W, H)); // dB grid C.Font.Size := 7; C.Font.Name := 'Courier New'; C.Font.Color := CLR_TEXTDIM; dB := -30.0; while dB >= DBmin do begin Yp := Round((DBmax - dB) / (DBmax - DBmin) * H); C.Pen.Color := CLR_BORDER; C.MoveTo(0, Yp); C.LineTo(W-1, Yp); C.TextOut(1, Yp - 9, Format('%4.0f', [dB])); dB := dB - 10; end; // Frequency grid — только вертикальные линии (метки в PbRuler) FreqStart := FCenterFreq - FSpanHz / 2; for i := 0 to 8 do begin X := ScaleX(i, 8, W); C.Pen.Color := CLR_BORDER; C.MoveTo(X, 0); C.LineTo(X, H); end; // Полоса фильтра и VFO маркер begin Half := FFilterBW / 2; // При CTUN OFF FCenterFreq=FVfoA → VfoX автоматически = W/2 VfoX := Round((FVfoA - FCenterFreq + FSpanHz / 2) / FSpanHz * W); case FMode of 0: begin Lo_Hz := -FFilterBW; Hi_Hz := -100; end; 1: begin Lo_Hz := 100; Hi_Hz := FFilterBW; end; else begin Lo_Hz := -Half; Hi_Hz := Half; end; end; X1 := Max(0, Min(W-1, VfoX + Round(Lo_Hz / FSpanHz * W))); X2 := Max(0, Min(W-1, VfoX + Round(Hi_Hz / FSpanHz * W))); // Закрашенная полоса C.Brush.Color := TColor($00102020); C.Brush.Style := bsSolid; C.Pen.Style := psClear; if X2 > X1 then C.FillRect(Rect(X1, 0, X2, H)); C.Pen.Style := psSolid; // Края полосы C.Pen.Color := TColor($0040FFCC); C.Pen.Width := 1; C.MoveTo(X1, 0); C.LineTo(X1, H); C.MoveTo(X2, 0); C.LineTo(X2, H); // Маркер VFO C.Pen.Color := TColor($0000AAFF); C.Pen.Width := 2; C.MoveTo(VfoX, 0); C.LineTo(VfoX, H - 12); C.Brush.Color := TColor($0000AAFF); C.Brush.Style := bsSolid; C.Pen.Width := 1; C.Polygon([Point(VfoX-5,0), Point(VfoX+5,0), Point(VfoX,8)]); end; // AGC lines — Thresh (amber) + HangLevel (cyan dashed), как в piHPSDR if FWDSPReady then begin // --- Thresh: GetRXAAGCThresh → dBm позиция на дисплее --- AGCy := Round((DBmax - FDSPEngine.AGCThresh) / (DBmax - DBmin) * H); if (AGCy >= 0) and (AGCy < H) then begin C.Pen.Color := TColor($0000AAFF); // янтарный C.Pen.Width := 1; C.Pen.Style := psDash; C.MoveTo(0, AGCy); C.LineTo(W, AGCy); C.Pen.Style := psSolid; C.Font.Color := TColor($0000AAFF); C.Font.Size := 6; C.TextOut(4, AGCy - 9, 'AGC T'); end; // --- HangLevel: GetRXAAGCHangLevel → dBm позиция --- AGCHangY := Round((DBmax - FDSPEngine.AGCHangLevel) / (DBmax - DBmin) * H); if (AGCHangY >= 0) and (AGCHangY < H) and (Abs(AGCHangY - AGCy) > 4) then // не рисуем если совпадают begin C.Pen.Color := TColor($00FFCC00); // голубой cyan C.Pen.Width := 1; C.Pen.Style := psDot; C.MoveTo(0, AGCHangY); C.LineTo(W, AGCHangY); C.Pen.Style := psSolid; C.Font.Color := TColor($00FFCC00); C.Font.Size := 6; C.TextOut(4, AGCHangY + 2, 'AGC H'); end; end else begin // WDSP не готов — рисуем по FAGCTop как раньше AGCy := Round((DBmax - (-FAGCTop)) / (DBmax - DBmin) * H); if (AGCy >= 0) and (AGCy < H) then begin C.Pen.Color := TColor($0000AAFF); C.Pen.Width := 1; C.Pen.Style := psDash; C.MoveTo(0, AGCy); C.LineTo(W, AGCy); C.Pen.Style := psSolid; C.Font.Color := TColor($0000AAFF); C.Font.Size := 6; C.TextOut(4, AGCy - 9, Format('AGC -%ddB', [FAGCTop])); end; end; // Spectrum trace — Thetis стиль: полигон-заливка + яркая линия поверх begin SetLength(SpPts, W + 2); for i := 0 to W - 1 do begin SrcF := i * 1023.0 / Max(1, W - 1); S0 := Min(Trunc(SrcF), 1023); S1 := Min(S0 + 1, 1023); Frac := SrcF - S0; dBv := FSpectrumBuf[S0] * (1.0 - Frac) + FSpectrumBuf[S1] * Frac; SpPts[i] := Point(i, Max(0, Min(H-1, Round((DBmax - dBv) / (DBmax - DBmin) * H)))); end; SpPts[W] := Point(W-1, H); SpPts[W+1] := Point(0, H); // Заливка — полупрозрачный градиент под кривой C.Brush.Style := bsSolid; C.Pen.Style := psClear; DrawSpectrumGradient(SpPts, W, H); // Линия спектра — яркий Thetis-зелёный C.Pen.Style := psSolid; C.Pen.Color := TColor($0040FF80); C.Pen.Width := 1; C.MoveTo(SpPts[0].X, SpPts[0].Y); for i := 1 to W - 1 do C.LineTo(SpPts[i].X, SpPts[i].Y); end; // Маркер правой кнопки — красная вертикальная линия + частота if FMarkerActive then DrawMarkerLine(C, W, H); end; procedure TMainForm.DrawWaterfall; // Алгоритм как в Thetis/PowerSDR: // // 1. EMA (Exponential Moving Average) спектра → FWfAvgBuf // Сглаживает шум: каждый пиксель усредняется по времени с α=0.25 // Это ключ к "чистому" водопаду — рисуем усреднённое, а не сырой FFT // // 2. WF AGC (FWfHigh): верхняя граница — "белое" // Цель: чуть выше пика усреднённого спектра // Медленный LP-фильтр α=0.02 (реагирует за ~50 кадров) // // 3. NF Track (FWfLow): нижняя граница — "чёрное" // Цель: шумовой пол = медиана нижних 30% усреднённого спектра // Очень медленный LP-фильтр α=0.005 (~200 кадров) // // Нормировка: V = (avg_dB - WfLow) / (WfHigh - WfLow) → 0..255 → палитра // // ОПТИМИЗАЦИЯ: // - Скролл: Canvas.Draw(0,1,...) заменён на BitBlt — прямой блочный копировщик // без overhead GDI объектов. При 1920×400 это экономит ~30-40% времени скролла. // - Новая строка: TLazIntfImage (создание объекта + GetPixel/SetPixel каждый кадр) // заменена на прямую запись в ScanLine[0] — один указатель на строку пикселей. // Убраны CreateIntfImage / LoadFromIntfImage — ~3-5 мс на кадр. var W, H, X: Integer; dB, frac, WatSrcF: Double; WatS0, WatS1, V: Integer; Col: LongWord; Tmp: TBitmap; Row0: PLongWord; // указатель на строку 0 в FWaterfallTemp (новая строка) // AGC/NF AvgDB: Double; PeakDB: Double; NFSum: Double; NFCount: Integer; WfHigh: Double; WfLow: Double; const // EMA для сглаживания спектра (чем меньше — тем медленнее/чище) ALPHA_SPEC = 0.25; // сглаживание спектра по времени // Скорость слежения High/Low ALPHA_HIGH = 0.02; // AGC верхней границы ALPHA_LOW = 0.005; // NF нижней границы (очень медленно) begin if (FWaterfallBitmap = nil) or (FWaterfallTemp = nil) then Exit; W := FWaterfallBitmap.Width; H := FWaterfallBitmap.Height; if (W <= 0) or (H <= 0) then Exit; if (FWaterfallTemp.Width <> W) or (FWaterfallTemp.Height <> H) then begin FWaterfallTemp.PixelFormat := pf32bit; FWaterfallTemp.SetSize(W, H); FWaterfallTemp.Canvas.Brush.Color := clBlack; FWaterfallTemp.Canvas.FillRect(0, 0, W, H); end; // ================================================================ // Шаг 1: EMA — обновляем FWfAvgBuf из FSpectrumBuf // avg[i] = avg[i] * (1-α) + spec[i] * α // ================================================================ PeakDB := -200.0; NFSum := 0.0; NFCount := 0; for X := 0 to 1023 do begin AvgDB := FWfAvgBuf[X] * (1.0 - ALPHA_SPEC) + FSpectrumBuf[X] * ALPHA_SPEC; FWfAvgBuf[X] := AvgDB; if AvgDB > PeakDB then PeakDB := AvgDB; end; // ================================================================ // Шаг 2: Оценка NF — берём нижние 30% бинов (самые тихие) // Сортировка дорого — используем threshold подход: // суммируем всё что ниже текущего FWfLow + 15dB margin // ================================================================ for X := 0 to 1023 do begin AvgDB := FWfAvgBuf[X]; if AvgDB < FWfLow + 15.0 then // бины близко к шуму begin NFSum := NFSum + AvgDB; Inc(NFCount); end; end; // ================================================================ // Шаг 3: Обновляем High/Low (только если включены) // ================================================================ if FWfAGCEnabled then begin // Target high = пик + 5dB (небольшой запас чтобы пик не "белел") if PeakDB > FWfHigh then // Быстрый attack: сигнал вырос — моментально поднимаем потолок FWfHigh := PeakDB + 5.0 else // Медленный decay: сигнал ушёл — медленно опускаем FWfHigh := FWfHigh + ALPHA_HIGH * ((PeakDB + 5.0) - FWfHigh); end; if FWfNFEnabled then begin if NFCount > 10 then begin // Target low = средний NF бин - 5dB (чуть ниже пола = "чёрное") FWfLow := FWfLow + ALPHA_LOW * ((NFSum / NFCount - 5.0) - FWfLow); end; end; // Защита от схлопывания WfHigh := FWfHigh; WfLow := FWfLow; if WfHigh < WfLow + 15.0 then WfHigh := WfLow + 15.0; if WfHigh > 0.0 then WfHigh := 0.0; // разумный предел if WfLow < -160 then WfLow := -160; // ================================================================ // Шаг 4: Скролл — BitBlt вместо Canvas.Draw // ОПТИМИЗАЦИЯ: BitBlt — прямой аппаратный/GDI блочный перенос памяти. // Canvas.Draw создаёт временные объекты и работает через GDI pipeline. // При 1920×400 пикселей разница ~2-3× по скорости скролла. // Копируем FWaterfallBitmap → FWaterfallTemp со сдвигом на 1 строку вниз. // ================================================================ BitBlt( FWaterfallTemp.Canvas.Handle, // dest HDC 0, 1, // dest X=0, Y=1 (сдвиг вниз на 1 строку) W, H - 1, // ширина, высота (на 1 строку меньше) FWaterfallBitmap.Canvas.Handle, // src HDC 0, 0, // src X=0, Y=0 SRCCOPY ); // ================================================================ // Шаг 5: Новая строка через прямой доступ ScanLine[0] // ОПТИМИЗАЦИЯ: Вместо CreateIntfImage → Colors[x,0] → LoadFromIntfImage // (создание объекта + W вызовов accessor + обратная конвертация каждый кадр) // используем ScanLine[0] — один указатель, W прямых записей в память. // Убраны: CreateIntfImage, LoadFromIntfImage, Img.Free. // Рисуем УСРЕДНЁННЫЙ спектр — не сырой! // ================================================================ if FWaterfallTemp.PixelFormat <> pf32bit then FWaterfallTemp.PixelFormat := pf32bit; Row0 := PLongWord(FWaterfallTemp.ScanLine[0]); for X := 0 to W - 1 do begin WatSrcF := X * 1023.0 / Max(1, W - 1); WatS0 := Min(Trunc(WatSrcF), 1023); WatS1 := Min(WatS0 + 1, 1023); frac := WatSrcF - WatS0; // Берём из усреднённого буфера, не из сырого FSpectrumBuf dB := FWfAvgBuf[WatS0] * (1.0 - frac) + FWfAvgBuf[WatS1] * frac; V := Round(Max(0.0, Min(1.0, (dB - WfLow) / (WfHigh - WfLow))) * 255); Col := WFALL_PALETTE[V]; // WFALL_PALETTE хранит цвет в формате $00RRGGBB (аналогично TColor) // pf32bit ScanLine: байты в памяти = BB GG RR AA (little-endian BGRA) // Записываем как $FF_RR_GG_BB с учётом порядка байт: Row0[X] := $FF000000 or ((Col and $000000FF) shl 16) // R (был BB палитры → R позиция) or (Col and $0000FF00) // G остаётся на месте or ((Col and $00FF0000) shr 16); // B (был RR палитры → B позиция) end; // ================================================================ // Шаг 6: Swap буферов // ================================================================ Tmp := FWaterfallBitmap; FWaterfallBitmap := FWaterfallTemp; FWaterfallTemp := Tmp; end; // =========================================================================== // Широкий S-метр — точная копия Thetis/PowerSDR стиля // Структура (высота H): // верх (H*35%): dBm шкала (-120..-20..0), тики, цифровое значение слева // середина (H*35%): полоса сигнала (зелёный бар + широкий пик + белый маркер) // низ (H*30%): S-шкала (S1..S9..+60), S-значение слева // =========================================================================== procedure TMainForm.DrawSMeterWide(ACanvas: TCanvas; R: TRect; Value, Peak, MinVal: Double; out ZX1, ZX2, ZY1, ZY2: Integer); const DB_MIN = -127.0; // левый край шкалы DB_MAX = -0.0; // правый край DB_S9 = -73.0; // граница S9 (зелёная/оранжевая) DB_OVR = -43.0; // граница "красной" зоны (+30 over S9) // dBm верхняя шкала: 7 меток DBM_MARKS: array[0..6] of Double = (-120,-100,-80,-60,-40,-20,0); DBM_LABELS: array[0..6] of string = ('-120','-100','-80','-60','-40','-20','0'); // S-шкала нижняя: 9 меток S_MARKS_DBM: array[0..8] of Double = (-121,-115,-109,-103,-93,-83,-73,-53,-33); S_MARKS_LBL: array[0..8] of string = ('S1','S3','S5','S7','S9','+10','+20','+40','+60'); // Цвета (BGR в TColor) CLR_SMETER_BG = TColor($00181818); // фон = CLR_PANEL CLR_BAR_GREEN = TColor($00091F00); // тёмно-зелёный бар до S9 (RGB 0,31,9) CLR_BAR_OVER = TColor($00091528); // тёмно-красный бар после S9 (RGB 40,21,9) — тот же тон что зелёный но красн. CLR_PEAK_FILL = TColor($00796F75); // светло-серый заполнитель пика (RGB 117,111,121) CLR_PEAK_MARKER = TColor($00F8F8FF); // белый пик-маркер (RGB 255,248,248) CLR_BAR_ORANGE = TColor($000A1A28); // оранжевая зона (>S9) CLR_BAR_RED = TColor($00000832); // красная зона (>+30) CLR_TICK_GREEN = TColor($00708070); // тики зелёной зоны CLR_TICK_WHITE = TColor($00E0E0DC); // тики белые (dBm шкала) CLR_TICK_BLUE = TColor($0070A090); // тики S9+ зоны CLR_LABEL_DBM = TColor($0096D8DC); // цифровое значение dBm (желтоватый) CLR_LABEL_S = TColor($00F0F0F0); // S-значение — белый CLR_SCALE_DBM = TColor($00D0DCDC); // метки dBm шкалы (бело-серые) CLR_SCALE_S_GREEN = TColor($00E0E0E0); // метки S1..S9 — белые CLR_SCALE_S_BLUE = TColor($0090D090); // метки +10..+60 — как S1..S9 оригинал (желт-зелён) CLR_BORDER = TColor($00303028); // рамки LEFT_INFO = 62; // ширина левой информационной колонки (dBm + S-value) TICK_LONG = 5; // длина длинного тика TICK_MED = 3; // длина среднего тика var W, H: Integer; BX, BW: Integer; // начало и ширина зоны шкалы Y_UPPER_MID: Integer; // середина верхней зоны (метки dBm) Y_BAR_TOP: Integer; // верх полосы сигнала Y_BAR_BOT: Integer; // низ полосы сигнала Y_LOWER_MID: Integer; // середина нижней зоны (метки S) BarEnd: Integer; // правый край текущего сигнала PeakLeft: Integer; // левый край peak fill PeakRight: Integer; // правый край peak fill (= белый маркер) MinX: Integer; // позиция минимума S9X, OvrX: Integer; // позиции границ зон i, X, TW: Integer; Lbl: string; SNum: Integer; function DBtoX(dB: Double): Integer; inline; begin Result := BX + Round((dB - DB_MIN) / (DB_MAX - DB_MIN) * BW); if Result < BX then Result := BX; if Result > BX + BW then Result := BX + BW; end; procedure HLine(Y, X1, X2: Integer; C: TColor); begin ACanvas.Pen.Color := C; ACanvas.MoveTo(X1, Y); ACanvas.LineTo(X2, Y); end; procedure VLine(X, Y1, Y2: Integer; C: TColor); begin ACanvas.Pen.Color := C; ACanvas.MoveTo(X, Y1); ACanvas.LineTo(X, Y2); end; procedure FillBar(X1, Y1, X2, Y2: Integer; C: TColor); begin ACanvas.Brush.Color := C; ACanvas.Brush.Style := bsSolid; ACanvas.Pen.Style := psClear; if X2 > X1 then ACanvas.FillRect(Rect(X1, Y1, X2, Y2)); ACanvas.Pen.Style := psSolid; end; begin W := R.Right - R.Left; H := R.Bottom - R.Top; // Инициализируем out-параметры ZX1 := 0; ZX2 := 0; ZY1 := 0; ZY2 := 0; if (W < 80) or (H < 16) then Exit; // --- Геометрия --- BX := R.Left + LEFT_INFO; BW := W - LEFT_INFO - 4; Y_UPPER_MID := R.Top + H * 3 div 10; // центр верхней зоны меток Y_BAR_TOP := R.Top + H * 36 div 100; // верх бара Y_BAR_BOT := R.Top + H * 66 div 100; // низ бара Y_LOWER_MID := R.Top + H * 80 div 100; // центр нижней зоны меток S9X := DBtoX(DB_S9); OvrX := DBtoX(DB_OVR); // ================================================================ // ФОН // ================================================================ ACanvas.Brush.Color := CLR_SMETER_BG; ACanvas.Brush.Style := bsSolid; ACanvas.Pen.Style := psClear; ACanvas.FillRect(R); ACanvas.Pen.Style := psSolid; // Лёгкий цветной фон зон шкалы в полосе бара FillBar(BX, Y_BAR_TOP, S9X, Y_BAR_BOT, TColor($00061006)); // тёмно-зелёный FillBar(S9X, Y_BAR_TOP, OvrX, Y_BAR_BOT, TColor($000A1006)); // тёмно-жёлтый FillBar(OvrX, Y_BAR_TOP, BX + BW, Y_BAR_BOT, TColor($00060A10)); // тёмно-красный // ================================================================ // ПОЛОСА СИГНАЛА // ================================================================ BarEnd := DBtoX(Value); // текущий уровень PeakLeft := DBtoX(MinVal); // левый край зоны = отслеживаемый минимум PeakRight:= DBtoX(Peak); // правый край зоны = отслеживаемый пик // Заполняем out-параметры зоны для alpha-blend (после возврата) ZX1 := Max(BX + 1, Min(PeakLeft, PeakRight)); ZX2 := Min(BX + BW - 1, Max(PeakLeft, PeakRight)); ZY1 := Y_BAR_TOP + 1; ZY2 := Y_BAR_BOT - 1; // Бар: зелёный до S9, красноватый после S9 — в том же тёмном стиле if BarEnd > BX then begin if BarEnd <= S9X then // Весь бар в зелёной зоне FillBar(BX, Y_BAR_TOP + 1, BarEnd, Y_BAR_BOT - 1, CLR_BAR_GREEN) else begin // Зелёная часть до S9 FillBar(BX, Y_BAR_TOP + 1, S9X, Y_BAR_BOT - 1, CLR_BAR_GREEN); // Красноватая часть после S9 FillBar(S9X, Y_BAR_TOP + 1, BarEnd, Y_BAR_BOT - 1, CLR_BAR_OVER); end; end; // Белый маркер пика (2px) — правый край зоны if (PeakRight > BX) and (PeakRight <= BX + BW) then begin ACanvas.Pen.Color := CLR_PEAK_MARKER; ACanvas.Pen.Width := 2; ACanvas.MoveTo(PeakRight, Y_BAR_TOP); ACanvas.LineTo(PeakRight, Y_BAR_BOT); ACanvas.Pen.Width := 1; end; // Рамка полосы ACanvas.Brush.Style := bsClear; ACanvas.Pen.Color := CLR_BORDER; ACanvas.Rectangle(BX, Y_BAR_TOP, BX + BW, Y_BAR_BOT); // ================================================================ // ВЕРХНЯЯ ШКАЛА — dBm метки (-120..-100..-20..0) // ================================================================ ACanvas.Font.Name := 'Courier New'; ACanvas.Font.Size := 6; ACanvas.Font.Style := []; ACanvas.Brush.Style:= bsClear; for i := 0 to 6 do begin X := DBtoX(DBM_MARKS[i]); // Длинный тик вверх от полосы VLine(X, Y_BAR_TOP - TICK_LONG, Y_BAR_TOP - 1, CLR_TICK_WHITE); // Метка Lbl := DBM_LABELS[i]; TW := ACanvas.TextWidth(Lbl); ACanvas.Font.Color := CLR_SCALE_DBM; ACanvas.TextOut(X - TW div 2, Y_BAR_TOP - TICK_LONG - ACanvas.TextHeight(Lbl) - 1, Lbl); end; // Мелкие тики каждые 10 dB между основными (каждые 5) i := -125; while i < 0 do begin X := DBtoX(i); VLine(X, Y_BAR_TOP - TICK_MED, Y_BAR_TOP - 1, CLR_TICK_GREEN); Inc(i, 5); end; // ================================================================ // НИЖНЯЯ ШКАЛА — S1..S9..+60 // ================================================================ for i := 0 to 8 do begin X := DBtoX(S_MARKS_DBM[i]); // Тик вниз от полосы if S_MARKS_DBM[i] >= DB_S9 then VLine(X, Y_BAR_BOT + 1, Y_BAR_BOT + TICK_LONG, CLR_TICK_BLUE) else VLine(X, Y_BAR_BOT + 1, Y_BAR_BOT + TICK_LONG, CLR_TICK_GREEN); // Метка Lbl := S_MARKS_LBL[i]; TW := ACanvas.TextWidth(Lbl); if S_MARKS_DBM[i] >= DB_S9 then ACanvas.Font.Color := CLR_SCALE_S_BLUE else ACanvas.Font.Color := CLR_SCALE_S_GREEN; ACanvas.TextOut(X - TW div 2, Y_BAR_BOT + TICK_LONG + 1, Lbl); end; // ================================================================ // ЛЕВАЯ КОЛОНКА — цифровое значение // ================================================================ // dBm значение (крупно, вверху) ACanvas.Font.Size := 8; ACanvas.Font.Style := [fsBold]; ACanvas.Font.Color := CLR_LABEL_DBM; Lbl := Format('%6.1f', [Value]); ACanvas.TextOut(R.Left + 1, R.Top + 1, Lbl); // 'dBm' мелко под значением ACanvas.Font.Size := 6; ACanvas.Font.Style := []; ACanvas.Font.Color := TColor($00507070); ACanvas.TextOut(R.Left + 1, Y_BAR_TOP - ACanvas.TextHeight('dBm') - 1, 'dBm'); // S-значение (внизу) ACanvas.Font.Size := 8; ACanvas.Font.Style := [fsBold]; ACanvas.Font.Color := CLR_LABEL_S; if Value < -93.0 then begin SNum := Round((Value - (-127.0)) / 6.0); if SNum < 1 then SNum := 1; if SNum > 9 then SNum := 9; Lbl := Format('S%d', [SNum]); end else begin SNum := Round(Value - (-93.0)); Lbl := Format('S9+%d', [SNum]); end; ACanvas.TextOut(R.Left + 1, Y_BAR_BOT + TICK_LONG + 1, Lbl); end; // Полупрозрачная белая заливка зоны X1..X2, Y1..Y2. // Вызывается ПОСЛЕ DrawSMeterWide — FSmBitmap уже содержит нарисованный фон. // Используем TLazIntfImage для попиксельного alpha-blend. procedure TMainForm.DrawSMeterZone(ACanvas: TCanvas; R: TRect; X1, X2, Y1, Y2: Integer); const ALPHA = 16384; // ~25% осветления (от 0..65535) var Img: TLazIntfImage; FC: TFPColor; IX, IY: Integer; begin if (X2 <= X1) or (FSmBitmap = nil) then Exit; // Получаем IntfImage из off-screen bitmap Img := FSmBitmap.CreateIntfImage; try for IY := Y1 to Y2 - 1 do for IX := X1 to X2 - 1 do begin FC := Img.Colors[IX, IY]; FC.Red := Min(65535, FC.Red + ALPHA); FC.Green := Min(65535, FC.Green + ALPHA); FC.Blue := Min(65535, FC.Blue + ALPHA); Img.Colors[IX, IY] := FC; end; // Записываем изменения обратно в bitmap FSmBitmap.LoadFromIntfImage(Img); finally Img.Free; end; end; procedure TMainForm.PbSMeterRightPaint(Sender: TObject); var W, H: Integer; ZX1, ZX2, ZY1, ZY2: Integer; begin W := PbSMeterRight.Width; H := PbSMeterRight.Height; if (W <= 0) or (H <= 0) then Exit; // Создаём/пересоздаём off-screen буфер if (FSmBitmap = nil) or (FSmBitmap.Width <> W) or (FSmBitmap.Height <> H) then begin FreeAndNil(FSmBitmap); FSmBitmap := TBitmap.Create; FSmBitmap.SetSize(W, H); end; // Рисуем основной S-метр в off-screen буфер DrawSMeterWide(FSmBitmap.Canvas, Rect(0, 0, W, H), FLastSMeter, FSMeterPeak, FSMeterMin, ZX1, ZX2, ZY1, ZY2); // Полупрозрачная белая заливка зоны Min..Peak поверх нарисованного DrawSMeterZone(FSmBitmap.Canvas, Rect(0, 0, W, H), ZX1, ZX2, ZY1, ZY2); // Выводим на экран одним блоком (без мерцания) PbSMeterRight.Canvas.Draw(0, 0, FSmBitmap); end; procedure TMainForm.PbFwdPowerPaint(Sender: TObject); begin DrawBarMeter(PbFwdPower.Canvas, Rect(0,0,PbFwdPower.Width,PbFwdPower.Height), FLastFwdW, 150, CLR_METER_ON); end; procedure TMainForm.PbSWRPaint(Sender: TObject); begin DrawBarMeter(PbSWR.Canvas, Rect(0,0,PbSWR.Width,PbSWR.Height), FLastSWR - 1.0, 4.0, CLR_AMBER); end; procedure TMainForm.PbSpectrumPaint(Sender: TObject); begin if (FSpectrumBitmap.Width > 0) and (FSpectrumBitmap.Height > 0) then PbSpectrum.Canvas.Draw(0, 0, FSpectrumBitmap) else begin PbSpectrum.Canvas.Brush.Color := CLR_BG; PbSpectrum.Canvas.FillRect(Rect(0,0,PbSpectrum.Width,PbSpectrum.Height)); end; end; // --------------------------------------------------------------------------- // Ruler — полоса частотных меток между спектром и водопадом // --------------------------------------------------------------------------- procedure TMainForm.DrawRuler; // ОПТИМИЗАЦИЯ: Ruler рисуется в offscreen FRulerBitmap и кэшируется. // Перерисовка происходит только при изменении FCenterFreq или FVfoA. // Это экономит TextOut + LineTo на каждом тике таймера (20fps = 20 перерисовок/сек). var C: TCanvas; W, H: Integer; i, X: Integer; FreqStart, FreqHz: Double; VfoX: Integer; Lbl: string; TW: Integer; begin if PbRuler = nil then Exit; W := PbRuler.Width; H := PbRuler.Height; if (W <= 0) or (H <= 0) then Exit; // Проверяем изменились ли частоты — если нет, пропускаем перерисовку if (Abs(FCenterFreq - FRulerLastCenter) < 0.5) and (Abs(FVfoA - FRulerLastVfo) < 0.5) and not FRulerDirty then Exit; // ничего не изменилось — кэш актуален // Обновляем кэш-bitmap если размер изменился if (FRulerBitmap.Width <> W) or (FRulerBitmap.Height <> H) then begin FRulerBitmap.PixelFormat := pf32bit; FRulerBitmap.SetSize(W, H); end; C := FRulerBitmap.Canvas; // Фон — чуть светлее чем BG, отделяет спектр от водопада C.Brush.Color := TColor($00141414); C.FillRect(Rect(0, 0, W, H)); // Верхняя граница — тонкая линия C.Pen.Color := CLR_BORDER; C.Pen.Width := 1; C.MoveTo(0, 0); C.LineTo(W, 0); // Нижняя граница C.MoveTo(0, H-1); C.LineTo(W, H-1); C.Font.Name := 'Courier New'; C.Font.Size := 7; FreqStart := FCenterFreq - FSpanHz / 2; // 8 делений — 9 точек (0..8) for i := 0 to 8 do begin X := ScaleX(i, 8, W); // Вертикальный тик C.Pen.Color := CLR_BORDER; C.MoveTo(X, 0); C.LineTo(X, H div 2); // Частотная метка — центрируем под тиком FreqHz := FreqStart + i * FSpanHz / 8; Lbl := Format('%.3f', [FreqHz / 1e6]); TW := C.TextWidth(Lbl); C.Font.Color := TColor($00888888); C.TextOut(X - TW div 2, H div 2 - 1, Lbl); end; // VFO маркер — яркий треугольник/линия VfoX := Round((FVfoA - FCenterFreq + FSpanHz / 2) / FSpanHz * W); if (VfoX >= 0) and (VfoX < W) then begin C.Pen.Color := TColor($0000FF80); // яркий зелёный C.Pen.Width := 2; C.MoveTo(VfoX, 0); C.LineTo(VfoX, H - 1); C.Pen.Width := 1; end; // Кэшируем текущие значения FRulerLastCenter := FCenterFreq; FRulerLastVfo := FVfoA; FRulerDirty := False; end; procedure TMainForm.PbRulerPaint(Sender: TObject); begin // ОПТИМИЗАЦИЯ: DrawRuler рисует в FRulerBitmap только при изменении частоты. // Здесь просто копируем кэшированный bitmap на Canvas — быстрый BitBlt. DrawRuler; // обновит FRulerBitmap если нужно (иначе выйдет сразу) if (FRulerBitmap <> nil) and (FRulerBitmap.Width > 0) and (FRulerBitmap.Height > 0) then PbRuler.Canvas.Draw(0, 0, FRulerBitmap); end; procedure TMainForm.PbWaterfallPaint(Sender: TObject); begin if (FWaterfallBitmap.Width > 0) and (FWaterfallBitmap.Height > 0) then PbWaterfall.Canvas.Draw(0, 0, FWaterfallBitmap) else begin PbWaterfall.Canvas.Brush.Color := CLR_BG; PbWaterfall.Canvas.FillRect(Rect(0,0,PbWaterfall.Width,PbWaterfall.Height)); end; // Маркер рисуем поверх bitmap на Canvas — не трогаем bitmap чтобы не портить водопад if FMarkerActive then DrawMarkerLine(PbWaterfall.Canvas, PbWaterfall.Width, PbWaterfall.Height); end; // =========================================================================== // Timers // =========================================================================== procedure TMainForm.MeterTimerTick(Sender: TObject); const AVG_ALPHA = 0.25; // EMA для сглаженного среднего уровня ZONE_DB = 6.0; // полуширина зоны вокруг среднего (дБ) // Адаптивный alpha: базовый + ускорение при большой разнице PEAK_BASE = 0.06; // базовая скорость пика MIN_BASE = 0.06; // базовая скорость минимума ACCEL_FACTOR = 0.018; // ускорение на каждый dB разницы MAX_ALPHA = 0.85; // максимальная скорость (не мгновенно) var PeakTarget, MinTarget: Double; PeakDiff, MinDiff: Double; PeakAlpha, MinAlpha: Double; begin if not FRunning then begin if PbSMeterRight <> nil then PbSMeterRight.Invalidate; Exit; end; // Сглаженное среднее (EMA) FSMeterAvg := FSMeterAvg * (1.0 - AVG_ALPHA) + FLastSMeter * AVG_ALPHA; // Цели для Peak и Min PeakTarget := Max(FLastSMeter, FSMeterAvg + ZONE_DB); MinTarget := Min(FLastSMeter, FSMeterAvg - ZONE_DB); // Адаптивный alpha: чем дальше от цели — тем быстрее догоняем PeakDiff := Abs(PeakTarget - FSMeterPeak); MinDiff := Abs(MinTarget - FSMeterMin); PeakAlpha := Min(MAX_ALPHA, PEAK_BASE + PeakDiff * ACCEL_FACTOR); MinAlpha := Min(MAX_ALPHA, MIN_BASE + MinDiff * ACCEL_FACTOR); FSMeterPeak := FSMeterPeak + PeakAlpha * (PeakTarget - FSMeterPeak); FSMeterMin := FSMeterMin + MinAlpha * (MinTarget - FSMeterMin); if PbSMeterRight <> nil then PbSMeterRight.Invalidate; end; procedure TMainForm.SpectrumTimerTick(Sender: TObject); var NowTick: QWord; ElapsedTick: QWord; begin // Синхронизируем размеры если PaintBox изменился (после ресайза) if (PbSpectrum.Width <> FSpectrumWidth) or (PbSpectrum.Height <> FSpectrumHeight) or (FSpectrumWidth = 0) then ResizeSpectrumPanels; ResizeSMeter; if FSpectrumWidth <= 0 then Exit; // Проверка таймаута — трансивер не отвечает if FRunning and FNetwork.Running then begin NowTick := GetTickCount64; ElapsedTick := NowTick - FRXStartTime; // Ждём первый пакет 5 секунд после старта if (FRXLastPktTime = 0) and (ElapsedTick > 5000) then begin BtnStartStop.Caption := 'START'; StyleButton(BtnStartStop, False); FRunning := False; FNetwork.SetRunAndFreq(False, FCenterFreq, FCenterFreq, 0); StatusBar1.Panels[4].Text := 'ТРАНСИВЕР НЕДОСТУПЕН'; BtnMOX.Enabled := False; Exit; end; // После первого пакета — следим чтобы поток не прерывался более 3 сек if (FRXLastPktTime > 0) and ((NowTick - FRXLastPktTime) > 3000) then begin BtnStartStop.Caption := 'START'; StyleButton(BtnStartStop, False); FRunning := False; FNetwork.SetRunAndFreq(False, FCenterFreq, FCenterFreq, 0); StatusBar1.Panels[4].Text := 'ПОТЕРЯ СВЯЗИ С ТРАНСИВЕРОМ'; BtnMOX.Enabled := False; Exit; end; end; if FRunning then begin // Обновляем StatusBar из таймера — без Synchronize в сетевом потоке StatusBar1.Panels[3].Text := Format('RX DDC%d | seq=%d | pkts=%d', [FLastDDCIndex, FLastDDCSeq, FRXPacketCount]); if FWDSPReady then begin FDSPEngine.UpdateSpectrum; FLastSMeter := FDSPEngine.GetSMeterDBm; FDSPEngine.SetSpectrumWidth(FSpectrumWidth); // актуальная ширина для SetAGC/SetAGCTop FDSPEngine.UpdateAGCLines(FSpectrumWidth); end; // Обновляем оверлей если видим if Assigned(FVfoOverlay) and FVfoOverlay.Visible then begin FVfoOverlay.UpdateSMeter(FLastSMeter); FVfoOverlay.UpdateVfo(FVfoA); PositionVfoOverlay; end; // Рисуем спектр и скроллим водопад только во время работы DrawSpectrum; DrawWaterfall; PbSpectrum.Invalidate; // ОПТИМИЗАЦИЯ: Ruler инвалидируем только если частота изменилась. // DrawRuler() сам обнаружит изменение и выставит FRulerDirty. // Это экономит WM_PAINT + TextOut на каждом тике при статичной частоте. if (Abs(FCenterFreq - FRulerLastCenter) > 0.5) or (Abs(FVfoA - FRulerLastVfo) > 0.5) or FRulerDirty then PbRuler.Invalidate; PbWaterfall.Invalidate; FSpectrumDirty := False; end else begin // Не подключены — показываем чистый экран (без анимации) if not FNetwork.Connected then begin // Очищаем до фона один раз при отключении // (просто не вызываем Draw* — бitmaps остаются как были) // Но при первом старте покажем пустой фон: if FSpectrumBitmap.Width <> FSpectrumWidth then begin FSpectrumBitmap.SetSize(FSpectrumWidth, FSpectrumHeight); ResetSpectrumBuf; DrawSpectrum; PbSpectrum.Invalidate; end; end else begin // Подключены, но не запущены — статичный спектр (шум) DrawSpectrum; PbSpectrum.Invalidate; FSpectrumDirty := False; end; end; // Если во время drag накопились грязные кадры — сбрасываем if FSpectrumDirty then begin DrawSpectrum; DrawWaterfall; PbSpectrum.Invalidate; if (Abs(FCenterFreq - FRulerLastCenter) > 0.5) or (Abs(FVfoA - FRulerLastVfo) > 0.5) or FRulerDirty then PbRuler.Invalidate; PbWaterfall.Invalidate; FSpectrumDirty := False; end; // Пушим текущее состояние в веб-клиенты (если есть) if Assigned(FWebServer) then FWebServer.PushSpectrum( FSpectrumBuf, 1024, FWfAvgBuf, FLastSMeter, FVfoA, FMode, FFilterBW, FAGCMode, FAGCTop, FSpanHz, FVolume, FWfAGCEnabled, FWfNFEnabled, FCurrentBand, FRunning and FNetwork.Connected, FRunning, FMuted, FCTun, BtnNR.Tag = 1, BtnNB.Tag = 1, BtnANF.Tag = 1, FCenterFreq, FFilter); end; // =========================================================================== // Network callbacks → sync helpers // =========================================================================== procedure TMainForm.OnDeviceFound(const Dev: THPSDRDevice); var BoardName, Entry: string; Sync: TDeviceFoundSync; M: TThreadMethod; begin BoardName := BoardTypeName(Dev.BoardType); Entry := Format('%s %s FW:%d DDC:%d', [Dev.IPAddress, BoardName, Dev.FirmwareVersion, Dev.NumDDCs]); Sync := TDeviceFoundSync.Create(Self, Dev, Entry); try M := Sync.Execute; TThread.Synchronize(nil, M); finally Sync.Free; end; end; procedure TMainForm.DoAddDevice(const Dev: THPSDRDevice; const Entry: string); var Idx: Integer; begin Idx := Length(FDevices); SetLength(FDevices, Idx + 1); FDevices[Idx].Dev := Dev; FDevices[Idx].Display := Entry; Inc(FDeviceCount); if Assigned(FDeviceDialog) then FDeviceDialog.AddDiscovered(Dev.IPAddress, Entry, Dev.BoardType); StatusBar1.Panels[4].Text := Format('Found: %s', [Dev.IPAddress]); end; procedure TMainForm.OnHPStatusCB(const Status: THighPriorityStatus); var ExcPwr, FwdPwr, RevPwr: Word; SupplyV, FwdW, SWRV: Double; Sync: TStatusUISync; M: TThreadMethod; begin ExcPwr := (Status.ExciterPwr0Hi shl 8) or Status.ExciterPwr0Lo; FwdPwr := (Status.FwdPwrAlex0Hi shl 8) or Status.FwdPwrAlex0Lo; RevPwr := (Status.RevPwrAlex0Hi shl 8) or Status.RevPwrAlex0Lo; SupplyV := ADCToSupplyVolts((Status.SupplyVoltsHi shl 8) or Status.SupplyVoltsLo); // FLastSMeter обновляется только из WDSP (GetSMeterDBm) в SpectrumTimerTick — // ExciterPwr это мощность TX, не уровень принятого сигнала. FwdW := ADCToWatts100(FwdPwr); if FwdW > 0 then SWRV := 1 + Sqrt(RevPwr / FwdPwr) else SWRV := 1.0; Sync := TStatusUISync.Create(Self, FwdW, SWRV, SupplyV, (Status.StatusBits and HPS_PLL_LOCKED) <> 0); try M := Sync.Execute; TThread.Synchronize(nil, M); finally Sync.Free; end; end; procedure TMainForm.DoUpdateStatus(FwdW, SWRV, SupplyV: Double; PLLLock: Boolean); var PLLStr: string; begin FLastFwdW := FwdW; FLastSWR := SWRV; LblFwdPwr.Caption := Format('%.0fW', [FwdW]); LblSWRVal.Caption := Format('SWR:%.1f', [SWRV]); if PLLLock then PLLStr := 'PLL OK' else PLLStr := 'PLL?'; StatusBar1.Panels[2].Text := Format('Supply: %.1fV | FWD: %.0fW | SWR: %.1f | %s', [SupplyV, FwdW, SWRV, PLLStr]); // Panels[3] зарезервирована для RX stats (DDC seq/pkt count) end; procedure TMainForm.OnDDCIQCB(DDCIndex: Integer; const Data: TDDCIQPacket); var SamplesPerFrame: Integer; begin // Вызывается из сетевого потока — UI не трогаем напрямую // Счётчик принятых пакетов Inc(FRXPacketCount); FRXLastPktTime := GetTickCount64; // фиксируем время последнего пакета // 1. Подаём IQ данные в DSP — только с активного DDC if (DDCIndex = FActiveDDC) and FWDSPReady then begin SamplesPerFrame := (Integer(Data.SamplesPerFrame[0]) shl 8) or Integer(Data.SamplesPerFrame[1]); if SamplesPerFrame <= 0 then SamplesPerFrame := 238; // fallback FDSPEngine.PushDDCPacket(Data.IQData, 0, SamplesPerFrame); end; // 2. Запоминаем последний seq — StatusBar обновит таймер (без Synchronize в горячем пути) FLastDDCSeq := (LongWord(Data.Seq[0]) shl 24) or (LongWord(Data.Seq[1]) shl 16) or (LongWord(Data.Seq[2]) shl 8) or LongWord(Data.Seq[3]); FLastDDCIndex := DDCIndex; end; procedure TMainForm.DoUpdateDDCSeq(DDCIdx: Integer; Seq: LongWord); begin StatusBar1.Panels[3].Text := Format('RX DDC%d | seq=%d | pkts=%d', [DDCIdx, Seq, FRXPacketCount]); end; procedure TMainForm.OnMicPacketCB(const Data: TMicDataPacket); begin // TODO: WDSP TXA end; // =========================================================================== // Button handlers // =========================================================================== { Поток открытия — отдельный класс, без анонимных процедур } // Открываем WDSP в фоновом потоке чтобы не блокировать UI при нажатии START type TWDSPOpenThread = class(TThread) private FForm: TMainForm; FResult: Boolean; procedure SyncDone; protected procedure Execute; override; public constructor Create(AForm: TMainForm); end; constructor TWDSPOpenThread.Create(AForm: TMainForm); begin inherited Create(False); FForm := AForm; FResult := False; FreeOnTerminate := True; end; procedure TWDSPOpenThread.Execute; var M: TThreadMethod; begin try FResult := FForm.FDSPEngine.Open; except FResult := False; end; M := SyncDone; TThread.Synchronize(nil, M); end; procedure TWDSPOpenThread.SyncDone; begin FForm.OnWDSPOpenDone(FResult); end; type TDiscoverThread = class(TThread) private FNet: THPSDRNetwork; FForm: TMainForm; protected procedure Execute; override; public constructor Create(ANet: THPSDRNetwork; AForm: TMainForm); end; constructor TDiscoverThread.Create(ANet: THPSDRNetwork; AForm: TMainForm); begin FNet := ANet; FForm := AForm; FreeOnTerminate := True; inherited Create(False); end; procedure TDiscoverThread.Execute; var Devs: THPSDRDeviceArray; Sync: TDDCSeqSync; M: TThreadMethod; begin Devs := FNet.Discover(2000); if Length(Devs) = 0 then begin // DDCIdx = -1 is the sentinel for "no device found" Sync := TDDCSeqSync.Create(FForm, -1, 0); try M := Sync.Execute; TThread.Synchronize(nil, M); finally Sync.Free; end; end; // Devices found are signalled via OnDeviceFound callback during Discover() end; { Перегруженный DoUpdateDDCSeq принимает -1 как «нет устройств» } procedure TMainForm.DoUpdateDDCSeqOrNoDevice(DDCIdx: Integer; Seq: LongWord); begin if DDCIdx = -1 then begin if Assigned(FDeviceDialog) then begin FDeviceDialog.ClearDiscovered; FDeviceDialog.AddDiscovered('', '-- no device found --'); end; StatusBar1.Panels[4].Text := 'No hardware found'; end else StatusBar1.Panels[3].Text := Format('DDC%d seq=%d', [DDCIdx, Seq]); end; procedure TMainForm.BtnDiscoverClick(Sender: TObject); begin // Открываем диалог выбора устройства if not Assigned(FDeviceDialog) then FDeviceDialog := TDeviceDialog.Create(Self); FDeviceDialog.OnDiscover := BtnDiscoverFromDialog; FDeviceDialog.ShowModal; // Результат диалога не обрабатываем здесь — START сам спросит если нужно end; procedure TMainForm.BtnDiscoverFromDialog(Sender: TObject); // Запускается когда пользователь нажимает DISCOVER внутри диалога begin SetLength(FDevices, 0); FDeviceCount := 0; StatusBar1.Panels[4].Text := 'Discovering...'; FNetwork.DirectIP := ''; TDiscoverThread.Create(FNetwork, Self); end; procedure TMainForm.BtnStartStopClick(Sender: TObject); var Idx: Integer; Dev: THPSDRDevice; GenPkt: TGeneralPacket; DUCPkt: TDUCSpecificPacket; G_Settings: TGlobalSettings; AutoIP: string; begin if FNetwork.Connected then begin // --- STOP --- if FRunning then begin FNetwork.UpdateState(FCenterFreq, FCenterFreq, 0, False, True, True); FNetwork.SetRunAndFreq(False, FCenterFreq, FCenterFreq, 0); FRunning := False; FTransmitting := False; if FWDSPReady then FDSPEngine.SetTXRun(False); StyleButton(BtnMOX, False); end; FRXPacketCount := 0; FActiveDDC := 0; StatusBar1.Panels[3].Text := 'RX: waiting...'; ResetSpectrumBuf; DrawSpectrum; PbSpectrum.Invalidate; FNetwork.Disconnect; BtnStartStop.Caption := 'START'; StyleButton(BtnStartStop, False); StatusBar1.Panels[4].Text := 'Not connected'; StatusBar1.Panels[0].Text := 'IP: --'; StatusBar1.Panels[1].Text := 'Board: --'; BtnMOX.Enabled := False; Exit; end; // --- START --- AutoIP := ''; FPendingBoardType := 0; if Assigned(FDeviceDialog) then AutoIP := FDeviceDialog.GetAutoStartIP; if AutoIP <> '' then begin FPendingIP := AutoIP; FPendingBoardType := FDeviceDialog.GetAutoStartBoardType; end else begin if not Assigned(FDeviceDialog) then FDeviceDialog := TDeviceDialog.Create(Self); FDeviceDialog.OnDiscover := BtnDiscoverFromDialog; if FDeviceDialog.ShowModal <> mrOk then Exit; if not FDeviceDialog.DialogResult.Accepted then Exit; FPendingIP := FDeviceDialog.DialogResult.IPAddress; FPendingBoardType := FDeviceDialog.GetSavedBoardType( FDeviceDialog.DialogResult.SavedIdx); end; // Создаём запись устройства из IP + BoardType из диалога если есть FillChar(Dev, SizeOf(Dev), 0); Dev.IPAddress := FPendingIP; Dev.BoardType := FPendingBoardType; // --- Открываем WDSP асинхронно чтобы не блокировать UI --- FPendingDev := Dev; BtnStartStop.Enabled := False; StatusBar1.Panels[3].Text := 'Opening WDSP...'; if FWDSPReady then // WDSP уже открыт — сразу подключаемся DoConnectDevice(Dev) else // Открываем в фоновом потоке TWDSPOpenThread.Create(Self); end; procedure TMainForm.OnWDSPOpenDone(Success: Boolean); begin // Вызывается из TWDSPOpenThread.SyncDone — уже в главном потоке FWDSPReady := Success; if not Success then StatusBar1.Panels[3].Text := 'WDSP: demo mode (libwdsp not loaded)'; DoConnectDevice(FPendingDev); end; procedure TMainForm.DoConnectDevice(const Dev: THPSDRDevice); var GenPkt: TGeneralPacket; DUCPkt: TDUCSpecificPacket; G_Settings: TGlobalSettings; begin BtnStartStop.Enabled := True; if not FNetwork.Connect(Dev) then begin ShowMessage('Failed to connect to ' + Dev.IPAddress + IfThen(FNetwork.LastError <> '', ': ' + FNetwork.LastError, '')); Exit; end; // --- Загружаем настройки устройства по MAC --- Move(FNetwork.Device.MAC[0], FDevMAC[0], 6); FDevConnected := True; FSettings.LoadDevice(FDevMAC, G_Settings, FBandCache); FVolume := G_Settings.Volume; FDriveLevel := G_Settings.DriveLevel; FActiveVfo := G_Settings.ActiveVfo; FCurrentBand := G_Settings.LastBand; // SampleRate — глобальный, загружаем до RestoreBand if G_Settings.SampleRate > 0 then begin FSampleRate := G_Settings.SampleRate; FSpanHz := FSampleRate; end; BtnNR.Tag := Ord(G_Settings.NREnabled); StyleButton(BtnNR, G_Settings.NREnabled); BtnNB.Tag := Ord(G_Settings.NBEnabled); StyleButton(BtnNB, G_Settings.NBEnabled); BtnANF.Tag := Ord(G_Settings.ANFEnabled); StyleButton(BtnANF, G_Settings.ANFEnabled); FWfAGCEnabled := G_Settings.WfAGCEnabled; StyleButton(BtnWfAGC, FWfAGCEnabled); FWfNFEnabled := G_Settings.WfNFEnabled; StyleButton(BtnWfNF, FWfNFEnabled); RestoreBand(FCurrentBand); StatusBar1.Panels[0].Text := 'IP: ' + FNetwork.Device.IPAddress; StatusBar1.Panels[1].Text := 'Board: ' + BoardTypeName(FNetwork.Device.BoardType); // General packet FillChar(GenPkt, SizeOf(GenPkt), 0); GenPkt.Command := CMD_GENERAL; GenPkt.Flags37 := $08; GenPkt.Flags38 := $01; GenPkt.PAConfig := $01; if FNetwork.Device.BoardType = 5 then GenPkt.AlexEnable := $03 else GenPkt.AlexEnable := $01; FNetwork.SendGeneralPacket(GenPkt); if FNetwork.Device.BoardType in [3, 4, 5] then FActiveDDC := 2 else FActiveDDC := 0; FNetwork.ConfigureDDCs(1, FSampleRate div 1000, 0); // Синхронизируем samplerate WDSP если нужно if FWDSPReady and (FDSPEngine.SampleRate <> FSampleRate) then begin FWDSPReady := False; FDSPEngine.ChangeSampleRate(FSampleRate); FWDSPReady := FDSPEngine.Initialized; end; if FWDSPReady then begin FDSPEngine.SetMode(FMode); FDSPEngine.SetVolume(FVolume / 100.0); ApplyModeFilter; // ChangeSampleRate пересоздаёт WDSP-канал — восстанавливаем все настройки FDSPEngine.SetAGCTop(FAGCTop); FDSPEngine.SetAGC(TWDSPAGCMode(FAGCMode), 50.0); // SetAGC вызывает UpdateAGCLines внутри — линии на спектре обновятся end; // DUC specific FillChar(DUCPkt, SizeOf(DUCPkt), 0); DUCPkt.NumDACs := 1; DUCPkt.SidetoneLevel := 50; DUCPkt.SidetoneFreqLo := 600; DUCPkt.KeyerSpeed := 20; DUCPkt.KeyerWeight := 50; DUCPkt.DUC0RateHi := Hi(192); DUCPkt.DUC0RateLo := Lo(192); DUCPkt.DUC0Bits := 24; FNetwork.SendDUCSpecific(DUCPkt); FNetwork.UpdateState(FCenterFreq, FCenterFreq, FDriveLevel, False, True, True); FNetwork.SetRunAndFreq(True, FCenterFreq, FCenterFreq, FDriveLevel); FRunning := True; FRXStartTime := GetTickCount64; FRXLastPktTime := 0; // Принудительно обновляем AGC линии — гарантированно после всех SetAGC вызовов if FWDSPReady then FDSPEngine.UpdateAGCLines(FSpectrumWidth); // ещё не получили ни одного пакета BtnStartStop.Caption := 'STOP'; StyleButton(BtnStartStop, True); StatusBar1.Panels[4].Text := 'RUNNING ' + FormatFreq(FVfoA); BtnMOX.Enabled := True; end; procedure TMainForm.FreqDispAChanged(Sender: TObject; NewFreq: Int64); begin ApplyVfoA(NewFreq); end; procedure TMainForm.FreqDispBChanged(Sender: TObject; NewFreq: Int64); begin FVfoB := NewFreq; if FActiveVfo = 1 then begin FCenterFreq := FVfoB; FCenterFreq := FVfoB; if FNetwork.Connected and FNetwork.Running then begin FNetwork.UpdateState(FVfoB, FVfoB, FDriveLevel, FTransmitting, True, True); FNetwork.SendFullHP; end; end; end; // --------------------------------------------------------------------------- // UpdateFilterButtons — обновляет подписи кнопок фильтра под текущий режим // --------------------------------------------------------------------------- procedure TMainForm.UpdateFilterButtons; var i: Integer; Names: array[0..FILT_COUNT-1] of string; DefIdx: Integer; begin case FMode of 0, 1: begin for i := 0 to FILT_COUNT-1 do Names[i] := FILT_SSB_NAMES[i]; DefIdx := FILT_SSB_DEF; end; 3, 4: begin for i := 0 to FILT_COUNT-1 do Names[i] := FILT_CW_NAMES[i]; DefIdx := FILT_CW_DEF; end; 5: begin for i := 0 to FILT_COUNT-1 do Names[i] := FILT_FM_NAMES[i]; DefIdx := FILT_FM_DEF; end; else begin for i := 0 to FILT_COUNT-1 do Names[i] := FILT_AM_NAMES[i]; DefIdx := FILT_AM_DEF; end; end; FFilter := DefIdx; case FMode of 0, 1: FFilterBW := FILT_SSB_BW[DefIdx]; 3, 4: FFilterBW := FILT_CW_BW[DefIdx]; 5: FFilterBW := FILT_FM_BW[DefIdx]; else FFilterBW := FILT_AM_BW[DefIdx]; end; for i := 0 to FILT_COUNT-1 do begin BtnFilter[i].Caption := Names[i]; StyleButton(BtnFilter[i], i = FFilter); end; end; // --------------------------------------------------------------------------- // ApplyVfoA — единая точка смены частоты VFO A // // CTUN ВЫКЛ (классический режим): // FCenterFreq = FVfoA — спектр центрирован на VFO, маркер всегда в центре // // CTUN ВКЛ (piHPSDR/Thetis режим): // FCenterFreq статичен — спектр/водопад не двигаются // FVfoA движется по дисплею (маркер гуляет) // Перецентрирование — только когда КРАЙ ПОЛОСЫ ФИЛЬТРА выходит за край дисплея // (по образцу OpenHPSDR/PowerSDR: "re-centering occurs as the edge of the passband // hits the edge of the display") // --------------------------------------------------------------------------- procedure TMainForm.ApplyVfoA(NewFreq: Int64); var Offset: Double; FiltLo: Double; FiltHi: Double; HalfSpan: Double; Scrolled: Boolean; begin FVfoA := NewFreq; Scrolled := False; if not FCTun then begin // ---- CTUN OFF ---- // DDC = VFO, сдвига нет FCenterFreq := FVfoA; if FWDSPReady then FDSPEngine.SetShift(0.0); end else begin // ---- CTUN ON ---- // DDC (FCenterFreq) стоит на месте. // SetRXAShiftFreq сдвигает спектр внутри WDSP так чтобы // демодулятор принимал сигнал на FVfoA, а не на FCenterFreq. // Shift = FVfoA - FCenterFreq (в Гц) Offset := FVfoA - FCenterFreq; if FWDSPReady then FDSPEngine.SetShift(Offset); // Проверяем не вышла ли полоса фильтра за край дисплея HalfSpan := FSpanHz / 2; case FMode of 0: begin FiltLo := Offset - FFilterBW; FiltHi := Offset - 100; end; 1: begin FiltLo := Offset + 100; FiltHi := Offset + FFilterBW; end; else begin FiltLo := Offset - FFilterBW/2; FiltHi := Offset + FFilterBW/2; end; end; if (FiltHi > HalfSpan) or (FiltLo < -HalfSpan) then begin // Полоса вышла за край → прокручиваем центр if FiltHi > HalfSpan then FCenterFreq := FVfoA - HalfSpan * 0.5 else FCenterFreq := FVfoA + HalfSpan * 0.5; // Пересчитываем сдвиг после прокрутки if FWDSPReady then FDSPEngine.SetShift(FVfoA - FCenterFreq); // DDC перестраивается на новый FCenterFreq Scrolled := True; end; end; // Обновляем VFO дисплей FreqDispA.Frequency := Round(FVfoA); // DDC: передаём FCenterFreq (при CTUN OFF = FVfoA, при CTUN ON = фиксирован) if FNetwork.Connected and FNetwork.Running then begin FNetwork.UpdateState(FCenterFreq, FCenterFreq, FDriveLevel, FTransmitting, True, True); FNetwork.SendFullHP; end; // Перерисовываем спектр всегда (маркер и полоса двигаются) // Если идёт drag — не вызываем Draw напрямую, таймер подхватит if FSpecDrag then FSpectrumDirty := True else begin DrawSpectrum; PbSpectrum.Invalidate; if Scrolled then begin DrawWaterfall; PbWaterfall.Invalidate; end; end; end; // --------------------------------------------------------------------------- // CTUN toggle // --------------------------------------------------------------------------- procedure TMainForm.BtnCTunClick(Sender: TObject); begin FCTun := not FCTun; StyleButton(BtnCTun, FCTun); FBandCache[FCurrentBand].CTun := FCTun; if not FCTun then begin // Выключили CTUN: центрируемся на VFO, shift=0 FCenterFreq := FVfoA; if FWDSPReady then FDSPEngine.SetShift(0.0); if FNetwork.Connected and FNetwork.Running then begin FNetwork.UpdateState(FCenterFreq, FCenterFreq, FDriveLevel, FTransmitting, True, True); FNetwork.SendFullHP; end; end else begin // Включили CTUN: фиксируем текущее положение дисплея // FCenterFreq остаётся как есть — спектр не прыгает end; DrawSpectrum; DrawWaterfall; PbSpectrum.Invalidate; PbWaterfall.Invalidate; end; // --------------------------------------------------------------------------- // Клик/драг по спектру/водопаду // --------------------------------------------------------------------------- // Перевод X пикселя в частоту function PixelToFreq(PixelX, PanelWidth: Integer; CenterFreq, SpanHz: Double): Double; begin Result := CenterFreq + (PixelX / PanelWidth - 0.5) * SpanHz; end; procedure TMainForm.DoSpectrumClick(PixelX: Integer; PanelWidth: Integer); var ClickFreq: Int64; StepHz: Int64; begin if PanelWidth <= 0 then Exit; StepHz := 100; // Пиксель → частота через текущий центр дисплея (FCenterFreq) ClickFreq := Round(FCenterFreq + (PixelX / PanelWidth - 0.5) * FSpanHz); ClickFreq := (ClickFreq div StepHz) * StepHz; ApplyVfoA(ClickFreq); end; procedure TMainForm.DoSpectrumDrag(PixelX: Integer; PanelWidth: Integer); var dPix: Integer; dFreq: Double; begin if PanelWidth <= 0 then Exit; dPix := PixelX - FSpecDragX0; dFreq := dPix / PanelWidth * FSpanHz; if FCTun then begin // CTUN ON: drag двигает окно просмотра (FCenterFreq/DDC) // VFO остаётся, shift обновляется = FVfoA - новый FCenterFreq FCenterFreq := FSpecDragFreq - dFreq; if FWDSPReady then FDSPEngine.SetShift(FVfoA - FCenterFreq); if FNetwork.Connected and FNetwork.Running then begin FNetwork.UpdateState(FCenterFreq, FCenterFreq, FDriveLevel, FTransmitting, True, True); FNetwork.SendFullHP; end; FSpectrumDirty := True; // таймер перерисует в ближайшем тике end else begin // CTUN OFF: drag двигает VFO, FCenterFreq следует через ApplyVfoA ApplyVfoA(Round(FSpecDragFreq - dFreq)); end; end; // Спектр procedure TMainForm.PbSpectrumMouseDown(Sender: TObject; Button: TMouseButton; Shift: TShiftState; X, Y: Integer); begin if Button = mbLeft then begin // Левая кнопка: сбрасываем маркер если активен if FMarkerActive then begin FMarkerActive := False; PbSpectrum.Invalidate; PbWaterfall.Invalidate; end; FSpecDrag := True; FSpecDragX0 := X; if FCTun then FSpecDragFreq := FCenterFreq else FSpecDragFreq := FVfoA; PbSpectrum.Cursor := crSizeWE; end else if Button = mbRight then begin // Правая кнопка: toggle маркера if FMarkerActive and (Abs(Round(X / PbSpectrum.Width * 1000) - FMarkerX) < 8) then begin // Клик рядом с текущим маркером — убираем FMarkerActive := False; end else begin // Ставим маркер в новое место FMarkerActive := True; FMarkerX := Round(X / PbSpectrum.Width * 1000); end; PbSpectrum.Invalidate; PbWaterfall.Invalidate; end; end; procedure TMainForm.PbSpectrumMouseMove(Sender: TObject; Shift: TShiftState; X, Y: Integer); begin if FSpecDrag and (ssLeft in Shift) then DoSpectrumDrag(X, PbSpectrum.Width); // Маркер следует за курсором — обновление произойдёт в ближайшем тике таймера (50ms) if FMarkerActive then FMarkerX := Round(X / PbSpectrum.Width * 1000); end; procedure TMainForm.PbSpectrumMouseUp(Sender: TObject; Button: TMouseButton; Shift: TShiftState; X, Y: Integer); begin if Button = mbLeft then begin if (Abs(X - FSpecDragX0) < 4) then DoSpectrumClick(X, PbSpectrum.Width); // это был клик, не драг FSpecDrag := False; PbSpectrum.Cursor := crDefault; end; end; // Водопад (аналогично) procedure TMainForm.PbWaterfallMouseDown(Sender: TObject; Button: TMouseButton; Shift: TShiftState; X, Y: Integer); begin if Button = mbLeft then begin if FMarkerActive then begin FMarkerActive := False; PbSpectrum.Invalidate; PbWaterfall.Invalidate; end; FSpecDrag := True; FSpecDragX0 := X; if FCTun then FSpecDragFreq := FCenterFreq else FSpecDragFreq := FVfoA; PbWaterfall.Cursor := crSizeWE; end else if Button = mbRight then begin if FMarkerActive and (Abs(Round(X / PbWaterfall.Width * 1000) - FMarkerX) < 8) then FMarkerActive := False else begin FMarkerActive := True; FMarkerX := 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 FMarkerActive then FMarkerX := Round(X / PbWaterfall.Width * 1000); end; procedure TMainForm.PbWaterfallMouseUp(Sender: TObject; Button: TMouseButton; Shift: TShiftState; X, Y: Integer); begin if Button = mbLeft then begin if (Abs(X - FSpecDragX0) < 4) then DoSpectrumClick(X, PbWaterfall.Width); FSpecDrag := False; PbWaterfall.Cursor := crDefault; end; end; procedure TMainForm.BtnBandClick(Sender: TObject); var NewBand: Integer; begin NewBand := (Sender as TFlatButton).Tag; if (NewBand < 0) or (NewBand >= BAND_COUNT) then Exit; if NewBand = FCurrentBand then Exit; // уже на этом диапазоне // Сохраняем настройки текущего диапазона перед сменой if FDevConnected then begin SaveCurrentBand; FSettings.Save; end; // Переключаем диапазон FCurrentBand := NewBand; // Восстанавливаем настройки нового диапазона RestoreBand(FCurrentBand); end; procedure TMainForm.ApplyModeFilter; var Lo, Hi, Half: Integer; begin Half := FFilterBW div 2; case FMode of 0: begin Lo := -FFilterBW; Hi := -100; end; // LSB: верхний срез -100 Гц 1: begin Lo := 100; Hi := FFilterBW; end; // USB 2: begin Lo := -Half; Hi := Half; end; // DSB 3: begin Lo := -Half; Hi := Half; end; // CWL 4: begin Lo := -Half; Hi := Half; end; // CWU 5: begin Lo := -Half; Hi := Half; end; // FM 6: begin Lo := -Half; Hi := Half; end; // AM 7: begin Lo := -Half; Hi := Half; end; // SAM else Lo := -Half; Hi := Half; end; if FWDSPReady then FDSPEngine.SetFilter(Lo, Hi); end; procedure TMainForm.BtnModeClick(Sender: TObject); var i, N: Integer; begin N := (Sender as TFlatButton).Tag; FMode := N; for i := 0 to MODE_COUNT - 1 do StyleButton(BtnMode[i], i = FMode); if FWDSPReady then FDSPEngine.SetMode(N); UpdateFilterButtons; // обновляем подписи и дефолт фильтра под новый режим ApplyModeFilter; end; procedure TMainForm.BtnFilterClick(Sender: TObject); var i, N: Integer; begin N := (Sender as TFlatButton).Tag; FFilter := N; // Устанавливаем полосу по текущему режиму case FMode of 0, 1: FFilterBW := FILT_SSB_BW[N]; 3, 4: FFilterBW := FILT_CW_BW[N]; 5: FFilterBW := FILT_FM_BW[N]; else FFilterBW := FILT_AM_BW[N]; // DSB, AM, SAM end; for i := 0 to FILT_COUNT - 1 do StyleButton(BtnFilter[i], i = FFilter); ApplyModeFilter; end; procedure TMainForm.BtnVfoSwapClick(Sender: TObject); var Tmp: Double; begin Tmp := FVfoA; FVfoA := FVfoB; FVfoB := Tmp; UpdateVfoDisplay; UpdateFilterButtons; if FRunning then FNetwork.SetRunAndFreq(True, FCenterFreq, FCenterFreq, FDriveLevel); end; procedure TMainForm.BtnVfoACopyBClick(Sender: TObject); begin FVfoB := FVfoA; UpdateVfoDisplay; end; procedure TMainForm.BtnVfoBCopyAClick(Sender: TObject); begin FVfoA := FVfoB; UpdateVfoDisplay; if FRunning then FNetwork.SetRunAndFreq(True, FCenterFreq, FCenterFreq, FDriveLevel); end; procedure TMainForm.BtnMOXClick(Sender: TObject); begin FTransmitting := not FTransmitting; if FTransmitting then begin BtnMOX.ClrNorm := TColor($00000044); BtnMOX.ClrText := TColor($000000FF); BtnMOX.ClrTextAct := TColor($000000FF); BtnMOX.Active := True; end else StyleButton(BtnMOX, False); if FWDSPReady then FDSPEngine.SetTXRun(FTransmitting); end; procedure TMainForm.BtnMuteClick(Sender: TObject); begin FMuted := not FMuted; StyleButton(BtnMute, FMuted); if FMuted then BtnMute.Caption := 'UNMUTE' else BtnMute.Caption := 'MUTE'; if FWDSPReady then FDSPEngine.SetMute(FMuted); end; procedure TMainForm.BtnNRClick(Sender: TObject); begin // Используем BtnNR напрямую — корректно работает и при вызове из веб-синхронизации (Sender=nil) BtnNR.Tag := 1 - BtnNR.Tag; StyleButton(BtnNR, BtnNR.Tag = 1); if FWDSPReady then FDSPEngine.SetNR(BtnNR.Tag = 1); end; procedure TMainForm.BtnNBClick(Sender: TObject); begin // Используем BtnNB напрямую — корректно работает и при вызове из веб-синхронизации (Sender=nil) BtnNB.Tag := 1 - BtnNB.Tag; StyleButton(BtnNB, BtnNB.Tag = 1); if FWDSPReady then FDSPEngine.SetNB(BtnNB.Tag = 1); end; procedure TMainForm.BtnANFClick(Sender: TObject); begin // Используем BtnANF напрямую — корректно работает и при вызове из веб-синхронизации (Sender=nil) BtnANF.Tag := 1 - BtnANF.Tag; StyleButton(BtnANF, BtnANF.Tag = 1); if FWDSPReady then FDSPEngine.SetANF(BtnANF.Tag = 1); end; procedure TMainForm.BtnWfAGCClick(Sender: TObject); begin FWfAGCEnabled := not FWfAGCEnabled; StyleButton(BtnWfAGC, FWfAGCEnabled); if not FWfAGCEnabled then FWfHigh := -50.0; // сбрасываем к умолчанию при выключении end; procedure TMainForm.BtnWfNFClick(Sender: TObject); begin FWfNFEnabled := not FWfNFEnabled; StyleButton(BtnWfNF, FWfNFEnabled); if not FWfNFEnabled then FWfLow := -120.0; end; procedure TMainForm.BtnHidePanelClick(Sender: TObject); begin FPanelHidden := not FPanelHidden; PanelLeft.Visible := not FPanelHidden; if FPanelHidden then begin BtnHidePanel.Caption := '▶ SHOW'; // PbSpectrum — TGraphicControl, не TWinControl, используем PanelRight FVfoOverlay.Parent := PanelRight; FVfoOverlay.Width := 260; FVfoOverlay.Height := 130; FVfoOverlay.SetState(FMode, FFilterBW, FVfoA, FLastSMeter); FVfoOverlay.Visible := True; FVfoOverlay.BringToFront; PositionVfoOverlay; end else begin BtnHidePanel.Caption := '◀ HIDE'; FVfoOverlay.Visible := False; end; ResizeSMeter; end; procedure TMainForm.PositionVfoOverlay; var VfoX, OW, SpTop: Integer; begin if not Assigned(FVfoOverlay) or not FVfoOverlay.Visible then Exit; OW := FVfoOverlay.Width; SpTop := PbSpectrum.Top; // смещение PbSpectrum внутри PanelRight if PbSpectrum.Width > 0 then VfoX := Round((FVfoA - FCenterFreq + FSpanHz / 2) / FSpanHz * PbSpectrum.Width) else VfoX := PbSpectrum.Width div 2; // Всегда справа от VFO-линии FVfoOverlay.Left := Min(PanelRight.Width - OW - 4, VfoX + 6); FVfoOverlay.Top := SpTop + 6; end; procedure TMainForm.PbSpectrumDblClick(Sender: TObject); begin // Двойной клик — ничего не делаем (оверлей всегда виден при скрытой панели) end; procedure TMainForm.OnModeFilterSelect(Mode: Integer; FilterBW: Integer); begin FMode := Mode; FFilterBW := FilterBW; if FWDSPReady then begin FDSPEngine.SetMode(FMode); ApplyModeFilter; end; if Assigned(FVfoOverlay) and FVfoOverlay.Visible then begin FVfoOverlay.SetState(FMode, FFilterBW, FVfoA, FLastSMeter); PositionVfoOverlay; // перепозиционируем при смене LSB↔USB end; end; procedure TMainForm.BtnSpanClick(Sender: TObject); var NewRate: Integer; DDCRate: Word; begin NewRate := (Sender as TFlatButton).Tag; if NewRate = FSampleRate then Exit; FSampleRate := NewRate; FSpanHz := NewRate; DDCRate := NewRate div 1000; // Обновляем кнопки сразу — UI отзывчив StyleButton(BtnSpan48k, FSampleRate = 48000); StyleButton(BtnSpan96k, FSampleRate = 96000); StyleButton(BtnSpan192k, FSampleRate = 192000); StyleButton(BtnSpan384k, FSampleRate = 384000); StyleButton(BtnSpan768k, FSampleRate = 768000); StyleButton(BtnSpan1536k, FSampleRate = 1536000); Application.ProcessMessages; FBandCache[FCurrentBand].SpanHz := FSpanHz; // для обратной совместимости // SampleRate — глобальный: сохраняем в global settings if FDevConnected then FSettings.SaveGlobal(FDevMAC, MakeGlobalSettings); // 1. Останавливаем WDSP и сбрасываем очередь старых пакетов if FWDSPReady then begin FWDSPReady := False; FDSPEngine.ChangeSampleRate(NewRate); // Close + FlushQueue + Open end; // 2. Отправляем новый rate трансиверу // (после остановки DSP — новые пакеты сразу идут с правильным rate) if FNetwork.Connected then FNetwork.ConfigureDDCs(1, DDCRate, 0); // 3. Восстанавливаем настройки DSP FWDSPReady := FDSPEngine.Initialized; if FWDSPReady then begin FDSPEngine.SetMode(FMode); FDSPEngine.SetVolume(FVolume / 100.0); ApplyModeFilter; // ChangeSampleRate пересоздаёт WDSP-канал — восстанавливаем AGC FDSPEngine.SetAGCTop(FAGCTop); FDSPEngine.SetAGC(TWDSPAGCMode(FAGCMode), 50.0); FDSPEngine.UpdateAGCLines(FSpectrumWidth); if FNetwork.Connected then FDSPEngine.SetShift(FVfoA - FCenterFreq); end; end; // =========================================================================== // Веб-интерфейс: callbacks от TWebServer (вызываются из WS-потока) // Все изменения состояния выполняем через TThread.Synchronize чтобы // не трогать UI и WDSP из чужого потока. // =========================================================================== procedure TMainForm.WebOnFreq(Hz: Double); begin FWebSyncFreq := Hz; FWebSyncM := SyncWebFreq; TThread.Synchronize(nil, FWebSyncM); end; procedure TMainForm.SyncWebFreq; begin FVfoA := FWebSyncFreq; if not FCTun then FCenterFreq := FVfoA; UpdateVfoDisplay; if FWDSPReady then begin FDSPEngine.SetShift(FVfoA - FCenterFreq); if FRunning then FNetwork.SetRunAndFreq(True, FCenterFreq, FCenterFreq, FDriveLevel); end; end; procedure TMainForm.WebOnMode(Mode: Integer); begin FWebSyncInt := Mode; FWebSyncM := SyncWebMode; TThread.Synchronize(nil, FWebSyncM); end; procedure TMainForm.SyncWebMode; var i: Integer; begin if (FWebSyncInt < 0) or (FWebSyncInt >= MODE_COUNT) then Exit; FMode := FWebSyncInt; for i := 0 to MODE_COUNT - 1 do StyleButton(BtnMode[i], i = FMode); if FWDSPReady then FDSPEngine.SetMode(FMode); UpdateFilterButtons; ApplyModeFilter; FBandCache[FCurrentBand].Mode := FMode; end; procedure TMainForm.WebOnFilter(BW: Integer); begin FWebSyncInt := BW; FWebSyncM := SyncWebFilter; TThread.Synchronize(nil, FWebSyncM); end; procedure TMainForm.SyncWebFilter; var best, dist, i, j: Integer; BW_arr: array[0..FILT_COUNT-1] of Integer; Idx: Integer; begin // Negative value = -(idx+1) means filter index was passed directly if FWebSyncInt < 0 then begin Idx := (-FWebSyncInt) - 1; if (Idx >= 0) and (Idx < FILT_COUNT) then begin FFilter := Idx; case FMode of 0,1: FFilterBW := FILT_SSB_BW[Idx]; 3,4: FFilterBW := FILT_CW_BW[Idx]; 5: FFilterBW := FILT_FM_BW[Idx]; else FFilterBW := FILT_AM_BW[Idx]; end; for j := 0 to FILT_COUNT-1 do StyleButton(BtnFilter[j], j = FFilter); ApplyModeFilter; FBandCache[FCurrentBand].FilterBW := FFilterBW; end; Exit; end; // Positive value = bandwidth in Hz, find closest filter FFilterBW := FWebSyncInt; case FMode of 0,1: for i := 0 to FILT_COUNT-1 do BW_arr[i] := FILT_SSB_BW[i]; 3,4: for i := 0 to FILT_COUNT-1 do BW_arr[i] := FILT_CW_BW[i]; 5: for i := 0 to FILT_COUNT-1 do BW_arr[i] := FILT_FM_BW[i]; else for i := 0 to FILT_COUNT-1 do BW_arr[i] := FILT_AM_BW[i]; end; best := 0; dist := MaxInt; for i := 0 to FILT_COUNT-1 do if Abs(BW_arr[i] - FWebSyncInt) < dist then begin dist := Abs(BW_arr[i] - FWebSyncInt); best := i; end; FFilter := best; for j := 0 to FILT_COUNT-1 do StyleButton(BtnFilter[j], j = FFilter); ApplyModeFilter; FBandCache[FCurrentBand].FilterBW := FFilterBW; end; procedure TMainForm.WebOnAGC(Mode: Integer); begin FWebSyncInt := Mode; FWebSyncM := SyncWebAGC; TThread.Synchronize(nil, FWebSyncM); end; procedure TMainForm.SyncWebAGC; var i: Integer; begin if (FWebSyncInt < 0) or (FWebSyncInt > 4) then Exit; FAGCMode := FWebSyncInt; for i := 0 to 4 do StyleButton(BtnAGCMode[i], i = FAGCMode); if FWDSPReady then FDSPEngine.SetAGC(TWDSPAGCMode(FAGCMode), 50.0); FBandCache[FCurrentBand].AGCMode := FAGCMode; end; procedure TMainForm.WebOnAGCTop(DB: Integer); begin FWebSyncInt := DB; FWebSyncM := SyncWebAGCTop; TThread.Synchronize(nil, FWebSyncM); end; procedure TMainForm.SyncWebAGCTop; begin FAGCTop := Max(20, Min(120, FWebSyncInt)); TrkAGC.Position := FAGCTop; LblAGCTop.Caption := Format('%ddB', [FAGCTop]); if FWDSPReady then begin FDSPEngine.SetAGCTop(FAGCTop); FDSPEngine.SetAGC(TWDSPAGCMode(FAGCMode), 50.0); end; FBandCache[FCurrentBand].AGCTop := FAGCTop; end; procedure TMainForm.WebOnBand(Idx: Integer); begin FWebSyncInt := Idx; FWebSyncM := SyncWebBand; TThread.Synchronize(nil, FWebSyncM); end; procedure TMainForm.SyncWebBand; begin if (FWebSyncInt < 0) or (FWebSyncInt >= BAND_COUNT) then Exit; if FWebSyncInt = FCurrentBand then Exit; if FDevConnected then begin SaveCurrentBand; FSettings.Save; end; FCurrentBand := FWebSyncInt; RestoreBand(FCurrentBand); end; procedure TMainForm.WebOnSpan(Hz: Integer); begin FWebSyncInt := Hz; FWebSyncM := SyncWebSpan; TThread.Synchronize(nil, FWebSyncM); end; procedure TMainForm.SyncWebSpan; var ValidSpans: array[0..5] of Integer; Found: Boolean; i: Integer; Btn: TFlatButton; begin ValidSpans[0]:=48000; ValidSpans[1]:=96000; ValidSpans[2]:=192000; ValidSpans[3]:=384000; ValidSpans[4]:=768000; ValidSpans[5]:=1536000; Found := False; for i := 0 to 5 do if ValidSpans[i] = FWebSyncInt then begin Found := True; Break; end; if not Found then Exit; if FWebSyncInt = FSampleRate then Exit; Btn := nil; if FWebSyncInt = 48000 then Btn := BtnSpan48k else if FWebSyncInt = 96000 then Btn := BtnSpan96k else if FWebSyncInt = 192000 then Btn := BtnSpan192k else if FWebSyncInt = 384000 then Btn := BtnSpan384k else if FWebSyncInt = 768000 then Btn := BtnSpan768k else if FWebSyncInt = 1536000 then Btn := BtnSpan1536k; if Assigned(Btn) then BtnSpanClick(Btn); end; procedure TMainForm.WebOnVolume(V: Integer); begin FWebSyncInt := V; FWebSyncM := SyncWebVolume; TThread.Synchronize(nil, FWebSyncM); end; procedure TMainForm.SyncWebVolume; begin FVolume := Max(0, Min(100, FWebSyncInt)); TrkVolume.Position := FVolume; if FWDSPReady then FDSPEngine.SetVolume(FVolume / 100.0); end; procedure TMainForm.WebOnWfAGC(On_: Boolean); begin FWebSyncBool := On_; FWebSyncM := SyncWebWfAGC; TThread.Synchronize(nil, FWebSyncM); end; procedure TMainForm.SyncWebWfAGC; begin FWfAGCEnabled := FWebSyncBool; StyleButton(BtnWfAGC, FWfAGCEnabled); end; procedure TMainForm.WebOnWfNF(On_: Boolean); begin FWebSyncBool := On_; FWebSyncM := SyncWebWfNF; TThread.Synchronize(nil, FWebSyncM); end; procedure TMainForm.SyncWebWfNF; begin FWfNFEnabled := FWebSyncBool; StyleButton(BtnWfNF, FWfNFEnabled); end; // ── Run ──────────────────────────────────────────────────────────────────── procedure TMainForm.WebOnRun(On_: Boolean); begin FWebSyncBool := On_; FWebSyncM := SyncWebRun; TThread.Synchronize(nil, FWebSyncM); end; procedure TMainForm.SyncWebRun; begin // Toggle only if state differs if FWebSyncBool <> FRunning then BtnStartStopClick(nil); end; // ── Mute ─────────────────────────────────────────────────────────────────── procedure TMainForm.WebOnMute(On_: Boolean); begin FWebSyncBool := On_; FWebSyncM := SyncWebMute; TThread.Synchronize(nil, FWebSyncM); end; procedure TMainForm.SyncWebMute; begin if FWebSyncBool <> FMuted then BtnMuteClick(nil); end; // ── CTUN ────────────────────────────────────────────────────────────────── procedure TMainForm.WebOnCtun(On_: Boolean); begin FWebSyncBool := On_; FWebSyncM := SyncWebCtun; TThread.Synchronize(nil, FWebSyncM); end; procedure TMainForm.SyncWebCtun; begin if FWebSyncBool <> FCTun then BtnCTunClick(nil); end; // ── NR ──────────────────────────────────────────────────────────────────── procedure TMainForm.WebOnNR(On_: Boolean); begin FWebSyncBool := On_; FWebSyncM := SyncWebNR; TThread.Synchronize(nil, FWebSyncM); end; procedure TMainForm.SyncWebNR; begin if FWebSyncBool <> (BtnNR.Tag = 1) then BtnNRClick(BtnNR); end; // ── NB ──────────────────────────────────────────────────────────────────── procedure TMainForm.WebOnNB(On_: Boolean); begin FWebSyncBool := On_; FWebSyncM := SyncWebNB; TThread.Synchronize(nil, FWebSyncM); end; procedure TMainForm.SyncWebNB; begin if FWebSyncBool <> (BtnNB.Tag = 1) then BtnNBClick(BtnNB); end; // ── ANF ─────────────────────────────────────────────────────────────────── procedure TMainForm.WebOnANF(On_: Boolean); begin FWebSyncBool := On_; FWebSyncM := SyncWebANF; TThread.Synchronize(nil, FWebSyncM); end; procedure TMainForm.SyncWebANF; begin if FWebSyncBool <> (BtnANF.Tag = 1) then BtnANFClick(BtnANF); end; procedure TMainForm.TrkDriveChange(Sender: TObject); begin FDriveLevel := Round(TrkDrive.Position / 100.0 * 255); if FWDSPReady then FDSPEngine.SetDriveLevel(TrkDrive.Position / 100.0); end; procedure TMainForm.TrkVolumeChange(Sender: TObject); begin FVolume := TrkVolume.Position; if FWDSPReady then FDSPEngine.SetVolume(FVolume / 100.0); end; // =========================================================================== // Mouse wheel — перестройка VFO // =========================================================================== procedure TMainForm.FormMouseWheel(Sender: TObject; Shift: TShiftState; WheelDelta: Integer; MousePos: TPoint; var Handled: Boolean); // Колёсико на форме/спектре/водопаде — меняет VFO с квантованием // Шаг зависит от модификаторов: Ctrl+Shift=1МГц, Ctrl=1кГц, Shift=10Гц, иначе 100Гц // Квантование: результат выровнен до кратного шагу (как в FreqDisplay) // CTUN логика: через ApplyVfoA var Step: Int64; Delta: Int64; Base: Int64; NewFreq: Int64; begin if (ssCtrl in Shift) and (ssShift in Shift) then Step := 1000000 else if ssCtrl in Shift then Step := 1000 else if ssShift in Shift then Step := 10 else Step := 100; Delta := 1; if WheelDelta < 0 then Delta := -1; // Квантование (та же логика что в FreqDisplay.ChangeByDigit) Base := (Round(FVfoA) div Step) * Step; if Delta > 0 then NewFreq := Base + Step else begin if Round(FVfoA) = Base then NewFreq := Base - Step else NewFreq := Base; // снэп к нижней границе end; NewFreq := Max(30000, Min(60000000, NewFreq)); ApplyVfoA(NewFreq); Handled := True; end; procedure TMainForm.BtnAGCModeClick(Sender: TObject); const AGCModes: array[0..4] of TWDSPAGCMode = ( agcFast, agcMedium, agcSlow, agcLong, agcOff); var i, N: Integer; begin N := (Sender as TFlatButton).Tag; FAGCMode := N; for i := 0 to 4 do StyleButton(BtnAGCMode[i], i = N); if FWDSPReady then FDSPEngine.SetAGC(AGCModes[N], 50.0); FBandCache[FCurrentBand].AGCMode := FAGCMode; end; procedure TMainForm.TrkAGCChange(Sender: TObject); begin FAGCTop := TrkAGC.Position; LblAGCTop.Caption := Format('%ddB', [FAGCTop]); if FWDSPReady then begin FDSPEngine.SetAGCTop(FAGCTop); FDSPEngine.SetAGC(TWDSPAGCMode(FAGCMode), 50.0); end; // Обновляем кэш диапазона FBandCache[FCurrentBand].AGCTop := FAGCTop; DrawSpectrum; PbSpectrum.Invalidate; end; // =========================================================================== // DSP/Audio callbacks — вызываются из рабочих потоков // =========================================================================== procedure TMainForm.OnAudioReady(const Left, Right: array of Single; Count: Integer); var MonoBuf: array[0..1023] of Single; i: Integer; begin if Assigned(FWebServer) and FWebServer.WebClientActive then begin for i := 0 to Count - 1 do MonoBuf[i] := (Left[i] + Right[i]) * 0.5; FWebServer.PushAudio(@MonoBuf[0], Count); Exit; end; FAudioOut.Write(Left, Right, Count); end; procedure TMainForm.OnSpectrumReady(const Pixels: array of Single; Count: Integer); var i, N: Integer; begin // Храним RAW данные от WDSP (всегда 1024 точки). // Интерполяция на ширину экрана делается в DrawSpectrum/DrawWaterfall. // Вызывается из DSP-потока — Single-запись атомарна. N := Min(Count, 1024); for i := 0 to N - 1 do FSpectrumBuf[i] := Pixels[i]; end; procedure TMainForm.AfterShowTick(Sender: TObject); begin // Вызывается один раз через 200 мс после старта формы FAfterShowTimer.Enabled := False; // Открываем PortAudio — теперь FPC уже перехватывает сигналы try if not FAudioOut.Open then StatusBar1.Panels[3].Text := 'Audio: ' + FAudioOut.LastError else StatusBar1.Panels[3].Text := 'Audio OK (PortAudio)'; except on E: Exception do StatusBar1.Panels[3].Text := 'Audio: ' + E.Message; end; end; end.