diff --git a/HPSDRNetwork.pas b/HPSDRNetwork.pas index 8fdb454..69c4a26 100644 --- a/HPSDRNetwork.pas +++ b/HPSDRNetwork.pas @@ -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; @@ -715,8 +723,10 @@ begin FRunning := False; StopThreads; DoCloseSocket(FSocket); - FConnected := False; - FDevice.Valid := False; + 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); diff --git a/MainForm.pas b/MainForm.pas index 6eb528a..69db365 100644 --- a/MainForm.pas +++ b/MainForm.pas @@ -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 4 — Mic 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 3 — PTT/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.MicBias then Result := Result or $10; + 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.SpecRefLevel := FSpecRefLevel; - FSpecView.SpecRange := FSpecRange; - FSpecView.SpecGridStep := FSpecGridStep; + // На передаче 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); diff --git a/Settings.pas b/Settings.pas index d01dafe..ee01b36 100644 --- a/Settings.pas +++ b/Settings.pas @@ -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); diff --git a/SettingsForm.pas b/SettingsForm.pas index 40b794c..746a7f6 100644 --- a/SettingsForm.pas +++ b/SettingsForm.pas @@ -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; diff --git a/SpectrumView.pas b/SpectrumView.pas index 9c9be41..705de32 100644 --- a/SpectrumView.pas +++ b/SpectrumView.pas @@ -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 diff --git a/WDSPEngine.pas b/WDSPEngine.pas index bce0dbe..77e664b 100644 --- a/WDSPEngine.pas +++ b/WDSPEngine.pas @@ -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);