feat: QO-100 central beacon BPSK decoder (Milestone 1 — stable demod)

Декодер центрального маяка QO-100 (10489.750, AO-40 BPSK 400 бод): фронтенд
до мягких символов + констелляция, с устойчивым захватом несущей/символа.

Цепочка (BeaconDecoder.pas): NCO-снос смещения → boxcar-децим до 9600 →
RRC матч-фильтр → AGC-нормировка → Gardner timing → Costas BPSK с frequency
offload в пред-RRC NCO. squaring-FFT оставлена индикатором несущей (prom).

Ключевые решения по итогам эфирной отладки:
- invert=True для тракта QO-100 (LNB+трансвертер зеркалит спектр).
- Декодер развязан от beacon-lock (тот ретюнил железо, рвал IQ).
- AGC-нормировка петель: сигнал ~-60 dBFS замораживал Costas/Gardner.
- Frequency offload: накопл. снос Costas стекает в FResidInc (без потолка
  ±π/символ) → держит ошибку наведения (1пкс≈300Гц) + дрейф LNB.

Интеграция: тап IQ в WDSPEngine.PushIQItemToDSP; RadioController владеет
декодером, клик-наведение (ПКМ BEACON + ЛКМ/Shift+ЛКМ по спектру); узкий
фильтр-маркер в SpectrumView/GL; окно констелляции BeaconScopeForm; файловый
лог HOME/ewsdr_beacon.log для диагностики.

Milestone 2 (далее): FEC — Viterbi k=7 + RS(160,128) + интерливер + дескремблер.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-06-21 18:15:00 +03:00
co-authored by Claude Opus 4.8
parent 28adf9508a
commit c063d7a197
8 changed files with 1134 additions and 11 deletions
+689
View File
@@ -0,0 +1,689 @@
unit BeaconDecoder;
{
QO-100 central beacon — DSP front-end (Milestone 1).
Средний маяк QO-100 (10489.750) = AMSAT AO-40 FEC телеметрия: BPSK 400 бод.
Этот юнит делает ТОЛЬКО демодуляцию до мягких символов и снимает констелляцию
для визуализации/диагностики. FEC-бэкенд (Viterbi k=7 + RS(160,128) + интерливер
+ дескремблер) подключается отдельно (libfec Карна) на следующем этапе.
Цепочка (вход — комплексный baseband IQ на FSampleRate из DDC, после beacon-lock):
NCO-снос смещения маяка к DC
→ boxcar-дециматор до ~9600 Гц (целое D)
→ RRC матч-фильтр (beta 0.5)
→ Gardner TED + линейный интерполятор (символьная синхра 400 бод)
→ Costas (BPSK, 2-й порядок) (фаза/несущая)
→ мягкий символ sign(I), констелляция, метрики (SNR, lock).
Feed() зовётся из DSP-потока (WDSPEngine.PushIQItemToDSP), снимок читается из UI —
обмен под критической секцией, как в прежнем BeaconLock.
}
{$IFDEF FPC}
{$MODE Delphi}
{$ENDIF}
interface
uses
Classes, SysUtils, SyncObjs, Math;
const
BCN_BAUD = 400.0; // символьная скорость маяка
BCN_TARGET_FS = 9600.0; // целевой rate после децимации
BCN_RRC_BETA = 0.5; // roll-off матч-фильтра
BCN_RRC_SPAN = 8; // длина RRC в символах (полу-)
BCN_SCOPE_PTS = 256; // точек констелляции в снимке
BCN_NFFT = 4096; // FFT грубой carrier-acquisition (~0.43 c @ 9600)
BCN_NFFT_LOG2 = 12;
type
// Снимок состояния для UI (констелляция + метрики). Копируется под CS.
TBeaconScope = record
Enabled: Boolean;
Count: Integer; // заполнено точек [0..BCN_SCOPE_PTS]
PtI, PtQ: array[0..BCN_SCOPE_PTS-1] of Single; // нормированные символы
SNRdB: Single;
SymRate: Single; // измеренная скорость, ~400
CarrierLock: Boolean;
SymbolLock: Boolean;
OffsetHz: Double; // текущий снос NCO
ResidHz: Single; // найденный остаточный снос несущей
CarProm: Single; // prominence линии несущей (squaring-FFT)
FsHz: Single; // rate IQ-тапа (диагностика)
FdecHz: Single; // rate после децимации
Sps: Single; // отсчётов на символ
DecRMS: Single; // RMS децимированного сигнала (до RRC)
SymMag: Single; // |символ| после RRC (lock-независимо)
TimErr: Single; // EMA |ошибки Gardner| (таймер-петля)
Wsym: Single; // дробная поправка периода символа
CarFreqHz: Single; // частота Costas (Гц, остаток несущей)
LockHold: Integer; // холдовер-счётчик лока
end;
TBeaconDecoder = class
private
FLock: TCriticalSection;
FEnabled: Boolean;
// --- параметры rate ---
FFs: Double; // вход (DDC) Гц
FDecim: Integer; // целая децимация
FFdec: Double; // rate после децимации
FSps: Double; // отсчётов на символ после децимации
// --- NCO сноса ---
FOffsetHz: Double;
FNcoPh: Double;
FNcoInc: Double;
FInvert: Boolean; // зеркальный baseband (QO-100/LNB): сопряжение IQ
// --- boxcar дециматор ---
FAccI, FAccQ: Double;
FAccN: Integer;
// --- RRC матч-фильтр ---
FRRC: array of Double;
FHistI: array of Double; // кольцевой буфер decimated I
FHistQ: array of Double;
FHistPos: Integer;
FNtaps: Integer;
// --- Gardner timing ---
FMuAcc: Double; // обратный отсчёт до следующего полу-символьного строба
FHalf: Boolean; // текущий строб = середина (true) или центр символа
FMidI, FMidQ: Double; // отсчёт середины (для Gardner)
FPrevI, FPrevQ: Double; // прошлый центр символа
FYd0I, FYd0Q: Double; // линия задержки RRC-выхода (новейший)
FYd1I, FYd1Q: Double; // предыдущий
FWsym: Double; // дробная коррекция периода (интегратор)
FTimErrEMA: Double;
// --- Costas (BPSK) ---
FCarPh: Double;
FCarFreq: Double;
FCarAlpha: Double; // proportional
FCarBeta: Double; // integral
// --- грубая carrier-acquisition (squaring-FFT) ---
// BPSK² снимает модуляцию → линия на 2·Δf. Находит подавленную несущую
// независимо от знака сноса (алиасинг сворачивает её в полосу), игнорируя
// DC-offset Pluto (вычитаем среднее перед возведением в квадрат).
FAcqRe, FAcqIm: array of Double; // буфер декадированных отсчётов (NFFT)
FAcqPos: Integer;
FResidDf: Double; // накопленный остаточный снос несущей (Гц)
FResidPh: Double;
FResidInc: Double;
FCarProm: Double; // prominence линии (EMA)
FCarPresent: Boolean; // линия несущей видна
FFreqLocked: Boolean; // Costas держит фазу (для индикации/symbol-lock)
FLockHold: Integer; // холдовер-счётчик лока (анти-мерцание)
// --- FLL (частотная петля Costas, decision-directed) ---
FFllGain: Double;
FPrevRre, FPrevRim: Double; // прошлый символ с убранной модуляцией
FBitRev: array of Integer; // бит-реверс FFT
FTwRe, FTwIm: array of Double;// твидлы
FFre, FFim: array of Double; // рабочие буферы FFT
// --- метрики ---
FSigAmp: Double; // EMA |I|
FQVar: Double; // EMA Q^2
FConstScale: Double;
FDecRMS: Double; // EMA RMS децимированного сигнала (диагностика тракта)
FSymMag: Double; // EMA |символ| после RRC (диагностика тракта)
FRrcAmp: Double; // AGC: EMA амплитуды выхода RRC (нормировка петель)
// --- констелляция (кольцо) ---
FRingI, FRingQ: array[0..BCN_SCOPE_PTS-1] of Single;
FRingPos: Integer;
FRingCnt: Integer;
procedure BuildRRC;
procedure BuildFFT;
procedure FFTInplace;
procedure RunAcquisition;
procedure ProcessDecimated(const xi, xq: Double);
procedure ProcessSymbol(const zi, zq: Double);
procedure ResetDSP;
public
constructor Create;
destructor Destroy; override;
procedure Configure(SampleRateHz: Double); // при смене rate
procedure SetOffsetHz(Hz: Double); // снос маяка к DC (display center)
procedure SetInvert(On_: Boolean); // зеркальный baseband (инверсия спектра)
function Inverted: Boolean;
procedure Reseed; // переинициализация петель (новое наведение)
procedure SetEnabled(On_: Boolean);
property Enabled: Boolean read FEnabled;
// Из DSP-потока, по одному комплексному отсчёту на FSampleRate.
procedure Feed(const I, Q: Double); inline;
procedure GetScope(out S: TBeaconScope);
end;
implementation
constructor TBeaconDecoder.Create;
begin
inherited Create;
FLock := TCriticalSection.Create;
FFs := 0;
FEnabled := False;
FConstScale := 1.0;
Configure(192000);
end;
destructor TBeaconDecoder.Destroy;
begin
FLock.Free;
inherited Destroy;
end;
procedure TBeaconDecoder.BuildRRC;
// Root-raised-cosine матч-фильтр, длина BCN_RRC_SPAN символов, нормировка по энергии.
var
i, mid: Integer;
t, beta, num, den, h, ssum: Double;
begin
beta := BCN_RRC_BETA;
FNtaps := Round(BCN_RRC_SPAN * FSps);
if FNtaps < 8 then FNtaps := 8;
if (FNtaps and 1) = 0 then Inc(FNtaps); // нечётное → симметрично
SetLength(FRRC, FNtaps);
mid := FNtaps div 2;
ssum := 0;
for i := 0 to FNtaps - 1 do
begin
t := (i - mid) / FSps;
if Abs(t) < 1e-9 then
h := 1.0 - beta + 4.0 * beta / Pi
else if Abs(Abs(4.0 * beta * t) - 1.0) < 1e-6 then
h := (beta / Sqrt(2.0)) *
((1.0 + 2.0 / Pi) * Sin(Pi / (4.0 * beta)) +
(1.0 - 2.0 / Pi) * Cos(Pi / (4.0 * beta)))
else
begin
num := Sin(Pi * t * (1.0 - beta)) +
4.0 * beta * t * Cos(Pi * t * (1.0 + beta));
den := Pi * t * (1.0 - Sqr(4.0 * beta * t));
h := num / den;
end;
FRRC[i] := h;
ssum := ssum + h * h;
end;
if ssum > 0 then
for i := 0 to FNtaps - 1 do
FRRC[i] := FRRC[i] / Sqrt(ssum);
SetLength(FHistI, FNtaps);
SetLength(FHistQ, FNtaps);
end;
procedure TBeaconDecoder.BuildFFT;
// Таблицы бит-реверса и твидлов для радикс-2 FFT длины BCN_NFFT.
var
i, j, bit, n: Integer;
begin
n := BCN_NFFT;
SetLength(FBitRev, n);
for i := 0 to n - 1 do
begin
j := 0;
for bit := 0 to BCN_NFFT_LOG2 - 1 do
if (i and (1 shl bit)) <> 0 then
j := j or (1 shl (BCN_NFFT_LOG2 - 1 - bit));
FBitRev[i] := j;
end;
SetLength(FTwRe, n div 2);
SetLength(FTwIm, n div 2);
for i := 0 to (n div 2) - 1 do
begin
FTwRe[i] := Cos(-2.0 * Pi * i / n);
FTwIm[i] := Sin(-2.0 * Pi * i / n);
end;
SetLength(FFre, n);
SetLength(FFim, n);
SetLength(FAcqRe, n);
SetLength(FAcqIm, n);
end;
procedure TBeaconDecoder.FFTInplace;
// Итеративный радикс-2 DIT FFT над FFre/FFim (длина BCN_NFFT), in-place.
var
n, half, step, i, j, k, twi: Integer;
ur, ui, tr, ti: Double;
begin
n := BCN_NFFT;
// бит-реверс перестановка
for i := 0 to n - 1 do
begin
j := FBitRev[i];
if j > i then
begin
tr := FFre[i]; FFre[i] := FFre[j]; FFre[j] := tr;
ti := FFim[i]; FFim[i] := FFim[j]; FFim[j] := ti;
end;
end;
half := 1;
while half < n do
begin
step := half * 2;
i := 0;
while i < n do
begin
k := 0;
while k < half do
begin
twi := (k * (n div step));
ur := FTwRe[twi]; ui := FTwIm[twi];
tr := ur * FFre[i + k + half] - ui * FFim[i + k + half];
ti := ur * FFim[i + k + half] + ui * FFre[i + k + half];
FFre[i + k + half] := FFre[i + k] - tr;
FFim[i + k + half] := FFim[i + k] - ti;
FFre[i + k] := FFre[i + k] + tr;
FFim[i + k] := FFim[i + k] + ti;
Inc(k);
end;
Inc(i, step);
end;
half := step;
end;
end;
procedure TBeaconDecoder.RunAcquisition;
// Индикатор наличия несущей маяка: вычитаем DC (offset Pluto), возводим в квадрат
// (снимаем BPSK), FFT → ищем самую сильную линию вне зоны DC. prominence (pk/среднее)
// = «несущая видна» (FCarPresent). Частоту НЕ оцениваем — снос целиком ведёт
// Costas + offload (см. ProcessSymbol); squaring-FFT давала неверный знак.
var
i, n, loBin: Integer;
mr, mi, re, im, mag, pk, sumMag, meanMag: Double;
begin
n := BCN_NFFT;
// среднее (DC) для подавления offset Pluto
mr := 0; mi := 0;
for i := 0 to n - 1 do begin mr := mr + FAcqRe[i]; mi := mi + FAcqIm[i]; end;
mr := mr / n; mi := mi / n;
// z² с вычтенным DC
for i := 0 to n - 1 do
begin
re := FAcqRe[i] - mr; im := FAcqIm[i] - mi;
FFre[i] := (re * re - im * im);
FFim[i] := (2.0 * re * im);
end;
FFTInplace;
// самая сильная линия, исключая ±loBin вокруг DC (там сидит остаток DC при Δf≈0)
loBin := 2;
pk := 0; sumMag := 0;
for i := 0 to n - 1 do
begin
mag := FFre[i] * FFre[i] + FFim[i] * FFim[i];
sumMag := sumMag + mag;
if (i >= loBin) and (i <= n - loBin) and (mag > pk) then pk := mag;
end;
meanMag := sumMag / n;
if meanMag > 1e-30 then
FCarProm := FCarProm + 0.4 * (pk / meanMag - FCarProm);
FCarPresent := FCarProm > 20.0; // линия выше среднего → несущая в полосе
end;
procedure TBeaconDecoder.ResetDSP;
var i: Integer;
begin
FNcoPh := 0;
FAccI := 0; FAccQ := 0; FAccN := 0;
for i := 0 to High(FHistI) do begin FHistI[i] := 0; FHistQ[i] := 0; end;
FHistPos := 0;
FMuAcc := FSps / 2.0;
FHalf := False;
FMidI := 0; FMidQ := 0; FPrevI := 0; FPrevQ := 0;
FYd0I := 0; FYd0Q := 0; FYd1I := 0; FYd1Q := 0;
FWsym := 0; FTimErrEMA := 0;
FCarPh := 0; FCarFreq := 0;
FSigAmp := 0; FQVar := 0;
FDecRMS := 0; FSymMag := 0; FRrcAmp := 0;
FRingPos := 0; FRingCnt := 0;
FAcqPos := 0; FResidDf := 0; FResidPh := 0; FResidInc := 0;
FCarProm := 0; FCarPresent := False; FFreqLocked := False;
FLockHold := 0;
FPrevRre := 0; FPrevRim := 0;
end;
procedure TBeaconDecoder.Configure(SampleRateHz: Double);
var
bn, zeta, theta, d: Double;
begin
if SampleRateHz < 1000 then Exit;
FLock.Enter;
try
if SameValue(SampleRateHz, FFs) and (FNtaps > 0) then Exit;
FFs := SampleRateHz;
FDecim := Max(1, Round(FFs / BCN_TARGET_FS));
FFdec := FFs / FDecim;
FSps := FFdec / BCN_BAUD;
FNcoInc := 2.0 * Pi * FOffsetHz / FFs;
BuildRRC;
if Length(FBitRev) <> BCN_NFFT then BuildFFT;
// Costas: 2-й порядок, нормированный на символ. Bn ~ 1.5% символьной.
bn := 0.02;
zeta := 0.707;
theta := bn / (zeta + 1.0 / (4.0 * zeta));
d := 1.0 + 2.0 * zeta * theta + theta * theta;
FCarAlpha := (4.0 * zeta * theta) / d;
FCarBeta := (4.0 * theta * theta) / d;
FFllGain := 0.002; // частотная петля (decision-directed), мягкая
ResetDSP;
finally
FLock.Leave;
end;
end;
procedure TBeaconDecoder.SetOffsetHz(Hz: Double);
begin
FLock.Enter;
try
FOffsetHz := Hz;
if FFs > 0 then FNcoInc := 2.0 * Pi * FOffsetHz / FFs;
finally
FLock.Leave;
end;
end;
procedure TBeaconDecoder.SetInvert(On_: Boolean);
begin
FLock.Enter;
try
if On_ = FInvert then Exit;
FInvert := On_;
ResetDSP; // смена ориентации → перезахват
finally
FLock.Leave;
end;
end;
function TBeaconDecoder.Inverted: Boolean;
begin
Result := FInvert;
end;
procedure TBeaconDecoder.Reseed;
begin
FLock.Enter;
try
ResetDSP;
finally
FLock.Leave;
end;
end;
procedure TBeaconDecoder.SetEnabled(On_: Boolean);
begin
FLock.Enter;
try
if On_ = FEnabled then Exit;
FEnabled := On_;
if On_ then ResetDSP;
finally
FLock.Leave;
end;
end;
procedure TBeaconDecoder.Feed(const I, Q: Double);
// Один комплексный отсчёт @ FFs. Снос маяка к DC + boxcar-децимация.
var
c, s, mi, mq, qq: Double;
begin
if not FEnabled then Exit;
// Зеркальный baseband (QO-100/LNB): сопряжение IQ переворачивает ось частот,
// тогда снос совпадает с осью дисплея.
if FInvert then qq := -Q else qq := Q;
// exp(-j*ph): сносим +OffsetHz к DC.
c := Cos(FNcoPh); s := Sin(FNcoPh);
mi := I * c + qq * s;
mq := qq * c - I * s;
FNcoPh := FNcoPh + FNcoInc;
if FNcoPh > Pi then FNcoPh := FNcoPh - 2.0 * Pi
else if FNcoPh < -Pi then FNcoPh := FNcoPh + 2.0 * Pi;
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 TBeaconDecoder.ProcessDecimated(const xi, xq: Double);
// RRC матч-фильтр + Gardner-таймер @ FFdec. Без CS (зовётся из Feed, под потоком DSP);
// снимок констелляции защищён в GetScope собственным копированием полей-массивов.
var
k, idx: Integer;
yi, yq, frac, e, step, cr, sr, ri, ii: Double;
begin
// остаточный снос несущей (по squaring-FFT acquisition) — поверх главного NCO
cr := Cos(FResidPh); sr := Sin(FResidPh);
ri := xi * cr + xq * sr;
ii := xq * cr - xi * sr;
FResidPh := FResidPh + FResidInc;
if FResidPh > Pi then FResidPh := FResidPh - 2.0 * Pi
else if FResidPh < -Pi then FResidPh := FResidPh + 2.0 * Pi;
// Диагностика тракта (lock-независимо): RMS децимированного сигнала на входе RRC.
FDecRMS := FDecRMS + 0.001 * (Sqrt(ri * ri + ii * ii) - FDecRMS);
// Буфер для грубой carrier-acquisition: кладём сигнал ПОСЛЕ главного NCO, но
// ДО остаточного — тогда squaring-FFT меряет АБСОЛЮТНЫЙ остаток (не зависит от
// FResidDf) → коррекция = прямое присвоение, без накопления и runaway.
FAcqRe[FAcqPos] := xi; FAcqIm[FAcqPos] := xq;
Inc(FAcqPos);
if FAcqPos >= BCN_NFFT then
begin
RunAcquisition;
FAcqPos := 0;
end;
// вставляем в кольцо истории (уже с остаточной коррекцией)
FHistI[FHistPos] := ri;
FHistQ[FHistPos] := ii;
// свёртка RRC
yi := 0; yq := 0;
idx := FHistPos;
for k := 0 to FNtaps - 1 do
begin
yi := yi + FRRC[k] * FHistI[idx];
yq := yq + FRRC[k] * FHistQ[idx];
Dec(idx); if idx < 0 then idx := FNtaps - 1;
end;
FHistPos := FHistPos + 1; if FHistPos >= FNtaps then FHistPos := 0;
// AGC: нормируем выход RRC к ~1. Без этого при слабом сигнале (~−60 dBFS) ошибки
// петель (Costas ∝ амплитуде, Gardner ∝ амплитуде²) на 3 порядка меньше калибровки
// усилений → петли практически заморожены, лока нет. Нормировка делает динамику
// петель независимой от абсолютного уровня. FRrcAmp медленный — это и метрика силы.
if FRrcAmp <= 0 then
FRrcAmp := Sqrt(yi * yi + yq * yq) + 1e-12 // сид на первом отсчёте (без скачка)
else
FRrcAmp := FRrcAmp + 0.0005 * (Sqrt(yi * yi + yq * yq) - FRrcAmp);
if FRrcAmp > 1e-12 then
begin
yi := yi / FRrcAmp;
yq := yq / FRrcAmp;
end;
FSymMag := FRrcAmp; // диагностика силы сигнала (сырой уровень до AGC)
// линия задержки RRC-выхода для линейной интерполяции
FYd1I := FYd0I; FYd1Q := FYd0Q;
FYd0I := yi; FYd0Q := yq;
// Gardner: полу-символьные стробы. FMuAcc убывает на 1/отсчёт; при переходе
// через 0 интерполируем и планируем следующий строб через ~FSps/2 отсчётов.
FMuAcc := FMuAcc - 1.0;
if FMuAcc <= 0.0 then
begin
frac := FMuAcc + 1.0; // позиция в (0..1] между Yd1 и Yd0
if frac < 0 then frac := 0;
yi := FYd1I + frac * (FYd0I - FYd1I);
yq := FYd1Q + frac * (FYd0Q - FYd1Q);
step := (FSps / 2.0) * (1.0 + FWsym); // базовый интервал до след. полу-символа
if FHalf then
begin
// середина символа — запоминаем для Gardner
FMidI := yi; FMidQ := yq;
end
else
begin
// центр символа → ошибка Gardner (вращательно-инвариантна):
// e = Re{ conj(mid) * (curr - prev) } по компонентам
e := FMidI * (yi - FPrevI) + FMidQ * (yq - FPrevQ);
FTimErrEMA := FTimErrEMA + 0.01 * (Abs(e) - FTimErrEMA);
// После AGC вход ~единичный, ошибка Gardner шумная (по логам wsym бился об
// ±0.05). Снижаем усиления интегратора/пропорц. и поджимаем кламп: маяк 400 бод
// стабилен (доли %), большой диапазон не нужен, а шум перестаёт расшатывать.
FWsym := FWsym + 0.0003 * e; // интегратор периода (медленнее)
if FWsym > 0.01 then FWsym := 0.01;
if FWsym < -0.01 then FWsym := -0.01;
FPrevI := yi; FPrevQ := yq;
ProcessSymbol(yi, yq); // символ → Costas (вход уже нормирован AGC)
step := step + 0.003 * e; // пропорциональная правка таймера (мягче)
end;
FMuAcc := FMuAcc + step;
FHalf := not FHalf;
end;
end;
procedure TBeaconDecoder.ProcessSymbol(const zi, zq: Double);
// Costas BPSK: дероворот по FCarPh, решающе-направленный детектор фазы, обновление
// контура; нормированный символ → констелляция + метрики.
var
c, s, dr, di, ce, na, absI, ef, leak: Double;
begin
c := Cos(FCarPh); s := Sin(FCarPh);
dr := zi * c + zq * s; // умножение на exp(-j FCarPh)
di := zq * c - zi * s;
if dr >= 0 then ce := di else ce := -di; // Costas: фазовый детектор sign(I)*Q
absI := Abs(dr);
// FLL (decision-directed): убираем модуляцию (rk = |I| + j·ce), частотная ошибка
// = Im(rk·conj(rk-1)). Расширяет диапазон захвата и держит частоту устойчиво
// (отрицательная ОС) — в отличие от open-loop squaring-FFT, не «убегает».
ef := ce * FPrevRre - absI * FPrevRim;
FPrevRre := absI; FPrevRim := ce;
FCarFreq := FCarFreq + FCarBeta * ce + FFllGain * ef;
if FCarFreq > 3.0 then FCarFreq := 3.0;
if FCarFreq < -3.0 then FCarFreq := -3.0;
// OFFLOAD частоты: медленно «стекаем» накопленный снос Costas в пред-RRC NCO
// (FResidInc), у которого НЕТ предела ±π/символ. По логам carHz упирался в кламп
// (±127/±191) из-за ошибки наведения (1 пиксель≈300Гц) + дрейфа LNB и срывал лок.
// Консервативно: сколько добавили в FResidInc, столько убрали из FCarFreq (без
// рывка). Утечка медленная (TC~100 символов >> группозадержки RRC ~4 симв) — стабильно.
// Итог: Costas держит лишь мелкий быстрый остаток, весь медленный снос — в FResidInc.
leak := 0.01 * FCarFreq;
FResidInc := FResidInc + leak / FSps; // рад/символ → рад/децим-отсчёт
FResidDf := FResidInc * FFdec / (2.0 * Pi); // для лога/диагностики
FCarFreq := FCarFreq - leak;
FCarPh := FCarPh + FCarFreq + FCarAlpha * ce;
if FCarPh > Pi then FCarPh := FCarPh - 2.0 * Pi
else if FCarPh < -Pi then FCarPh := FCarPh + 2.0 * Pi;
// метрики — медленные EMA (~0.8с): SNR по логам скачет −8…+6 за секунду, на быстрой
// EMA порог лока дребезжит. Замедляем, чтобы оценка была устойчивой.
FSigAmp := FSigAmp + 0.003 * (Abs(dr) - FSigAmp);
FQVar := FQVar + 0.003 * (di * di - FQVar);
// Холдовер-лок (анти-мерцание): копим счётчик при хорошем SNR, тратим при плохом.
// Лок держится сквозь кратковременные провалы (~секунды), не отваливаясь на каждом.
if FQVar > 1e-12 then
begin
if (FSigAmp * FSigAmp) / FQVar > 2.0 then
begin
if FLockHold < 2400 then Inc(FLockHold, 3); // захват быстрее, чем потеря
end
else
if FLockHold > 0 then Dec(FLockHold);
end;
if FLockHold >= 600 then FFreqLocked := True
else if FLockHold <= 150 then FFreqLocked := False;
// нормировка констелляции к ±1 по амплитуде сигнала
if FSigAmp > 1e-9 then FConstScale := 1.0 / FSigAmp;
na := FConstScale;
FRingI[FRingPos] := dr * na;
FRingQ[FRingPos] := di * na;
FRingPos := FRingPos + 1; if FRingPos >= BCN_SCOPE_PTS then FRingPos := 0;
if FRingCnt < BCN_SCOPE_PTS then Inc(FRingCnt);
end;
procedure TBeaconDecoder.GetScope(out S: TBeaconScope);
var
i, idx: Integer;
snr: Double;
begin
FLock.Enter;
try
S.Enabled := FEnabled;
S.OffsetHz := FOffsetHz;
S.SymRate := BCN_BAUD * (1.0 + FWsym);
S.ResidHz := FResidDf;
S.CarProm := FCarProm;
S.FsHz := FFs;
S.FdecHz := FFdec;
S.Sps := FSps;
S.DecRMS := FDecRMS;
S.SymMag := FSymMag;
S.TimErr := FTimErrEMA;
S.Wsym := FWsym;
S.CarFreqHz := FCarFreq * BCN_BAUD / (2.0 * Pi); // рад/символ → Гц
S.LockHold := FLockHold;
// SNR ≈ (E|I|)^2 / E[Q^2] (мощность сигнала по I против шума по Q)
if FQVar > 1e-12 then
snr := (FSigAmp * FSigAmp) / FQVar
else
snr := 0;
if snr > 1e-6 then S.SNRdB := 10.0 * Log10(snr) else S.SNRdB := -99;
// CARRIER = линия несущей видна (squaring-FFT) И Costas держит фазу.
S.CarrierLock := FCarPresent and FFreqLocked;
S.SymbolLock := FFreqLocked and
(FTimErrEMA < 0.25 * Max(FSigAmp, 1e-9)) and (FRingCnt >= BCN_SCOPE_PTS);
S.Count := FRingCnt;
// выгружаем кольцо в хронологическом порядке (старейший → новейший)
for i := 0 to FRingCnt - 1 do
begin
if FRingCnt < BCN_SCOPE_PTS then
idx := i
else
begin
idx := FRingPos + i;
if idx >= BCN_SCOPE_PTS then Dec(idx, BCN_SCOPE_PTS);
end;
S.PtI[i] := FRingI[idx];
S.PtQ[i] := FRingQ[idx];
end;
finally
FLock.Leave;
end;
end;
end.
+165
View File
@@ -0,0 +1,165 @@
unit BeaconScopeForm;
{ TBeaconScopeForm — окно диагностики QO-100 beacon-декодера (Milestone 1).
Показывает BPSK-констелляцию демодулированного маяка + метрики захвата:
carrier-lock, symbol-lock, SNR, измеренную скорость, снос NCO.
Это смотровое окно фронтенда ДО подключения FEC: если констелляция сжимается
в два чётких сгустка по оси I и горят оба лока — демодуляция корректна, можно
цеплять Viterbi/RS (libfec). Открывается из RX-блока MainForm (ПКМ по BEACON).
Данные тянутся из TRadioController.GetBeaconScope по таймеру (~25 Гц). }
{$mode objfpc}{$H+}
interface
uses
Classes, SysUtils, Math, StrUtils,
Forms, Controls, Graphics, ExtCtrls,
AppTheme, BeaconDecoder, RadioController;
type
TBeaconScopeForm = class(TForm)
private
FCtl: TRadioController;
FBox: TPaintBox;
FTimer: TTimer;
FTheme: TAppTheme;
procedure DoTick(Sender: TObject);
procedure DoPaint(Sender: TObject);
public
constructor CreateWith(AOwner: TComponent; ACtl: TRadioController);
procedure ApplyTheme(const T: TAppTheme);
end;
implementation
constructor TBeaconScopeForm.CreateWith(AOwner: TComponent; ACtl: TRadioController);
begin
inherited CreateNew(AOwner);
FCtl := ACtl;
FTheme := DarkTheme;
Caption := 'QO-100 Beacon — Constellation';
BorderStyle := bsSizeable;
Width := 360;
Height := 510;
Position := poScreenCenter;
FBox := TPaintBox.Create(Self);
FBox.Parent := Self;
FBox.Align := alClient;
FBox.OnPaint := @DoPaint;
FTimer := TTimer.Create(Self);
FTimer.Interval := 40; // ~25 Гц
FTimer.OnTimer := @DoTick;
FTimer.Enabled := True;
end;
procedure TBeaconScopeForm.ApplyTheme(const T: TAppTheme);
begin
FTheme := T;
if FBox <> nil then FBox.Invalidate;
end;
procedure TBeaconScopeForm.DoTick(Sender: TObject);
begin
if (FBox <> nil) and Visible then FBox.Invalidate;
end;
procedure TBeaconScopeForm.DoPaint(Sender: TObject);
var
C: TCanvas;
W, H, cx, cy, r, i, px, py: Integer;
S: TBeaconScope;
ok: Boolean;
C1, C2: TColor;
st: string;
begin
C := FBox.Canvas;
W := FBox.Width; H := FBox.Height;
// фон
C.Brush.Style := bsSolid;
C.Brush.Color := FTheme.BG;
C.FillRect(0, 0, W, H);
// область констелляции — квадрат сверху, текст снизу
r := (Min(W, H - 70) - 20) div 2;
if r < 20 then r := 20;
cx := W div 2;
cy := 10 + r;
// сетка/оси
C.Pen.Style := psSolid;
C.Pen.Color := FTheme.SpecGrid;
C.Line(cx - r, cy, cx + r, cy); // ось I (горизонт)
C.Line(cx, cy - r, cx, cy + r); // ось Q (вертикаль)
C.Pen.Color := FTheme.Border;
C.Brush.Style := bsClear;
C.Rectangle(cx - r, cy - r, cx + r, cy + r);
// целевые точки BPSK (±1, 0) — ориентир
C.Pen.Color := FTheme.TextDim;
C.Ellipse(cx + r div 2 - 3, cy - 3, cx + r div 2 + 3, cy + 3);
C.Ellipse(cx - r div 2 - 3, cy - 3, cx - r div 2 + 3, cy + 3);
ok := (FCtl <> nil) and FCtl.GetBeaconScope(S);
if ok and S.Enabled then
begin
// точки констелляции (нормированы к ±1; масштаб r/2 → ±1 на половине радиуса)
C.Pen.Style := psClear;
C.Brush.Style := bsSolid;
C.Brush.Color := FTheme.MeterOn;
for i := 0 to S.Count - 1 do
begin
px := cx + Round(S.PtI[i] * (r / 2));
py := cy - Round(S.PtQ[i] * (r / 2));
if (px >= cx - r) and (px <= cx + r) and (py >= cy - r) and (py <= cy + r) then
C.FillRect(px - 1, py - 1, px + 2, py + 2);
end;
end;
// ----- метрики -----
C.Brush.Style := bsClear;
C.Font.Name := 'Courier New';
C.Font.Size := 9;
py := cy + r + 12;
if not ok then
begin
C.Font.Color := FTheme.TextDim;
C.TextOut(14, py, 'decoder unavailable');
Exit;
end;
if not S.Enabled then
begin
C.Font.Color := FTheme.TextDim;
C.TextOut(14, py, 'decoder OFF (enable via BEACON \ decode)');
Exit;
end;
if S.CarrierLock then C1 := FTheme.MeterOn else C1 := FTheme.TextDim;
if S.SymbolLock then C2 := FTheme.MeterOn else C2 := FTheme.TextDim;
C.Font.Color := C1;
C.TextOut(14, py, 'CARRIER ' + IfThen(S.CarrierLock, 'LOCK', '----'));
C.Font.Color := C2;
C.TextOut(14, py + 18, 'SYMBOL ' + IfThen(S.SymbolLock, 'LOCK', '----'));
C.Font.Color := FTheme.Text;
if S.SNRdB > -50 then st := Format('%.1f dB', [S.SNRdB]) else st := '--';
C.TextOut(14, py + 36, 'SNR ' + st);
C.TextOut(14, py + 54, Format('BAUD %.1f (off %.0f Hz)', [S.SymRate, S.OffsetHz]));
C.Font.Color := FTheme.TextDim;
C.TextOut(14, py + 72, Format('CARRIER resid %.0f Hz prom %.0f', [S.ResidHz, S.CarProm]));
if FCtl <> nil then
C.TextOut(14, py + 90, 'INVERT ' + IfThen(FCtl.BeaconDecodeInvert, 'ON', 'OFF'));
C.TextOut(14, py + 108, Format('RATE Fs %.0f Fdec %.0f sps %.1f',
[S.FsHz, S.FdecHz, S.Sps]));
end;
end.
+49 -1
View File
@@ -39,7 +39,7 @@ uses
WinFirewall, WinFirewall,
BoardUtils, WisdomBuilder, UISync, BoardUtils, WisdomBuilder, UISync,
FlatEdit, FMRepeater, FlatEdit, FMRepeater,
ChannelStore, ChannelsForm, ChannelStore, ChannelsForm, BeaconScopeForm,
PowerInhibit, PowerInhibit,
DeviceStore, DeviceStore,
RadioBackend, PlutoBackend, RadioBackend, PlutoBackend,
@@ -135,6 +135,7 @@ type
FSpecDragX0: Integer; // X при нажатии FSpecDragX0: Integer; // X при нажатии
FSpecDragFreq: Double; // FCenterFreq при нажатии FSpecDragFreq: Double; // FCenterFreq при нажатии
FBeaconArming: Boolean; // ждём клик по маяку (после включения BEACON) FBeaconArming: Boolean; // ждём клик по маяку (после включения BEACON)
FBeaconDecArming: Boolean; // ждём клик: навести узкий фильтр декодера на маяк
FSpectrumDirty: Boolean; // таймер должен перерисовать спектр/водопад FSpectrumDirty: Boolean; // таймер должен перерисовать спектр/водопад
// TX DUC-очередь (FDUCPending*) переехала в TRadioController (OnTXIQ). // TX DUC-очередь (FDUCPending*) переехала в TRadioController (OnTXIQ).
FSMeterPeak: Double; // верхняя граница светлой зоны FSMeterPeak: Double; // верхняя граница светлой зоны
@@ -287,6 +288,7 @@ type
BtnChannels: TFlatButton; BtnChannels: TFlatButton;
FChannelsDropDown: TFlatDropDown; FChannelsDropDown: TFlatDropDown;
FChannelsForm: TObject; // TChannelsForm (cast при использовании) FChannelsForm: TObject; // TChannelsForm (cast при использовании)
FBeaconScopeForm: TBeaconScopeForm; // окно констелляции маяка (ПКМ по BEACON)
// ---- Right panel ---- // ---- Right panel ----
PanelRight: TPanel; PanelRight: TPanel;
@@ -384,6 +386,8 @@ type
procedure BtnFilterClick(Sender: TObject); procedure BtnFilterClick(Sender: TObject);
procedure BtnCTunClick(Sender: TObject); procedure BtnCTunClick(Sender: TObject);
procedure BtnBeaconClick(Sender: TObject); procedure BtnBeaconClick(Sender: TObject);
procedure BtnBeaconMouseDown(Sender: TObject; Button: TMouseButton;
Shift: TShiftState; X, Y: Integer);
procedure UpdateBeaconButton; procedure UpdateBeaconButton;
procedure BtnDUPClick(Sender: TObject); procedure BtnDUPClick(Sender: TObject);
procedure ApplyDUP(Active: Boolean); procedure ApplyDUP(Active: Boolean);
@@ -795,6 +799,7 @@ begin
FController.FTXSpecGridStep := 10.0; FController.FTXSpecGridStep := 10.0;
FSettingsForm := nil; FSettingsForm := nil;
FChannelsForm := nil; FChannelsForm := nil;
FBeaconScopeForm := nil;
FillChar(FController.FDevMAC, SizeOf(FController.FDevMAC), 0); FillChar(FController.FDevMAC, SizeOf(FController.FDevMAC), 0);
// Инициализируем кэш диапазонов умолчаниями // Инициализируем кэш диапазонов умолчаниями
@@ -1752,6 +1757,7 @@ begin
LeftPanelButtonWidth(LEFT_W, 3, 2), LeftPanelButtonWidth(LEFT_W, 3, 2),
BTN_H, BtnBeaconClick); BTN_H, BtnBeaconClick);
BtnBeacon.Visible := False; BtnBeacon.Visible := False;
BtnBeacon.OnMouseDown := BtnBeaconMouseDown; // ПКМ → декодер + окно констелляции
StyleButton(BtnBeacon, False); StyleButton(BtnBeacon, False);
MakeLbl(PanelRXBlock, 'VOL', 4, 50); MakeLbl(PanelRXBlock, 'VOL', 4, 50);
@@ -3272,6 +3278,9 @@ function TMainForm.BeaconStatusText: string;
// Компактный статус QO-100 beacon lock для поля 7 статус-бара (Pluto). Courier 8pt, // Компактный статус QO-100 beacon lock для поля 7 статус-бара (Pluto). Courier 8pt,
// поле ~200px → держим ≤ ~24 символов. // поле ~200px → держим ≤ ~24 символов.
begin begin
if FBeaconDecArming then Exit('DEC: click beacon');
if FController.BeaconDecodeEnabled and (FController.BeaconDecodeFreqHz > 0) then
Exit('DEC: shift-click=re-aim');
if not FController.BeaconLockEnabled then Exit('BCN: off'); if not FController.BeaconLockEnabled then Exit('BCN: off');
if FBeaconArming then Exit('BCN: click beacon'); if FBeaconArming then Exit('BCN: click beacon');
case FController.BeaconState of case FController.BeaconState of
@@ -3988,6 +3997,28 @@ begin
UpdateBeaconButton; UpdateBeaconButton;
end; end;
procedure TMainForm.BtnBeaconMouseDown(Sender: TObject; Button: TMouseButton;
Shift: TShiftState; X, Y: Integer);
// ПКМ по BEACON: включает фронтенд-декодер маяка (BPSK 400 бод) и открывает окно
// констелляции для диагностики демодуляции (Milestone 1 — до подключения FEC).
begin
if Button <> mbRight then Exit;
FController.SetBeaconDecode(not FController.BeaconDecodeEnabled);
if FController.BeaconDecodeEnabled then
begin
// «Вооружаем» наведение: следующий ЛКМ по спектру поставит узкий фильтр
// декодера на маяк. Потом перенавести — Shift+ЛКМ по спектру.
FBeaconDecArming := True;
if FBeaconScopeForm = nil then
FBeaconScopeForm := TBeaconScopeForm.CreateWith(Self, FController);
FBeaconScopeForm.ApplyTheme(DarkTheme);
FBeaconScopeForm.Show;
end
else
FBeaconDecArming := False;
UpdateBeaconButton;
end;
procedure TMainForm.UpdateBeaconButton; procedure TMainForm.UpdateBeaconButton;
// Beacon-маркеры + кнопка BEACON. Кнопка видима только на Pluto в трансвертере // Beacon-маркеры + кнопка BEACON. Кнопка видима только на Pluto в трансвертере
// (см. rfXvtr) — это ПРОСТО кнопка: подпись 'BEACON', подсветка = лок включён. // (см. rfXvtr) — это ПРОСТО кнопка: подпись 'BEACON', подсветка = лок включён.
@@ -3996,9 +4027,15 @@ procedure TMainForm.UpdateBeaconButton;
begin begin
// Маркеры на спектре: опорная частота (зелёная) + отслеживаемый центроид (оранж). // Маркеры на спектре: опорная частота (зелёная) + отслеживаемый центроид (оранж).
if FSpecView <> nil then if FSpecView <> nil then
begin
FSpecView.SetBeaconMarkers(FController.BeaconLockEnabled, FSpecView.SetBeaconMarkers(FController.BeaconLockEnabled,
FController.BeaconRefHz, FController.BeaconRefHz,
FController.BeaconTrackedFreqHz); FController.BeaconTrackedFreqHz);
// Узкий фильтр-маркер декодера (±450 Гц ≈ полоса BPSK-маяка).
FSpecView.SetBeaconDecMarker(
FController.BeaconDecodeEnabled and (FController.BeaconDecodeFreqHz > 0),
FController.BeaconDecodeFreqHz, 450.0);
end;
UpdateStatusField7; // моментальный апдейт статус-бара (не ждём MeterTimer) UpdateStatusField7; // моментальный апдейт статус-бара (не ждём MeterTimer)
if BtnBeacon = nil then Exit; if BtnBeacon = nil then Exit;
BtnBeacon.Caption := 'BEACON'; BtnBeacon.Caption := 'BEACON';
@@ -4186,6 +4223,17 @@ begin
if Button = mbLeft then if Button = mbLeft then
begin begin
// Наведение узкого фильтра ДЕКОДЕРА на маяк: после ПКМ BEACON (вооружено)
// либо Shift+ЛКМ в любой момент при включённом декодере. Не трогает VFO.
if FController.BeaconDecodeEnabled and (PbSpectrum.Width > 0) and
(FBeaconDecArming or (ssShift in Shift)) then
begin
FController.SetBeaconDecodeAtHz(
PixelToFreq(X, PbSpectrum.Width, FController.FCenterFreq, FController.FSpanHz));
FBeaconDecArming := False;
UpdateBeaconButton;
Exit;
end;
// Наведение beacon-lock: ЛКМ указывает на маяк (вместо перестройки VFO). // Наведение beacon-lock: ЛКМ указывает на маяк (вместо перестройки VFO).
if FBeaconArming and (PbSpectrum.Width > 0) then if FBeaconArming and (PbSpectrum.Width > 0) then
begin begin
+162 -2
View File
@@ -49,7 +49,7 @@ interface
uses uses
Classes, SysUtils, Math, Classes, SysUtils, Math,
HPSDRProtocol, HPSDRNetwork, RadioBackend, PlutoBackend, IIOBindings, HPSDRProtocol, HPSDRNetwork, RadioBackend, PlutoBackend, IIOBindings,
WDSPEngine, AudioOutput, AudioInput, WDSPEngine, AudioOutput, AudioInput, BeaconDecoder,
Settings, ChannelStore, FMRepeater, BoardUtils, DeviceStore; Settings, ChannelStore, FMRepeater, BoardUtils, DeviceStore;
type type
@@ -196,6 +196,14 @@ type
FBeaconLocked: Boolean; // пик уверенно виден FBeaconLocked: Boolean; // пик уверенно виден
FBeaconCorrCooldown: Integer; // тиков паузы после коррекции (осадка пайплайна) FBeaconCorrCooldown: Integer; // тиков паузы после коррекции (осадка пайплайна)
FBeaconSaveCooldown: Integer; // троттлинг записи LOError на диск (тиков) FBeaconSaveCooldown: Integer; // троттлинг записи LOError на диск (тиков)
// ---- QO-100 beacon DECODE (BPSK 400 бод, AO-40) — фронтенд+констелляция ----
FBeaconDec: TBeaconDecoder; // владеет; тап IQ в WDSPEngine
FBeaconDecOn: Boolean; // декодер включён пользователем
FBeaconDecHz: Double; // АБСОЛЮТНАЯ display-частота наведения декодера
// (узкий фильтр, ставится кликом); 0 = центр/DC
FBeaconDecInvert: Boolean; // зеркальный baseband (QO-100/LNB) — деф. True
FBeaconLogTick: Integer; // троттл периодического лога состояния декодера
FBeaconLogScope: TBeaconScope; // буфер снимка для лога (не аллоцируем в контуре)
// ---- Аудио / TX ---- // ---- Аудио / TX ----
FVolume: Integer; FVolume: Integer;
@@ -477,6 +485,7 @@ type
// QO-100 beacon lock — стабилизация дрейфа LNB по опорному маяку. // QO-100 beacon lock — стабилизация дрейфа LNB по опорному маяку.
procedure SetBeaconLock(On_: Boolean); procedure SetBeaconLock(On_: Boolean);
procedure ServiceBeaconLock; // одна итерация контура (MeterTimer @10 Гц) procedure ServiceBeaconLock; // одна итерация контура (MeterTimer @10 Гц)
procedure BeaconLog(const Msg: string); // диагностика декодера → HOME/ewsdr_beacon.log
procedure SetBeaconRefHz(Hz: Double); // опорная частота маяка (downlink) procedure SetBeaconRefHz(Hz: Double); // опорная частота маяка (downlink)
procedure BeaconSeedAtHz(DisplayHz: Double); // пользователь указал маяк кликом procedure BeaconSeedAtHz(DisplayHz: Double); // пользователь указал маяк кликом
function BeaconState: TBeaconState; function BeaconState: TBeaconState;
@@ -486,6 +495,15 @@ type
function BeaconRefHz: Double; // опорная частота маяка (для маркера) function BeaconRefHz: Double; // опорная частота маяка (для маркера)
function BeaconTrackedFreqHz: Double;// отслеживаемая display-частота маяка (центроид) function BeaconTrackedFreqHz: Double;// отслеживаемая display-частота маяка (центроид)
// QO-100 beacon DECODE (BPSK 400 бод, AO-40) — фронтенд + констелляция.
procedure SetBeaconDecode(On_: Boolean);
procedure SetBeaconDecodeAtHz(DisplayHz: Double); // навести узкий фильтр декодера
function BeaconDecodeEnabled: Boolean;
function BeaconDecodeFreqHz: Double; // частота наведения (для маркера)
procedure SetBeaconDecodeInvert(On_: Boolean); // зеркальный baseband вкл/выкл
function BeaconDecodeInvert: Boolean;
function GetBeaconScope(out S: TBeaconScope): Boolean;
// Отображение / приём // Отображение / приём
procedure SetCTun(On_: Boolean); procedure SetCTun(On_: Boolean);
procedure SetDuplex(On_: Boolean); procedure SetDuplex(On_: Boolean);
@@ -596,6 +614,16 @@ begin
FBeaconLockHz := 0.0; FBeaconLockHz := 0.0;
FBeaconLockOn := False; FBeaconLockOn := False;
// QO-100 beacon DECODE: фронтенд BPSK 400 бод (тап IQ в движке).
FBeaconDec := TBeaconDecoder.Create;
FBeaconDecOn := False;
// Тракт QO-100 (LNB+трансвертер) зеркалит спектр: проверено приборно (symMag ×4
// и prom 500-800 при invert=ON против шума при OFF, на наведении точно на маяк).
// Декодеру нужно сопряжение, чтобы наведённый offset доставлял маяк к DC.
FBeaconDecInvert := True;
FBeaconDec.SetInvert(FBeaconDecInvert);
FDSPEngine.SetBeaconDecoder(FBeaconDec);
// Audio out/in — объекты создаём сейчас, Open вызывается позже (после показа // Audio out/in — объекты создаём сейчас, Open вызывается позже (после показа
// формы / при подключении устройства). // формы / при подключении устройства).
FAudioOut := TAudioOutput.Create(48000); FAudioOut := TAudioOutput.Create(48000);
@@ -612,7 +640,13 @@ begin
end; end;
if Assigned(FAudioOut) then begin FAudioOut.Close; FreeAndNil(FAudioOut); end; if Assigned(FAudioOut) then begin FAudioOut.Close; FreeAndNil(FAudioOut); end;
if Assigned(FAudioIn) then begin FAudioIn.Close; FreeAndNil(FAudioIn); end; if Assigned(FAudioIn) then begin FAudioIn.Close; FreeAndNil(FAudioIn); end;
if Assigned(FDSPEngine) then begin FDSPEngine.Close; FreeAndNil(FDSPEngine); end; if Assigned(FDSPEngine) then
begin
FDSPEngine.SetBeaconDecoder(nil);
FDSPEngine.Close;
FreeAndNil(FDSPEngine);
end;
if Assigned(FBeaconDec) then FreeAndNil(FBeaconDec);
end; end;
destructor TRadioController.Destroy; destructor TRadioController.Destroy;
@@ -1200,6 +1234,45 @@ procedure TRadioController.ServiceBeaconLock;
// лок не теряется; во время settle позицию не двигаем (FFT в транзиенте). // лок не теряется; во время settle позицию не двигаем (FFT в транзиенте).
var win, pk, prom, cand, e, dLock: Double; var win, pk, prom, cand, e, dLock: Double;
begin begin
// Beacon-декодер: ЦЕЛЬ — маяк на DC IQ-тапа (= центр RX-спектра, тот же сырой
// DDC-IQ). По умолчанию offset=0: пользователь ставит маяк в центр спектра
// (CTUN/перестройка), squaring-FFT acquisition доводит остаток. ТОЛЬКО когда
// beacon-lock реально захвачен (зелёный) — доверяем его оценке и сносим
// FBeaconTrackHzFCenterFreq (авто-центровка). Развязано от сырого FBeaconTrackHz,
// т.к. при выключенном/несошедшемся локе он недостоверен.
// Декодер НЕ зависит от beacon-lock: lock физически ретюнит железо
// (ApplyBeaconLOError → PushNetworkState), что рвёт IQ-поток и сбивает петли
// декодера (Costas/FLL/Gardner), которым нужны секунды непрерывного сигнала.
// У декодера своя петля несущей — она точнее, чем display-FFT-lock. Поэтому
// offset = только явный клик-аим, иначе DC. Авто-offset от FBeaconTrackHz убран.
if FBeaconDecOn and Assigned(FBeaconDec) then
begin
if FBeaconDecHz > 0 then
FBeaconDec.SetOffsetHz(FBeaconDecHz - FCenterFreq) // наведённый узкий фильтр
else
FBeaconDec.SetOffsetHz(0); // иначе — центр/DC
// Периодический снимок состояния (раз в ~1с при @10Гц-таймере) в лог-файл.
Inc(FBeaconLogTick);
if FBeaconLogTick >= 10 then
begin
FBeaconLogTick := 0;
GetBeaconScope(FBeaconLogScope);
BeaconLog(Format(
'STATE decHz=%.1f center=%.1f off=%.1f | Fs=%.0f Fdec=%.0f sps=%.1f | ' +
'resid=%.1f prom=%.1f snr=%.1f baud=%.1f decRMS=%.5f symMag=%.5f ' +
'timErr=%.5f wsym=%.4f carHz=%.1f hold=%d | CAR=%d SYM=%d invert=%d lockOn=%d locked=%d',
[FBeaconDecHz, FCenterFreq, FBeaconLogScope.OffsetHz,
FBeaconLogScope.FsHz, FBeaconLogScope.FdecHz, FBeaconLogScope.Sps,
FBeaconLogScope.ResidHz, FBeaconLogScope.CarProm, FBeaconLogScope.SNRdB,
FBeaconLogScope.SymRate, FBeaconLogScope.DecRMS, FBeaconLogScope.SymMag,
FBeaconLogScope.TimErr, FBeaconLogScope.Wsym, FBeaconLogScope.CarFreqHz,
FBeaconLogScope.LockHold,
Ord(FBeaconLogScope.CarrierLock), Ord(FBeaconLogScope.SymbolLock),
Ord(FBeaconDecInvert), Ord(FBeaconLockOn), Ord(FBeaconLocked)]));
end;
end;
if not FBeaconLockOn then Exit; if not FBeaconLockOn then Exit;
if FCurrentXvtr < 0 then Exit; // только в трансвертер-режиме if FCurrentXvtr < 0 then Exit; // только в трансвертер-режиме
if FBeaconSaveCooldown > 0 then Dec(FBeaconSaveCooldown); if FBeaconSaveCooldown > 0 then Dec(FBeaconSaveCooldown);
@@ -1310,6 +1383,93 @@ begin
Result := FBeaconTrackHz; Result := FBeaconTrackHz;
end; end;
procedure TRadioController.SetBeaconDecode(On_: Boolean);
// Включает фронтенд-декодер маяка. Цель — маяк на DC (центр RX-спектра): стартуем
// с offset=0 (или явного клик-аима), дальше частоту держит петля несущей декодера.
// От beacon-lock декодер НЕ зависит (см. ServiceBeaconLock). Пользователь наводит
// клик на маяк либо центрирует его в спектре, acquisition/Costas доводят.
begin
FBeaconDecOn := On_;
if not Assigned(FBeaconDec) then Exit;
if not On_ then FBeaconDecHz := 0; // сброс наведения при выключении
if On_ then
begin
if FBeaconDecHz > 0 then
FBeaconDec.SetOffsetHz(FBeaconDecHz - FCenterFreq)
else
FBeaconDec.SetOffsetHz(0);
end;
FBeaconDec.SetEnabled(On_);
end;
procedure TRadioController.SetBeaconDecodeAtHz(DisplayHz: Double);
// Пользователь навёл узкий фильтр на маяк (клик по спектру). Запоминаем абсолютную
// частоту, сносим её к DC и переинициализируем петли (одноразовая грубая оценка
// заново). Снос держится в ServiceBeaconLock при перестройке/панораме.
begin
FBeaconDecHz := DisplayHz;
if Assigned(FBeaconDec) then
begin
FBeaconDec.SetOffsetHz(FBeaconDecHz - FCenterFreq);
FBeaconDec.Reseed;
end;
BeaconLog(Format('AIM click: DisplayHz=%.1f CenterFreq=%.1f -> offset=%.1f invert=%d span=%.0f',
[DisplayHz, FCenterFreq, FBeaconDecHz - FCenterFreq, Ord(FBeaconDecInvert), FSpanHz]));
Changed(rfBeaconLock);
end;
procedure TRadioController.BeaconLog(const Msg: string);
// Диагностика декодера маяка: дописываем строку в HOME/ewsdr_beacon.log.
// Дёшево и кросс-платформенно; файл потом скидывается на разбор.
var
f: TextFile;
path, home: string;
begin
home := GetEnvironmentVariable('HOME');
if home = '' then home := GetTempDir;
path := IncludeTrailingPathDelimiter(home) + 'ewsdr_beacon.log';
try
AssignFile(f, path);
if FileExists(path) then Append(f) else Rewrite(f);
try
WriteLn(f, FormatDateTime('hh:nn:ss.zzz', Now), ' ', Msg);
finally
CloseFile(f);
end;
except
// лог не должен ронять контур — глотаем ошибки I/O
end;
end;
function TRadioController.BeaconDecodeEnabled: Boolean;
begin
Result := FBeaconDecOn;
end;
function TRadioController.BeaconDecodeFreqHz: Double;
begin
Result := FBeaconDecHz;
end;
procedure TRadioController.SetBeaconDecodeInvert(On_: Boolean);
begin
FBeaconDecInvert := On_;
if Assigned(FBeaconDec) then FBeaconDec.SetInvert(On_);
Changed(rfBeaconLock);
end;
function TRadioController.BeaconDecodeInvert: Boolean;
begin
Result := FBeaconDecInvert;
end;
function TRadioController.GetBeaconScope(out S: TBeaconScope): Boolean;
begin
Result := Assigned(FBeaconDec);
if Result then FBeaconDec.GetScope(S)
else FillChar(S, SizeOf(S), 0);
end;
procedure TRadioController.ApplyModeFilter; procedure TRadioController.ApplyModeFilter;
var var
Lo, Hi, Half: Integer; Lo, Hi, Half: Integer;
+28 -1
View File
@@ -77,6 +77,10 @@ type
FBeaconMarkActive: Boolean; FBeaconMarkActive: Boolean;
FBeaconRefFreq: Double; // опорная частота маяка (где ДОЛЖЕН быть) FBeaconRefFreq: Double; // опорная частота маяка (где ДОЛЖЕН быть)
FBeaconTrkFreq: Double; // отслеживаемый центроид маяка сейчас FBeaconTrkFreq: Double; // отслеживаемый центроид маяка сейчас
// узкий фильтр-маркер декодера (наведение пользователем)
FBeaconDecActive: Boolean;
FBeaconDecFreq: Double;
FBeaconDecHalf: Double; // полуширина фильтра, Гц
// ── Spectrum buffer ─────────────────────────────────────────────────────── // ── Spectrum buffer ───────────────────────────────────────────────────────
FSpectrumBuf: array[0..1023] of Single; FSpectrumBuf: array[0..1023] of Single;
FSpectrumBufCount: Integer; FSpectrumBufCount: Integer;
@@ -190,6 +194,7 @@ type
// QO-100 beacon-маркеры: опорная частота (зелёная) + позиция трекера (оранж). // QO-100 beacon-маркеры: опорная частота (зелёная) + позиция трекера (оранж).
// Спектр перерисовывается каждый кадр, поэтому отдельный invalidate не нужен. // Спектр перерисовывается каждый кадр, поэтому отдельный invalidate не нужен.
procedure SetBeaconMarkers(Active: Boolean; RefHz, TrkHz: Double); procedure SetBeaconMarkers(Active: Boolean; RefHz, TrkHz: Double);
procedure SetBeaconDecMarker(Active: Boolean; FreqHz, HalfHz: Double);
// ── S-метр ──────────────────────────────────────────────────────────────── // ── S-метр ────────────────────────────────────────────────────────────────
property LastSMeter: Double read GetLastSMeter write SetLastSMeter; property LastSMeter: Double read GetLastSMeter write SetLastSMeter;
@@ -501,6 +506,13 @@ begin
FBeaconTrkFreq := TrkHz; FBeaconTrkFreq := TrkHz;
end; end;
procedure TSpectrumView.SetBeaconDecMarker(Active: Boolean; FreqHz, HalfHz: Double);
begin
FBeaconDecActive := Active;
FBeaconDecFreq := FreqHz;
FBeaconDecHalf := HalfHz;
end;
procedure TSpectrumView.DrawBeaconMarkers(C: TCanvas; W, H: Integer); procedure TSpectrumView.DrawBeaconMarkers(C: TCanvas; W, H: Integer);
// Две вертикали: опорная частота маяка (зелёная пунктир) и отслеживаемый // Две вертикали: опорная частота маяка (зелёная пунктир) и отслеживаемый
// центроид (оранжевая сплошная). Расхождение видно глазом → понятно, сел ли // центроид (оранжевая сплошная). Расхождение видно глазом → понятно, сел ли
@@ -514,9 +526,24 @@ procedure TSpectrumView.DrawBeaconMarkers(C: TCanvas; W, H: Integer);
C.Pen.Color := Col; C.Pen.Width := 1; C.Pen.Style := Style; C.Pen.Color := Col; C.Pen.Width := 1; C.Pen.Style := Style;
C.MoveTo(x, 0); C.LineTo(x, H); C.MoveTo(x, 0); C.LineTo(x, H);
end; end;
var
xc, x1, x2: Integer;
begin
if FBeaconMarkActive then
begin begin
Vline(FBeaconRefFreq, clLime, psDot); // где маяк ДОЛЖЕН быть Vline(FBeaconRefFreq, clLime, psDot); // где маяк ДОЛЖЕН быть
Vline(FBeaconTrkFreq, TColor($000AA5FF), psSolid); // отслеживаемый центроид (оранж) Vline(FBeaconTrkFreq, TColor($000AA5FF), psSolid); // отслеживаемый центроид (оранж)
end;
// Узкий фильтр-маркер декодера: жёлтые грани полосы + центральная линия.
if FBeaconDecActive and (FSpanHz > 0) then
begin
xc := Round((FBeaconDecFreq - (FCenterFreq - FSpanHz / 2)) / FSpanHz * W);
x1 := Round((FBeaconDecFreq - FBeaconDecHalf - (FCenterFreq - FSpanHz / 2)) / FSpanHz * W);
x2 := Round((FBeaconDecFreq + FBeaconDecHalf - (FCenterFreq - FSpanHz / 2)) / FSpanHz * W);
BlendBand(x1, x2, H, $FF, $D0, $20, 48); // полупрозрачная полоса
C.Pen.Color := TColor($0020D0FF); C.Pen.Width := 1; C.Pen.Style := psSolid;
if (xc >= 0) and (xc < W) then begin C.MoveTo(xc, 0); C.LineTo(xc, H); end;
end;
C.Pen.Style := psSolid; C.Pen.Style := psSolid;
end; end;
@@ -975,7 +1002,7 @@ begin
end; end;
if FMarkerActive then DrawMarkerLine(C, W, H); if FMarkerActive then DrawMarkerLine(C, W, H);
if FBeaconMarkActive then DrawBeaconMarkers(C, W, H); if FBeaconMarkActive or FBeaconDecActive then DrawBeaconMarkers(C, W, H);
DrawADCOverloadOverlay(C, W, H); DrawADCOverloadOverlay(C, W, H);
if Assigned(FSampleRateOverlay) then if Assigned(FSampleRateOverlay) then
FSampleRateOverlay.DrawOverlay(FSpectrumBitmap, C, W, H); FSampleRateOverlay.DrawOverlay(FSpectrumBitmap, C, W, H);
+10 -1
View File
@@ -667,10 +667,19 @@ procedure TSpectrumViewOpenGL.DrawBeaconMarkersGL(W, H: Integer);
if (x < 0) or (x >= W) then Exit; if (x < 0) or (x >= W) then Exit;
DrawLine(x, 0, x, H, Col, 1, False); DrawLine(x, 0, x, H, Col, 1, False);
end; end;
begin
if FBeaconMarkActive then
begin begin
Vline(FBeaconRefFreq, clLime); // где маяк ДОЛЖЕН быть Vline(FBeaconRefFreq, clLime); // где маяк ДОЛЖЕН быть
Vline(FBeaconTrkFreq, TColor($000AA5FF)); // отслеживаемый центроид (оранж) Vline(FBeaconTrkFreq, TColor($000AA5FF)); // отслеживаемый центроид (оранж)
end; end;
if FBeaconDecActive then
begin
Vline(FBeaconDecFreq - FBeaconDecHalf, TColor($0020D0FF)); // грань фильтра
Vline(FBeaconDecFreq + FBeaconDecHalf, TColor($0020D0FF));
Vline(FBeaconDecFreq, TColor($0020D0FF)); // центр наведения
end;
end;
procedure TSpectrumViewOpenGL.DrawADCOverlay(W, H: Integer); procedure TSpectrumViewOpenGL.DrawADCOverlay(W, H: Integer);
var var
@@ -797,7 +806,7 @@ begin
DrawAGCLines(W, H, DBmax, InvRange); DrawAGCLines(W, H, DBmax, InvRange);
DrawSpectrumCurve(W, H, DBmax, InvRange); DrawSpectrumCurve(W, H, DBmax, InvRange);
DrawMarker(W, H); DrawMarker(W, H);
if FBeaconMarkActive then DrawBeaconMarkersGL(W, H); if FBeaconMarkActive or FBeaconDecActive then DrawBeaconMarkersGL(W, H);
DrawADCOverlay(W, H); DrawADCOverlay(W, H);
DrawCachedOverlays(W, H); DrawCachedOverlays(W, H);
+16
View File
@@ -25,6 +25,7 @@ interface
uses uses
Classes, SysUtils, Math, SyncObjs, Classes, SysUtils, Math, SyncObjs,
BeaconDecoder,
WDSP; WDSP;
const const
@@ -292,6 +293,7 @@ type
FMuted: Boolean; FMuted: Boolean;
FVolume: Double; FVolume: Double;
FLastError: string; FLastError: string;
FBeaconDec: TBeaconDecoder; // не владеет; тап маяка в PushIQItemToDSP
function ModeToWDSP(Mode: Integer): Integer; function ModeToWDSP(Mode: Integer): Integer;
procedure ApplyDefaultFilter; procedure ApplyDefaultFilter;
@@ -331,6 +333,9 @@ type
procedure Close; procedure Close;
procedure ChangeSampleRate(NewRate: Integer); procedure ChangeSampleRate(NewRate: Integer);
// QO-100 beacon-декодер: тап RX-IQ для демодуляции маяка (не владеет).
procedure SetBeaconDecoder(D: TBeaconDecoder);
// Подача 24-bit big-endian IQ из DDC пакета // Подача 24-bit big-endian IQ из DDC пакета
// Buf — массив байт, DataOffset — смещение до IQ данных внутри Buf // Buf — массив байт, DataOffset — смещение до IQ данных внутри Buf
procedure PushDDCPacket(const Buf: array of Byte; procedure PushDDCPacket(const Buf: array of Byte;
@@ -1131,6 +1136,15 @@ begin
// Переинициализируем WDSP с новым rate // Переинициализируем WDSP с новым rate
Open; Open;
// Beacon-декодер пересчитывает децимацию/RRC под новый IQ-rate.
if FBeaconDec <> nil then FBeaconDec.Configure(FSampleRate);
end;
procedure TWDSPEngine.SetBeaconDecoder(D: TBeaconDecoder);
begin
FBeaconDec := D;
if (D <> nil) and (FSampleRate > 0) then D.Configure(FSampleRate);
end; end;
procedure TWDSPEngine.PushDDCPacket(const Buf: array of Byte; procedure TWDSPEngine.PushDDCPacket(const Buf: array of Byte;
@@ -1213,6 +1227,8 @@ begin
FeedDisplaySample(IR * SCALE, QR * SCALE); FeedDisplaySample(IR * SCALE, QR * SCALE);
if FBeaconDec <> nil then FBeaconDec.Feed(IR * SCALE, QR * SCALE);
FRXAccI[FRXAccPos] := IR * SCALE; FRXAccI[FRXAccPos] := IR * SCALE;
FRXAccQ[FRXAccPos] := QR * SCALE; FRXAccQ[FRXAccPos] := QR * SCALE;
Inc(FRXAccPos); Inc(FRXAccPos);
+11 -2
View File
@@ -17,9 +17,9 @@
<UseVersionInfo Value="True"/> <UseVersionInfo Value="True"/>
<AutoIncrementBuild Value="True"/> <AutoIncrementBuild Value="True"/>
<MinorVersionNr Value="9"/> <MinorVersionNr Value="9"/>
<BuildNr Value="213"/> <BuildNr Value="214"/>
</VersionInfo> </VersionInfo>
<MacroValues Count="25"> <MacroValues Count="26">
<Macro1 Name="LCLWidgetType" Value="qt6"/> <Macro1 Name="LCLWidgetType" Value="qt6"/>
<Macro2 Name="LCLWidgetType" Value="qt6"/> <Macro2 Name="LCLWidgetType" Value="qt6"/>
<Macro3 Name="LCLWidgetType" Value="qt6"/> <Macro3 Name="LCLWidgetType" Value="qt6"/>
@@ -45,6 +45,7 @@
<Macro23 Name="LCLWidgetType" Value="qt6"/> <Macro23 Name="LCLWidgetType" Value="qt6"/>
<Macro24 Name="LCLWidgetType" Value="qt6"/> <Macro24 Name="LCLWidgetType" Value="qt6"/>
<Macro25 Name="LCLWidgetType" Value="qt6"/> <Macro25 Name="LCLWidgetType" Value="qt6"/>
<Macro26 Name="LCLWidgetType" Value="qt6"/>
</MacroValues> </MacroValues>
<BuildModes> <BuildModes>
<Item Name="Debug" Default="True"/> <Item Name="Debug" Default="True"/>
@@ -130,6 +131,14 @@
<Filename Value="WDSPEngine.pas"/> <Filename Value="WDSPEngine.pas"/>
<IsPartOfProject Value="True"/> <IsPartOfProject Value="True"/>
</Unit> </Unit>
<Unit>
<Filename Value="BeaconDecoder.pas"/>
<IsPartOfProject Value="True"/>
</Unit>
<Unit>
<Filename Value="BeaconScopeForm.pas"/>
<IsPartOfProject Value="True"/>
</Unit>
<Unit> <Unit>
<Filename Value="VfoOverlay.pas"/> <Filename Value="VfoOverlay.pas"/>
<IsPartOfProject Value="True"/> <IsPartOfProject Value="True"/>