unit WaterfallView; { WaterfallView.pas — рендеринг водопада. TWaterfallView — изолированный класс, не зависит от SpectrumView. } {$IFDEF FPC} {$MODE Delphi} {$ENDIF} interface uses Classes, SysUtils, Graphics, ExtCtrls, Controls, Math, IntfGraphics, FPImage, LCLIntf, LCLType, GraphType, AppTheme; type TWaterfallView = class private FWaterfallBitmap: TBitmap; FWfBitmap: TBitmap; FWfBitmapW: Integer; FWfBitmapH: Integer; FWfIntfImg: TLazIntfImage; FWfPixels: array of LongWord; FWaterfallBuf: array[0..1023] of Single; FWaterfallBufCount: Integer; FWaterfallDirty: Boolean; FWfFrameCounter: Integer; FWfFrameInterval: Integer; FWfAGCEnabled: Boolean; FWfNFEnabled: Boolean; FWfManualHigh: Double; FWfManualLow: Double; FWfAGCOffset: Double; FWfHigh: Double; FWfLow: Double; FCenterFreq: Double; FSpanHz: Double; FTXMode: Boolean; FTXFreq: Double; FTXSpanHz: Double; FMarkerActive: Boolean; FMarkerX: Integer; FTheme: TAppTheme; FLightTheme: Boolean; FPbWaterfall: TPaintBox; procedure DrawMarkerLine(C: TCanvas; W, H: Integer); public constructor Create; destructor Destroy; override; property CenterFreq: Double read FCenterFreq write FCenterFreq; property SpanHz: Double read FSpanHz write FSpanHz; property TXMode: Boolean read FTXMode write FTXMode; property TXFreq: Double read FTXFreq write FTXFreq; property TXSpanHz: Double read FTXSpanHz write FTXSpanHz; property WfAGCEnabled: Boolean read FWfAGCEnabled write FWfAGCEnabled; property WfNFEnabled: Boolean read FWfNFEnabled write FWfNFEnabled; property WfManualHigh: Double read FWfManualHigh write FWfManualHigh; property WfManualLow: Double read FWfManualLow write FWfManualLow; property WfAGCOffset: Double read FWfAGCOffset write FWfAGCOffset; property WaterfallDirty: Boolean read FWaterfallDirty write FWaterfallDirty; property WfFrameInterval: Integer read FWfFrameInterval write FWfFrameInterval; property MarkerActive: Boolean read FMarkerActive write FMarkerActive; property MarkerX: Integer read FMarkerX write FMarkerX; property PbWaterfall: TPaintBox write FPbWaterfall; procedure SetWaterfallData(const Pixels: array of Single; Count: Integer); procedure DrawWaterfall; procedure PaintWaterfall(Sender: TObject); procedure SetWaterfallBitmapSize(W, H: Integer); procedure ResetWfAvgBuf; procedure ResetWfBuf; procedure SetTheme(const T: TAppTheme); end; implementation // ════════════════════════════════════════════════════════════════════════════ // Вспомогательные функции // ════════════════════════════════════════════════════════════════════════════ function FormatFreqWF(Hz: Double): string; var Mhz: Int64; KHz, Rest: Integer; begin Mhz := Trunc(Hz / 1000000); KHz := Trunc((Hz - Mhz * 1000000) / 1000); Rest := Trunc(Hz) mod 1000; Result := Format('%d.%3.3d.%3.3d', [Mhz, KHz, Rest]); end; function WaterfallEnhancedColorThetis(ValueDB, LowDB, HighDB: Double): LongWord; var Overall, 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 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; R := EnsureRange(R, 0, 255); G := EnsureRange(G, 0, 255); B := EnsureRange(B, 0, 255); 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); var T: Double; Seg: Integer; 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); Result := ($FF shl 24) or (R shl 16) or (G shl 8) or B; end; procedure InitRawDesc32WF(var Desc: TRawImageDescription; AWidth, AHeight: Integer); begin FillChar(Desc, SizeOf(Desc), 0); Desc.Format := ricfRGBA; Desc.Width := AWidth; Desc.Height := AHeight; Desc.Depth := 32; Desc.BitOrder := riboBitsInOrder; Desc.ByteOrder := riboLSBFirst; Desc.LineOrder := riloTopToBottom; Desc.BitsPerPixel := 32; Desc.LineEnd := rileDWordBoundary; Desc.BlueShift := 0; Desc.BluePrec := 8; Desc.GreenShift := 8; Desc.GreenPrec := 8; Desc.RedShift := 16; Desc.RedPrec := 8; Desc.AlphaShift := 24; Desc.AlphaPrec := 8; end; // ════════════════════════════════════════════════════════════════════════════ // TWaterfallView // ════════════════════════════════════════════════════════════════════════════ constructor TWaterfallView.Create; begin inherited Create; FWaterfallBitmap := TBitmap.Create; FWfBitmap := TBitmap.Create; FWfIntfImg := nil; FWfBitmapW := 0; FWfBitmapH := 0; FWfManualHigh := -80.0; FWfManualLow := -130.0; FWfAGCOffset := 0.0; FWfHigh := -80.0; FWfLow := -130.0; FWfAGCEnabled := True; FWfNFEnabled := False; FWaterfallBufCount := 1024; FWfFrameInterval := 2; FWfFrameCounter := 0; FWaterfallDirty := True; FTheme := DarkTheme; FLightTheme := False; FSpanHz := 192000; ResetWfBuf; end; destructor TWaterfallView.Destroy; begin FWaterfallBitmap.Free; FWfBitmap.Free; FreeAndNil(FWfIntfImg); inherited; end; procedure TWaterfallView.SetTheme(const T: TAppTheme); begin FTheme := T; FLightTheme := T.BG > TColor($00808080); FWaterfallDirty := True; // Сброс размера — чтобы следующий кадр перезаполнил фон новой темой FWfBitmapW := 0; FWfBitmapH := 0; SetLength(FWfPixels, 0); FreeAndNil(FWfIntfImg); end; procedure TWaterfallView.ResetWfAvgBuf; begin FWfHigh := FWfManualHigh; FWfLow := FWfManualLow; FWaterfallDirty := True; end; procedure TWaterfallView.ResetWfBuf; var i: Integer; begin for i := 0 to 1023 do FWaterfallBuf[i] := -130.0; FWfHigh := FWfManualHigh; FWfLow := FWfManualLow; FWaterfallDirty := True; end; procedure TWaterfallView.SetWaterfallData(const Pixels: array of Single; Count: Integer); var i, N: Integer; begin N := Min(Count, 1024); for i := 0 to N - 1 do FWaterfallBuf[i] := Pixels[i]; FWaterfallBufCount := N; Inc(FWfFrameCounter); if FWfFrameCounter >= Max(1, FWfFrameInterval) then begin FWfFrameCounter := 0; FWaterfallDirty := True; end; end; procedure TWaterfallView.SetWaterfallBitmapSize(W, H: Integer); begin FWaterfallBitmap.SetSize(W, H); FWfBitmapW := 0; FWfBitmapH := 0; SetLength(FWfPixels, 0); FreeAndNil(FWfIntfImg); end; procedure TWaterfallView.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 := FormatFreqWF(Round(MarkerFreq)); C.Pen.Color := TColor($004444FF); 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; // ──────────────────────────────────────────────────────────────────────────── // DrawWaterfall // ──────────────────────────────────────────────────────────────────────────── procedure TWaterfallView.DrawWaterfall; var W, H, X: Integer; dB, frac, WatSrcF: Double; WatS0, WatS1: Integer; TargetLow, TargetHigh: Double; HistMinDB, HistMaxDB, HistStepDB: Double; NoiseFloorDB, SignalTopDB: Double; CumCount, LowTargetCount, HighTargetCount, HistIdx: Integer; WfHigh, WfLow, InvRange, Step: Double; Px: PLongWord; Desc: TRawImageDescription; Row, Col: Integer; RowPtr: PByte; Src: PLongWord; BPPi: Integer; 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; 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); 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 CumCount := 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]; 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; InvRange := 255.0 / (WfHigh - WfLow); if (FWfBitmapW <> W) or (FWfBitmapH <> H) then begin FWfBitmapW := W; FWfBitmapH := H; SetLength(FWfPixels, W * H); Pal := $FF000000 or ((LongWord(FTheme.Panel) and $FF) shl 16) or (LongWord(FTheme.Panel) and $FF00) or ((LongWord(FTheme.Panel) shr 16) and $FF); FillDWord(FWfPixels[0], W * H, Pal); FWfBitmap.SetSize(W, H); FreeAndNil(FWfIntfImg); end; if H > 1 then Move(FWfPixels[0], FWfPixels[W], (H - 1) * SizeOf(LongWord) * W); Step := (SrcCount - 1.0) / Max(1, W - 1); WatSrcF := 0.0; Px := @FWfPixels[0]; for X := 0 to W - 1 do begin if FTXMode and (FTXSpanHz > 0) and (FSpanHz > 0) then begin dB := FCenterFreq - FSpanHz * 0.5 + X * FSpanHz / Max(1, W - 1) - FTXFreq; if (dB < -FTXSpanHz * 0.5) or (dB > FTXSpanHz * 0.5) then begin if H > 1 then Px^ := FWfPixels[X + W] else Px^ := 0; Inc(Px); WatSrcF := WatSrcF + Step; Continue; end; frac := (dB + FTXSpanHz * 0.5) / FTXSpanHz * (SrcCount - 1); WatS0 := Trunc(frac); if WatS0 > SrcCount - 2 then WatS0 := SrcCount - 2; WatS1 := WatS0 + 1; frac := frac - WatS0; dB := FWaterfallBuf[WatS0] * (1.0 - frac) + FWaterfallBuf[WatS1] * frac; end else begin WatS0 := Trunc(WatSrcF); if WatS0 > SrcCount - 2 then WatS0 := SrcCount - 2; WatS1 := WatS0 + 1; frac := WatSrcF - WatS0; dB := FWaterfallBuf[WatS0] * (1.0 - frac) + FWaterfallBuf[WatS1] * frac; end; if FLightTheme then Pal := WaterfallLightTheme(dB, WfLow, WfHigh) else Pal := WaterfallEnhancedColorThetis(dB, WfLow, WfHigh); Px^ := Pal; Inc(Px); WatSrcF := WatSrcF + Step; end; if FWfIntfImg = nil then begin FWfIntfImg := TLazIntfImage.Create(W, H); InitRawDesc32WF(Desc, W, H); FWfIntfImg.DataDescription := Desc; FWfIntfImg.CreateData; end; BPPi := FWfIntfImg.DataDescription.BitsPerPixel div 8; if (BPPi = 4) and (FWfIntfImg.PixelData <> nil) then Move(FWfPixels[0], FWfIntfImg.PixelData^, W * H * 4) else begin Src := @FWfPixels[0]; for Row := 0 to H - 1 do begin RowPtr := FWfIntfImg.GetDataLineStart(Row); if RowPtr = nil then begin Inc(Src, W); Continue; end; for Col := 0 to W - 1 do begin RowPtr[0] := Byte(Src^); RowPtr[1] := Byte(Src^ shr 8); RowPtr[2] := Byte(Src^ shr 16); if BPPi >= 4 then RowPtr[3] := $FF; Inc(Src); Inc(RowPtr, BPPi); end; end; end; FWfBitmap.LoadFromIntfImage(FWfIntfImg); end; // ──────────────────────────────────────────────────────────────────────────── // PaintWaterfall // ──────────────────────────────────────────────────────────────────────────── procedure TWaterfallView.PaintWaterfall(Sender: TObject); var PB: TPaintBox; W, H: Integer; begin PB := TPaintBox(Sender); W := PB.Width; H := PB.Height; if (W <= 0) or (H <= 0) then Exit; if (FWfBitmap <> nil) and (FWfBitmap.Width = W) and (FWfBitmap.Height = H) then PB.Canvas.Draw(0, 0, FWfBitmap) else begin PB.Canvas.Brush.Color := FTheme.Panel; PB.Canvas.FillRect(Rect(0, 0, W, H)); end; if FMarkerActive then DrawMarkerLine(PB.Canvas, W, H); end; end.