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, 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; // --- 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; 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; // Сохраняем настройки при закрытии 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.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.SetSize(RW, WH); FWaterfallBitmap.Canvas.Brush.Color := clBlack; FWaterfallBitmap.Canvas.FillRect(Rect(0, 0, RW, WH)); FWaterfallTemp.SetSize(RW, WH); FWaterfallTemp.Canvas.Brush.Color := clBlack; FWaterfallTemp.Canvas.FillRect(Rect(0, 0, RW, WH)); 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. // Каждый пиксель ниже кривой: Alpha = (1 - T²) × 18000, T = глубина [0..1]. // Цвет: зелёно-бирюзовый (Thetis-стиль). Смешивается с существующим фоном. var Img: TLazIntfImage; FC: TFPColor; Row, Col: Integer; YcurveAt: Integer; GradH: Integer; T: Double; Alpha16: Integer; YCurve: array of Integer; begin if FSpectrumBitmap = nil then Exit; SetLength(YCurve, W); for Col := 0 to W - 1 do YCurve[Col] := SpPts[Col].Y; Img := FSpectrumBitmap.CreateIntfImage; try for Row := 0 to H - 1 do for Col := 0 to W - 1 do begin YcurveAt := YCurve[Col]; if Row < YcurveAt then Continue; GradH := H - YcurveAt; if GradH < 1 then GradH := 1; T := (Row - YcurveAt) / GradH; Alpha16 := Round((1.0 - T * T) * 18000); if Alpha16 <= 0 then Continue; FC := Img.Colors[Col, Row]; FC.Green := Min(65535, FC.Green + Alpha16 * 2); FC.Blue := Min(65535, FC.Blue + Alpha16); Img.Colors[Col, Row] := FC; end; FSpectrumBitmap.LoadFromIntfImage(Img); finally Img.Free; 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 → палитра var W, H, X: Integer; dB, frac, WatSrcF: Double; WatS0, WatS1, V: Integer; Col: LongWord; Tmp: TBitmap; Img: TLazIntfImage; FC: TFPColor; // AGC/NF AvgDB: Double; PeakDB: Double; SortedSum: Double; NF30Count: Integer; // Sorted sample для медианы NF 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.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: Скролл — двойной буфер // ================================================================ FWaterfallTemp.Canvas.Draw(0, 1, FWaterfallBitmap); // ================================================================ // ================================================================ // Шаг 5: Новая строка через TLazIntfImage из FWfAvgBuf // Рисуем УСРЕДНЁННЫЙ спектр — не сырой! // ================================================================ Img := FWaterfallTemp.CreateIntfImage; try 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]; FC.Red := Byte(Col) shl 8; FC.Green := Byte(Col shr 8) shl 8; FC.Blue := Byte(Col shr 16) shl 8; FC.Alpha := $FFFF; Img.Colors[X, 0] := FC; end; FWaterfallTemp.LoadFromIntfImage(Img); finally Img.Free; 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; 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; C := PbRuler.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; end; procedure TMainForm.PbRulerPaint(Sender: TObject); begin DrawRuler; 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; 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; 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.