fix(web): детализация спектра/водопада — честный размер кадра, поток 'W', настройка разрешения

Веб-пульт рисовал заметно грубее десктопа, и часть причин копилась давно.

Клиент вообще не разбирал бинарный фрейм 0x57: водопад рисовался из кадра
СПЕКТРА, а собственный поток водопада (свой детектор и усреднение
wf_avg_time_ms) уходил в никуда, занимая половину трафика. Теперь водопад
идёт из своего потока.

Адаптер слал константу 1024 вместо реального числа точек. Разрешение
анализатора = ширина панадаптера, и при узкой панели (грид из двух колонок,
маленькое окно) кадр короче — хвост буфера, нули = 0 dBm, уезжал клиенту
белой полосой, а частотная шкала сжималась. В демоне это обходили
DAEMON_SPEC_WIDTH. Длина фрейма теперь равна числу заполненных точек;
клиент и так считал N из byteLength.

Децимация под потолок протокола делила «i*Count div N» — на нецелом
отношении соседние группы получались по 1 и 2 точки, а смещение
max-детектора зависит от размера группы: по спектру шла гребёнка ±3 дБ,
в водопаде — вертикальная рябь. Заменено на целочисленный фактор
(группы одинаковой ширины).

Водопад терял 2 кадра из 3: анализатор даёт до 60 кадров/с, web шлёт 20
строк/с, а бралась просто «последняя». Введено двухступенчатое накопление
max-hold (DSP-поток → PushState → PushLoop) по идиоме
TWaterfallView.SetWaterfallData — короткие посылки CW/FT8 больше не
проваливаются между строками.

Отдельно исправлено в headless: PushState демона (20 Гц) и PushLoop (20 Гц)
идут вразнобой, и на дрейфе фаз одна и та же строка уезжала клиенту дважды
(водопад двоил и полз рывками), а на остановленном RX прокручивалась
застывшая строка. Фрейм 'W' теперь отправляется только при реально пришедшем
от DSP кадре (FWfAccumFresh → WfNew → FWfFresh). Спектр так не гейтится —
это живая кривая.

Рендер в браузере: значение бина для пикселя берётся максимумом по диапазону
вместо «ближайшего» (узкие сигналы мерцали и пропадали на узком экране), при
бинах реже пикселей — интерполяция; сглаживание спектра вынесено отдельным
проходом по всем бинам (в цикле по пикселям часть бинов не обновлялась
вовсе); строка водопада рисуется из сырых бинов, IIR ~200 мс остался только
для слежения за уровнями AGC — он размазывал водопад по вертикали.

Потолок точек кадра вынесен в настройку web.spec_pixels (Settings →
Advanced → Web Server → Spectrum: 1024/2048/4096, дефолт 1024). Буферы
кадра и WS-фреймы выросли под 4096 (WEB_SPEC_MAX, синхронно с
WDSPEngine.SPECTRUM_PIXELS). В демоне эта же настройка задаёт ширину
анализатора — рендера там нет, поэтому DAEMON_SPEC_WIDTH убран.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-07-31 15:35:55 +03:00
co-authored by Claude Opus 5
parent c95620fb23
commit a820182716
8 changed files with 243 additions and 63 deletions
+12 -3
View File
@@ -232,6 +232,7 @@ type
FWebBindAddr: string;
FWebUser: string;
FWebPass: string;
FWebSpecPixels: Integer; // потолок точек кадра спектра/водопада для web
// --- CAT ---
FCATAdapter: TCATAdapter; // адаптер CAT поверх контроллера (движок+транспорты+контекст)
FCATLastGlobal: TGlobalSettings; // текущие CAT-настройки (UI/persist; ApplySettings → адаптер)
@@ -753,7 +754,7 @@ type
procedure ApplyWfAGCNF(WfAGC, WfNF: Boolean);
procedure ApplyADCSettings(Dither, Random: Boolean);
procedure ApplyWebSettings(Enabled: Boolean; Port: Integer;
const BindAddr, User, Pass: string);
const BindAddr, User, Pass: string; SpecPixels: Integer);
procedure ApplyGridParams(RefLevel, Range, GridStep: Double);
// Пушит активные grid-параметры (RX или TX в зависимости от FTransmitting)
// в FSpecView и сбрасывает кэш сетки. Вызывается при смене RX↔TX и при
@@ -1064,6 +1065,8 @@ begin
FWebBindAddr := WebCfg.BindAddr;
FWebUser := WebCfg.User;
FWebPass := WebCfg.Pass;
FWebSpecPixels := EnsureRange(WebCfg.SpecPixels, 256, SPECTRUM_PIXELS);
FController.FWebSpecPixels := FWebSpecPixels;
FWebServer := TWebServer.Create(FWebUser, FWebPass, Word(FWebPort), FWebBindAddr);
// Вынесенные в TWebAdapter чистые-контроллерные команды (host=nil: их хендлеры
// ещё не нужны хосту; host-зависимые WebOnXxx пока на MainForm — переедут в WA2).
@@ -5691,7 +5694,7 @@ begin
end;
procedure TMainForm.ApplyWebSettings(Enabled: Boolean; Port: Integer;
const BindAddr, User, Pass: string);
const BindAddr, User, Pass: string; SpecPixels: Integer);
var W: TWebSettings;
begin
FWebEnabled := Enabled;
@@ -5699,6 +5702,10 @@ begin
FWebBindAddr := BindAddr;
FWebUser := User;
FWebPass := Pass;
// Потолок точек кадра для web — применяется сразу, следующий же кадр DSP
// снимается с новым разрешением (переарм анализатора не нужен).
FWebSpecPixels := EnsureRange(SpecPixels, 256, SPECTRUM_PIXELS);
FController.FWebSpecPixels := FWebSpecPixels;
FWebServer.Reconfigure(User, Pass, BindAddr, Word(Port));
if Enabled then FWebServer.Start;
W.Enabled := Enabled;
@@ -5706,6 +5713,7 @@ begin
W.BindAddr := BindAddr;
W.User := User;
W.Pass := Pass;
W.SpecPixels := FWebSpecPixels;
FController.FSettings.SaveWebSettings(W);
end;
@@ -8559,7 +8567,8 @@ begin
FController.FDisplayPixels, FController.FWfSpeedDiv);
SF.LoadSpecMSAA(FController.FSpecMSAA);
SF.LoadADCSettings(FController.FDitherEnabled, FController.FRandomEnabled);
SF.LoadWebSettings(FWebEnabled, FWebPort, FWebBindAddr, FWebUser, FWebPass);
SF.LoadWebSettings(FWebEnabled, FWebPort, FWebBindAddr, FWebUser, FWebPass,
FWebSpecPixels);
SF.LoadFPS(FDisplayFPS);
SF.LoadLightTheme(FLightTheme);
SF.LoadFreqMhzDigits(FFreqMhzDigits);
+54 -14
View File
@@ -229,10 +229,17 @@ type
// Снимок для исходящего web-зеркала (PushState читает напрямую). Пишется из
// DSP-потока в OnSpectrumReady/OnWaterfallReady; читается из GUI-таймера —
// та же толерантная гонка, что и раньше (косметика пикселей).
FSpectrumBuf: array[0..1023] of Single;
FWaterfallBuf: array[0..1023] of Single;
FSpectrumBuf: array[0..SPECTRUM_PIXELS-1] of Single;
FWaterfallBuf: array[0..SPECTRUM_PIXELS-1] of Single;
FSpectrumBufCount: Integer;
FWaterfallBufCount: Integer;
// Потолок точек кадра для web (настройка web.spec_pixels, 1024/2048/4096).
// Кадр анализатора шире — децимируем до него; уже — уходит как есть.
FWebSpecPixels: Integer;
// Водопад копится max-hold'ом до тех пор, пока PushState не заберёт строку
// (анализатор даёт до 60 кадров/с, web шлёт 20 строк/с). Сбрасывает флаг
// тот, кто забрал строку, — тогда следующий кадр начинает новую.
FWfAccumFresh: Boolean;
// ---- Движки (владение: см. шапку юнита) ----
// FNetwork — абстрактный бэкенд железа (TRadioBackend): конкретный класс
@@ -1041,7 +1048,10 @@ begin
ResetPanDDCMaps; // паны: все слоты свободны
FZoomFactor := 0.0; FPanSlider := 0.5; // 0.0 = весь span (без зума)
FCurrentBand := 5; FCurrentXvtr := -1;
FSpectrumBufCount := 1024; FWaterfallBufCount := 1024;
// Кадра ещё не было — 0 точек. Ненулевой счётчик заставил бы web рисовать
// нулевой (= 0 dBm, полная шкала) буфер белой полосой.
FSpectrumBufCount := 0; FWaterfallBufCount := 0; FWfAccumFresh := False;
FWebSpecPixels := 1024; // дефолт web.spec_pixels; хост переопределит из настроек
FPAMaxPower := 100.0;
FPlutoTxMaxAttDb := -10.0; // ADI-дефолт: линейный режим выходных каскадов
FWfAGCEnabled := True; FDitherEnabled := True; FRandomEnabled := True;
@@ -2650,32 +2660,45 @@ begin
SetMOX(True);
end;
// Снимок кадра для web-зеркала: протокол фиксирован на 1024 точки, поэтому
// при большем разрешении анализатора децимируем max-детектором (не обрезаем).
// Снимок кадра для web-зеркала: потолок точек задаёт настройка web.spec_pixels
// (Limit), при большем разрешении анализатора децимируем max-детектором
// (не обрезаем).
// DstCount — сколько точек РЕАЛЬНО заполнено; отправлять больше нельзя, иначе
// хвост буфера (нули = 0 dBm) уедет в эфир белой полосой, а частотная шкала
// клиента растянется на несуществующие точки.
//
// Группы — строго одинаковой ширины (целочисленный фактор). Прежнее деление
// «i*Count div N» давало на нецелом отношении соседние группы разной ширины
// (1 и 2 точки), а смещение max-детектора зависит от числа точек в группе —
// по спектру шла гребёнка ±3 дБ, в водопаде видимая как вертикальная рябь.
procedure SnapshotPixels(const Pixels: array of Single; Count: Integer;
var Dst: array of Single; out DstCount: Integer);
var Dst: array of Single; Limit: Integer; out DstCount: Integer);
var
i, j, S0, S1, N: Integer;
i, j, S0, S1, N, K, M: Integer;
V: Single;
begin
N := Length(Dst);
N := EnsureRange(Limit, 64, Length(Dst));
if Count > Length(Pixels) then Count := Length(Pixels);
if Count < 0 then Count := 0;
if Count <= N then
begin
for i := 0 to Count - 1 do Dst[i] := Pixels[i];
DstCount := Count;
Exit;
end;
for i := 0 to N - 1 do
K := (Count + N - 1) div N; // точек анализатора на точку web
M := (Count + K - 1) div K; // столько точек получится (<= N)
for i := 0 to M - 1 do
begin
S0 := Int64(i) * Count div N;
S1 := (Int64(i) + 1) * Count div N - 1;
S0 := i * K;
S1 := S0 + K - 1;
if S1 > Count - 1 then S1 := Count - 1; // хвостовая группа короче
V := Pixels[S0];
for j := S0 + 1 to S1 do
if Pixels[j] > V then V := Pixels[j];
Dst[i] := V;
end;
DstCount := N;
DstCount := M;
end;
procedure TRadioController.OnSpectrumReady(const Pixels: array of Single;
@@ -2683,7 +2706,7 @@ procedure TRadioController.OnSpectrumReady(const Pixels: array of Single;
// DSP-поток: сохраняем последний кадр для web-зеркала и отдаём сырьё наружу
// (UI рисует FSpecView). Децимации у спектра нет.
begin
SnapshotPixels(Pixels, Count, FSpectrumBuf, FSpectrumBufCount);
SnapshotPixels(Pixels, Count, FSpectrumBuf, FWebSpecPixels, FSpectrumBufCount);
if Assigned(FOnSpectrumData) then FOnSpectrumData(Pixels, Count);
end;
@@ -2691,8 +2714,25 @@ procedure TRadioController.OnWaterfallReady(const Pixels: array of Single;
Count: Integer);
// DSP-поток: web-зеркало получает КАЖДЫЙ кадр; децимация рендера водопада —
// забота фронтенда (OnWaterfallData).
//
// Web шлёт 20 строк/с, анализатор даёт до 60 кадров/с — просто «последний
// кадр» терял бы 2 из 3 (короткая посылка CW/FT8 могла не попасть ни в одну
// строку). Копим кадры max-hold'ом до тех пор, пока PushState не заберёт
// строку (идиома TWaterfallView.SetWaterfallData при WfFrameInterval>1).
var
Tmp: array[0..SPECTRUM_PIXELS-1] of Single;
N, i: Integer;
begin
SnapshotPixels(Pixels, Count, FWaterfallBuf, FWaterfallBufCount);
SnapshotPixels(Pixels, Count, Tmp, FWebSpecPixels, N);
if (not FWfAccumFresh) or (N <> FWaterfallBufCount) then
begin
for i := 0 to N - 1 do FWaterfallBuf[i] := Tmp[i];
FWaterfallBufCount := N;
end
else
for i := 0 to N - 1 do
if Tmp[i] > FWaterfallBuf[i] then FWaterfallBuf[i] := Tmp[i];
FWfAccumFresh := True;
if Assigned(FOnWaterfallData) then FOnWaterfallData(Pixels, Count);
end;
+8
View File
@@ -265,6 +265,11 @@ type
BindAddr: string; // e.g. '0.0.0.0' (все интерфейсы)
User: string;
Pass: string;
// Потолок точек в кадре спектра/водопада для веб-клиента (1024/2048/4096).
// Реально уходит min(этот потолок, разрешение анализатора = ширина
// панадаптера). Трафик линеен: 4096 точек — это ~650 КБ/с на клиента
// (два потока по 20 кадров/с), 1024 — ~160 КБ/с.
SpecPixels: Integer;
end;
// Transverter (XVTR) entry — один трансвертер.
@@ -1994,6 +1999,7 @@ begin
W.BindAddr := '0.0.0.0';
W.User := 'admin';
W.Pass := 'hpsdr';
W.SpecPixels := 1024; // экономный дефолт: столько же, сколько слал протокол раньше
end;
procedure TSettingsManager.LoadWebSettings(out W: TWebSettings);
@@ -2007,6 +2013,7 @@ begin
W.BindAddr := JS(O, 'bind_addr', '0.0.0.0');
W.User := JS(O, 'user', 'admin');
W.Pass := JS(O, 'pass', 'hpsdr');
W.SpecPixels := EnsureRange(JI(O, 'spec_pixels', 1024), 256, 4096);
end;
procedure TSettingsManager.SaveWebSettings(const W: TWebSettings);
@@ -2018,6 +2025,7 @@ begin
JWS(O, 'bind_addr', W.BindAddr);
JWS(O, 'user', W.User);
JWS(O, 'pass', W.Pass);
JW(O, 'spec_pixels', W.SpecPixels);
Save;
end;
+37 -12
View File
@@ -66,7 +66,7 @@ type
TOnWfAGCNFChange = procedure(WfAGC, WfNF: Boolean) of object;
TOnADCChange = procedure(Dither, Random: Boolean) of object;
TOnWebSettingsChange = procedure(Enabled: Boolean; Port: Integer;
const BindAddr, User, Pass: string) of object;
const BindAddr, User, Pass: string; SpecPixels: Integer) of object;
TOnCATChange = procedure(
const SerEnabled: array of Boolean;
const SerPort: array of string;
@@ -382,6 +382,7 @@ type
FEdWebBind: TFlatEdit;
FEdWebUser: TFlatEdit;
FEdWebPass: TFlatEdit;
FCmbWebRes: TFlatComboBox;
// ---- Close button ----
FBtnClose: TFlatButton;
@@ -581,7 +582,7 @@ type
procedure LoadSpecMSAA(Samples: Integer);
procedure LoadADCSettings(Dither, Random: Boolean);
procedure LoadWebSettings(Enabled: Boolean; Port: Integer;
const BindAddr, User, Pass: string);
const BindAddr, User, Pass: string; SpecPixels: Integer);
property OnCATChange: TOnCATChange read FOnCATChange write FOnCATChange;
property OnSliceCATChange: TOnSliceCATChange read FOnSliceCATChange write FOnSliceCATChange;
@@ -2497,7 +2498,8 @@ var
Chk: TFlatCheckBox;
Ed: TFlatEdit;
Spin: TFlatSpinEdit;
Y: Integer;
Lbl: TLabel;
Y, i: Integer;
begin
with TLabel.Create(Self) do
begin
@@ -2536,7 +2538,7 @@ begin
FChkRandom := Chk;
// ── Web Server ────────────────────────────────────────────────────────────
Grp := MakeGroupPanel(FPageAdvanced, 'Web Server', MARGIN, 230, 500, 252);
Grp := MakeGroupPanel(FPageAdvanced, 'Web Server', MARGIN, 230, 500, 318);
Y := R1;
Chk := TFlatCheckBox.Create(Self);
@@ -2604,6 +2606,18 @@ begin
Ed.Text := 'hpsdr';
Ed.OnChange := OnWebAnyChange;
FEdWebPass := Ed;
// Потолок точек в кадре спектра/водопада, уходящем веб-клиенту. Реально
// отправляется min(этот потолок, разрешение анализатора = ширина
// панадаптера); выше — децимация max-детектором.
Y := Y + ROW_H;
MakeLbl(Grp, 'Spectrum:', GRP_PAD, Y + 4, LBL_W);
FCmbWebRes := MakeCombo(Grp, GRP_PAD + LBL_W + 12, Y, 130, OnWebAnyChange);
for i := 0 to High(DISPLAY_RES_NAMES) do FCmbWebRes.Items.Add(DISPLAY_RES_NAMES[i]);
FCmbWebRes.ItemIndex := 0;
Lbl := MakeLbl(Grp, '1024 px ~160 KB/s, 4096 px ~650 KB/s per client',
GRP_PAD + LBL_W + 12, Y + ROW_H - 8, 320);
Lbl.Font.Size := 8;
end;
procedure TSettingsForm.OnADCChkChange(Sender: TObject);
@@ -2614,15 +2628,21 @@ begin
end;
procedure TSettingsForm.OnWebAnyChange(Sender: TObject);
var Res: Integer;
begin
if FLoading then Exit;
if Assigned(FOnWebSettingsChange) then
FOnWebSettingsChange(
FChkWebEnabled.Checked,
FEdWebPort.Value,
FEdWebBind.Text,
FEdWebUser.Text,
FEdWebPass.Text);
if not Assigned(FOnWebSettingsChange) then Exit;
if (FCmbWebRes.ItemIndex >= 0) and (FCmbWebRes.ItemIndex <= High(DISPLAY_RES_VALUES)) then
Res := DISPLAY_RES_VALUES[FCmbWebRes.ItemIndex]
else
Res := DISPLAY_RES_VALUES[0];
FOnWebSettingsChange(
FChkWebEnabled.Checked,
FEdWebPort.Value,
FEdWebBind.Text,
FEdWebUser.Text,
FEdWebPass.Text,
Res);
end;
procedure TSettingsForm.LoadADCSettings(Dither, Random: Boolean);
@@ -2637,7 +2657,8 @@ begin
end;
procedure TSettingsForm.LoadWebSettings(Enabled: Boolean; Port: Integer;
const BindAddr, User, Pass: string);
const BindAddr, User, Pass: string; SpecPixels: Integer);
var i: Integer;
begin
FLoading := True;
try
@@ -2646,6 +2667,10 @@ begin
FEdWebBind.Text := BindAddr;
FEdWebUser.Text := User;
FEdWebPass.Text := Pass;
FCmbWebRes.ItemIndex := 0;
for i := 0 to High(DISPLAY_RES_VALUES) do
if DISPLAY_RES_VALUES[i] = SpecPixels then
begin FCmbWebRes.ItemIndex := i; Break; end;
finally
FLoading := False;
end;
+14 -2
View File
@@ -468,7 +468,12 @@ var
StatusText, BoardText, IPText, SupplyText: string;
PLLText, RXText, TXText, SeqText: string;
ViewCenter, ViewSpan: Double;
WfNew: Boolean;
begin
// Пришла ли от DSP новая строка водопада с прошлого пуша. Снимаем ДО пуша:
// без этого сервер повторно отправлял бы прежнюю строку, когда кадров нет
// (RX остановлен) или когда пуш случился чаще кадров — водопад двоил.
WfNew := FController.FWfAccumFresh;
// Видимое окно (центр+span) с учётом зума: пиксели спектра уже зумлены
// анализатором, web-метки/маппинг должны соответствовать.
FController.GetViewWindow(ViewCenter, ViewSpan);
@@ -558,9 +563,13 @@ begin
FController.InQO100,
FController.FRxMuteOnTx);
// Счётчики — РЕАЛЬНЫЕ: кадр короче потолка (web.spec_pixels), когда
// разрешение анализатора (= ширина панадаптера) меньше него — узкая панель,
// грид из двух колонок. Константа здесь отправляла хвост буфера — нули
// (0 dBm) поверх шкалы и сжатый по частоте спектр.
FServer.PushSpectrum(
FController.FSpectrumBuf, 1024,
FController.FWaterfallBuf,
FController.FSpectrumBuf, FController.FSpectrumBufCount,
FController.FWaterfallBuf, FController.FWaterfallBufCount, WfNew,
FController.FLastSMeter,
FController.FVfoA, FController.FMode, FController.FFilterBW, FController.FAGCMode, FController.FAGCTop,
ViewSpan, FController.ActiveVolume,
@@ -576,6 +585,9 @@ begin
FController.FLastFwdW, FController.FLastSWR, FController.FPAMaxPower,
StatusText, BoardText, IPText, SupplyText,
PLLText, RXText, TXText, SeqText);
// Строка забрана — следующий кадр DSP начинает новое накопление.
if WfNew then FController.FWfAccumFresh := False;
end;
end.
+33 -4
View File
@@ -833,6 +833,17 @@ begin
'else{for(let i=0;i<=8;i++){const x=i*w/8,f=st+i*localSpan/8;rctx.strokeStyle="#2a3a2e";rctx.lineWidth=1;rctx.beginPath();rctx.moveTo(x+.5,0);rctx.lineTo(x+.5,7);rctx.stroke();rctx.fillStyle="#7a9a80";rctx.font="10px monospace";const t=(f/1e6).toFixed(3);rctx.fillText(t,x-14,21);}}' +
'const vxr=hzToX(activeVfoHz(),w);rctx.strokeStyle="#60d070";rctx.lineWidth=1;rctx.beginPath();rctx.moveTo(vxr+.5,0);rctx.lineTo(vxr+.5,h);rctx.stroke();}' +
// Значение бина для пикселя x канваса шириной w. Бинов больше пикселей —
// берём МАКСИМУМ по диапазону (выбор «ближайшего» выбрасывал остальные:
// узкие сигналы мерцали и пропадали на узком экране). Бинов меньше —
// линейная интерполяция, иначе ступеньки.
'function binPick(a,x,w){const n=a.length;if(n<1)return -140;' +
'let i0=Math.floor(x*n/w),i1=Math.floor((x+1)*n/w)-1;' +
'if(i0<0)i0=0;if(i0>n-1)i0=n-1;if(i1>n-1)i1=n-1;' +
'if(i1<i0){const p=(x+0.5)*n/w-0.5,j=Math.floor(p),t=p-j,' +
'v0=a[cl(j,0,n-1)],v1=a[cl(j+1,0,n-1)];return v0+(v1-v0)*t;}' +
'let v=a[i0];for(let i=i0+1;i<=i1;i++)if(a[i]>v)v=a[i];return v;}' +
'function drawSpec(bins){const w=specCv.clientWidth,h=specCv.clientHeight;sctx.fillStyle="#050a05";sctx.fillRect(0,0,w,h);' +
'for(let i=1;i<5;i++){const y=i*h/5;sctx.strokeStyle="#121e12";sctx.lineWidth=1;sctx.beginPath();sctx.moveTo(0,y);sctx.lineTo(w,y);sctx.stroke();}' +
'if((cur.mode===M_FM||cur.mode===M_DMR||cur.mode===M_FMRAW)&&FM_STEP_HZ[fmStepIdx]>0){' +
@@ -844,12 +855,16 @@ begin
'if(x<0||x>=w)continue;sctx.strokeStyle="#121e12";sctx.lineWidth=1;sctx.beginPath();sctx.moveTo(x+.5,0);sctx.lineTo(x+.5,h);sctx.stroke();}}' +
'if(!bins?.length){drawMk(sctx,w,h);return;}' +
'if(specSm.length!==bins.length)specSm=new Array(bins.length).fill(-130);' +
// Сглаживание — отдельным проходом по ВСЕМ бинам: раньше оно шло внутри
// цикла по пикселям, поэтому при узком канвасе часть бинов не обновлялась
// вовсе, а при широком одни и те же обновлялись по нескольку раз за кадр.
'for(let i=0;i<bins.length;i++)specSm[i]=specSm[i]*.70+bins[i]*.30;' +
'const avf=activeVfoHz();const[fl,fh]=fltLH(),x1=hzToX(avf+fl,w),x2=hzToX(avf+fh,w);' +
'sctx.fillStyle=(cur.transmitting||cur.tuning)?"rgba(255,60,60,.18)":"rgba(80,150,255,.12)";sctx.fillRect(Math.min(x1,x2),0,Math.abs(x2-x1),h);' +
'sctx.beginPath();for(let x=0;x<w;x++){const i=Math.floor(x*(bins.length-1)/Math.max(1,w-1));specSm[i]=specSm[i]*.70+bins[i]*.30;const y=cl(((-specSm[i]-20)/120)*h,0,h-1);x===0?sctx.moveTo(0,y):sctx.lineTo(x,y);}' +
'sctx.beginPath();for(let x=0;x<w;x++){const y=cl(((-binPick(specSm,x,w)-20)/120)*h,0,h-1);x===0?sctx.moveTo(0,y):sctx.lineTo(x,y);}' +
'const g=sctx.createLinearGradient(0,0,0,h);g.addColorStop(0,"rgba(60,230,60,.2)");g.addColorStop(1,"rgba(60,230,60,.01)");' +
'sctx.lineTo(w,h);sctx.lineTo(0,h);sctx.closePath();sctx.fillStyle=g;sctx.fill();' +
'sctx.beginPath();for(let x=0;x<w;x++){const i=Math.floor(x*(bins.length-1)/Math.max(1,w-1));const y=cl(((-specSm[i]-20)/120)*h,0,h-1);x===0?sctx.moveTo(0,y):sctx.lineTo(x,y);}' +
'sctx.beginPath();for(let x=0;x<w;x++){const y=cl(((-binPick(specSm,x,w)-20)/120)*h,0,h-1);x===0?sctx.moveTo(0,y):sctx.lineTo(x,y);}' +
'sctx.strokeStyle="#3ddd3d";sctx.lineWidth=1.2;sctx.stroke();' +
'const vx=hzToX(avf,w);sctx.strokeStyle="#60ff80";sctx.lineWidth=1;sctx.beginPath();sctx.moveTo(vx,0);sctx.lineTo(vx,h);sctx.stroke();drawBcn(sctx,w,h);drawMk(sctx,w,h);}' +
@@ -858,6 +873,10 @@ begin
'if(wfBuf.width!==w||wfBuf.height!==h){wfBuf.width=w;wfBuf.height=h;wfBCtx.fillStyle="#000";wfBCtx.fillRect(0,0,w,h);}' +
'if(wfAvg.length!==bins.length){wfAvg=new Array(bins.length).fill(-120);wfHi=-50;wfLo=-120;}' +
'const useAgc=cur.wf_agc!==false;const useNf=cur.wf_nf!==false;' +
// wfAvg — только для слежения за уровнями (hi/lo). Саму строку рисуем из
// сырых бинов: IIR по времени размазывал водопад по вертикали (постоянная
// ~200 мс), короткие посылки выцветали. Усреднение по времени уже сделал
// анализатор WDSP (wf_avg_time_ms).
'for(let i=0;i<bins.length;i++){wfAvg[i]=wfAvg[i]*.75+bins[i]*.25;}' +
'let peak=-200,nfSum=0,nfCount=0;' +
'for(let i=0;i<bins.length;i++){if(wfAvg[i]>peak)peak=wfAvg[i];}' +
@@ -867,7 +886,7 @@ begin
'let hHi=wfHi,hLo=wfLo;if(hHi<hLo+15)hHi=hLo+15;' +
'if(hHi>0)hHi=0;if(hLo<-160)hLo=-160;wfHi=hHi;wfLo=hLo;' +
'wfBCtx.drawImage(wfBuf,0,0,w,h-1,0,1,w,h-1);const row=wfBCtx.createImageData(w,1);' +
'for(let x=0;x<w;x++){const i=Math.floor(x*(wfAvg.length-1)/Math.max(1,w-1));const[r,g,b]=pal(wfAvg[i]);const o=x*4;row.data[o]=r;row.data[o+1]=g;row.data[o+2]=b;row.data[o+3]=255;}' +
'for(let x=0;x<w;x++){const[r,g,b]=pal(binPick(bins,x,w));const o=x*4;row.data[o]=r;row.data[o+1]=g;row.data[o+2]=b;row.data[o+3]=255;}' +
'wfBCtx.putImageData(row,0,0);wctx.drawImage(wfBuf,0,0,w,h);' +
'const avf2=activeVfoHz();const[fl2,fh2]=fltLH(),x1w=hzToX(avf2+fl2,w),x2w=hzToX(avf2+fh2,w);' +
'wctx.fillStyle=(cur.transmitting||cur.tuning)?"rgba(255,60,60,.15)":"rgba(220,230,230,.10)";wctx.fillRect(Math.min(x1w,x2w),0,Math.abs(x2w-x1w),h);' +
@@ -1051,13 +1070,23 @@ begin
'}catch(e){console.error("startTXMic:",e);cleanupTXMic();}' +
'finally{txMicStarting=false;}}' +
'function f32(ab){const n=(ab.byteLength-1)>>2;if(n<1)return[];const tmp=new Uint8Array(ab,1),al=new ArrayBuffer(n*4);' +
'new Uint8Array(al).set(tmp);return Array.from(new Float32Array(al));}' +
'function connect(){const p=location.protocol==="https:"?"wss":"ws";ws=new WebSocket(p+"://"+location.host+"/ws");ws.binaryType="arraybuffer";' +
'ws.onopen=()=>{stEl.textContent="connected";initOpus();};' +
'ws.onclose=()=>{cleanupTXMic();stEl.textContent="disconnected...";setTimeout(connect,3000);};' +
'ws.onerror=()=>ws.close();' +
'ws.onmessage=e=>{if(typeof e.data==="string"){try{applyState(JSON.parse(e.data));}catch(x){}return;}' +
'const ab=e.data,t=(new DataView(ab)).getUint8(0);' +
'if(t===0x53){const n=(ab.byteLength-1)>>2;const tmp=new Uint8Array(ab,1);const al=new ArrayBuffer(n*4);new Uint8Array(al).set(tmp);const b=Array.from(new Float32Array(al));drawSpec(b);drawWf(b);drawRuler();}' +
// Число точек берём из длины фрейма — сервер шлёт ровно столько, сколько
// выдал анализатор (<=1024). Копия нужна: срез с offset 1 не выровнен под
// Float32Array.
'if(t===0x53){drawSpec(f32(ab));drawRuler();}' +
// 0x57 = строка водопада. Раньше не разбиралась вовсе — водопад рисовался
// из кадра спектра, а свой поток (со своим детектором и усреднением
// wf_avg_time_ms) уходил в никуда, занимая половину трафика.
'else if(t===0x57){drawWf(f32(ab));}' +
'else if(t===0x41){if(audioCtx&&audioCtx.state==="suspended")audioCtx.resume();playOpus(new Uint8Array(ab,1));}};}' +
// Оверлей для user gesture (AudioContext требует клика)
+68 -20
View File
@@ -93,10 +93,13 @@ const
MIC_MAX_PLC = 5;
MAX_WS_CLIENTS = 4;
WS_GUID = '258EAFA5-E914-47DA-95CA-C5AB0DC85B11';
// Потолок точек в кадре спектра/водопада (настройка web.spec_pixels режет
// ниже). Совпадает с WDSPEngine.SPECTRUM_PIXELS — больше анализатор не даёт.
WEB_SPEC_MAX = 4096;
// Типы бинарных фреймов (первый байт = тип)
WS_MSG_SPECTRUM = Byte(Ord('S')); // S + 1024×Float32
WS_MSG_WATERFALL = Byte(Ord('W')); // W + N×Float32
WS_MSG_SPECTRUM = Byte(Ord('S')); // S + N×Float32 (N = длина фрейма, <= WEB_SPEC_MAX)
WS_MSG_WATERFALL = Byte(Ord('W')); // W + N×Float32 (N = длина фрейма, <= WEB_SPEC_MAX)
WS_MSG_AUDIO = Byte(Ord('A')); // A + Opus bytes
WS_MSG_AUDIO_PCM = Byte(Ord('P')); // P + N×Float32 (mono 48k)
WS_MSG_STATE = Byte(Ord('J')); // J + JSON text
@@ -240,9 +243,11 @@ type
FBindIP: string;
// ── Состояние (обновляется из MainForm) ──
FSpectrumBuf: array[0..1023] of Single;
FWfBuf: array[0..1023] of Single;
FWfCount: Integer;
FSpectrumBuf: array[0..WEB_SPEC_MAX-1] of Single;
FSpecCount: Integer; // точек в кадре спектра (0 = кадра ещё не было)
FWfBuf: array[0..WEB_SPEC_MAX-1] of Single;
FWfCount: Integer; // точек в строке водопада
FWfFresh: Boolean; // строка накапливается (max-hold) и ещё не отдана
FSMeter: Double;
FFreq: Double;
FMode: Integer;
@@ -395,7 +400,7 @@ type
// Вызывается из таймера спектра (UI thread)
procedure PushSpectrum(
const Buf: array of Single; Count: Integer;
const WfBuf_: array of Single;
const WfBuf_: array of Single; WfCount: Integer; WfNew: Boolean;
SMeter: Double;
Freq: Double; Mode, FilterBW, AGCMode, AGCTop: Integer;
SpanHz: Double; Volume: Integer;
@@ -1625,9 +1630,9 @@ end;
procedure TWebServer.PushLoop;
var
Tick, StateLastTick: QWord;
SpecBuf: array[0..4096] of Byte;
WfBuf: array[0..4096] of Byte;
i: Integer;
SpecBuf: array[0..1 + WEB_SPEC_MAX*4] of Byte;
WfBuf: array[0..1 + WEB_SPEC_MAX*4] of Byte;
i, SN, WN: Integer;
HasClients: Boolean;
begin
StateLastTick := GetTickCount64;
@@ -1640,23 +1645,49 @@ begin
HasClients := FClientCount > 0;
FClientLock.Leave;
if not HasClients then Continue;
if not HasClients then
begin
// Без клиентов строку никто не забирает — иначе накопитель max-hold рос
// бы всё время простоя и первая строка после подключения была бы
// максимумом за минуты.
FStateLock.Enter;
FWfFresh := False;
FStateLock.Leave;
Continue;
end;
FStateLock.Enter;
try
// Длина фрейма = число реально заполненных точек: клиент считает N из
// byteLength и растягивает ровно их на всю ширину канваса.
SN := FSpecCount;
SpecBuf[0] := WS_MSG_SPECTRUM;
for i := 0 to 1023 do
for i := 0 to SN - 1 do
PSingle(Pointer(PByte(@SpecBuf[1]) + i*4))^ := FSpectrumBuf[i];
WfBuf[0] := WS_MSG_WATERFALL;
for i := 0 to 1023 do
PSingle(Pointer(PByte(@WfBuf[1]) + i*4))^ := FWfBuf[i];
// Водопад — ТОЛЬКО если с прошлой отправки пришла новая строка. Иначе в
// демоне (PushState 20 Гц и этот цикл 20 Гц идут вразнобой) на дрейфе фаз
// одна и та же строка уезжала клиенту дважды — водопад «двоил» и полз
// рывками, а на остановленном RX прокручивал застывшую строку вечно.
// Спектр так гейтить нельзя: это живая кривая, её надо перерисовывать.
if FWfFresh then
begin
WN := FWfCount;
WfBuf[0] := WS_MSG_WATERFALL;
for i := 0 to WN - 1 do
PSingle(Pointer(PByte(@WfBuf[1]) + i*4))^ := FWfBuf[i];
FWfFresh := False; // следующий кадр DSP начнёт новое накопление
end
else
WN := 0;
finally
FStateLock.Leave;
end;
BroadcastBinary(SpecBuf[0], 1 + 1024*4);
BroadcastBinary(WfBuf[0], 1 + 1024*4);
// Кадр спектра шлём и пустым (SN=0, до первого кадра DSP): клиент
// перерисовывает по нему линейку и оверлеи, а пустой спектр не рисует.
BroadcastBinary(SpecBuf[0], 1 + SN*4);
if WN > 0 then BroadcastBinary(WfBuf[0], 1 + WN*4);
if Tick - StateLastTick >= 200 then
begin
@@ -1878,7 +1909,7 @@ end;
procedure TWebServer.PushSpectrum(
const Buf: array of Single; Count: Integer;
const WfBuf_: array of Single;
const WfBuf_: array of Single; WfCount: Integer; WfNew: Boolean;
SMeter: Double;
Freq: Double; Mode, FilterBW, AGCMode, AGCTop: Integer;
SpanHz: Double; Volume: Integer;
@@ -1893,14 +1924,31 @@ procedure TWebServer.PushSpectrum(
FwdW, SWRV, PAMaxPower: Double;
const StatusText, BoardText, IPText, SupplyText, PLLText,
RXText, TXText, SeqText: string);
var N, i: Integer;
var N, W, i: Integer;
begin
if not FRunning then Exit;
N := Min(Count, 1024);
N := EnsureRange(Min(Count, Length(Buf)), 0, WEB_SPEC_MAX);
W := EnsureRange(Min(WfCount, Length(WfBuf_)), 0, WEB_SPEC_MAX);
FStateLock.Enter;
try
for i := 0 to N-1 do FSpectrumBuf[i] := Buf[i];
for i := 0 to Min(High(WfBuf_), 1023) do FWfBuf[i] := WfBuf_[i];
FSpecCount := N;
// Водопад: зовут нас чаще, чем PushLoop отдаёт строку (в GUI 60 против 20
// раз/с) — между отправками копим max-hold'ом, иначе кадры терялись бы.
// WfNew=False — новой строки от DSP не было: не трогаем ни накопитель, ни
// флаг, иначе PushLoop отправил бы прежнюю строку повторно.
if WfNew then
begin
if (not FWfFresh) or (W <> FWfCount) then
begin
for i := 0 to W-1 do FWfBuf[i] := WfBuf_[i];
FWfCount := W;
end
else
for i := 0 to W-1 do
if WfBuf_[i] > FWfBuf[i] then FWfBuf[i] := WfBuf_[i];
FWfFresh := True;
end;
FSMeter := SMeter;
FFreq := Freq;
FMode := Mode;
+17 -8
View File
@@ -26,12 +26,11 @@ program ewsdrd;
uses
{$IFDEF UNIX}cthreads, BaseUnix,{$ENDIF}
Classes, SysUtils,
RadioController, WebServer, WebAdapter,
Classes, SysUtils, Math,
RadioController, WebServer, WebAdapter, WDSPEngine,
HPSDRNetwork, Settings, WDSP, PlatformUtils, DeviceStore, BoardUtils;
const
DAEMON_SPEC_WIDTH = 1024; // ширина спектра для StartRunning (web-буфер = 1024)
PUSH_INTERVAL_MS = 50; // период исходящего web-зеркала (как GUI-таймер)
type
@@ -52,6 +51,9 @@ type
procedure OnState(Sender: TObject; Field: TRadioField);
// WDSP открываем лениво и синхронно (нет UI — блокировать главный поток ок).
function EnsureWDSP: Boolean;
// Ширина анализатора в демоне: рендера нет, поэтому её задаёт потолок
// web-кадра (web.spec_pixels) — ровно столько точек и уйдёт клиенту.
function DaemonSpecWidth: Integer;
procedure ConnectTo(const Dev: THPSDRDevice);
procedure PushDeviceList;
procedure PushXvtr;
@@ -148,6 +150,7 @@ begin
// --- Веб-сервер + адаптер ---
FCtrl.FSettings.LoadWebSettings(WebCfg);
FCtrl.FWebSpecPixels := EnsureRange(WebCfg.SpecPixels, 256, SPECTRUM_PIXELS);
FWeb := TWebServer.Create(WebCfg.User, WebCfg.Pass, Word(WebCfg.Port), WebCfg.BindAddr);
FWebAd := TWebAdapter.Create(FCtrl, FWeb, nil);
// RX-аудио веб-клиенту — через route-хук контроллера (D1).
@@ -231,10 +234,16 @@ begin
Exit;
end;
// Ширина анализатора спектра. Без UI её никто не выставляет (GUI зовёт
// SetSpectrumWidth(панель) каждый тик), и анализатор отдаёт дефолтные 64 пикселя
// → web рисует лишь ~6% ширины, остальное белым. Ставим ровно под web-буфер
// (SPECTRUM_PIXELS=1024). FLastSpectrumW переживает пересоздание канала.
FCtrl.FDSPEngine.SetSpectrumWidth(DAEMON_SPEC_WIDTH);
// SetSpectrumWidth(панель) каждый тик), и анализатор отдал бы дефолтные 64
// пикселя. Ставим ровно под потолок web-кадра (web.spec_pixels) — больше
// демону не нужно, меньше — обрезало бы настройку.
// FLastSpectrumW переживает пересоздание канала.
FCtrl.FDSPEngine.SetSpectrumWidth(DaemonSpecWidth);
end;
function THeadlessHost.DaemonSpecWidth: Integer;
begin
Result := EnsureRange(FCtrl.FWebSpecPixels, 256, SPECTRUM_PIXELS);
end;
procedure THeadlessHost.ConnectTo(const Dev: THPSDRDevice);
@@ -242,7 +251,7 @@ begin
if FCtrl.FNetwork.Connected then Exit;
if not EnsureWDSP then Exit;
Log('Connecting to ' + Dev.IPAddress + '...');
if FCtrl.ConnectAndRun(Dev, DAEMON_SPEC_WIDTH) then
if FCtrl.ConnectAndRun(Dev, DaemonSpecWidth) then
Log('Running. Board=' + IntToStr(FCtrl.FNetwork.Device.BoardType))
else
Log('Connect FAILED: ' + FCtrl.FNetwork.LastError);