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
+19 -6
View File
@@ -121,9 +121,13 @@ type
FPortDUCIQ: Word;
FDirectIP: string; // для unicast discovery
// Кэш DDC Specific — keepalive повторно шлёт первые 5 сек после старта
// Кэш DDC/DUC Specific — keepalive повторно шлёт первые 5 сек после старта.
// duc_specific_thread эмулятора биндится на 1026 только после HP Run=1,
// поэтому первый DUC Specific может прийти раньше bind() и быть потерян.
FCachedDDCSpec: TDDCSpecificPacket;
FCachedDDCValid: Boolean;
FCachedDUCSpec: TDUCSpecificPacket;
FCachedDUCValid: Boolean;
FResendCount: Integer;
// Текущее состояние для построения HP пакетов
@@ -403,14 +407,18 @@ begin
if not FNet.FConnected then Continue;
// Отправляем полный HP с частотами и ALEX — как piHPSDR
FNet.SendFullHP;
// Повторно отправляем DDC Specific первые 5 сек после Run=1 (каждые 500 мс).
// ddc_specific_thread на эмуляторе/железе стартует ПОСЛЕ получения Run=1,
// поэтому первый ConfigureDDCs может прийти раньше чем порт 1025 откроется.
if FNet.FRunning and FNet.FCachedDDCValid then
// Повторно отправляем DDC/DUC Specific первые 5 сек после Run=1 (каждые 500 мс).
// ddc_specific_thread / duc_specific_thread на эмуляторе/железе стартуют
// ПОСЛЕ получения Run=1, поэтому первые ConfigureDDCs / SendDUCSpecific
// могут прийти раньше чем порты 1025/1026 откроются.
if FNet.FRunning then
begin
Inc(FNet.FResendCount);
if (FNet.FResendCount <= 100) and (FNet.FResendCount mod 10 = 2) then
FNet.SendDDCSpecific(FNet.FCachedDDCSpec);
begin
if FNet.FCachedDDCValid then FNet.SendDDCSpecific(FNet.FCachedDDCSpec);
if FNet.FCachedDUCValid then FNet.SendDUCSpecific(FNet.FCachedDUCSpec);
end;
end;
end;
end;
@@ -717,6 +725,8 @@ begin
DoCloseSocket(FSocket);
FConnected := False;
FDevice.Valid := False;
FCachedDDCValid := False;
FCachedDUCValid := False;
end;
procedure THPSDRNetwork.StartThreads;
@@ -940,6 +950,9 @@ begin
B := Pkt;
PackSeqBytes(B.Seq, NextSeq(FSeqDUCSpec));
DoSendTo(FSocket, B, SizeOf(B), FDevice.IPAddress, FPortDUCSpec);
// Кэш для пере-отправки в keepalive (см. ResendThread)
FCachedDUCSpec := Pkt;
FCachedDUCValid := True;
end;
procedure THPSDRNetwork.SendHighPriority(const Pkt: THighPriorityPacket);
+83 -12
View File
@@ -217,6 +217,11 @@ type
FSpecRefLevel: Double;
FSpecRange: Double;
FSpecGridStep: Double;
// TX-specific grid (Thetis-style: при TX рисуем спектр в своих границах).
// Применяются к FSpecView когда FTransmitting=True; иначе используются RX-значения.
FTXSpecRefLevel: Double;
FTXSpecRange: Double;
FTXSpecGridStep: Double;
// DSP data buffers (copy of last DSP output, for WebServer.PushSpectrum)
FSpectrumBuf: array[0..1023] of Single;
FWaterfallBuf: array[0..1023] of Single;
@@ -559,6 +564,10 @@ type
procedure ApplyWaterfallParams(WfDet, WfAvgMode: Integer;
WfAvgTimeMS, WfHigh, WfLow, WfAGCOffset: Double);
procedure ApplyGridParams(RefLevel, Range, GridStep: Double);
// Пушит активные grid-параметры (RX или TX в зависимости от FTransmitting)
// в FSpecView и сбрасывает кэш сетки. Вызывается при смене RX↔TX и при
// правке любого из соответствующих полей (FSpec*, FTXSpec*).
procedure ApplySpecViewGridFromState;
procedure ApplyAudioDevice(DevIndex: Integer; const DevName: string);
procedure ApplyAudioInputDevice(DevIndex: Integer; const DevName: string);
// TX settings: применить FTXSettings к WDSP и переслать DUC Specific
@@ -999,6 +1008,10 @@ begin
FSpecRefLevel := -20.0;
FSpecRange := 110.0;
FSpecGridStep := 10.0;
// TX grid defaults (синхронно с TSettingsManager.DefaultTX)
FTXSpecRefLevel := 0.0;
FTXSpecRange := 80.0;
FTXSpecGridStep := 10.0;
FSettingsForm := nil;
FillChar(FDevMAC, SizeOf(FDevMAC), 0);
@@ -1108,6 +1121,8 @@ begin
FSpecView.SpecRefLevel := FSpecRefLevel;
FSpecView.SpecRange := FSpecRange;
FSpecView.SpecGridStep := FSpecGridStep;
// TX overlay: ширина окна = TX_SAMPLE_RATE из движка (фикс 192k)
FSpecView.TXSpanHz := WDSPEngine.TX_SAMPLE_RATE;
FSpecView.WfFrameInterval := FWaterfallFrameInterval;
FNetwork := THPSDRNetwork.Create;
@@ -2487,6 +2502,12 @@ begin
FSpecView.SpanHz := FSpanHz;
FSpecView.Mode := FMode;
FSpecView.FilterBW := FFilterBW;
// TX overlay следует за активной TX-частотой (учитывает split). При CTUN
// спектр TX будет отрисован на VFO, а не в центре дисплея.
FSpecView.TXFreq := ActiveTXFreqHz;
// Какой VFO привязан к TX (для split-полос фильтра): 1=B при FSplitTxB, иначе FActiveVfo.
if FSplitTxB then FSpecView.TXVfoIndex := 1
else FSpecView.TXVfoIndex := FActiveVfo;
end;
@@ -2855,34 +2876,38 @@ end;
// ---------------------------------------------------------------------------
function TMainForm.BuildMicLineSelectByte: Byte;
// HPSDR Protocol-2, DUC Specific byte 50:
// bit 0 — mic boost (+20 dB)
// bit 1 — Line in вместо mic in
// bit 2 — Mic-jack PTT enable (tip)
// bit 4Mic bias on (электретный капсюль)
// Биты 3 (PTT tip-bias-ring) и 5..7 не используются.
// openHPSDR Ethernet Protocol v4.3, DUC Specific byte 50:
// bit 0 — Line in (1 = line-in вместо mic-in)
// bit 1 — Mic Boost (+20 dB)
// bit 2 — 0 = Orion mic PTT enabled, 1 = disabled (инверсия!)
// bit 3PTT/bias polarity (не используем)
// bit 4 — Orion mic bias (1 = enable +5V bias)
// bit 5 — Saturn 3.5mm/XLR (не используем)
begin
Result := 0;
if FTXSettings.MicBoost then Result := Result or $01;
if FTXSettings.MicLineIn then Result := Result or $02;
if FTXSettings.MicPTTEnabled then Result := Result or $04;
if FTXSettings.MicLineIn then Result := Result or $01;
if FTXSettings.MicBoost then Result := Result or $02;
if not FTXSettings.MicPTTEnabled then Result := Result or $04;
if FTXSettings.MicBias then Result := Result or $10;
end;
procedure TMainForm.SendDUCSpecificFromSettings;
var
DUCPkt: TDUCSpecificPacket;
RateKsps: Word;
begin
if not (FNetwork.Connected and FNetwork.Running) then Exit;
FillChar(DUCPkt, SizeOf(DUCPkt), 0);
// DUC0 rate в ksps берём из WDSP-движка (фиксированный TX rate, не RX).
RateKsps := FDSPEngine.TXSampleRate div 1000;
DUCPkt.NumDACs := 1;
DUCPkt.SidetoneLevel := 50;
DUCPkt.SidetoneFreqHi := Hi(600);
DUCPkt.SidetoneFreqLo := Lo(600);
DUCPkt.KeyerSpeed := 20;
DUCPkt.KeyerWeight := 50;
DUCPkt.DUC0RateHi := Hi(192);
DUCPkt.DUC0RateLo := Lo(192);
DUCPkt.DUC0RateHi := Hi(RateKsps);
DUCPkt.DUC0RateLo := Lo(RateKsps);
DUCPkt.DUC0Bits := 24;
DUCPkt.MicLineSelect := BuildMicLineSelectByte;
FNetwork.SendDUCSpecific(DUCPkt);
@@ -3290,6 +3315,10 @@ begin
CATApplySettings(G_Settings);
// TX-настройки per-device — отдельная JSON-секция "tx"
FSettings.LoadTX(FDevMAC, FTXSettings);
FTXSpecRefLevel := FTXSettings.TXSpecRefLevel;
FTXSpecRange := FTXSettings.TXSpecRange;
if FTXSettings.TXSpecGridStep > 0 then
FTXSpecGridStep := FTXSettings.TXSpecGridStep;
FVolume := G_Settings.Volume;
FActiveVfo := G_Settings.ActiveVfo;
// PA settings
@@ -3431,6 +3460,7 @@ begin
SendDUCSpecificFromSettings;
// Применяем TX-цепь (фильтр, mic gain, EQ, leveler, ALC, comp, ...).
ApplyTXSettingsToDSP;
ApplyModeFilter;
// Принудительно обновляем AGC линии — гарантированно после всех SetAGC вызовов
if FWDSPReady then
@@ -4096,8 +4126,10 @@ begin
7: begin Lo := -Half; Hi := Half; end; // SAM
else Lo := -Half; Hi := Half;
end;
if FWDSPReady then
if FWDSPReady then begin
FDSPEngine.SetFilter(Lo, Hi);
FDSPEngine.SetTXFilter(Lo, Hi);
end;
end;
procedure TMainForm.BtnModeClick(Sender: TObject);
@@ -4111,6 +4143,7 @@ begin
FDSPEngine.SetMode(N);
UpdateFilterButtons; // обновляем подписи и дефолт фильтра под новый режим
ApplyModeFilter;
SyncSpecViewFreq; // FSpecView.Mode/FilterBW — иначе полоса рисуется под старый режим
end;
procedure TMainForm.BtnFilterClick(Sender: TObject);
@@ -4128,6 +4161,7 @@ begin
end;
for i := 0 to FILT_COUNT - 1 do StyleButton(BtnFilter[i], i = FFilter);
ApplyModeFilter;
SyncSpecViewFreq;
end;
procedure TMainForm.BtnVfoSwapClick(Sender: TObject);
@@ -4223,6 +4257,13 @@ begin
BtnMOX.Active := True;
end else
StyleButton(BtnMOX, False);
// TX-overlay в SpectrumView: фиксируем актуальную TX-частоту/VFO-индекс и режим
FSpecView.TXFreq := ActiveTXFreqHz;
if FSplitTxB then FSpecView.TXVfoIndex := 1
else FSpecView.TXVfoIndex := FActiveVfo;
FSpecView.TXMode := FTransmitting;
// Перерисовываем сетку спектра под активный тракт (Thetis-style).
ApplySpecViewGridFromState;
if FWDSPReady then
FDSPEngine.SetTXRun(FTransmitting);
if FNetwork.Connected and FNetwork.Running then
@@ -4396,6 +4437,7 @@ begin
FDSPEngine.SetMode(FMode);
ApplyModeFilter;
end;
SyncSpecViewFreq;
if Assigned(FVfoOverlay) and FVfoOverlay.Visible then
begin
FVfoOverlay.SetState(FMode, FFilterBW, FVfoA, FLastSMeter);
@@ -4516,6 +4558,7 @@ begin
if FWDSPReady then FDSPEngine.SetMode(FMode);
UpdateFilterButtons;
ApplyModeFilter;
SyncSpecViewFreq;
FBandCache[FCurrentBand].Mode := FMode;
end;
@@ -4547,6 +4590,7 @@ begin
end;
for j := 0 to FILT_COUNT-1 do StyleButton(BtnFilter[j], j = FFilter);
ApplyModeFilter;
SyncSpecViewFreq;
FBandCache[FCurrentBand].FilterBW := FFilterBW;
end;
Exit;
@@ -4569,6 +4613,7 @@ begin
FFilter := best;
for j := 0 to FILT_COUNT-1 do StyleButton(BtnFilter[j], j = FFilter);
ApplyModeFilter;
SyncSpecViewFreq;
FBandCache[FCurrentBand].FilterBW := FFilterBW;
end;
@@ -4984,10 +5029,21 @@ procedure TMainForm.OnTXSettingsChange(const T: TTXSettings);
// перешиваем WDSP TX-цепь и (если поменялись mic-биты) перепосылаем DUC Specific.
var
OldMicByte, NewMicByte: Byte;
GridChanged: Boolean;
begin
OldMicByte := BuildMicLineSelectByte;
GridChanged := (FTXSpecRefLevel <> T.TXSpecRefLevel)
or (FTXSpecRange <> T.TXSpecRange)
or (FTXSpecGridStep <> T.TXSpecGridStep);
FTXSettings := T;
NewMicByte := BuildMicLineSelectByte;
// TX-grid в момент правки — обновляем рабочие поля и (если на передаче) пушим в FSpecView
FTXSpecRefLevel := FTXSettings.TXSpecRefLevel;
FTXSpecRange := FTXSettings.TXSpecRange;
if FTXSettings.TXSpecGridStep > 0 then
FTXSpecGridStep := FTXSettings.TXSpecGridStep;
if GridChanged and FTransmitting then
ApplySpecViewGridFromState;
ApplyTXSettingsToDSP;
if OldMicByte <> NewMicByte then
SendDUCSpecificFromSettings;
@@ -5164,10 +5220,25 @@ begin
FSpecRefLevel := RefLevel;
FSpecRange := Range;
if GridStep > 0 then FSpecGridStep := GridStep;
// На передаче FSpecView показывает TX-сетку — RX-правка не должна её перетирать.
if not FTransmitting then ApplySpecViewGridFromState;
end;
procedure TMainForm.ApplySpecViewGridFromState;
begin
if FTransmitting then
begin
FSpecView.SpecRefLevel := FTXSpecRefLevel;
FSpecView.SpecRange := FTXSpecRange;
FSpecView.SpecGridStep := FTXSpecGridStep;
end else
begin
FSpecView.SpecRefLevel := FSpecRefLevel;
FSpecView.SpecRange := FSpecRange;
FSpecView.SpecGridStep := FSpecGridStep;
end;
InvalidateGridCache;
FSpectrumDirty := True;
end;
procedure TMainForm.ApplyAudioDevice(DevIndex: Integer; const DevName: string);
+14
View File
@@ -89,6 +89,10 @@ type
TXWfDetector: Integer;
TXWfAvgMode: Integer;
TXWfAvgTimeMS: Double;
// TX Grid (отдельные от RX — Thetis отрисовывает спектр TX в своих границах)
TXSpecRefLevel: Double; // dBm reference (top of scale), e.g. 0
TXSpecRange: Double; // dB range, e.g. 80
TXSpecGridStep: Double; // grid step in dB, e.g. 10
end;
TGlobalSettings = record
@@ -765,6 +769,10 @@ begin
T.TXWfDetector := 0;
T.TXWfAvgMode := 3;
T.TXWfAvgTimeMS := 120.0;
// TX Grid — потолок 0 dBm (TX-уровни выше RX), 80 dB динамики, шаг 10 dB
T.TXSpecRefLevel := 0.0;
T.TXSpecRange := 80.0;
T.TXSpecGridStep := 10.0;
end;
function TSettingsManager.LoadTX(const MAC: array of Byte;
@@ -824,6 +832,9 @@ begin
T.TXWfDetector := JI(O,'tx_wf_detector', T.TXWfDetector);
T.TXWfAvgMode := JI(O,'tx_wf_avg_mode', T.TXWfAvgMode);
T.TXWfAvgTimeMS := JD(O,'tx_wf_avg_time_ms', T.TXWfAvgTimeMS);
T.TXSpecRefLevel := JD(O,'tx_spec_ref_level', T.TXSpecRefLevel);
T.TXSpecRange := JD(O,'tx_spec_range', T.TXSpecRange);
T.TXSpecGridStep := JD(O,'tx_spec_grid_step', T.TXSpecGridStep);
end;
procedure TSettingsManager.SaveTX(const MAC: array of Byte;
@@ -875,6 +886,9 @@ begin
JW(O,'tx_wf_detector', T.TXWfDetector);
JW(O,'tx_wf_avg_mode', T.TXWfAvgMode);
JW(O,'tx_wf_avg_time_ms', T.TXWfAvgTimeMS);
JW(O,'tx_spec_ref_level', T.TXSpecRefLevel);
JW(O,'tx_spec_range', T.TXSpecRange);
JW(O,'tx_spec_grid_step', T.TXSpecGridStep);
end;
procedure TSettingsManager.SaveTheme(ALightTheme: Boolean);
+60
View File
@@ -167,6 +167,10 @@ type
FCmbTXWfDet: TComboBox;
FCmbTXWfAvg: TComboBox;
FEdTXWfAvgTime: TFloatSpinEdit;
// TX Grid (отдельные от RX grid в Display)
FEdTXRefLevel: TEdit;
FEdTXRange: TEdit;
FCmbTXGridStep: TComboBox;
// ---- CAT tab controls ----
FCATSerialEn: array[0..3] of TCheckBox;
@@ -1084,6 +1088,37 @@ begin
FCmbTXWfAvg.ItemIndex := 3;
MakeLbl(Grp, 'Avg time (ms)', PAD + 430, R1 + 5 * STEP + 5, 100);
FEdTXWfAvgTime := MkFloat(Grp, PAD + 540, R1 + 5 * STEP, 120.0, 1.0, 5000.0, 1.0);
// ---- TX Grid (RefLevel / Range / GridStep — отдельные от RX) ----
Inc(Y, 290 + 12);
Grp := MakeGroupPanel(FPageTransmit, 'TX Grid', MARGIN, Y, 700, 170);
MakeLbl(Grp, 'Reference level', PAD, R1 + 5, LW);
FEdTXRefLevel := TEdit.Create(Self);
FEdTXRefLevel.Parent := Grp;
FEdTXRefLevel.SetBounds(CX, R1, 96, BTN_H);
FEdTXRefLevel.Color := CLR_INPUT;
FEdTXRefLevel.Font.Color := CLR_INPUT_TEXT;
FEdTXRefLevel.Font.Name := UI_FONT;
FEdTXRefLevel.Font.Size := 9;
FEdTXRefLevel.Text := '0';
FEdTXRefLevel.OnExit := OnTXAnyChange;
MakeLbl(Grp, 'Range (dB)', PAD, R1 + STEP + 5, LW);
FEdTXRange := TEdit.Create(Self);
FEdTXRange.Parent := Grp;
FEdTXRange.SetBounds(CX, R1 + STEP, 96, BTN_H);
FEdTXRange.Color := CLR_INPUT;
FEdTXRange.Font.Color := CLR_INPUT_TEXT;
FEdTXRange.Font.Name := UI_FONT;
FEdTXRange.Font.Size := 9;
FEdTXRange.Text := '80';
FEdTXRange.OnExit := OnTXAnyChange;
MakeLbl(Grp, 'Grid step', PAD, R1 + 2 * STEP + 5, LW);
FCmbTXGridStep := MkCmb(Grp, CX, R1 + 2 * STEP, 130);
for i := 0 to High(GRID_STEP_NAMES) do FCmbTXGridStep.Items.Add(GRID_STEP_NAMES[i]);
FCmbTXGridStep.ItemIndex := 1;
end;
procedure TSettingsForm.CollectTXFromUI;
@@ -1139,6 +1174,19 @@ begin
if (FCmbTXWfAvg <> nil) and (FCmbTXWfAvg.ItemIndex >= 0) then
FTX.TXWfAvgMode := WF_AVGMODE_VALUES[FCmbTXWfAvg.ItemIndex];
if FEdTXWfAvgTime <> nil then FTX.TXWfAvgTimeMS := FEdTXWfAvgTime.Value;
if FEdTXRefLevel <> nil then
begin
Val(FEdTXRefLevel.Text, FTX.TXSpecRefLevel, i);
if i <> 0 then FTX.TXSpecRefLevel := 0.0;
end;
if FEdTXRange <> nil then
begin
Val(FEdTXRange.Text, FTX.TXSpecRange, i);
if i <> 0 then FTX.TXSpecRange := 80.0;
end;
if (FCmbTXGridStep <> nil) and (FCmbTXGridStep.ItemIndex >= 0)
and (FCmbTXGridStep.ItemIndex <= High(GRID_STEPS)) then
FTX.TXSpecGridStep := GRID_STEPS[FCmbTXGridStep.ItemIndex];
end;
procedure TSettingsForm.FireTXChange;
@@ -1241,6 +1289,18 @@ begin
end;
end;
if FEdTXWfAvgTime <> nil then FEdTXWfAvgTime.Value := T.TXWfAvgTimeMS;
if FEdTXRefLevel <> nil then FEdTXRefLevel.Text := FormatFloat('0.#', T.TXSpecRefLevel);
if FEdTXRange <> nil then FEdTXRange.Text := FormatFloat('0.#', T.TXSpecRange);
if FCmbTXGridStep <> nil then
begin
FCmbTXGridStep.ItemIndex := 1; // дефолт 10 dB
for i := 0 to High(GRID_STEPS) do
if Abs(GRID_STEPS[i] - T.TXSpecGridStep) < 0.5 then
begin
FCmbTXGridStep.ItemIndex := i;
Break;
end;
end;
finally
FLoading := False;
end;
+173 -13
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;
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)));
// Логика:
// Не 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;
// ── 3. AGC линии ──────────────────────────────────────────────────────────
if FWDSPReady then
@@ -729,6 +828,31 @@ begin
if FSpPtsLen <> W + 2 then
begin SetLength(FSpPts, W + 2); FSpPtsLen := W + 2; end;
SrcCount := EnsureRange(FSpectrumBufCount, 2, 1024);
if FTXMode and (FTXSpanHz > 0) and (FSpanHz > 0) then
begin
// 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);
@@ -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;
@@ -850,12 +985,37 @@ begin
Step := (SrcCount - 1.0) / Max(1, W - 1);
WatSrcF := 0.0; Px := @FWfPixels[0];
for X := 0 to W - 1 do
begin
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
+28 -14
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;
@@ -674,8 +686,8 @@ begin
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(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);