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
+162 -2
View File
@@ -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 реально захвачен (зелёный) — доверяем его оценке и сносим
// 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 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;