From 8569236214e2192d4bf4ab6eaf7db3f6c82bb6ef Mon Sep 17 00:00:00 2001 From: Uladzimir Karpenka Date: Fri, 14 Aug 2026 13:01:26 +0300 Subject: [PATCH] =?UTF-8?q?feat(beacon):=20=D0=B0=D0=B2=D1=82=D0=BE=D0=BF?= =?UTF-8?q?=D0=BE=D0=B8=D1=81=D0=BA=20=D0=BC=D0=B0=D1=8F=D0=BA=D0=B0=20?= =?UTF-8?q?=D0=B2=20=D0=BE=D0=BA=D0=BD=D0=B5=20=D0=BB=D1=83=D0=BF=D1=8B=20?= =?UTF-8?q?(=D0=BA=D0=BD=D0=BE=D0=BF=D0=BA=D0=B0=20AUTO)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Лупа сделала прицеливание возможным, но целиться всё равно надо руками. 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 --- BeaconScopeForm.pas | 199 ++++++++++++++++++++++++++++++++++++++++++-- RadioController.pas | 4 + Settings.pas | 4 + 3 files changed, 202 insertions(+), 5 deletions(-) diff --git a/BeaconScopeForm.pas b/BeaconScopeForm.pas index 7208760..fe6c931 100644 --- a/BeaconScopeForm.pas +++ b/BeaconScopeForm.pas @@ -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); diff --git a/RadioController.pas b/RadioController.pas index ea65100..8513296 100644 --- a/RadioController.pas +++ b/RadioController.pas @@ -339,6 +339,7 @@ type FBcnZoomSpanHz: Double; // ширина окна лупы, Гц FBcnZoomOffsetHz: Double; // центр = FBeaconRefHz + этот сдвиг FBcnZoomFollow: Boolean; // держать наведение декодера в окне + FBcnZoomAuto: Boolean; // автопоиск маяка в окне лупы // ---- DMR 4FSK receive decoder ---- FDMRDec: TDMRDecoder; // 48 kHz FM discriminator -> DMR worker @@ -1327,6 +1328,7 @@ begin FBcnZoomSpanHz := 40000.0; FBcnZoomOffsetHz := 0.0; FBcnZoomFollow := True; + FBcnZoomAuto := False; FBeaconDec.SetInvert(FBeaconDecInvert); FDSPEngine.SetBeaconDecoder(FBeaconDec); @@ -3959,6 +3961,7 @@ begin Result.BeaconZoomSpanHz := FBcnZoomSpanHz; Result.BeaconZoomOffsetHz := FBcnZoomOffsetHz; Result.BeaconZoomFollow := FBcnZoomFollow; + Result.BeaconZoomAuto := FBcnZoomAuto; Result.AudioSampleRate := FAudioOut.SampleRate; Result.ShowSpectrum := FShowSpectrum; Result.ShowWaterfall := FShowWaterfall; @@ -7355,6 +7358,7 @@ begin FBcnZoomSpanHz := EnsureRange(FLoadedGlobal.BeaconZoomSpanHz, 600.0, 200000.0); FBcnZoomOffsetHz := EnsureRange(FLoadedGlobal.BeaconZoomOffsetHz, -500000.0, 500000.0); FBcnZoomFollow := FLoadedGlobal.BeaconZoomFollow; + FBcnZoomAuto := FLoadedGlobal.BeaconZoomAuto; if FWDSPReady then FDSPEngine.SetDisplayResLimit(FDisplayPixels); FShowSpectrum := FLoadedGlobal.ShowSpectrum; FShowWaterfall := FLoadedGlobal.ShowWaterfall; diff --git a/Settings.pas b/Settings.pas index 47d3e6b..0ae2855 100644 --- a/Settings.pas +++ b/Settings.pas @@ -620,6 +620,7 @@ type BeaconZoomSpanHz: Double; // ширина окна лупы, Гц BeaconZoomOffsetHz: Double; // центр окна = BeaconRefHz + этот сдвиг BeaconZoomFollow: Boolean; // держать наведение декодера в окне + BeaconZoomAuto: Boolean; // автопоиск маяка в окне лупы // --- Audio settings --- AudioOutDevice: string; // PortAudio output device name ('' = default) AudioSampleRate: Integer; // PortAudio stream sample rate (e.g. 48000) @@ -988,6 +989,7 @@ begin G.BeaconZoomSpanHz := 40000.0; G.BeaconZoomOffsetHz := 0.0; G.BeaconZoomFollow := True; + G.BeaconZoomAuto := False; G.AudioOutDevice := ''; G.AudioSampleRate := 48000; G.AudioInDevice := ''; @@ -1241,6 +1243,7 @@ begin G.BeaconZoomSpanHz := JD(GObj,'beacon_zoom_span',40000.0); G.BeaconZoomOffsetHz := JD(GObj,'beacon_zoom_offset',0.0); G.BeaconZoomFollow := JB(GObj,'beacon_zoom_follow',True); + G.BeaconZoomAuto := JB(GObj,'beacon_zoom_auto',False); // Audio settings G.AudioOutDevice := JS(GObj,'audio_out_device',''); G.AudioSampleRate := JI(GObj,'audio_sample_rate',48000); @@ -1391,6 +1394,7 @@ begin JW(O,'beacon_zoom_span',G.BeaconZoomSpanHz); JW(O,'beacon_zoom_offset',G.BeaconZoomOffsetHz); JW(O,'beacon_zoom_follow',G.BeaconZoomFollow); + JW(O,'beacon_zoom_auto',G.BeaconZoomAuto); // Audio settings JWS(O,'audio_out_device',G.AudioOutDevice); JW(O,'audio_sample_rate',G.AudioSampleRate);