Merge feature/beacon-zoom-panel: лупа наведения на маяк QO-100

Окно Beacon получило карточку BEACON TUNING: узкий кусок спектра вокруг маяка в
высоком разрешении плюс мини-водопад. На спектрограмме 576к маяк занимает пару
пикселей и ткнуть в него для наведения декодера почти нельзя — здесь то же
наведение делается по широкому следу. Наведение с главной спектрограммы не
тронуто.

Увеличение делает сам WDSP: второй analyzer на том же IQ главного тракта, со
своей FFT и ассиметричным span-clip, посчитанным прямо из границ окна в герцах.

Вошло:
- лупа с кликом-наведением, зумом колесом, панорамированием и FOLLOW;
- автопоиск маяка (AUTO) по min-hold — критерий «непрерывный след», а не
  «самый громкий»;
- состояние захвата прямо в лупе: полоса приёма меняет начертание на локе,
  рядом SNR и расхождение с опорной частотой;
- окно лупы переживает перезапуск (сдвиг от опорной, а не абсолют);
- контур больше не принимает за маяк голую несущую — проверка BPSK по
  констелляции;
- взведённый клик снимается при любом наведении, не только мышью;
- попутно: пропадавшая расстройка тона в статусе терминала телеграфа ('%+d'
  в Format Object Pascal не работает).

На эфире проверено частично: наведение и картинка — да, автопоиск и новый
критерий лока — нет.
This commit is contained in:
2026-08-14 13:03:27 +03:00
5 changed files with 1911 additions and 150 deletions
+1386 -142
View File
File diff suppressed because it is too large Load Diff
+18 -2
View File
@@ -1661,7 +1661,19 @@ begin
PbSpectrum.Invalidate;
end;
rfBeaconLock:
UpdateBeaconButton; // статус/коррекция QO-100 beacon lock
begin
// Наведение ставит не только клик по спектру: есть AUTO в окне маяка,
// web и CAT. Как только декодер куда-то наведён — снимаем «вооружение»,
// иначе следующий случайный ЛКМ по панораме утащил бы маяк на пустое
// место. Перерисовку просим только в этот момент: маркеры и так едут с
// кадрами, а на стопе спектр статичен.
if FBeaconArming and (FController.BeaconDecodeFreqHz > 0) then
begin
FBeaconArming := False;
FSpectrumDirty := True;
end;
UpdateBeaconButton; // статус/коррекция QO-100 beacon lock
end;
rfCTun:
begin
StyleButton(BtnCTun, FController.FCTun);
@@ -8280,7 +8292,11 @@ begin
begin
St := Format('RX %d WPM', [FController.CWDecSpeedWPM]);
if FController.CWDecToneOffsetHz <> 0 then
St := St + Format(', tone %+d Hz', [FController.CWDecToneOffsetHz]);
// Знак руками: флага '+' в Format Object Pascal нет, '%+d' съедается
// молча и в строку попадает голая 'd'.
St := St + Format(', tone %s%d Hz',
[IfThen(FController.CWDecToneOffsetHz > 0, '+', ''),
FController.CWDecToneOffsetHz]);
if not FController.CWDecSignal then St := St + ', no signal';
end
else
+132 -4
View File
@@ -324,6 +324,7 @@ type
FBeaconTrackHz: Double; // измеренная АБСОЛЮТНАЯ display-частота маяка
// (наведение + остаток декодера) — для оранж. маркера
FBeaconSNRdB: Double; // SNR из последнего снимка декодера (индикация лока)
FBeaconBal: Double; // EMA доли меньшинства знаков I (BPSK-ность)
FBeaconLocked: Boolean; // декодер солидно залочен (гистерезис по SNR)
FBeaconCorrCooldown: Integer; // тиков паузы после коррекции (осадка ретюна LO)
FBeaconSaveCooldown: Integer; // троттлинг записи LOError на диск (тиков)
@@ -332,6 +333,13 @@ type
FBeaconDecHz: Double; // АБСОЛЮТНАЯ display-частота наведения декодера
// (узкий фильтр, ставится кликом); 0 = ещё не наведён
FBeaconDecInvert: Boolean; // зеркальный baseband (QO-100/LNB) — деф. True
// Лупа наведения (окно Beacon) — состояние окна, переживающее перезапуск.
// Центр хранится СДВИГОМ от FBeaconRefHz: так он не зависит ни от правки
// опорной частоты, ни от накопленной калибровки LOError.
FBcnZoomSpanHz: Double; // ширина окна лупы, Гц
FBcnZoomOffsetHz: Double; // центр = FBeaconRefHz + этот сдвиг
FBcnZoomFollow: Boolean; // держать наведение декодера в окне
FBcnZoomAuto: Boolean; // автопоиск маяка в окне лупы
// ---- DMR 4FSK receive decoder ----
FDMRDec: TDMRDecoder; // 48 kHz FM discriminator -> DMR worker
@@ -988,6 +996,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;
@@ -1303,6 +1325,10 @@ begin
// и prom 500-800 при invert=ON против шума при OFF, на наведении точно на маяк).
// Декодеру нужно сопряжение, чтобы наведённый offset доставлял маяк к DC.
FBeaconDecInvert := True;
FBcnZoomSpanHz := 40000.0;
FBcnZoomOffsetHz := 0.0;
FBcnZoomFollow := True;
FBcnZoomAuto := False;
FBeaconDec.SetInvert(FBeaconDecInvert);
FDSPEngine.SetBeaconDecoder(FBeaconDec);
@@ -3277,6 +3303,17 @@ const
// --- метрика лока (берём из снимка декодера: CarrierLock + SNR) ---
BEACON_LOCK_SNR_DB = 3.0; // солидный лок: декодер несёт несущую и SNR выше этого
BEACON_UNLOCK_SNR_DB = 0.0; // гистерезис: лок гаснет ниже этого
// --- «это точно BPSK, а не голая несущая» ---
// CarrierLock+SNR подтверждают лишь то, что в полосе есть узкая линия и Costas
// держит фазу. Ровная несущая (телеграф, пораженка, остаток несущей у цифры) даёт
// ровно это, причём с ОТЛИЧНЫМ SNR: захваченная Costas'ом, она лежит целиком на
// оси I, Q пустой. Контур тогда честно уводил LOError, подтягивая чужой сигнал к
// опорной частоте маяка. Различаем по констелляции: у настоящего BPSK точки ходят
// между двумя сгустками ±I (данные после свёрточного кодера равновероятны), у
// несущей все точки в одном. Меряем долю меньшинства по знаку I.
BEACON_BAL_MIN = 0.15; // ниже — на BPSK не похоже, лок не даём
BEACON_BAL_DROP = 0.08; // гистерезис: уже взятый лок гаснет ниже этого
BEACON_BAL_MIN_PTS = 64; // меньше точек в кольце — судить рано
procedure TRadioController.SetBeaconRefHz(Hz: Double);
begin
@@ -3296,6 +3333,8 @@ begin
FBeaconDec.SetOffsetHz(DisplayHz - FCenterFreq);
FBeaconDec.Reseed; // одноразовый грубый захват частоты заново
end;
FBeaconBal := 0.0; // новое наведение — BPSK-ность доказывается заново
FBeaconLocked := False;
FBeaconCorrCooldown := BEACON_SETTLE_TICKS; // дать декодеру захватиться до 1-й коррекции
Changed(rfBeaconLock);
end;
@@ -3312,6 +3351,7 @@ begin
FBeaconCorrCooldown := 0;
FBeaconSaveCooldown := 0;
FBeaconLocked := False;
FBeaconBal := 0.0;
FBeaconSNRdB := -99.0;
FBeaconLockHz := 0.0; // сессионный аккумулятор индикации (LOError несёт калибровку)
FBeaconDecHz := 0.0; // ещё не наведён — ждём клик по маяку
@@ -3331,6 +3371,20 @@ begin
Changed(rfBeaconLock);
end;
function BeaconIQBalance(const S: TBeaconScope): Double;
// Доля меньшинства среди знаков I в кольце констелляции: 0.5 — идеально
// сбалансированный BPSK, 0 — все точки в одном сгустке (немодулированная
// несущая). -1 = точек мало, судить рано.
var
i, Neg: Integer;
begin
if S.Count < BEACON_BAL_MIN_PTS then Exit(-1.0);
Neg := 0;
for i := 0 to S.Count - 1 do
if S.PtI[i] < 0 then Inc(Neg);
Result := Min(Neg, S.Count - Neg) / S.Count;
end;
procedure TRadioController.ServiceBeaconLock;
// Одна итерация контура (UI-таймер @10 Гц, MeterTimer). Эталон частоты — ДЕКОДЕР:
// он точно ведёт несущую маяка своим NCO (RunAcquisition + Costas-offload), и его
@@ -3342,7 +3396,7 @@ procedure TRadioController.ServiceBeaconLock;
// baseband-смещение ≈ 0: маяк сдвинулся на −dLock железом, наведение тоже на −dLock).
var
S: TBeaconScope;
residHz, measured, e, dLock: Double;
residHz, measured, e, dLock, Bal: Double;
begin
if not FBeaconLockOn then Exit;
if not Assigned(FBeaconDec) then Exit;
@@ -3359,9 +3413,17 @@ begin
FBeaconDec.GetScope(S);
FBeaconSNRdB := FBeaconSNRdB + 0.2 * (S.SNRdB - FBeaconSNRdB);
// Солидный лок: декодер несёт несущую И SNR выше порога (гистерезис против мерцания).
if S.CarrierLock and (FBeaconSNRdB >= BEACON_LOCK_SNR_DB) then FBeaconLocked := True
else if FBeaconSNRdB < BEACON_UNLOCK_SNR_DB then FBeaconLocked := False;
// Доля меньшинства по знаку I: у BPSK ≈0.5, у голой несущей ≈0. Кольцо в 256
// точек при 400 Bd набирается за ~0.6 с, EMA добавляет ещё столько же — то есть
// чужой сигнал отваливается примерно за секунду, а не за десяток (кадр FEC).
Bal := BeaconIQBalance(S);
if Bal >= 0 then FBeaconBal := FBeaconBal + 0.2 * (Bal - FBeaconBal);
// Солидный лок: декодер несёт несущую, SNR выше порога И картинка похожа на
// BPSK (гистерезис по обоим — против мерцания).
if S.CarrierLock and (FBeaconSNRdB >= BEACON_LOCK_SNR_DB)
and (FBeaconBal >= BEACON_BAL_MIN) then FBeaconLocked := True
else if (FBeaconSNRdB < BEACON_UNLOCK_SNR_DB)
or (FBeaconBal < BEACON_BAL_DROP) then FBeaconLocked := False;
// Остаток несущей декодера (Гц, относительно наведения). invert выравнивает ось
// декодера с дисплеем → display_маяка = наведение + остаток (знак BEACON_RESID_SIGN).
@@ -3486,6 +3548,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);
@@ -3838,6 +3958,10 @@ begin
Result.SpecRefLevel := FSpecRefLevel;
Result.SpecRange := FSpecRange;
Result.SpecGridStep := FSpecGridStep;
Result.BeaconZoomSpanHz := FBcnZoomSpanHz;
Result.BeaconZoomOffsetHz := FBcnZoomOffsetHz;
Result.BeaconZoomFollow := FBcnZoomFollow;
Result.BeaconZoomAuto := FBcnZoomAuto;
Result.AudioSampleRate := FAudioOut.SampleRate;
Result.ShowSpectrum := FShowSpectrum;
Result.ShowWaterfall := FShowWaterfall;
@@ -7231,6 +7355,10 @@ begin
FDisplayPixels := EnsureRange(FLoadedGlobal.DisplayPixels, 64, 4096);
FWfSpeedDiv := EnsureRange(FLoadedGlobal.WfSpeedDiv, 1, 8);
FSpecMSAA := EnsureRange(FLoadedGlobal.SpecMSAA, 1, 8);
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;
+19
View File
@@ -614,6 +614,13 @@ type
SpecRefLevel: Double; // dBm reference (top of scale), e.g. -20
SpecRange: Double; // dB range, e.g. 110 (bottom = ref - range)
SpecGridStep: Double; // grid step in dB, e.g. 10
// --- Лупа наведения на маяк QO-100 (карточка BEACON TUNING) ---
// Центр хранится СДВИГОМ от опорной частоты маяка, а не абсолютом: так
// окно переживает и правку опорной частоты, и калибровку LOError.
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)
@@ -979,6 +986,10 @@ begin
G.SpecRefLevel := -20.0;
G.SpecRange := 110.0;
G.SpecGridStep := 10.0;
G.BeaconZoomSpanHz := 40000.0;
G.BeaconZoomOffsetHz := 0.0;
G.BeaconZoomFollow := True;
G.BeaconZoomAuto := False;
G.AudioOutDevice := '';
G.AudioSampleRate := 48000;
G.AudioInDevice := '';
@@ -1229,6 +1240,10 @@ begin
G.SpecRefLevel := JD(GObj,'spec_ref_level',-20.0);
G.SpecRange := JD(GObj,'spec_range',110.0);
G.SpecGridStep := JD(GObj,'spec_grid_step',10.0);
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);
@@ -1376,6 +1391,10 @@ begin
JW(O,'spec_ref_level',G.SpecRefLevel);
JW(O,'spec_range',G.SpecRange);
JW(O,'spec_grid_step',G.SpecGridStep);
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);
+356 -2
View File
@@ -142,17 +142,29 @@ const
RX_DISP_ID = 0; // analyzer для RX-спектра/водопада
TX_DISP_ID = 1; // analyzer для TX-спектра/водопада
// Отдельный analyzer лупы QO-100-маяка (окно BeaconScopeForm). Кормится тем
// же IQ главного тракта, но со своим FFT и своим span-clip — узкое окно
// вокруг маяка в высоком разрешении. Живёт только пока окно маяка открыто.
BCN_DISP_ID = 4;
// --- Панадаптеры на аппаратных DDC (этап 3) ---
// Пан 0 = главный тракт (RXA/RX_DISP_ID), доп. паны 1..MAX_PANS-1 живут на
// своих DDC. Единый потолок для движка/контроллера/UI.
MAX_PANS = 4;
// Analyzer id пана N = PAN_DISP_BASE + N (9..11). ID 4/5 заняты веткой
// feature/qo100-zoom-panel — не пересекаемся при будущем merge.
// Analyzer id пана N = PAN_DISP_BASE + N (9..11). ID 4 занят лупой маяка
// (BCN_DISP_ID), 5 держим свободным про запас.
PAN_DISP_BASE = 8;
DSP_BUFSIZE = 1024;
SPECTRUM_PIXELS = 4096; // потолок точек дисплея; синхронно с WaterfallView.WF_MAX_PIXELS
// Точек в кадре лупы маяка. Фиксировано (а не по ширине виджета), чтобы
// изменение размера окна маяка не переармировало analyzer: переарм сбрасывает
// FFT-историю, а на крупных FFT она наполняется почти секунду. Рендер сам
// ужимает кадр под свою ширину.
BCN_ZOOM_PIXELS = 1024;
// Частота кадров лупы. Своя, ниже главного дисплея — см.
// ApplyBeaconAnalyzerSettings.
BCN_ZOOM_FPS = 15.0;
DISPLAY_BLOCK_SIZE = 64;
// Очередь IQ пакетов между сетевым и DSP потоком (общая: главный + паны).
@@ -336,6 +348,27 @@ type
FViewLowHz: Double;
FViewHighHz: Double;
// --- Лупа маяка (BCN_DISP_ID) ---------------------------------------
// Второй analyzer на том же IQ главного тракта: своя FFT (крупнее) и свой
// span-clip, вырезающий узкое окно вокруг маяка. Создаётся/убивается по
// запросу UI (окно маяка открыто/закрыто), поэтому весь доступ к disp'у
// сериализуется FBcnLock: SetAnalyzer/Destroy зовёт UI-поток, Spectrum0 —
// DSP-поток, GetPixels — display-поток.
FBcnLock: TCriticalSection;
FBcnOpen: Boolean; // XCreateAnalyzer(BCN_DISP_ID) жив
FBcnDispBuf: array of Double; // feed анализатора (DISPLAY_BLOCK_SIZE пар)
FBcnDispPos: Integer;
FBcnReqCenter: Double; // запрошенный центр окна, Гц отн. DDC-центра
FBcnReqSpan: Double; // запрошенная ширина окна, Гц
FBcnLowHz: Double; // фактическое окно (снаппится по бинам FFT)
FBcnHighHz: Double;
FBcnFFT: Integer; // размер FFT лупы (свой, не FFFTSize)
FBcnPixCount: Integer; // точек в кадре лупы
FBcnSpecPix: array[0..SPECTRUM_PIXELS - 1] of Single;
FBcnWfPix: array[0..SPECTRUM_PIXELS - 1] of Single;
FBcnSpecFresh: Boolean; // в кэше есть непрочитанный кадр спектра
FBcnWfFresh: Boolean;
FOnAudio: TOnAudioReady;
FOnDemodAudio:TOnAudioReady; // pre-volume/mute tap for digital decoders
FOnSpectrum: TOnSpectrumReady;
@@ -572,6 +605,12 @@ type
procedure ApplyPSParams; // прокидывает FPS*-параметры в WDSP calcc
procedure ApplyRXAnalyzerSettings; // применяет RX FFT/Window/Det/Avg к RX_DISP_ID
procedure ApplyTXAnalyzerSettings; // применяет TX FFT/Window/Det/Avg к TX_DISP_ID
// --- Лупа маяка ---
procedure ApplyBeaconAnalyzerSettings; // span-clip окна маяка; под FBcnLock
function BeaconFFTFor(SpanHz: Double): Integer; // FFT под запрошенную ширину
procedure CloseBeaconAnalyzer; // Destroy + сброс (сам берёт FBcnLock)
procedure RearmBeaconAnalyzer; // полный переарм (смена rate)
procedure ReapplyBeaconDisplayModes; // детектор/усреднение без переарма
public
constructor Create(SampleRate: Integer = 192000;
@@ -771,6 +810,23 @@ type
property ViewLowHz: Double read FViewLowHz;
property ViewHighHz: Double read FViewHighHz;
// --- Лупа маяка (BCN_DISP_ID) ---------------------------------------
// Включает/выключает второй analyzer на IQ главного тракта и задаёт его
// окно: ACenterHz/ASpanHz — В ГЕРЦАХ ОТНОСИТЕЛЬНО ЦЕНТРА DDC (как
// ViewLowHz/ViewHighHz). Окно клампится в полосу захвата. Вызывать из
// UI-потока. Enabled=False — analyzer уничтожается (нулевая цена, когда
// окно маяка закрыто).
procedure SetBeaconZoom(AEnabled: Boolean; ACenterHz, ASpanHz: Double);
// Забрать последний кадр лупы. Fresh=False, если нового кадра не было —
// вызывающий рисует предыдущий. Low/High — фактическое окно (Гц отн.
// центра DDC), уже снаппенное по бинам FFT.
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;
property BeaconZoomOpen: Boolean read FBcnOpen;
property BeaconZoomFFT: Integer read FBcnFFT;
property Initialized: Boolean read FInitialized;
property SampleRate: Integer read FSampleRate;
property TXSampleRate: Integer read FTXSampleRate;
@@ -1427,6 +1483,16 @@ begin
FMainGated := False;
FMainLock := TCriticalSection.Create;
// Лупа маяка — analyzer создаётся по требованию UI (SetBeaconZoom)
FBcnLock := TCriticalSection.Create;
FBcnOpen := False;
FBcnDispPos := 0;
FBcnReqCenter := 0.0;
FBcnReqSpan := 40000.0;
FBcnFFT := 0;
FBcnPixCount := 0;
SetLength(FBcnDispBuf, DISPLAY_BLOCK_SIZE * 2);
// Мультислайсы
FSliceLock := TCriticalSection.Create;
FillChar(FSlices, SizeOf(FSlices), 0);
@@ -1526,6 +1592,7 @@ begin
RTLEventDestroy(FTXMicSem);
FSliceLock.Free;
FMainLock.Free;
FBcnLock.Free;
inherited;
end;
@@ -1757,12 +1824,241 @@ begin
end;
end;
// ---------------------------------------------------------------------------
// Лупа маяка (BCN_DISP_ID)
// ---------------------------------------------------------------------------
function TWDSPEngine.BeaconFFTFor(SpanHz: Double): Integer;
// FFT лупы выбираем ОТ ШИРИНЫ ОКНА, а не от настройки дисплея: цель — держать
// ~BINS_PER_PIXEL бинов на точку кадра, чтобы узкое окно оставалось детальным
// (в этом весь смысл лупы). Span-clip НЕ удешевляет FFT — она всё равно по всей
// полосе захвата, поэтому потолок жёсткий: 262144 (= max_size в
// XCreateAnalyzer), а частота кадров лупы понижена (BCN_ZOOM_FPS).
const
BINS_PER_PIXEL = 2.0;
var
Want: Double;
begin
if SpanHz <= 0 then Exit(65536);
Want := BINS_PER_PIXEL * BCN_ZOOM_PIXELS * FSampleRate / SpanHz;
Result := 16384;
while (Result < Want) and (Result < 262144) do
Result := Result shl 1;
end;
procedure TWDSPEngine.ApplyBeaconAnalyzerSettings;
// Переармирует analyzer лупы под FBcnReqCenter/FBcnReqSpan. Вызывать ПОД
// FBcnLock при FBcnOpen=True.
//
// Окно вырезается тем же span-clip'ом, что и зум главного спектра, но здесь
// клипы считаются НЕ из зум-слайдера, а прямо из желаемых границ в Гц —
// анализатор умеет ассиметричный клип, поэтому окно можно поставить в любое
// место полосы захвата, а не только вокруг центра DDC.
var
AvgCount, WfAvgCount, MaxW, Overlap: Integer;
Backmult, WfBackmult: Double;
Bins, ClipL, ClipH: Integer;
BinWidth, BW, WantLo, WantHi: Double;
begin
FBcnFFT := BeaconFFTFor(FBcnReqSpan);
FBcnDispPos := 0;
BinWidth := FSampleRate / FBcnFFT;
Bins := FBcnFFT;
BW := Bins * BinWidth;
// Желаемые границы, зажатые в полосу захвата с запасом в один бин.
WantLo := FBcnReqCenter - FBcnReqSpan / 2.0;
WantHi := FBcnReqCenter + FBcnReqSpan / 2.0;
if WantLo < -0.5 * BW + BinWidth then WantLo := -0.5 * BW + BinWidth;
if WantHi > 0.5 * BW - BinWidth then WantHi := 0.5 * BW - BinWidth;
if WantHi - WantLo < 4 * BinWidth then WantHi := WantLo + 4 * BinWidth;
ClipL := Floor((WantLo + 0.5 * BW + BinWidth / 2.0) / BinWidth);
ClipH := Floor((0.5 * BW - BinWidth / 2.0 - WantHi) / BinWidth);
ClipL := EnsureRange(ClipL, 0, Bins - 4);
ClipH := EnsureRange(ClipH, 0, Bins - 4 - ClipL);
// Фактическое окно — то же выражение, что в ApplyRXAnalyzerSettings.
FBcnLowHz := -(0.5 * BW - ClipL * BinWidth + BinWidth / 2.0);
FBcnHighHz := (0.5 * BW - ClipH * BinWidth - BinWidth / 2.0);
// Кадры лупы считаем реже главного дисплея: FFT здесь крупная (до 262144) и
// идёт по ВСЕЙ полосе захвата, а для наведения на маяк 15 кадров/с с запасом.
// На 60 fps это была бы полноценная загрузка ядра ради картинки, в которой
// ничего не меняется.
Overlap := Max(0, Ceil(FBcnFFT - FSampleRate / BCN_ZOOM_FPS));
MaxW := FBcnFFT + Round(Min(0.10 * FSampleRate, 0.10 * FBcnFFT * BCN_ZOOM_FPS));
AverageTimeToParams(FSpecAvgMode, FSpecAvgTimeMS, AvgCount, Backmult);
AverageTimeToParams(FWfAvgMode, FWfAvgTimeMS, WfAvgCount, WfBackmult);
FBcnPixCount := BCN_ZOOM_PIXELS;
SetAnalyzer(BCN_DISP_ID, 2, 1, 1, @FFlp[0], FBcnFFT, DISPLAY_BLOCK_SIZE,
FWindowType, 14.0, Overlap, 0, ClipL, ClipH, BCN_ZOOM_PIXELS,
1, 0, 0.0, 0.0, MaxW);
SetDisplayDetectorMode(BCN_DISP_ID, 0, FSpecDetector);
SetDisplayAverageMode (BCN_DISP_ID, 0, FSpecAvgMode);
SetDisplayNumAverage (BCN_DISP_ID, 0, AvgCount);
SetDisplayAvBackmult (BCN_DISP_ID, 0, Backmult);
SetDisplayNormOneHz (BCN_DISP_ID, 0, UsePanNormOneHz(FSpecDetector));
SetDisplayDetectorMode(BCN_DISP_ID, 1, FWfDetector);
SetDisplayAverageMode (BCN_DISP_ID, 1, FWfAvgMode);
SetDisplayNumAverage (BCN_DISP_ID, 1, WfAvgCount);
SetDisplayAvBackmult (BCN_DISP_ID, 1, WfBackmult);
SetDisplayNormOneHz (BCN_DISP_ID, 1, 0);
SetDisplaySampleRate (BCN_DISP_ID, FSampleRate);
end;
procedure TWDSPEngine.CloseBeaconAnalyzer;
begin
FBcnLock.Enter;
try
if not FBcnOpen then Exit;
FBcnOpen := False; // DSP/display-потоки перестают трогать disp
DestroyAnalyzer(BCN_DISP_ID);
FBcnSpecFresh := False;
FBcnWfFresh := False;
FBcnPixCount := 0;
FBcnDispPos := 0;
finally
FBcnLock.Leave;
end;
end;
procedure TWDSPEngine.RearmBeaconAnalyzer;
// Полный переарм лупы — после смены sample rate: и окно, и выбор FFT считаются
// от FSampleRate.
begin
if not FBcnOpen then Exit;
FBcnLock.Enter;
try
if FBcnOpen then ApplyBeaconAnalyzerSettings;
finally
FBcnLock.Leave;
end;
end;
procedure TWDSPEngine.ReapplyBeaconDisplayModes;
// Детектор/усреднение с главного дисплея на лупу БЕЗ SetAnalyzer: переарм
// сбросил бы FFT-историю (на 256к бинах это ~0.5с замершего кадра), а сменой
// детектора/усреднения окно лупы не меняется.
var
AvgCount, WfAvgCount: Integer;
Backmult, WfBackmult: Double;
begin
if not FBcnOpen then Exit;
FBcnLock.Enter;
try
if not FBcnOpen then Exit;
AverageTimeToParams(FSpecAvgMode, FSpecAvgTimeMS, AvgCount, Backmult);
AverageTimeToParams(FWfAvgMode, FWfAvgTimeMS, WfAvgCount, WfBackmult);
SetDisplayDetectorMode(BCN_DISP_ID, 0, FSpecDetector);
SetDisplayAverageMode (BCN_DISP_ID, 0, FSpecAvgMode);
SetDisplayNumAverage (BCN_DISP_ID, 0, AvgCount);
SetDisplayAvBackmult (BCN_DISP_ID, 0, Backmult);
SetDisplayNormOneHz (BCN_DISP_ID, 0, UsePanNormOneHz(FSpecDetector));
SetDisplayDetectorMode(BCN_DISP_ID, 1, FWfDetector);
SetDisplayAverageMode (BCN_DISP_ID, 1, FWfAvgMode);
SetDisplayNumAverage (BCN_DISP_ID, 1, WfAvgCount);
SetDisplayAvBackmult (BCN_DISP_ID, 1, WfBackmult);
finally
FBcnLock.Leave;
end;
end;
procedure TWDSPEngine.SetBeaconZoom(AEnabled: Boolean; ACenterHz, ASpanHz: Double);
var
Success: Integer;
SameWindow: Boolean;
begin
if (not AEnabled) or (not FInitialized) or (not FAnalyzerOpen) then
begin
CloseBeaconAnalyzer;
Exit;
end;
ASpanHz := EnsureRange(ASpanHz, 200.0, FSampleRate);
ACenterHz := EnsureRange(ACenterHz, -FSampleRate / 2.0, FSampleRate / 2.0);
FBcnLock.Enter;
try
// Переарм сбрасывает историю FFT (кадр «замирает» на время наполнения) —
// на неизменившемся окне не дёргаем, лупа зовётся из таймера UI.
SameWindow := FBcnOpen and
(Abs(ACenterHz - FBcnReqCenter) < 0.5) and
(Abs(ASpanHz - FBcnReqSpan) < 0.5);
FBcnReqCenter := ACenterHz;
FBcnReqSpan := ASpanHz;
if SameWindow then Exit;
if not FBcnOpen then
begin
Success := -1;
XCreateAnalyzer(BCN_DISP_ID, @Success, 262144, 1, 1, nil);
if Success <> 0 then
begin
FLastError := Format('XCreateAnalyzer(BCN) failed: %d', [Success]);
Exit; // лупа не поднялась — окно маяка просто останется пустым
end;
FBcnOpen := True;
end;
ApplyBeaconAnalyzerSettings;
finally
FBcnLock.Leave;
end;
end;
function TWDSPEngine.GetBeaconZoomSpectrum(var Pixels: array of Single;
out Count: Integer; out LowHz, HighHz: Double): Boolean;
var N: Integer;
begin
Result := False;
Count := 0;
LowHz := 0;
HighHz := 0;
if not FBcnOpen then Exit;
FBcnLock.Enter;
try
if not FBcnOpen then Exit;
LowHz := FBcnLowHz;
HighHz := FBcnHighHz;
N := Min(FBcnPixCount, Length(Pixels));
if N <= 0 then Exit;
Move(FBcnSpecPix[0], Pixels[0], N * SizeOf(Single));
Count := N;
Result := FBcnSpecFresh;
FBcnSpecFresh := False;
finally
FBcnLock.Leave;
end;
end;
function TWDSPEngine.GetBeaconZoomWaterfall(var Pixels: array of Single;
out Count: Integer): Boolean;
var N: Integer;
begin
Result := False;
Count := 0;
if not FBcnOpen then Exit;
FBcnLock.Enter;
try
if not FBcnOpen then Exit;
N := Min(FBcnPixCount, Length(Pixels));
if N <= 0 then Exit;
Move(FBcnWfPix[0], Pixels[0], N * SizeOf(Single));
Count := N;
Result := FBcnWfFresh;
FBcnWfFresh := False;
finally
FBcnLock.Leave;
end;
end;
procedure TWDSPEngine.ApplyAnalyzerSettings;
// Совместимый вход: применяет настройки и к RX, и к TX analyzer'у, и к панам.
begin
ApplyRXAnalyzerSettings;
ApplyTXAnalyzerSettings;
ReapplyPanDisplaySettings;
ReapplyBeaconDisplayModes;
end;
procedure TWDSPEngine.SetZoomPan(AZoom, APan: Double);
@@ -1796,6 +2092,9 @@ begin
end;
DestroyAnalyzer(RX_DISP_ID);
DestroyAnalyzer(TX_DISP_ID);
// Лупа маяка: не пересоздаётся в OpenAnalyzer — окно маяка запросит её сама
// (SetBeaconZoom зовётся с таймера формы).
CloseBeaconAnalyzer;
// Анализаторы доп. панов (записи FPans сохраняются — пересоздание в OpenAnalyzer).
FSliceLock.Enter;
try
@@ -2085,6 +2384,8 @@ begin
// главного дисплея сбрасывается (при смене rate это неизбежно), паны
// не затронуты.
if FAnalyzerOpen then ApplyRXAnalyzerSettings;
// Лупа маяка тоже считает окно и FFT от rate — переармируем.
RearmBeaconAnalyzer;
// Beacon-декодер пересчитывает децимацию/RRC под новый IQ-rate.
if FBeaconDec <> nil then FBeaconDec.Configure(FSampleRate);
@@ -2310,6 +2611,26 @@ begin
end;
end;
// Лупа маяка — второй analyzer на том же потоке сэмплов. Живёт только пока
// окно маяка открыто, поэтому в обычном режиме это одна проверка Boolean.
if FBcnOpen then
begin
FBcnDispBuf[FBcnDispPos * 2] := DI;
FBcnDispBuf[FBcnDispPos * 2 + 1] := DQ;
Inc(FBcnDispPos);
if FBcnDispPos >= DISPLAY_BLOCK_SIZE then
begin
FBcnDispPos := 0;
FBcnLock.Enter; // UI-поток может в этот момент рвать analyzer
try
if FBcnOpen then
Spectrum0(1, BCN_DISP_ID, 0, 0, @FBcnDispBuf[0]);
finally
FBcnLock.Leave;
end;
end;
end;
if Bec <> nil then Bec.Feed(DI, DQ);
FRXAccI[FRXAccPos] := DI;
@@ -4058,6 +4379,7 @@ var
i, N, p: Integer;
DispID: Integer;
Cal: Single;
MainCal: Single; // калибровка главного тракта (Cal ниже перетирается панами)
GotSpec, GotWf: Boolean;
begin
if not FInitialized or not FAnalyzerOpen then Exit;
@@ -4068,6 +4390,7 @@ begin
// На TX-дисплее коррекция входного тракта не при чём.
if Assigned(FOnDispCal) and (DispID = RX_DISP_ID) then Cal := FOnDispCal(0)
else Cal := 0;
MainCal := Cal;
Flag := 0;
GetPixels(DispID, 0, @PixBuf[0], @Flag);
@@ -4128,6 +4451,37 @@ begin
if GotWf and Assigned(FOnPanWaterfall) then
FOnPanWaterfall(p, WfBuf, N);
end;
// Лупа маяка: кадр кладём в кэш, а не в колбэк — окно маяка тянет его своим
// таймером (форма живёт отдельно от главного рендера и может быть закрыта).
if FBcnOpen then
begin
FBcnLock.Enter;
try
if FBcnOpen then
begin
N := EnsureRange(FBcnPixCount, 0, SPECTRUM_PIXELS);
Flag := 0;
GetPixels(BCN_DISP_ID, 0, @PixBuf[0], @Flag);
if Flag <> 0 then
begin
for i := 0 to N - 1 do
FBcnSpecPix[i] := PixBuf[i] + MainCal;
FBcnSpecFresh := True;
end;
Flag := 0;
GetPixels(BCN_DISP_ID, 1, @WfBuf[0], @Flag);
if Flag <> 0 then
begin
for i := 0 to N - 1 do
FBcnWfPix[i] := WfBuf[i] + MainCal;
FBcnWfFresh := True;
end;
end;
finally
FBcnLock.Leave;
end;
end;
end;
procedure TWDSPEngine.GetSpectrumData(var Pixels: array of Single;