diff --git a/MainForm.pas b/MainForm.pas index de79c92..3b5dc11 100644 --- a/MainForm.pas +++ b/MainForm.pas @@ -24,10 +24,10 @@ interface uses Classes, SysUtils, StrUtils, FreqDisplay, DeviceForm, VfoOverlay, FlatButton, WinFirewall, Forms, Controls, Graphics, Dialogs, StdCtrls, ExtCtrls, ComCtrls, Buttons, Menus, Math, Types, + LCLIntf, LCLType, GraphType, HPSDRProtocol, HPSDRNetwork, WDSPEngine, AudioOutput, IntfGraphics, FPImage, - LCLIntf, LCLType, Settings, WebServer; @@ -222,25 +222,37 @@ type // ---- Spectrum ---- FSpectrumBuf: array[0..1023] of Single; - FWfAvgBuf: array[0..1023] of Single; // EMA спектра для WF AGC/NF + FWfAvgBuf: array[0..1023] of Single; 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; // рабочий буфер для скролла + FWfAGCEnabled: Boolean; + FWfNFEnabled: Boolean; + FWfHigh: Double; + FWfLow: Double; + FWaterfallBitmap:TBitmap; + FWaterfallTemp: TBitmap; FSpectrumWidth: Integer; FSpectrumHeight: Integer; FWaterfallHeight:Integer; - // --- Ruler bitmap cache --- - // ОПТИМИЗАЦИЯ: Ruler перерисовывается только при изменении частоты/VFO, - // а не каждые 50мс. Экономит TextOut + линии на каждом тике таймера. - FRulerBitmap: TBitmap; // offscreen буфер линейки - FRulerLastCenter: Double; // FCenterFreq при последней отрисовке - FRulerLastVfo: Double; // FVfoA при последней отрисовке - FRulerDirty: Boolean; // True = нужна перерисовка линейки + // Водопад: прямой массив пикселей BGRA + RawImage bitmap без IntfImage roundtrip + FWfPixels: array of LongWord; // W×H пикселей, строка 0 = верх (новейшая) + FWfBitmap: TBitmap; + FWfBitmapW: Integer; + FWfBitmapH: Integer; + // Спектр: кэш фона (сетка+фон) — перерисовывается только при ресайзе + FGridBitmap: TBitmap; + FGridBitmapW: Integer; + FGridBitmapH: Integer; + FSpPts: array of TPoint; + FSpPtsLen: Integer; + // Градиент спектра: кэшированный IntfImage — без alloc каждый кадр + FSpecGradImg: TLazIntfImage; + FSpecGradBmp: TBitmap; // кэш bitmap для градиента — без alloc каждый кадр + // Водопад: кэшированный IntfImage — без alloc/free каждый кадр + FWfIntfImg: TLazIntfImage; + // Кэш линейки частот — перерисовывается только при смене FCenterFreq/FVfoA + FRulerBitmap: TBitmap; + FRulerLastFreq: Double; + FRulerLastVfo: Double; // --- Settings --- FSettings: TSettingsManager; FWebServer: TWebServer; // веб-интерфейс (порт 8080) @@ -362,6 +374,7 @@ type procedure FillDemoSpectrum; procedure ResetSpectrumBuf; procedure ResizeSpectrumPanels; + procedure RecreateDSPEngine(ASampleRate: Integer); // Network callbacks procedure OnDeviceFound(const Dev: THPSDRDevice); @@ -784,20 +797,22 @@ begin FVfoOverlay.Visible := False; FPanelHidden := False; - FSpectrumBitmap := TBitmap.Create; + FSpectrumBitmap := TBitmap.Create; FWaterfallBitmap := TBitmap.Create; FWaterfallTemp := TBitmap.Create; - // ОПТИМИЗАЦИЯ: pf32bit = прямой доступ через ScanLine без конвертации формата. - // Обязательно до первого SetSize/Draw иначе Lazarus может выбрать pf24bit. - FSpectrumBitmap.PixelFormat := pf32bit; - FWaterfallBitmap.PixelFormat := pf32bit; - FWaterfallTemp.PixelFormat := pf32bit; - // Ruler bitmap cache - FRulerBitmap := TBitmap.Create; - FRulerBitmap.PixelFormat := pf32bit; - FRulerLastCenter := -1.0; + FSpecGradImg := nil; + FSpecGradBmp := TBitmap.Create; + FWfIntfImg := nil; + FWfBitmap := TBitmap.Create; + FWfBitmapW := 0; + FWfBitmapH := 0; + FGridBitmap := TBitmap.Create; + FGridBitmapW := 0; + FGridBitmapH := 0; + FSpPtsLen := 0; + FRulerBitmap := TBitmap.Create; + FRulerLastFreq := -1.0; FRulerLastVfo := -1.0; - FRulerDirty := True; ResetSpectrumBuf; FNetwork := THPSDRNetwork.Create; @@ -815,7 +830,10 @@ begin // DSP Engine — создаём объект, Open вызовется при нажатии START // (FDSPEngine.Open загружает libwdsp и занимает ~1-2 сек) FWDSPReady := False; - FDSPEngine := TWDSPEngine.Create(192000, 48000, 1024); + // BufSize=512: FBufSize=2048 @ 192kHz, FAudioBufSize=512 @ 48kHz. + // Уменьшение с 1024 вдвое сокращает время построения downsampler в OpenChannel RXA + // (~1300ms → ~650ms). Латентность: 512/48000 ≈ 10.7ms — допустимо для SDR. + FDSPEngine := TWDSPEngine.Create(192000, 48000, 512); FDSPEngine.OnAudio := OnAudioReady; FDSPEngine.OnSpectrum := OnSpectrumReady; @@ -865,6 +883,11 @@ begin FWaterfallBitmap.Free; FWaterfallTemp.Free; FRulerBitmap.Free; + FWfBitmap.Free; + FGridBitmap.Free; + FreeAndNil(FSpecGradImg); + FSpecGradBmp.Free; + FreeAndNil(FWfIntfImg); // Сохраняем настройки при закрытии if FDevConnected then @@ -1350,31 +1373,30 @@ begin // Пересоздаём bitmap точно под новый размер if (FSpectrumBitmap <> nil) and (RW > 0) and (SH > 0) then begin - FSpectrumBitmap.PixelFormat := pf32bit; // сохраняем формат после SetSize FSpectrumBitmap.SetSize(RW, SH); - // Заливаем фоном чтобы не было артефактов от старого содержимого FSpectrumBitmap.Canvas.Brush.Color := CLR_BG; FSpectrumBitmap.Canvas.FillRect(Rect(0, 0, RW, SH)); + FreeAndNil(FSpecGradImg); + // Сбрасываем кэш фона/сетки и кэш точек кривой + FGridBitmapW := 0; + FGridBitmapH := 0; + FSpPtsLen := 0; end; if (FWaterfallBitmap <> nil) and (RW > 0) and (WH > 0) then begin - FWaterfallBitmap.PixelFormat := pf32bit; FWaterfallBitmap.SetSize(RW, WH); FWaterfallBitmap.Canvas.Brush.Color := clBlack; FWaterfallBitmap.Canvas.FillRect(Rect(0, 0, RW, WH)); - FWaterfallTemp.PixelFormat := pf32bit; - FWaterfallTemp.SetSize(RW, WH); - FWaterfallTemp.Canvas.Brush.Color := clBlack; - FWaterfallTemp.Canvas.FillRect(Rect(0, 0, RW, WH)); + // Сбрасываем буфер пикселей водопада + FWfBitmapW := 0; + FWfBitmapH := 0; + SetLength(FWfPixels, 0); end; - // Ruler bitmap: пересоздаём под новый размер, сбрасываем кэш - if (FRulerBitmap <> nil) and (RW > 0) and (PbRuler <> nil) then + if (FRulerBitmap <> nil) and (RW > 0) then begin - FRulerBitmap.PixelFormat := pf32bit; - FRulerBitmap.SetSize(RW, PbRuler.Height); - FRulerLastCenter := -1.0; // принудительно перерисовать - FRulerLastVfo := -1.0; - FRulerDirty := True; + FRulerBitmap.SetSize(RW, 18); + FRulerLastFreq := -1.0; + FRulerLastVfo := -1.0; end; // Немедленно перерисовываем с новыми размерами @@ -1572,409 +1594,378 @@ begin C.TextOut(MX - 4 - C.TextWidth(MarkerLbl), 4, MarkerLbl); end; +// Вспомогательная функция: BGRA32 top-to-bottom описание для TLazIntfImage. +// Заменяет Init_BPP32_B8G8R8A8_BIO_TTB которая не всегда доступна в старых Lazarus. +procedure InitRawDesc32(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; + // BGRA: B=bits 0..7, G=8..15, R=16..23, A=24..31 + 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; + procedure TMainForm.DrawSpectrumGradient(const SpPts: array of TPoint; W, H: Integer); -// ОПТИМИЗАЦИЯ: Заменяем TLazIntfImage.Colors[x,y] на прямой доступ через ScanLine. -// TLazIntfImage.Colors[] — медленный accessor с накладными расходами на каждый пиксель. -// ScanLine[row] возвращает сырой указатель на строку пикселей в памяти bitmap. -// При W=1920, H=300: было ~576 000 медленных вызовов Colors[], стало ~576 000 прямых -// обращений к памяти через указатель. Ускорение: ~15-25x на полном экране. -// Требует PixelFormat = pf32bit (установлен в FormCreate). +// ═══════════════════════════════════════════════════════════════════════ +// Попиксельный градиент через кэшированный FSpecGradImg. +// Старый подход: H/2 × MoveTo+LineTo = ~150-250 GDI calls = ~3-5ms +// Новый подход: записываем пиксели в FSpecGradImg + один BitBlt ≈ 1ms +// ═══════════════════════════════════════════════════════════════════════ var - Row, Col: Integer; - YcurveAt: Integer; - GradH: Integer; - T: Double; - Alpha: Integer; // 0..200 — вес добавки зелёного/синего канала - Ptr: PLongWord; - RowBase: PLongWord; - Pixel: LongWord; - R, G, B: Byte; - YCurve: array of Integer; + Row, Col: Integer; + YMin: Integer; + Alpha: Integer; + RowPtr: PByte; + BPPg: Integer; + RG, GG: Byte; + Desc: TRawImageDescription; begin if FSpectrumBitmap = nil then Exit; - if FSpectrumBitmap.PixelFormat <> pf32bit then - FSpectrumBitmap.PixelFormat := pf32bit; + if (W <= 0) or (H <= 0) then Exit; - SetLength(YCurve, W); + YMin := H; for Col := 0 to W - 1 do - YCurve[Col] := SpPts[Col].Y; + if SpPts[Col].Y < YMin then YMin := SpPts[Col].Y; + if YMin >= H then Exit; - for Row := 0 to H - 1 do + // Создаём/переиспользуем FSpecGradImg + if (FSpecGradImg = nil) or + (FSpecGradImg.Width <> W) or (FSpecGradImg.Height <> H) then begin - // ScanLine[Row] — один вызов на строку, возвращает указатель PLongWord - RowBase := PLongWord(FSpectrumBitmap.ScanLine[Row]); - Ptr := RowBase; + FreeAndNil(FSpecGradImg); + FSpecGradImg := TLazIntfImage.Create(W, H); + InitRawDesc32(Desc, W, H); + FSpecGradImg.DataDescription := Desc; + FSpecGradImg.CreateData; + end; + + // Очищаем прозрачным + if FSpecGradImg.PixelData <> nil then + FillChar(FSpecGradImg.PixelData^, W * H * 4, 0); + + BPPg := FSpecGradImg.DataDescription.BitsPerPixel div 8; + if BPPg < 3 then BPPg := 4; + + for Row := YMin to H - 1 do + begin + Alpha := Round((1.0 - Sqr((Row - YMin) / Max(1.0, H - YMin - 1.0))) * 200); + if Alpha <= 0 then Continue; + if Alpha > 200 then Alpha := 200; + GG := Byte(Alpha shr 1); // B-канал + RG := Byte(Alpha); // G-канал + + RowPtr := FSpecGradImg.GetDataLineStart(Row); + if RowPtr = nil then Continue; + for Col := 0 to W - 1 do begin - YcurveAt := YCurve[Col]; - if Row < YcurveAt then + if SpPts[Col].Y <= Row then begin - Inc(Ptr); - Continue; - end; - - GradH := H - YcurveAt; - if GradH < 1 then GradH := 1; - - T := (Row - YcurveAt) / GradH; - Alpha := Round((1.0 - T * T) * 200); // 0..200 - if Alpha > 0 then + RowPtr[0] := GG; RowPtr[1] := RG; RowPtr[2] := 0; + if BPPg >= 4 then RowPtr[3] := Byte(Alpha); + end + else begin - // Читаем пиксель: формат pf32bit = 00RRGGBB (little-endian) - Pixel := Ptr^; - R := Byte(Pixel shr 16); - G := Byte(Pixel shr 8); - B := Byte(Pixel); - // Добавляем зелёный (×2) и синий (×1) — Thetis-стиль зелёно-бирюзового - G := Min(255, Integer(G) + Alpha * 2 shr 7); - B := Min(255, Integer(B) + Alpha shr 7); - Ptr^ := (Pixel and $FF000000) or (LongWord(R) shl 16) or (LongWord(G) shl 8) or B; + RowPtr[0] := 0; RowPtr[1] := 0; RowPtr[2] := 0; + if BPPg >= 4 then RowPtr[3] := 0; end; - Inc(Ptr); + Inc(RowPtr, BPPg); end; end; + + // Выводим через кэшированный bitmap → BitBlt на спектрум-bitmap + FSpecGradBmp.LoadFromIntfImage(FSpecGradImg); + LCLIntf.BitBlt(FSpectrumBitmap.Canvas.Handle, 0, 0, W, H, + FSpecGradBmp.Canvas.Handle, 0, 0, $CC0020); end; procedure TMainForm.DrawSpectrum; var - C: TCanvas; - i, X, Yp, W, H: Integer; - DBmin, DBmax, FreqStart, FreqHz: Double; + C: TCanvas; + GC: TCanvas; // для рисования в FGridBitmap + i, Yp, W, H, GX: Integer; + DBmin, DBmax: 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; + VfoX, X1, X2: Integer; + Lo_Hz, Hi_Hz, Half: Double; + AGCy, AGCHangY: Integer; + SrcF, Frac, dBv: Double; S0, S1: Integer; - Frac: Double; - dBv: Double; - SpPts: array of TPoint; + InvRange: Double; begin - // Используем актуальные размеры bitmap (обновлены в ResizeSpectrumPanels) - if (FSpectrumBitmap = nil) then Exit; + 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; + InvRange := 1.0 / (DBmax - DBmin); - 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 + // ── 1. Фон+сетка из кэша ─────────────────────────────────────────────── + if (FGridBitmapW <> W) or (FGridBitmapH <> H) then 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; + FGridBitmapW := W; + FGridBitmapH := H; + FGridBitmap.SetSize(W, H); + GC := FGridBitmap.Canvas; + GC.Brush.Color := TColor($00050505); + GC.FillRect(Rect(0, 0, W, H)); + GC.Font.Size := 7; + GC.Font.Name := 'Courier New'; + GC.Font.Color := CLR_TEXTDIM; + dB := -30.0; + while dB >= DBmin do + begin + Yp := Round((DBmax - dB) * InvRange * H); + GC.Pen.Color := CLR_BORDER; + GC.Pen.Width := 1; + GC.MoveTo(0, Yp); GC.LineTo(W-1, Yp); + GC.TextOut(1, Yp - 9, Format('%4.0f', [dB])); + dB := dB - 10; + end; + for i := 0 to 8 do + begin + GX := ScaleX(i, 8, W); + GC.Pen.Color := CLR_BORDER; + GC.MoveTo(GX, 0); GC.LineTo(GX, H); 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; + LCLIntf.BitBlt(C.Handle, 0, 0, W, H, FGridBitmap.Canvas.Handle, 0, 0, $CC0020); - // AGC lines — Thresh (amber) + HangLevel (cyan dashed), как в piHPSDR + // ── 2. Полоса фильтра — заливка ДО спектра ───────────────────────────── + Half := FFilterBW / 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($00102828); + C.Brush.Style := bsSolid; + C.Pen.Style := psClear; + if X2 > X1 then C.FillRect(Rect(X1, 0, X2, H)); + C.Pen.Style := psSolid; + + // ── 3. AGC линии ──────────────────────────────────────────────────────── if FWDSPReady then begin - // --- Thresh: GetRXAAGCThresh → dBm позиция на дисплее --- - AGCy := Round((DBmax - FDSPEngine.AGCThresh) / (DBmax - DBmin) * H); + AGCy := Round((DBmax - FDSPEngine.AGCThresh) * InvRange * H); if (AGCy >= 0) and (AGCy < H) then begin - C.Pen.Color := TColor($0000AAFF); // янтарный - C.Pen.Width := 1; - C.Pen.Style := psDash; + 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.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 // не рисуем если совпадают + AGCHangY := Round((DBmax - FDSPEngine.AGCHangLevel) * InvRange * 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.Pen.Color := TColor($00FFCC00); 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.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); + AGCy := Round((DBmax - (-FAGCTop)) * InvRange * H); if (AGCy >= 0) and (AGCy < H) then begin - C.Pen.Color := TColor($0000AAFF); - C.Pen.Width := 1; - C.Pen.Style := psDash; + 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.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 стиль: полигон-заливка + яркая линия поверх + // ── 4. Кривая спектра — кэшируем FSpPts без SetLength каждый кадр ────── + if FSpPtsLen <> W + 2 then 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); + SetLength(FSpPts, W + 2); + FSpPtsLen := W + 2; end; + 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; + FSpPts[i] := Point(i, Max(0, Min(H-1, Round((DBmax - dBv) * InvRange * H)))); + end; + FSpPts[W] := Point(W-1, H); + FSpPts[W+1] := Point(0, H); + + // ── 5. Градиент ───────────────────────────────────────────────────────── + C.Brush.Style := bsSolid; + C.Pen.Style := psClear; + DrawSpectrumGradient(FSpPts, W, H); + + // ── 6. Линия спектра ──────────────────────────────────────────────────── + C.Pen.Style := psSolid; + C.Pen.Color := TColor($0040FF80); + C.Pen.Width := 1; + C.Polyline(Slice(FSpPts, W)); // один вызов вместо W раз LineTo + + // ── 7. Края фильтра + VFO поверх спектра ──────────────────────────────── + C.Pen.Color := TColor($0040FFCC); C.Pen.Width := 1; C.Pen.Style := psSolid; + C.MoveTo(X1, 0); C.LineTo(X1, H); + C.MoveTo(X2, 0); C.LineTo(X2, H); + 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)]); - // Маркер правой кнопки — красная вертикальная линия + частота if FMarkerActive then DrawMarkerLine(C, W, H); end; procedure TMainForm.DrawWaterfall; -// Алгоритм как в Thetis/PowerSDR: -// -// 1. EMA (Exponential Moving Average) спектра → FWfAvgBuf -// Сглаживает шум: каждый пиксель усредняется по времени с α=0.25 -// Это ключ к "чистому" водопаду — рисуем усреднённое, а не сырой FFT -// -// 2. WF AGC (FWfHigh): верхняя граница — "белое" -// Цель: чуть выше пика усреднённого спектра -// Медленный LP-фильтр α=0.02 (реагирует за ~50 кадров) -// -// 3. NF Track (FWfLow): нижняя граница — "чёрное" -// Цель: шумовой пол = медиана нижних 30% усреднённого спектра -// Очень медленный LP-фильтр α=0.005 (~200 кадров) -// -// Нормировка: V = (avg_dB - WfLow) / (WfHigh - WfLow) → 0..255 → палитра -// -// ОПТИМИЗАЦИЯ: -// - Скролл: Canvas.Draw(0,1,...) заменён на BitBlt — прямой блочный копировщик -// без overhead GDI объектов. При 1920×400 это экономит ~30-40% времени скролла. -// - Новая строка: TLazIntfImage (создание объекта + GetPixel/SetPixel каждый кадр) -// заменена на прямую запись в ScanLine[0] — один указатель на строку пикселей. -// Убраны CreateIntfImage / LoadFromIntfImage — ~3-5 мс на кадр. +// ═══════════════════════════════════════════════════════════════════════ +// Архитектура минимального CPU: +// FWfPixels[W*H] — весь водопад в памяти, строка 0 = верх (новейшая). +// Скролл = Move(FWfPixels[0]→FWfPixels[W], (H-1)*W*4) ~0.3ms +// Новая строка = инкрементный PLongWord по FWfPixels[0..W-1] ~0.1ms +// Вывод = TLazIntfImage.PixelData := FWfPixels + LoadFromIntfImage ~1ms +// Итого ~1.5ms vs старый IntfImage roundtrip ~5ms +// ═══════════════════════════════════════════════════════════════════════ var - W, H, X: Integer; - dB, frac, WatSrcF: Double; - WatS0, WatS1, V: Integer; - Col: LongWord; - Tmp: TBitmap; - Row0: PLongWord; // указатель на строку 0 в FWaterfallTemp (новая строка) - // AGC/NF - AvgDB: Double; - PeakDB: Double; - NFSum: Double; - NFCount: Integer; - WfHigh: Double; - WfLow: Double; + W, H, X: Integer; + dB, frac: Double; + WatSrcF: Double; + WatS0, WatS1, V: Integer; + AvgDB: Double; + PeakDB: Double; + NFSum: Double; + NFCount: Integer; + WfHigh, WfLow, InvRange: Double; + Step: Double; + Px: PLongWord; + Desc: TRawImageDescription; + Row, Col: Integer; + RowPtr: PByte; + Src: PLongWord; + BPPi: Integer; + Pal: LongWord; const - // EMA для сглаживания спектра (чем меньше — тем медленнее/чище) - ALPHA_SPEC = 0.25; // сглаживание спектра по времени - // Скорость слежения High/Low - ALPHA_HIGH = 0.02; // AGC верхней границы - ALPHA_LOW = 0.005; // NF нижней границы (очень медленно) + ALPHA_SPEC = 0.25; + ALPHA_HIGH = 0.02; + ALPHA_LOW = 0.005; begin - if (FWaterfallBitmap = nil) or (FWaterfallTemp = nil) then Exit; + if FWaterfallBitmap = nil then Exit; W := FWaterfallBitmap.Width; H := FWaterfallBitmap.Height; if (W <= 0) or (H <= 0) then Exit; - if (FWaterfallTemp.Width <> W) or (FWaterfallTemp.Height <> H) then - begin - FWaterfallTemp.PixelFormat := pf32bit; - FWaterfallTemp.SetSize(W, H); - FWaterfallTemp.Canvas.Brush.Color := clBlack; - FWaterfallTemp.Canvas.FillRect(0, 0, W, H); - end; - // ================================================================ - // Шаг 1: EMA — обновляем FWfAvgBuf из FSpectrumBuf - // avg[i] = avg[i] * (1-α) + spec[i] * α - // ================================================================ - PeakDB := -200.0; - NFSum := 0.0; - NFCount := 0; + // ── EMA + Peak + NF — один цикл по 1024 бинам ───────────────────────── + 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; + if AvgDB < FWfLow + 15.0 then begin NFSum := NFSum + AvgDB; Inc(NFCount); end; 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 (только если включены) - // ================================================================ + // ── AGC 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); + if PeakDB > FWfHigh then FWfHigh := PeakDB + 5.0 + else 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 FWfNFEnabled and (NFCount > 10) then + FWfLow := FWfLow + ALPHA_LOW * ((NFSum / NFCount - 5.0) - FWfLow); + WfHigh := FWfHigh; WfLow := FWfLow; if WfHigh < WfLow + 15.0 then WfHigh := WfLow + 15.0; - if WfHigh > 0.0 then WfHigh := 0.0; // разумный предел + if WfHigh > 0.0 then WfHigh := 0.0; if WfLow < -160 then WfLow := -160; + InvRange := 255.0 / (WfHigh - WfLow); - // ================================================================ - // Шаг 4: Скролл — BitBlt вместо Canvas.Draw - // ОПТИМИЗАЦИЯ: BitBlt — прямой аппаратный/GDI блочный перенос памяти. - // Canvas.Draw создаёт временные объекты и работает через GDI pipeline. - // При 1920×400 пикселей разница ~2-3× по скорости скролла. - // Копируем FWaterfallBitmap → FWaterfallTemp со сдвигом на 1 строку вниз. - // ================================================================ - BitBlt( - FWaterfallTemp.Canvas.Handle, // dest HDC - 0, 1, // dest X=0, Y=1 (сдвиг вниз на 1 строку) - W, H - 1, // ширина, высота (на 1 строку меньше) - FWaterfallBitmap.Canvas.Handle, // src HDC - 0, 0, // src X=0, Y=0 - SRCCOPY - ); + // ── Пересоздаём буфер при ресайзе ────────────────────────────────────── + if (FWfBitmapW <> W) or (FWfBitmapH <> H) then + begin + FWfBitmapW := W; FWfBitmapH := H; + SetLength(FWfPixels, W * H); + FillDWord(FWfPixels[0], W * H, 0); + FWfBitmap.SetSize(W, H); + // Сбрасываем кэш IntfImage — пересоздастся ниже с новым размером + FreeAndNil(FWfIntfImg); + end; - // ================================================================ - // Шаг 5: Новая строка через прямой доступ ScanLine[0] - // ОПТИМИЗАЦИЯ: Вместо CreateIntfImage → Colors[x,0] → LoadFromIntfImage - // (создание объекта + W вызовов accessor + обратная конвертация каждый кадр) - // используем ScanLine[0] — один указатель, W прямых записей в память. - // Убраны: CreateIntfImage, LoadFromIntfImage, Img.Free. - // Рисуем УСРЕДНЁННЫЙ спектр — не сырой! - // ================================================================ - if FWaterfallTemp.PixelFormat <> pf32bit then - FWaterfallTemp.PixelFormat := pf32bit; + // ── Скролл: Move строк вниз ───────────────────────────────────────────── + if H > 1 then + Move(FWfPixels[0], FWfPixels[W], (H - 1) * SizeOf(LongWord) * W); - Row0 := PLongWord(FWaterfallTemp.ScanLine[0]); + // ── Новая строка 0 — инкрементный указатель, без умножения X*BPP ──────── + Step := 1023.0 / Max(1, W - 1); + WatSrcF := 0.0; + Px := @FWfPixels[0]; for X := 0 to W - 1 do begin - WatSrcF := X * 1023.0 / Max(1, W - 1); - WatS0 := Min(Trunc(WatSrcF), 1023); - WatS1 := Min(WatS0 + 1, 1023); - frac := WatSrcF - WatS0; - // Берём из усреднённого буфера, не из сырого FSpectrumBuf - dB := FWfAvgBuf[WatS0] * (1.0 - frac) + FWfAvgBuf[WatS1] * frac; - - V := Round(Max(0.0, Min(1.0, (dB - WfLow) / (WfHigh - WfLow))) * 255); - Col := WFALL_PALETTE[V]; - // WFALL_PALETTE хранит цвет в формате $00RRGGBB (аналогично TColor) - // pf32bit ScanLine: байты в памяти = BB GG RR AA (little-endian BGRA) - // Записываем как $FF_RR_GG_BB с учётом порядка байт: - Row0[X] := $FF000000 - or ((Col and $000000FF) shl 16) // R (был BB палитры → R позиция) - or (Col and $0000FF00) // G остаётся на месте - or ((Col and $00FF0000) shr 16); // B (был RR палитры → B позиция) + WatS0 := Trunc(WatSrcF); + if WatS0 > 1022 then WatS0 := 1022; + WatS1 := WatS0 + 1; + frac := WatSrcF - WatS0; + dB := FWfAvgBuf[WatS0] * (1.0 - frac) + FWfAvgBuf[WatS1] * frac; + V := Trunc((dB - WfLow) * InvRange); + if V < 0 then V := 0; + if V > 255 then V := 255; + // Палитра задана как RGB, а в буфере нужен BGRA. + // Меняем местами R/B и ставим alpha=255, чтобы совпадало с web/Thetis-градиентом. + Pal := WFALL_PALETTE[V]; + Px^ := ((Pal and $000000FF) shl 16) or + (Pal and $0000FF00) or + ((Pal and $00FF0000) shr 16) or + $FF000000; + Inc(Px); + WatSrcF := WatSrcF + Step; end; - // ================================================================ - // Шаг 6: Swap буферов - // ================================================================ - Tmp := FWaterfallBitmap; - FWaterfallBitmap := FWaterfallTemp; - FWaterfallTemp := Tmp; + // ── Записываем FWfPixels в FWfBitmap через кэшированный TLazIntfImage ──── + // FWfIntfImg создаётся один раз на весь lifetime (или при ресайзе). + // Move(FWfPixels → IntfImg.PixelData) = один memcpy без GDI roundtrip. + // LoadFromIntfImage = один вызов platformAPI → ~1ms на Linux GTK2. + if FWfIntfImg = nil then + begin + FWfIntfImg := TLazIntfImage.Create(W, H); + InitRawDesc32(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^ shr 16); // B + RowPtr[1] := Byte(Src^ shr 8); // G + RowPtr[2] := Byte(Src^); // R + if BPPi >= 4 then RowPtr[3] := $FF; + Inc(Src); Inc(RowPtr, BPPi); + end; + end; + end; + FWfBitmap.LoadFromIntfImage(FWfIntfImg); end; // =========================================================================== @@ -2317,15 +2308,13 @@ end; // Ruler — полоса частотных меток между спектром и водопадом // --------------------------------------------------------------------------- procedure TMainForm.DrawRuler; -// ОПТИМИЗАЦИЯ: Ruler рисуется в offscreen FRulerBitmap и кэшируется. -// Перерисовка происходит только при изменении FCenterFreq или FVfoA. -// Это экономит TextOut + LineTo на каждом тике таймера (20fps = 20 перерисовок/сек). +// ОПТИМИЗАЦИЯ: рисуем в offscreen FRulerBitmap, перерисовываем только когда +// изменился FCenterFreq. Экономим TextOut+LineTo на каждом тике таймера (20fps). var C: TCanvas; W, H: Integer; i, X: Integer; - FreqStart, - FreqHz: Double; + FreqStart, FreqHz: Double; VfoX: Integer; Lbl: string; TW: Integer; @@ -2335,93 +2324,74 @@ begin H := PbRuler.Height; if (W <= 0) or (H <= 0) then Exit; - // Проверяем изменились ли частоты — если нет, пропускаем перерисовку - if (Abs(FCenterFreq - FRulerLastCenter) < 0.5) and - (Abs(FVfoA - FRulerLastVfo) < 0.5) and - not FRulerDirty then - Exit; // ничего не изменилось — кэш актуален + // Пропускаем перерисовку если ни частота центра, ни VFO не изменились + if (Abs(FCenterFreq - FRulerLastFreq) < 0.5) and + (Abs(FVfoA - FRulerLastVfo) < 0.5) then Exit; + FRulerLastFreq := FCenterFreq; + FRulerLastVfo := FVfoA; - // Обновляем кэш-bitmap если размер изменился if (FRulerBitmap.Width <> W) or (FRulerBitmap.Height <> H) then - begin - FRulerBitmap.PixelFormat := pf32bit; FRulerBitmap.SetSize(W, H); - end; C := FRulerBitmap.Canvas; - - // Фон — чуть светлее чем BG, отделяет спектр от водопада C.Brush.Color := TColor($00141414); C.FillRect(Rect(0, 0, W, H)); - - // Верхняя граница — тонкая линия C.Pen.Color := CLR_BORDER; C.Pen.Width := 1; C.MoveTo(0, 0); C.LineTo(W, 0); - // Нижняя граница C.MoveTo(0, H-1); C.LineTo(W, H-1); - - C.Font.Name := 'Courier New'; - C.Font.Size := 7; - + 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); - - // Частотная метка — центрируем под тиком + 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.Color := TColor($0000FF80); + C.Pen.Width := 2; + C.MoveTo(VfoX, 0); C.LineTo(VfoX, H - 1); C.Pen.Width := 1; end; - - // Кэшируем текущие значения - FRulerLastCenter := FCenterFreq; - FRulerLastVfo := FVfoA; - FRulerDirty := False; end; procedure TMainForm.PbRulerPaint(Sender: TObject); begin - // ОПТИМИЗАЦИЯ: DrawRuler рисует в FRulerBitmap только при изменении частоты. - // Здесь просто копируем кэшированный bitmap на Canvas — быстрый BitBlt. - DrawRuler; // обновит FRulerBitmap если нужно (иначе выйдет сразу) - if (FRulerBitmap <> nil) and - (FRulerBitmap.Width > 0) and (FRulerBitmap.Height > 0) then + DrawRuler; // обновит FRulerBitmap только если FCenterFreq изменился + if (FRulerBitmap.Width > 0) and (FRulerBitmap.Height > 0) then PbRuler.Canvas.Draw(0, 0, FRulerBitmap); end; procedure TMainForm.PbWaterfallPaint(Sender: TObject); +// Отображаем FWfBitmap — он уже содержит готовый кадр из DrawWaterfall. +// DrawWaterfall делает скролл через Move() в байтах IntfImage (~0.3ms), +// пишет новую строку, и один раз LoadFromIntfImage (~2ms). +// Здесь просто Canvas.Draw — данные уже в bitmap, GDI копирует за ~1ms. +var + W, H: Integer; begin - if (FWaterfallBitmap.Width > 0) and (FWaterfallBitmap.Height > 0) then - PbWaterfall.Canvas.Draw(0, 0, FWaterfallBitmap) + W := PbWaterfall.Width; + H := PbWaterfall.Height; + if (W <= 0) or (H <= 0) then Exit; + + if (FWfBitmap <> nil) and (FWfBitmap.Width = W) and (FWfBitmap.Height = H) then + PbWaterfall.Canvas.Draw(0, 0, FWfBitmap) else begin - PbWaterfall.Canvas.Brush.Color := CLR_BG; - PbWaterfall.Canvas.FillRect(Rect(0,0,PbWaterfall.Width,PbWaterfall.Height)); + PbWaterfall.Canvas.Brush.Color := clBlack; + PbWaterfall.Canvas.FillRect(Rect(0, 0, W, H)); end; - // Маркер рисуем поверх bitmap на Canvas — не трогаем bitmap чтобы не портить водопад + if FMarkerActive then - DrawMarkerLine(PbWaterfall.Canvas, PbWaterfall.Width, PbWaterfall.Height); + DrawMarkerLine(PbWaterfall.Canvas, W, H); end; // =========================================================================== @@ -2531,18 +2501,22 @@ begin FVfoOverlay.UpdateVfo(FVfoA); PositionVfoOverlay; end; - // Рисуем спектр и скроллим водопад только во время работы - DrawSpectrum; - DrawWaterfall; - PbSpectrum.Invalidate; - // ОПТИМИЗАЦИЯ: Ruler инвалидируем только если частота изменилась. - // DrawRuler() сам обнаружит изменение и выставит FRulerDirty. - // Это экономит WM_PAINT + TextOut на каждом тике при статичной частоте. - if (Abs(FCenterFreq - FRulerLastCenter) > 0.5) or - (Abs(FVfoA - FRulerLastVfo) > 0.5) or FRulerDirty then - PbRuler.Invalidate; - PbWaterfall.Invalidate; - FSpectrumDirty := False; + // Рисуем только при новых спектральных данных/изменении UI (FSpectrumDirty). + // Это резко снижает CPU в холостом режиме. + if FSpectrumDirty then + begin + DrawSpectrum; + PbSpectrum.Invalidate; + + // Водопад рисуем на каждом новом спектральном кадре для плавности. + DrawWaterfall; + PbWaterfall.Invalidate; + + if (Abs(FCenterFreq - FRulerLastFreq) >= 0.5) or + (Abs(FVfoA - FRulerLastVfo) >= 0.5) then + PbRuler.Invalidate; + FSpectrumDirty := False; + end; end else begin @@ -2569,14 +2543,14 @@ begin end; end; - // Если во время drag накопились грязные кадры — сбрасываем - if FSpectrumDirty then + // Если не в режиме RUN, но есть "грязный" кадр (drag/marker/UI), перерисуем. + if FSpectrumDirty and (not FRunning) then begin DrawSpectrum; DrawWaterfall; PbSpectrum.Invalidate; - if (Abs(FCenterFreq - FRulerLastCenter) > 0.5) or - (Abs(FVfoA - FRulerLastVfo) > 0.5) or FRulerDirty then + if (Abs(FCenterFreq - FRulerLastFreq) >= 0.5) or + (Abs(FVfoA - FRulerLastVfo) >= 0.5) then PbRuler.Invalidate; PbWaterfall.Invalidate; FSpectrumDirty := False; @@ -2816,6 +2790,20 @@ begin StatusBar1.Panels[3].Text := Format('DDC%d seq=%d', [DDCIdx, Seq]); end; +procedure TMainForm.RecreateDSPEngine(ASampleRate: Integer); +begin + if ASampleRate <= 0 then Exit; + if Assigned(FDSPEngine) then + begin + FDSPEngine.Close; + FreeAndNil(FDSPEngine); + end; + FWDSPReady := False; + FDSPEngine := TWDSPEngine.Create(ASampleRate, 48000, 512); + FDSPEngine.OnAudio := OnAudioReady; + FDSPEngine.OnSpectrum := OnSpectrumReady; +end; + procedure TMainForm.BtnDiscoverClick(Sender: TObject); begin // Открываем диалог выбора устройства @@ -2838,11 +2826,15 @@ end; procedure TMainForm.BtnStartStopClick(Sender: TObject); var - Idx: Integer; + Idx, i: Integer; Dev: THPSDRDevice; GenPkt: TGeneralPacket; DUCPkt: TDUCSpecificPacket; G_Settings: TGlobalSettings; + PreG: TGlobalSettings; + PreBands: array[0..CFG_BAND_COUNT-1] of TBandSettings; + PreloadRate: Integer; + FoundByIP: Boolean; AutoIP: string; begin if FNetwork.Connected then @@ -2901,6 +2893,28 @@ begin Dev.IPAddress := FPendingIP; Dev.BoardType := FPendingBoardType; + // Подбираем sample rate до открытия WDSP, чтобы не делать двойной Open: + // 1) Open на 192k, 2) сразу ChangeSampleRate+Open ещё раз. + // Если устройство уже было обнаружено (есть MAC) и есть сохранённый профиль, + // создаём WDSPEngine сразу с нужной частотой. + PreloadRate := FSampleRate; + FoundByIP := False; + for i := 0 to High(FDevices) do + if SameText(FDevices[i].Dev.IPAddress, Dev.IPAddress) then + begin + FoundByIP := True; + if FSettings.LoadDevice(FDevices[i].Dev.MAC, PreG, PreBands) and (PreG.SampleRate > 0) then + PreloadRate := PreG.SampleRate; + Break; + end; + if FoundByIP and (PreloadRate > 0) then + begin + FSampleRate := PreloadRate; + FSpanHz := PreloadRate; + if (not FWDSPReady) and (Assigned(FDSPEngine)) and (FDSPEngine.SampleRate <> PreloadRate) then + RecreateDSPEngine(PreloadRate); + end; + // --- Открываем WDSP асинхронно чтобы не блокировать UI --- FPendingDev := Dev; BtnStartStop.Enabled := False; @@ -2918,7 +2932,16 @@ begin // Вызывается из TWDSPOpenThread.SyncDone — уже в главном потоке FWDSPReady := Success; if not Success then - StatusBar1.Panels[3].Text := 'WDSP: demo mode (libwdsp not loaded)'; + begin + StatusBar1.Panels[3].Text := 'WDSP open failed'; + BtnStartStop.Enabled := True; + BtnStartStop.Caption := 'START'; + StyleButton(BtnStartStop, False); + ShowMessage('WDSP not loaded.' + LineEnding + + IfThen(FDSPEngine.LastError <> '', FDSPEngine.LastError, 'Unknown WDSP init error.') + LineEnding + + 'Put wdsp.dll рядом с ewsdr.exe (той же разрядности, что и приложение).'); + Exit; + end; DoConnectDevice(FPendingDev); end; @@ -3312,7 +3335,10 @@ begin DoSpectrumDrag(X, PbSpectrum.Width); // Маркер следует за курсором — обновление произойдёт в ближайшем тике таймера (50ms) if FMarkerActive then + begin FMarkerX := Round(X / PbSpectrum.Width * 1000); + FSpectrumDirty := True; + end; end; procedure TMainForm.PbSpectrumMouseUp(Sender: TObject; Button: TMouseButton; @@ -3365,7 +3391,10 @@ begin if FSpecDrag and (ssLeft in Shift) then DoSpectrumDrag(X, PbWaterfall.Width); if FMarkerActive then + begin FMarkerX := Round(X / PbWaterfall.Width * 1000); + FSpectrumDirty := True; + end; end; procedure TMainForm.PbWaterfallMouseUp(Sender: TObject; Button: TMouseButton; @@ -4085,6 +4114,7 @@ begin N := Min(Count, 1024); for i := 0 to N - 1 do FSpectrumBuf[i] := Pixels[i]; + FSpectrumDirty := True; end; diff --git a/WDSPEngine.pas b/WDSPEngine.pas index f12d62c..27b0a0d 100644 --- a/WDSPEngine.pas +++ b/WDSPEngine.pas @@ -145,6 +145,7 @@ type FANFEnabled: Boolean; FMuted: Boolean; FVolume: Double; + FLastError: string; function ModeToWDSP(Mode: Integer): Integer; procedure ApplyDefaultFilter; @@ -211,6 +212,7 @@ type property Mode: Integer read FMode; property FilterLow: Integer read FFilterLow; property FilterHigh: Integer read FFilterHigh; + property LastError: string read FLastError; property SMeter: Double read FSMeter; property OnAudio: TOnAudioReady read FOnAudio write FOnAudio; property OnSpectrum: TOnSpectrumReady read FOnSpectrum write FOnSpectrum; @@ -349,69 +351,31 @@ end; procedure TWDSPEngine.OpenAnalyzer; var - Success: Integer; - MaxW: Integer; - Ovrlp: Integer; + Success: Integer; + MaxW: Integer; + Ovrlp: Integer; FRAME_RATE: Integer; begin if FAnalyzerOpen then Exit; - // ---- Шаг 1: создать анализатор ---- - // success = 0 означает УСПЕХ (WDSP Guide p.119) - // m_size = максимальный FFT размер (должен быть степенью 2, ≤ 262144) - // ОПТИМИЗАЦИЯ: 262144 → 16384. WDSP аллоцирует и инициализирует twiddle-таблицы - // для ВСЕХ степеней 2 вплоть до m_size при каждом вызове XCreateAnalyzer. - // Реальный рабочий FFT = 4096, запас ×4 = 16384 достаточен для будущих апгрейдов. - // Эффект: время открытия WDSP ~10 сек → ~1 сек. - // m_LO = 1 (нет spur elimination) - // m_stitch = 1 (один sub-span) Success := -1; XCreateAnalyzer(DISP_ID, @Success, 16384, 1, 1, nil); - if Success <> 0 then - begin - // Анализатор не создан — работаем без спектра, краша не будет - Exit; - end; + if Success <> 0 then Exit; - // ---- Шаг 2: SetAnalyzer ---- - // Рассчитываем параметры по документации (WDSP Guide p.120-121) - FRAME_RATE := 20; // 20 fps - // max_w = fft_size + min(KEEP_TIME*sample_rate, KEEP_TIME*fft_size*frame_rate) - // KEEP_TIME = 0.1 - // min(0.1*192000, 0.1*4096*20) = min(19200, 8192) = 8192 - MaxW := 4096 + 8192; // = 12288 - - // ovrlp = max(0, ceil(fft_size - sample_rate/frame_rate)) - // = max(0, ceil(4096 - 192000/20)) = max(0, 4096-9600) = 0 + FRAME_RATE := 20; + // max_w: WDSP аллоцирует ring-buffer и FFT планы на max_w сэмплов. + // Формула: fft_size + min(0.1*samplerate, 0.1*fft_size*fps) + // = 4096 + min(19200, 8192) = 12288 → 1700ms инициализации! + // Реально нам нужен только один FFT-кадр без усреднения по времени. + // MaxW = sz = 4096 — минимально допустимое значение (должно быть ≥ sz). + // piHPSDR использует именно sz без добавки keep_time. + MaxW := 4096; Ovrlp := 0; - FFlp[0] := 0; // LO на нижней стороне, нет spur elimination - SetAnalyzer( - DISP_ID, - 1, // n_pixout: 1 (один выход — панадаптер и водопад одинаковые) - 1, // n_fft: 1 (без spur elimination) - 1, // typ: 1 = COMPLEX (I+Q) - @FFlp[0], // flp: [0] — LO ниже сигнала - 4096, // sz: размер FFT - FAudioBufSize, // bf_sz: блок данных @ AudioRate = dsp_size (1024 @ 48kHz) - 1, // win_type: 1 = 4-term Blackman-Harris - 14.0, // pi: Kaiser beta (не используется при BH) - Ovrlp, // ovrlp: перекрытие = 0 - 0, // clp: обрезка краёв = 0 - 0.0, // fscLin: pan left = 0 (полный диапазон) - 0.0, // fscHin: pan right = 0 (полный диапазон) - SPECTRUM_PIXELS, // n_pix: пикселей на выходе GetPixels - 1, // n_stch: 1 sub-span (нет stitching) - 0, // calset: нет калибровки - 0.0, // fmin - 0.0, // fmax - MaxW // max_w: рассчитан выше = 12288 - ); + FFlp[0] := 0; + SetAnalyzer(DISP_ID, 1, 1, 1, @FFlp[0], 4096, FAudioBufSize, 1, 14.0, + Ovrlp, 0, 0.0, 0.0, SPECTRUM_PIXELS, 1, 0, 0.0, 0.0, MaxW); - // ---- Шаг 3: настройка детектора и усреднения ---- - // DETECTOR_MODE_AVERAGE + LOG_RECURSIVE — плавный, как в piHPSDR/Thetis - // backmult ~0.45 = инерция ~2.2 кадра → плавно, без смазывания быстрых сигналов - // NumAverage = FRAME_RATE * 0.12 ≈ 2..3 при 20fps — достаточно для LOG_RECURSIVE SetDisplayDetectorMode(DISP_ID, 0, DETECTOR_MODE_AVERAGE); SetDisplayAverageMode(DISP_ID, 0, AVERAGE_MODE_LOG_RECURSIVE); SetDisplayNumAverage(DISP_ID, 0, Max(2, Trunc(FRAME_RATE * 0.12))); @@ -434,66 +398,65 @@ end; // --------------------------------------------------------------------------- function TWDSPEngine.Open: Boolean; +var + DllPath: string; begin Result := False; + FLastError := ''; if FInitialized then Exit; - // Проверяем что libwdsp загружена (WEAKEXTERNALSYMBOLS: nil если нет DLL) if not Assigned(@OpenChannel) then begin - // wdsp.dll / libwdsp.so не найдена — демо-режим FInitialized := False; + DllPath := IncludeTrailingPathDelimiter(ExtractFilePath(ParamStr(0))) + WDSP_LIB; + if FileExists(DllPath) then + FLastError := Format('Symbols from %s are not resolved. Check architecture (x64/x86) and dependencies.', [DllPath]) + else + FLastError := Format('WDSP library not found: %s', [DllPath]); Exit; end; try - // ОПТИМИЗАЦИЯ: Анализатор создаём ПЕРВЫМ — до OpenChannel. - // Это соответствует порядку piHPSDR и гарантирует что DISP_ID готов - // до того как DSP поток начнёт вызывать Spectrum0. + // Анализатор ПЕРВЫМ — до OpenChannel (порядок как в piHPSDR) OpenAnalyzer; - // НЕ используем SetRXASpectrum — кормим анализатор вручную - // через Spectrum0 с Double буфером, чтобы избежать SIGSEGV - // (fexchange0 работает с Single, а внутри WDSP Spectrum2 ждёт Double) + if not FAnalyzerOpen then + begin + FLastError := 'XCreateAnalyzer failed (WDSP init failed).'; + Exit; + end; - // ---- RXA channel 0 ---- - // ОПТИМИЗАЦИЯ: state=0 (не запускать сразу) — настраиваем все параметры - // вхолостую, только потом стартуем через SetChannelState. - // Канал не тратит CPU на обработку "мусорных" данных в процессе настройки. OpenChannel( RXA_CHAN, - FBufSize, // in_size @ FSampleRate (4096 @ 192kHz) - FAudioBufSize, // dsp_size @ AudioRate (1024 @ 48kHz) — как piHPSDR buffer_size + FBufSize, + FAudioBufSize, FSampleRate, - FAudioRate, // dsp_rate = 48kHz - FAudioRate, // out_rate = 48kHz - 0, // atype: 0=RX - 0, // state: 0=hold (запустим после настройки всех параметров) + FAudioRate, + FAudioRate, + 0, + 0, // state=0: создать но не запускать 0.010, 0.010, 0.010, 0.010, 0 ); SetRXAMode(RXA_CHAN, ModeToWDSP(FMode)); - SetRXAShiftRun(RXA_CHAN, 1); // включаем NCO шифтер (нужен для CTUN) - SetRXAShiftFreq(RXA_CHAN, 0.0); // по умолчанию сдвига нет + SetRXAShiftRun(RXA_CHAN, 1); + SetRXAShiftFreq(RXA_CHAN, 0.0); RXASetPassband(RXA_CHAN, FFilterLow, FFilterHigh); - SetAGC(FAGCMode, 50.0); // настраиваем AGC с полными параметрами piHPSDR + SetAGC(FAGCMode, 50.0); SetRXAPanelGain1(RXA_CHAN, FVolume); SetRXAPanelSelect(RXA_CHAN, 3); SetRXAPanelRun(RXA_CHAN, 1); + SetChannelState(RXA_CHAN, 1, 0); - // Запускаем RXA канал только после полной настройки - SetChannelState(RXA_CHAN, 1, 0); // run, no delay-wait - - // ---- TXA channel 1 ---- OpenChannel( TXA_CHAN, - FBufSize, - FBufSize, + FAudioBufSize, + FAudioBufSize, FAudioRate, - 48000, - FSampleRate, - 1, // atype: 1=TX - 0, // state: 0=hold + FAudioRate, + FAudioRate, + 1, + 0, 0.010, 0.010, 0.010, 0.010, 0 ); @@ -519,8 +482,12 @@ begin FDSPThread.Priority := tpHighest; // DSP критический поток except - FInitialized := False; - Result := False; + on E: Exception do + begin + FLastError := E.ClassName + ': ' + E.Message; + FInitialized := False; + Result := False; + end; end; end; diff --git a/WebServer.pas b/WebServer.pas index 5089e32..a4aa14c 100644 --- a/WebServer.pas +++ b/WebServer.pas @@ -1046,13 +1046,13 @@ var i, n, Enc: Integer; Pkt: array of Byte; begin - if not FRunning then begin AudioLog('EXIT: FRunning=false'); Exit; end; + if not FRunning then Exit; FClientLock.Enter; HasWs := FClientCount > 0; FClientLock.Leave; - if not HasWs then begin AudioLog('EXIT: FClientCount=0'); Exit; end; - if (Samples = nil) or (Count <= 0) then begin AudioLog('EXIT: Samples nil/Count=0'); Exit; end; - if not FOpusReady then begin AudioLog('EXIT: FOpusReady=false'); Exit; end; + if not HasWs then Exit; + if (Samples = nil) or (Count <= 0) then Exit; + if not FOpusReady then Exit; i := 0; while i < Count do @@ -1073,10 +1073,7 @@ begin Pkt[0] := WS_MSG_AUDIO; Move(FOpusOut[0], Pkt[1], Enc); BroadcastBinary(Pkt[0], Length(Pkt)); - AudioLog('SENT opus bytes=' + IntToStr(Enc) + ' clients=' + IntToStr(FClientCount)); - end - else - AudioLog('ENCODE FAILED Enc=' + IntToStr(Enc)); + end; FOpusBufPos := 0; end; end; diff --git a/WebUtils.pas b/WebUtils.pas index 86a53c7..6ec261e 100644 --- a/WebUtils.pas +++ b/WebUtils.pas @@ -6,7 +6,6 @@ unit WebUtils; - SHA-1 (минимальная реализация для WebSocket handshake) - Base64 (кодирование) - JSON helpers (JsonGetStr, JsonGetFloat, JsonGetInt, JsonGetBool) - - AudioLog (отладочное логирование аудио) ИСПРАВЛЕНИЯ: - (Windows build fix) Порядок uses: стандартные RTL-юниты первыми, @@ -76,10 +75,6 @@ function JsonGetFloat(const Json, Key: string; Def: Double): Double; function JsonGetInt(const Json, Key: string; Def: Integer): Integer; function JsonGetBool(const Json, Key: string; Def: Boolean): Boolean; -{ ── Отладочное логирование аудио ─────────────────────────────────────────── } - -procedure AudioLog(const S: string); - implementation { ═══════════════════════════════════════════════════════════════════════════ @@ -318,20 +313,4 @@ begin else Result := (S = 'true') or (S = '1'); end; -{ ═══════════════════════════════════════════════════════════════════════════ - Отладочное логирование аудио - ═══════════════════════════════════════════════════════════════════════════ } - -procedure AudioLog(const S: string); -var F: TextFile; -begin - try - AssignFile(F, 'audio_debug.log'); - if FileExists('audio_debug.log') then Append(F) else Rewrite(F); - WriteLn(F, FormatDateTime('hh:nn:ss.zzz', Now) + ' ' + S); - CloseFile(F); - except - end; -end; - end. diff --git a/hpsdr_settings.json b/hpsdr_settings.json index 565d17b..31c0bfd 100644 --- a/hpsdr_settings.json +++ b/hpsdr_settings.json @@ -285,7 +285,7 @@ "span_hz" : 1.9200000000000000E+005 }, "1" : { - "vfo_a" : 3.6210000000000000E+006, + "vfo_a" : 3.6870000000000000E+006, "vfo_b" : 3.7500000000000000E+006, "mode" : 0, "filter_idx" : 5, @@ -307,7 +307,7 @@ "span_hz" : 1.9200000000000000E+005 }, "3" : { - "vfo_a" : 7.1620000000000000E+006, + "vfo_a" : 7.1410000000000000E+006, "vfo_b" : 7.1000000000000000E+006, "mode" : 0, "filter_idx" : 5, @@ -329,7 +329,7 @@ "span_hz" : 1.9200000000000000E+005 }, "5" : { - "vfo_a" : 1.4163000000000000E+007, + "vfo_a" : 1.4189000000000000E+007, "vfo_b" : 1.4200000000000000E+007, "mode" : 1, "filter_idx" : 5,