mirror of
https://git.vladimir.cc/vladimir/ewsdr.git
synced 2026-08-25 20:37:33 +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:
+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);}}' +
|
||||
'const vxr=hzToX(activeVfoHz(),w);rctx.strokeStyle="#60d070";rctx.lineWidth=1;rctx.beginPath();rctx.moveTo(vxr+.5,0);rctx.lineTo(vxr+.5,h);rctx.stroke();}' +
|
||||
|
||||
// Значение бина для пикселя x канваса шириной w. Бинов больше пикселей —
|
||||
// берём МАКСИМУМ по диапазону (выбор «ближайшего» выбрасывал остальные:
|
||||
// узкие сигналы мерцали и пропадали на узком экране). Бинов меньше —
|
||||
// линейная интерполяция, иначе ступеньки.
|
||||
'function binPick(a,x,w){const n=a.length;if(n<1)return -140;' +
|
||||
'let i0=Math.floor(x*n/w),i1=Math.floor((x+1)*n/w)-1;' +
|
||||
'if(i0<0)i0=0;if(i0>n-1)i0=n-1;if(i1>n-1)i1=n-1;' +
|
||||
'if(i1<i0){const p=(x+0.5)*n/w-0.5,j=Math.floor(p),t=p-j,' +
|
||||
'v0=a[cl(j,0,n-1)],v1=a[cl(j+1,0,n-1)];return v0+(v1-v0)*t;}' +
|
||||
'let v=a[i0];for(let i=i0+1;i<=i1;i++)if(a[i]>v)v=a[i];return v;}' +
|
||||
|
||||
'function drawSpec(bins){const w=specCv.clientWidth,h=specCv.clientHeight;sctx.fillStyle="#050a05";sctx.fillRect(0,0,w,h);' +
|
||||
'for(let i=1;i<5;i++){const y=i*h/5;sctx.strokeStyle="#121e12";sctx.lineWidth=1;sctx.beginPath();sctx.moveTo(0,y);sctx.lineTo(w,y);sctx.stroke();}' +
|
||||
'if((cur.mode===M_FM||cur.mode===M_DMR||cur.mode===M_FMRAW)&&FM_STEP_HZ[fmStepIdx]>0){' +
|
||||
@@ -844,12 +855,16 @@ begin
|
||||
'if(x<0||x>=w)continue;sctx.strokeStyle="#121e12";sctx.lineWidth=1;sctx.beginPath();sctx.moveTo(x+.5,0);sctx.lineTo(x+.5,h);sctx.stroke();}}' +
|
||||
'if(!bins?.length){drawMk(sctx,w,h);return;}' +
|
||||
'if(specSm.length!==bins.length)specSm=new Array(bins.length).fill(-130);' +
|
||||
// Сглаживание — отдельным проходом по ВСЕМ бинам: раньше оно шло внутри
|
||||
// цикла по пикселям, поэтому при узком канвасе часть бинов не обновлялась
|
||||
// вовсе, а при широком одни и те же обновлялись по нескольку раз за кадр.
|
||||
'for(let i=0;i<bins.length;i++)specSm[i]=specSm[i]*.70+bins[i]*.30;' +
|
||||
'const avf=activeVfoHz();const[fl,fh]=fltLH(),x1=hzToX(avf+fl,w),x2=hzToX(avf+fh,w);' +
|
||||
'sctx.fillStyle=(cur.transmitting||cur.tuning)?"rgba(255,60,60,.18)":"rgba(80,150,255,.12)";sctx.fillRect(Math.min(x1,x2),0,Math.abs(x2-x1),h);' +
|
||||
'sctx.beginPath();for(let x=0;x<w;x++){const i=Math.floor(x*(bins.length-1)/Math.max(1,w-1));specSm[i]=specSm[i]*.70+bins[i]*.30;const y=cl(((-specSm[i]-20)/120)*h,0,h-1);x===0?sctx.moveTo(0,y):sctx.lineTo(x,y);}' +
|
||||
'sctx.beginPath();for(let x=0;x<w;x++){const y=cl(((-binPick(specSm,x,w)-20)/120)*h,0,h-1);x===0?sctx.moveTo(0,y):sctx.lineTo(x,y);}' +
|
||||
'const g=sctx.createLinearGradient(0,0,0,h);g.addColorStop(0,"rgba(60,230,60,.2)");g.addColorStop(1,"rgba(60,230,60,.01)");' +
|
||||
'sctx.lineTo(w,h);sctx.lineTo(0,h);sctx.closePath();sctx.fillStyle=g;sctx.fill();' +
|
||||
'sctx.beginPath();for(let x=0;x<w;x++){const i=Math.floor(x*(bins.length-1)/Math.max(1,w-1));const y=cl(((-specSm[i]-20)/120)*h,0,h-1);x===0?sctx.moveTo(0,y):sctx.lineTo(x,y);}' +
|
||||
'sctx.beginPath();for(let x=0;x<w;x++){const y=cl(((-binPick(specSm,x,w)-20)/120)*h,0,h-1);x===0?sctx.moveTo(0,y):sctx.lineTo(x,y);}' +
|
||||
'sctx.strokeStyle="#3ddd3d";sctx.lineWidth=1.2;sctx.stroke();' +
|
||||
'const vx=hzToX(avf,w);sctx.strokeStyle="#60ff80";sctx.lineWidth=1;sctx.beginPath();sctx.moveTo(vx,0);sctx.lineTo(vx,h);sctx.stroke();drawBcn(sctx,w,h);drawMk(sctx,w,h);}' +
|
||||
|
||||
@@ -858,6 +873,10 @@ begin
|
||||
'if(wfBuf.width!==w||wfBuf.height!==h){wfBuf.width=w;wfBuf.height=h;wfBCtx.fillStyle="#000";wfBCtx.fillRect(0,0,w,h);}' +
|
||||
'if(wfAvg.length!==bins.length){wfAvg=new Array(bins.length).fill(-120);wfHi=-50;wfLo=-120;}' +
|
||||
'const useAgc=cur.wf_agc!==false;const useNf=cur.wf_nf!==false;' +
|
||||
// wfAvg — только для слежения за уровнями (hi/lo). Саму строку рисуем из
|
||||
// сырых бинов: IIR по времени размазывал водопад по вертикали (постоянная
|
||||
// ~200 мс), короткие посылки выцветали. Усреднение по времени уже сделал
|
||||
// анализатор WDSP (wf_avg_time_ms).
|
||||
'for(let i=0;i<bins.length;i++){wfAvg[i]=wfAvg[i]*.75+bins[i]*.25;}' +
|
||||
'let peak=-200,nfSum=0,nfCount=0;' +
|
||||
'for(let i=0;i<bins.length;i++){if(wfAvg[i]>peak)peak=wfAvg[i];}' +
|
||||
@@ -867,7 +886,7 @@ begin
|
||||
'let hHi=wfHi,hLo=wfLo;if(hHi<hLo+15)hHi=hLo+15;' +
|
||||
'if(hHi>0)hHi=0;if(hLo<-160)hLo=-160;wfHi=hHi;wfLo=hLo;' +
|
||||
'wfBCtx.drawImage(wfBuf,0,0,w,h-1,0,1,w,h-1);const row=wfBCtx.createImageData(w,1);' +
|
||||
'for(let x=0;x<w;x++){const i=Math.floor(x*(wfAvg.length-1)/Math.max(1,w-1));const[r,g,b]=pal(wfAvg[i]);const o=x*4;row.data[o]=r;row.data[o+1]=g;row.data[o+2]=b;row.data[o+3]=255;}' +
|
||||
'for(let x=0;x<w;x++){const[r,g,b]=pal(binPick(bins,x,w));const o=x*4;row.data[o]=r;row.data[o+1]=g;row.data[o+2]=b;row.data[o+3]=255;}' +
|
||||
'wfBCtx.putImageData(row,0,0);wctx.drawImage(wfBuf,0,0,w,h);' +
|
||||
'const avf2=activeVfoHz();const[fl2,fh2]=fltLH(),x1w=hzToX(avf2+fl2,w),x2w=hzToX(avf2+fh2,w);' +
|
||||
'wctx.fillStyle=(cur.transmitting||cur.tuning)?"rgba(255,60,60,.15)":"rgba(220,230,230,.10)";wctx.fillRect(Math.min(x1w,x2w),0,Math.abs(x2w-x1w),h);' +
|
||||
@@ -1051,13 +1070,23 @@ begin
|
||||
'}catch(e){console.error("startTXMic:",e);cleanupTXMic();}' +
|
||||
'finally{txMicStarting=false;}}' +
|
||||
|
||||
'function f32(ab){const n=(ab.byteLength-1)>>2;if(n<1)return[];const tmp=new Uint8Array(ab,1),al=new ArrayBuffer(n*4);' +
|
||||
'new Uint8Array(al).set(tmp);return Array.from(new Float32Array(al));}' +
|
||||
|
||||
'function connect(){const p=location.protocol==="https:"?"wss":"ws";ws=new WebSocket(p+"://"+location.host+"/ws");ws.binaryType="arraybuffer";' +
|
||||
'ws.onopen=()=>{stEl.textContent="connected";initOpus();};' +
|
||||
'ws.onclose=()=>{cleanupTXMic();stEl.textContent="disconnected...";setTimeout(connect,3000);};' +
|
||||
'ws.onerror=()=>ws.close();' +
|
||||
'ws.onmessage=e=>{if(typeof e.data==="string"){try{applyState(JSON.parse(e.data));}catch(x){}return;}' +
|
||||
'const ab=e.data,t=(new DataView(ab)).getUint8(0);' +
|
||||
'if(t===0x53){const n=(ab.byteLength-1)>>2;const tmp=new Uint8Array(ab,1);const al=new ArrayBuffer(n*4);new Uint8Array(al).set(tmp);const b=Array.from(new Float32Array(al));drawSpec(b);drawWf(b);drawRuler();}' +
|
||||
// Число точек берём из длины фрейма — сервер шлёт ровно столько, сколько
|
||||
// выдал анализатор (<=1024). Копия нужна: срез с offset 1 не выровнен под
|
||||
// Float32Array.
|
||||
'if(t===0x53){drawSpec(f32(ab));drawRuler();}' +
|
||||
// 0x57 = строка водопада. Раньше не разбиралась вовсе — водопад рисовался
|
||||
// из кадра спектра, а свой поток (со своим детектором и усреднением
|
||||
// wf_avg_time_ms) уходил в никуда, занимая половину трафика.
|
||||
'else if(t===0x57){drawWf(f32(ab));}' +
|
||||
'else if(t===0x41){if(audioCtx&&audioCtx.state==="suspended")audioCtx.resume();playOpus(new Uint8Array(ab,1));}};}' +
|
||||
|
||||
// Оверлей для user gesture (AudioContext требует клика)
|
||||
|
||||
Reference in New Issue
Block a user