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
+68 -20
View File
@@ -93,10 +93,13 @@ const
MIC_MAX_PLC = 5;
MAX_WS_CLIENTS = 4;
WS_GUID = '258EAFA5-E914-47DA-95CA-C5AB0DC85B11';
// Потолок точек в кадре спектра/водопада (настройка web.spec_pixels режет
// ниже). Совпадает с WDSPEngine.SPECTRUM_PIXELS — больше анализатор не даёт.
WEB_SPEC_MAX = 4096;
// Типы бинарных фреймов (первый байт = тип)
WS_MSG_SPECTRUM = Byte(Ord('S')); // S + 1024×Float32
WS_MSG_WATERFALL = Byte(Ord('W')); // W + N×Float32
WS_MSG_SPECTRUM = Byte(Ord('S')); // S + N×Float32 (N = длина фрейма, <= WEB_SPEC_MAX)
WS_MSG_WATERFALL = Byte(Ord('W')); // W + N×Float32 (N = длина фрейма, <= WEB_SPEC_MAX)
WS_MSG_AUDIO = Byte(Ord('A')); // A + Opus bytes
WS_MSG_AUDIO_PCM = Byte(Ord('P')); // P + N×Float32 (mono 48k)
WS_MSG_STATE = Byte(Ord('J')); // J + JSON text
@@ -240,9 +243,11 @@ type
FBindIP: string;
// ── Состояние (обновляется из MainForm) ──
FSpectrumBuf: array[0..1023] of Single;
FWfBuf: array[0..1023] of Single;
FWfCount: Integer;
FSpectrumBuf: array[0..WEB_SPEC_MAX-1] of Single;
FSpecCount: Integer; // точек в кадре спектра (0 = кадра ещё не было)
FWfBuf: array[0..WEB_SPEC_MAX-1] of Single;
FWfCount: Integer; // точек в строке водопада
FWfFresh: Boolean; // строка накапливается (max-hold) и ещё не отдана
FSMeter: Double;
FFreq: Double;
FMode: Integer;
@@ -395,7 +400,7 @@ type
// Вызывается из таймера спектра (UI thread)
procedure PushSpectrum(
const Buf: array of Single; Count: Integer;
const WfBuf_: array of Single;
const WfBuf_: array of Single; WfCount: Integer; WfNew: Boolean;
SMeter: Double;
Freq: Double; Mode, FilterBW, AGCMode, AGCTop: Integer;
SpanHz: Double; Volume: Integer;
@@ -1625,9 +1630,9 @@ end;
procedure TWebServer.PushLoop;
var
Tick, StateLastTick: QWord;
SpecBuf: array[0..4096] of Byte;
WfBuf: array[0..4096] of Byte;
i: Integer;
SpecBuf: array[0..1 + WEB_SPEC_MAX*4] of Byte;
WfBuf: array[0..1 + WEB_SPEC_MAX*4] of Byte;
i, SN, WN: Integer;
HasClients: Boolean;
begin
StateLastTick := GetTickCount64;
@@ -1640,23 +1645,49 @@ begin
HasClients := FClientCount > 0;
FClientLock.Leave;
if not HasClients then Continue;
if not HasClients then
begin
// Без клиентов строку никто не забирает — иначе накопитель max-hold рос
// бы всё время простоя и первая строка после подключения была бы
// максимумом за минуты.
FStateLock.Enter;
FWfFresh := False;
FStateLock.Leave;
Continue;
end;
FStateLock.Enter;
try
// Длина фрейма = число реально заполненных точек: клиент считает N из
// byteLength и растягивает ровно их на всю ширину канваса.
SN := FSpecCount;
SpecBuf[0] := WS_MSG_SPECTRUM;
for i := 0 to 1023 do
for i := 0 to SN - 1 do
PSingle(Pointer(PByte(@SpecBuf[1]) + i*4))^ := FSpectrumBuf[i];
WfBuf[0] := WS_MSG_WATERFALL;
for i := 0 to 1023 do
PSingle(Pointer(PByte(@WfBuf[1]) + i*4))^ := FWfBuf[i];
// Водопад — ТОЛЬКО если с прошлой отправки пришла новая строка. Иначе в
// демоне (PushState 20 Гц и этот цикл 20 Гц идут вразнобой) на дрейфе фаз
// одна и та же строка уезжала клиенту дважды — водопад «двоил» и полз
// рывками, а на остановленном RX прокручивал застывшую строку вечно.
// Спектр так гейтить нельзя: это живая кривая, её надо перерисовывать.
if FWfFresh then
begin
WN := FWfCount;
WfBuf[0] := WS_MSG_WATERFALL;
for i := 0 to WN - 1 do
PSingle(Pointer(PByte(@WfBuf[1]) + i*4))^ := FWfBuf[i];
FWfFresh := False; // следующий кадр DSP начнёт новое накопление
end
else
WN := 0;
finally
FStateLock.Leave;
end;
BroadcastBinary(SpecBuf[0], 1 + 1024*4);
BroadcastBinary(WfBuf[0], 1 + 1024*4);
// Кадр спектра шлём и пустым (SN=0, до первого кадра DSP): клиент
// перерисовывает по нему линейку и оверлеи, а пустой спектр не рисует.
BroadcastBinary(SpecBuf[0], 1 + SN*4);
if WN > 0 then BroadcastBinary(WfBuf[0], 1 + WN*4);
if Tick - StateLastTick >= 200 then
begin
@@ -1878,7 +1909,7 @@ end;
procedure TWebServer.PushSpectrum(
const Buf: array of Single; Count: Integer;
const WfBuf_: array of Single;
const WfBuf_: array of Single; WfCount: Integer; WfNew: Boolean;
SMeter: Double;
Freq: Double; Mode, FilterBW, AGCMode, AGCTop: Integer;
SpanHz: Double; Volume: Integer;
@@ -1893,14 +1924,31 @@ procedure TWebServer.PushSpectrum(
FwdW, SWRV, PAMaxPower: Double;
const StatusText, BoardText, IPText, SupplyText, PLLText,
RXText, TXText, SeqText: string);
var N, i: Integer;
var N, W, i: Integer;
begin
if not FRunning then Exit;
N := Min(Count, 1024);
N := EnsureRange(Min(Count, Length(Buf)), 0, WEB_SPEC_MAX);
W := EnsureRange(Min(WfCount, Length(WfBuf_)), 0, WEB_SPEC_MAX);
FStateLock.Enter;
try
for i := 0 to N-1 do FSpectrumBuf[i] := Buf[i];
for i := 0 to Min(High(WfBuf_), 1023) do FWfBuf[i] := WfBuf_[i];
FSpecCount := N;
// Водопад: зовут нас чаще, чем PushLoop отдаёт строку (в GUI 60 против 20
// раз/с) — между отправками копим max-hold'ом, иначе кадры терялись бы.
// WfNew=False — новой строки от DSP не было: не трогаем ни накопитель, ни
// флаг, иначе PushLoop отправил бы прежнюю строку повторно.
if WfNew then
begin
if (not FWfFresh) or (W <> FWfCount) then
begin
for i := 0 to W-1 do FWfBuf[i] := WfBuf_[i];
FWfCount := W;
end
else
for i := 0 to W-1 do
if WfBuf_[i] > FWfBuf[i] then FWfBuf[i] := WfBuf_[i];
FWfFresh := True;
end;
FSMeter := SMeter;
FFreq := Freq;
FMode := Mode;