mirror of
https://git.vladimir.cc/vladimir/ewsdr.git
synced 2026-08-25 17:27:32 +00:00
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:
+12
-3
@@ -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
@@ -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;
|
||||||
|
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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);
|
||||||
|
|||||||
Reference in New Issue
Block a user