diff --git a/MainForm.pas b/MainForm.pas index 662bf94..9e2be8b 100644 --- a/MainForm.pas +++ b/MainForm.pas @@ -232,6 +232,7 @@ type FWebBindAddr: string; FWebUser: string; FWebPass: string; + FWebSpecPixels: Integer; // потолок точек кадра спектра/водопада для web // --- CAT --- FCATAdapter: TCATAdapter; // адаптер CAT поверх контроллера (движок+транспорты+контекст) FCATLastGlobal: TGlobalSettings; // текущие CAT-настройки (UI/persist; ApplySettings → адаптер) @@ -753,7 +754,7 @@ type procedure ApplyWfAGCNF(WfAGC, WfNF: Boolean); procedure ApplyADCSettings(Dither, Random: Boolean); procedure ApplyWebSettings(Enabled: Boolean; Port: Integer; - const BindAddr, User, Pass: string); + const BindAddr, User, Pass: string; SpecPixels: Integer); procedure ApplyGridParams(RefLevel, Range, GridStep: Double); // Пушит активные grid-параметры (RX или TX в зависимости от FTransmitting) // в FSpecView и сбрасывает кэш сетки. Вызывается при смене RX↔TX и при @@ -1064,6 +1065,8 @@ begin FWebBindAddr := WebCfg.BindAddr; FWebUser := WebCfg.User; FWebPass := WebCfg.Pass; + FWebSpecPixels := EnsureRange(WebCfg.SpecPixels, 256, SPECTRUM_PIXELS); + FController.FWebSpecPixels := FWebSpecPixels; FWebServer := TWebServer.Create(FWebUser, FWebPass, Word(FWebPort), FWebBindAddr); // Вынесенные в TWebAdapter чистые-контроллерные команды (host=nil: их хендлеры // ещё не нужны хосту; host-зависимые WebOnXxx пока на MainForm — переедут в WA2). @@ -5691,7 +5694,7 @@ begin end; procedure TMainForm.ApplyWebSettings(Enabled: Boolean; Port: Integer; - const BindAddr, User, Pass: string); + const BindAddr, User, Pass: string; SpecPixels: Integer); var W: TWebSettings; begin FWebEnabled := Enabled; @@ -5699,6 +5702,10 @@ begin FWebBindAddr := BindAddr; FWebUser := User; FWebPass := Pass; + // Потолок точек кадра для web — применяется сразу, следующий же кадр DSP + // снимается с новым разрешением (переарм анализатора не нужен). + FWebSpecPixels := EnsureRange(SpecPixels, 256, SPECTRUM_PIXELS); + FController.FWebSpecPixels := FWebSpecPixels; FWebServer.Reconfigure(User, Pass, BindAddr, Word(Port)); if Enabled then FWebServer.Start; W.Enabled := Enabled; @@ -5706,6 +5713,7 @@ begin W.BindAddr := BindAddr; W.User := User; W.Pass := Pass; + W.SpecPixels := FWebSpecPixels; FController.FSettings.SaveWebSettings(W); end; @@ -8559,7 +8567,8 @@ begin FController.FDisplayPixels, FController.FWfSpeedDiv); SF.LoadSpecMSAA(FController.FSpecMSAA); SF.LoadADCSettings(FController.FDitherEnabled, FController.FRandomEnabled); - SF.LoadWebSettings(FWebEnabled, FWebPort, FWebBindAddr, FWebUser, FWebPass); + SF.LoadWebSettings(FWebEnabled, FWebPort, FWebBindAddr, FWebUser, FWebPass, + FWebSpecPixels); SF.LoadFPS(FDisplayFPS); SF.LoadLightTheme(FLightTheme); SF.LoadFreqMhzDigits(FFreqMhzDigits); diff --git a/RadioController.pas b/RadioController.pas index ce6d25c..ef84284 100644 --- a/RadioController.pas +++ b/RadioController.pas @@ -229,10 +229,17 @@ type // Снимок для исходящего web-зеркала (PushState читает напрямую). Пишется из // DSP-потока в OnSpectrumReady/OnWaterfallReady; читается из GUI-таймера — // та же толерантная гонка, что и раньше (косметика пикселей). - FSpectrumBuf: array[0..1023] of Single; - FWaterfallBuf: array[0..1023] of Single; + FSpectrumBuf: array[0..SPECTRUM_PIXELS-1] of Single; + FWaterfallBuf: array[0..SPECTRUM_PIXELS-1] of Single; FSpectrumBufCount: Integer; FWaterfallBufCount: Integer; + // Потолок точек кадра для web (настройка web.spec_pixels, 1024/2048/4096). + // Кадр анализатора шире — децимируем до него; уже — уходит как есть. + FWebSpecPixels: Integer; + // Водопад копится max-hold'ом до тех пор, пока PushState не заберёт строку + // (анализатор даёт до 60 кадров/с, web шлёт 20 строк/с). Сбрасывает флаг + // тот, кто забрал строку, — тогда следующий кадр начинает новую. + FWfAccumFresh: Boolean; // ---- Движки (владение: см. шапку юнита) ---- // FNetwork — абстрактный бэкенд железа (TRadioBackend): конкретный класс @@ -1041,7 +1048,10 @@ begin ResetPanDDCMaps; // паны: все слоты свободны FZoomFactor := 0.0; FPanSlider := 0.5; // 0.0 = весь span (без зума) FCurrentBand := 5; FCurrentXvtr := -1; - FSpectrumBufCount := 1024; FWaterfallBufCount := 1024; + // Кадра ещё не было — 0 точек. Ненулевой счётчик заставил бы web рисовать + // нулевой (= 0 dBm, полная шкала) буфер белой полосой. + FSpectrumBufCount := 0; FWaterfallBufCount := 0; FWfAccumFresh := False; + FWebSpecPixels := 1024; // дефолт web.spec_pixels; хост переопределит из настроек FPAMaxPower := 100.0; FPlutoTxMaxAttDb := -10.0; // ADI-дефолт: линейный режим выходных каскадов FWfAGCEnabled := True; FDitherEnabled := True; FRandomEnabled := True; @@ -2650,32 +2660,45 @@ begin SetMOX(True); end; -// Снимок кадра для web-зеркала: протокол фиксирован на 1024 точки, поэтому -// при большем разрешении анализатора децимируем max-детектором (не обрезаем). +// Снимок кадра для web-зеркала: потолок точек задаёт настройка web.spec_pixels +// (Limit), при большем разрешении анализатора децимируем max-детектором +// (не обрезаем). +// DstCount — сколько точек РЕАЛЬНО заполнено; отправлять больше нельзя, иначе +// хвост буфера (нули = 0 dBm) уедет в эфир белой полосой, а частотная шкала +// клиента растянется на несуществующие точки. +// +// Группы — строго одинаковой ширины (целочисленный фактор). Прежнее деление +// «i*Count div N» давало на нецелом отношении соседние группы разной ширины +// (1 и 2 точки), а смещение max-детектора зависит от числа точек в группе — +// по спектру шла гребёнка ±3 дБ, в водопаде видимая как вертикальная рябь. procedure SnapshotPixels(const Pixels: array of Single; Count: Integer; - var Dst: array of Single; out DstCount: Integer); + var Dst: array of Single; Limit: Integer; out DstCount: Integer); var - i, j, S0, S1, N: Integer; + i, j, S0, S1, N, K, M: Integer; V: Single; begin - N := Length(Dst); + N := EnsureRange(Limit, 64, Length(Dst)); if Count > Length(Pixels) then Count := Length(Pixels); + if Count < 0 then Count := 0; if Count <= N then begin for i := 0 to Count - 1 do Dst[i] := Pixels[i]; DstCount := Count; Exit; end; - for i := 0 to N - 1 do + K := (Count + N - 1) div N; // точек анализатора на точку web + M := (Count + K - 1) div K; // столько точек получится (<= N) + for i := 0 to M - 1 do begin - S0 := Int64(i) * Count div N; - S1 := (Int64(i) + 1) * Count div N - 1; + S0 := i * K; + S1 := S0 + K - 1; + if S1 > Count - 1 then S1 := Count - 1; // хвостовая группа короче V := Pixels[S0]; for j := S0 + 1 to S1 do if Pixels[j] > V then V := Pixels[j]; Dst[i] := V; end; - DstCount := N; + DstCount := M; end; procedure TRadioController.OnSpectrumReady(const Pixels: array of Single; @@ -2683,7 +2706,7 @@ procedure TRadioController.OnSpectrumReady(const Pixels: array of Single; // DSP-поток: сохраняем последний кадр для web-зеркала и отдаём сырьё наружу // (UI рисует FSpecView). Децимации у спектра нет. begin - SnapshotPixels(Pixels, Count, FSpectrumBuf, FSpectrumBufCount); + SnapshotPixels(Pixels, Count, FSpectrumBuf, FWebSpecPixels, FSpectrumBufCount); if Assigned(FOnSpectrumData) then FOnSpectrumData(Pixels, Count); end; @@ -2691,8 +2714,25 @@ procedure TRadioController.OnWaterfallReady(const Pixels: array of Single; Count: Integer); // DSP-поток: web-зеркало получает КАЖДЫЙ кадр; децимация рендера водопада — // забота фронтенда (OnWaterfallData). +// +// Web шлёт 20 строк/с, анализатор даёт до 60 кадров/с — просто «последний +// кадр» терял бы 2 из 3 (короткая посылка CW/FT8 могла не попасть ни в одну +// строку). Копим кадры max-hold'ом до тех пор, пока PushState не заберёт +// строку (идиома TWaterfallView.SetWaterfallData при WfFrameInterval>1). +var + Tmp: array[0..SPECTRUM_PIXELS-1] of Single; + N, i: Integer; begin - SnapshotPixels(Pixels, Count, FWaterfallBuf, FWaterfallBufCount); + SnapshotPixels(Pixels, Count, Tmp, FWebSpecPixels, N); + if (not FWfAccumFresh) or (N <> FWaterfallBufCount) then + begin + for i := 0 to N - 1 do FWaterfallBuf[i] := Tmp[i]; + FWaterfallBufCount := N; + end + else + for i := 0 to N - 1 do + if Tmp[i] > FWaterfallBuf[i] then FWaterfallBuf[i] := Tmp[i]; + FWfAccumFresh := True; if Assigned(FOnWaterfallData) then FOnWaterfallData(Pixels, Count); end; diff --git a/Settings.pas b/Settings.pas index 4cc721a..4fa70b7 100644 --- a/Settings.pas +++ b/Settings.pas @@ -265,6 +265,11 @@ type BindAddr: string; // e.g. '0.0.0.0' (все интерфейсы) User: string; Pass: string; + // Потолок точек в кадре спектра/водопада для веб-клиента (1024/2048/4096). + // Реально уходит min(этот потолок, разрешение анализатора = ширина + // панадаптера). Трафик линеен: 4096 точек — это ~650 КБ/с на клиента + // (два потока по 20 кадров/с), 1024 — ~160 КБ/с. + SpecPixels: Integer; end; // Transverter (XVTR) entry — один трансвертер. @@ -1994,6 +1999,7 @@ begin W.BindAddr := '0.0.0.0'; W.User := 'admin'; W.Pass := 'hpsdr'; + W.SpecPixels := 1024; // экономный дефолт: столько же, сколько слал протокол раньше end; procedure TSettingsManager.LoadWebSettings(out W: TWebSettings); @@ -2007,6 +2013,7 @@ begin W.BindAddr := JS(O, 'bind_addr', '0.0.0.0'); W.User := JS(O, 'user', 'admin'); W.Pass := JS(O, 'pass', 'hpsdr'); + W.SpecPixels := EnsureRange(JI(O, 'spec_pixels', 1024), 256, 4096); end; procedure TSettingsManager.SaveWebSettings(const W: TWebSettings); @@ -2018,6 +2025,7 @@ begin JWS(O, 'bind_addr', W.BindAddr); JWS(O, 'user', W.User); JWS(O, 'pass', W.Pass); + JW(O, 'spec_pixels', W.SpecPixels); Save; end; diff --git a/SettingsForm.pas b/SettingsForm.pas index 44ff617..c5308c4 100644 --- a/SettingsForm.pas +++ b/SettingsForm.pas @@ -66,7 +66,7 @@ type TOnWfAGCNFChange = procedure(WfAGC, WfNF: Boolean) of object; TOnADCChange = procedure(Dither, Random: Boolean) of object; TOnWebSettingsChange = procedure(Enabled: Boolean; Port: Integer; - const BindAddr, User, Pass: string) of object; + const BindAddr, User, Pass: string; SpecPixels: Integer) of object; TOnCATChange = procedure( const SerEnabled: array of Boolean; const SerPort: array of string; @@ -382,6 +382,7 @@ type FEdWebBind: TFlatEdit; FEdWebUser: TFlatEdit; FEdWebPass: TFlatEdit; + FCmbWebRes: TFlatComboBox; // ---- Close button ---- FBtnClose: TFlatButton; @@ -581,7 +582,7 @@ type procedure LoadSpecMSAA(Samples: Integer); procedure LoadADCSettings(Dither, Random: Boolean); procedure LoadWebSettings(Enabled: Boolean; Port: Integer; - const BindAddr, User, Pass: string); + const BindAddr, User, Pass: string; SpecPixels: Integer); property OnCATChange: TOnCATChange read FOnCATChange write FOnCATChange; property OnSliceCATChange: TOnSliceCATChange read FOnSliceCATChange write FOnSliceCATChange; @@ -2497,7 +2498,8 @@ var Chk: TFlatCheckBox; Ed: TFlatEdit; Spin: TFlatSpinEdit; - Y: Integer; + Lbl: TLabel; + Y, i: Integer; begin with TLabel.Create(Self) do begin @@ -2536,7 +2538,7 @@ begin FChkRandom := Chk; // ── Web Server ──────────────────────────────────────────────────────────── - Grp := MakeGroupPanel(FPageAdvanced, 'Web Server', MARGIN, 230, 500, 252); + Grp := MakeGroupPanel(FPageAdvanced, 'Web Server', MARGIN, 230, 500, 318); Y := R1; Chk := TFlatCheckBox.Create(Self); @@ -2604,6 +2606,18 @@ begin Ed.Text := 'hpsdr'; Ed.OnChange := OnWebAnyChange; FEdWebPass := Ed; + + // Потолок точек в кадре спектра/водопада, уходящем веб-клиенту. Реально + // отправляется min(этот потолок, разрешение анализатора = ширина + // панадаптера); выше — децимация max-детектором. + Y := Y + ROW_H; + MakeLbl(Grp, 'Spectrum:', GRP_PAD, Y + 4, LBL_W); + FCmbWebRes := MakeCombo(Grp, GRP_PAD + LBL_W + 12, Y, 130, OnWebAnyChange); + for i := 0 to High(DISPLAY_RES_NAMES) do FCmbWebRes.Items.Add(DISPLAY_RES_NAMES[i]); + FCmbWebRes.ItemIndex := 0; + Lbl := MakeLbl(Grp, '1024 px ~160 KB/s, 4096 px ~650 KB/s per client', + GRP_PAD + LBL_W + 12, Y + ROW_H - 8, 320); + Lbl.Font.Size := 8; end; procedure TSettingsForm.OnADCChkChange(Sender: TObject); @@ -2614,15 +2628,21 @@ begin end; procedure TSettingsForm.OnWebAnyChange(Sender: TObject); +var Res: Integer; begin if FLoading then Exit; - if Assigned(FOnWebSettingsChange) then - FOnWebSettingsChange( - FChkWebEnabled.Checked, - FEdWebPort.Value, - FEdWebBind.Text, - FEdWebUser.Text, - FEdWebPass.Text); + if not Assigned(FOnWebSettingsChange) then Exit; + if (FCmbWebRes.ItemIndex >= 0) and (FCmbWebRes.ItemIndex <= High(DISPLAY_RES_VALUES)) then + Res := DISPLAY_RES_VALUES[FCmbWebRes.ItemIndex] + else + Res := DISPLAY_RES_VALUES[0]; + FOnWebSettingsChange( + FChkWebEnabled.Checked, + FEdWebPort.Value, + FEdWebBind.Text, + FEdWebUser.Text, + FEdWebPass.Text, + Res); end; procedure TSettingsForm.LoadADCSettings(Dither, Random: Boolean); @@ -2637,7 +2657,8 @@ begin end; procedure TSettingsForm.LoadWebSettings(Enabled: Boolean; Port: Integer; - const BindAddr, User, Pass: string); + const BindAddr, User, Pass: string; SpecPixels: Integer); +var i: Integer; begin FLoading := True; try @@ -2646,6 +2667,10 @@ begin FEdWebBind.Text := BindAddr; FEdWebUser.Text := User; FEdWebPass.Text := Pass; + FCmbWebRes.ItemIndex := 0; + for i := 0 to High(DISPLAY_RES_VALUES) do + if DISPLAY_RES_VALUES[i] = SpecPixels then + begin FCmbWebRes.ItemIndex := i; Break; end; finally FLoading := False; end; diff --git a/WebAdapter.pas b/WebAdapter.pas index d92b5ce..93680f6 100644 --- a/WebAdapter.pas +++ b/WebAdapter.pas @@ -468,7 +468,12 @@ var StatusText, BoardText, IPText, SupplyText: string; PLLText, RXText, TXText, SeqText: string; ViewCenter, ViewSpan: Double; + WfNew: Boolean; begin + // Пришла ли от DSP новая строка водопада с прошлого пуша. Снимаем ДО пуша: + // без этого сервер повторно отправлял бы прежнюю строку, когда кадров нет + // (RX остановлен) или когда пуш случился чаще кадров — водопад двоил. + WfNew := FController.FWfAccumFresh; // Видимое окно (центр+span) с учётом зума: пиксели спектра уже зумлены // анализатором, web-метки/маппинг должны соответствовать. FController.GetViewWindow(ViewCenter, ViewSpan); @@ -558,9 +563,13 @@ begin FController.InQO100, FController.FRxMuteOnTx); + // Счётчики — РЕАЛЬНЫЕ: кадр короче потолка (web.spec_pixels), когда + // разрешение анализатора (= ширина панадаптера) меньше него — узкая панель, + // грид из двух колонок. Константа здесь отправляла хвост буфера — нули + // (0 dBm) поверх шкалы и сжатый по частоте спектр. FServer.PushSpectrum( - FController.FSpectrumBuf, 1024, - FController.FWaterfallBuf, + FController.FSpectrumBuf, FController.FSpectrumBufCount, + FController.FWaterfallBuf, FController.FWaterfallBufCount, WfNew, FController.FLastSMeter, FController.FVfoA, FController.FMode, FController.FFilterBW, FController.FAGCMode, FController.FAGCTop, ViewSpan, FController.ActiveVolume, @@ -576,6 +585,9 @@ begin FController.FLastFwdW, FController.FLastSWR, FController.FPAMaxPower, StatusText, BoardText, IPText, SupplyText, PLLText, RXText, TXText, SeqText); + + // Строка забрана — следующий кадр DSP начинает новое накопление. + if WfNew then FController.FWfAccumFresh := False; end; end. diff --git a/WebPageHtml.pas b/WebPageHtml.pas index 77b49b7..71ef9fb 100644 --- a/WebPageHtml.pas +++ b/WebPageHtml.pas @@ -833,6 +833,17 @@ begin 'else{for(let i=0;i<=8;i++){const x=i*w/8,f=st+i*localSpan/8;rctx.strokeStyle="#2a3a2e";rctx.lineWidth=1;rctx.beginPath();rctx.moveTo(x+.5,0);rctx.lineTo(x+.5,7);rctx.stroke();rctx.fillStyle="#7a9a80";rctx.font="10px monospace";const t=(f/1e6).toFixed(3);rctx.fillText(t,x-14,21);}}' + 'const vxr=hzToX(activeVfoHz(),w);rctx.strokeStyle="#60d070";rctx.lineWidth=1;rctx.beginPath();rctx.moveTo(vxr+.5,0);rctx.lineTo(vxr+.5,h);rctx.stroke();}' + + // Значение бина для пикселя x канваса шириной w. Бинов больше пикселей — + // берём МАКСИМУМ по диапазону (выбор «ближайшего» выбрасывал остальные: + // узкие сигналы мерцали и пропадали на узком экране). Бинов меньше — + // линейная интерполяция, иначе ступеньки. + 'function binPick(a,x,w){const n=a.length;if(n<1)return -140;' + + 'let i0=Math.floor(x*n/w),i1=Math.floor((x+1)*n/w)-1;' + + 'if(i0<0)i0=0;if(i0>n-1)i0=n-1;if(i1>n-1)i1=n-1;' + + 'if(i1v)v=a[i];return v;}' + + 'function drawSpec(bins){const w=specCv.clientWidth,h=specCv.clientHeight;sctx.fillStyle="#050a05";sctx.fillRect(0,0,w,h);' + 'for(let i=1;i<5;i++){const y=i*h/5;sctx.strokeStyle="#121e12";sctx.lineWidth=1;sctx.beginPath();sctx.moveTo(0,y);sctx.lineTo(w,y);sctx.stroke();}' + 'if((cur.mode===M_FM||cur.mode===M_DMR||cur.mode===M_FMRAW)&&FM_STEP_HZ[fmStepIdx]>0){' + @@ -844,12 +855,16 @@ begin 'if(x<0||x>=w)continue;sctx.strokeStyle="#121e12";sctx.lineWidth=1;sctx.beginPath();sctx.moveTo(x+.5,0);sctx.lineTo(x+.5,h);sctx.stroke();}}' + 'if(!bins?.length){drawMk(sctx,w,h);return;}' + 'if(specSm.length!==bins.length)specSm=new Array(bins.length).fill(-130);' + + // Сглаживание — отдельным проходом по ВСЕМ бинам: раньше оно шло внутри + // цикла по пикселям, поэтому при узком канвасе часть бинов не обновлялась + // вовсе, а при широком одни и те же обновлялись по нескольку раз за кадр. + 'for(let i=0;ipeak)peak=wfAvg[i];}' + @@ -867,7 +886,7 @@ begin 'let hHi=wfHi,hLo=wfLo;if(hHi0)hHi=0;if(hLo<-160)hLo=-160;wfHi=hHi;wfLo=hLo;' + 'wfBCtx.drawImage(wfBuf,0,0,w,h-1,0,1,w,h-1);const row=wfBCtx.createImageData(w,1);' + - 'for(let x=0;x>2;if(n<1)return[];const tmp=new Uint8Array(ab,1),al=new ArrayBuffer(n*4);' + + 'new Uint8Array(al).set(tmp);return Array.from(new Float32Array(al));}' + + 'function connect(){const p=location.protocol==="https:"?"wss":"ws";ws=new WebSocket(p+"://"+location.host+"/ws");ws.binaryType="arraybuffer";' + 'ws.onopen=()=>{stEl.textContent="connected";initOpus();};' + 'ws.onclose=()=>{cleanupTXMic();stEl.textContent="disconnected...";setTimeout(connect,3000);};' + 'ws.onerror=()=>ws.close();' + 'ws.onmessage=e=>{if(typeof e.data==="string"){try{applyState(JSON.parse(e.data));}catch(x){}return;}' + 'const ab=e.data,t=(new DataView(ab)).getUint8(0);' + - 'if(t===0x53){const n=(ab.byteLength-1)>>2;const tmp=new Uint8Array(ab,1);const al=new ArrayBuffer(n*4);new Uint8Array(al).set(tmp);const b=Array.from(new Float32Array(al));drawSpec(b);drawWf(b);drawRuler();}' + + // Число точек берём из длины фрейма — сервер шлёт ровно столько, сколько + // выдал анализатор (<=1024). Копия нужна: срез с offset 1 не выровнен под + // Float32Array. + 'if(t===0x53){drawSpec(f32(ab));drawRuler();}' + + // 0x57 = строка водопада. Раньше не разбиралась вовсе — водопад рисовался + // из кадра спектра, а свой поток (со своим детектором и усреднением + // wf_avg_time_ms) уходил в никуда, занимая половину трафика. + 'else if(t===0x57){drawWf(f32(ab));}' + 'else if(t===0x41){if(audioCtx&&audioCtx.state==="suspended")audioCtx.resume();playOpus(new Uint8Array(ab,1));}};}' + // Оверлей для user gesture (AudioContext требует клика) diff --git a/WebServer.pas b/WebServer.pas index ba8e098..d51d98a 100644 --- a/WebServer.pas +++ b/WebServer.pas @@ -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; diff --git a/ewsdrd.lpr b/ewsdrd.lpr index 7049ac8..3ffb099 100644 --- a/ewsdrd.lpr +++ b/ewsdrd.lpr @@ -26,12 +26,11 @@ program ewsdrd; uses {$IFDEF UNIX}cthreads, BaseUnix,{$ENDIF} - Classes, SysUtils, - RadioController, WebServer, WebAdapter, + Classes, SysUtils, Math, + RadioController, WebServer, WebAdapter, WDSPEngine, HPSDRNetwork, Settings, WDSP, PlatformUtils, DeviceStore, BoardUtils; const - DAEMON_SPEC_WIDTH = 1024; // ширина спектра для StartRunning (web-буфер = 1024) PUSH_INTERVAL_MS = 50; // период исходящего web-зеркала (как GUI-таймер) type @@ -52,6 +51,9 @@ type procedure OnState(Sender: TObject; Field: TRadioField); // WDSP открываем лениво и синхронно (нет UI — блокировать главный поток ок). function EnsureWDSP: Boolean; + // Ширина анализатора в демоне: рендера нет, поэтому её задаёт потолок + // web-кадра (web.spec_pixels) — ровно столько точек и уйдёт клиенту. + function DaemonSpecWidth: Integer; procedure ConnectTo(const Dev: THPSDRDevice); procedure PushDeviceList; procedure PushXvtr; @@ -148,6 +150,7 @@ begin // --- Веб-сервер + адаптер --- FCtrl.FSettings.LoadWebSettings(WebCfg); + FCtrl.FWebSpecPixels := EnsureRange(WebCfg.SpecPixels, 256, SPECTRUM_PIXELS); FWeb := TWebServer.Create(WebCfg.User, WebCfg.Pass, Word(WebCfg.Port), WebCfg.BindAddr); FWebAd := TWebAdapter.Create(FCtrl, FWeb, nil); // RX-аудио веб-клиенту — через route-хук контроллера (D1). @@ -231,10 +234,16 @@ begin Exit; end; // Ширина анализатора спектра. Без UI её никто не выставляет (GUI зовёт - // SetSpectrumWidth(панель) каждый тик), и анализатор отдаёт дефолтные 64 пикселя - // → web рисует лишь ~6% ширины, остальное белым. Ставим ровно под web-буфер - // (SPECTRUM_PIXELS=1024). FLastSpectrumW переживает пересоздание канала. - FCtrl.FDSPEngine.SetSpectrumWidth(DAEMON_SPEC_WIDTH); + // SetSpectrumWidth(панель) каждый тик), и анализатор отдал бы дефолтные 64 + // пикселя. Ставим ровно под потолок web-кадра (web.spec_pixels) — больше + // демону не нужно, меньше — обрезало бы настройку. + // FLastSpectrumW переживает пересоздание канала. + FCtrl.FDSPEngine.SetSpectrumWidth(DaemonSpecWidth); +end; + +function THeadlessHost.DaemonSpecWidth: Integer; +begin + Result := EnsureRange(FCtrl.FWebSpecPixels, 256, SPECTRUM_PIXELS); end; procedure THeadlessHost.ConnectTo(const Dev: THPSDRDevice); @@ -242,7 +251,7 @@ begin if FCtrl.FNetwork.Connected then Exit; if not EnsureWDSP then Exit; Log('Connecting to ' + Dev.IPAddress + '...'); - if FCtrl.ConnectAndRun(Dev, DAEMON_SPEC_WIDTH) then + if FCtrl.ConnectAndRun(Dev, DaemonSpecWidth) then Log('Running. Board=' + IntToStr(FCtrl.FNetwork.Device.BoardType)) else Log('Connect FAILED: ' + FCtrl.FNetwork.LastError);