feat(spectrum): zoom + pan для спектра и водопада (WDSP span-clip)

Зум через нативный span-clip WDSP (fscLin/fscHin в SetAnalyzer): clp=0,
видимое окно сужается клипом, при зуме 0 полоса == sample rate (старое
поведение сохранено). Конвенция как в Thetis: ZoomFactor 0=без зума..1=макс.

- WDSPEngine: SetZoomPan + расчёт span-clip, ViewLowHz/ViewHighHz.
- RadioController: GetViewWindow (единая истина видимого окна для оверлеев и
  мыши), ApplyZoom, ZoomIn/Out по лестнице кратностей, ZoomDefault. Якорь
  зума кнопками — на маркере активного VFO. rfZoom.
- Per-band персист зума/пана (TBandSettings + Save/Load/Restore).
- MainForm: маршрутизация оверлеев/кликов через GetViewWindow (покрывает CPU
  и OpenGL), резерв строки PANZOOM_H в layout + сплиттере, rfZoom-рендер.
- WebAdapter: web-спектр/наведение на view-окно (делит те же WDSP-пиксели).
- PanZoomBar (новый): нижняя полоса = линейка полного диапазона (частоты +
  деления), поверх окно-селектор «стекло» (полупрозрачное, объёмная фаска).
  Середина=пан, края=зум, клик мимо=центрировать, двойной клик=сброс.

Сборка ewsdr (--ws=qt6) и ewsdrd — ОК. На железе не проверено.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-06-30 21:18:24 +03:00
co-authored by Claude Opus 4.8
parent 0e550dd9f9
commit 371b2858bd
6 changed files with 814 additions and 22 deletions
+175 -12
View File
@@ -33,6 +33,7 @@ uses
WebServer, WebAdapter,
CATAdapter,
SpectrumView, SpectrumViewOpengl,
PanZoomBar,
WidebandView,
StatusBar,
PlatformUtils,
@@ -296,6 +297,12 @@ type
PbRuler: TPaintBox; // полоса частотных меток между спектром и водопадом
PanelSplitter: TPanel; // перетаскиваемый разделитель спектр/водопад
PbWaterfall: TControl;
// ---- Панель пана/зума под водопадом ----
PbPanZoom: TPaintBox; // полоса-ползунок пана зума
FPanZoomBar: TPanZoomBar;
BtnZoomIn: TFlatButton;
BtnZoomDef: TFlatButton;
BtnZoomOut: TFlatButton;
// ---- Status bar ----
StatusPanel: TMainStatusBar;
@@ -437,6 +444,19 @@ type
procedure DoSpectrumClick(PixelX: Integer; PanelWidth: Integer);
procedure DoSpectrumDrag(PixelX: Integer; PanelWidth: Integer);
procedure DoWidebandClick(PixelX: Integer; PanelWidth: Integer);
// ---- Пан/зум ----
procedure PbPanZoomMouseDown(Sender: TObject; Button: TMouseButton;
Shift: TShiftState; X, Y: Integer);
procedure PbPanZoomMouseMove(Sender: TObject; Shift: TShiftState; X, Y: Integer);
procedure PbPanZoomMouseUp(Sender: TObject; Button: TMouseButton;
Shift: TShiftState; X, Y: Integer);
procedure PbPanZoomDblClick(Sender: TObject);
procedure OnPanZoomWindow(AZoom, APan: Double);
procedure BtnZoomInClick(Sender: TObject);
procedure BtnZoomDefClick(Sender: TObject);
procedure BtnZoomOutClick(Sender: TObject);
procedure PositionPanZoomBar(BottomY, RW, H: Integer; AVisible: Boolean);
procedure UpdatePanZoomBar;
procedure BtnVfoSwapClick(Sender: TObject);
procedure BtnVfoACopyBClick(Sender: TObject);
procedure BtnVfoBCopyAClick(Sender: TObject);
@@ -1068,6 +1088,7 @@ begin
FController.FNetwork.Disconnect;
FreeAndNil(FSpecView);
FreeAndNil(FWidebandView);
FreeAndNil(FPanZoomBar);
// Сохраняем настройки при закрытии
if FController.FDevConnected then
@@ -1370,6 +1391,21 @@ begin
// актуальную ширину спектра (UI-зависимая величина).
if FController.FWDSPReady then
FController.FDSPEngine.UpdateAGCLines(FSpectrumWidth);
UpdatePanZoomBar;
end;
rfZoom:
begin
// Зум/пан изменились: окно просмотра, шаг линейки и полоса фильтра
// следуют за span → пересинк + полная перерисовка спектра и водопада.
FSpecView.InvalidateRulerCache;
SyncSpecViewFreq;
UpdatePanZoomBar;
PositionVfoOverlay;
FSpecView.DrawSpectrum;
PbSpectrum.Invalidate;
FSpecView.DrawWaterfall;
if PbWaterfall <> nil then PbWaterfall.Invalidate;
if PbRuler <> nil then PbRuler.Invalidate;
end;
rfFMRpt:
begin
@@ -2194,8 +2230,25 @@ begin
end;
PbWaterfall.Parent := PanelRight;
// ---- Панель пана/зума под водопадом ----
// Кнопки создаём с дефолтными цветами; финальная тема — в ApplyDarkTheme.
FPanZoomBar := TPanZoomBar.Create;
FPanZoomBar.OnZoomPan := OnPanZoomWindow;
PbPanZoom := TPaintBox.Create(Self);
PbPanZoom.Parent := PanelRight;
PbPanZoom.OnPaint := FPanZoomBar.Paint;
PbPanZoom.OnMouseDown := PbPanZoomMouseDown;
PbPanZoom.OnMouseMove := PbPanZoomMouseMove;
PbPanZoom.OnMouseUp := PbPanZoomMouseUp;
PbPanZoom.OnDblClick := PbPanZoomDblClick;
FPanZoomBar.PaintBox := PbPanZoom;
BtnZoomOut := MakeFlatBtn(PanelRight, '', 0, 0, 10, 10, BtnZoomOutClick);
BtnZoomDef := MakeFlatBtn(PanelRight, '⌂', 0, 0, 10, 10, BtnZoomDefClick);
BtnZoomIn := MakeFlatBtn(PanelRight, '+', 0, 0, 10, 10, BtnZoomInClick);
// Initial layout
ResizeSpectrumPanels;
UpdatePanZoomBar;
end;
// ===========================================================================
@@ -2241,7 +2294,9 @@ begin
DeltaY := P.Y - FSplitterDragY0;
TopOff := 0;
AvailH := PanelRight.ClientHeight - TopOff - RULER_H - SPLITTER_H;
// Низ зарезервирован под панель пана/зума (она видна, пока виден водопад).
AvailH := PanelRight.ClientHeight - TopOff - RULER_H - SPLITTER_H
- MulDiv(22, Screen.PixelsPerInch, 96);
if AvailH <= 0 then Exit;
NewSH := FSplitterSH0 + DeltaY;
@@ -2279,7 +2334,9 @@ begin
DeltaY := P.Y - FSplitterDragY0;
TopOff := 0;
AvailH := PanelRight.ClientHeight - TopOff - RULER_H - SPLITTER_H;
// Низ зарезервирован под панель пана/зума (она видна, пока виден водопад).
AvailH := PanelRight.ClientHeight - TopOff - RULER_H - SPLITTER_H
- MulDiv(22, Screen.PixelsPerInch, 96);
if AvailH <= 0 then Exit;
NewSH := FSplitterSH0 + DeltaY;
@@ -2483,12 +2540,19 @@ var
RW, RH, SH, WH, TopOff, WideH, WideSpecH: Integer;
AvailH: Integer;
RULER_H: Integer;
PANZOOM_H: Integer;
ShowPZ: Boolean;
begin
RULER_H := MulDiv(18, Screen.PixelsPerInch, 96);
if PanelRight = nil then Exit;
if Assigned(FSpecView) then SyncSpecViewFreq;
RW := PanelRight.ClientWidth;
RH := PanelRight.ClientHeight;
// Резервируем низ под панель пана/зума (видна, если виден спектр или водопад).
PANZOOM_H := MulDiv(22, Screen.PixelsPerInch, 96);
ShowPZ := FController.FShowSpectrum or FController.FShowWaterfall;
PositionPanZoomBar(RH - PANZOOM_H, RW, PANZOOM_H, ShowPZ);
if ShowPZ then RH := RH - PANZOOM_H;
UpdateWidebandFrequencyView;
WideH := 0;
WideSpecH := 0;
@@ -2712,6 +2776,14 @@ begin
if BtnFMRptPlus <> nil then StyleButton(BtnFMRptPlus, BtnFMRptPlus.Active);
if FCTCSSDropDown <> nil then FCTCSSDropDown.ApplyStyle(StyleButton, T.Panel);
if FStepDropDown <> nil then FStepDropDown.ApplyStyle(StyleButton, T.Panel);
if BtnZoomIn <> nil then StyleButton(BtnZoomIn, False);
if BtnZoomDef <> nil then StyleButton(BtnZoomDef, False);
if BtnZoomOut <> nil then StyleButton(BtnZoomOut, False);
if FPanZoomBar <> nil then
begin
FPanZoomBar.SetTheme(T);
if PbPanZoom <> nil then PbPanZoom.Color := T.SliderBG;
end;
if BtnChannels <> nil then StyleButton(BtnChannels, False);
if FChannelsDropDown <> nil then FChannelsDropDown.ApplyStyle(StyleButton, T.Panel);
StyleButton(BtnNR, BtnNR.Active);
@@ -2995,12 +3067,15 @@ begin
end;
procedure TMainForm.SyncSpecViewFreq;
var ViewCenter, ViewSpan: Double;
begin
// Видимое окно с учётом зума (центр+span). Без зума == FCenterFreq/FSpanHz.
FController.GetViewWindow(ViewCenter, ViewSpan);
FSpecView.VfoA := FController.FVfoA;
FSpecView.VfoB := FController.FVfoB;
FSpecView.ActiveVfo := FController.FActiveVfo;
FSpecView.CenterFreq := FController.FCenterFreq;
FSpecView.SpanHz := FController.FSpanHz;
FSpecView.CenterFreq := ViewCenter;
FSpecView.SpanHz := ViewSpan;
FSpecView.Mode := FController.FMode;
FSpecView.FilterBW := FController.FFilterBW;
if FController.FMode = MODE_FM then
@@ -3017,9 +3092,10 @@ begin
// Бэндплан QO-100 следует за видом спектра (downlink-домен). SetView сам
// отсекает неизменное → пересборки кэша на каждый вызов нет.
if Assigned(FBandPlanOverlay) then
FBandPlanOverlay.SetView(FController.FCenterFreq, FController.FSpanHz);
FBandPlanOverlay.SetView(ViewCenter, ViewSpan);
if UpdateWidebandFrequencyView and FController.FShowWideband and (PbWideband <> nil) then
PbWideband.Invalidate;
UpdatePanZoomBar; // линейка пана следует за частотой/зумом (no-op если без изменений)
end;
@@ -4120,13 +4196,15 @@ procedure TMainForm.DoSpectrumClick(PixelX: Integer; PanelWidth: Integer);
var
ClickFreq: Int64;
StepHz: Int64;
ViewCenter, ViewSpan: Double;
begin
if PanelWidth <= 0 then Exit;
if (FController.FMode = MODE_FM) and FController.FFMStepOn then
StepHz := FM_STEP_HZ[FController.FFMStepIdx]
else
StepHz := 100;
ClickFreq := Round(FController.FCenterFreq + (PixelX / PanelWidth - 0.5) * FController.FSpanHz);
FController.GetViewWindow(ViewCenter, ViewSpan);
ClickFreq := Round(ViewCenter + (PixelX / PanelWidth - 0.5) * ViewSpan);
ClickFreq := ((ClickFreq + StepHz div 2) div StepHz) * StepHz;
if FController.FActiveVfo = 0 then
ApplyVfoA(ClickFreq)
@@ -4226,10 +4304,12 @@ procedure TMainForm.DoSpectrumDrag(PixelX: Integer; PanelWidth: Integer);
var
dPix: Integer;
dFreq: Double;
ViewCenter, ViewSpan: Double;
begin
if PanelWidth <= 0 then Exit;
FController.GetViewWindow(ViewCenter, ViewSpan);
dPix := PixelX - FSpecDragX0;
dFreq := dPix / PanelWidth * FController.FSpanHz;
dFreq := dPix / PanelWidth * ViewSpan;
if FController.FCTun then
// CTUN ON: drag двигает окно просмотра (DDC-центр) без смены VFO; shift,
// сеть и рендер — в контроллере SetCenter / OnControllerState(rfCenterFreq).
@@ -4244,9 +4324,89 @@ begin
end;
end;
// ---------------------------------------------------------------------------
// Панель пана / зума
// ---------------------------------------------------------------------------
procedure TMainForm.PositionPanZoomBar(BottomY, RW, H: Integer; AVisible: Boolean);
var BtnW, Gap, StripW, X: Integer;
begin
if (PbPanZoom = nil) or (BtnZoomIn = nil) then Exit;
PbPanZoom.Visible := AVisible;
BtnZoomIn.Visible := AVisible;
BtnZoomDef.Visible := AVisible;
BtnZoomOut.Visible := AVisible;
if not AVisible then Exit;
Gap := MulDiv(2, Screen.PixelsPerInch, 96);
BtnW := H; // квадратные кнопки в высоту строки
StripW := RW - 3 * BtnW - 4 * Gap;
if StripW < 20 then StripW := 20;
PbPanZoom.SetBounds(0, BottomY, StripW, H);
X := StripW + Gap;
BtnZoomOut.SetBounds(X, BottomY, BtnW, H); Inc(X, BtnW + Gap);
BtnZoomDef.SetBounds(X, BottomY, BtnW, H); Inc(X, BtnW + Gap);
BtnZoomIn.SetBounds(X, BottomY, BtnW, H);
PbPanZoom.Invalidate;
end;
procedure TMainForm.UpdatePanZoomBar;
begin
if FPanZoomBar <> nil then
begin
// Полоса — линейка ПОЛНОГО диапазона (базовые центр/span), стекло-ползунок
// показывает видимое окно (zoom/pan).
FPanZoomBar.SetFreq(FController.FCenterFreq, FController.FSpanHz);
FPanZoomBar.SetState(FController.ZoomFactor, FController.PanSlider);
end;
end;
procedure TMainForm.OnPanZoomWindow(AZoom, APan: Double);
begin
// Окно-селектор задаёт и зум, и пан напрямую (без якоря на VFO).
FController.ApplyZoom(AZoom, APan);
end;
procedure TMainForm.PbPanZoomDblClick(Sender: TObject);
begin
if FPanZoomBar <> nil then FPanZoomBar.HandleDblClick;
end;
procedure TMainForm.PbPanZoomMouseDown(Sender: TObject; Button: TMouseButton;
Shift: TShiftState; X, Y: Integer);
begin
if FPanZoomBar <> nil then FPanZoomBar.HandleMouseDown(Button, X, Y);
end;
procedure TMainForm.PbPanZoomMouseMove(Sender: TObject; Shift: TShiftState; X, Y: Integer);
begin
if FPanZoomBar <> nil then FPanZoomBar.HandleMouseMove(X, Y);
end;
procedure TMainForm.PbPanZoomMouseUp(Sender: TObject; Button: TMouseButton;
Shift: TShiftState; X, Y: Integer);
begin
if FPanZoomBar <> nil then FPanZoomBar.HandleMouseUp;
end;
procedure TMainForm.BtnZoomInClick(Sender: TObject);
begin
FController.ZoomIn;
end;
procedure TMainForm.BtnZoomDefClick(Sender: TObject);
begin
FController.ZoomDefault;
end;
procedure TMainForm.BtnZoomOutClick(Sender: TObject);
begin
FController.ZoomOut;
end;
// Спектр
procedure TMainForm.PbSpectrumMouseDown(Sender: TObject; Button: TMouseButton;
Shift: TShiftState; X, Y: Integer);
var VC, VS: Double;
begin
if Assigned(FVfoOverlay) and
FVfoOverlay.HandleMouseDown(Button, X, Y) then Exit;
@@ -4261,8 +4421,9 @@ begin
if FController.BeaconLockEnabled and (PbSpectrum.Width > 0) and
(FBeaconArming or (ssShift in Shift)) then
begin
FController.GetViewWindow(VC, VS);
FController.BeaconSeedAtHz(
PixelToFreq(X, PbSpectrum.Width, FController.FCenterFreq, FController.FSpanHz));
PixelToFreq(X, PbSpectrum.Width, VC, VS));
FBeaconArming := False;
UpdateBeaconButton;
Exit;
@@ -4851,13 +5012,15 @@ procedure TMainForm.PositionVfoOverlay;
var
VfoX, FilterEndX, OW, NewLeft, NewTop: Integer;
HiHz: Double;
ViewCenter, ViewSpan: Double;
begin
if not Assigned(FVfoOverlay) or not FVfoOverlay.Visible then Exit;
OW := FVfoOverlay.Width;
FController.GetViewWindow(ViewCenter, ViewSpan);
if (PbSpectrum.Width > 0) and (FController.FSpanHz > 0) then
if (PbSpectrum.Width > 0) and (ViewSpan > 0) then
begin
VfoX := Round((FController.FVfoA - FController.FCenterFreq + FController.FSpanHz / 2) / FController.FSpanHz * PbSpectrum.Width)
VfoX := Round((FController.FVfoA - ViewCenter + ViewSpan / 2) / ViewSpan * PbSpectrum.Width)
end
else
VfoX := PbSpectrum.Width div 2;
@@ -4868,8 +5031,8 @@ begin
else
HiHz := FController.FFilterBW / 2;
end;
if (PbSpectrum.Width > 0) and (FController.FSpanHz > 0) then
FilterEndX := VfoX + Round(HiHz / FController.FSpanHz * PbSpectrum.Width)
if (PbSpectrum.Width > 0) and (ViewSpan > 0) then
FilterEndX := VfoX + Round(HiHz / ViewSpan * PbSpectrum.Width)
else
FilterEndX := VfoX;
FilterEndX := Max(0, Min(PbSpectrum.Width - 1, FilterEndX));
+425
View File
@@ -0,0 +1,425 @@
unit PanZoomBar;
{
PanZoomBar.pas — нижняя панель пана/зума спектра и водопада.
Полоса = мини-линейка ПОЛНОГО диапазона (sample rate): цифры частот + деления.
Поверх — окно-селектор «стекло» (полупрозрачное, с объёмной фаской), равное
видимой полосе. Взаимодействие:
• тянешь СЕРЕДИНУ окна → пан (сдвиг видимой полосы);
• тянешь ЛЕВЫЙ/ПРАВЫЙ край → зум (меняешь ширину окна, противоположный край
зафиксирован), курсор у края ⇔;
• клик мимо окна → окно центрируется на курсоре (пан);
• двойной клик → сброс к полному диапазону (зум 0).
Сквозь стекло видны цифры/деления. Callback OnZoomPan(zoom, pan) отдаёт обе
величины (зум 0..1, пан 0..1) — контроллер применяет их напрямую.
Рисует в чужой TPaintBox (как RulerView) через offscreen-битмап. Полупрозрачность
стекла — реальный per-pixel альфа-бленд (TLazIntfImage). Кнопки +/умолч/- — в MainForm.
}
{$IFDEF FPC}
{$MODE Delphi}
{$ENDIF}
interface
uses
Classes, SysUtils, Graphics, ExtCtrls, Controls, Math,
LCLIntf, LCLType, IntfGraphics, fpImage, AppTheme;
type
TPanZoomEvent = procedure(AZoom, APan: Double) of object;
TDragMode = (dmNone, dmBody, dmLeft, dmRight);
TPanZoomBar = class
private
FPb: TPaintBox;
FBmp: TBitmap;
FTheme: TAppTheme;
FZoom: Double; // 0..1 (0 = без зума, весь span виден)
FPan: Double; // 0..1 (положение окна по span)
FCenterFreq: Double; // центр ПОЛНОГО диапазона (DDC), Гц
FSpanHz: Double; // полный span (= sample rate), Гц
FDragMode: TDragMode;
FDragGrab: Double; // body: (курсор_доля − левый_край_доля) в момент захвата
FDragLo: Double; // зафиксированный левый край окна (для edge-зума), доля
FDragHi: Double; // зафиксированный правый край окна, доля
FOnZoomPan: TPanZoomEvent;
function VisFraction: Double; // доля span, видимая при текущем зуме
function ThumbGeom(W: Integer; out L, Wd: Integer): Boolean; // px-геометрия окна
procedure CurWindow(out Lo, Hi: Double); // доли краёв окна
procedure EmitWindow(NewLo, NewHi: Double); // окно → zoom/pan + callback
function EdgePx(Wd: Integer): Integer;
procedure DrawRulerInto(W, H: Integer);
procedure BlendThumb(L, Wd, W, H: Integer);
public
constructor Create;
destructor Destroy; override;
property PaintBox: TPaintBox write FPb;
property OnZoomPan: TPanZoomEvent read FOnZoomPan write FOnZoomPan;
procedure SetState(AZoom, APan: Double);
procedure SetFreq(ACenterFreq, ASpanHz: Double);
procedure SetTheme(const T: TAppTheme);
procedure Paint(Sender: TObject);
procedure HandleMouseDown(Button: TMouseButton; X, Y: Integer);
procedure HandleMouseMove(X, Y: Integer);
procedure HandleMouseUp;
procedure HandleDblClick;
end;
implementation
const
MIN_THUMB_PX = 12; // окно не уже этого на экране — чтобы было за что схватить
MIN_VIS = 0.01; // минимальная видимая доля (= максимальный зум ~100x)
// Линейная интерполяция байт-канала.
function MixB(Bg, Fg: Integer; A: Double): Integer; inline;
begin
Result := Round(Bg * (1.0 - A) + Fg * A);
if Result < 0 then Result := 0 else if Result > 255 then Result := 255;
end;
function FreqLabel(Hz: Double): string;
begin
Result := Format('%.3f', [Hz / 1e6]);
end;
// Видимая доля → коэффициент зума z (инверсия формулы анализатора).
function VisToZoom(Vis: Double): Double;
var V: Double;
begin
V := EnsureRange(Vis, MIN_VIS, 1.0);
Result := EnsureRange((Power(10.0, (1.0 - V) / 0.99) - 1.0) / 9.0, 0.0, 1.0);
end;
constructor TPanZoomBar.Create;
begin
inherited Create;
FBmp := TBitmap.Create;
FBmp.PixelFormat := pf32bit;
FTheme := DarkTheme;
FZoom := 0.0;
FPan := 0.5;
FCenterFreq := 0.0;
FSpanHz := 192000.0;
FDragMode := dmNone;
end;
destructor TPanZoomBar.Destroy;
begin
FBmp.Free;
inherited;
end;
procedure TPanZoomBar.SetTheme(const T: TAppTheme);
begin
FTheme := T;
if Assigned(FPb) then FPb.Invalidate;
end;
procedure TPanZoomBar.SetState(AZoom, APan: Double);
var NewZoom, NewPan: Double;
begin
NewZoom := EnsureRange(AZoom, 0.0, 1.0);
NewPan := EnsureRange(APan, 0.0, 1.0);
if (Abs(NewZoom - FZoom) < 1e-4) and (Abs(NewPan - FPan) < 1e-4) then Exit;
FZoom := NewZoom;
FPan := NewPan;
if Assigned(FPb) then FPb.Invalidate;
end;
procedure TPanZoomBar.SetFreq(ACenterFreq, ASpanHz: Double);
begin
if (Abs(ACenterFreq - FCenterFreq) < 0.5) and (Abs(ASpanHz - FSpanHz) < 1.0) then Exit;
FCenterFreq := ACenterFreq;
FSpanHz := ASpanHz;
if Assigned(FPb) then FPb.Invalidate;
end;
function TPanZoomBar.VisFraction: Double;
// Та же формула, что и span-clip в WDSPEngine: видимая доля = width/bins.
begin
Result := EnsureRange(1.0 - 0.99 * Log10(9.0 * FZoom + 1.0), MIN_VIS, 1.0);
end;
procedure TPanZoomBar.CurWindow(out Lo, Hi: Double);
var Vis: Double;
begin
Vis := VisFraction;
Lo := EnsureRange(FPan, 0.0, 1.0) * (1.0 - Vis);
Hi := Lo + Vis;
end;
function TPanZoomBar.EdgePx(Wd: Integer): Integer;
// Зона захвата края — узкая, но не съедает всё узкое окно (оставляем тело).
begin
Result := Max(3, Min(8, Wd div 3));
end;
function TPanZoomBar.ThumbGeom(W: Integer; out L, Wd: Integer): Boolean;
var Lo, Hi: Double;
begin
Result := W > 0;
if not Result then begin L := 0; Wd := 0; Exit; end;
CurWindow(Lo, Hi);
Wd := Max(MIN_THUMB_PX, Round((Hi - Lo) * W));
if Wd > W then Wd := W;
L := Round(Lo * W);
if L < 0 then L := 0;
if L + Wd > W then L := W - Wd;
end;
procedure TPanZoomBar.EmitWindow(NewLo, NewHi: Double);
var Vis, Z, P: Double;
begin
if NewLo < 0.0 then NewLo := 0.0;
if NewHi > 1.0 then NewHi := 1.0;
if NewHi - NewLo < MIN_VIS then NewHi := NewLo + MIN_VIS;
if NewHi > 1.0 then begin NewHi := 1.0; NewLo := NewHi - MIN_VIS; end;
Vis := NewHi - NewLo;
Z := VisToZoom(Vis);
if (1.0 - Vis) > 1e-6 then P := NewLo / (1.0 - Vis) else P := 0.5;
P := EnsureRange(P, 0.0, 1.0);
// Локально обновляем сразу — драг плавный даже до round-trip контроллера.
FZoom := Z; FPan := P;
if Assigned(FPb) then FPb.Invalidate;
if Assigned(FOnZoomPan) then FOnZoomPan(Z, P);
end;
procedure TPanZoomBar.DrawRulerInto(W, H: Integer);
// Цифры частот + деления на всю ширину (= полный диапазон).
var
C: TCanvas;
i, X, TickMaj, TickMin, LabelY, NDiv, LabelEvery, TW: Integer;
FreqStart, FreqHz, PixPerDiv: Double;
Lbl: string;
begin
C := FBmp.Canvas;
C.Brush.Style := bsSolid;
C.Brush.Color := FTheme.SliderBG;
C.FillRect(Rect(0, 0, W, H));
C.Font.Name := 'Courier New';
C.Font.Style := [];
C.Font.Height := -Max(7, Round(H * 0.40));
C.Brush.Style := bsClear;
TickMaj := Max(3, Round(H * 0.28));
TickMin := Max(2, Round(H * 0.16));
LabelY := TickMaj + ((H - TickMaj) - C.TextHeight('0')) div 2;
if LabelY < TickMaj then LabelY := TickMaj;
NDiv := 8;
if FSpanHz <= 0 then Exit;
FreqStart := FCenterFreq - FSpanHz / 2;
PixPerDiv := W / NDiv;
if PixPerDiv >= (C.TextWidth('000.000') + 8) then LabelEvery := 1
else if PixPerDiv * 2 >= (C.TextWidth('000.000') + 8) then LabelEvery := 2
else LabelEvery := 4;
for i := 0 to NDiv do
begin
X := Round(i * PixPerDiv);
if X >= W then X := W - 1;
C.Pen.Color := FTheme.RulerBorder;
if (i mod LabelEvery) = 0 then
begin
C.MoveTo(X, 0); C.LineTo(X, TickMaj);
FreqHz := FreqStart + i * FSpanHz / NDiv;
Lbl := FreqLabel(FreqHz);
TW := C.TextWidth(Lbl);
C.Font.Color := FTheme.RulerText;
C.TextOut(EnsureRange(X - TW div 2, 0, W - TW), LabelY, Lbl);
end
else
begin
C.MoveTo(X, 0); C.LineTo(X, TickMin);
end;
end;
C.Brush.Style := bsSolid;
end;
procedure TPanZoomBar.BlendThumb(L, Wd, W, H: Integer);
// Полупрозрачное «стекло» с объёмной фаской поверх линейки.
var
Intf: TLazIntfImage;
x, y, top, bot: Integer;
c: TFPColor;
tR, tG, tB, bgR, bgG, bgB, nR, nG, nB: Integer;
A, RowT, Gloss: Double;
Active: Boolean;
begin
if (Wd <= 0) or (W <= 0) or (H <= 2) then Exit;
Active := FDragMode <> dmNone;
if Active then
begin
tR := GetRValue(ColorToRGB(FTheme.SliderThumbDrag));
tG := GetGValue(ColorToRGB(FTheme.SliderThumbDrag));
tB := GetBValue(ColorToRGB(FTheme.SliderThumbDrag));
end
else
begin
tR := GetRValue(ColorToRGB(FTheme.SliderThumbNorm));
tG := GetGValue(ColorToRGB(FTheme.SliderThumbNorm));
tB := GetBValue(ColorToRGB(FTheme.SliderThumbNorm));
end;
top := 1;
bot := H - 2;
Intf := FBmp.CreateIntfImage;
try
for y := top to bot do
begin
if (bot - top) > 0 then RowT := (y - top) / (bot - top) else RowT := 0.0;
Gloss := 1.18 - 0.42 * RowT; // объём: ярче вверху, темнее внизу
A := 0.42 + 0.10 * (1.0 - RowT);
for x := L to L + Wd - 1 do
begin
if (x < 0) or (x >= W) then Continue;
c := Intf.Colors[x, y];
bgR := c.red shr 8; bgG := c.green shr 8; bgB := c.blue shr 8;
nR := MixB(bgR, EnsureRange(Round(tR * Gloss), 0, 255), A);
nG := MixB(bgG, EnsureRange(Round(tG * Gloss), 0, 255), A);
nB := MixB(bgB, EnsureRange(Round(tB * Gloss), 0, 255), A);
c.red := nR * 257; c.green := nG * 257; c.blue := nB * 257; c.alpha := $FFFF;
Intf.Colors[x, y] := c;
end;
end;
FBmp.LoadFromIntfImage(Intf);
finally
Intf.Free;
end;
// Объёмная фаска + маркеры краёв (две вертикальные риски — «ручки» ресайза).
with FBmp.Canvas do
begin
Pen.Width := 1; Brush.Style := bsClear;
Pen.Color := FTheme.SliderThumbBdr;
Rectangle(L, top, L + Wd, bot + 1);
Pen.Color := RGBToColor(EnsureRange(tR + 70, 0, 255),
EnsureRange(tG + 70, 0, 255),
EnsureRange(tB + 70, 0, 255));
MoveTo(L + 1, top + 1); LineTo(L + Wd - 1, top + 1);
MoveTo(L + 1, top + 1); LineTo(L + 1, bot);
Pen.Color := RGBToColor(EnsureRange(tR - 60, 0, 255),
EnsureRange(tG - 60, 0, 255),
EnsureRange(tB - 60, 0, 255));
MoveTo(L + 1, bot); LineTo(L + Wd - 1, bot);
MoveTo(L + Wd - 1, top + 1); LineTo(L + Wd - 1, bot + 1);
// «ручки» захвата по краям (видны, когда окно достаточно широкое)
if Wd >= 3 * MIN_THUMB_PX then
begin
Pen.Color := FTheme.SliderThumbBdr;
MoveTo(L + 3, top + 2); LineTo(L + 3, bot - 1);
MoveTo(L + Wd - 4, top + 2); LineTo(L + Wd - 4, bot - 1);
end;
Brush.Style := bsSolid;
end;
end;
procedure TPanZoomBar.Paint(Sender: TObject);
var W, H, L, Wd: Integer;
begin
if FPb = nil then Exit;
W := FPb.Width; H := FPb.Height;
if (W <= 0) or (H <= 0) then Exit;
if (FBmp.Width <> W) or (FBmp.Height <> H) then FBmp.SetSize(W, H);
DrawRulerInto(W, H);
if ThumbGeom(W, L, Wd) then BlendThumb(L, Wd, W, H);
FBmp.Canvas.Brush.Style := bsClear;
FBmp.Canvas.Pen.Color := FTheme.RulerBorder;
FBmp.Canvas.Rectangle(0, 0, W, H);
FBmp.Canvas.Brush.Style := bsSolid;
FPb.Canvas.Draw(0, 0, FBmp);
end;
procedure TPanZoomBar.HandleMouseDown(Button: TMouseButton; X, Y: Integer);
var W, L, Wd, Edge: Integer; Lo, Hi, MouseFrac: Double;
begin
if (FPb = nil) or (Button <> mbLeft) then Exit;
W := FPb.Width;
if not ThumbGeom(W, L, Wd) then Exit;
CurWindow(Lo, Hi);
FDragLo := Lo; FDragHi := Hi;
Edge := EdgePx(Wd);
MouseFrac := X / W;
if (X >= L) and (X < L + Edge) then
FDragMode := dmLeft
else if (X > L + Wd - Edge) and (X <= L + Wd) then
FDragMode := dmRight
else if (X >= L) and (X <= L + Wd) then
begin
FDragMode := dmBody;
FDragGrab := MouseFrac - Lo; // тянем тело за точку захвата
end
else
begin
// Клик мимо окна → центрируем окно на курсоре, дальше тянем телом.
FDragMode := dmBody;
FDragGrab := (Hi - Lo) / 2.0;
EmitWindow(MouseFrac - FDragGrab, MouseFrac - FDragGrab + (Hi - Lo));
end;
if Assigned(FPb) then FPb.Invalidate;
end;
procedure TPanZoomBar.HandleMouseMove(X, Y: Integer);
var W, L, Wd, Edge: Integer; MouseFrac, Width: Double;
begin
if FPb = nil then Exit;
W := FPb.Width;
if W <= 0 then Exit;
MouseFrac := X / W;
if FDragMode = dmNone then
begin
// Hover: курсор-подсказка над краями (зум) / телом (пан).
if ThumbGeom(W, L, Wd) then
begin
Edge := EdgePx(Wd);
if ((X >= L) and (X < L + Edge)) or ((X > L + Wd - Edge) and (X <= L + Wd)) then
FPb.Cursor := crSizeWE
else if (X >= L) and (X <= L + Wd) then
FPb.Cursor := crSizeAll
else
FPb.Cursor := crDefault;
end;
Exit;
end;
case FDragMode of
dmLeft: EmitWindow(Min(MouseFrac, FDragHi - MIN_VIS), FDragHi);
dmRight: EmitWindow(FDragLo, Max(MouseFrac, FDragLo + MIN_VIS));
dmBody:
begin
Width := FDragHi - FDragLo; // ширина фиксирована → зум не меняется
EmitWindow(EnsureRange(MouseFrac - FDragGrab, 0.0, 1.0 - Width),
EnsureRange(MouseFrac - FDragGrab, 0.0, 1.0 - Width) + Width);
end;
end;
end;
procedure TPanZoomBar.HandleMouseUp;
begin
if FDragMode = dmNone then Exit;
FDragMode := dmNone;
if Assigned(FPb) then FPb.Invalidate;
end;
procedure TPanZoomBar.HandleDblClick;
begin
// Сброс к полному диапазону.
FDragMode := dmNone;
EmitWindow(0.0, 1.0);
end;
end.
+140 -1
View File
@@ -66,7 +66,7 @@ type
rfRxGain, // Pluto: hw-gain (auto/manual dB)
rfVolume, rfMute, rfDrive, rfAtten,
rfBand, rfXvtr, rfBandRestore, // rfBandRestore: явная смена диапазона (сброс Wf-avg в UI)
rfCenterFreq, rfSpan, rfSampleRate,
rfCenterFreq, rfSpan, rfSampleRate, rfZoom,
rfCTun, rfDuplex, rfRxMuteOnTx, // rfRxMuteOnTx: QO-100 self-monitor toggle
rfRunning, rfConnected, rfTransmitting, rfTuning,
rfNR, rfNB, rfSNB, rfANF,
@@ -138,6 +138,7 @@ type
procedure ApplyModeDefaults; // дефолтный фильтр/девиация под текущий FMode
procedure SanitizeFilterForMode; // FM: индекс фильтра 0/1 + BW/девиация (state)
function ActiveVfoHz: Double; // частота активного VFO (A или B)
function AnchoredPan(AZoom: Double): Double; // пан, центрирующий окно на маркере VFO
procedure ApplyTuneCore(VfoHz: Double); // CTUN shift/center/scroll для актив. VFO
public
// ============================================================
@@ -181,6 +182,8 @@ type
FCenterFreq: Double;
FSpanHz: Double;
FSampleRate: Integer;
FZoomFactor: Double; // зум спектра/водопада 0.05..1.0 (1.0 = без зума)
FPanSlider: Double; // положение окна зума 0..1 (0.5 = центр)
FCurrentBand: Integer;
FCurrentXvtr: Integer;
@@ -480,6 +483,18 @@ type
procedure SetSpan(Hz: Integer);
procedure SetSampleRate(Hz: Integer);
// ---- Zoom/pan спектра и водопада ----
// GetViewWindow — единая истина «видимого окна» (центр+span в Гц) для всех
// оверлеев и маппинга мыши: при зуме отдаёт окно из WDSP-анализатора, иначе
// базовые FCenterFreq/FSpanHz.
procedure GetViewWindow(out ACenter: Double; out ASpan: Double);
procedure ApplyZoom(AZoom, APan: Double); // задать зум+пан, сохранить в band, Changed(rfZoom)
procedure ZoomIn; // шаг увеличения (якорь — маркер активного VFO)
procedure ZoomOut; // шаг уменьшения
procedure ZoomDefault; // сброс в 1.0 (весь span)
function ZoomFactor: Double;
function PanSlider: Double;
// QO-100 beacon lock — стабилизация дрейфа LNB по опорному маяку.
// QO-100 beacon lock (= декодер + трим LOError; декодер = эталон частоты).
procedure SetBeaconLock(On_: Boolean); // вкл/выкл лок (запускает декодер)
@@ -610,6 +625,7 @@ begin
FRxMuteOnTx := True; // QO-100 self-monitor выкл по умолчанию (RX заглушён на TX)
FVolume := 70; FDrivePercent := 50; FDriveLevel := 0; FAtten := 0;
FCenterFreq := FVfoA; FSpanHz := 192000; FSampleRate := 192000;
FZoomFactor := 0.0; FPanSlider := 0.5; // 0.0 = весь span (без зума)
FCurrentBand := 5; FCurrentXvtr := -1;
FSpectrumBufCount := 1024; FWaterfallBufCount := 1024;
FPAMaxPower := 100.0;
@@ -1452,6 +1468,8 @@ begin
Result.CTun := FCTun;
Result.CenterHz := FCenterFreq; // DDC-центр → восстановим offset CTun при загрузке
Result.SpanHz := FSpanHz;
Result.ZoomFactor := FZoomFactor;
Result.PanSlider := FPanSlider;
Result.FMSQOn := FFMSQOn;
Result.FMSQLevel := FFMSQLevel;
Result.CTCSSOn := FFMCTCSSOn;
@@ -1667,6 +1685,12 @@ begin
ApplyBandDSP(B);
// --- Zoom/pan диапазона: восстанавливаем и переармируем анализатор ---
FZoomFactor := EnsureRange(B.ZoomFactor, 0.0, 1.0);
FPanSlider := EnsureRange(B.PanSlider, 0.0, 1.0);
if Assigned(FDSPEngine) then FDSPEngine.SetZoomPan(FZoomFactor, FPanSlider);
Changed(rfZoom);
// --- VFO / drive (только RestoreBand — bandstack-путь VFO не трогает) ---
// При CTun восстанавливаем сохранённый DDC-центр, чтобы VFO остался на своём
// месте в полосе (а не прыгал в центр). Берём только если offset влезает в
@@ -2327,6 +2351,121 @@ end;
procedure TRadioController.SetSpan(Hz: Integer);
begin FSpanHz := Hz; Changed(rfSpan); { TODO wiring } end;
// ════════════════════════════════════════════════════════════════════════════
// Zoom / pan спектра и водопада
// ════════════════════════════════════════════════════════════════════════════
function TRadioController.ZoomFactor: Double;
begin Result := FZoomFactor; end;
function TRadioController.PanSlider: Double;
begin Result := FPanSlider; end;
procedure TRadioController.GetViewWindow(out ACenter: Double; out ASpan: Double);
// Видимое окно (центр+span, Гц) с учётом зума. Формула совпадает с расчётом
// span-clip в WDSPEngine.ApplyRXAnalyzerSettings (bw=FSpanHz, clp=0), поэтому
// оверлеи точно ложатся на пиксели. Без зума → базовые центр/span.
var
FVis, OneMinusFVis, VisSpan, LowEdgeOff, CenterOff: Double;
begin
if (FZoomFactor <= 0.0) or (FSpanHz <= 0.0) then
begin
ACenter := FCenterFreq;
ASpan := FSpanHz;
Exit;
end;
OneMinusFVis := 0.99 * Log10(9.0 * EnsureRange(FZoomFactor, 0.0, 1.0) + 1.0);
FVis := 1.0 - OneMinusFVis; // доля span после зума
if FVis < 1.0 / 100.0 then FVis := 1.0 / 100.0; // ZOOM_LIMIT
VisSpan := FVis * FSpanHz;
LowEdgeOff := -FSpanHz / 2.0 + EnsureRange(FPanSlider, 0.0, 1.0) * OneMinusFVis * FSpanHz;
CenterOff := LowEdgeOff + VisSpan / 2.0;
ACenter := FCenterFreq + CenterOff;
ASpan := VisSpan;
end;
function TRadioController.AnchoredPan(AZoom: Double): Double;
// Положение пана, при котором видимое окно центрировано на маркере активного
// VFO. Клампится в [0..1], чтобы окно не вылезало за span (у краёв маркер
// перестаёт быть строго по центру — окно упирается в край).
var OneMinusFVis, U: Double;
begin
if (AZoom <= 0.0) or (FSpanHz <= 0.0) then Exit(0.5);
OneMinusFVis := 0.99 * Log10(9.0 * EnsureRange(AZoom, 0.0, 1.0) + 1.0);
if OneMinusFVis <= 1e-9 then Exit(0.5);
U := (ActiveVfoHz - FCenterFreq) / FSpanHz; // нормализованный сдвиг маркера
Result := EnsureRange(0.5 + U / OneMinusFVis, 0.0, 1.0);
end;
procedure TRadioController.ApplyZoom(AZoom, APan: Double);
begin
FZoomFactor := EnsureRange(AZoom, 0.0, 1.0);
FPanSlider := EnsureRange(APan, 0.0, 1.0);
if Assigned(FDSPEngine) then
FDSPEngine.SetZoomPan(FZoomFactor, FPanSlider);
if (FCurrentBand >= 0) and (FCurrentBand < CFG_BAND_COUNT) then
begin
FBandCache[FCurrentBand].ZoomFactor := FZoomFactor;
FBandCache[FCurrentBand].PanSlider := FPanSlider;
end;
Changed(rfZoom);
end;
// Лестница кратностей увеличения: мелкий шаг в начале (1.0→1.2→1.45…), крупнее
// к концу. Кнопки [+]/[-] шагают по ней; ZoomFactor (0..1) хранит соответствующий
// z. Перевод кратность↔z — инверсия формулы анализатора (см. ApplyRXAnalyzerSettings).
const
ZOOM_MAG_LADDER: array[0..16] of Double =
(1.0, 1.1, 1.15, 1.2, 1.45, 1.75, 2.1, 2.5, 3.0, 3.7, 4.7, 6.2, 8.5, 12.0, 20.0, 40.0, 100.0);
function ZoomMagToZ(M: Double): Double;
begin
if M <= 1.0 then Exit(0.0);
// M = 1/(1-0.99*log10(9z+1)) ⇒ z = (10^((1-1/M)/0.99) - 1)/9
Result := EnsureRange((Power(10.0, (1.0 - 1.0 / M) / 0.99) - 1.0) / 9.0, 0.0, 1.0);
end;
function ZoomZToMag(Z: Double): Double;
begin
Result := 1.0 / (1.0 - 0.99 * Log10(9.0 * EnsureRange(Z, 0.0, 1.0) + 1.0));
end;
procedure TRadioController.ZoomIn;
var CurMag, Z: Double; i: Integer;
begin
CurMag := ZoomZToMag(FZoomFactor);
for i := 0 to High(ZOOM_MAG_LADDER) do
if ZOOM_MAG_LADDER[i] > CurMag * 1.001 then
begin
Z := ZoomMagToZ(ZOOM_MAG_LADDER[i]);
ApplyZoom(Z, AnchoredPan(Z));
Exit;
end;
end;
procedure TRadioController.ZoomOut;
var CurMag, Z: Double; i: Integer;
begin
CurMag := ZoomZToMag(FZoomFactor);
for i := High(ZOOM_MAG_LADDER) downto 0 do
if ZOOM_MAG_LADDER[i] < CurMag / 1.001 then
begin
if ZOOM_MAG_LADDER[i] <= 1.0 then ApplyZoom(0.0, 0.5)
else
begin
Z := ZoomMagToZ(ZOOM_MAG_LADDER[i]);
ApplyZoom(Z, AnchoredPan(Z));
end;
Exit;
end;
ApplyZoom(0.0, 0.5);
end;
procedure TRadioController.ZoomDefault;
begin
ApplyZoom(0.0, 0.5);
end;
procedure TRadioController.SetSampleRate(Hz: Integer);
// Смена частоты дискретизации DDC. В этом приложении span ≡ sample rate.
// Несёт: пересоздание WDSP-канала (Close+FlushQueue+Open), новый rate в
+7
View File
@@ -67,6 +67,8 @@ type
FMStepIdx: Integer; // 0..3 (6.25/12.5/20/25 kHz)
FMRptDir: Integer; // RPT_NONE/RPT_MINUS/RPT_PLUS
FMRptOffsetHz: Double; // Hz, 0 = use band default
ZoomFactor: Double; // зум спектра/водопада 0.0..1.0 (0.0 = без зума)
PanSlider: Double; // положение окна зума 0..1 (0.5 = центр)
end;
// TX-настройки per-device. Хранятся в JSON-секции "tx" под MAC,
@@ -407,6 +409,8 @@ begin
B.FMStepIdx := 3; // 25 kHz
B.FMRptDir := 0;
B.FMRptOffsetHz := 0.0;
B.ZoomFactor := 0.0;
B.PanSlider := 0.5;
end;
class procedure TSettingsManager.DefaultGlobal(out G: TGlobalSettings);
@@ -823,6 +827,7 @@ begin
JW(O,'ctcss_on',B.CTCSSOn); JW(O,'ctcss_idx',B.CTCSSToneIdx);
JW(O,'fmstep_on',B.FMStepOn); JW(O,'fmstep_idx',B.FMStepIdx);
JW(O,'fmrpt_dir',B.FMRptDir); JW(O,'fmrpt_offset',B.FMRptOffsetHz);
JW(O,'zoom_factor',B.ZoomFactor); JW(O,'pan_slider',B.PanSlider);
end;
function TSettingsManager.LoadBand(const MAC: array of Byte; BandIdx: Integer;
@@ -852,6 +857,8 @@ begin
B.FMStepIdx := EnsureRange(JI(O,'fmstep_idx', B.FMStepIdx), 0, 3);
B.FMRptDir := EnsureRange(JI(O,'fmrpt_dir', B.FMRptDir), 0, 2);
B.FMRptOffsetHz := JD(O,'fmrpt_offset', B.FMRptOffsetHz);
B.ZoomFactor := EnsureRange(JD(O,'zoom_factor', B.ZoomFactor), 0.0, 1.0);
B.PanSlider := EnsureRange(JD(O,'pan_slider', B.PanSlider), 0.0, 1.0);
end;
procedure TSettingsManager.SaveWindowBounds(L, T, W, H, DPI: Integer; Maximized: Boolean);
+55 -2
View File
@@ -204,6 +204,16 @@ type
FWfAvgMode: Integer;
FWfAvgTimeMS: Double;
// Zoom/pan анализатора (span-clip в SetAnalyzer). Конвенция WDSP/Thetis:
// FZoomFactor 0.0..1.0, где 0.0 = весь span (без зума), 1.0 = максимум.
// FPanSlider 0..1 (положение окна по span). FViewLowHz/FViewHighHz —
// границы видимого окна в Гц относительно DDC-центра (low отрицательный),
// пересчитываются в ApplyRXAnalyzerSettings.
FZoomFactor: Double;
FPanSlider: Double;
FViewLowHz: Double;
FViewHighHz: Double;
FOnAudio: TOnAudioReady;
FOnSpectrum: TOnSpectrumReady;
FOnWaterfall: TOnWaterfallReady;
@@ -431,6 +441,14 @@ type
// S-meter
function GetSMeterDBm: Double;
// Zoom/pan: задать коэффициент зума и положение пана и переармировать
// RX-анализатор. View* — границы видимого окна (Гц отн. DDC-центра).
procedure SetZoomPan(AZoom, APan: Double);
property ZoomFactor: Double read FZoomFactor;
property PanSlider: Double read FPanSlider;
property ViewLowHz: Double read FViewLowHz;
property ViewHighHz: Double read FViewHighHz;
property Initialized: Boolean read FInitialized;
property SampleRate: Integer read FSampleRate;
property TXSampleRate: Integer read FTXSampleRate;
@@ -768,6 +786,10 @@ begin
FWfDetector := 0; // Peak
FWfAvgMode := 3; // Log Recursive
FWfAvgTimeMS := 120.0;
FZoomFactor := 0.0; // 0.0 = весь span, без зума
FPanSlider := 0.5; // окно по центру
FViewLowHz := -FSampleRate / 2.0;
FViewHighHz := FSampleRate / 2.0;
SetLength(FRXIn, FBufSize * 2); // in_size пар @ FSampleRate (4096*2)
SetLength(FRXOut, FAudioBufSize * 2); // out_size пар @ FAudioRate (1024*2)
@@ -906,7 +928,11 @@ procedure TWDSPEngine.ApplyRXAnalyzerSettings;
var
AnalyzerFFT, AvgCount, WfAvgCount, MaxW, Overlap, DisplayPixels: Integer;
Backmult, WfBackmult: Double;
const KEEP_TIME_SEC = 0.10;
Bins, SpanClipL, SpanClipH, ZWidth: Integer;
BinWidth, BW, ZoomSlider: Double;
const
KEEP_TIME_SEC = 0.10;
ZOOM_LIMIT = 100.0; // максимальная кратность зума
begin
AnalyzerFFT := CalcDisplayFFTSize;
FDispPos := 0;
@@ -919,8 +945,25 @@ begin
AverageTimeToParams(FSpecAvgMode, FSpecAvgTimeMS, AvgCount, Backmult);
AverageTimeToParams(FWfAvgMode, FWfAvgTimeMS, WfAvgCount, WfBackmult);
// Zoom/pan: считаем span-clip для SetAnalyzer (fscLin/fscHin).
// clp=0 — оставляем полный span FFT, чтобы при зуме 0.0 видимая полоса была
// ровно == FSampleRate (остальной код это предполагает). Зум делает только
// span-clip: FZoomFactor=0.0 → ZWidth=Bins → SpanClipL=SpanClipH=0.
BinWidth := FSampleRate / AnalyzerFFT;
Bins := AnalyzerFFT;
BW := Bins * BinWidth;
ZoomSlider := Log10(9.0 * EnsureRange(FZoomFactor, 0.0, 1.0) + 1.0);
ZWidth := Round(Bins * (1.0 - (1.0 - 1.0 / ZOOM_LIMIT) * ZoomSlider));
ZWidth := EnsureRange(ZWidth, 1, Bins);
SpanClipL := Floor(EnsureRange(FPanSlider, 0.0, 1.0) * (Bins - ZWidth));
SpanClipH := Bins - ZWidth - SpanClipL;
// Видимое окно в Гц относительно DDC-центра (low отрицательный). Поправка
// BinWidth/2 — у комплексного FFT на одну отрицательную ячейку больше.
FViewLowHz := -(0.5 * BW - SpanClipL * BinWidth + BinWidth / 2.0);
FViewHighHz := (0.5 * BW - SpanClipH * BinWidth - BinWidth / 2.0);
SetAnalyzer(RX_DISP_ID, 2, 1, 1, @FFlp[0], AnalyzerFFT, DISPLAY_BLOCK_SIZE,
FWindowType, 14.0, Overlap, 0, 0.0, 0.0, DisplayPixels,
FWindowType, 14.0, Overlap, 0, SpanClipL, SpanClipH, DisplayPixels,
1, 0, 0.0, 0.0, MaxW);
SetDisplayDetectorMode(RX_DISP_ID, 0, FSpecDetector);
SetDisplayAverageMode (RX_DISP_ID, 0, FSpecAvgMode);
@@ -978,6 +1021,16 @@ begin
ApplyTXAnalyzerSettings;
end;
procedure TWDSPEngine.SetZoomPan(AZoom, APan: Double);
// Задаёт зум/пан и переармирует RX-анализатор (SetAnalyzer внутри WDSP
// защищён собственной критической секцией, дисплей-поток не мешает).
begin
FZoomFactor := EnsureRange(AZoom, 0.0, 1.0);
FPanSlider := EnsureRange(APan, 0.0, 1.0);
if FAnalyzerOpen then
ApplyRXAnalyzerSettings;
end;
procedure TWDSPEngine.CloseAnalyzer;
begin
if not FAnalyzerOpen then Exit;
+12 -7
View File
@@ -369,13 +369,14 @@ procedure TWebAdapter.OnBeaconSeed(Frac: Double);
begin FSyncFreq := Frac; FController.Invoke(@SyncBeaconSeed); end;
procedure TWebAdapter.SyncBeaconSeed;
var Hz: Double;
var Hz, VC, VS: Double;
begin
// Frac (доля 0..1 по ширине спектра) → абс. display-Hz от ЖИВЫХ центра/спана
// контроллера (зеркалит десктоп PixelToFreq(X,W,FCenterFreq,FSpanHz)). Так
// наведение точно, даже когда LO ретюнится локом и клиентский center отстаёт.
// Frac (доля 0..1 по ширине спектра) → абс. display-Hz от ЖИВОГО видимого окна
// контроллера (зеркалит десктоп PixelToFreq с учётом зума). Так наведение
// точно, даже когда LO ретюнится локом и клиентский center отстаёт.
if (FSyncFreq < 0) or (FSyncFreq > 1) then Exit;
Hz := FController.FCenterFreq + (FSyncFreq - 0.5) * FController.FSpanHz;
FController.GetViewWindow(VC, VS);
Hz := VC + (FSyncFreq - 0.5) * VS;
FController.BeaconSeedAtHz(Hz);
end;
@@ -466,7 +467,11 @@ procedure TWebAdapter.PushState;
var
StatusText, BoardText, IPText, SupplyText: string;
PLLText, RXText, TXText, SeqText: string;
ViewCenter, ViewSpan: Double;
begin
// Видимое окно (центр+span) с учётом зума: пиксели спектра уже зумлены
// анализатором, web-метки/маппинг должны соответствовать.
FController.GetViewWindow(ViewCenter, ViewSpan);
if FController.FNetwork.Connected then
begin
if FController.FRunning then StatusText := 'Running'
@@ -558,13 +563,13 @@ begin
FController.FWaterfallBuf,
FController.FLastSMeter,
FController.FVfoA, FController.FMode, FController.FFilterBW, FController.FAGCMode, FController.FAGCTop,
FController.FSpanHz, FController.FVolume,
ViewSpan, FController.FVolume,
FController.FWfAGCEnabled, FController.FWfNFEnabled,
FController.FCurrentBand,
FController.FRunning and FController.FNetwork.Connected,
FController.FRunning, FController.FMuted, FController.FCTun,
FController.FNRMode, FController.FNBMode, FController.FSNB, FController.FANF,
FController.FCenterFreq, FController.FFilter,
ViewCenter, FController.FFilter,
FController.FVfoB, FController.FActiveVfo,
FController.FTransmitting, FController.FDrivePercent,
FController.FAtten, FController.FTuning, FController.FDisplayDuplex,