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; FWebBindAddr: string;
FWebUser: string; FWebUser: string;
FWebPass: string; FWebPass: string;
FWebSpecPixels: Integer; // потолок точек кадра спектра/водопада для web
// --- CAT --- // --- CAT ---
FCATAdapter: TCATAdapter; // адаптер CAT поверх контроллера (движок+транспорты+контекст) FCATAdapter: TCATAdapter; // адаптер CAT поверх контроллера (движок+транспорты+контекст)
FCATLastGlobal: TGlobalSettings; // текущие CAT-настройки (UI/persist; ApplySettings → адаптер) FCATLastGlobal: TGlobalSettings; // текущие CAT-настройки (UI/persist; ApplySettings → адаптер)
@@ -753,7 +754,7 @@ type
procedure ApplyWfAGCNF(WfAGC, WfNF: Boolean); procedure ApplyWfAGCNF(WfAGC, WfNF: Boolean);
procedure ApplyADCSettings(Dither, Random: Boolean); procedure ApplyADCSettings(Dither, Random: Boolean);
procedure ApplyWebSettings(Enabled: Boolean; Port: Integer; procedure ApplyWebSettings(Enabled: Boolean; Port: Integer;
const BindAddr, User, Pass: string); const BindAddr, User, Pass: string; SpecPixels: Integer);
procedure ApplyGridParams(RefLevel, Range, GridStep: Double); procedure ApplyGridParams(RefLevel, Range, GridStep: Double);
// Пушит активные grid-параметры (RX или TX в зависимости от FTransmitting) // Пушит активные grid-параметры (RX или TX в зависимости от FTransmitting)
// в FSpecView и сбрасывает кэш сетки. Вызывается при смене RX↔TX и при // в FSpecView и сбрасывает кэш сетки. Вызывается при смене RX↔TX и при
@@ -1064,6 +1065,8 @@ begin
FWebBindAddr := WebCfg.BindAddr; FWebBindAddr := WebCfg.BindAddr;
FWebUser := WebCfg.User; FWebUser := WebCfg.User;
FWebPass := WebCfg.Pass; FWebPass := WebCfg.Pass;
FWebSpecPixels := EnsureRange(WebCfg.SpecPixels, 256, SPECTRUM_PIXELS);
FController.FWebSpecPixels := FWebSpecPixels;
FWebServer := TWebServer.Create(FWebUser, FWebPass, Word(FWebPort), FWebBindAddr); FWebServer := TWebServer.Create(FWebUser, FWebPass, Word(FWebPort), FWebBindAddr);
// Вынесенные в TWebAdapter чистые-контроллерные команды (host=nil: их хендлеры // Вынесенные в TWebAdapter чистые-контроллерные команды (host=nil: их хендлеры
// ещё не нужны хосту; host-зависимые WebOnXxx пока на MainForm — переедут в WA2). // ещё не нужны хосту; host-зависимые WebOnXxx пока на MainForm — переедут в WA2).
@@ -5691,7 +5694,7 @@ begin
end; end;
procedure TMainForm.ApplyWebSettings(Enabled: Boolean; Port: Integer; procedure TMainForm.ApplyWebSettings(Enabled: Boolean; Port: Integer;
const BindAddr, User, Pass: string); const BindAddr, User, Pass: string; SpecPixels: Integer);
var W: TWebSettings; var W: TWebSettings;
begin begin
FWebEnabled := Enabled; FWebEnabled := Enabled;
@@ -5699,6 +5702,10 @@ begin
FWebBindAddr := BindAddr; FWebBindAddr := BindAddr;
FWebUser := User; FWebUser := User;
FWebPass := Pass; FWebPass := Pass;
// Потолок точек кадра для web — применяется сразу, следующий же кадр DSP
// снимается с новым разрешением (переарм анализатора не нужен).
FWebSpecPixels := EnsureRange(SpecPixels, 256, SPECTRUM_PIXELS);
FController.FWebSpecPixels := FWebSpecPixels;
FWebServer.Reconfigure(User, Pass, BindAddr, Word(Port)); FWebServer.Reconfigure(User, Pass, BindAddr, Word(Port));
if Enabled then FWebServer.Start; if Enabled then FWebServer.Start;
W.Enabled := Enabled; W.Enabled := Enabled;
@@ -5706,6 +5713,7 @@ begin
W.BindAddr := BindAddr; W.BindAddr := BindAddr;
W.User := User; W.User := User;
W.Pass := Pass; W.Pass := Pass;
W.SpecPixels := FWebSpecPixels;
FController.FSettings.SaveWebSettings(W); FController.FSettings.SaveWebSettings(W);
end; end;
@@ -8559,7 +8567,8 @@ begin
FController.FDisplayPixels, FController.FWfSpeedDiv); FController.FDisplayPixels, FController.FWfSpeedDiv);
SF.LoadSpecMSAA(FController.FSpecMSAA); SF.LoadSpecMSAA(FController.FSpecMSAA);
SF.LoadADCSettings(FController.FDitherEnabled, FController.FRandomEnabled); 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.LoadFPS(FDisplayFPS);
SF.LoadLightTheme(FLightTheme); SF.LoadLightTheme(FLightTheme);
SF.LoadFreqMhzDigits(FFreqMhzDigits); SF.LoadFreqMhzDigits(FFreqMhzDigits);
+54 -14
View File
@@ -229,10 +229,17 @@ type
// Снимок для исходящего web-зеркала (PushState читает напрямую). Пишется из // Снимок для исходящего web-зеркала (PushState читает напрямую). Пишется из
// DSP-потока в OnSpectrumReady/OnWaterfallReady; читается из GUI-таймера — // DSP-потока в OnSpectrumReady/OnWaterfallReady; читается из GUI-таймера —
// та же толерантная гонка, что и раньше (косметика пикселей). // та же толерантная гонка, что и раньше (косметика пикселей).
FSpectrumBuf: array[0..1023] of Single; FSpectrumBuf: array[0..SPECTRUM_PIXELS-1] of Single;
FWaterfallBuf: array[0..1023] of Single; FWaterfallBuf: array[0..SPECTRUM_PIXELS-1] of Single;
FSpectrumBufCount: Integer; FSpectrumBufCount: Integer;
FWaterfallBufCount: Integer; FWaterfallBufCount: Integer;
// Потолок точек кадра для web (настройка web.spec_pixels, 1024/2048/4096).
// Кадр анализатора шире — децимируем до него; уже — уходит как есть.
FWebSpecPixels: Integer;
// Водопад копится max-hold'ом до тех пор, пока PushState не заберёт строку
// (анализатор даёт до 60 кадров/с, web шлёт 20 строк/с). Сбрасывает флаг
// тот, кто забрал строку, — тогда следующий кадр начинает новую.
FWfAccumFresh: Boolean;
// ---- Движки (владение: см. шапку юнита) ---- // ---- Движки (владение: см. шапку юнита) ----
// FNetwork — абстрактный бэкенд железа (TRadioBackend): конкретный класс // FNetwork — абстрактный бэкенд железа (TRadioBackend): конкретный класс
@@ -1041,7 +1048,10 @@ begin
ResetPanDDCMaps; // паны: все слоты свободны ResetPanDDCMaps; // паны: все слоты свободны
FZoomFactor := 0.0; FPanSlider := 0.5; // 0.0 = весь span (без зума) FZoomFactor := 0.0; FPanSlider := 0.5; // 0.0 = весь span (без зума)
FCurrentBand := 5; FCurrentXvtr := -1; 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; FPAMaxPower := 100.0;
FPlutoTxMaxAttDb := -10.0; // ADI-дефолт: линейный режим выходных каскадов FPlutoTxMaxAttDb := -10.0; // ADI-дефолт: линейный режим выходных каскадов
FWfAGCEnabled := True; FDitherEnabled := True; FRandomEnabled := True; FWfAGCEnabled := True; FDitherEnabled := True; FRandomEnabled := True;
@@ -2650,32 +2660,45 @@ begin
SetMOX(True); SetMOX(True);
end; end;
// Снимок кадра для web-зеркала: протокол фиксирован на 1024 точки, поэтому // Снимок кадра для web-зеркала: потолок точек задаёт настройка web.spec_pixels
// при большем разрешении анализатора децимируем max-детектором (не обрезаем). // (Limit), при большем разрешении анализатора децимируем max-детектором
// (не обрезаем).
// DstCount — сколько точек РЕАЛЬНО заполнено; отправлять больше нельзя, иначе
// хвост буфера (нули = 0 dBm) уедет в эфир белой полосой, а частотная шкала
// клиента растянется на несуществующие точки.
//
// Группы — строго одинаковой ширины (целочисленный фактор). Прежнее деление
// «i*Count div N» давало на нецелом отношении соседние группы разной ширины
// (1 и 2 точки), а смещение max-детектора зависит от числа точек в группе —
// по спектру шла гребёнка ±3 дБ, в водопаде видимая как вертикальная рябь.
procedure SnapshotPixels(const Pixels: array of Single; Count: Integer; 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 var
i, j, S0, S1, N: Integer; i, j, S0, S1, N, K, M: Integer;
V: Single; V: Single;
begin begin
N := Length(Dst); N := EnsureRange(Limit, 64, Length(Dst));
if Count > Length(Pixels) then Count := Length(Pixels); if Count > Length(Pixels) then Count := Length(Pixels);
if Count < 0 then Count := 0;
if Count <= N then if Count <= N then
begin begin
for i := 0 to Count - 1 do Dst[i] := Pixels[i]; for i := 0 to Count - 1 do Dst[i] := Pixels[i];
DstCount := Count; DstCount := Count;
Exit; Exit;
end; 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 begin
S0 := Int64(i) * Count div N; S0 := i * K;
S1 := (Int64(i) + 1) * Count div N - 1; S1 := S0 + K - 1;
if S1 > Count - 1 then S1 := Count - 1; // хвостовая группа короче
V := Pixels[S0]; V := Pixels[S0];
for j := S0 + 1 to S1 do for j := S0 + 1 to S1 do
if Pixels[j] > V then V := Pixels[j]; if Pixels[j] > V then V := Pixels[j];
Dst[i] := V; Dst[i] := V;
end; end;
DstCount := N; DstCount := M;
end; end;
procedure TRadioController.OnSpectrumReady(const Pixels: array of Single; procedure TRadioController.OnSpectrumReady(const Pixels: array of Single;
@@ -2683,7 +2706,7 @@ procedure TRadioController.OnSpectrumReady(const Pixels: array of Single;
// DSP-поток: сохраняем последний кадр для web-зеркала и отдаём сырьё наружу // DSP-поток: сохраняем последний кадр для web-зеркала и отдаём сырьё наружу
// (UI рисует FSpecView). Децимации у спектра нет. // (UI рисует FSpecView). Децимации у спектра нет.
begin begin
SnapshotPixels(Pixels, Count, FSpectrumBuf, FSpectrumBufCount); SnapshotPixels(Pixels, Count, FSpectrumBuf, FWebSpecPixels, FSpectrumBufCount);
if Assigned(FOnSpectrumData) then FOnSpectrumData(Pixels, Count); if Assigned(FOnSpectrumData) then FOnSpectrumData(Pixels, Count);
end; end;
@@ -2691,8 +2714,25 @@ procedure TRadioController.OnWaterfallReady(const Pixels: array of Single;
Count: Integer); Count: Integer);
// DSP-поток: web-зеркало получает КАЖДЫЙ кадр; децимация рендера водопада — // DSP-поток: web-зеркало получает КАЖДЫЙ кадр; децимация рендера водопада —
// забота фронтенда (OnWaterfallData). // забота фронтенда (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 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); if Assigned(FOnWaterfallData) then FOnWaterfallData(Pixels, Count);
end; end;
+8
View File
@@ -265,6 +265,11 @@ type
BindAddr: string; // e.g. '0.0.0.0' (все интерфейсы) BindAddr: string; // e.g. '0.0.0.0' (все интерфейсы)
User: string; User: string;
Pass: string; Pass: string;
// Потолок точек в кадре спектра/водопада для веб-клиента (1024/2048/4096).
// Реально уходит min(этот потолок, разрешение анализатора = ширина
// панадаптера). Трафик линеен: 4096 точек — это ~650 КБ/с на клиента
// (два потока по 20 кадров/с), 1024 — ~160 КБ/с.
SpecPixels: Integer;
end; end;
// Transverter (XVTR) entry — один трансвертер. // Transverter (XVTR) entry — один трансвертер.
@@ -1994,6 +1999,7 @@ begin
W.BindAddr := '0.0.0.0'; W.BindAddr := '0.0.0.0';
W.User := 'admin'; W.User := 'admin';
W.Pass := 'hpsdr'; W.Pass := 'hpsdr';
W.SpecPixels := 1024; // экономный дефолт: столько же, сколько слал протокол раньше
end; end;
procedure TSettingsManager.LoadWebSettings(out W: TWebSettings); procedure TSettingsManager.LoadWebSettings(out W: TWebSettings);
@@ -2007,6 +2013,7 @@ begin
W.BindAddr := JS(O, 'bind_addr', '0.0.0.0'); W.BindAddr := JS(O, 'bind_addr', '0.0.0.0');
W.User := JS(O, 'user', 'admin'); W.User := JS(O, 'user', 'admin');
W.Pass := JS(O, 'pass', 'hpsdr'); W.Pass := JS(O, 'pass', 'hpsdr');
W.SpecPixels := EnsureRange(JI(O, 'spec_pixels', 1024), 256, 4096);
end; end;
procedure TSettingsManager.SaveWebSettings(const W: TWebSettings); procedure TSettingsManager.SaveWebSettings(const W: TWebSettings);
@@ -2018,6 +2025,7 @@ begin
JWS(O, 'bind_addr', W.BindAddr); JWS(O, 'bind_addr', W.BindAddr);
JWS(O, 'user', W.User); JWS(O, 'user', W.User);
JWS(O, 'pass', W.Pass); JWS(O, 'pass', W.Pass);
JW(O, 'spec_pixels', W.SpecPixels);
Save; Save;
end; end;
+37 -12
View File
@@ -66,7 +66,7 @@ type
TOnWfAGCNFChange = procedure(WfAGC, WfNF: Boolean) of object; TOnWfAGCNFChange = procedure(WfAGC, WfNF: Boolean) of object;
TOnADCChange = procedure(Dither, Random: Boolean) of object; TOnADCChange = procedure(Dither, Random: Boolean) of object;
TOnWebSettingsChange = procedure(Enabled: Boolean; Port: Integer; TOnWebSettingsChange = procedure(Enabled: Boolean; Port: Integer;
const BindAddr, User, Pass: string) of object; const BindAddr, User, Pass: string; SpecPixels: Integer) of object;
TOnCATChange = procedure( TOnCATChange = procedure(
const SerEnabled: array of Boolean; const SerEnabled: array of Boolean;
const SerPort: array of string; const SerPort: array of string;
@@ -382,6 +382,7 @@ type
FEdWebBind: TFlatEdit; FEdWebBind: TFlatEdit;
FEdWebUser: TFlatEdit; FEdWebUser: TFlatEdit;
FEdWebPass: TFlatEdit; FEdWebPass: TFlatEdit;
FCmbWebRes: TFlatComboBox;
// ---- Close button ---- // ---- Close button ----
FBtnClose: TFlatButton; FBtnClose: TFlatButton;
@@ -581,7 +582,7 @@ type
procedure LoadSpecMSAA(Samples: Integer); procedure LoadSpecMSAA(Samples: Integer);
procedure LoadADCSettings(Dither, Random: Boolean); procedure LoadADCSettings(Dither, Random: Boolean);
procedure LoadWebSettings(Enabled: Boolean; Port: Integer; 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 OnCATChange: TOnCATChange read FOnCATChange write FOnCATChange;
property OnSliceCATChange: TOnSliceCATChange read FOnSliceCATChange write FOnSliceCATChange; property OnSliceCATChange: TOnSliceCATChange read FOnSliceCATChange write FOnSliceCATChange;
@@ -2497,7 +2498,8 @@ var
Chk: TFlatCheckBox; Chk: TFlatCheckBox;
Ed: TFlatEdit; Ed: TFlatEdit;
Spin: TFlatSpinEdit; Spin: TFlatSpinEdit;
Y: Integer; Lbl: TLabel;
Y, i: Integer;
begin begin
with TLabel.Create(Self) do with TLabel.Create(Self) do
begin begin
@@ -2536,7 +2538,7 @@ begin
FChkRandom := Chk; FChkRandom := Chk;
// ── Web Server ──────────────────────────────────────────────────────────── // ── Web Server ────────────────────────────────────────────────────────────
Grp := MakeGroupPanel(FPageAdvanced, 'Web Server', MARGIN, 230, 500, 252); Grp := MakeGroupPanel(FPageAdvanced, 'Web Server', MARGIN, 230, 500, 318);
Y := R1; Y := R1;
Chk := TFlatCheckBox.Create(Self); Chk := TFlatCheckBox.Create(Self);
@@ -2604,6 +2606,18 @@ begin
Ed.Text := 'hpsdr'; Ed.Text := 'hpsdr';
Ed.OnChange := OnWebAnyChange; Ed.OnChange := OnWebAnyChange;
FEdWebPass := Ed; 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; end;
procedure TSettingsForm.OnADCChkChange(Sender: TObject); procedure TSettingsForm.OnADCChkChange(Sender: TObject);
@@ -2614,15 +2628,21 @@ begin
end; end;
procedure TSettingsForm.OnWebAnyChange(Sender: TObject); procedure TSettingsForm.OnWebAnyChange(Sender: TObject);
var Res: Integer;
begin begin
if FLoading then Exit; if FLoading then Exit;
if Assigned(FOnWebSettingsChange) then if not Assigned(FOnWebSettingsChange) then Exit;
FOnWebSettingsChange( if (FCmbWebRes.ItemIndex >= 0) and (FCmbWebRes.ItemIndex <= High(DISPLAY_RES_VALUES)) then
FChkWebEnabled.Checked, Res := DISPLAY_RES_VALUES[FCmbWebRes.ItemIndex]
FEdWebPort.Value, else
FEdWebBind.Text, Res := DISPLAY_RES_VALUES[0];
FEdWebUser.Text, FOnWebSettingsChange(
FEdWebPass.Text); FChkWebEnabled.Checked,
FEdWebPort.Value,
FEdWebBind.Text,
FEdWebUser.Text,
FEdWebPass.Text,
Res);
end; end;
procedure TSettingsForm.LoadADCSettings(Dither, Random: Boolean); procedure TSettingsForm.LoadADCSettings(Dither, Random: Boolean);
@@ -2637,7 +2657,8 @@ begin
end; end;
procedure TSettingsForm.LoadWebSettings(Enabled: Boolean; Port: Integer; procedure TSettingsForm.LoadWebSettings(Enabled: Boolean; Port: Integer;
const BindAddr, User, Pass: string); const BindAddr, User, Pass: string; SpecPixels: Integer);
var i: Integer;
begin begin
FLoading := True; FLoading := True;
try try
@@ -2646,6 +2667,10 @@ begin
FEdWebBind.Text := BindAddr; FEdWebBind.Text := BindAddr;
FEdWebUser.Text := User; FEdWebUser.Text := User;
FEdWebPass.Text := Pass; 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 finally
FLoading := False; FLoading := False;
end; end;
+14 -2
View File
@@ -468,7 +468,12 @@ var
StatusText, BoardText, IPText, SupplyText: string; StatusText, BoardText, IPText, SupplyText: string;
PLLText, RXText, TXText, SeqText: string; PLLText, RXText, TXText, SeqText: string;
ViewCenter, ViewSpan: Double; ViewCenter, ViewSpan: Double;
WfNew: Boolean;
begin begin
// Пришла ли от DSP новая строка водопада с прошлого пуша. Снимаем ДО пуша:
// без этого сервер повторно отправлял бы прежнюю строку, когда кадров нет
// (RX остановлен) или когда пуш случился чаще кадров — водопад двоил.
WfNew := FController.FWfAccumFresh;
// Видимое окно (центр+span) с учётом зума: пиксели спектра уже зумлены // Видимое окно (центр+span) с учётом зума: пиксели спектра уже зумлены
// анализатором, web-метки/маппинг должны соответствовать. // анализатором, web-метки/маппинг должны соответствовать.
FController.GetViewWindow(ViewCenter, ViewSpan); FController.GetViewWindow(ViewCenter, ViewSpan);
@@ -558,9 +563,13 @@ begin
FController.InQO100, FController.InQO100,
FController.FRxMuteOnTx); FController.FRxMuteOnTx);
// Счётчики — РЕАЛЬНЫЕ: кадр короче потолка (web.spec_pixels), когда
// разрешение анализатора (= ширина панадаптера) меньше него — узкая панель,
// грид из двух колонок. Константа здесь отправляла хвост буфера — нули
// (0 dBm) поверх шкалы и сжатый по частоте спектр.
FServer.PushSpectrum( FServer.PushSpectrum(
FController.FSpectrumBuf, 1024, FController.FSpectrumBuf, FController.FSpectrumBufCount,
FController.FWaterfallBuf, FController.FWaterfallBuf, FController.FWaterfallBufCount, WfNew,
FController.FLastSMeter, FController.FLastSMeter,
FController.FVfoA, FController.FMode, FController.FFilterBW, FController.FAGCMode, FController.FAGCTop, FController.FVfoA, FController.FMode, FController.FFilterBW, FController.FAGCMode, FController.FAGCTop,
ViewSpan, FController.ActiveVolume, ViewSpan, FController.ActiveVolume,
@@ -576,6 +585,9 @@ begin
FController.FLastFwdW, FController.FLastSWR, FController.FPAMaxPower, FController.FLastFwdW, FController.FLastSWR, FController.FPAMaxPower,
StatusText, BoardText, IPText, SupplyText, StatusText, BoardText, IPText, SupplyText,
PLLText, RXText, TXText, SeqText); PLLText, RXText, TXText, SeqText);
// Строка забрана — следующий кадр DSP начинает новое накопление.
if WfNew then FController.FWfAccumFresh := False;
end; end;
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);}}' + '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();}' + '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);' + '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();}' + '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){' + '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(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(!bins?.length){drawMk(sctx,w,h);return;}' +
'if(specSm.length!==bins.length)specSm=new Array(bins.length).fill(-130);' + '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);' + '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.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)");' + '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.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();' + '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);}' + '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(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;}' + '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;' + '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;}' + 'for(let i=0;i<bins.length;i++){wfAvg[i]=wfAvg[i]*.75+bins[i]*.25;}' +
'let peak=-200,nfSum=0,nfCount=0;' + 'let peak=-200,nfSum=0,nfCount=0;' +
'for(let i=0;i<bins.length;i++){if(wfAvg[i]>peak)peak=wfAvg[i];}' + '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;' + '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;' + '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);' + '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);' + '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);' + '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);' + '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();}' + '}catch(e){console.error("startTXMic:",e);cleanupTXMic();}' +
'finally{txMicStarting=false;}}' + '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";' + '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.onopen=()=>{stEl.textContent="connected";initOpus();};' +
'ws.onclose=()=>{cleanupTXMic();stEl.textContent="disconnected...";setTimeout(connect,3000);};' + 'ws.onclose=()=>{cleanupTXMic();stEl.textContent="disconnected...";setTimeout(connect,3000);};' +
'ws.onerror=()=>ws.close();' + 'ws.onerror=()=>ws.close();' +
'ws.onmessage=e=>{if(typeof e.data==="string"){try{applyState(JSON.parse(e.data));}catch(x){}return;}' + '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);' + '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));}};}' + 'else if(t===0x41){if(audioCtx&&audioCtx.state==="suspended")audioCtx.resume();playOpus(new Uint8Array(ab,1));}};}' +
// Оверлей для user gesture (AudioContext требует клика) // Оверлей для user gesture (AudioContext требует клика)
+68 -20
View File
@@ -93,10 +93,13 @@ const
MIC_MAX_PLC = 5; MIC_MAX_PLC = 5;
MAX_WS_CLIENTS = 4; MAX_WS_CLIENTS = 4;
WS_GUID = '258EAFA5-E914-47DA-95CA-C5AB0DC85B11'; 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_SPECTRUM = Byte(Ord('S')); // S + N×Float32 (N = длина фрейма, <= WEB_SPEC_MAX)
WS_MSG_WATERFALL = Byte(Ord('W')); // W + N×Float32 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 = Byte(Ord('A')); // A + Opus bytes
WS_MSG_AUDIO_PCM = Byte(Ord('P')); // P + N×Float32 (mono 48k) WS_MSG_AUDIO_PCM = Byte(Ord('P')); // P + N×Float32 (mono 48k)
WS_MSG_STATE = Byte(Ord('J')); // J + JSON text WS_MSG_STATE = Byte(Ord('J')); // J + JSON text
@@ -240,9 +243,11 @@ type
FBindIP: string; FBindIP: string;
// ── Состояние (обновляется из MainForm) ── // ── Состояние (обновляется из MainForm) ──
FSpectrumBuf: array[0..1023] of Single; FSpectrumBuf: array[0..WEB_SPEC_MAX-1] of Single;
FWfBuf: array[0..1023] of Single; FSpecCount: Integer; // точек в кадре спектра (0 = кадра ещё не было)
FWfCount: Integer; FWfBuf: array[0..WEB_SPEC_MAX-1] of Single;
FWfCount: Integer; // точек в строке водопада
FWfFresh: Boolean; // строка накапливается (max-hold) и ещё не отдана
FSMeter: Double; FSMeter: Double;
FFreq: Double; FFreq: Double;
FMode: Integer; FMode: Integer;
@@ -395,7 +400,7 @@ type
// Вызывается из таймера спектра (UI thread) // Вызывается из таймера спектра (UI thread)
procedure PushSpectrum( procedure PushSpectrum(
const Buf: array of Single; Count: Integer; const Buf: array of Single; Count: Integer;
const WfBuf_: array of Single; const WfBuf_: array of Single; WfCount: Integer; WfNew: Boolean;
SMeter: Double; SMeter: Double;
Freq: Double; Mode, FilterBW, AGCMode, AGCTop: Integer; Freq: Double; Mode, FilterBW, AGCMode, AGCTop: Integer;
SpanHz: Double; Volume: Integer; SpanHz: Double; Volume: Integer;
@@ -1625,9 +1630,9 @@ end;
procedure TWebServer.PushLoop; procedure TWebServer.PushLoop;
var var
Tick, StateLastTick: QWord; Tick, StateLastTick: QWord;
SpecBuf: array[0..4096] of Byte; SpecBuf: array[0..1 + WEB_SPEC_MAX*4] of Byte;
WfBuf: array[0..4096] of Byte; WfBuf: array[0..1 + WEB_SPEC_MAX*4] of Byte;
i: Integer; i, SN, WN: Integer;
HasClients: Boolean; HasClients: Boolean;
begin begin
StateLastTick := GetTickCount64; StateLastTick := GetTickCount64;
@@ -1640,23 +1645,49 @@ begin
HasClients := FClientCount > 0; HasClients := FClientCount > 0;
FClientLock.Leave; FClientLock.Leave;
if not HasClients then Continue; if not HasClients then
begin
// Без клиентов строку никто не забирает — иначе накопитель max-hold рос
// бы всё время простоя и первая строка после подключения была бы
// максимумом за минуты.
FStateLock.Enter;
FWfFresh := False;
FStateLock.Leave;
Continue;
end;
FStateLock.Enter; FStateLock.Enter;
try try
// Длина фрейма = число реально заполненных точек: клиент считает N из
// byteLength и растягивает ровно их на всю ширину канваса.
SN := FSpecCount;
SpecBuf[0] := WS_MSG_SPECTRUM; 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]; PSingle(Pointer(PByte(@SpecBuf[1]) + i*4))^ := FSpectrumBuf[i];
WfBuf[0] := WS_MSG_WATERFALL; // Водопад — ТОЛЬКО если с прошлой отправки пришла новая строка. Иначе в
for i := 0 to 1023 do // демоне (PushState 20 Гц и этот цикл 20 Гц идут вразнобой) на дрейфе фаз
PSingle(Pointer(PByte(@WfBuf[1]) + i*4))^ := FWfBuf[i]; // одна и та же строка уезжала клиенту дважды — водопад «двоил» и полз
// рывками, а на остановленном 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 finally
FStateLock.Leave; FStateLock.Leave;
end; end;
BroadcastBinary(SpecBuf[0], 1 + 1024*4); // Кадр спектра шлём и пустым (SN=0, до первого кадра DSP): клиент
BroadcastBinary(WfBuf[0], 1 + 1024*4); // перерисовывает по нему линейку и оверлеи, а пустой спектр не рисует.
BroadcastBinary(SpecBuf[0], 1 + SN*4);
if WN > 0 then BroadcastBinary(WfBuf[0], 1 + WN*4);
if Tick - StateLastTick >= 200 then if Tick - StateLastTick >= 200 then
begin begin
@@ -1878,7 +1909,7 @@ end;
procedure TWebServer.PushSpectrum( procedure TWebServer.PushSpectrum(
const Buf: array of Single; Count: Integer; const Buf: array of Single; Count: Integer;
const WfBuf_: array of Single; const WfBuf_: array of Single; WfCount: Integer; WfNew: Boolean;
SMeter: Double; SMeter: Double;
Freq: Double; Mode, FilterBW, AGCMode, AGCTop: Integer; Freq: Double; Mode, FilterBW, AGCMode, AGCTop: Integer;
SpanHz: Double; Volume: Integer; SpanHz: Double; Volume: Integer;
@@ -1893,14 +1924,31 @@ procedure TWebServer.PushSpectrum(
FwdW, SWRV, PAMaxPower: Double; FwdW, SWRV, PAMaxPower: Double;
const StatusText, BoardText, IPText, SupplyText, PLLText, const StatusText, BoardText, IPText, SupplyText, PLLText,
RXText, TXText, SeqText: string); RXText, TXText, SeqText: string);
var N, i: Integer; var N, W, i: Integer;
begin begin
if not FRunning then Exit; 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; FStateLock.Enter;
try try
for i := 0 to N-1 do FSpectrumBuf[i] := Buf[i]; 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; FSMeter := SMeter;
FFreq := Freq; FFreq := Freq;
FMode := Mode; FMode := Mode;
+17 -8
View File
@@ -26,12 +26,11 @@ program ewsdrd;
uses uses
{$IFDEF UNIX}cthreads, BaseUnix,{$ENDIF} {$IFDEF UNIX}cthreads, BaseUnix,{$ENDIF}
Classes, SysUtils, Classes, SysUtils, Math,
RadioController, WebServer, WebAdapter, RadioController, WebServer, WebAdapter, WDSPEngine,
HPSDRNetwork, Settings, WDSP, PlatformUtils, DeviceStore, BoardUtils; HPSDRNetwork, Settings, WDSP, PlatformUtils, DeviceStore, BoardUtils;
const const
DAEMON_SPEC_WIDTH = 1024; // ширина спектра для StartRunning (web-буфер = 1024)
PUSH_INTERVAL_MS = 50; // период исходящего web-зеркала (как GUI-таймер) PUSH_INTERVAL_MS = 50; // период исходящего web-зеркала (как GUI-таймер)
type type
@@ -52,6 +51,9 @@ type
procedure OnState(Sender: TObject; Field: TRadioField); procedure OnState(Sender: TObject; Field: TRadioField);
// WDSP открываем лениво и синхронно (нет UI — блокировать главный поток ок). // WDSP открываем лениво и синхронно (нет UI — блокировать главный поток ок).
function EnsureWDSP: Boolean; function EnsureWDSP: Boolean;
// Ширина анализатора в демоне: рендера нет, поэтому её задаёт потолок
// web-кадра (web.spec_pixels) — ровно столько точек и уйдёт клиенту.
function DaemonSpecWidth: Integer;
procedure ConnectTo(const Dev: THPSDRDevice); procedure ConnectTo(const Dev: THPSDRDevice);
procedure PushDeviceList; procedure PushDeviceList;
procedure PushXvtr; procedure PushXvtr;
@@ -148,6 +150,7 @@ begin
// --- Веб-сервер + адаптер --- // --- Веб-сервер + адаптер ---
FCtrl.FSettings.LoadWebSettings(WebCfg); FCtrl.FSettings.LoadWebSettings(WebCfg);
FCtrl.FWebSpecPixels := EnsureRange(WebCfg.SpecPixels, 256, SPECTRUM_PIXELS);
FWeb := TWebServer.Create(WebCfg.User, WebCfg.Pass, Word(WebCfg.Port), WebCfg.BindAddr); FWeb := TWebServer.Create(WebCfg.User, WebCfg.Pass, Word(WebCfg.Port), WebCfg.BindAddr);
FWebAd := TWebAdapter.Create(FCtrl, FWeb, nil); FWebAd := TWebAdapter.Create(FCtrl, FWeb, nil);
// RX-аудио веб-клиенту — через route-хук контроллера (D1). // RX-аудио веб-клиенту — через route-хук контроллера (D1).
@@ -231,10 +234,16 @@ begin
Exit; Exit;
end; end;
// Ширина анализатора спектра. Без UI её никто не выставляет (GUI зовёт // Ширина анализатора спектра. Без UI её никто не выставляет (GUI зовёт
// SetSpectrumWidth(панель) каждый тик), и анализатор отдаёт дефолтные 64 пикселя // SetSpectrumWidth(панель) каждый тик), и анализатор отдал бы дефолтные 64
// → web рисует лишь ~6% ширины, остальное белым. Ставим ровно под web-буфер // пикселя. Ставим ровно под потолок web-кадра (web.spec_pixels) — больше
// (SPECTRUM_PIXELS=1024). FLastSpectrumW переживает пересоздание канала. // демону не нужно, меньше — обрезало бы настройку.
FCtrl.FDSPEngine.SetSpectrumWidth(DAEMON_SPEC_WIDTH); // FLastSpectrumW переживает пересоздание канала.
FCtrl.FDSPEngine.SetSpectrumWidth(DaemonSpecWidth);
end;
function THeadlessHost.DaemonSpecWidth: Integer;
begin
Result := EnsureRange(FCtrl.FWebSpecPixels, 256, SPECTRUM_PIXELS);
end; end;
procedure THeadlessHost.ConnectTo(const Dev: THPSDRDevice); procedure THeadlessHost.ConnectTo(const Dev: THPSDRDevice);
@@ -242,7 +251,7 @@ begin
if FCtrl.FNetwork.Connected then Exit; if FCtrl.FNetwork.Connected then Exit;
if not EnsureWDSP then Exit; if not EnsureWDSP then Exit;
Log('Connecting to ' + Dev.IPAddress + '...'); 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)) Log('Running. Board=' + IntToStr(FCtrl.FNetwork.Device.BoardType))
else else
Log('Connect FAILED: ' + FCtrl.FNetwork.LastError); Log('Connect FAILED: ' + FCtrl.FNetwork.LastError);