feat(fm): add universal FM RAW decoder output

This commit is contained in:
2026-07-16 18:58:28 +03:00
parent 3acd569da5
commit cd816c54d6
16 changed files with 656 additions and 148 deletions
+165 -5
View File
@@ -21,7 +21,7 @@ uses
Classes, SysUtils, Graphics, GraphType, ExtCtrls, Controls, Math,
AppTheme,
AlertOverlay, SampleRateOverlay, VfoOverlay, BandPlanOverlay,
WaterfallView, SMeterView, RulerView;
WaterfallView, SMeterView, RulerView, RadioModes;
const
SV_CLR_BG = TColor($00101010);
@@ -36,6 +36,16 @@ type
LastUse: Int64;
end;
// Маркер 2TON/IMD-измерения на спектре: кружок на пике продукта + подпись.
// Тона подписываются уровнем шкалы (dB), IMD-продукты — в dBc от среднего
// уровня тонов.
TIMDMarker = record
FreqHz: Double; // абсолютная частота продукта
LevelDB: Double; // измеренный пик (шкала дисплея)
Caption: string;
IsTone: Boolean; // True = основной тон, False = IMD-продукт
end;
TSpectrumView = class
protected
// ── Off-screen bitmaps ────────────────────────────────────────────────────
@@ -99,6 +109,11 @@ type
FBeaconDecActive: Boolean;
FBeaconDecFreq: Double;
FBeaconDecHalf: Double; // полуширина фильтра, Гц
// ── 2TON/IMD-маркеры (пики тонов и продуктов IMD3 при двухтональнике) ────
FIMDActive: Boolean;
FIMDTone1Hz: Double; // абс. частоты тонов (TX freq + аудио-оффсет)
FIMDTone2Hz: Double;
FIMDSummary: string; // сводка «IMD3 −NN dBc» (обновляет ComputeIMDMarkers)
// ── Spectrum buffer ───────────────────────────────────────────────────────
FSpectrumBuf: array[0..WF_MAX_PIXELS-1] of Single;
FSpectrumBufCount: Integer;
@@ -149,6 +164,12 @@ type
procedure DrawSliceFilterLinesRaw(W, H: Integer);
procedure DrawBandLetterRaw(X1, X2: Integer; L: Char; Clr: TColor);
procedure DrawBeaconMarkersRaw(W, H: Integer);
// 2TON/IMD: замер пиков по TX-буферу (общий для CPU/GL рендеров).
// Возвращает число валидных маркеров в M (0 если измерять нечего):
// [0..1] тона, [2..3] IMD3 (2f1f2, 2f2f1). Обновляет FIMDSummary.
function ComputeIMDMarkers(var M: array of TIMDMarker): Integer;
procedure DrawIMDMarkersRaw(W, H: Integer; DBmax, InvRange: Double);
procedure RawCircle(CX, CY, R: Integer; Color: TColor);
procedure BlendBand(X1, X2, H: Integer; R, G, B, Alpha: Byte);
procedure FillBandRaw(X1, X2, H: Integer; Color: TColor);
procedure CopyGridToSpectrum(W, H: Integer);
@@ -244,6 +265,12 @@ type
procedure SetBeaconMarkers(Active: Boolean; RefHz, TrkHz: Double);
procedure SetBeaconDecMarker(Active: Boolean; FreqHz, HalfHz: Double);
// 2TON/IMD-маркеры: кружки с уровнями на пиках тонов и продуктов IMD3
// (замер линейности/PureSignal). Включает MainForm при 2TON+TX+DUP:
// дисплей на RX показывает СВОЙ сигнал после PA — настоящие IMD-плечи
// (на цифровом TX-спектре продуктов PA нет, там замер бессмыслен).
procedure SetIMDMarkers(Active: Boolean; Tone1Hz, Tone2Hz: Double);
// ── S-метр ────────────────────────────────────────────────────────────────
property LastSMeter: Double read GetLastSMeter write SetLastSMeter;
property SMeterPeak: Double read GetSMeterPeak write SetSMeterPeak;
@@ -653,6 +680,136 @@ begin
end;
end;
procedure TSpectrumView.SetIMDMarkers(Active: Boolean; Tone1Hz, Tone2Hz: Double);
begin
FIMDActive := Active;
FIMDTone1Hz := Tone1Hz;
FIMDTone2Hz := Tone2Hz;
if not Active then FIMDSummary := '';
end;
function TSpectrumView.ComputeIMDMarkers(var M: array of TIMDMarker): Integer;
// Пики меряются по FSpectrumBuf. Основной сценарий — DUP: дисплей на RX
// (TXMode=False), буфер покрывает видимое окно FCenterFreq ± FSpanHz/2, и мы
// видим СВОЙ сигнал после PA (реальные IMD-плечи). При TXMode буфер покрывает
// FTXFreq ± FTXSpanHz/2 (цифровой TX-тракт — оставлено для общности).
// Окно поиска ±250 Гц вокруг ожидаемой частоты — ловим пик независимо от
// джиттера FFT.
var
SrcCount, i: Integer;
ToneAvg, MapLo, MapSpan: Double;
function PeakDB(FreqHz: Double): Double;
var C, Win, k, i1, i2: Integer;
begin
Result := -999.0;
C := Round((FreqHz - MapLo) / MapSpan * (SrcCount - 1));
// Ожидаемая частота вне окна буфера (зум/узкий span) — маркер невалиден,
// иначе намеряли бы уровень краевого бина.
if (C < 0) or (C > SrcCount - 1) then Exit;
Win := Max(2, Round(250.0 * (SrcCount - 1) / MapSpan));
i1 := Max(0, C - Win);
i2 := Min(SrcCount - 1, C + Win);
for k := i1 to i2 do
if FSpectrumBuf[k] > Result then Result := FSpectrumBuf[k];
end;
begin
Result := 0;
FIMDSummary := '';
if not FIMDActive then Exit;
if (FSpectrumBufCount < 2) or (Length(M) < 4) then Exit;
if FTXMode then
begin
if FTXSpanHz <= 0 then Exit;
MapLo := FTXFreq - FTXSpanHz * 0.5;
MapSpan := FTXSpanHz;
end
else
begin
if FSpanHz <= 0 then Exit;
MapLo := FCenterFreq - FSpanHz * 0.5;
MapSpan := FSpanHz;
end;
SrcCount := EnsureRange(FSpectrumBufCount, 2, WF_MAX_PIXELS);
M[0].FreqHz := FIMDTone1Hz; M[0].IsTone := True;
M[1].FreqHz := FIMDTone2Hz; M[1].IsTone := True;
M[2].FreqHz := 2 * FIMDTone1Hz - FIMDTone2Hz; M[2].IsTone := False;
M[3].FreqHz := 2 * FIMDTone2Hz - FIMDTone1Hz; M[3].IsTone := False;
for i := 0 to 3 do
M[i].LevelDB := PeakDB(M[i].FreqHz);
ToneAvg := (M[0].LevelDB + M[1].LevelDB) * 0.5;
M[0].Caption := Format('%.0f', [M[0].LevelDB]);
M[1].Caption := Format('%.0f', [M[1].LevelDB]);
// IMD-продукты — в dBc относительно среднего уровня тонов
M[2].Caption := Format('%.0fc', [M[2].LevelDB - ToneAvg]);
M[3].Caption := Format('%.0fc', [M[3].LevelDB - ToneAvg]);
// Сводка только когда оба тона и хотя бы один продукт в окне буфера
if (M[0].LevelDB > -500) and (M[1].LevelDB > -500) and
(Max(M[2].LevelDB, M[3].LevelDB) > -500) then
FIMDSummary := Format('IMD3 %.0f dBc',
[Max(M[2].LevelDB, M[3].LevelDB) - ToneAvg]);
Result := 4;
end;
procedure TSpectrumView.RawCircle(CX, CY, R: Integer; Color: TColor);
// Залитый кружок радиуса R: по строкам, ширина из уравнения окружности.
var
DY, HalfW, Y, X1, X2, K: Integer;
Px: LongWord;
P: PLongWord;
begin
if FRawBase = nil then Exit;
Px := RawPack(Color);
for DY := -R to R do
begin
Y := CY + DY;
if (Y < 0) or (Y >= FRawH) then Continue;
HalfW := Round(Sqrt(R * R - DY * DY));
X1 := Max(0, CX - HalfW);
X2 := Min(FRawW - 1, CX + HalfW);
if X2 < X1 then Continue;
P := PLongWord(FRawBase + Y * FRawBPL + X1 * 4);
for K := X1 to X2 do begin P^ := Px; Inc(P); end;
end;
end;
procedure TSpectrumView.DrawIMDMarkersRaw(W, H: Integer; DBmax, InvRange: Double);
const
CLR_TONE = TColor($0050FF50); // зелёный — основные тона
CLR_IMD = TColor($000A8CFF); // оранжевый — продукты IMD3
var
M: array[0..3] of TIMDMarker;
N, i, X, Y, TY: Integer;
Col: TColor;
begin
if FSpanHz <= 0 then Exit;
N := ComputeIMDMarkers(M);
if N = 0 then Exit;
for i := 0 to N - 1 do
begin
if M[i].LevelDB < -500 then Continue; // частота вне окна буфера
X := Round((M[i].FreqHz - (FCenterFreq - FSpanHz / 2)) / FSpanHz * W);
if (X < 0) or (X >= W) then Continue;
Y := Max(0, Min(H - 1, Round((DBmax - M[i].LevelDB) * InvRange * H)));
if M[i].IsTone then Col := CLR_TONE else Col := CLR_IMD;
RawCircle(X, Y, 4, Col);
// Подпись правее-выше кружка; у верхней кромки — вниз, у правой — влево.
TY := Y - 14;
if TY < 2 then TY := Y + 6;
if X + 6 + RawTextWidth(M[i].Caption, 7, True) < W then
RawText(X + 6, TY, M[i].Caption, Col, 7, True, clNone)
else
RawText(X - 6 - RawTextWidth(M[i].Caption, 7, True), TY,
M[i].Caption, Col, 7, True, clNone);
end;
// Сводка в левом верхнем углу
if FIMDSummary <> '' then
RawText(6, 6, FIMDSummary, CLR_IMD, 9, True, clNone);
end;
// ════════════════════════════════════════════════════════════════════════════
// Full-raw примитивы кадра (валидны только между RawBegin/RawEnd)
// ════════════════════════════════════════════════════════════════════════════
@@ -1098,10 +1255,10 @@ begin
Half := BW / 2;
VfoX := Round((VfoFreq - FCenterFreq + FSpanHz / 2) / FSpanHz * W);
case Mode of
0: begin Lo_Hz := -BW; Hi_Hz := -100; end; // LSB
1: begin Lo_Hz := 100; Hi_Hz := BW; end; // USB
9: begin Lo_Hz := -BW; Hi_Hz := 0; end; // DIGL — полоса от нуля
8: begin Lo_Hz := 0; Hi_Hz := BW; end; // DIGU
MODE_LSB: begin Lo_Hz := -BW; Hi_Hz := -100; end;
MODE_USB: begin Lo_Hz := 100; Hi_Hz := BW; end;
MODE_DIGL: begin Lo_Hz := -BW; Hi_Hz := 0; end; // полоса от нуля
MODE_DIGU: begin Lo_Hz := 0; Hi_Hz := BW; end;
else begin Lo_Hz := -Half; Hi_Hz := Half; end;
end;
X1 := Max(0, Min(W - 1, VfoX + Round(Lo_Hz / FSpanHz * W)));
@@ -1546,6 +1703,9 @@ begin
if FMarkerActive then DrawMarkerLineRaw(W, H);
if FBeaconMarkActive or FBeaconDecActive then DrawBeaconMarkersRaw(W, H);
// 2TON: кружки с уровнями на пиках тонов/IMD3 (активирует MainForm при
// 2TON+TX+DUP — реальный сигнал после PA виден на RX-спектре)
if FIMDActive then DrawIMDMarkersRaw(W, H, DBmax, InvRange);
// Композиты оверлеев — внутри внешнего лока: их собственные BeginUpdate/