From 092a202f5f56cb1e2b362a267d1ebdd458379e06 Mon Sep 17 00:00:00 2001 From: Vladimir Date: Sat, 4 Jul 2026 16:11:58 +0300 Subject: [PATCH] =?UTF-8?q?feat(waterfall):=20=D0=BF=D0=B0=D0=BB=D0=B8?= =?UTF-8?q?=D1=82=D1=80=D1=8B=20Inferno/Turbo=20+=20gamma,=20=D1=80=D0=B0?= =?UTF-8?q?=D0=B7=D1=80=D0=B5=D1=88=D0=B5=D0=BD=D0=B8=D0=B5=20=D0=B4=D0=BE?= =?UTF-8?q?=204096,=20HiDPI?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - SPECTRUM_PIXELS 1024→4096 + настройка display_pixels (лимит точек анализатора, SetDisplayResLimit); web-зеркало остаётся 1024 — max-децимация в SnapshotPixels - палитры водопада Classic/Inferno/Turbo + регулятор gamma (в LUT), настройки wf_palette/wf_gamma, страница Waterfall → группа Rendering - GL-водопад переведён на общий LUT/AGC-уровни базового класса (убраны дубли ColorForDB/UpdateLevels) - явные glViewport в GL-вьюхах умножаются на PixelScale (HiDPI), текстура водопада — в физической ширине Co-Authored-By: Claude Fable 5 --- MainForm.pas | 23 ++++ RadioController.pas | 56 ++++++-- Settings.pas | 12 ++ SettingsForm.pas | 62 +++++++++ SpectrumView.pas | 20 ++- SpectrumViewOpengl.pas | 9 +- WDSPEngine.pas | 20 ++- WaterfallView.pas | 289 +++++++++++++++++++++++++++------------- WaterfallViewOpengl.pas | 166 ++++------------------- WidebandView.pas | 3 +- 10 files changed, 397 insertions(+), 263 deletions(-) diff --git a/MainForm.pas b/MainForm.pas index 8945a3f..d934297 100644 --- a/MainForm.pas +++ b/MainForm.pas @@ -587,6 +587,8 @@ type SpecAvgTimeMS: Double); procedure ApplyWaterfallParams(WfDet, WfAvgMode: Integer; WfAvgTimeMS, WfHigh, WfLow, WfAGCOffset: Double); + procedure ApplyWfRenderParams(Palette: Integer; Gamma: Double; + ResLimit: Integer); procedure ApplyWfAGCNF(WfAGC, WfNF: Boolean); procedure ApplyADCSettings(Dither, Random: Boolean); procedure ApplyWebSettings(Enabled: Boolean; Port: Integer; @@ -1028,6 +1030,8 @@ begin FSpecView.WfManualHigh := FController.FWfManualHigh; FSpecView.WfManualLow := FController.FWfManualLow; FSpecView.WfAGCOffset := FController.FWfAGCOffset; + FSpecView.WfPalette := FController.FWfPalette; + FSpecView.WfGamma := FController.FWfGamma; FSpecView.SpecRefLevel := FController.FSpecRefLevel; FSpecView.SpecRange := FController.FSpecRange; FSpecView.SpecGridStep := FController.FSpecGridStep; @@ -1469,6 +1473,8 @@ begin FSpecView.WfManualHigh := FController.FWfManualHigh; FSpecView.WfManualLow := FController.FWfManualLow; FSpecView.WfAGCOffset := FController.FWfAGCOffset; + FSpecView.WfPalette := FController.FWfPalette; + FSpecView.WfGamma := FController.FWfGamma; FSpecView.ResetWfAvgBuf; if Assigned(FWidebandView) then FWidebandView.SetFillSpectrum(FController.FWidebandFill); @@ -6435,6 +6441,20 @@ begin FSpectrumDirty := True; end; +procedure TMainForm.ApplyWfRenderParams(Palette: Integer; Gamma: Double; + ResLimit: Integer); +begin + FController.FWfPalette := Palette; + FController.FWfGamma := Gamma; + FController.FDisplayPixels := ResLimit; + FSpecView.WfPalette := Palette; + FSpecView.WfGamma := Gamma; + if FController.FWDSPReady then + FController.FDSPEngine.SetDisplayResLimit(ResLimit); + FWaterfallDirty := True; + FSpectrumDirty := True; +end; + procedure TMainForm.ApplyGridParams(RefLevel, Range, GridStep: Double); begin FController.FSpecRefLevel := RefLevel; @@ -6602,6 +6622,7 @@ begin FSettingsForm := SF; SF.OnDisplayChange := ApplyDisplayParams; SF.OnWaterfallChange := ApplyWaterfallParams; + SF.OnWfRenderChange := ApplyWfRenderParams; SF.OnGridChange := ApplyGridParams; SF.OnAudioDevChange := ApplyAudioDevice; SF.OnAudioInDevChange := ApplyAudioInputDevice; @@ -6646,6 +6667,8 @@ begin SF.LoadVisibility(FController.FShowSpectrum, FController.FShowWaterfall, FController.FShowWideband, FController.FWidebandFill, FController.FSpectrumFill); SF.LoadWfAGCNF(FController.FWfAGCEnabled, FController.FWfNFEnabled); + SF.LoadWfRender(FController.FWfPalette, FController.FWfGamma, + FController.FDisplayPixels); SF.LoadADCSettings(FController.FDitherEnabled, FController.FRandomEnabled); SF.LoadWebSettings(FWebEnabled, FWebPort, FWebBindAddr, FWebUser, FWebPass); SF.LoadFPS(FDisplayFPS); diff --git a/RadioController.pas b/RadioController.pas index 06d99f9..7fedfbb 100644 --- a/RadioController.pas +++ b/RadioController.pas @@ -281,6 +281,9 @@ type FWfManualHigh: Double; FWfManualLow: Double; FWfAGCOffset: Double; + FWfPalette: Integer; // 0=Classic, 1=Inferno, 2=Turbo + FWfGamma: Double; // контраст палитры водопада + FDisplayPixels: Integer; // потолок точек анализатора FSpecRefLevel: Double; FSpecRange: Double; FSpecGridStep: Double; @@ -688,6 +691,7 @@ begin FPAMaxPower := 100.0; FPlutoTxMaxAttDb := -10.0; // ADI-дефолт: линейный режим выходных каскадов FWfAGCEnabled := True; FDitherEnabled := True; FRandomEnabled := True; + FWfPalette := 0; FWfGamma := 1.0; FDisplayPixels := 4096; FFMStepOn := True; FLocalAudio := True; // GUI: звук на локальную карту; демон выключит явно FLastSMeter := -130; FLastSWR := 1; FLastSupplyV := -1; FLastSupplyA := -1; @@ -709,6 +713,7 @@ begin // Роутинг аудио слайсов на их звуковухи — внутренний, вешаем сразу (не // зависит от фронтенда, в отличие от OnAudio/OnSpectrum). FDSPEngine.OnSliceAudio := OnSliceAudioReady; + FDSPEngine.SetDisplayResLimit(FDisplayPixels); // QO-100 beacon lock: эталон частоты = декодер (см. ServiceBeaconLock). FBeaconRefHz := 10489750000.0; // центральный BPSK-маяк NB-транспондера (downlink) @@ -1256,18 +1261,40 @@ begin if Result then S := FSlices[idx]; end; +// Снимок кадра для web-зеркала: протокол фиксирован на 1024 точки, поэтому +// при большем разрешении анализатора децимируем max-детектором (не обрезаем). +procedure SnapshotPixels(const Pixels: array of Single; Count: Integer; + var Dst: array of Single; out DstCount: Integer); +var + i, j, S0, S1, N: Integer; + V: Single; +begin + N := Length(Dst); + if Count > Length(Pixels) then Count := Length(Pixels); + if Count <= N then + begin + for i := 0 to Count - 1 do Dst[i] := Pixels[i]; + DstCount := Count; + Exit; + end; + for i := 0 to N - 1 do + begin + S0 := Int64(i) * Count div N; + S1 := (Int64(i) + 1) * Count div N - 1; + V := Pixels[S0]; + for j := S0 + 1 to S1 do + if Pixels[j] > V then V := Pixels[j]; + Dst[i] := V; + end; + DstCount := N; +end; + procedure TRadioController.OnSpectrumReady(const Pixels: array of Single; Count: Integer); // DSP-поток: сохраняем последний кадр для web-зеркала и отдаём сырьё наружу // (UI рисует FSpecView). Децимации у спектра нет. -var - i, N: Integer; begin - N := Count; - if N > Length(FSpectrumBuf) then N := Length(FSpectrumBuf); - for i := 0 to N - 1 do - FSpectrumBuf[i] := Pixels[i]; - FSpectrumBufCount := N; + SnapshotPixels(Pixels, Count, FSpectrumBuf, FSpectrumBufCount); if Assigned(FOnSpectrumData) then FOnSpectrumData(Pixels, Count); end; @@ -1275,14 +1302,8 @@ procedure TRadioController.OnWaterfallReady(const Pixels: array of Single; Count: Integer); // DSP-поток: web-зеркало получает КАЖДЫЙ кадр; децимация рендера водопада — // забота фронтенда (OnWaterfallData). -var - i, N: Integer; begin - N := Count; - if N > Length(FWaterfallBuf) then N := Length(FWaterfallBuf); - for i := 0 to N - 1 do - FWaterfallBuf[i] := Pixels[i]; - FWaterfallBufCount := N; + SnapshotPixels(Pixels, Count, FWaterfallBuf, FWaterfallBufCount); if Assigned(FOnWaterfallData) then FOnWaterfallData(Pixels, Count); end; @@ -1892,6 +1913,9 @@ begin Result.WfManualHigh := FWfManualHigh; Result.WfManualLow := FWfManualLow; Result.WfAGCOffset := FWfAGCOffset; + Result.WfPalette := FWfPalette; + Result.WfGamma := FWfGamma; + Result.DisplayPixels := FDisplayPixels; Result.SpecRefLevel := FSpecRefLevel; Result.SpecRange := FSpecRange; Result.SpecGridStep := FSpecGridStep; @@ -3478,6 +3502,10 @@ begin FWfManualHigh := FLoadedGlobal.WfManualHigh; FWfManualLow := FLoadedGlobal.WfManualLow; FWfAGCOffset := FLoadedGlobal.WfAGCOffset; + FWfPalette := EnsureRange(FLoadedGlobal.WfPalette, 0, 2); + FWfGamma := EnsureRange(FLoadedGlobal.WfGamma, 0.3, 3.0); + FDisplayPixels := EnsureRange(FLoadedGlobal.DisplayPixels, 64, 4096); + if FWDSPReady then FDSPEngine.SetDisplayResLimit(FDisplayPixels); FShowSpectrum := FLoadedGlobal.ShowSpectrum; FShowWaterfall := FLoadedGlobal.ShowWaterfall; FShowWideband := FLoadedGlobal.ShowWideband; diff --git a/Settings.pas b/Settings.pas index 69b44e3..98ebd86 100644 --- a/Settings.pas +++ b/Settings.pas @@ -250,6 +250,9 @@ type WfManualHigh: Double; // manual waterfall high threshold WfManualLow: Double; // manual waterfall low threshold WfAGCOffset: Double; // AGC offset in dB + WfPalette: Integer; // 0=Classic, 1=Inferno, 2=Turbo + WfGamma: Double; // контраст палитры (0.3..3.0, 1.0 = линейно) + DisplayPixels: Integer; // потолок точек анализатора (1024/2048/4096) SpecRefLevel: Double; // dBm reference (top of scale), e.g. -20 SpecRange: Double; // dB range, e.g. 110 (bottom = ref - range) SpecGridStep: Double; // grid step in dB, e.g. 10 @@ -443,6 +446,9 @@ begin G.WfManualHigh := -80.0; G.WfManualLow := -130.0; G.WfAGCOffset := 0.0; + G.WfPalette := 0; // Classic + G.WfGamma := 1.0; + G.DisplayPixels := 4096; // 1:1 до 4K-ширины G.SpecRefLevel := -20.0; G.SpecRange := 110.0; G.SpecGridStep := 10.0; @@ -666,6 +672,9 @@ begin G.WfManualHigh := JD(GObj,'wf_manual_high',-80.0); G.WfManualLow := JD(GObj,'wf_manual_low',-130.0); G.WfAGCOffset := JD(GObj,'wf_agc_offset',0.0); + G.WfPalette := JI(GObj,'wf_palette',0); + G.WfGamma := JD(GObj,'wf_gamma',1.0); + G.DisplayPixels := JI(GObj,'display_pixels',4096); G.SpecRefLevel := JD(GObj,'spec_ref_level',-20.0); G.SpecRange := JD(GObj,'spec_range',110.0); G.SpecGridStep := JD(GObj,'spec_grid_step',10.0); @@ -771,6 +780,9 @@ begin JW(O,'wf_manual_high',G.WfManualHigh); JW(O,'wf_manual_low',G.WfManualLow); JW(O,'wf_agc_offset',G.WfAGCOffset); + JW(O,'wf_palette',G.WfPalette); + JW(O,'wf_gamma',G.WfGamma); + JW(O,'display_pixels',G.DisplayPixels); JW(O,'spec_ref_level',G.SpecRefLevel); JW(O,'spec_range',G.SpecRange); JW(O,'spec_grid_step',G.SpecGridStep); diff --git a/SettingsForm.pas b/SettingsForm.pas index c3f82fa..97126da 100644 --- a/SettingsForm.pas +++ b/SettingsForm.pas @@ -47,6 +47,8 @@ type SpecAvgMode: Integer; SpecAvgTimeMS: Double) of object; TOnWaterfallParamChange = procedure(WfDetector, WfAvgMode: Integer; WfAvgTimeMS, WfHigh, WfLow, WfAGCOffset: Double) of object; + TOnWfRenderChange = procedure(Palette: Integer; Gamma: Double; + ResLimit: Integer) of object; TOnGridParamChange = procedure(RefLevel, Range, GridStep: Double) of object; TOnAudioDevChange = procedure(DevIndex: Integer; const DevName: string) of object; TOnAudioInDevChange = procedure(DevIndex: Integer; const DevName: string) of object; @@ -119,6 +121,9 @@ type FWfAGCOffset: TFlatFloatSpinEdit; FChkWfAGC: TFlatCheckBox; FChkWfNF: TFlatCheckBox; + FCmbWfPalette: TFlatComboBox; + FWfGamma: TFlatFloatSpinEdit; + FCmbWfRes: TFlatComboBox; FEdRefLevel: TFlatEdit; FEdRange: TFlatEdit; FCmbGridStep: TFlatComboBox; @@ -289,6 +294,7 @@ type FOnWebSettingsChange: TOnWebSettingsChange; FOnDisplayChange: TOnDisplayParamChange; FOnWaterfallChange: TOnWaterfallParamChange; + FOnWfRenderChange: TOnWfRenderChange; FOnGridChange: TOnGridParamChange; FOnAudioDevChange: TOnAudioDevChange; FOnAudioInDevChange: TOnAudioInDevChange; @@ -378,6 +384,8 @@ type procedure FireDisplayChange; procedure FireWaterfallChange; + procedure FireWfRenderChange; + procedure OnWfRenderCtlChange(Sender: TObject); procedure FireGridChange; function GetSpecAvgTimeMS: Double; @@ -423,6 +431,7 @@ type procedure LoadAlexSettings(const A: TAlexSettings; BoardType: Integer); procedure LoadXvtrSettings(const X: TXvtrSettings); procedure LoadWfAGCNF(AGC, NF: Boolean); + procedure LoadWfRender(Palette: Integer; Gamma: Double; ResLimit: Integer); procedure LoadADCSettings(Dither, Random: Boolean); procedure LoadWebSettings(Enabled: Boolean; Port: Integer; const BindAddr, User, Pass: string); @@ -435,6 +444,7 @@ type property OnVHFCalChange: TOnVHFCalChange read FOnVHFCalChange write FOnVHFCalChange; property OnDisplayChange: TOnDisplayParamChange read FOnDisplayChange write FOnDisplayChange; property OnWaterfallChange: TOnWaterfallParamChange read FOnWaterfallChange write FOnWaterfallChange; + property OnWfRenderChange: TOnWfRenderChange read FOnWfRenderChange write FOnWfRenderChange; property OnGridChange: TOnGridParamChange read FOnGridChange write FOnGridChange; property OnAudioDevChange: TOnAudioDevChange read FOnAudioDevChange write FOnAudioDevChange; property OnAudioInDevChange: TOnAudioInDevChange read FOnAudioInDevChange write FOnAudioInDevChange; @@ -492,6 +502,10 @@ const GRID_STEPS: array[0..4] of Double = (5.0, 10.0, 20.0, 30.0, 40.0); GRID_STEP_NAMES: array[0..4] of string = ('5 dB','10 dB','20 dB','30 dB','40 dB'); + WF_PALETTE_NAMES: array[0..2] of string = ('Classic', 'Inferno', 'Turbo'); + DISPLAY_RES_VALUES: array[0..2] of Integer = (1024, 2048, 4096); + DISPLAY_RES_NAMES: array[0..2] of string = ('1024 px', '2048 px', '4096 px'); + FPS_VALUES: array[0..12] of Integer = (5, 10, 15, 20, 25, 30, 40, 50, 60, 75, 80, 90, 100); FPS_NAMES: array[0..12] of string = ('5 fps','10 fps','15 fps','20 fps','25 fps','30 fps', '40 fps','50 fps','60 fps','75 fps','80 fps','90 fps','100 fps'); @@ -1093,6 +1107,19 @@ begin FChkWfNF.Font.Name := UI_FONT; FChkWfNF.Font.Size := 9; FChkWfNF.OnChange := OnWfAGCNFChkChange; + + Grp := MakeGroupPanel(FPageWaterfall, 'Rendering', MARGIN, 614, 650, 170); + MakeLbl(Grp, 'Palette:', PAD, R1 + 5, LW); + FCmbWfPalette := MakeCombo(Grp, CX, R1, CMB_W, OnWfRenderCtlChange); + for i := 0 to High(WF_PALETTE_NAMES) do FCmbWfPalette.Items.Add(WF_PALETTE_NAMES[i]); + FCmbWfPalette.ItemIndex := 0; + MakeLbl(Grp, 'Gamma:', PAD, R1 + STEP + 5, LW); + FWfGamma := MkFloat(CX, R1 + STEP, SPW, 1.0, 0.3, 3.0, 0.05, OnWfRenderCtlChange); + FWfGamma.DecimalPlaces := 2; + MakeLbl(Grp, 'Resolution:', PAD, R1 + 2 * STEP + 5, LW); + FCmbWfRes := MakeCombo(Grp, CX, R1 + 2 * STEP, 130, OnWfRenderCtlChange); + for i := 0 to High(DISPLAY_RES_NAMES) do FCmbWfRes.Items.Add(DISPLAY_RES_NAMES[i]); + FCmbWfRes.ItemIndex := High(DISPLAY_RES_NAMES); end; // --------------------------------------------------------------------------- @@ -2310,6 +2337,41 @@ begin GetWfAGCOffset); end; +procedure TSettingsForm.FireWfRenderChange; +var + ResV: Integer; +begin + if FLoading then Exit; + if not Assigned(FOnWfRenderChange) then Exit; + if (FCmbWfRes.ItemIndex >= 0) and (FCmbWfRes.ItemIndex <= High(DISPLAY_RES_VALUES)) then + ResV := DISPLAY_RES_VALUES[FCmbWfRes.ItemIndex] + else + ResV := DISPLAY_RES_VALUES[High(DISPLAY_RES_VALUES)]; + FOnWfRenderChange(Max(0, FCmbWfPalette.ItemIndex), FWfGamma.Value, ResV); +end; + +procedure TSettingsForm.OnWfRenderCtlChange(Sender: TObject); +begin FireWfRenderChange; end; + +procedure TSettingsForm.LoadWfRender(Palette: Integer; Gamma: Double; ResLimit: Integer); +var i: Integer; +begin + FLoading := True; + try + if (Palette >= 0) and (Palette < FCmbWfPalette.Items.Count) then + FCmbWfPalette.ItemIndex := Palette + else + FCmbWfPalette.ItemIndex := 0; + FWfGamma.Value := EnsureRange(Gamma, 0.3, 3.0); + FCmbWfRes.ItemIndex := High(DISPLAY_RES_VALUES); + for i := 0 to High(DISPLAY_RES_VALUES) do + if DISPLAY_RES_VALUES[i] = ResLimit then + begin FCmbWfRes.ItemIndex := i; Break; end; + finally + FLoading := False; + end; +end; + procedure TSettingsForm.FireGridChange; begin if FLoading then Exit; diff --git a/SpectrumView.pas b/SpectrumView.pas index 4f09e16..9e1012b 100644 --- a/SpectrumView.pas +++ b/SpectrumView.pas @@ -100,7 +100,7 @@ type FBeaconDecFreq: Double; FBeaconDecHalf: Double; // полуширина фильтра, Гц // ── Spectrum buffer ─────────────────────────────────────────────────────── - FSpectrumBuf: array[0..1023] of Single; + FSpectrumBuf: array[0..WF_MAX_PIXELS-1] of Single; FSpectrumBufCount: Integer; FSpectrumDirty: Boolean; // ── Sub-views ───────────────────────────────────────────────────────────── @@ -172,6 +172,10 @@ type procedure SetWfAGCOffset(V: Double); function GetWfFrameInterval: Integer; procedure SetWfFrameInterval(V: Integer); + function GetWfPalette: Integer; + procedure SetWfPalette(V: Integer); + function GetWfGamma: Double; + procedure SetWfGamma(V: Double); procedure SetPbWaterfall(V: TControl); // SMeter sub-view delegating accessors function GetLastSMeter: Double; @@ -254,6 +258,8 @@ type property FillSpectrum: Boolean read FFillSpectrum write SetFillSpectrum; property WaterfallDirty: Boolean read GetWaterfallDirty write SetWaterfallDirty; property WfFrameInterval: Integer read GetWfFrameInterval write SetWfFrameInterval; + property WfPalette: Integer read GetWfPalette write SetWfPalette; + property WfGamma: Double read GetWfGamma write SetWfGamma; // ── Ссылки на PaintBox ──────────────────────────────────────────────────── property PbWaterfall: TControl write SetPbWaterfall; @@ -528,6 +534,10 @@ function TSpectrumView.GetWfAGCOffset: Double; begin Result := FWaterfall. procedure TSpectrumView.SetWfAGCOffset(V: Double); begin FWaterfall.WfAGCOffset := V; end; function TSpectrumView.GetWfFrameInterval: Integer; begin Result := FWaterfall.WfFrameInterval; end; procedure TSpectrumView.SetWfFrameInterval(V: Integer); begin FWaterfall.WfFrameInterval := V; end; +function TSpectrumView.GetWfPalette: Integer; begin Result := FWaterfall.WfPalette; end; +procedure TSpectrumView.SetWfPalette(V: Integer); begin FWaterfall.WfPalette := V; end; +function TSpectrumView.GetWfGamma: Double; begin Result := FWaterfall.WfGamma; end; +procedure TSpectrumView.SetWfGamma(V: Double); begin FWaterfall.WfGamma := V; end; procedure TSpectrumView.SetPbWaterfall(V: TControl); begin FWaterfall.PbWaterfall := V; end; function TSpectrumView.GetLastSMeter: Double; begin Result := FSMeter.LastSMeter; end; @@ -1148,7 +1158,7 @@ end; procedure TSpectrumView.SetSpectrumData(const Pixels: array of Single; Count: Integer); var i, N: Integer; begin - N := Min(Count, 1024); + N := Min(Count, WF_MAX_PIXELS); for i := 0 to N - 1 do FSpectrumBuf[i] := Pixels[i]; FSpectrumBufCount := N; FSpectrumDirty := True; @@ -1228,7 +1238,7 @@ end; procedure TSpectrumView.ResetSpectrumBuf; var i: Integer; begin - for i := 0 to 1023 do FSpectrumBuf[i] := -130.0; + for i := 0 to High(FSpectrumBuf) do FSpectrumBuf[i] := -130.0; FWaterfall.ResetWfBuf; end; @@ -1249,7 +1259,7 @@ begin 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); + FSpectrumBuf[i mod WF_MAX_PIXELS] := Max(Noise, Sig); end; end; @@ -1394,7 +1404,7 @@ begin // Точки кривой спектра if FSpPtsLen <> W + 2 then begin SetLength(FSpPts, W + 2); FSpPtsLen := W + 2; end; - SrcCount := EnsureRange(FSpectrumBufCount, 2, 1024); + SrcCount := EnsureRange(FSpectrumBufCount, 2, WF_MAX_PIXELS); if FTXMode and (FTXSpanHz > 0) and (FSpanHz > 0) then begin for i := 0 to W - 1 do diff --git a/SpectrumViewOpengl.pas b/SpectrumViewOpengl.pas index 034b495..70b449b 100644 --- a/SpectrumViewOpengl.pas +++ b/SpectrumViewOpengl.pas @@ -17,7 +17,8 @@ interface uses Classes, SysUtils, Graphics, Controls, Math, Types, OpenGLContextEx, GL, - AppTheme, SpectrumView, VfoOverlay, BandPlanOverlay, WaterfallViewOpengl; + AppTheme, SpectrumView, VfoOverlay, BandPlanOverlay, + WaterfallView, WaterfallViewOpengl; type TGLTextureCache = record @@ -663,7 +664,7 @@ var R, G, B: GLFloat; begin if Length(FSpY) <> W then SetLength(FSpY, W); - SrcCount := ClampI(FSpectrumBufCount, 2, 1024); + SrcCount := ClampI(FSpectrumBufCount, 2, WF_MAX_PIXELS); for I := 0 to W - 1 do begin if FTXMode and (FTXSpanHz > 0) and (FSpanHz > 0) then @@ -944,7 +945,9 @@ begin H := FGLControl.Height; if (W <= 0) or (H <= 0) then Exit; - glViewport(0, 0, W, H); + // HiDPI: viewport в физических пикселях FBO, координаты — логические + glViewport(0, 0, Round(W * FGLControl.PixelScale), + Round(H * FGLControl.PixelScale)); glMatrixMode(GL_PROJECTION); glLoadIdentity; glOrtho(0, W, H, 0, -1, 1); diff --git a/WDSPEngine.pas b/WDSPEngine.pas index 52aebca..633027c 100644 --- a/WDSPEngine.pas +++ b/WDSPEngine.pas @@ -103,7 +103,7 @@ const RX_DISP_ID = 0; // analyzer для RX-спектра/водопада TX_DISP_ID = 1; // analyzer для TX-спектра/водопада DSP_BUFSIZE = 1024; - SPECTRUM_PIXELS = 1024; + SPECTRUM_PIXELS = 4096; // потолок точек дисплея; синхронно с WaterfallView.WF_MAX_PIXELS DISPLAY_BLOCK_SIZE = 64; // Очередь IQ пакетов между сетевым и DSP потоком @@ -338,6 +338,7 @@ type FAGCHangLevel: Double; // читается из WDSP (GetRXAAGCHangLevel) FAGCThresh: Double; // читается из WDSP (GetRXAAGCThresh) FLastSpectrumW: Integer; // последняя известная ширина спектра (пикс) + FDisplayResLimit: Integer; // потолок точек анализатора (настройка display_pixels) FShiftHz: Double; // NCO shift для CTUN FNRMode: Integer; FNBMode: Integer; @@ -442,6 +443,7 @@ type procedure SetAGCHangThreshold(Threshold: Integer); procedure UpdateAGCLines(SpectrumW: Integer); // читает hang/thresh из WDSP для линии на спектре procedure SetSpectrumWidth(W: Integer); // обновляет FLastSpectrumW для корректных AGC линий + procedure SetDisplayResLimit(V: Integer); // потолок точек анализатора (1024/2048/4096) procedure SetShift(ShiftHz: Double); // CTUN NCO сдвиг procedure SetNRMode(Mode: Integer); procedure SetNR(Enable: Boolean); @@ -851,6 +853,7 @@ begin FDisplayFPS := 60; FEffectiveDisplayFPS := 60.0; FDisplayPixelCount := SPECTRUM_PIXELS; + FDisplayResLimit := SPECTRUM_PIXELS; FSpecDetector := 0; // Peak FSpecAvgMode := 3; // Log Recursive FSpecAvgTimeMS := 30.0; @@ -1017,7 +1020,8 @@ begin MaxW := AnalyzerFFT + Round(Min(KEEP_TIME_SEC * FSampleRate, KEEP_TIME_SEC * AnalyzerFFT * EnsureRange(FDisplayFPS, 5, 100))); CalcAnalyzerTiming(AnalyzerFFT, Overlap, FEffectiveDisplayFPS); - DisplayPixels := EnsureRange(FLastSpectrumW, 64, SPECTRUM_PIXELS); + DisplayPixels := EnsureRange(FLastSpectrumW, 64, + EnsureRange(FDisplayResLimit, 64, SPECTRUM_PIXELS)); FDisplayPixelCount := DisplayPixels; AverageTimeToParams(FSpecAvgMode, FSpecAvgTimeMS, AvgCount, Backmult); @@ -1071,7 +1075,8 @@ begin MaxW := AnalyzerFFT + Round(Min(KEEP_TIME_SEC * FTXSampleRate, KEEP_TIME_SEC * AnalyzerFFT * EnsureRange(FDisplayFPS, 5, 100))); CalcAnalyzerTiming(AnalyzerFFT, Overlap, EffectiveFPS); - DisplayPixels := EnsureRange(FLastSpectrumW, 64, SPECTRUM_PIXELS); + DisplayPixels := EnsureRange(FLastSpectrumW, 64, + EnsureRange(FDisplayResLimit, 64, SPECTRUM_PIXELS)); AverageTimeToParams(FTXSpecAvgMode, FTXSpecAvgTimeMS, AvgCount, Backmult); AverageTimeToParams(FTXWfAvgMode, FTXWfAvgTimeMS, WfAvgCount, WfBackmult); @@ -1909,6 +1914,15 @@ begin ApplyAnalyzerSettings; end; +procedure TWDSPEngine.SetDisplayResLimit(V: Integer); +begin + V := EnsureRange(V, 64, SPECTRUM_PIXELS); + if FDisplayResLimit = V then Exit; + FDisplayResLimit := V; + if FAnalyzerOpen then + ApplyAnalyzerSettings; +end; + procedure TWDSPEngine.UpdateAGCLines(SpectrumW: Integer); begin if not FInitialized then Exit; diff --git a/WaterfallView.pas b/WaterfallView.pas index e204605..48494be 100644 --- a/WaterfallView.pas +++ b/WaterfallView.pas @@ -15,6 +15,16 @@ uses Classes, SysUtils, Graphics, ExtCtrls, Controls, Math, LCLIntf, LCLType, AppTheme; +const + // Максимум точек строки водопада; держать в синхроне с + // WDSPEngine.SPECTRUM_PIXELS. + WF_MAX_PIXELS = 4096; + + // Палитры водопада (индексы совпадают с настройкой wf_palette) + WF_PAL_CLASSIC = 0; // Thetis enhanced (тёмная) / пастельная (светлая тема) + WF_PAL_INFERNO = 1; + WF_PAL_TURBO = 2; + type TWaterfallView = class protected @@ -24,11 +34,16 @@ type FWfBitmapH: Integer; FWfRowBuf: array of LongWord; // одна строка (новейшая), нативный порядок FWfHead: Integer; // физ. строка bitmap с новейшей линией - FWfLut: array[0..511] of LongWord; + FWfLut: array[0..511] of LongWord; // нативный порядок байт (CPU bitmap) + FWfLutARGB: array[0..511] of LongWord; // логический $AARRGGBB (GL-путь) FWfLutLow: Double; FWfLutHigh: Double; FWfLutLight: Boolean; - FWaterfallBuf: array[0..1023] of Single; + FWfLutPalette: Integer; + FWfLutGamma: Double; + FWfPalette: Integer; + FWfGamma: Double; + FWaterfallBuf: array[0..WF_MAX_PIXELS-1] of Single; FWaterfallBufCount: Integer; FWaterfallDirty: Boolean; FWfFrameCounter: Integer; @@ -53,6 +68,12 @@ type procedure DrawMarkerLine(C: TCanvas; W, H: Integer); procedure BuildWfLut(WfLow, WfHigh: Double); + function LutNeedsRebuild(WfLow, WfHigh: Double): Boolean; + function PaletteColor(T: Double): LongWord; // t∈[0..1] → $AARRGGBB + procedure SetWfPalette(V: Integer); + procedure SetWfGamma(V: Double); + procedure UpdateAutoLevels; // гистограммный AGC → FWfLow/FWfHigh + procedure GetEffectiveLevels(out Lo, Hi: Double); public constructor Create; destructor Destroy; override; @@ -72,6 +93,8 @@ type property MarkerActive: Boolean read FMarkerActive write FMarkerActive; property MarkerX: Integer read FMarkerX write FMarkerX; property PbWaterfall: TControl write FPbWaterfall; + property WfPalette: Integer read FWfPalette write SetWfPalette; + property WfGamma: Double read FWfGamma write SetWfGamma; procedure SetWaterfallData(const Pixels: array of Single; Count: Integer); virtual; procedure DrawWaterfall; virtual; @@ -97,29 +120,29 @@ begin Result := Format('%d.%3.3d.%3.3d', [Mhz, KHz, Rest]); end; -function WaterfallEnhancedColorThetis(ValueDB, LowDB, HighDB: Double): LongWord; +// Классическая тёмная палитра (Thetis enhanced), T∈[0..1] +function ClassicDarkColor(T: Double): LongWord; var - Overall, Local: Double; + Local: Double; R, G, B: Integer; begin - if ValueDB <= LowDB then begin Result := $FF000000; Exit; end; - if ValueDB >= HighDB then begin Result := ($FF shl 24) or (255 shl 16) or (124 shl 8) or 192; Exit; end; - Overall := (ValueDB - LowDB) / Max(1E-9, HighDB - LowDB); - if Overall < (2.0 / 9.0) then - begin Local := Overall / (2.0/9.0); R := 0; G := 0; B := Round(Local * 255.0); end - else if Overall < (3.0 / 9.0) then - begin Local := (Overall - 2.0/9.0) / (1.0/9.0); R := 0; G := Round(Local*255.0); B := 255; end - else if Overall < (4.0 / 9.0) then - begin Local := (Overall - 3.0/9.0) / (1.0/9.0); R := 0; G := 255; B := Round((1.0-Local)*255.0); end - else if Overall < (5.0 / 9.0) then - begin Local := (Overall - 4.0/9.0) / (1.0/9.0); R := Round(Local*255.0); G := 255; B := 0; end - else if Overall < (7.0 / 9.0) then - begin Local := (Overall - 5.0/9.0) / (2.0/9.0); R := 255; G := Round((1.0-Local)*255.0); B := 0; end - else if Overall < (8.0 / 9.0) then - begin Local := (Overall - 7.0/9.0) / (1.0/9.0); R := 255; G := 0; B := Round(Local*255.0); end + if T <= 0.0 then begin Result := $FF000000; Exit; end; + if T >= 1.0 then begin Result := ($FF shl 24) or (255 shl 16) or (124 shl 8) or 192; Exit; end; + if T < (2.0 / 9.0) then + begin Local := T / (2.0/9.0); R := 0; G := 0; B := Round(Local * 255.0); end + else if T < (3.0 / 9.0) then + begin Local := (T - 2.0/9.0) / (1.0/9.0); R := 0; G := Round(Local*255.0); B := 255; end + else if T < (4.0 / 9.0) then + begin Local := (T - 3.0/9.0) / (1.0/9.0); R := 0; G := 255; B := Round((1.0-Local)*255.0); end + else if T < (5.0 / 9.0) then + begin Local := (T - 4.0/9.0) / (1.0/9.0); R := Round(Local*255.0); G := 255; B := 0; end + else if T < (7.0 / 9.0) then + begin Local := (T - 5.0/9.0) / (2.0/9.0); R := 255; G := Round((1.0-Local)*255.0); B := 0; end + else if T < (8.0 / 9.0) then + begin Local := (T - 7.0/9.0) / (1.0/9.0); R := 255; G := 0; B := Round(Local*255.0); end else begin - Local := (Overall - 8.0/9.0) / (1.0/9.0); + Local := (T - 8.0/9.0) / (1.0/9.0); R := Round((0.75 + 0.25 * (1.0 - Local)) * 255.0); G := Round(Local * 255.0 * 0.5); B := 255; @@ -130,28 +153,48 @@ begin Result := ($FF shl 24) or (R shl 16) or (G shl 8) or B; end; -function WaterfallLightTheme(ValueDB, LowDB, HighDB: Double): LongWord; -const - NSTOPS = 8; - SR: array[0..NSTOPS-1] of Integer = (224, 160, 20, 0, 0, 220, 255, 255); - SG: array[0..NSTOPS-1] of Integer = (230, 185, 80, 185, 200, 210, 90, 20); - SB: array[0..NSTOPS-1] of Integer = (232, 210, 200, 210, 80, 0, 0, 20); +// Линейная интерполяция по таблице стопов, T∈[0..1] +function GradientColor(const SR, SG, SB: array of Integer; T: Double): LongWord; var - T: Double; - Seg: Integer; - R, G, B: Integer; + F: Double; + Seg, N, R, G, B: Integer; begin - if ValueDB <= LowDB then begin Result := $FFE0E6E8; Exit; end; - if ValueDB >= HighDB then begin Result := ($FF shl 24) or (SR[NSTOPS-1] shl 16) or (SG[NSTOPS-1] shl 8) or SB[NSTOPS-1]; Exit; end; - T := (ValueDB - LowDB) / Max(1E-9, HighDB - LowDB) * (NSTOPS - 1); - Seg := Min(NSTOPS - 2, Trunc(T)); - T := T - Seg; - R := EnsureRange(Round(SR[Seg] * (1.0 - T) + SR[Seg+1] * T), 0, 255); - G := EnsureRange(Round(SG[Seg] * (1.0 - T) + SG[Seg+1] * T), 0, 255); - B := EnsureRange(Round(SB[Seg] * (1.0 - T) + SB[Seg+1] * T), 0, 255); + N := Length(SR); + if T <= 0.0 then + begin + Result := ($FF shl 24) or (SR[0] shl 16) or (SG[0] shl 8) or SB[0]; + Exit; + end; + if T >= 1.0 then + begin + Result := ($FF shl 24) or (SR[N-1] shl 16) or (SG[N-1] shl 8) or SB[N-1]; + Exit; + end; + F := T * (N - 1); + Seg := Min(N - 2, Trunc(F)); + F := F - Seg; + R := EnsureRange(Round(SR[Seg] * (1.0 - F) + SR[Seg+1] * F), 0, 255); + G := EnsureRange(Round(SG[Seg] * (1.0 - F) + SG[Seg+1] * F), 0, 255); + B := EnsureRange(Round(SB[Seg] * (1.0 - F) + SB[Seg+1] * F), 0, 255); Result := ($FF shl 24) or (R shl 16) or (G shl 8) or B; end; +const + // Классическая палитра светлой темы (пастель, фон E0E6E8) + LIGHT_R: array[0..7] of Integer = (224, 160, 20, 0, 0, 220, 255, 255); + LIGHT_G: array[0..7] of Integer = (230, 185, 80, 185, 200, 210, 90, 20); + LIGHT_B: array[0..7] of Integer = (232, 210, 200, 210, 80, 0, 0, 20); + + // Inferno (matplotlib), перцептуально-равномерная, 11 опорных точек + INF_R: array[0..10] of Integer = ( 0, 22, 66, 106, 147, 188, 221, 243, 252, 246, 252); + INF_G: array[0..10] of Integer = ( 0, 11, 10, 23, 38, 55, 81, 120, 165, 215, 255); + INF_B: array[0..10] of Integer = ( 4, 57, 104, 110, 103, 84, 58, 25, 10, 70, 164); + + // Turbo (Google), улучшенный jet без артефактов яркости, 9 опорных точек + TRB_R: array[0..8] of Integer = ( 48, 70, 24, 97, 164, 249, 247, 202, 122); + TRB_G: array[0..8] of Integer = ( 18, 118, 221, 252, 252, 163, 94, 33, 4); + TRB_B: array[0..8] of Integer = ( 59, 232, 194, 108, 60, 63, 33, 6, 3); + // Преобразует логический $AARRGGBB в нативный для ScanLine порядок байт. // DARWIN (Cocoa): в памяти [A,R,G,B]; прочие (Qt6/Win32): [B,G,R,A]. // Платформы little-endian, поэтому байт 0 — младший байт LongWord. @@ -195,9 +238,13 @@ begin FTheme := DarkTheme; FLightTheme := False; FSpanHz := 192000; + FWfPalette := WF_PAL_CLASSIC; + FWfGamma := 1.0; FWfLutLow := 1.0; // невалидно → LUT перестроится при первом кадре FWfLutHigh := 0.0; FWfLutLight := False; + FWfLutPalette := -1; + FWfLutGamma := 0.0; ResetWfBuf; end; @@ -232,16 +279,34 @@ end; procedure TWaterfallView.ResetWfBuf; var i: Integer; begin - for i := 0 to 1023 do FWaterfallBuf[i] := -130.0; + for i := 0 to High(FWaterfallBuf) do FWaterfallBuf[i] := -130.0; FWfHigh := FWfManualHigh; FWfLow := FWfManualLow; FWaterfallDirty := True; end; +procedure TWaterfallView.SetWfPalette(V: Integer); +begin + V := EnsureRange(V, WF_PAL_CLASSIC, WF_PAL_TURBO); + if V = FWfPalette then Exit; + FWfPalette := V; + FWfLutPalette := -1; // LUT перестроится на следующем кадре + FWaterfallDirty := True; +end; + +procedure TWaterfallView.SetWfGamma(V: Double); +begin + V := EnsureRange(V, 0.3, 3.0); + if Abs(V - FWfGamma) < 1E-6 then Exit; + FWfGamma := V; + FWfLutGamma := 0.0; // LUT перестроится на следующем кадре + FWaterfallDirty := True; +end; + procedure TWaterfallView.SetWaterfallData(const Pixels: array of Single; Count: Integer); var i, N: Integer; begin - N := Min(Count, 1024); + N := Min(Count, WF_MAX_PIXELS); for i := 0 to N - 1 do FWaterfallBuf[i] := Pixels[i]; FWaterfallBufCount := N; Inc(FWfFrameCounter); @@ -279,58 +344,71 @@ begin C.TextOut(MX - 4 - C.TextWidth(MarkerLbl), 4, MarkerLbl); end; +// Цвет палитры для нормированного уровня T∈[0..1] (без гаммы — она в LUT) +function TWaterfallView.PaletteColor(T: Double): LongWord; +begin + case FWfPalette of + WF_PAL_INFERNO: Result := GradientColor(INF_R, INF_G, INF_B, T); + WF_PAL_TURBO: Result := GradientColor(TRB_R, TRB_G, TRB_B, T); + else + if FLightTheme then + Result := GradientColor(LIGHT_R, LIGHT_G, LIGHT_B, T) + else + Result := ClassicDarkColor(T); + end; +end; + +function TWaterfallView.LutNeedsRebuild(WfLow, WfHigh: Double): Boolean; +begin + Result := (FWfLutLight <> FLightTheme) or + (FWfLutPalette <> FWfPalette) or + (Abs(FWfLutGamma - FWfGamma) > 1E-6) or + (Abs(WfLow - FWfLutLow) > 0.05) or + (Abs(WfHigh - FWfLutHigh) > 0.05); +end; + // Перестраивает палитру dB→цвет (512 точек) один раз на кадр вместо // вычисления цвета с плавающей точкой на каждый пиксель. procedure TWaterfallView.BuildWfLut(WfLow, WfHigh: Double); var i: Integer; - dB, Range: Double; + T: Double; C: LongWord; begin - Range := WfHigh - WfLow; - if Range < 1E-6 then Range := 1E-6; for i := 0 to High(FWfLut) do begin - dB := WfLow + (i / High(FWfLut)) * Range; - if FLightTheme then - C := WaterfallLightTheme(dB, WfLow, WfHigh) - else - C := WaterfallEnhancedColorThetis(dB, WfLow, WfHigh); - FWfLut[i] := NativePixel(C); + T := i / High(FWfLut); + // Гамма <1 вытягивает слабые сигналы из шума, >1 притемняет + if Abs(FWfGamma - 1.0) > 1E-6 then T := Power(T, FWfGamma); + C := PaletteColor(T); + FWfLutARGB[i] := C; + FWfLut[i] := NativePixel(C); end; - FWfLutLow := WfLow; - FWfLutHigh := WfHigh; - FWfLutLight := FLightTheme; + FWfLutLow := WfLow; + FWfLutHigh := WfHigh; + FWfLutLight := FLightTheme; + FWfLutPalette := FWfPalette; + FWfLutGamma := FWfGamma; end; -// ──────────────────────────────────────────────────────────────────────────── -// DrawWaterfall -// ──────────────────────────────────────────────────────────────────────────── - -procedure TWaterfallView.DrawWaterfall; +// Гистограммный AGC: оценивает шумовой пол (30-й перцентиль) и верх сигналов +// (98-й), плавно подтягивает FWfLow/FWfHigh. Общий для CPU- и GL-пути. +procedure TWaterfallView.UpdateAutoLevels; var - W, H, X: Integer; - dB, frac, WatSrcF: Double; - WatS0, WatS1: Integer; + X, SrcCount, LowTargetCount, HighTargetCount, CumCount, HistIdx: Integer; + Hist: array[0..191] of Integer; + HistMinDB, HistStepDB, NoiseFloorDB, SignalTopDB: Double; TargetLow, TargetHigh: Double; - HistMinDB, HistMaxDB, HistStepDB: Double; - NoiseFloorDB, SignalTopDB: Double; - CumCount, LowTargetCount, HighTargetCount, HistIdx: Integer; - WfHigh, WfLow, LutScale, Step: Double; - Row, Idx: Integer; RowPtr: PByte; - Pal: LongWord; Hist: array[0..191] of Integer; SrcCount: Integer; const ALPHA_HIGH = 0.10; ALPHA_LOW = 0.08; WF_AUTO_OFFSET = -4.0; WF_MIN_RANGE = 48.0; WF_MAX_RANGE = 62.0; + HIST_MAX_DB = 22.0; begin - if FWaterfallBitmap = nil then Exit; - W := FWaterfallBitmap.Width; H := FWaterfallBitmap.Height; - if (W <= 0) or (H <= 0) then Exit; - SrcCount := EnsureRange(FWaterfallBufCount, 2, 1024); - + if not FWfAGCEnabled then Exit; + SrcCount := EnsureRange(FWaterfallBufCount, 2, WF_MAX_PIXELS); FillChar(Hist, SizeOf(Hist), 0); - HistMinDB := -170.0; HistMaxDB := 22.0; - HistStepDB := (HistMaxDB - HistMinDB) / Length(Hist); + HistMinDB := -170.0; + HistStepDB := (HIST_MAX_DB - HistMinDB) / Length(Hist); for X := 0 to SrcCount - 1 do begin HistIdx := EnsureRange(Trunc((FWaterfallBuf[X] - HistMinDB) / HistStepDB), 0, High(Hist)); @@ -342,41 +420,62 @@ begin CumCount := 0; NoiseFloorDB := FWfLow; SignalTopDB := FWfHigh; for HistIdx := 0 to High(Hist) do begin - CumCount := CumCount + Hist[HistIdx]; + Inc(CumCount, Hist[HistIdx]); if CumCount >= LowTargetCount then begin NoiseFloorDB := HistMinDB + (HistIdx + 0.5) * HistStepDB; Break; end; end; CumCount := 0; for HistIdx := 0 to High(Hist) do begin - CumCount := CumCount + Hist[HistIdx]; + Inc(CumCount, Hist[HistIdx]); if CumCount >= HighTargetCount then begin SignalTopDB := HistMinDB + (HistIdx + 0.5) * HistStepDB; Break; end; end; - if FWfAGCEnabled then - begin - TargetLow := NoiseFloorDB + WF_AUTO_OFFSET + FWfAGCOffset; - if FWfNFEnabled then - TargetHigh := Max(TargetLow + WF_MIN_RANGE, SignalTopDB + 6.0) - else - TargetHigh := TargetLow + 52.0; - if TargetHigh > TargetLow + WF_MAX_RANGE then TargetHigh := TargetLow + WF_MAX_RANGE; - FWfLow := FWfLow + ALPHA_LOW * (TargetLow - FWfLow); - FWfHigh := FWfHigh + ALPHA_HIGH * (TargetHigh - FWfHigh); - end; - WfHigh := FWfHigh; WfLow := FWfLow; - if not FWfAGCEnabled then begin WfHigh := FWfManualHigh; WfLow := FWfManualLow; end; - if WfHigh < WfLow + 40.0 then WfHigh := WfLow + 40.0; - if WfHigh > 0.0 then WfHigh := 0.0; - if WfLow < -160 then WfLow := -160; + TargetLow := NoiseFloorDB + WF_AUTO_OFFSET + FWfAGCOffset; + if FWfNFEnabled then + TargetHigh := Max(TargetLow + WF_MIN_RANGE, SignalTopDB + 6.0) + else + TargetHigh := TargetLow + 52.0; + if TargetHigh > TargetLow + WF_MAX_RANGE then TargetHigh := TargetLow + WF_MAX_RANGE; + FWfLow := FWfLow + ALPHA_LOW * (TargetLow - FWfLow); + FWfHigh := FWfHigh + ALPHA_HIGH * (TargetHigh - FWfHigh); +end; + +procedure TWaterfallView.GetEffectiveLevels(out Lo, Hi: Double); +begin + if FWfAGCEnabled then begin Lo := FWfLow; Hi := FWfHigh; end + else begin Lo := FWfManualLow; Hi := FWfManualHigh; end; + if Hi < Lo + 40.0 then Hi := Lo + 40.0; + if Hi > 0.0 then Hi := 0.0; + if Lo < -160 then Lo := -160; +end; + +// ──────────────────────────────────────────────────────────────────────────── +// DrawWaterfall +// ──────────────────────────────────────────────────────────────────────────── + +procedure TWaterfallView.DrawWaterfall; +var + W, H, X: Integer; + dB, frac, WatSrcF: Double; + WatS0, WatS1: Integer; + WfHigh, WfLow, LutScale, Step: Double; + Row, Idx: Integer; RowPtr: PByte; + Pal: LongWord; SrcCount: Integer; +begin + if FWaterfallBitmap = nil then Exit; + W := FWaterfallBitmap.Width; H := FWaterfallBitmap.Height; + if (W <= 0) or (H <= 0) then Exit; + SrcCount := EnsureRange(FWaterfallBufCount, 2, WF_MAX_PIXELS); + + UpdateAutoLevels; + GetEffectiveLevels(WfLow, WfHigh); LutScale := High(FWfLut) / (WfHigh - WfLow); - // Палитра зависит только от WfLow/WfHigh/темы — перестраиваем лишь при - // ощутимом изменении (AGC сдвигает границы медленно). - if (FWfLutLight <> FLightTheme) or - (Abs(WfLow - FWfLutLow) > 0.05) or - (Abs(WfHigh - FWfLutHigh) > 0.05) then + // Палитра зависит только от WfLow/WfHigh/темы/палитры/гаммы — перестраиваем + // лишь при ощутимом изменении (AGC сдвигает границы медленно). + if LutNeedsRebuild(WfLow, WfHigh) then BuildWfLut(WfLow, WfHigh); if (FWfBitmapW <> W) or (FWfBitmapH <> H) then diff --git a/WaterfallViewOpengl.pas b/WaterfallViewOpengl.pas index 61db121..8f5537e 100644 --- a/WaterfallViewOpengl.pas +++ b/WaterfallViewOpengl.pas @@ -38,9 +38,7 @@ type procedure DrawTextureWrapped(W, H: Integer); procedure DrawMarker(W, H: Integer); procedure UploadMarkerText(const S: string); - procedure UpdateLevels; function FormatFreqGL(Hz: Double): string; - function ColorForDB(ValueDB, LowDB, HighDB: Double): LongWord; public constructor Create; destructor Destroy; override; @@ -102,125 +100,6 @@ begin Tex := 0; end; -function TWaterfallViewOpenGL.ColorForDB(ValueDB, LowDB, HighDB: Double): LongWord; -const - NSTOPS = 8; - SR: array[0..NSTOPS-1] of Integer = (224, 160, 20, 0, 0, 220, 255, 255); - SG: array[0..NSTOPS-1] of Integer = (230, 185, 80, 185, 200, 210, 90, 20); - SB: array[0..NSTOPS-1] of Integer = (232, 210, 200, 210, 80, 0, 0, 20); -var - Overall, Local, T: Double; - Seg, R, G, B: Integer; -begin - if FLightTheme then - begin - if ValueDB <= LowDB then begin Result := $FFE0E6E8; Exit; end; - if ValueDB >= HighDB then - begin - Result := ($FF shl 24) or (SR[NSTOPS-1] shl 16) or - (SG[NSTOPS-1] shl 8) or SB[NSTOPS-1]; - Exit; - end; - T := (ValueDB - LowDB) / Max(1E-9, HighDB - LowDB) * (NSTOPS - 1); - Seg := Min(NSTOPS - 2, Trunc(T)); - T := T - Seg; - R := EnsureRange(Round(SR[Seg] * (1.0 - T) + SR[Seg+1] * T), 0, 255); - G := EnsureRange(Round(SG[Seg] * (1.0 - T) + SG[Seg+1] * T), 0, 255); - B := EnsureRange(Round(SB[Seg] * (1.0 - T) + SB[Seg+1] * T), 0, 255); - Result := ($FF shl 24) or (R shl 16) or (G shl 8) or B; - Exit; - end; - - if ValueDB <= LowDB then begin Result := $FF000000; Exit; end; - if ValueDB >= HighDB then - begin - Result := ($FF shl 24) or (255 shl 16) or (124 shl 8) or 192; - Exit; - end; - Overall := (ValueDB - LowDB) / Max(1E-9, HighDB - LowDB); - if Overall < (2.0 / 9.0) then - begin Local := Overall / (2.0/9.0); R := 0; G := 0; B := Round(Local * 255.0); end - else if Overall < (3.0 / 9.0) then - begin Local := (Overall - 2.0/9.0) / (1.0/9.0); R := 0; G := Round(Local*255.0); B := 255; end - else if Overall < (4.0 / 9.0) then - begin Local := (Overall - 3.0/9.0) / (1.0/9.0); R := 0; G := 255; B := Round((1.0-Local)*255.0); end - else if Overall < (5.0 / 9.0) then - begin Local := (Overall - 4.0/9.0) / (1.0/9.0); R := Round(Local*255.0); G := 255; B := 0; end - else if Overall < (7.0 / 9.0) then - begin Local := (Overall - 5.0/9.0) / (2.0/9.0); R := 255; G := Round((1.0-Local)*255.0); B := 0; end - else if Overall < (8.0 / 9.0) then - begin Local := (Overall - 7.0/9.0) / (1.0/9.0); R := 255; G := 0; B := Round(Local*255.0); end - else - begin - Local := (Overall - 8.0/9.0) / (1.0/9.0); - R := Round((0.75 + 0.25 * (1.0 - Local)) * 255.0); - G := Round(Local * 255.0 * 0.5); - B := 255; - end; - Result := ($FF shl 24) or (EnsureRange(R, 0, 255) shl 16) or - (EnsureRange(G, 0, 255) shl 8) or EnsureRange(B, 0, 255); -end; - -procedure TWaterfallViewOpenGL.UpdateLevels; -var - X, SrcCount, LowTargetCount, HighTargetCount, CumCount, HistIdx: Integer; - Hist: array[0..191] of Integer; - HistMinDB, HistMaxDB, HistStepDB, NoiseFloorDB, SignalTopDB: Double; - TargetLow, TargetHigh: Double; -const - ALPHA_HIGH = 0.10; - ALPHA_LOW = 0.08; - WF_AUTO_OFFSET = -4.0; - WF_MIN_RANGE = 48.0; - WF_MAX_RANGE = 62.0; -begin - SrcCount := EnsureRange(FWaterfallBufCount, 2, 1024); - FillChar(Hist, SizeOf(Hist), 0); - HistMinDB := -170.0; - HistMaxDB := 22.0; - HistStepDB := (HistMaxDB - HistMinDB) / Length(Hist); - for X := 0 to SrcCount - 1 do - begin - HistIdx := EnsureRange(Trunc((FWaterfallBuf[X] - HistMinDB) / HistStepDB), 0, High(Hist)); - Inc(Hist[HistIdx]); - end; - - LowTargetCount := Round(SrcCount * 0.30); - HighTargetCount := Round(SrcCount * 0.98); - CumCount := 0; - NoiseFloorDB := FWfLow; - SignalTopDB := FWfHigh; - for HistIdx := 0 to High(Hist) do - begin - Inc(CumCount, Hist[HistIdx]); - if CumCount >= LowTargetCount then - begin - NoiseFloorDB := HistMinDB + (HistIdx + 0.5) * HistStepDB; - Break; - end; - end; - CumCount := 0; - for HistIdx := 0 to High(Hist) do - begin - Inc(CumCount, Hist[HistIdx]); - if CumCount >= HighTargetCount then - begin - SignalTopDB := HistMinDB + (HistIdx + 0.5) * HistStepDB; - Break; - end; - end; - - if FWfAGCEnabled then - begin - TargetLow := NoiseFloorDB + WF_AUTO_OFFSET + FWfAGCOffset; - if FWfNFEnabled then TargetHigh := Max(TargetLow + WF_MIN_RANGE, SignalTopDB + 6.0) - else TargetHigh := TargetLow + 52.0; - if TargetHigh > TargetLow + WF_MAX_RANGE then TargetHigh := TargetLow + WF_MAX_RANGE; - FWfLow := FWfLow + ALPHA_LOW * (TargetLow - FWfLow); - FWfHigh := FWfHigh + ALPHA_HIGH * (TargetHigh - FWfHigh); - end; -end; - procedure TWaterfallViewOpenGL.EnsureTexture(W, H: Integer); var Bg: LongWord; @@ -235,7 +114,7 @@ begin FLatestRow := 0; SetLength(FRowPixels, W * 4); SetLength(Pixels, W * H * 4); - Bg := ColorForDB(-130.0, FWfManualLow, FWfManualHigh); + Bg := PaletteColor(0.0); // «холодный» конец палитры = фон for I := 0 to W * H - 1 do begin Pixels[I * 4] := Byte(Bg shr 16); @@ -256,24 +135,18 @@ end; procedure TWaterfallViewOpenGL.UploadCurrentRow(W: Integer); var - X, SrcCount, S0, S1, Off: Integer; - SrcF, Step, Frac, DBV, WfHigh, WfLow: Double; + X, SrcCount, S0, S1, Off, Idx: Integer; + SrcF, Step, Frac, DBV, WfHigh, WfLow, LutScale: Double; Pal: LongWord; begin if (FTexture = 0) or (W <= 0) then Exit; - UpdateLevels; - WfHigh := FWfHigh; - WfLow := FWfLow; - if not FWfAGCEnabled then - begin - WfHigh := FWfManualHigh; - WfLow := FWfManualLow; - end; - if WfHigh < WfLow + 40.0 then WfHigh := WfLow + 40.0; - if WfHigh > 0.0 then WfHigh := 0.0; - if WfLow < -160 then WfLow := -160; + UpdateAutoLevels; + GetEffectiveLevels(WfLow, WfHigh); + LutScale := High(FWfLut) / (WfHigh - WfLow); + if LutNeedsRebuild(WfLow, WfHigh) then + BuildWfLut(WfLow, WfHigh); - SrcCount := EnsureRange(FWaterfallBufCount, 2, 1024); + SrcCount := EnsureRange(FWaterfallBufCount, 2, WF_MAX_PIXELS); Step := (SrcCount - 1.0) / Max(1, W - 1); SrcF := 0.0; for X := 0 to W - 1 do @@ -299,7 +172,9 @@ begin Frac := SrcF - S0; DBV := FWaterfallBuf[S0] * (1.0 - Frac) + FWaterfallBuf[S1] * Frac; end; - Pal := ColorForDB(DBV, WfLow, WfHigh); + Idx := Round((DBV - WfLow) * LutScale); + if Idx < 0 then Idx := 0 else if Idx > High(FWfLut) then Idx := High(FWfLut); + Pal := FWfLutARGB[Idx]; Off := X * 4; FRowPixels[Off] := Byte(Pal shr 16); FRowPixels[Off + 1] := Byte(Pal shr 8); @@ -447,8 +322,10 @@ end; procedure TWaterfallViewOpenGL.SetWaterfallBitmapSize(W, H: Integer); begin if (W <= 0) or (H <= 0) then Exit; + // Ширина текстуры — в физических пикселях (HiDPI): строка водопада должна + // ложиться 1:1 на пиксели фреймбуфера, иначе GL размажет её при увеличении. if Assigned(FGLControl) and FGLControl.MakeCurrent then - EnsureTexture(W, H); + EnsureTexture(Round(W * FGLControl.PixelScale), H); FWaterfallDirty := True; end; @@ -459,7 +336,8 @@ end; procedure TWaterfallViewOpenGL.PaintWaterfall(Sender: TObject); var - W, H: Integer; + W, H, TexW: Integer; + Scale: Double; R, G, B: GLFloat; begin if Sender is TOpenGLControl then FGLControl := TOpenGLControl(Sender); @@ -467,8 +345,12 @@ begin W := FGLControl.Width; H := FGLControl.Height; if (W <= 0) or (H <= 0) then Exit; + // HiDPI: viewport покрывает весь FBO (физические пиксели), координаты + // остаются логическими; строка водопада строится в физической ширине. + Scale := FGLControl.PixelScale; + TexW := Round(W * Scale); - glViewport(0, 0, W, H); + glViewport(0, 0, TexW, Round(H * Scale)); glMatrixMode(GL_PROJECTION); glLoadIdentity; glOrtho(0, W, H, 0, -1, 1); @@ -483,10 +365,10 @@ begin glClearColor(R, G, B, 1); glClear(GL_COLOR_BUFFER_BIT); - EnsureTexture(W, H); + EnsureTexture(TexW, H); if FWaterfallDirty then begin - UploadCurrentRow(W); + UploadCurrentRow(TexW); FWaterfallDirty := False; end; DrawTextureWrapped(W, H); diff --git a/WidebandView.pas b/WidebandView.pas index 00ab4a0..520a8e1 100644 --- a/WidebandView.pas +++ b/WidebandView.pas @@ -1077,7 +1077,8 @@ begin UploadLabelTextureToGL; FGLBackgroundDirty := False; end; - glViewport(0, 0, W, H); + // HiDPI: viewport в физических пикселях FBO, координаты — логические + glViewport(0, 0, Round(W * C.PixelScale), Round(H * C.PixelScale)); glMatrixMode(GL_PROJECTION); glLoadIdentity; glOrtho(0, W, 0, H, -1, 1);