TX: фиксированный sample rate, per-device grid, CTUN-aware overlay, alpha-blend filter bands

Декаплинг TX от RX sample rate (как в Thetis):
- WDSPEngine.pas: константа TX_SAMPLE_RATE=192000, поля FTXSampleRate/FTXOutBufSize.
  TXA OpenChannel теперь использует FTXSampleRate как out_rate, TX-анализатор
  через SetDisplaySampleRate(TX_DISP_ID, FTXSampleRate). ProcessTXBlock работает
  с FTXOutBufSize вместо FBufSize. Public TXSampleRate для MainForm.
- MainForm.SendDUCSpecificFromSettings: DUC0Rate теперь берётся из
  FDSPEngine.TXSampleRate, а не из RX-настроек — TX всегда 192k независимо от
  выбранного RX-rate (48/96/192/384k).

DUC Specific cache + keepalive resend (HPSDRNetwork.pas):
- FCachedDUCSpec/FCachedDUCValid параллельно DDC-кэшу.
- KeepAlive thread в первые 5s после Run=1 переотправляет и DDC, и DUC
  Specific — устраняет гонку, когда DUC настройки терялись железом.
- Disconnect сбрасывает оба valid-флага.

Per-device TX grid settings (Thetis-style):
- Settings.pas: TXSpecRefLevel/TXSpecRange/TXSpecGridStep в TTXSettings
  (defaults 0.0/80.0/10.0). LoadTX/SaveTX — ключи tx_spec_ref_level/range/grid_step.
- SettingsForm.pas: новая группа \"TX Grid\" во вкладке Transmit
  (RefLevel edit, Range edit, GridStep combo). CollectTXFromUI читает значения,
  LoadTXSettings заполняет UI.
- MainForm.pas: поля FTXSpecRefLevel/Range/GridStep, ApplySpecViewGridFromState
  пушит в FSpecView либо TX, либо RX параметры в зависимости от FTransmitting.
  ApplyGridParams (RX-путь) уважает FTransmitting и не перебивает TX-grid.
  OnTXSettingsChange при активном TX моментально применяет новый grid.

TX-aware spectrum/waterfall с CTUN-выравниванием (SpectrumView.pas):
- Поля FTXMode/FTXFreq/FTXSpanHz/FTXVfoIndex. TXSpanHz=192000 при создании.
- DrawSpectrum/DrawWaterfall в FTXMode мапят данные TX-анализатора (192k вокруг
  FTXFreq) в RX-координаты FCenterFreq/FSpanHz. Формула:
    freq = FCenterFreq - FSpanHz/2 + x*FSpanHz/W
    idx  = (freq-FTXFreq + TXSpanHz/2)/TXSpanHz * (SrcCount-1)
  Вне диапазона — спектр уходит в -200 dB; водопад копирует пиксель из
  предыдущей строки (FWfPixels[X+W]) — вместо чёрной полосы продолжает
  скроллиться старая RX-картинка.
- Эффект: при CTUN ON TX-сигнал рисуется на полосе фильтра (на VFO), а не в
  центре. При RX rate ≠ 192k частотная сетка совпадает между RX и TX
  автоматически (ruler всегда в RX-координатах).
- MainForm.SyncSpecViewFreq/ApplyMOX обновляют TXFreq/TXVfoIndex/TXMode при
  каждом PTT и смене VFO; учитывается split (FSplitTxB → TXVfoIndex=1).

Alpha-blended filter bands (визуальный feedback TX/RX):
- SpectrumView: PixelFormat:=pf32bit, процедура BlendBand через ScanLine[Y]
  внутри BeginUpdate/EndUpdate. Альфа-смешение по формуле
  (a*src + (255-a)*dst) / 255 для каждого канала BGR.
- В FTXMode полоса фильтра становится полупрозрачно красной. Если split
  (FTXVfoIndex<>FActiveVfo): на RX-частоте полоса зелёная, на TX-частоте —
  красная (CalcFilterBandX вычисляет границы для произвольной VFO-freq).
- При FTXMode=False — старое поведение (сплошная заливка SpecFilter).

Build: lazbuild --ws=qt6 ewsdr.lpi — clean, 0 errors.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
2026-05-06 14:17:49 +03:00
co-authored by Claude Opus 4.7
parent ce97ee486c
commit 96d7e0c759
6 changed files with 400 additions and 68 deletions
+188 -28
View File
@@ -80,6 +80,16 @@ type
FSpecRange: Double;
FSpecGridStep: Double;
// ── TX overlay (Thetis-style: при TX данные в FSpectrumBuf приходят
// от TX-анализатора @ FTXSpanHz Гц вокруг FTXFreq, и должны быть
// отрисованы в RX-координатах (FCenterFreq/FSpanHz), чтобы линейка
// частот совпадала с RX и сигнал стоял на правильной частоте даже
// при включённом CTUN. Если RX rate ≠ TX rate, видна только пересечение.
FTXMode: Boolean;
FTXFreq: Double; // центр TX-сигнала (Hz)
FTXSpanHz: Double; // ширина TX-окна (192000 для Hermes/Saturn)
FTXVfoIndex: Integer; // 0=A, 1=B — какой VFO привязан к TX (для split)
// ── Marker ────────────────────────────────────────────────────────────────
FMarkerActive: Boolean;
FMarkerX: Integer;
@@ -114,6 +124,14 @@ type
function ScaleX(X, Total, Width: Integer): Integer;
procedure DrawSpectrumGradient(const SpPts: array of TPoint; W, H: Integer);
procedure DrawMarkerLine(C: TCanvas; W, H: Integer);
// Альфа-blending вертикальной полосы поверх FSpectrumBitmap (pf32bit, BGRA).
// Используется для прозрачной заливки полосы фильтра разными цветами
// (RX-зелёный / TX-красный) поверх градиента сетки.
procedure BlendBand(X1, X2, H: Integer; R, G, B, Alpha: Byte);
// Считает X1/X2 для полосы фильтра вокруг указанной частоты VFO, c учётом
// текущего режима (LSB/USB/DSB и пр.) и FFilterBW.
procedure CalcFilterBandX(VfoFreq: Double; W: Integer;
out X1, X2, VfoX: Integer);
public
constructor Create;
@@ -144,6 +162,12 @@ type
property SpecRange: Double read FSpecRange write FSpecRange;
property SpecGridStep: Double read FSpecGridStep write FSpecGridStep;
// ── TX overlay ────────────────────────────────────────────────────────────
property TXMode: Boolean read FTXMode write FTXMode;
property TXFreq: Double read FTXFreq write FTXFreq;
property TXSpanHz: Double read FTXSpanHz write FTXSpanHz;
property TXVfoIndex: Integer read FTXVfoIndex write FTXVfoIndex;
// ── Маркер ────────────────────────────────────────────────────────────────
property MarkerActive: Boolean read FMarkerActive write FMarkerActive;
property MarkerX: Integer read FMarkerX write FMarkerX;
@@ -358,6 +382,8 @@ constructor TSpectrumView.Create;
begin
inherited Create;
FSpectrumBitmap := TBitmap.Create;
// pf32bit нужен для попиксельного alpha-blend в BlendBand (TX/RX-полосы фильтра).
FSpectrumBitmap.PixelFormat := pf32bit;
FWaterfallBitmap := TBitmap.Create;
FWfBitmap := TBitmap.Create;
FGridBitmap := TBitmap.Create;
@@ -383,6 +409,11 @@ begin
FSpecRange := 110.0;
FSpecGridStep := 10.0;
FSpanHz := 192000;
// TX overlay defaults — сетка совпадает с RX по умолчанию
FTXMode := False;
FTXFreq := 0.0;
FTXSpanHz := 192000.0;
FTXVfoIndex := 0;
FSpectrumBufCount := 1024;
FWaterfallBufCount := 1024;
FWfFrameInterval := 2;
@@ -442,6 +473,61 @@ begin
C.TextOut(MX - 4 - C.TextWidth(MarkerLbl), 4, MarkerLbl);
end;
procedure TSpectrumView.BlendBand(X1, X2, H: Integer; R, G, B, Alpha: Byte);
// Накладывает полупрозрачную вертикальную полосу [X1..X2) высотой H на
// FSpectrumBitmap. Канал A — сила оверлея 0..255 (255 = полностью непрозрачно).
// Формула: dst = src*(1-a) + color*a, BGRA-порядок (pf32bit Lazarus default).
// BeginUpdate(False) — нужен для прямого доступа к raw-пикселям через ScanLine.
var
Y, X, InvA: Integer;
Row: PByte;
W: Integer;
begin
if (FSpectrumBitmap = nil) or (Alpha = 0) then Exit;
W := FSpectrumBitmap.Width;
if X1 < 0 then X1 := 0;
if X2 > W then X2 := W;
if X2 <= X1 then Exit;
InvA := 255 - Alpha;
FSpectrumBitmap.BeginUpdate(False);
try
for Y := 0 to H - 1 do
begin
Row := PByte(FSpectrumBitmap.ScanLine[Y]);
if Row = nil then Continue;
Inc(Row, X1 * 4);
for X := X1 to X2 - 1 do
begin
Row[0] := Byte((Alpha * B + InvA * Row[0]) div 255);
Row[1] := Byte((Alpha * G + InvA * Row[1]) div 255);
Row[2] := Byte((Alpha * R + InvA * Row[2]) div 255);
// Row[3] оставляем — на BitBlt в TPaintBox не влияет.
Inc(Row, 4);
end;
end;
finally
FSpectrumBitmap.EndUpdate(False);
end;
end;
procedure TSpectrumView.CalcFilterBandX(VfoFreq: Double; W: Integer;
out X1, X2, VfoX: Integer);
// Возвращает X-координаты левого/правого края полосы фильтра вокруг VfoFreq
// и саму позицию VFO. Учитывает FMode (LSB/USB/DSB) и FFilterBW.
var
Lo_Hz, Hi_Hz, Half: Double;
begin
Half := FFilterBW / 2;
VfoX := Round((VfoFreq - FCenterFreq + FSpanHz / 2) / FSpanHz * W);
case FMode of
0: begin Lo_Hz := -FFilterBW; Hi_Hz := -100; end;
1: begin Lo_Hz := 100; Hi_Hz := FFilterBW; end;
else begin Lo_Hz := -Half; Hi_Hz := Half; end;
end;
X1 := Max(0, Min(W - 1, VfoX + Round(Lo_Hz / FSpanHz * W)));
X2 := Max(0, Min(W - 1, VfoX + Round(Hi_Hz / FSpanHz * W)));
end;
procedure TSpectrumView.DrawSpectrumGradient(const SpPts: array of TPoint; W, H: Integer);
var
Row, Col: Integer; YMin, Alpha: Integer;
@@ -630,7 +716,7 @@ var
i, Yp, W, H, GX: Integer;
DBmin, DBmax, dB: Double;
VfoX, X1, X2: Integer;
Lo_Hz, Hi_Hz, Half: Double;
TXVfoX, TXX1, TXX2: Integer;
AGCy, AGCHangY: Integer;
SrcF, Frac, dBv: Double;
S0, S1: Integer;
@@ -679,21 +765,34 @@ begin
LCLIntf.BitBlt(C.Handle, 0, 0, W, H, FGridBitmap.Canvas.Handle, 0, 0, $CC0020);
// ── 2. Полоса фильтра ─────────────────────────────────────────────────────
Half := FFilterBW / 2;
if FActiveVfo = 0 then
VfoX := Round((FVfoA - FCenterFreq + FSpanHz/2) / FSpanHz * W)
else
VfoX := Round((FVfoB - FCenterFreq + FSpanHz/2) / FSpanHz * W);
case FMode of
0: begin Lo_Hz := -FFilterBW; Hi_Hz := -100; end;
1: begin Lo_Hz := 100; Hi_Hz := FFilterBW; end;
else begin Lo_Hz := -Half; Hi_Hz := Half; end;
// Логика:
// Не TX → одна полоса на FActiveVfo, штатным цветом FTheme.SpecFilter (solid).
// TX, не split → одна полоса на FActiveVfo, alpha-blend красный.
// TX, split → две полосы: RX (FActiveVfo) — alpha-blend зелёный,
// TX (FTXVfoIndex) — alpha-blend красный.
// Edges + VFO-cursor рисуются позже на VfoX/X1/X2 — это RX-VFO-полоса
// (для split-режима TX-полоса рисуется без edges чтобы визуально отличалась).
if FActiveVfo = 0 then CalcFilterBandX(FVfoA, W, X1, X2, VfoX)
else CalcFilterBandX(FVfoB, W, X1, X2, VfoX);
if FTXMode then
begin
if FTXVfoIndex <> FActiveVfo then
begin
// Split TX: RX-полоса зелёная, TX-полоса (на другом VFO) красная.
if X2 > X1 then BlendBand(X1, X2, H, $30, $C0, $30, 100);
if FTXVfoIndex = 0 then CalcFilterBandX(FVfoA, W, TXX1, TXX2, TXVfoX)
else CalcFilterBandX(FVfoB, W, TXX1, TXX2, TXVfoX);
if TXX2 > TXX1 then BlendBand(TXX1, TXX2, H, $E0, $30, $30, 100);
end else
// Не split: единственная полоса — TX на активном VFO, красная.
if X2 > X1 then BlendBand(X1, X2, H, $E0, $30, $30, 100);
end else
begin
// Обычный приём — заливка штатным цветом без alpha.
C.Brush.Color := FTheme.SpecFilter; C.Brush.Style := bsSolid; C.Pen.Style := psClear;
if X2 > X1 then C.FillRect(Rect(X1, 0, X2, H));
C.Pen.Style := psSolid;
end;
X1 := Max(0, Min(W-1, VfoX + Round(Lo_Hz / FSpanHz * W)));
X2 := Max(0, Min(W-1, VfoX + Round(Hi_Hz / FSpanHz * W)));
C.Brush.Color := FTheme.SpecFilter; C.Brush.Style := bsSolid; C.Pen.Style := psClear;
if X2 > X1 then C.FillRect(Rect(X1, 0, X2, H));
C.Pen.Style := psSolid;
// ── 3. AGC линии ──────────────────────────────────────────────────────────
if FWDSPReady then
@@ -729,15 +828,40 @@ begin
if FSpPtsLen <> W + 2 then
begin SetLength(FSpPts, W + 2); FSpPtsLen := W + 2; end;
SrcCount := EnsureRange(FSpectrumBufCount, 2, 1024);
for i := 0 to W - 1 do
if FTXMode and (FTXSpanHz > 0) and (FSpanHz > 0) then
begin
SrcF := i * (SrcCount - 1.0) / Max(1, W - 1);
S0 := Min(Trunc(SrcF), SrcCount - 1);
S1 := Min(S0 + 1, SrcCount - 1);
Frac := SrcF - S0;
dBv := FSpectrumBuf[S0] * (1.0 - Frac) + FSpectrumBuf[S1] * Frac;
FSpPts[i] := Point(i, Max(0, Min(H-1, Round((DBmax - dBv) * InvRange * H))));
end;
// TX-режим: данные приходят @ FTXSpanHz вокруг FTXFreq (TX-анализатор —
// baseband), а рисуем в координатах RX (FCenterFreq/FSpanHz). Это даёт
// правильное положение TX-сигнала при CTUN ON и выравнивает сетку, когда
// RX rate ≠ TX rate (192k).
for i := 0 to W - 1 do
begin
// freq, соответствующая пикселю i в RX-координатах
SrcF := FCenterFreq - FSpanHz * 0.5 + i * FSpanHz / Max(1, W - 1);
// смещение от центра TX-окна
Frac := SrcF - FTXFreq;
if (Frac < -FTXSpanHz * 0.5) or (Frac > FTXSpanHz * 0.5) then
dBv := -200.0 // вне диапазона TX-анализатора
else
begin
SrcF := (Frac + FTXSpanHz * 0.5) / FTXSpanHz * (SrcCount - 1);
S0 := Min(Trunc(SrcF), SrcCount - 1);
S1 := Min(S0 + 1, SrcCount - 1);
Frac := SrcF - S0;
dBv := FSpectrumBuf[S0] * (1.0 - Frac) + FSpectrumBuf[S1] * Frac;
end;
FSpPts[i] := Point(i, Max(0, Min(H-1, Round((DBmax - dBv) * InvRange * H))));
end;
end else
for i := 0 to W - 1 do
begin
SrcF := i * (SrcCount - 1.0) / Max(1, W - 1);
S0 := Min(Trunc(SrcF), SrcCount - 1);
S1 := Min(S0 + 1, SrcCount - 1);
Frac := SrcF - S0;
dBv := FSpectrumBuf[S0] * (1.0 - Frac) + FSpectrumBuf[S1] * Frac;
FSpPts[i] := Point(i, Max(0, Min(H-1, Round((DBmax - dBv) * InvRange * H))));
end;
FSpPts[W] := Point(W-1, H); FSpPts[W+1] := Point(0, H);
// ── 5. Градиент ───────────────────────────────────────────────────────────
@@ -756,6 +880,17 @@ begin
C.MoveTo(VfoX, 0); C.LineTo(VfoX, H - 12);
C.Brush.Color := FTheme.SpecVfoCursor; C.Brush.Style := bsSolid; C.Pen.Width := 1;
C.Polygon([Point(VfoX-5,0), Point(VfoX+5,0), Point(VfoX,8)]);
// Split TX: рисуем края + cursor для TX-VFO красным, чтобы было видно где идёт передача
if FTXMode and (FTXVfoIndex <> FActiveVfo) then
begin
C.Pen.Color := TColor($002030E0); C.Pen.Width := 1; C.Pen.Style := psSolid;
C.MoveTo(TXX1, 0); C.LineTo(TXX1, H);
C.MoveTo(TXX2, 0); C.LineTo(TXX2, H);
C.Pen.Width := 2;
C.MoveTo(TXVfoX, 0); C.LineTo(TXVfoX, H - 12);
C.Brush.Color := TColor($002030E0); C.Brush.Style := bsSolid; C.Pen.Width := 1;
C.Polygon([Point(TXVfoX-5,0), Point(TXVfoX+5,0), Point(TXVfoX,8)]);
end;
if FMarkerActive then DrawMarkerLine(C, W, H);
end;
@@ -851,11 +986,36 @@ begin
WatSrcF := 0.0; Px := @FWfPixels[0];
for X := 0 to W - 1 do
begin
WatS0 := Trunc(WatSrcF);
if WatS0 > SrcCount - 2 then WatS0 := SrcCount - 2;
WatS1 := WatS0 + 1;
frac := WatSrcF - WatS0;
dB := FWaterfallBuf[WatS0] * (1.0 - frac) + FWaterfallBuf[WatS1] * frac;
if FTXMode and (FTXSpanHz > 0) and (FSpanHz > 0) then
begin
// частота для пикселя X в RX-координатах → индекс в TX-буфере
dB := FCenterFreq - FSpanHz * 0.5 + X * FSpanHz / Max(1, W - 1) - FTXFreq;
if (dB < -FTXSpanHz * 0.5) or (dB > FTXSpanHz * 0.5) then
begin
// Вне TX-окна: копируем пиксель из предыдущей строки (она лежит на
// расстоянии W в FWfPixels — Move уже сдвинул её ниже). Так старая
// RX-картинка продолжается без чёрной "дыры", пока TX рисует своё
// окно в центре.
if H > 1 then Px^ := FWfPixels[X + W]
else Px^ := 0;
Inc(Px);
WatSrcF := WatSrcF + Step;
Continue;
end;
frac := (dB + FTXSpanHz * 0.5) / FTXSpanHz * (SrcCount - 1);
WatS0 := Trunc(frac);
if WatS0 > SrcCount - 2 then WatS0 := SrcCount - 2;
WatS1 := WatS0 + 1;
frac := frac - WatS0;
dB := FWaterfallBuf[WatS0] * (1.0 - frac) + FWaterfallBuf[WatS1] * frac;
end else
begin
WatS0 := Trunc(WatSrcF);
if WatS0 > SrcCount - 2 then WatS0 := SrcCount - 2;
WatS1 := WatS0 + 1;
frac := WatSrcF - WatS0;
dB := FWaterfallBuf[WatS0] * (1.0 - frac) + FWaterfallBuf[WatS1] * frac;
end;
V := Trunc((dB - WfLow) * InvRange);
if V < 0 then V := 0; if V > 255 then V := 255;
if FLightTheme then