feat(beacon): автопоиск маяка в окне лупы (кнопка AUTO)

Лупа сделала прицеливание возможным, но целиться всё равно надо руками. AUTO
находит маяк сам, в текущем окне лупы: что видно, то и обыскивается — заодно
зумом можно сузить область поиска.

Критерий — не «самый громкий», иначе поймается первая же SSB-станция. Копим
min-hold по кадрам лупы (~1.6 с): у маяка несущая непрерывна и минимум остаётся
высоким, а речь, телеграф и цифра в паузах проваливаются к шуму и срезаются
минимумом. Это тот самый «непрерывный след», который видно на водопаде, только
числом.

По накопленному: шумовой пол = медиана, кандидат = максимум скользящего среднего
шириной с полосу приёма (±450 Гц) — среднее по полосе, а не отдельный бин, потому
что маяк это плато ~900 Гц, и по такой мере он выигрывает у узкой пораженки с той
же высотой пика. Порог 6 дБ над полом; центр уточняется центроидом превышения,
чтобы наведение село на середину плато.

- Тумблер рядом с FOLLOW, состояние в настройках (beacon_zoom_auto).
- На локе автопоиск молчит: в контур не лезем.
- После наведения выдержка ~3 с — дать декодеру попробовать захватиться.
- Нашли там же, где уже стоит наведение (ближе 150 Гц) — не трогаем.
  BeaconSeedAtHz дёргает Reseed, и без этой проверки при слабом сигнале
  автопоиск сбрасывал бы захват по кругу, мешая декодеру сойтись.
- В накопление идут только свежие кадры: таймер формы (25 Гц) быстрее
  анализатора (15 к/с), и повторный учёт кадра сокращал бы окно наблюдения,
  ради которого min-hold и заведён.

В строке захвата появляется пометка AUTO — и только пока автопоиск реально
работает: на локе она гаснет, чтобы не обещать действие, которого нет.

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

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-14 13:01:26 +03:00
co-authored by Claude Opus 5
parent 51599b2457
commit 8569236214
3 changed files with 202 additions and 5 deletions
+194 -5
View File
@@ -47,6 +47,13 @@ const
BCN_CLR_REF = TColor($0000FF00); // clLime — где маяк ДОЛЖЕН быть (пунктир)
BCN_CLR_TRK = TColor($000AA5FF); // отслеживаемый центроид (оранж)
BCN_CLR_DEC = TColor($0020D0FF); // грани полосы приёма + центр наведения
// Автопоиск маяка. Копим min-hold по кадрам лупы: у маяка несущая непрерывна,
// и минимум за пару секунд остаётся высоким, а любая SSB/CW-станция в паузах
// проваливается к шуму — это и отсеивает «громко, но не маяк».
BCN_AUTO_FRAMES = 24; // кадров в min-hold (~1.6 с при 15 к/с анализатора)
BCN_AUTO_COOLDOWN = 75; // тиков паузы после наведения (~3 с при 25 Гц)
BCN_AUTO_MIN_DB = 6.0; // насколько кандидат должен возвышаться над полом
BCN_AUTO_MIN_MOVE = 150.0; // ближе этого к текущему наведению не пере-наводим
type
TBeaconScopeForm = class(TForm)
@@ -78,7 +85,7 @@ type
FWfBox: TPaintBox; // мини-водопад лупы
FWf: TWaterfallView;
FStrip: TBitmap; // офскрин спектра (без него мигает)
FBtnZoomIn, FBtnZoomOut, FBtnReset, FBtnFollow: TFlatButton;
FBtnZoomIn, FBtnZoomOut, FBtnReset, FBtnFollow, FBtnAuto: TFlatButton;
FCenterHz: Double; // центр окна лупы (display Hz), 0 = ещё не задан
FSpanHz: Double; // ширина окна лупы
@@ -100,6 +107,14 @@ type
// лезть за копией TBeaconScope на каждую отрисовку незачем.
FState: TBeaconState;
FSNRdB: Double;
// --- Автопоиск ---
FAuto: Boolean; // кнопка AUTO взведена
FAutoMin: array[0..BCN_MAX_PIX - 1] of Single; // min-hold кадров
FAutoFrames: Integer; // сколько кадров уже в min-hold
FAutoCnt: Integer; // точек в кадре, под который копится min-hold
FAutoLo, FAutoHi: Double; // окно, под которое копится (сменилось — сброс)
FAutoCooldown: Integer; // тиков паузы после наведения
FCursorX: Integer; // -1 = курсор вне лупы
FDragging: Boolean; // ЛКМ зажата в лупе
FDragMoved: Boolean; // увели мышь → это панорамирование, не клик
@@ -145,6 +160,10 @@ type
procedure BtnZoomOutClick(Sender: TObject);
procedure BtnResetClick(Sender: TObject);
procedure BtnFollowClick(Sender: TObject);
procedure BtnAutoClick(Sender: TObject);
procedure AutoReset; // сбросить накопление min-hold
procedure AutoService(NewFrame: Boolean); // одна итерация автопоиска
function AutoFindBeacon(out FreqHz: Double): Boolean;
procedure StyleTuneButtons;
protected
procedure DoShow; override;
@@ -311,7 +330,8 @@ begin
FTuneHint := TLabel.Create(Self);
FTuneHint.Parent := FTune;
FTuneHint.Caption := 'click the beacon to point the decoder · wheel zooms · drag pans';
FTuneHint.Caption :=
'click the beacon to point the decoder · AUTO finds it in this window';
FTuneHint.AutoSize := False;
FTuneHint.SetBounds(DpiScale(120), DpiScale(11), DpiScale(360), DpiScale(16));
FTuneHint.Font.Size := 8;
@@ -319,6 +339,8 @@ begin
FBtnFollow := MakeFlatBtn(FTune, 'FOLLOW', 0, 0,
DpiScale(66), DpiScale(22), @BtnFollowClick);
FBtnAuto := MakeFlatBtn(FTune, 'AUTO', 0, 0,
DpiScale(52), DpiScale(22), @BtnAutoClick);
FBtnReset := MakeFlatBtn(FTune, 'RESET', 0, 0,
DpiScale(58), DpiScale(22), @BtnResetClick);
FBtnZoomOut := MakeFlatBtn(FTune, '', 0, 0,
@@ -384,7 +406,9 @@ begin
Style(FBtnZoomOut);
Style(FBtnReset);
Style(FBtnFollow);
Style(FBtnAuto);
if FBtnFollow <> nil then FBtnFollow.Active := FFollow;
if FBtnAuto <> nil then FBtnAuto.Active := FAuto;
end;
procedure TBeaconScopeForm.LayoutTunePanel;
@@ -399,6 +423,8 @@ begin
Y := DpiScale(8);
X := W - Pad;
Dec(X, FBtnFollow.Width); FBtnFollow.SetBounds(X, Y, FBtnFollow.Width, FBtnFollow.Height);
Dec(X, DpiScale(6) + FBtnAuto.Width);
FBtnAuto.SetBounds(X, Y, FBtnAuto.Width, FBtnAuto.Height);
Dec(X, DpiScale(6) + FBtnReset.Width);
FBtnReset.SetBounds(X, Y, FBtnReset.Width, FBtnReset.Height);
Dec(X, DpiScale(10) + FBtnZoomIn.Width);
@@ -517,6 +543,150 @@ begin
FCenterHz := EnsureRange(FCenterHz, CapLo + Half, CapHi - Half);
end;
// ===========================================================================
// Автопоиск маяка
// ===========================================================================
// Ищем в ТЕКУЩЕМ окне лупы — что видно, то и обыскивается. Это и предсказуемо,
// и даёт управление: зумом пользователь сам сужает область поиска.
procedure TBeaconScopeForm.AutoReset;
begin
FAutoFrames := 0;
FAutoCnt := 0;
end;
function TBeaconScopeForm.AutoFindBeacon(out FreqHz: Double): Boolean;
// По накопленному min-hold: шумовой пол = медиана, кандидат = максимум
// скользящего среднего шириной с полосу приёма декодера. Именно среднее по
// полосе, а не отдельный бин: маяк — плато ~900 Гц, и по нему он выигрывает у
// узкой пораженки/бирди с той же высотой пика.
var
N, i, Half, i1, i2, Best: Integer;
BinHz, FloorDb, Sum, BestVal, V, WSum, FSum, Wt: Double;
Sorted: array of Double;
TmpD: Double;
a, b: Integer;
begin
Result := False;
FreqHz := 0;
N := FAutoCnt;
if (N < 16) or (FAutoFrames < BCN_AUTO_FRAMES) then Exit;
BinHz := (FAutoHi - FAutoLo) / (N - 1);
if BinHz <= 0 then Exit;
// Шумовой пол — медиана по прореженной выборке (как в шкале дБ кадра).
SetLength(Sorted, (N + 7) div 8);
for i := 0 to High(Sorted) do
Sorted[i] := FAutoMin[Min(i * 8, N - 1)];
for a := 1 to High(Sorted) do
begin
TmpD := Sorted[a];
b := a - 1;
while (b >= 0) and (Sorted[b] > TmpD) do
begin
Sorted[b + 1] := Sorted[b];
Dec(b);
end;
Sorted[b + 1] := TmpD;
end;
FloorDb := Sorted[Length(Sorted) div 2];
Half := Max(1, Round(BCN_DEC_BW_HZ / BinHz));
if 2 * Half + 1 >= N then Exit; // окно уже полосы приёма — не ищем
// Скользящее среднее одним проходом: сумма правится краями окна.
Sum := 0;
for i := 0 to 2 * Half do Sum := Sum + FAutoMin[i];
Best := Half;
BestVal := Sum / (2 * Half + 1);
for i := Half + 1 to N - Half - 1 do
begin
Sum := Sum - FAutoMin[i - Half - 1] + FAutoMin[i + Half];
V := Sum / (2 * Half + 1);
if V > BestVal then
begin
BestVal := V;
Best := i;
end;
end;
if BestVal - FloorDb < BCN_AUTO_MIN_DB then Exit; // ничего похожего на маяк
// Уточнение центра: центроид превышения над полом внутри полосы. Декодер
// дотянет и с ошибкой в сотню герц (у него свой acquisition), но так наведение
// сразу садится на середину плато.
i1 := Max(0, Best - Half);
i2 := Min(N - 1, Best + Half);
WSum := 0;
FSum := 0;
for i := i1 to i2 do
begin
Wt := FAutoMin[i] - FloorDb;
if Wt <= 0 then Continue;
WSum := WSum + Wt;
FSum := FSum + Wt * i;
end;
if WSum <= 0 then Exit;
FreqHz := FAutoLo + (FSum / WSum) * BinHz;
Result := True;
end;
procedure TBeaconScopeForm.AutoService(NewFrame: Boolean);
// Раз в тик, при взведённой AUTO. Пока декодер солидно залочен — не мешаем
// контуру; потерял лок (или его и не было) — копим min-hold и наводимся сами.
// В накопление идут только СВЕЖИЕ кадры: таймер формы быстрее анализатора, и
// повторный учёт одного и того же кадра сокращал бы окно наблюдения, ради
// которого min-hold и заведён.
var
i: Integer;
F: Double;
begin
if not FAuto then Exit;
if FCtl = nil then Exit;
if FState = bsLock then
begin
AutoReset; // лок держится — искать нечего
Exit;
end;
if FAutoCooldown > 0 then
begin
Dec(FAutoCooldown); // ждём, пока декодер попробует захватиться
AutoReset;
Exit;
end;
if (not NewFrame) or (FPixCount < 16) then Exit;
// Окно переехало (зум/пан/смена rate) — накопленное больше не про эти бины.
if (FPixCount <> FAutoCnt) or (Abs(FViewLo - FAutoLo) > 0.5)
or (Abs(FViewHi - FAutoHi) > 0.5) then
begin
FAutoCnt := FPixCount;
FAutoLo := FViewLo;
FAutoHi := FViewHi;
FAutoFrames := 0;
end;
if FAutoFrames = 0 then
for i := 0 to FAutoCnt - 1 do FAutoMin[i] := FPix[i]
else
for i := 0 to FAutoCnt - 1 do
if FPix[i] < FAutoMin[i] then FAutoMin[i] := FPix[i];
Inc(FAutoFrames);
if FAutoFrames < BCN_AUTO_FRAMES then Exit;
// Наводимся, только если нашли ДРУГОЕ место. Если декодер и так смотрит сюда,
// а лока нет (слабый сигнал) — пусть работает: BeaconSeedAtHz дёргает Reseed,
// и повторные наведения на ту же частоту сбрасывали бы захват по кругу.
if AutoFindBeacon(F) then
if (FCtl.BeaconDecodeFreqHz <= 0)
or (Abs(F - FCtl.BeaconDecodeFreqHz) > BCN_AUTO_MIN_MOVE) then
begin
FCtl.BeaconSeedAtHz(F); // тот же путь, что клик мышью
FAutoCooldown := BCN_AUTO_COOLDOWN;
end;
AutoReset; // не нашли (или не двигались) — копим заново
end;
procedure TBeaconScopeForm.RestoreZoomState;
// Окно лупы переживает перезапуск: ширина, положение и FOLLOW лежат в
// глобальных настройках (персист — общий SaveGlobalSettings при выходе).
@@ -526,11 +696,14 @@ begin
if FCtl = nil then Exit;
FSpanHz := EnsureRange(FCtl.FBcnZoomSpanHz, BCN_SPAN_MIN, BCN_SPAN_MAX);
FFollow := FCtl.FBcnZoomFollow;
FAuto := FCtl.FBcnZoomAuto;
if FCtl.BeaconRefHz > 0 then
FCenterHz := FCtl.BeaconRefHz + FCtl.FBcnZoomOffsetHz
else
FCenterHz := DefaultCenterHz;
if FBtnFollow <> nil then FBtnFollow.Active := FFollow;
if FBtnAuto <> nil then FBtnAuto.Active := FAuto;
AutoReset;
ClampWindow;
// Наведение декодера могло остаться за краем восстановленного окна — его
// подтянет FOLLOW на первом же тике (PollZoom), отдельной логики не нужно.
@@ -545,6 +718,7 @@ begin
if FCtl = nil then Exit;
FCtl.FBcnZoomSpanHz := FSpanHz;
FCtl.FBcnZoomFollow := FFollow;
FCtl.FBcnZoomAuto := FAuto;
if FCtl.BeaconRefHz > 0 then
FCtl.FBcnZoomOffsetHz := FCenterHz - FCtl.BeaconRefHz;
end;
@@ -555,7 +729,7 @@ procedure TBeaconScopeForm.PollZoom;
var
Cnt: Integer;
Lo, Hi, Dec_: Double;
GotWf: Boolean;
GotWf, Fresh: Boolean;
begin
if FCtl = nil then Exit;
@@ -583,8 +757,8 @@ begin
// Возврат GetBeaconZoomSpectrum = «в кэше был НЕпрочитанный кадр»; это и есть
// главный триггер перерисовки трассы. Границы окна тут не помощник: пока
// никто не панорамирует, они не меняются, а пиксели — каждый кадр.
if FCtl.GetBeaconZoomSpectrum(FPix, Cnt, Lo, Hi) then
FZoomDirty := True;
Fresh := FCtl.GetBeaconZoomSpectrum(FPix, Cnt, Lo, Hi);
if Fresh then FZoomDirty := True;
if Cnt > 0 then
begin
if (Cnt <> FPixCount) or (Abs(Lo - FViewLo) > 0.01)
@@ -613,6 +787,8 @@ begin
FLastTrkHz := FCtl.BeaconTrackedFreqHz;
FLastDecHz := FCtl.BeaconDecodeFreqHz;
AutoService(Fresh);
GotWf := FCtl.GetBeaconZoomWaterfall(FWfPix, Cnt);
if GotWf and (Cnt > 0) and (FWf <> nil) then
begin
@@ -806,6 +982,15 @@ begin
if FBtnFollow <> nil then FBtnFollow.Active := FFollow;
end;
procedure TBeaconScopeForm.BtnAutoClick(Sender: TObject);
begin
FAuto := not FAuto;
if FBtnAuto <> nil then FBtnAuto.Active := FAuto;
AutoReset;
FAutoCooldown := 0; // включили руками — ищем сразу, без выдержки
FZoomDirty := True; // в строке захвата появляется/пропадает пометка AUTO
end;
procedure TBeaconScopeForm.DoShow;
begin
inherited DoShow;
@@ -1146,6 +1331,10 @@ begin
end;
if (FState <> bsIdle) and (FSNRdB > -50) then
Txt := Txt + Format(' · %.1f dB', [FSNRdB]);
// Автопоиск помечаем только пока он реально работает: на локе он молчит, и
// светить «AUTO» там значило бы обещать действие, которого не происходит.
if FAuto and (FState <> bsLock) then
Txt := Txt + ' · AUTO';
C.TextOut(DpiScale(6), DpiScale(21), Txt);
a := DpiScale(6) + C.TextWidth(Txt) + DpiScale(10);