diff --git a/BeaconDecoder.pas b/BeaconDecoder.pas new file mode 100644 index 0000000..8fe2f61 --- /dev/null +++ b/BeaconDecoder.pas @@ -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. diff --git a/BeaconScopeForm.pas b/BeaconScopeForm.pas new file mode 100644 index 0000000..b2a703f --- /dev/null +++ b/BeaconScopeForm.pas @@ -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. diff --git a/MainForm.pas b/MainForm.pas index d660abe..beef32c 100644 --- a/MainForm.pas +++ b/MainForm.pas @@ -39,7 +39,7 @@ uses WinFirewall, BoardUtils, WisdomBuilder, UISync, FlatEdit, FMRepeater, - ChannelStore, ChannelsForm, + ChannelStore, ChannelsForm, BeaconScopeForm, PowerInhibit, DeviceStore, RadioBackend, PlutoBackend, @@ -135,6 +135,7 @@ type FSpecDragX0: Integer; // X при нажатии FSpecDragFreq: Double; // FCenterFreq при нажатии FBeaconArming: Boolean; // ждём клик по маяку (после включения BEACON) + FBeaconDecArming: Boolean; // ждём клик: навести узкий фильтр декодера на маяк FSpectrumDirty: Boolean; // таймер должен перерисовать спектр/водопад // TX DUC-очередь (FDUCPending*) переехала в TRadioController (OnTXIQ). FSMeterPeak: Double; // верхняя граница светлой зоны @@ -287,6 +288,7 @@ type BtnChannels: TFlatButton; FChannelsDropDown: TFlatDropDown; FChannelsForm: TObject; // TChannelsForm (cast при использовании) + FBeaconScopeForm: TBeaconScopeForm; // окно констелляции маяка (ПКМ по BEACON) // ---- Right panel ---- PanelRight: TPanel; @@ -384,6 +386,8 @@ type procedure BtnFilterClick(Sender: TObject); procedure BtnCTunClick(Sender: TObject); procedure BtnBeaconClick(Sender: TObject); + procedure BtnBeaconMouseDown(Sender: TObject; Button: TMouseButton; + Shift: TShiftState; X, Y: Integer); procedure UpdateBeaconButton; procedure BtnDUPClick(Sender: TObject); procedure ApplyDUP(Active: Boolean); @@ -795,6 +799,7 @@ begin FController.FTXSpecGridStep := 10.0; FSettingsForm := nil; FChannelsForm := nil; + FBeaconScopeForm := nil; FillChar(FController.FDevMAC, SizeOf(FController.FDevMAC), 0); // Инициализируем кэш диапазонов умолчаниями @@ -1752,6 +1757,7 @@ begin LeftPanelButtonWidth(LEFT_W, 3, 2), BTN_H, BtnBeaconClick); BtnBeacon.Visible := False; + BtnBeacon.OnMouseDown := BtnBeaconMouseDown; // ПКМ → декодер + окно констелляции StyleButton(BtnBeacon, False); MakeLbl(PanelRXBlock, 'VOL', 4, 50); @@ -3272,6 +3278,9 @@ function TMainForm.BeaconStatusText: string; // Компактный статус QO-100 beacon lock для поля 7 статус-бара (Pluto). Courier 8pt, // поле ~200px → держим ≤ ~24 символов. 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 FBeaconArming then Exit('BCN: click beacon'); case FController.BeaconState of @@ -3988,6 +3997,28 @@ begin UpdateBeaconButton; 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; // Beacon-маркеры + кнопка BEACON. Кнопка видима только на Pluto в трансвертере // (см. rfXvtr) — это ПРОСТО кнопка: подпись 'BEACON', подсветка = лок включён. @@ -3996,9 +4027,15 @@ procedure TMainForm.UpdateBeaconButton; begin // Маркеры на спектре: опорная частота (зелёная) + отслеживаемый центроид (оранж). if FSpecView <> nil then + begin FSpecView.SetBeaconMarkers(FController.BeaconLockEnabled, FController.BeaconRefHz, FController.BeaconTrackedFreqHz); + // Узкий фильтр-маркер декодера (±450 Гц ≈ полоса BPSK-маяка). + FSpecView.SetBeaconDecMarker( + FController.BeaconDecodeEnabled and (FController.BeaconDecodeFreqHz > 0), + FController.BeaconDecodeFreqHz, 450.0); + end; UpdateStatusField7; // моментальный апдейт статус-бара (не ждём MeterTimer) if BtnBeacon = nil then Exit; BtnBeacon.Caption := 'BEACON'; @@ -4186,6 +4223,17 @@ begin if Button = mbLeft then 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). if FBeaconArming and (PbSpectrum.Width > 0) then begin diff --git a/RadioController.pas b/RadioController.pas index 9725ba6..97c52e9 100644 --- a/RadioController.pas +++ b/RadioController.pas @@ -49,7 +49,7 @@ interface uses Classes, SysUtils, Math, HPSDRProtocol, HPSDRNetwork, RadioBackend, PlutoBackend, IIOBindings, - WDSPEngine, AudioOutput, AudioInput, + WDSPEngine, AudioOutput, AudioInput, BeaconDecoder, Settings, ChannelStore, FMRepeater, BoardUtils, DeviceStore; type @@ -196,6 +196,14 @@ type FBeaconLocked: Boolean; // пик уверенно виден FBeaconCorrCooldown: Integer; // тиков паузы после коррекции (осадка пайплайна) 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 ---- FVolume: Integer; @@ -477,6 +485,7 @@ type // QO-100 beacon lock — стабилизация дрейфа LNB по опорному маяку. procedure SetBeaconLock(On_: Boolean); procedure ServiceBeaconLock; // одна итерация контура (MeterTimer @10 Гц) + procedure BeaconLog(const Msg: string); // диагностика декодера → HOME/ewsdr_beacon.log procedure SetBeaconRefHz(Hz: Double); // опорная частота маяка (downlink) procedure BeaconSeedAtHz(DisplayHz: Double); // пользователь указал маяк кликом function BeaconState: TBeaconState; @@ -486,6 +495,15 @@ type function BeaconRefHz: Double; // опорная частота маяка (для маркера) 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 SetDuplex(On_: Boolean); @@ -596,6 +614,16 @@ begin FBeaconLockHz := 0.0; 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 вызывается позже (после показа // формы / при подключении устройства). FAudioOut := TAudioOutput.Create(48000); @@ -612,7 +640,13 @@ begin end; 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; + if Assigned(FDSPEngine) then + begin + FDSPEngine.SetBeaconDecoder(nil); + FDSPEngine.Close; + FreeAndNil(FDSPEngine); + end; + if Assigned(FBeaconDec) then FreeAndNil(FBeaconDec); end; destructor TRadioController.Destroy; @@ -1200,6 +1234,45 @@ procedure TRadioController.ServiceBeaconLock; // лок не теряется; во время settle позицию не двигаем (FFT в транзиенте). var win, pk, prom, cand, e, dLock: Double; begin + // Beacon-декодер: ЦЕЛЬ — маяк на DC IQ-тапа (= центр RX-спектра, тот же сырой + // DDC-IQ). По умолчанию offset=0: пользователь ставит маяк в центр спектра + // (CTUN/перестройка), squaring-FFT acquisition доводит остаток. ТОЛЬКО когда + // beacon-lock реально захвачен (зелёный) — доверяем его оценке и сносим + // FBeaconTrackHz−FCenterFreq (авто-центровка). Развязано от сырого 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 FCurrentXvtr < 0 then Exit; // только в трансвертер-режиме if FBeaconSaveCooldown > 0 then Dec(FBeaconSaveCooldown); @@ -1310,6 +1383,93 @@ begin Result := FBeaconTrackHz; 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; var Lo, Hi, Half: Integer; diff --git a/SpectrumView.pas b/SpectrumView.pas index 0e01336..5e3f9fa 100644 --- a/SpectrumView.pas +++ b/SpectrumView.pas @@ -77,6 +77,10 @@ type FBeaconMarkActive: Boolean; FBeaconRefFreq: Double; // опорная частота маяка (где ДОЛЖЕН быть) FBeaconTrkFreq: Double; // отслеживаемый центроид маяка сейчас + // узкий фильтр-маркер декодера (наведение пользователем) + FBeaconDecActive: Boolean; + FBeaconDecFreq: Double; + FBeaconDecHalf: Double; // полуширина фильтра, Гц // ── Spectrum buffer ─────────────────────────────────────────────────────── FSpectrumBuf: array[0..1023] of Single; FSpectrumBufCount: Integer; @@ -190,6 +194,7 @@ type // QO-100 beacon-маркеры: опорная частота (зелёная) + позиция трекера (оранж). // Спектр перерисовывается каждый кадр, поэтому отдельный invalidate не нужен. procedure SetBeaconMarkers(Active: Boolean; RefHz, TrkHz: Double); + procedure SetBeaconDecMarker(Active: Boolean; FreqHz, HalfHz: Double); // ── S-метр ──────────────────────────────────────────────────────────────── property LastSMeter: Double read GetLastSMeter write SetLastSMeter; @@ -501,6 +506,13 @@ begin FBeaconTrkFreq := TrkHz; 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); // Две вертикали: опорная частота маяка (зелёная пунктир) и отслеживаемый // центроид (оранжевая сплошная). Расхождение видно глазом → понятно, сел ли @@ -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.MoveTo(x, 0); C.LineTo(x, H); end; +var + xc, x1, x2: Integer; begin - Vline(FBeaconRefFreq, clLime, psDot); // где маяк ДОЛЖЕН быть - Vline(FBeaconTrkFreq, TColor($000AA5FF), psSolid); // отслеживаемый центроид (оранж) + if FBeaconMarkActive then + begin + Vline(FBeaconRefFreq, clLime, psDot); // где маяк ДОЛЖЕН быть + 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; end; @@ -975,7 +1002,7 @@ begin end; 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); if Assigned(FSampleRateOverlay) then FSampleRateOverlay.DrawOverlay(FSpectrumBitmap, C, W, H); diff --git a/SpectrumViewOpengl.pas b/SpectrumViewOpengl.pas index 43205e1..1dc1391 100644 --- a/SpectrumViewOpengl.pas +++ b/SpectrumViewOpengl.pas @@ -668,8 +668,17 @@ procedure TSpectrumViewOpenGL.DrawBeaconMarkersGL(W, H: Integer); DrawLine(x, 0, x, H, Col, 1, False); end; begin - Vline(FBeaconRefFreq, clLime); // где маяк ДОЛЖЕН быть - Vline(FBeaconTrkFreq, TColor($000AA5FF)); // отслеживаемый центроид (оранж) + if FBeaconMarkActive then + begin + Vline(FBeaconRefFreq, clLime); // где маяк ДОЛЖЕН быть + Vline(FBeaconTrkFreq, TColor($000AA5FF)); // отслеживаемый центроид (оранж) + 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); @@ -797,7 +806,7 @@ begin DrawAGCLines(W, H, DBmax, InvRange); DrawSpectrumCurve(W, H, DBmax, InvRange); DrawMarker(W, H); - if FBeaconMarkActive then DrawBeaconMarkersGL(W, H); + if FBeaconMarkActive or FBeaconDecActive then DrawBeaconMarkersGL(W, H); DrawADCOverlay(W, H); DrawCachedOverlays(W, H); diff --git a/WDSPEngine.pas b/WDSPEngine.pas index 0fc15a9..92ffcf9 100644 --- a/WDSPEngine.pas +++ b/WDSPEngine.pas @@ -25,6 +25,7 @@ interface uses Classes, SysUtils, Math, SyncObjs, + BeaconDecoder, WDSP; const @@ -292,6 +293,7 @@ type FMuted: Boolean; FVolume: Double; FLastError: string; + FBeaconDec: TBeaconDecoder; // не владеет; тап маяка в PushIQItemToDSP function ModeToWDSP(Mode: Integer): Integer; procedure ApplyDefaultFilter; @@ -331,6 +333,9 @@ type procedure Close; procedure ChangeSampleRate(NewRate: Integer); + // QO-100 beacon-декодер: тап RX-IQ для демодуляции маяка (не владеет). + procedure SetBeaconDecoder(D: TBeaconDecoder); + // Подача 24-bit big-endian IQ из DDC пакета // Buf — массив байт, DataOffset — смещение до IQ данных внутри Buf procedure PushDDCPacket(const Buf: array of Byte; @@ -1131,6 +1136,15 @@ begin // Переинициализируем WDSP с новым rate 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; procedure TWDSPEngine.PushDDCPacket(const Buf: array of Byte; @@ -1213,6 +1227,8 @@ begin FeedDisplaySample(IR * SCALE, QR * SCALE); + if FBeaconDec <> nil then FBeaconDec.Feed(IR * SCALE, QR * SCALE); + FRXAccI[FRXAccPos] := IR * SCALE; FRXAccQ[FRXAccPos] := QR * SCALE; Inc(FRXAccPos); diff --git a/ewsdr.lpi b/ewsdr.lpi index e2d7d57..8d00486 100644 --- a/ewsdr.lpi +++ b/ewsdr.lpi @@ -17,9 +17,9 @@ - + - + @@ -45,6 +45,7 @@ + @@ -130,6 +131,14 @@ + + + + + + + +