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 <noreply@anthropic.com>
This commit is contained in:
2026-06-23 13:52:03 +03:00
co-authored by Claude Opus 4.8
parent d78d835485
commit 6b695ce756
8 changed files with 87 additions and 21 deletions
+8 -1
View File
@@ -633,7 +633,14 @@ begin
Result.MinSampleRate := 48000; Result.MinSampleRate := 48000;
Result.MaxSampleRate := 1536000; Result.MaxSampleRate := 1536000;
Result.SampleRateMode := srmDiscrete; 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.MinFreqHz := 0;
Result.MaxFreqHz := 61440000; Result.MaxFreqHz := 61440000;
end; end;
+13 -4
View File
@@ -478,6 +478,7 @@ type
procedure OnAlexSettingsChange(const A: TAlexSettings); procedure OnAlexSettingsChange(const A: TAlexSettings);
procedure OnXvtrSettingsChange(const X: TXvtrSettings); procedure OnXvtrSettingsChange(const X: TXvtrSettings);
procedure PushXvtrToWeb; procedure PushXvtrToWeb;
procedure PushRatePresetsToWeb; // backend sample-rate пресеты → web
// XVTR helpers // XVTR helpers
function FindXvtrIdxForFreq(FreqHz: Double): Integer; function FindXvtrIdxForFreq(FreqHz: Double): Integer;
function XvtrTranslate(VisibleHz: Double): Double; function XvtrTranslate(VisibleHz: Double): Double;
@@ -882,6 +883,7 @@ begin
if FWebEnabled then FWebServer.Start; if FWebEnabled then FWebServer.Start;
PushXvtrToWeb; PushXvtrToWeb;
PushDeviceListToWeb; // saved-устройства сразу видны web-overlay PushDeviceListToWeb; // saved-устройства сразу видны web-overlay
PushRatePresetsToWeb; // дефолтный бэкенд до connect; перезапишется на смене
FillChar(FCATLastGlobal, SizeOf(FCATLastGlobal), 0); FillChar(FCATLastGlobal, SizeOf(FCATLastGlobal), 0);
FCATLastGlobal.CATTcpPort := 19090; FCATLastGlobal.CATTcpPort := 19090;
for i := 0 to 3 do for i := 0 to 3 do
@@ -3678,12 +3680,11 @@ begin
Caps := FController.BackendCaps; Caps := FController.BackendCaps;
if Assigned(FSampleRateOverlay) then if Assigned(FSampleRateOverlay) then
begin begin
if (Caps.SampleRateMode = srmContinuous) and (Length(Caps.RatePresets) > 0) then // Единый источник — контроллер (caps бэкенда). UI больше не владеет списком.
FSampleRateOverlay.SetRatePresets(Caps.RatePresets) FSampleRateOverlay.SetRatePresets(FController.SampleRatePresets);
else
FSampleRateOverlay.SetRatePresetsDefault; // HPSDR 48k..1536k
FSampleRateOverlay.SetCurrentRate(FController.FSampleRate); FSampleRateOverlay.SetCurrentRate(FController.FSampleRate);
end; end;
PushRatePresetsToWeb; // тот же источник зеркалим в web-морду
// Отдельный Pluto RF-AGC блок под AGC: показываем и сдвигаем панели ниже. // Отдельный Pluto RF-AGC блок под AGC: показываем и сдвигаем панели ниже.
LayoutLeftPanel(Caps.HasHWGain); LayoutLeftPanel(Caps.HasHWGain);
@@ -5366,6 +5367,14 @@ begin
end; end;
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; procedure TMainForm.PushXvtrToWeb;
var var
Arr: TWebXvtrArray; Arr: TWebXvtrArray;
+2 -1
View File
@@ -29,6 +29,7 @@ uses
type type
TBackendKind = (bkHPSDR, bkPluto); TBackendKind = (bkHPSDR, bkPluto);
TSampleRateMode = (srmDiscrete, srmContinuous); TSampleRateMode = (srmDiscrete, srmContinuous);
TBackendRateArray = array of Integer; // пресеты sample-rate (общий тип UI)
// Описание возможностей бэкенда. Контроллер гейтит UI/поведение по этим полям. // Описание возможностей бэкенда. Контроллер гейтит UI/поведение по этим полям.
TBackendCaps = record TBackendCaps = record
@@ -46,7 +47,7 @@ type
MinSampleRate: Integer; MinSampleRate: Integer;
MaxSampleRate: Integer; MaxSampleRate: Integer;
SampleRateMode: TSampleRateMode; SampleRateMode: TSampleRateMode;
RatePresets: array of Integer; // пресеты для SampleRateOverlay RatePresets: TBackendRateArray; // пресеты для SampleRateOverlay
MinFreqHz: Double; MinFreqHz: Double;
MaxFreqHz: Double; MaxFreqHz: Double;
end; end;
+8
View File
@@ -544,6 +544,9 @@ type
procedure EnsureBackend(Kind: TBackendKind); procedure EnsureBackend(Kind: TBackendKind);
// Возможности текущего бэкенда (для гейтинга UI / пресетов sample rate). // Возможности текущего бэкенда (для гейтинга UI / пресетов sample rate).
function BackendCaps: TBackendCaps; 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). // True если активный бэкенд — Pluto (VHF/UHF band-план вместо HF).
function IsPluto: Boolean; function IsPluto: Boolean;
// Человекочитаемое имя подключённой платы для статус-строки (UI/Web). // Человекочитаемое имя подключённой платы для статус-строки (UI/Web).
@@ -3154,6 +3157,11 @@ begin
else FillChar(Result, SizeOf(Result), 0); else FillChar(Result, SizeOf(Result), 0);
end; end;
function TRadioController.SampleRatePresets: TBackendRateArray;
begin
Result := BackendCaps.RatePresets;
end;
function TRadioController.IsPluto: Boolean; function TRadioController.IsPluto: Boolean;
begin begin
Result := (FNetwork <> nil) and (FNetwork.Caps.Kind = bkPluto); Result := (FNetwork <> nil) and (FNetwork.Caps.Kind = bkPluto);
+8 -6
View File
@@ -199,14 +199,16 @@ procedure TWebAdapter.OnSpan(Hz: Integer);
begin FSyncInt := Hz; FController.Invoke(@SyncSpan); end; begin FSyncInt := Hz; FController.Invoke(@SyncSpan); end;
procedure TWebAdapter.SyncSpan; procedure TWebAdapter.SyncSpan;
// span ≡ sample rate. Принимаем только валидные значения (как десктоп-оверлей); // span ≡ sample rate. Валидируем по пресетам текущего бэкенда (единый источник,
// как десктоп-оверлей) — иначе Pluto-рейты (960k/2304k/…) отбрасывались бы.
// SetSampleRate сам пересоздаёт WDSP-канал + персистит (idempotent при том же rate). // SetSampleRate сам пересоздаёт WDSP-канал + персистит (idempotent при том же rate).
const var
ValidSpans: array[0..5] of Integer = (48000, 96000, 192000, 384000, 768000, 1536000); Presets: TBackendRateArray;
var i: Integer; i: Integer;
begin begin
for i := 0 to High(ValidSpans) do Presets := FController.SampleRatePresets;
if ValidSpans[i] = FSyncInt then for i := 0 to High(Presets) do
if Presets[i] = FSyncInt then
begin begin
FController.SetSampleRate(FSyncInt); FController.SetSampleRate(FSyncInt);
Exit; Exit;
+10 -5
View File
@@ -16,7 +16,7 @@ unit WebPageHtml;
- Opus: decodeFrame() правильный метод, async/await - Opus: decodeFrame() правильный метод, async/await
- Opus: opQueue буферизует пакеты пока WASM не готов - Opus: opQueue буферизует пакеты пока WASM не готов
- Opus: AudioContext создаётся только по user gesture (оверлей) - Opus: AudioContext создаётся только по user gesture (оверлей)
- SPANS: только 48K..1536K - SPANS: строятся из state.rate_presets (HPSDR/Pluto разные наборы)
- (Mode fix) modTglBtn теперь отображает ТЕКУЩИЙ активный режим - (Mode fix) modTglBtn теперь отображает ТЕКУЩИЙ активный режим
(LSB/USB, CWL/CWU, AM/SAM, FM), а не следующий. При клике (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");' + '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)});};' + 'ws2({cmd:"freq_a",hz:Math.round(vfoA)});ws2({cmd:"freq_b",hz:Math.round(vfoB)});};' +
// SPAN кнопки: только 48K..1536K // SPAN/sample-rate кнопки: строятся из state.rate_presets (HPSDR и Pluto
'const SPANS=[48000,96000,192000,384000,768000,1536000];' + // имеют разные наборы; единый источник — backend caps в контроллере).
'const SPAN_L=["48K","96K","192K","384K","768K","1536K"];' + 'let SPANS=[48000,96000,192000,384000,768000,1536000];' +
'(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);});})();' + '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);});}' + '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_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];' + 'const BAND_F=[1900000,3750000,5357000,7100000,10125000,14200000,18120000,21200000,24940000,28500000,50150000];' +
@@ -684,6 +686,9 @@ begin
'if(bandsChanged)rebuildBandSel();' + 'if(bandsChanged)rebuildBandSel();' +
'else if(curChanged){const s=q("bandSel");if(s&&XVTR_CUR>=0)s.value="x"+XVTR_CUR;}' + '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 из сервера только когда пользователь не тянет // Обновляем localCenter/localSpan из сервера только когда пользователь не тянет
'if(!dragging&&Date.now()-wheelActiveTs>300){' + 'if(!dragging&&Date.now()-wheelActiveTs>300){' +
// В CTUN без DUP: TX-спектр WDSP центрирован по vfo_a_hz, а не по DDC LO. // В CTUN без DUP: TX-спектр WDSP центрирован по vfo_a_hz, а не по DDC LO.
+30 -3
View File
@@ -297,6 +297,7 @@ type
FFreqMhzDigits: Integer; // 3=999MHz, 4=9.999GHz, 5=99.999GHz FFreqMhzDigits: Integer; // 3=999MHz, 4=9.999GHz, 5=99.999GHz
FXvtrBands: TWebXvtrArray; // список enabled XVTR (для web-UI) FXvtrBands: TWebXvtrArray; // список enabled XVTR (для web-UI)
FDevices: TWebDeviceArray; // saved+discovered устройства (для web-overlay) FDevices: TWebDeviceArray; // saved+discovered устройства (для web-overlay)
FRatePresets: array of Integer; // sample-rate пресеты текущего бэкенда (для span-кнопок)
FCurrentXvtr: Integer; // -1 = HF, иначе индекс активного XVTR FCurrentXvtr: Integer; // -1 = HF, иначе индекс активного XVTR
FOnXvtrBand: TWebCmdXvtrBand; FOnXvtrBand: TWebCmdXvtrBand;
@@ -392,6 +393,7 @@ type
property OnXvtrBand: TWebCmdXvtrBand read FOnXvtrBand write FOnXvtrBand; property OnXvtrBand: TWebCmdXvtrBand read FOnXvtrBand write FOnXvtrBand;
procedure SetXvtrBands(const ABands: TWebXvtrArray; ACurrent: Integer); procedure SetXvtrBands(const ABands: TWebXvtrArray; ACurrent: Integer);
procedure SetDeviceList(const ADevices: TWebDeviceArray); procedure SetDeviceList(const ADevices: TWebDeviceArray);
procedure SetRatePresets(const ARates: array of Integer);
property OnSpan: TWebCmdSpan read FOnSpan write FOnSpan; property OnSpan: TWebCmdSpan read FOnSpan write FOnSpan;
property OnVolume: TWebCmdVolume read FOnVolume write FOnVolume; property OnVolume: TWebCmdVolume read FOnVolume write FOnVolume;
property OnWfAGC: TWebCmdWfAGC read FOnWfAGC write FOnWfAGC; property OnWfAGC: TWebCmdWfAGC read FOnWfAGC write FOnWfAGC;
@@ -535,6 +537,8 @@ begin
FCurrentXvtr := -1; FCurrentXvtr := -1;
FFreqMhzDigits := 3; FFreqMhzDigits := 3;
SetLength(FXvtrBands, 0); SetLength(FXvtrBands, 0);
// Bootstrap-набор рейтов (до connect): хост перезапишет под бэкенд (HPSDR/Pluto).
SetRatePresets([48000, 96000, 192000, 384000, 768000, 1536000]);
end; end;
procedure TWebServer.SetXvtrBands(const ABands: TWebXvtrArray; ACurrent: Integer); procedure TWebServer.SetXvtrBands(const ABands: TWebXvtrArray; ACurrent: Integer);
@@ -564,6 +568,21 @@ begin
end; end;
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; destructor TWebServer.Destroy;
begin begin
Stop; Stop;
@@ -1542,13 +1561,21 @@ const
('LSB','USB','DSB','CWL','CWU','FM','AM','SAM'); ('LSB','USB','DSB','CWL','CWU','FM','AM','SAM');
var var
FS: TFormatSettings; FS: TFormatSettings;
XvtrJson, DevJson: string; XvtrJson, DevJson, RateJson: string;
i: Integer; i: Integer;
begin begin
FS := DefaultFormatSettings; FS := DefaultFormatSettings;
FS.DecimalSeparator := '.'; FS.DecimalSeparator := '.';
FStateLock.Enter; FStateLock.Enter;
try 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"},...] // Сборка массива xvtr_bands: [{"idx":0,"name":"2m"},...]
XvtrJson := '['; XvtrJson := '[';
for i := 0 to High(FXvtrBands) do for i := 0 to High(FXvtrBands) do
@@ -1586,7 +1613,7 @@ begin
'"status_text":"%s","board_text":"%s","ip_text":"%s","supply_text":"%s",' + '"status_text":"%s","board_text":"%s","ip_text":"%s","supply_text":"%s",' +
'"pll_text":"%s","rx_text":"%s","tx_text":"%s","seq_text":"%s",' + '"pll_text":"%s","rx_text":"%s","tx_text":"%s","seq_text":"%s",' +
'"xvtr_current":%d,"xvtr_bands":%s,"freq_mhz_digits":%d,' + '"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, [FFreq, FVfoB, FActiveVfo,
FMode, MODE_N[FMode mod 8], FMode, MODE_N[FMode mod 8],
FFilterIdx, FFilterBW, FFilterIdx, FFilterBW,
@@ -1615,7 +1642,7 @@ begin
JsonEscape(FSupplyText), JsonEscape(FPLLText), JsonEscape(FRXText), JsonEscape(FSupplyText), JsonEscape(FPLLText), JsonEscape(FRXText),
JsonEscape(FTXText), JsonEscape(FSeqText), JsonEscape(FTXText), JsonEscape(FSeqText),
FCurrentXvtr, XvtrJson, FFreqMhzDigits, FCurrentXvtr, XvtrJson, FFreqMhzDigits,
FFMStepIdx, DevJson FFMStepIdx, DevJson, RateJson
], FS); ], FS);
finally finally
FStateLock.Leave; FStateLock.Leave;
+8 -1
View File
@@ -55,6 +55,7 @@ type
procedure ConnectTo(const Dev: THPSDRDevice); procedure ConnectTo(const Dev: THPSDRDevice);
procedure PushDeviceList; procedure PushDeviceList;
procedure PushXvtr; procedure PushXvtr;
procedure PushRatePresets; // sample-rate пресеты бэкенда → web
// Входящие web-команды (из WS-потока): запоминаем параметр и маршалим в Sync*. // Входящие web-команды (из WS-потока): запоминаем параметр и маршалим в Sync*.
procedure WebRun(On_: Boolean); procedure WebRun(On_: Boolean);
procedure WebConnect(const IP: string); procedure WebConnect(const IP: string);
@@ -196,7 +197,7 @@ begin
// выходные web-списки, которых нет в периодическом PushState. // выходные web-списки, которых нет в периодическом PushState.
case Field of case Field of
rfDeviceList: PushDeviceList; // discovered обновился rfDeviceList: PushDeviceList; // discovered обновился
rfXvtr, rfDevice: begin PushXvtr; PushDeviceList; end; // connect / xvtr-смена rfXvtr, rfDevice: begin PushXvtr; PushDeviceList; PushRatePresets; end; // connect / xvtr / backend
end; end;
end; end;
@@ -291,6 +292,11 @@ begin
FWeb.SetXvtrBands(Arr, FCtrl.FCurrentXvtr); FWeb.SetXvtrBands(Arr, FCtrl.FCurrentXvtr);
end; end;
procedure THeadlessHost.PushRatePresets;
begin
FWeb.SetRatePresets(FCtrl.SampleRatePresets);
end;
// --- Входящие web-команды (WS-поток) → маршалинг в главный поток --- // --- Входящие web-команды (WS-поток) → маршалинг в главный поток ---
procedure THeadlessHost.WebRun(On_: Boolean); procedure THeadlessHost.WebRun(On_: Boolean);
@@ -382,6 +388,7 @@ begin
end; end;
Log('Web control on http://' + WebCfg.BindAddr + ':' + IntToStr(WebCfg.Port)); Log('Web control on http://' + WebCfg.BindAddr + ':' + IntToStr(WebCfg.Port));
PushDeviceList; PushDeviceList;
PushRatePresets; // дефолтный бэкенд (HPSDR) до connect; перезапишется на смене
// FRunning ставим ДО autostart: первый WDSP-open может занять время, и если за // FRunning ставим ДО autostart: первый WDSP-open может занять время, и если за
// него прилетит SIGINT (RequestStop→FRunning:=False), цикл сразу завершится, а // него прилетит SIGINT (RequestStop→FRunning:=False), цикл сразу завершится, а