diff --git a/BeaconLock.pas b/BeaconLock.pas new file mode 100644 index 0000000..afa5338 --- /dev/null +++ b/BeaconLock.pas @@ -0,0 +1,339 @@ +unit BeaconLock; + +{$IFDEF FPC} + {$MODE Delphi} +{$ENDIF} + +{ + QO-100 Beacon Lock — выделенный узкополосный трекер несущей опорного маяка. + + Задача: LNB-гетеродин (≈9750 МГц) дрейфует с температурой на единицы–десятки + кГц → весь downlink плывёт. Маяк (нижний CW 10489.500 МГц) имеет ИЗВЕСТНУЮ + частоту. Меряя, где он реально стоит, получаем ошибку LO и компенсируем её. + + Двухступенчатая схема: + 1. ГРУБЫЙ захват — снаружи (по display-FFT) даёт начальную оценку смещения + маяка в пределах ~±300 Гц; передаётся через Seed. + 2. ТОЧНЫЙ лок (этот модуль) — комплексный NCO сносит ожидаемую частоту маяка + к DC, boxcar-дециматор сужает полосу до ~2 кГц (большой processing gain), + затем FLL по фазовому дискриминатору доводит NCO ровно на несущую. + Лок-метрика — SNR через carrier-EMA / noise-EMA на децимированном потоке. + + Точность: единицы Гц при достаточном SNR (узкая полоса + длинное усреднение + фазы), что и есть «железный» лок (в духе SDR Console). + + Потоки: Feed() зовётся из DSP-потока (дёшево, на сэмпл). Результаты читаются + из управляющего потока через снапшот под критической секцией. +} + +interface + +uses + SysUtils, Math, SyncObjs; + +type + TBeaconState = (bsIdle, // выключен / нет данных + bsSearch, // идёт захват (SNR ниже порога) + bsLock); // несущая захвачена и стабильна + + TBeaconTracker = class + private + FLock: TCriticalSection; + // --- конфигурация --- + FSampleRate: Double; // вход (Hz) + FDecim: Integer; // коэффициент децимации (Fs/FDecim ≈ FDecimRate) + FDecimRate: Double; // частота децимированного потока (Hz) + FLoopGain: Double; // гейн FLL (0..1, малый) + FLockSNRdB: Double; // порог объявления лока + FTargetHz: Double; // ожидаемое смещение маяка от центра (Hz) + // --- состояние NCO/децимации (DSP-поток) --- + FNcoFreq: Double; // текущая частота NCO (быстрая, дёрганая) + FNcoSmooth: Double; // сглаженная оценка смещения маяка (для коррекции/маркера) + FSeedHz: Double; // якорь clamp: NCO не уходит дальше ±CLAMP от сида + FPhCos, FPhSin: Double; // текущий фазор NCO (для сноса к DC) + FStepCos, FStepSin: Double; // инкремент фазора за сэмпл (Δ = −2π·FNcoFreq/Fs) + FNcoNorm: Integer; // счётчик до ренормализации фазора + FAccI, FAccQ: Double; // аккумулятор boxcar-дециматора + FAccN: Integer; + FPrevI, FPrevQ: Double; // предыдущий децимированный сэмпл (для дискриминатора) + FHasPrev: Boolean; + FMeanI, FMeanQ: Double; // EMA комплексного децим. потока → амплитуда несущей + FNoisePow: Double; // EMA мощности шума |z-mean|^2 + FResAvg: Double; // EMA остаточной частоты (для контроля стабильности) + FActive: Boolean; // трекинг включён + // --- снапшот результата (под FLock) --- + FResMeasHz: Double; // измеренное смещение маяка = FNcoFreq на момент снапшота + FResSNRdB: Double; + FResState: TBeaconState; + procedure RecomputeStep; // пересчёт шага фазора по FNcoFreq (фазу НЕ трогает) + procedure ResetPhasor; // сброс фазора в 0 + пересчёт шага + procedure ProcessDecimated(const zi, zq: Double); + procedure Snapshot; + public + constructor Create; + destructor Destroy; override; + + // Настройка под текущий sample rate. DecimRateHz — целевая полоса + // децимированного потока (по умолчанию ~2 кГц: ±1 кГц захват FLL). + procedure Configure(ASampleRate: Double; ADecimRateHz: Double = 4000.0); + // Ожидаемое смещение маяка от DDC-центра (Hz). Меняется при перестройке. + procedure SetTarget(AOffsetHz: Double); + // Грубая оценка от FFT — переинициализирует NCO (захват/переакквизиция). + procedure Seed(AOffsetHz: Double); + procedure Start; + procedure Stop; + procedure Reset; + + // DSP-поток: один комплексный сэмпл @ FSampleRate. + procedure Feed(const I, Q: Double); inline; + + // Управляющий поток: снапшот результата. + function MeasuredOffsetHz: Double; // измеренное смещение маяка от центра + function ErrorHz: Double; // MeasuredOffset − Target (ошибка LO) + function SNRdB: Double; + function State: TBeaconState; + function Locked: Boolean; + property Active: Boolean read FActive; + end; + +implementation + +const + NCO_RENORM = 2048; // ренормализация фазора каждые N сэмплов + EMA_MEAN = 0.002; // постоянная EMA несущей (медленная — узкая полоса) + EMA_NOISE = 0.002; + EMA_RES = 0.01; + EMA_NCO = 0.001; // сглаживание оценки частоты (TC ~0.25 с @4кГц) + CLAMP_HZ = 1500.0; // NCO держим в ±CLAMP от сида (без runaway на край) + NOISE_FLOOR = 1e-30; // ТОЛЬКО защита от деления на ноль (не маскировать сигнал!) + SNR_MAX = 40.0; + +constructor TBeaconTracker.Create; +begin + inherited Create; + FLock := TCriticalSection.Create; + FSampleRate := 1536000.0; + FDecimRate := 4000.0; // квадрат-домен → захват ±FDecimRate/4 = ±1 кГц + FLoopGain := 0.02; + FLockSNRdB := 3.0; + Reset; + Configure(FSampleRate, FDecimRate); +end; + +destructor TBeaconTracker.Destroy; +begin + FLock.Free; + inherited Destroy; +end; + +procedure TBeaconTracker.Configure(ASampleRate, ADecimRateHz: Double); +begin + FLock.Enter; + try + if ASampleRate < 1.0 then ASampleRate := 1536000.0; + if ADecimRateHz < 100.0 then ADecimRateHz := 4000.0; + FSampleRate := ASampleRate; + FDecim := Max(1, Round(ASampleRate / ADecimRateHz)); + FDecimRate := ASampleRate / FDecim; + // сброс рабочих аккумуляторов (NCO частоту сохраняем — это оценка маяка) + FAccI := 0; FAccQ := 0; FAccN := 0; + FHasPrev := False; + FMeanI := 0; FMeanQ := 0; FNoisePow := 0; FResAvg := 0; + ResetPhasor; + finally + FLock.Leave; + end; +end; + +procedure TBeaconTracker.RecomputeStep; +// Пересчёт шага фазора под текущую FNcoFreq (снос на −FNcoFreq, маяк → DC). +// Фазу НЕ трогаем — смена частоты непрерывна по фазе. +var w: Double; +begin + w := -2.0 * Pi * FNcoFreq / FSampleRate; + FStepCos := Cos(w); + FStepSin := Sin(w); +end; + +procedure TBeaconTracker.ResetPhasor; +begin + FPhCos := 1.0; + FPhSin := 0.0; + FNcoNorm := 0; + RecomputeStep; +end; + +procedure TBeaconTracker.SetTarget(AOffsetHz: Double); +begin + FLock.Enter; + try + FTargetHz := AOffsetHz; + finally + FLock.Leave; + end; +end; + +procedure TBeaconTracker.Seed(AOffsetHz: Double); +begin + FLock.Enter; + try + FNcoFreq := AOffsetHz; + FNcoSmooth := AOffsetHz; + FSeedHz := AOffsetHz; // якорь clamp + ResetPhasor; + FHasPrev := False; + FMeanI := 0; FMeanQ := 0; FNoisePow := 0; FResAvg := 0; + finally + FLock.Leave; + end; +end; + +procedure TBeaconTracker.Start; +begin + FActive := True; +end; + +procedure TBeaconTracker.Stop; +begin + FActive := False; +end; + +procedure TBeaconTracker.Reset; +begin + FLock.Enter; + try + FNcoFreq := 0; FNcoSmooth := 0; FSeedHz := 0; FTargetHz := 0; + FAccI := 0; FAccQ := 0; FAccN := 0; + FHasPrev := False; + FMeanI := 0; FMeanQ := 0; FNoisePow := 0; FResAvg := 0; + FResMeasHz := 0; FResSNRdB := -99.0; FResState := bsIdle; + FActive := False; + ResetPhasor; + finally + FLock.Leave; + end; +end; + +procedure TBeaconTracker.Feed(const I, Q: Double); +var + mi, mq, nc, ns: Double; +begin + if not FActive then Exit; + // снос к DC: z * conj(nco) (nco = exp(+jωt), шаг отрицательной частоты) + mi := I * FPhCos - Q * FPhSin; + mq := I * FPhSin + Q * FPhCos; + // продвинуть фазор: phase *= step + nc := FPhCos * FStepCos - FPhSin * FStepSin; + ns := FPhCos * FStepSin + FPhSin * FStepCos; + FPhCos := nc; FPhSin := ns; + Inc(FNcoNorm); + if FNcoNorm >= NCO_RENORM then + begin + // ренормализация (борьба с накоплением ошибки округления) + nc := 1.5 - 0.5 * (FPhCos * FPhCos + FPhSin * FPhSin); + FPhCos := FPhCos * nc; + FPhSin := FPhSin * nc; + FNcoNorm := 0; + end; + // boxcar-дециматор + FAccI := FAccI + mi; + FAccQ := FAccQ + mq; + Inc(FAccN); + if FAccN >= FDecim then + begin + ProcessDecimated(FAccI / FDecim, FAccQ / FDecim); + FAccI := 0; FAccQ := 0; FAccN := 0; + end; +end; + +procedure TBeaconTracker.ProcessDecimated(const zi, zq: Double); +// Работаем в КВАДРАТЕ сигнала w = z²: для BPSK (средний маяк) это снимает +// модуляцию ±180° (b²=1), оставляя чистую несущую на 2× частоте; для чистого +// CW (нижний/верхний) — тоже корректно. Дискриминатор по w даёт 2·остаток, +// делим на 2. Захват ±FDecimRate/4. carrier/noise-EMA считаем в w-домене — +// для BPSK среднее по z = 0, а по w = z² несущая когерентна. +var + wi, wq, pr, pi, phase, res2, resHz, di, dq, np: Double; +begin + wi := zi * zi - zq * zq; // Re(z²) + wq := 2.0 * zi * zq; // Im(z²) + if FHasPrev then + begin + // фазовый дискриминатор по квадрату: arg(w[n] * conj(w[n-1])) + pr := wi * FPrevI + wq * FPrevQ; + pi := wq * FPrevI - wi * FPrevQ; + phase := ArcTan2(pi, pr); + res2 := phase * FDecimRate / (2.0 * Pi); // = 2 · остаточная частота + resHz := 0.5 * res2; + // FLL: подтянуть NCO на остаточную частоту, но удержать в окне ±CLAMP_HZ + // вокруг сида — иначе на BPSK/шуме петля убегает на край спектра. + FNcoFreq := FNcoFreq + FLoopGain * resHz; + if FNcoFreq > FSeedHz + CLAMP_HZ then FNcoFreq := FSeedHz + CLAMP_HZ; + if FNcoFreq < FSeedHz - CLAMP_HZ then FNcoFreq := FSeedHz - CLAMP_HZ; + FResAvg := FResAvg + EMA_RES * (Abs(resHz) - FResAvg); + FNcoSmooth := FNcoSmooth + EMA_NCO * (FNcoFreq - FNcoSmooth); // стаб. оценка + RecomputeStep; // обновить шаг фазора под новую FNcoFreq (фаза непрерывна) + end; + FPrevI := wi; FPrevQ := wq; FHasPrev := True; // FPrev* = предыдущий w + + // carrier-EMA в w-домене (несущая² у DC) и шум как отклонение от среднего + FMeanI := FMeanI + EMA_MEAN * (wi - FMeanI); + FMeanQ := FMeanQ + EMA_MEAN * (wq - FMeanQ); + di := wi - FMeanI; dq := wq - FMeanQ; + np := di * di + dq * dq; + FNoisePow := FNoisePow + EMA_NOISE * (np - FNoisePow); + + Snapshot; +end; + +procedure TBeaconTracker.Snapshot; +var + sigPow, snr: Double; + st: TBeaconState; +begin + sigPow := FMeanI * FMeanI + FMeanQ * FMeanQ; + snr := 10.0 * Log10((sigPow + 1e-30) / (FNoisePow + NOISE_FLOOR)); + if snr > SNR_MAX then snr := SNR_MAX; + // Лок по когерентности несущей (SNR в квадрат-домене). Гейт по остаточной + // частоте убран — на BPSK он давал ложный «не-лок». + if snr >= FLockSNRdB then + st := bsLock + else + st := bsSearch; + FLock.Enter; + try + FResMeasHz := FNcoSmooth; // сглаженная оценка → стабильная коррекция/маркер + FResSNRdB := snr; + FResState := st; + finally + FLock.Leave; + end; +end; + +function TBeaconTracker.MeasuredOffsetHz: Double; +begin + FLock.Enter; try Result := FResMeasHz; finally FLock.Leave; end; +end; + +function TBeaconTracker.ErrorHz: Double; +begin + FLock.Enter; try Result := FResMeasHz - FTargetHz; finally FLock.Leave; end; +end; + +function TBeaconTracker.SNRdB: Double; +begin + FLock.Enter; try Result := FResSNRdB; finally FLock.Leave; end; +end; + +function TBeaconTracker.State: TBeaconState; +begin + if not FActive then Exit(bsIdle); + FLock.Enter; try Result := FResState; finally FLock.Leave; end; +end; + +function TBeaconTracker.Locked: Boolean; +begin + Result := State = bsLock; +end; + +end. diff --git a/MainForm.pas b/MainForm.pas index d06f051..66fd52c 100644 --- a/MainForm.pas +++ b/MainForm.pas @@ -43,7 +43,7 @@ uses PowerInhibit, DeviceStore, RadioBackend, PlutoBackend, - RadioController, ChannelController; + RadioController, ChannelController, BeaconLock; const CLR_BG = TColor($00101010); @@ -134,6 +134,7 @@ type FSpecDrag: Boolean; FSpecDragX0: Integer; // X при нажатии FSpecDragFreq: Double; // FCenterFreq при нажатии + FBeaconArming: Boolean; // ждём клик по маяку (после включения BEACON) FSpectrumDirty: Boolean; // таймер должен перерисовать спектр/водопад // TX DUC-очередь (FDUCPending*) переехала в TRadioController (OnTXIQ). FSMeterPeak: Double; // верхняя граница светлой зоны @@ -237,6 +238,7 @@ type PanelFilter: TPanel; BtnFilter: array[0..FILT_COUNT-1] of TFlatButton; BtnCTun: TFlatButton; + BtnBeacon: TFlatButton; // QO-100 beacon lock (видим только в XVTR-режиме) BtnDUP: TFlatButton; PanelFMSQ: TPanel; BtnFMSQ: TFlatButton; @@ -377,6 +379,8 @@ type procedure BtnModeClick(Sender: TObject); procedure BtnFilterClick(Sender: TObject); procedure BtnCTunClick(Sender: TObject); + procedure BtnBeaconClick(Sender: TObject); + procedure UpdateBeaconButton; procedure BtnDUPClick(Sender: TObject); procedure ApplyDUP(Active: Boolean); procedure BtnTUNClick(Sender: TObject); @@ -1190,6 +1194,12 @@ begin if BtnXvtrBand[i] <> nil then StyleButton(BtnXvtrBand[i], i = FController.FCurrentXvtr); UpdateFreqDisplayMax; // трансвертер может быть >6 ГГц (QO-100) + // BEACON-кнопка актуальна только в трансвертер-режиме (QO-100 lock). + if BtnBeacon <> nil then + begin + BtnBeacon.Visible := FController.FCurrentXvtr >= 0; + UpdateBeaconButton; + end; // Wideband-вид + web-зеркало xvtr (ранее в обёртках Activate/Deactivate). // rfXvtr эмитят ТОЛЬКО ActivateXvtr/DeactivateXvtr — состояние финальное. UpdateWidebandFrequencyView; @@ -1252,6 +1262,8 @@ begin FSpecView.DrawSpectrum; PbSpectrum.Invalidate; end; + rfBeaconLock: + UpdateBeaconButton; // статус/коррекция QO-100 beacon lock rfCTun: begin StyleButton(BtnCTun, FController.FCTun); @@ -1694,6 +1706,15 @@ begin FChannelsDropDown.OnSelect := OnChannelsDropDownSelect; RefreshChannelsDropDown; + // QO-100 beacon lock — 3-я колонка ряда. Видима только в XVTR-режиме + // (см. рендер rfXvtr ниже). Подпись/подсветка обновляются по rfBeaconLock. + BtnBeacon := MakeBtn(PanelRXBlock, 'BEACON', + LeftPanelButtonLeft(LEFT_W, 3, 2), 16, + LeftPanelButtonWidth(LEFT_W, 3, 2), + BTN_H, BtnBeaconClick); + BtnBeacon.Visible := False; + StyleButton(BtnBeacon, False); + MakeLbl(PanelRXBlock, 'VOL', 4, 50); TrkVolume := TFlatSlider.Create(Self); TrkVolume.Parent := PanelRXBlock; TrkVolume.Left := 34; TrkVolume.Top := 46; @@ -2946,6 +2967,8 @@ begin Exit; end; + // QO-100 beacon lock: одна итерация контура (no-op если лок выключен). + FController.ServiceBeaconLock; // Сглаженное среднее (EMA) FSMeterAvg := FSMeterAvg * (1.0 - AVG_ALPHA) + FController.FLastSMeter * AVG_ALPHA; @@ -3864,6 +3887,59 @@ begin FController.SetCTun(not FController.FCTun); end; +procedure TMainForm.BtnBeaconClick(Sender: TObject); +begin + FController.SetBeaconLock(not FController.BeaconLockEnabled); + // При включении — «вооружаем» клик: следующий ЛКМ по спектру укажет маяк + // (надёжнее автопоиска в забитой полосе). Авто-сид работает до клика. + FBeaconArming := FController.BeaconLockEnabled; + UpdateBeaconButton; +end; + +procedure TMainForm.UpdateBeaconButton; +// Подпись + подсветка кнопки BEACON по состоянию лока. Зовётся из BtnBeaconClick +// и из рендера rfBeaconLock (контур обновляет статус каждый тик). +var + cap: string; + corr: Double; +begin + if BtnBeacon = nil then Exit; + // Маркеры на спектре: опорная частота (зелёная) + позиция трекера (оранж). + if FSpecView <> nil then + FSpecView.SetBeaconMarkers(FController.BeaconLockEnabled, + FController.BeaconRefHz, + FController.BeaconTrackedFreqHz); + if not FController.BeaconLockEnabled then + begin + BtnBeacon.Caption := 'BEACON'; + StyleButton(BtnBeacon, False); + end + else if FBeaconArming then + begin + BtnBeacon.Caption := 'CLICK BCN'; // ждём клик пользователя по маяку + StyleButton(BtnBeacon, True); + end + else + begin + case FController.BeaconState of + bsLock: + begin + corr := FController.BeaconCorrectionHz; + // LOCK + коррекция LO (Гц) + SNR — видно запас. + cap := Format('L%.0f /%.0f', [corr, FController.BeaconSNRdB]); + StyleButton(BtnBeacon, True); + end; + bsSearch: + // Показываем SNR при поиске — диагностика: растёт → маяк ловится. + begin cap := Format('BCN ?%.0f', [FController.BeaconSNRdB]); StyleButton(BtnBeacon, True); end; + else + begin cap := 'BCN --'; StyleButton(BtnBeacon, False); end; + end; + BtnBeacon.Caption := cap; + end; + BtnBeacon.Repaint; +end; + // --------------------------------------------------------------------------- // DUP toggle (Thetis-style local display mode — без аппаратной команды) // --------------------------------------------------------------------------- @@ -4057,6 +4133,15 @@ begin if Button = mbLeft then begin + // Наведение beacon-lock: ЛКМ указывает на маяк (вместо перестройки VFO). + if FBeaconArming and (PbSpectrum.Width > 0) then + begin + FController.BeaconSeedAtHz( + PixelToFreq(X, PbSpectrum.Width, FController.FCenterFreq, FController.FSpanHz)); + FBeaconArming := False; + UpdateBeaconButton; + Exit; + end; // Левая кнопка: сбрасываем маркер если активен if FSpecView.MarkerActive then begin diff --git a/RadioController.pas b/RadioController.pas index 3a0bba3..fc9ca93 100644 --- a/RadioController.pas +++ b/RadioController.pas @@ -50,7 +50,7 @@ uses Classes, SysUtils, Math, HPSDRProtocol, HPSDRNetwork, RadioBackend, PlutoBackend, IIOBindings, WDSPEngine, AudioOutput, AudioInput, - Settings, ChannelStore, FMRepeater, BoardUtils, DeviceStore; + Settings, ChannelStore, FMRepeater, BoardUtils, DeviceStore, BeaconLock; type // Поле, которое изменилось — для гранулярных уведомлений наружу. @@ -71,6 +71,7 @@ type rfChannel, rfSMeter, rfPower, rfSWR, rfSupply, rfPLL, // телеметрия rfADCOverload, // ADC overload (рендер индикатора) + rfBeaconLock, // QO-100 beacon lock: статус/коррекция rfDevice, // подключённое устройство сменилось rfDeviceList // список discovered устройств обновился ); @@ -176,6 +177,20 @@ type FCurrentBand: Integer; FCurrentXvtr: Integer; + // ---- QO-100 beacon lock ---- + FBeaconTracker: TBeaconTracker; // владеем (Free в FreeEngines) + FBeaconLockOn: Boolean; // лок включён пользователем + FBeaconLockHz: Double; // текущая динамич. коррекция LO (Hz), поверх LOError + FBeaconRefHz: Double; // опорная частота маяка (Hz, downlink), деф. 10489.500e6 + FBeaconSearchTicks: Integer; // тиков без лока (троттлинг переакквизиции) + FBeaconManualSeed: Boolean; // пользователь указал маяк кликом → не автопоиск + // FFT-трекинг маяка (робастный, по display-FFT — без NCO/FLL): + FBeaconTrackHz: Double; // отслеживаемая АБСОЛЮТНАЯ display-частота маяка + // (не baseband-offset — иначе сдвиг центра/водопада ломает окно) + FBeaconPromDB: Double; // «высота» пика над шумом окна (лок-метрика) + FBeaconLocked: Boolean; // пик уверенно виден + FBeaconCorrCooldown: Integer; // тиков паузы после коррекции (осадка пайплайна) + // ---- Аудио / TX ---- FVolume: Integer; FMuted: Boolean; @@ -321,6 +336,12 @@ type // ActiveTXFreqHz — текущая TX-частота (split/active VFO + FM-репитер сдвиг). function XvtrTranslate(VisibleHz: Double): Double; function ActiveTXFreqHz: Double; + // Грубый поиск пика маяка в display-FFT около ожидаемой позиции → baseband + // offset (Hz). False если маяк вне span / слишком слаб. Сид для трекера. + function CoarseBeaconOffset(ExpectOff: Double; out OffHz: Double): Boolean; + // Измерение пика маяка по display-FFT в окне вокруг ExpectOff: возвращает + // уточнённое смещение (parabolic) и «высоту» пика над средним окна (для лока). + function MeasureBeaconFFT(ExpectOff: Double; out PeakOff, PromDB: Double): Boolean; // Применяет текущие FMode/FFilterBW/FM-параметры к DSP (RX/TX фильтр, // FM-девиация, squelch). Вызывается командами и UI-путями смены режима. @@ -425,6 +446,18 @@ type procedure SetSpan(Hz: Integer); procedure SetSampleRate(Hz: Integer); + // QO-100 beacon lock — стабилизация дрейфа LNB по опорному маяку. + procedure SetBeaconLock(On_: Boolean); + procedure ServiceBeaconLock; // одна итерация контура (зовётся UI-таймером ~4 Гц) + procedure SetBeaconRefHz(Hz: Double); // опорная частота маяка (downlink) + procedure BeaconSeedAtHz(DisplayHz: Double); // пользователь указал маяк кликом + function BeaconState: TBeaconState; + function BeaconCorrectionHz: Double; // текущая коррекция LO (FBeaconLockHz) + function BeaconSNRdB: Double; + function BeaconLockEnabled: Boolean; + function BeaconRefHz: Double; // опорная частота маяка (для маркера) + function BeaconTrackedFreqHz: Double;// где сидит трекер: центр + measured offset + // Отображение / приём procedure SetCTun(On_: Boolean); procedure SetDuplex(On_: Boolean); @@ -520,6 +553,13 @@ begin // буфер вдвое сокращает построение downsampler в OpenChannel RXA. FDSPEngine := TWDSPEngine.Create(ASampleRate, 48000, 512); + // QO-100 beacon lock: трекер кормится RX-IQ из движка (тап в PushIQItemToDSP). + FBeaconTracker := TBeaconTracker.Create; + FBeaconRefHz := 10489750000.0; // средний BPSK-маяк NB-транспондера (downlink) + FBeaconLockHz := 0.0; + FBeaconLockOn := False; + FDSPEngine.SetBeaconTracker(FBeaconTracker); + // Audio out/in — объекты создаём сейчас, Open вызывается позже (после показа // формы / при подключении устройства). FAudioOut := TAudioOutput.Create(48000); @@ -537,6 +577,9 @@ begin if Assigned(FAudioOut) then begin FAudioOut.Close; FreeAndNil(FAudioOut); end; if Assigned(FAudioIn) then begin FAudioIn.Close; FreeAndNil(FAudioIn); end; if Assigned(FDSPEngine) then begin FDSPEngine.Close; FreeAndNil(FDSPEngine); end; + // Трекер освобождаем ПОСЛЕ движка — движок (Close остановил DSP-поток) больше + // не вызовет Feed. Указатель в движке уже мёртв вместе с движком. + if Assigned(FBeaconTracker) then FreeAndNil(FBeaconTracker); end; destructor TRadioController.Destroy; @@ -899,10 +942,13 @@ begin Result := VisibleHz; if (FCurrentXvtr < 0) or (FCurrentXvtr >= CFG_XVTR_COUNT) then Exit; if not FXvtrSettings.Entries[FCurrentXvtr].Enabled then Exit; - // f_IF = f_visible - LOOffset + LOError + // f_IF = f_visible - LOOffset + LOError + BeaconLock + // FBeaconLockHz — динамическая коррекция дрейфа LNB (QO-100 beacon lock), + // поверх статичной ручной LOError. 0 когда лок выключен. Result := VisibleHz - FXvtrSettings.Entries[FCurrentXvtr].LOOffset - + FXvtrSettings.Entries[FCurrentXvtr].LOError; + + FXvtrSettings.Entries[FCurrentXvtr].LOError + + FBeaconLockHz; end; function TRadioController.ActiveTXFreqHz: Double; @@ -919,6 +965,226 @@ begin end; end; +// --------------------------------------------------------------------------- +// QO-100 beacon lock +// --------------------------------------------------------------------------- + +const + BEACON_GAIN = 0.4; // гейн контроллера (доля ошибки за коррекцию) + BEACON_DEADBAND_HZ = 75.0; // внутри — коррекции стоп (без вечного подкручивания) + BEACON_SETTLE_TICKS = 8; // пауза (~0.8 с @10Гц) после коррекции — осадка FFT-пайплайна + BEACON_UNLOCK_PROM_DB = 3.0; // гистерезис: лок снимается ниже этого (lock при LOCK_PROM) + BEACON_SEARCH_HZ = 6000.0; // окно поиска пика вокруг клика/ожидаемой позиции + BEACON_REACQ_TICKS = 30; // тиков (≈3 с @10Гц) до переакквизиции по FFT + BEACON_LOCK_PROM_DB = 6.0; // пик над средним окна (dB) для статуса LOCK + BEACON_LOBE_DB = 10.0; // ширина лепестка для центроида (dB ниже пика) + +function TRadioController.CoarseBeaconOffset(ExpectOff: Double; out OffHz: Double): Boolean; +// Ищет максимум display-FFT в окне ±BEACON_SEARCH_HZ вокруг ExpectOff (baseband +// offset). Пиксели покрывают анализаторную полосу = FSampleRate, центр (пиксель +// n/2) = baseband 0, рост вправо — та же конвенция, что и маркеры (проверено: +// зелёная садится на маяк). Возвращает уточнённый baseband offset пика. +var + pix: array[0..SPECTRUM_PIXELS - 1] of Single; + n, i, best: Integer; + hzPerPix, lo, hi, off, denom, delta: Double; + bestv: Single; +begin + Result := False; OffHz := 0; + if not FWDSPReady or not Assigned(FDSPEngine) then Exit; + n := 0; + FDSPEngine.GetSpectrumData(pix, n); + if n < 8 then Exit; + hzPerPix := FSpanHz / n; + lo := ExpectOff - BEACON_SEARCH_HZ; + hi := ExpectOff + BEACON_SEARCH_HZ; + best := -1; bestv := -1e30; + for i := 0 to n - 1 do + begin + off := (i - n / 2) * hzPerPix; // baseband offset пикселя i + if (off < lo) or (off > hi) then Continue; + if pix[i] > bestv then begin bestv := pix[i]; best := i; end; + end; + if best < 0 then Exit; // окно вне span + delta := 0.0; + // Параболическая интерполяция пика (пиксели в dB ≈ гауссов пик) — субдискретная + // точность, чтобы сид попал в ±1 кГц окно захвата FLL даже при крупном бине. + if (best > 0) and (best < n - 1) then + begin + denom := pix[best - 1] - 2.0 * pix[best] + pix[best + 1]; + if Abs(denom) > 1e-12 then + delta := 0.5 * (pix[best - 1] - pix[best + 1]) / denom; + if delta > 0.5 then delta := 0.5; + if delta < -0.5 then delta := -0.5; + end; + OffHz := (best + delta - n / 2) * hzPerPix; + Result := True; +end; + +function TRadioController.MeasureBeaconFFT(ExpectOff: Double; + out PeakOff, PromDB: Double): Boolean; +// Маяк по display-FFT в окне ±BEACON_SEARCH_HZ вокруг ExpectOff. Средний маяк — +// BPSK с ПОДАВЛЕННОЙ несущей: в спектре не линия, а широкая симметричная «полка» +// (~2.4 кГц), peak-finder по ней скачет. Поэтому позицию берём как +// POWER-ВЗВЕШЕННЫЙ ЦЕНТРОИД лепестка (пиксели в пределах BEACON_LOBE_DB от пика): +// центр симметричной полки = несущая, стабилен. PromDB — пик над средним окна. +var + pix: array[0..SPECTRUM_PIXELS - 1] of Single; + n, i, best, cnt: Integer; + hzPerPix, lo, hi, off, sum, thr, p, wsum, fsum: Double; + bestv: Single; +begin + Result := False; PeakOff := ExpectOff; PromDB := -99.0; + if not FWDSPReady or not Assigned(FDSPEngine) then Exit; + n := 0; + FDSPEngine.GetSpectrumData(pix, n); + if n < 8 then Exit; + hzPerPix := FSpanHz / n; + lo := ExpectOff - BEACON_SEARCH_HZ; + hi := ExpectOff + BEACON_SEARCH_HZ; + best := -1; bestv := -1e30; sum := 0; cnt := 0; + for i := 0 to n - 1 do + begin + off := (i - n / 2) * hzPerPix; + if (off < lo) or (off > hi) then Continue; + sum := sum + pix[i]; Inc(cnt); + if pix[i] > bestv then begin bestv := pix[i]; best := i; end; + end; + if (best < 0) or (cnt < 4) then Exit; + // Центроид лепестка: пиксели в пределах BEACON_LOBE_DB ниже пика, вес — + // линейная мощность над порогом (шум за порогом не входит). + thr := bestv - BEACON_LOBE_DB; + wsum := 0; fsum := 0; + for i := 0 to n - 1 do + begin + off := (i - n / 2) * hzPerPix; + if (off < lo) or (off > hi) then Continue; + if pix[i] < thr then Continue; + p := Power(10.0, (pix[i] - thr) / 10.0); // линейная мощность над порогом + wsum := wsum + p; + fsum := fsum + p * off; + end; + if wsum > 0 then PeakOff := fsum / wsum + else PeakOff := (best - n / 2) * hzPerPix; + PromDB := bestv - (sum / cnt); // пик над средним окна (dB) + Result := True; +end; + +procedure TRadioController.SetBeaconRefHz(Hz: Double); +begin + FBeaconRefHz := Hz; +end; + +procedure TRadioController.BeaconSeedAtHz(DisplayHz: Double); +// Пользователь кликнул по маяку → задаём область отслеживания (уточняем пик по +// display-FFT рядом с кликом) и включаем ручной режим (контур ведёт ИМЕННО этот +// пик, без автопоиска, который в забитой полосе хватает соседей). +var clickOff, refined, prom: Double; +begin + FBeaconManualSeed := True; + FBeaconSearchTicks := 0; + FBeaconCorrCooldown := BEACON_SETTLE_TICKS; // дать FFT устаканиться после сида + clickOff := DisplayHz - FCenterFreq; + if MeasureBeaconFFT(clickOff, refined, prom) then + FBeaconTrackHz := FCenterFreq + refined + else + FBeaconTrackHz := DisplayHz; + if not FBeaconLockOn then SetBeaconLock(True); +end; + +procedure TRadioController.SetBeaconLock(On_: Boolean); +var refined, prom: Double; +begin + if On_ = FBeaconLockOn then Exit; + FBeaconLockOn := On_; + if On_ then + begin + FBeaconSearchTicks := 0; + FBeaconCorrCooldown := 0; + FBeaconManualSeed := False; // авто до первого клика пользователя + FBeaconLocked := False; + // Начальная область — у опорной частоты (уточняем пик, если виден). + if MeasureBeaconFFT(FBeaconRefHz - FCenterFreq, refined, prom) then + FBeaconTrackHz := FCenterFreq + refined + else + FBeaconTrackHz := FBeaconRefHz; + end; + // При выключении FBeaconLockHz замораживаем (как калибровка LNB). + Changed(rfBeaconLock); +end; + +procedure TRadioController.ServiceBeaconLock; +// Одна итерация контура (UI-таймер @10 Гц). Меряет пик маяка по display-FFT, +// сглаживает, и (после ручного клика) тянет FBeaconLockHz так, чтобы маяк +// оставался на опорной частоте → весь downlink стабилен. Без NCO/FLL. +var pk, prom, measDisp, e: Double; +begin + if not FBeaconLockOn then Exit; + if FCurrentXvtr < 0 then Exit; // только в трансвертер-режиме + // Окно поиска — вокруг ТЕКУЩЕЙ display-позиции маяка, пересчитанной в baseband + // через актуальный FCenterFreq. Поэтому сдвиг водопада/центра окно не ломает. + if not MeasureBeaconFFT(FBeaconTrackHz - FCenterFreq, pk, prom) then Exit; + + // Сопровождаем центроид плавно (в display-частоте; стабильность важнее скорости). + FBeaconTrackHz := FBeaconTrackHz + 0.12 * ((FCenterFreq + pk) - FBeaconTrackHz); + FBeaconPromDB := FBeaconPromDB + 0.2 * (prom - FBeaconPromDB); + // Гистерезис лока: загорается при LOCK_PROM, гаснет ниже UNLOCK_PROM (без мерцания). + if FBeaconPromDB >= BEACON_LOCK_PROM_DB then FBeaconLocked := True + else if FBeaconPromDB < BEACON_UNLOCK_PROM_DB then FBeaconLocked := False; + + // Коррекцию НЕ применяем каждый тик: display-FFT отстаёт от ретюна LO на + // задержку пайплайна, и спам-коррекции дают перелёт (маяк улетает из окна). + // После каждой коррекции ждём BEACON_SETTLE_TICKS, давая FFT отразить новый LO. + // Слежение за пиком (выше) идёт каждый тик — окно едет за маяком. + if FBeaconCorrCooldown > 0 then + Dec(FBeaconCorrCooldown) + else if FBeaconLocked and FBeaconManualSeed then + begin + // e = видимое_смещение_маяка − опорное. d(e)/d(FBeaconLockHz) = −1, + // поэтому FBeaconLockHz += e подтягивает маяк к опорной (см. вывод знака). + measDisp := FBeaconTrackHz; + e := measDisp - FBeaconRefHz; + if Abs(e) > BEACON_DEADBAND_HZ then + begin + FBeaconLockHz := FBeaconLockHz + BEACON_GAIN * e; + PushNetworkState; // ретюн LNB-IF: новый LO = XvtrTranslate(центр) + FBeaconCorrCooldown := BEACON_SETTLE_TICKS; + end; + end; + Changed(rfBeaconLock); +end; + +function TRadioController.BeaconState: TBeaconState; +begin + if not FBeaconLockOn then Exit(bsIdle); + if FBeaconLocked then Result := bsLock else Result := bsSearch; +end; + +function TRadioController.BeaconCorrectionHz: Double; +begin + Result := FBeaconLockHz; +end; + +function TRadioController.BeaconSNRdB: Double; +begin + Result := FBeaconPromDB; // «высота» пика над шумом окна (dB) +end; + +function TRadioController.BeaconLockEnabled: Boolean; +begin + Result := FBeaconLockOn; +end; + +function TRadioController.BeaconRefHz: Double; +begin + Result := FBeaconRefHz; +end; + +function TRadioController.BeaconTrackedFreqHz: Double; +begin + Result := FBeaconTrackHz; +end; + procedure TRadioController.ApplyModeFilter; var Lo, Hi, Half: Integer; diff --git a/SpectrumView.pas b/SpectrumView.pas index ca6a65f..96bb578 100644 --- a/SpectrumView.pas +++ b/SpectrumView.pas @@ -73,6 +73,10 @@ type // ── Marker ──────────────────────────────────────────────────────────────── FMarkerActive: Boolean; FMarkerX: Integer; + // ── QO-100 beacon markers (диагностика лока) ────────────────────────────── + FBeaconMarkActive: Boolean; + FBeaconRefFreq: Double; // опорная частота маяка (где ДОЛЖЕН быть) + FBeaconTrkFreq: Double; // где сидит трекер (NCO) сейчас // ── Spectrum buffer ─────────────────────────────────────────────────────── FSpectrumBuf: array[0..1023] of Single; FSpectrumBufCount: Integer; @@ -99,6 +103,7 @@ type procedure SetFillSpectrum(V: Boolean); procedure DrawSpectrumGradient(const SpPts: array of TPoint; W, H: Integer); procedure DrawMarkerLine(C: TCanvas; W, H: Integer); + procedure DrawBeaconMarkers(C: TCanvas; W, H: Integer); procedure BlendBand(X1, X2, H: Integer; R, G, B, Alpha: Byte); procedure CopyGridToSpectrum(W, H: Integer); procedure DrawADCOverloadOverlay(C: TCanvas; W, H: Integer); @@ -182,6 +187,10 @@ type property MarkerActive: Boolean read FMarkerActive write SetMarkerActive; property MarkerX: Integer read FMarkerX write SetMarkerX; + // QO-100 beacon-маркеры: опорная частота (зелёная) + позиция трекера (оранж). + // Спектр перерисовывается каждый кадр, поэтому отдельный invalidate не нужен. + procedure SetBeaconMarkers(Active: Boolean; RefHz, TrkHz: Double); + // ── S-метр ──────────────────────────────────────────────────────────────── property LastSMeter: Double read GetLastSMeter write SetLastSMeter; property SMeterPeak: Double read GetSMeterPeak write SetSMeterPeak; @@ -485,6 +494,32 @@ begin C.TextOut(MX - 4 - C.TextWidth(MarkerLbl), 4, MarkerLbl); end; +procedure TSpectrumView.SetBeaconMarkers(Active: Boolean; RefHz, TrkHz: Double); +begin + FBeaconMarkActive := Active; + FBeaconRefFreq := RefHz; + FBeaconTrkFreq := TrkHz; +end; + +procedure TSpectrumView.DrawBeaconMarkers(C: TCanvas; W, H: Integer); +// Две вертикали: опорная частота маяка (зелёная пунктир) и текущая позиция +// трекера (оранжевая сплошная). Расхождение видно глазом → понятно, на пик +// сел трекер или на шум. + procedure Vline(FreqHz: Double; Col: TColor; Style: TPenStyle); + var x: Integer; + begin + if FSpanHz <= 0 then Exit; + x := Round((FreqHz - (FCenterFreq - FSpanHz / 2)) / FSpanHz * W); + if (x < 0) or (x >= W) then Exit; + C.Pen.Color := Col; C.Pen.Width := 1; C.Pen.Style := Style; + C.MoveTo(x, 0); C.LineTo(x, H); + end; +begin + Vline(FBeaconRefFreq, clLime, psDot); // где маяк ДОЛЖЕН быть + Vline(FBeaconTrkFreq, TColor($000AA5FF), psSolid); // где сидит трекер (оранж) + C.Pen.Style := psSolid; +end; + procedure TSpectrumView.BlendBand(X1, X2, H: Integer; R, G, B, Alpha: Byte); var Y, X, InvA: Integer; @@ -940,6 +975,7 @@ begin end; if FMarkerActive then DrawMarkerLine(C, W, H); + if FBeaconMarkActive then DrawBeaconMarkers(C, W, H); DrawADCOverloadOverlay(C, W, H); if Assigned(FSampleRateOverlay) then FSampleRateOverlay.DrawOverlay(FSpectrumBitmap, C, W, H); diff --git a/SpectrumViewOpengl.pas b/SpectrumViewOpengl.pas index f70c92a..d8a8de7 100644 --- a/SpectrumViewOpengl.pas +++ b/SpectrumViewOpengl.pas @@ -75,6 +75,7 @@ type procedure DrawAGCLines(W, H: Integer; DBmax, InvRange: Double); procedure DrawSpectrumCurve(W, H: Integer; DBmax, InvRange: Double); procedure DrawMarker(W, H: Integer); + procedure DrawBeaconMarkersGL(W, H: Integer); procedure DrawADCOverlay(W, H: Integer); procedure DrawCachedOverlays(W, H: Integer); function FormatFreqGL(Hz: Double): string; @@ -656,6 +657,21 @@ begin else DrawTexture(FMarkerLabelTex, MX - 4 - FMarkerLabelTex.W, 4); end; +procedure TSpectrumViewOpenGL.DrawBeaconMarkersGL(W, H: Integer); +// QO-100 beacon-маркеры: опорная частота (зелёная) + позиция трекера (оранж). + procedure Vline(FreqHz: Double; Col: TColor); + var x: Integer; + begin + if FSpanHz <= 0 then Exit; + x := Round((FreqHz - (FCenterFreq - FSpanHz / 2)) / FSpanHz * W); + if (x < 0) or (x >= W) then Exit; + DrawLine(x, 0, x, H, Col, 1, False); + end; +begin + Vline(FBeaconRefFreq, clLime); // где маяк ДОЛЖЕН быть + Vline(FBeaconTrkFreq, TColor($000AA5FF)); // где сидит трекер (оранж) +end; + procedure TSpectrumViewOpenGL.DrawADCOverlay(W, H: Integer); var B: TBitmap; @@ -781,6 +797,7 @@ begin DrawAGCLines(W, H, DBmax, InvRange); DrawSpectrumCurve(W, H, DBmax, InvRange); DrawMarker(W, H); + if FBeaconMarkActive then DrawBeaconMarkersGL(W, H); DrawADCOverlay(W, H); DrawCachedOverlays(W, H); diff --git a/WDSPEngine.pas b/WDSPEngine.pas index 0fc15a9..af363f1 100644 --- a/WDSPEngine.pas +++ b/WDSPEngine.pas @@ -25,7 +25,7 @@ interface uses Classes, SysUtils, Math, SyncObjs, - WDSP; + WDSP, BeaconLock; const // WDSP mode integers (нет именованных констант в WDSP.pas) @@ -292,6 +292,7 @@ type FMuted: Boolean; FVolume: Double; FLastError: string; + FBeaconTracker: TBeaconTracker; // QO-100 beacon lock (НЕ владеем; см. контроллер) function ModeToWDSP(Mode: Integer): Integer; procedure ApplyDefaultFilter; @@ -354,6 +355,9 @@ type procedure UpdateAGCLines(SpectrumW: Integer); // читает hang/thresh из WDSP для линии на спектре procedure SetSpectrumWidth(W: Integer); // обновляет FLastSpectrumW для корректных AGC линий procedure SetShift(ShiftHz: Double); // CTUN NCO сдвиг + // QO-100 beacon lock: трекер кормится RX-IQ из DSP-потока. Владелец — + // контроллер; движок лишь подаёт сэмплы и сообщает sample rate. + procedure SetBeaconTracker(T: TBeaconTracker); procedure SetNRMode(Mode: Integer); procedure SetNR(Enable: Boolean); procedure SetNBMode(Mode: Integer); @@ -1119,6 +1123,7 @@ begin // Обновляем параметры FSampleRate := NewRate; FBufSize := FAudioBufSize * NewRate div FAudioRate; + if FBeaconTracker <> nil then FBeaconTracker.Configure(FSampleRate); // Перераспределяем буферы под новый размер SetLength(FRXIn, FBufSize * 2); @@ -1213,6 +1218,11 @@ begin FeedDisplaySample(IR * SCALE, QR * SCALE); + // QO-100 beacon lock: тот же baseband-IQ @ FSampleRate (трекер сам + // мгновенно выходит, если лок не активен — стоимость ~1 проверка/сэмпл). + if FBeaconTracker <> nil then + FBeaconTracker.Feed(IR * SCALE, QR * SCALE); + FRXAccI[FRXAccPos] := IR * SCALE; FRXAccQ[FRXAccPos] := QR * SCALE; Inc(FRXAccPos); @@ -1371,6 +1381,12 @@ begin UpdateAGCLines(FLastSpectrumW); end; +procedure TWDSPEngine.SetBeaconTracker(T: TBeaconTracker); +begin + FBeaconTracker := T; + if (T <> nil) and (FSampleRate > 0) then T.Configure(FSampleRate); +end; + procedure TWDSPEngine.SetShift(ShiftHz: Double); begin FShiftHz := ShiftHz;