From 6b695ce7565e9246ab6542e8ba8c4a6cfe8ec674 Mon Sep 17 00:00:00 2001 From: Vladimir Date: Tue, 23 Jun 2026 13:52:03 +0300 Subject: [PATCH] feat: unify sample-rate presets across desktop + web UIs Sample-rate (span) presets were duplicated in three places and none was authoritative: HPSDR set hardcoded in the desktop overlay (SPAN_RATES), Pluto set in backend caps, and a separate hardcoded list in the web JS. The web showed HPSDR rates even on Pluto, and the web adapter silently dropped any rate outside a hardcoded HPSDR whitelist (so Pluto-only rates like 960k/2304k never switched). Single source of truth = backend caps -> controller: - HPSDRNetwork.Caps now fills RatePresets [48k..1536k] (was nil). - RadioBackend: named type TBackendRateArray. - TRadioController.SampleRatePresets returns BackendCaps.RatePresets. Both UIs derive from it: - Desktop: ApplyBackendCapsToUI always feeds the overlay from SampleRatePresets (overlay no longer owns the HPSDR list). - Web: WebServer.SetRatePresets + rate_presets in state JSON; hosts (daemon OnState/startup, GUI ApplyBackendCapsToUI/startup) push SampleRatePresets; JS builds span buttons dynamically from it. - WebAdapter.SyncSpan validates against SampleRatePresets instead of a hardcoded whitelist, so any backend rate is accepted. Co-Authored-By: Claude Opus 4.8 --- HPSDRNetwork.pas | 9 ++++++++- MainForm.pas | 17 +++++++++++++---- RadioBackend.pas | 3 ++- RadioController.pas | 8 ++++++++ WebAdapter.pas | 14 ++++++++------ WebPageHtml.pas | 15 ++++++++++----- WebServer.pas | 33 ++++++++++++++++++++++++++++++--- ewsdrd.lpr | 9 ++++++++- 8 files changed, 87 insertions(+), 21 deletions(-) diff --git a/HPSDRNetwork.pas b/HPSDRNetwork.pas index 62e2bf8..0af281b 100644 --- a/HPSDRNetwork.pas +++ b/HPSDRNetwork.pas @@ -633,7 +633,14 @@ begin Result.MinSampleRate := 48000; Result.MaxSampleRate := 1536000; Result.SampleRateMode := srmDiscrete; - Result.RatePresets := nil; // контроллер использует свой HPSDR-набор + // Бэкенд — единственный источник своих рейтов (общий для desktop/web UI). + SetLength(Result.RatePresets, 6); + Result.RatePresets[0] := 48000; + Result.RatePresets[1] := 96000; + Result.RatePresets[2] := 192000; + Result.RatePresets[3] := 384000; + Result.RatePresets[4] := 768000; + Result.RatePresets[5] := 1536000; Result.MinFreqHz := 0; Result.MaxFreqHz := 61440000; end; diff --git a/MainForm.pas b/MainForm.pas index 5549546..aaf5770 100644 --- a/MainForm.pas +++ b/MainForm.pas @@ -478,6 +478,7 @@ type procedure OnAlexSettingsChange(const A: TAlexSettings); procedure OnXvtrSettingsChange(const X: TXvtrSettings); procedure PushXvtrToWeb; + procedure PushRatePresetsToWeb; // backend sample-rate пресеты → web // XVTR helpers function FindXvtrIdxForFreq(FreqHz: Double): Integer; function XvtrTranslate(VisibleHz: Double): Double; @@ -882,6 +883,7 @@ begin if FWebEnabled then FWebServer.Start; PushXvtrToWeb; PushDeviceListToWeb; // saved-устройства сразу видны web-overlay + PushRatePresetsToWeb; // дефолтный бэкенд до connect; перезапишется на смене FillChar(FCATLastGlobal, SizeOf(FCATLastGlobal), 0); FCATLastGlobal.CATTcpPort := 19090; for i := 0 to 3 do @@ -3678,12 +3680,11 @@ begin Caps := FController.BackendCaps; if Assigned(FSampleRateOverlay) then begin - if (Caps.SampleRateMode = srmContinuous) and (Length(Caps.RatePresets) > 0) then - FSampleRateOverlay.SetRatePresets(Caps.RatePresets) - else - FSampleRateOverlay.SetRatePresetsDefault; // HPSDR 48k..1536k + // Единый источник — контроллер (caps бэкенда). UI больше не владеет списком. + FSampleRateOverlay.SetRatePresets(FController.SampleRatePresets); FSampleRateOverlay.SetCurrentRate(FController.FSampleRate); end; + PushRatePresetsToWeb; // тот же источник зеркалим в web-морду // Отдельный Pluto RF-AGC блок под AGC: показываем и сдвигаем панели ниже. LayoutLeftPanel(Caps.HasHWGain); @@ -5366,6 +5367,14 @@ begin end; end; +procedure TMainForm.PushRatePresetsToWeb; +// Пресеты sample-rate бэкенда → web (HPSDR/Pluto разные). Единый источник — +// FController.SampleRatePresets, как и для desktop-оверлея. +begin + if FWebServer = nil then Exit; + FWebServer.SetRatePresets(FController.SampleRatePresets); +end; + procedure TMainForm.PushXvtrToWeb; var Arr: TWebXvtrArray; diff --git a/RadioBackend.pas b/RadioBackend.pas index c1ca5a7..3ccaeaa 100644 --- a/RadioBackend.pas +++ b/RadioBackend.pas @@ -29,6 +29,7 @@ uses type TBackendKind = (bkHPSDR, bkPluto); TSampleRateMode = (srmDiscrete, srmContinuous); + TBackendRateArray = array of Integer; // пресеты sample-rate (общий тип UI) // Описание возможностей бэкенда. Контроллер гейтит UI/поведение по этим полям. TBackendCaps = record @@ -46,7 +47,7 @@ type MinSampleRate: Integer; MaxSampleRate: Integer; SampleRateMode: TSampleRateMode; - RatePresets: array of Integer; // пресеты для SampleRateOverlay + RatePresets: TBackendRateArray; // пресеты для SampleRateOverlay MinFreqHz: Double; MaxFreqHz: Double; end; diff --git a/RadioController.pas b/RadioController.pas index d103ee0..d4fddfc 100644 --- a/RadioController.pas +++ b/RadioController.pas @@ -544,6 +544,9 @@ type procedure EnsureBackend(Kind: TBackendKind); // Возможности текущего бэкенда (для гейтинга UI / пресетов sample rate). function BackendCaps: TBackendCaps; + // Единый источник пресетов sample-rate для UI (desktop overlay + web). + // Берётся из caps текущего бэкенда (HPSDR 48k..1536k / Pluto 576k..5760k). + function SampleRatePresets: TBackendRateArray; // True если активный бэкенд — Pluto (VHF/UHF band-план вместо HF). function IsPluto: Boolean; // Человекочитаемое имя подключённой платы для статус-строки (UI/Web). @@ -3154,6 +3157,11 @@ begin else FillChar(Result, SizeOf(Result), 0); end; +function TRadioController.SampleRatePresets: TBackendRateArray; +begin + Result := BackendCaps.RatePresets; +end; + function TRadioController.IsPluto: Boolean; begin Result := (FNetwork <> nil) and (FNetwork.Caps.Kind = bkPluto); diff --git a/WebAdapter.pas b/WebAdapter.pas index 3677b84..1c4494a 100644 --- a/WebAdapter.pas +++ b/WebAdapter.pas @@ -199,14 +199,16 @@ procedure TWebAdapter.OnSpan(Hz: Integer); begin FSyncInt := Hz; FController.Invoke(@SyncSpan); end; procedure TWebAdapter.SyncSpan; -// span ≡ sample rate. Принимаем только валидные значения (как десктоп-оверлей); +// span ≡ sample rate. Валидируем по пресетам текущего бэкенда (единый источник, +// как десктоп-оверлей) — иначе Pluto-рейты (960k/2304k/…) отбрасывались бы. // SetSampleRate сам пересоздаёт WDSP-канал + персистит (idempotent при том же rate). -const - ValidSpans: array[0..5] of Integer = (48000, 96000, 192000, 384000, 768000, 1536000); -var i: Integer; +var + Presets: TBackendRateArray; + i: Integer; begin - for i := 0 to High(ValidSpans) do - if ValidSpans[i] = FSyncInt then + Presets := FController.SampleRatePresets; + for i := 0 to High(Presets) do + if Presets[i] = FSyncInt then begin FController.SetSampleRate(FSyncInt); Exit; diff --git a/WebPageHtml.pas b/WebPageHtml.pas index d3b6398..43936c0 100644 --- a/WebPageHtml.pas +++ b/WebPageHtml.pas @@ -16,7 +16,7 @@ unit WebPageHtml; - Opus: decodeFrame() правильный метод, async/await - Opus: opQueue буферизует пакеты пока WASM не готов - Opus: AudioContext создаётся только по user gesture (оверлей) - - SPANS: только 48K..1536K + - SPANS: строятся из state.rate_presets (HPSDR/Pluto разные наборы) - (Mode fix) modTglBtn теперь отображает ТЕКУЩИЙ активный режим (LSB/USB, CWL/CWU, AM/SAM, FM), а не следующий. При клике переключает на парный режим внутри группы. @@ -516,11 +516,13 @@ begin 'q("btnSwap").onclick=()=>{const t=vfoA;vfoA=vfoB;vfoB=t;renderVfos();pend("freq");' + 'ws2({cmd:"freq_a",hz:Math.round(vfoA)});ws2({cmd:"freq_b",hz:Math.round(vfoB)});};' + - // SPAN кнопки: только 48K..1536K - 'const SPANS=[48000,96000,192000,384000,768000,1536000];' + - 'const SPAN_L=["48K","96K","192K","384K","768K","1536K"];' + - '(function(){const c=q("spanOv");SPANS.forEach((hz,i)=>{const b=document.createElement("button");b.className="sp-btn";b.id="sb"+i;b.textContent=SPAN_L[i];b.onclick=()=>ws2({cmd:"span",hz});c.appendChild(b);});})();' + + // SPAN/sample-rate кнопки: строятся из state.rate_presets (HPSDR и Pluto + // имеют разные наборы; единый источник — backend caps в контроллере). + 'let SPANS=[48000,96000,192000,384000,768000,1536000];' + + 'function fmtRate(hz){return Math.round(hz/1000)+"K";}' + + 'function buildSpanButtons(){const c=q("spanOv");if(!c)return;c.innerHTML="";SPANS.forEach((hz,i)=>{const b=document.createElement("button");b.className="sp-btn";b.id="sb"+i;b.textContent=fmtRate(hz);b.onclick=()=>ws2({cmd:"span",hz});c.appendChild(b);});updSpan(localSpan);}' + 'function updSpan(s){SPANS.forEach((hz,i)=>{const b=q("sb"+i);if(b)b.classList.toggle("on",hz===s);});}' + + 'buildSpanButtons();' + 'const BAND_N=["160m","80m","60m","40m","30m","20m","17m","15m","12m","10m","6m"];' + 'const BAND_F=[1900000,3750000,5357000,7100000,10125000,14200000,18120000,21200000,24940000,28500000,50150000];' + @@ -684,6 +686,9 @@ begin 'if(bandsChanged)rebuildBandSel();' + 'else if(curChanged){const s=q("bandSel");if(s&&XVTR_CUR>=0)s.value="x"+XVTR_CUR;}' + '}' + + // Пресеты sample-rate сменились (другой бэкенд) — перестраиваем span-кнопки. + 'if(msg.rate_presets!=null&&Array.isArray(msg.rate_presets)&&msg.rate_presets.length){' + + 'if(JSON.stringify(msg.rate_presets)!==JSON.stringify(SPANS)){SPANS=msg.rate_presets;buildSpanButtons();}}' + // Обновляем localCenter/localSpan из сервера только когда пользователь не тянет 'if(!dragging&&Date.now()-wheelActiveTs>300){' + // В CTUN без DUP: TX-спектр WDSP центрирован по vfo_a_hz, а не по DDC LO. diff --git a/WebServer.pas b/WebServer.pas index e2be74e..17eb2f7 100644 --- a/WebServer.pas +++ b/WebServer.pas @@ -297,6 +297,7 @@ type FFreqMhzDigits: Integer; // 3=999MHz, 4=9.999GHz, 5=99.999GHz FXvtrBands: TWebXvtrArray; // список enabled XVTR (для web-UI) FDevices: TWebDeviceArray; // saved+discovered устройства (для web-overlay) + FRatePresets: array of Integer; // sample-rate пресеты текущего бэкенда (для span-кнопок) FCurrentXvtr: Integer; // -1 = HF, иначе индекс активного XVTR FOnXvtrBand: TWebCmdXvtrBand; @@ -392,6 +393,7 @@ type property OnXvtrBand: TWebCmdXvtrBand read FOnXvtrBand write FOnXvtrBand; procedure SetXvtrBands(const ABands: TWebXvtrArray; ACurrent: Integer); procedure SetDeviceList(const ADevices: TWebDeviceArray); + procedure SetRatePresets(const ARates: array of Integer); property OnSpan: TWebCmdSpan read FOnSpan write FOnSpan; property OnVolume: TWebCmdVolume read FOnVolume write FOnVolume; property OnWfAGC: TWebCmdWfAGC read FOnWfAGC write FOnWfAGC; @@ -535,6 +537,8 @@ begin FCurrentXvtr := -1; FFreqMhzDigits := 3; SetLength(FXvtrBands, 0); + // Bootstrap-набор рейтов (до connect): хост перезапишет под бэкенд (HPSDR/Pluto). + SetRatePresets([48000, 96000, 192000, 384000, 768000, 1536000]); end; procedure TWebServer.SetXvtrBands(const ABands: TWebXvtrArray; ACurrent: Integer); @@ -564,6 +568,21 @@ begin end; end; +procedure TWebServer.SetRatePresets(const ARates: array of Integer); +// Список пресетов sample-rate текущего бэкенда (из TRadioController). Web строит +// span-кнопки из него — HPSDR и Pluto имеют разные наборы. +var i: Integer; +begin + FStateLock.Enter; + try + SetLength(FRatePresets, Length(ARates)); + for i := 0 to High(ARates) do + FRatePresets[i] := ARates[i]; + finally + FStateLock.Leave; + end; +end; + destructor TWebServer.Destroy; begin Stop; @@ -1542,13 +1561,21 @@ const ('LSB','USB','DSB','CWL','CWU','FM','AM','SAM'); var FS: TFormatSettings; - XvtrJson, DevJson: string; + XvtrJson, DevJson, RateJson: string; i: Integer; begin FS := DefaultFormatSettings; FS.DecimalSeparator := '.'; FStateLock.Enter; try + // Сборка массива rate_presets: [48000,96000,...] (span-кнопки web). + RateJson := '['; + for i := 0 to High(FRatePresets) do + begin + if i > 0 then RateJson := RateJson + ','; + RateJson := RateJson + IntToStr(FRatePresets[i]); + end; + RateJson := RateJson + ']'; // Сборка массива xvtr_bands: [{"idx":0,"name":"2m"},...] XvtrJson := '['; for i := 0 to High(FXvtrBands) do @@ -1586,7 +1613,7 @@ begin '"status_text":"%s","board_text":"%s","ip_text":"%s","supply_text":"%s",' + '"pll_text":"%s","rx_text":"%s","tx_text":"%s","seq_text":"%s",' + '"xvtr_current":%d,"xvtr_bands":%s,"freq_mhz_digits":%d,' + - '"fmstep_idx":%d,"devices":%s}', + '"fmstep_idx":%d,"devices":%s,"rate_presets":%s}', [FFreq, FVfoB, FActiveVfo, FMode, MODE_N[FMode mod 8], FFilterIdx, FFilterBW, @@ -1615,7 +1642,7 @@ begin JsonEscape(FSupplyText), JsonEscape(FPLLText), JsonEscape(FRXText), JsonEscape(FTXText), JsonEscape(FSeqText), FCurrentXvtr, XvtrJson, FFreqMhzDigits, - FFMStepIdx, DevJson + FFMStepIdx, DevJson, RateJson ], FS); finally FStateLock.Leave; diff --git a/ewsdrd.lpr b/ewsdrd.lpr index d9cd469..6cce558 100644 --- a/ewsdrd.lpr +++ b/ewsdrd.lpr @@ -55,6 +55,7 @@ type procedure ConnectTo(const Dev: THPSDRDevice); procedure PushDeviceList; procedure PushXvtr; + procedure PushRatePresets; // sample-rate пресеты бэкенда → web // Входящие web-команды (из WS-потока): запоминаем параметр и маршалим в Sync*. procedure WebRun(On_: Boolean); procedure WebConnect(const IP: string); @@ -196,7 +197,7 @@ begin // выходные web-списки, которых нет в периодическом PushState. case Field of rfDeviceList: PushDeviceList; // discovered обновился - rfXvtr, rfDevice: begin PushXvtr; PushDeviceList; end; // connect / xvtr-смена + rfXvtr, rfDevice: begin PushXvtr; PushDeviceList; PushRatePresets; end; // connect / xvtr / backend end; end; @@ -291,6 +292,11 @@ begin FWeb.SetXvtrBands(Arr, FCtrl.FCurrentXvtr); end; +procedure THeadlessHost.PushRatePresets; +begin + FWeb.SetRatePresets(FCtrl.SampleRatePresets); +end; + // --- Входящие web-команды (WS-поток) → маршалинг в главный поток --- procedure THeadlessHost.WebRun(On_: Boolean); @@ -382,6 +388,7 @@ begin end; Log('Web control on http://' + WebCfg.BindAddr + ':' + IntToStr(WebCfg.Port)); PushDeviceList; + PushRatePresets; // дефолтный бэкенд (HPSDR) до connect; перезапишется на смене // FRunning ставим ДО autostart: первый WDSP-open может занять время, и если за // него прилетит SIGINT (RequestStop→FRunning:=False), цикл сразу завершится, а