feat(beacon): лупа наведения на маяк QO-100 в окне Beacon

На спектрограмме 576к центральный маяк — пара пикселей, ткнуть в него для
наведения декодера почти нельзя. В окне Beacon появилась карточка BEACON
TUNING: узкий кусок спектра вокруг маяка в высоком разрешении + мини-водопад,
клик по нему = BeaconSeedAtHz, ровно как клик по главному спектру. Наведение
с главной спектрограммы не тронуто.

Увеличение делает сам WDSP, а не растяжка бинов главного дисплея: второй
analyzer BCN_DISP_ID=4 на том же IQ главного тракта, со своей FFT и своим
span-clip. Клипы fscLin/fscHin считаются прямо из желаемых границ окна в
герцах, а не из зум-слайдера — анализатор умеет ассиметричный клип, поэтому
окно ставится в любое место полосы захвата, а не только вокруг центра DDC.

- WDSPEngine: SetBeaconZoom + GetBeaconZoom{Spectrum,Waterfall}. Analyzer живёт
  только пока окно маяка открыто; весь доступ к disp'у под FBcnLock (SetAnalyzer
  из UI, Spectrum0 из DSP-потока, GetPixels из display-потока). Кадр (1024
  точки) фиксирован по размеру — ресайз окна не переармирует analyzer и не
  морозит спектр на наполнение FFT-окна.
- Span-clip НЕ удешевляет FFT — она по всей полосе захвата. Отсюда потолок
  262144 и своя пониженная частота кадров BCN_ZOOM_FPS=15 через ovrlp: на 60 fps
  это было бы ядро под нагрузкой ради картинки, в которой ничего не меняется.
  Переарм по смене sample rate, детектор/усреднение — без переарма.
- RadioController: SetBeaconZoomWindow / GetBeaconZoom* / GetCaptureWindow /
  BeaconZoomBinHz. Наружу — абсолютные display-Гц, те же, в которых живут
  BeaconSeedAtHz и маркеры главного спектра.
- BeaconScopeForm: клик = наведение, колесо = зум вокруг курсора, drag = пан,
  двойной клик = центрировать, кнопки -/+/RESET/FOLLOW (FOLLOW подтягивает окно
  только когда маркер ушёл за край, а не возит картинку постоянно). Маркеры —
  теми же цветами и с той же семантикой, что DrawBeaconMarkersRaw/GL: опорная
  пунктиром, центроид, три линии полосы приёма ±450 Гц, без полупрозрачной
  заливки. Водопад — TWaterfallView с палитрой/гаммой из контроллера.
- Рендер по конвенции проекта: кадр лупы собирается в офскрин только по
  FZoomDirty (новый кадр анализатора, сдвиг маркеров/окна, ресайз, тема), Paint
  = блит + курсор. Констелляция с метриками переведена туда же: статичная
  обвязка (карточки, оси, рамки и подписи плиток) кэшируется в FScopeChrome и
  пересобирается лишь на ресайз/смену темы — раньше десяток RoundRect и TextOut
  рисовались заново 25 раз в секунду прямо в обработчике Paint.

Лупа рисуется на CPU: GL-путь в проекте есть только у главного панафолла и
только под ключом, все прочие панели — обычные TPaintBox.

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

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-14 11:42:25 +03:00
co-authored by Claude Opus 5
parent b0a93ddb14
commit 705da75c26
3 changed files with 1523 additions and 144 deletions
+72
View File
@@ -988,6 +988,20 @@ type
function BeaconDecodeInvert: Boolean;
function GetBeaconScope(out S: TBeaconScope): Boolean;
function GetBeaconFrame(out Frame: TBeaconFrame; out RSErrors: Integer): Boolean;
// --- Лупа маяка (окно наведения в BeaconScopeForm) -------------------
// Отдельный WDSP-анализатор на IQ главного тракта, вырезающий узкое окно
// вокруг маяка: на общей спектрограмме 576к маяк — пара пикселей, точно
// ткнуть в него нельзя. Все частоты — АБСОЛЮТНЫЕ display-Гц, как у
// BeaconSeedAtHz. Зовётся с таймера окна маяка; окно закрыто → Enabled=False
// и анализатор уничтожается.
procedure SetBeaconZoomWindow(AEnabled: Boolean; ACenterHz, ASpanHz: Double);
function GetBeaconZoomSpectrum(var Pixels: array of Single;
out Count: Integer; out LowHz, HighHz: Double): Boolean;
function GetBeaconZoomWaterfall(var Pixels: array of Single;
out Count: Integer): Boolean;
// Полоса захвата (display-Гц): за её пределы лупу не увести.
procedure GetCaptureWindow(out ALowHz, AHighHz: Double);
function BeaconZoomBinHz: Double; // разрешение лупы, Гц/бин (для подписи)
function GetDMRStatus(out S: TDMRStatus): Boolean;
function GetSliceDMRStatus(SliceId: Integer; out S: TDMRStatus): Boolean;
@@ -3486,6 +3500,64 @@ begin
Result := Assigned(FBeaconDec) and FBeaconDec.GetLastFrame(Frame, RSErrors);
end;
// ---------------------------------------------------------------------------
// Лупа маяка: окно наведения высокого разрешения (BeaconScopeForm)
// ---------------------------------------------------------------------------
// Движок работает в Гц относительно центра DDC, наружу отдаём абсолютные
// display-Гц — в них живут BeaconSeedAtHz, маркеры и клики пользователя.
procedure TRadioController.GetCaptureWindow(out ALowHz, AHighHz: Double);
var Half: Double;
begin
Half := FSpanHz / 2.0;
if Half <= 0 then Half := FSampleRate / 2.0;
ALowHz := FCenterFreq - Half;
AHighHz := FCenterFreq + Half;
end;
procedure TRadioController.SetBeaconZoomWindow(AEnabled: Boolean;
ACenterHz, ASpanHz: Double);
begin
if not Assigned(FDSPEngine) then Exit;
if AEnabled and (ACenterHz > 0) then
FDSPEngine.SetBeaconZoom(True, ACenterHz - FCenterFreq, ASpanHz)
else
FDSPEngine.SetBeaconZoom(False, 0, 0);
end;
function TRadioController.GetBeaconZoomSpectrum(var Pixels: array of Single;
out Count: Integer; out LowHz, HighHz: Double): Boolean;
begin
Count := 0;
LowHz := 0;
HighHz := 0;
Result := False;
if not Assigned(FDSPEngine) then Exit;
Result := FDSPEngine.GetBeaconZoomSpectrum(Pixels, Count, LowHz, HighHz);
if Count > 0 then
begin
LowHz := LowHz + FCenterFreq;
HighHz := HighHz + FCenterFreq;
end;
end;
function TRadioController.GetBeaconZoomWaterfall(var Pixels: array of Single;
out Count: Integer): Boolean;
begin
Count := 0;
Result := False;
if not Assigned(FDSPEngine) then Exit;
Result := FDSPEngine.GetBeaconZoomWaterfall(Pixels, Count);
end;
function TRadioController.BeaconZoomBinHz: Double;
begin
Result := 0;
if not Assigned(FDSPEngine) then Exit;
if FDSPEngine.BeaconZoomFFT > 0 then
Result := FDSPEngine.SampleRate / FDSPEngine.BeaconZoomFFT;
end;
function TRadioController.GetDMRStatus(out S: TDMRStatus): Boolean;
begin
Result := Assigned(FDMRDec);