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
+30 -16
View File
@@ -108,6 +108,12 @@ const
// TX mic ring buffer — должен быть степенью двойки
TX_MIC_RING = 8192;
// TX/DUC sample rate. Фиксирован независимо от FSampleRate (RX).
// Thetis/piHPSDR держат TX rate постоянным (192k для Hermes/Saturn DUC0),
// меняя только RX rate. Это гарантирует, что DUC IQ всегда подаётся в том
// темпе, который ожидает железо (DUC0RateHi/Lo в DUC Specific = 192).
TX_SAMPLE_RATE = 192000;
type
// Пакет в очереди между сетевым и DSP потоком
TIQQueueItem = record
@@ -145,6 +151,8 @@ type
FAudioRate: Integer;
FBufSize: Integer; // входной буфер @ FSampleRate
FAudioBufSize: Integer; // выходной буфер @ FAudioRate (= FBufSize * FAudioRate / FSampleRate)
FTXSampleRate: Integer; // фиксированный rate TX/DUC (TX_SAMPLE_RATE), не меняется при ChangeSampleRate
FTXOutBufSize: Integer; // выходной буфер TXA в IQ парах @ FTXSampleRate (= FAudioBufSize * FTXSampleRate / FAudioRate)
// Interleaved double I/Q буферы для fexchange0
FRXIn: array of Double;
@@ -386,6 +394,7 @@ type
property Initialized: Boolean read FInitialized;
property SampleRate: Integer read FSampleRate;
property TXSampleRate: Integer read FTXSampleRate;
property AudioBufSize: Integer read FAudioBufSize;
property AGCTop: Double read FAGCTop;
property AGCSlope: Integer read FAGCSlope;
@@ -644,6 +653,9 @@ begin
// piHPSDR: in_size = buffer_size * sample_rate/48000 = 1024*4 = 4096 @ 192kHz
FAudioBufSize := BufSize; // dsp_size = out_size @ AudioRate
FBufSize := BufSize * SampleRate div AudioRate; // in_size @ SampleRate = 4096
// TX rate фиксирован — не зависит от RX. FTXOutBufSize = out_size TXA @ FTXSampleRate.
FTXSampleRate := TX_SAMPLE_RATE;
FTXOutBufSize := BufSize * FTXSampleRate div AudioRate; // = 4096 @ 48k audio, 192k tx
FInitialized := False;
FAnalyzerOpen := False;
FMode := MODE_USB;
@@ -672,10 +684,10 @@ begin
FWfAvgMode := 3; // Log Recursive
FWfAvgTimeMS := 120.0;
SetLength(FRXIn, FBufSize * 2); // in_size пар @ FSampleRate (4096*2)
SetLength(FRXOut, FAudioBufSize * 2); // out_size пар @ FAudioRate (1024*2)
SetLength(FTXIn, FAudioBufSize * 2);
SetLength(FTXOut, FBufSize * 2);
SetLength(FRXIn, FBufSize * 2); // in_size пар @ FSampleRate (4096*2)
SetLength(FRXOut, FAudioBufSize * 2); // out_size пар @ FAudioRate (1024*2)
SetLength(FTXIn, FAudioBufSize * 2); // in_size пар @ FAudioRate
SetLength(FTXOut, FTXOutBufSize * 2); // out_size пар @ FTXSampleRate (фикс)
SetLength(FRXAccI, FBufSize);
SetLength(FRXAccQ, FBufSize);
SetLength(FSpecBuf, FBufSize * 2);
@@ -847,7 +859,7 @@ const KEEP_TIME_SEC = 0.10;
begin
AnalyzerFFT := NormalizeFFTSize(FTXFFTSize);
FTXDispPos := 0;
MaxW := AnalyzerFFT + Round(Min(KEEP_TIME_SEC * FSampleRate,
MaxW := AnalyzerFFT + Round(Min(KEEP_TIME_SEC * FTXSampleRate,
KEEP_TIME_SEC * AnalyzerFFT * EnsureRange(FDisplayFPS, 5, 100)));
CalcAnalyzerTiming(AnalyzerFFT, Overlap, EffectiveFPS);
DisplayPixels := EnsureRange(FLastSpectrumW, 64, SPECTRUM_PIXELS);
@@ -868,7 +880,7 @@ begin
SetDisplayNumAverage (TX_DISP_ID, 1, WfAvgCount);
SetDisplayAvBackmult (TX_DISP_ID, 1, WfBackmult);
SetDisplayNormOneHz (TX_DISP_ID, 1, 0);
SetDisplaySampleRate (TX_DISP_ID, FSampleRate);
SetDisplaySampleRate (TX_DISP_ID, FTXSampleRate);
end;
procedure TWDSPEngine.ApplyAnalyzerSettings;
@@ -951,16 +963,16 @@ begin
SetRXAPanelRun(RXA_CHAN, 1);
SetChannelState(RXA_CHAN, 1, 0);
// TXA: mic @ FAudioRate → IQ @ FSampleRate (DUC rate)
// out_size = FAudioBufSize * FSampleRate/FAudioRate = FBufSize (4096 @ 192kHz)
// FTXOut уже имеет размер FBufSize*2 — подходит идеально
// TXA: mic @ FAudioRate → IQ @ FTXSampleRate (фиксированный DUC rate, не зависит от RX).
// out_size = FAudioBufSize * FTXSampleRate/FAudioRate = FTXOutBufSize (4096 @ 192k tx).
// FTXOut уже имеет размер FTXOutBufSize*2.
OpenChannel(
TXA_CHAN,
FAudioBufSize,
FAudioBufSize,
FAudioRate,
FAudioRate,
FSampleRate,
FTXSampleRate,
1,
0,
0.010, 0.010, 0.010, 0.010,
@@ -969,7 +981,8 @@ begin
SetTXAMode(TXA_CHAN, ModeToWDSP(FMode));
SetTXAPanelRun(TXA_CHAN, 1);
SetTXAPanelSelect(TXA_CHAN, 1);
// PatchPanel: 1=Q, 2=I. Mic подаётся в I (FTXIn[i*2]), Q=0 — выбираем I.
SetTXAPanelSelect(TXA_CHAN, 2);
FInitialized := True;
// Прокидываем все TX-параметры из FTX*-полей: фильтр, mic gain, EQ,
@@ -1186,6 +1199,7 @@ begin
FMode := Mode;
if not FInitialized then Exit;
SetRXAMode(RXA_CHAN, ModeToWDSP(Mode));
SetTXAMode(TXA_CHAN, ModeToWDSP(Mode));
// НЕ вызываем ApplyDefaultFilter — фильтр устанавливается явно из UI
end;
@@ -1490,7 +1504,7 @@ end;
procedure TWDSPEngine.ProcessTXBlock;
// Вызывается из TTXDSPThread — обрабатывает один блок FAudioBufSize mic сэмплов
// через WDSP TXA, выдаёт FBufSize IQ пар @ FSampleRate в FOnTXIQ callback.
// через WDSP TXA, выдаёт FTXOutBufSize IQ пар @ FTXSampleRate в FOnTXIQ callback.
// Аналог tx_full_buffer() в piHPSDR/transmitter.c:
// если mic ring buffer содержит достаточно данных — берём их,
// иначе обрабатываем тишину (нули), как piHPSDR при mic_sample=0.0.
@@ -1526,15 +1540,15 @@ begin
// TX сигнал → отдельный TX-analyzer (TX_DISP_ID).
// Подаём чанками по DISPLAY_BLOCK_SIZE — analyzer ожидает именно такой
// размер блока (см. SetAnalyzer). Если подавать FTXOut целиком
// (FBufSize пар), analyzer прочитает только первые DISPLAY_BLOCK_SIZE
// (FTXOutBufSize пар), analyzer прочитает только первые DISPLAY_BLOCK_SIZE
// и проигнорирует остальное → спектр будет пустой.
if FAnalyzerOpen then
for i := 0 to FBufSize - 1 do
for i := 0 to FTXOutBufSize - 1 do
FeedTXDisplaySample(FTXOut[i * 2], FTXOut[i * 2 + 1]);
// FTXOut содержит FBufSize IQ пар @ FSampleRate (interleaved double)
// FTXOut содержит FTXOutBufSize IQ пар @ FTXSampleRate (interleaved double)
if Assigned(FOnTXIQ) then
FOnTXIQ(FTXOut, FBufSize);
FOnTXIQ(FTXOut, FTXOutBufSize);
end;
procedure TWDSPEngine.PushTXMicSamples16(const Src: array of SmallInt; N: Integer);