Files
ewsdr/WebPageHtml.pas
T
2026-03-11 22:14:30 +03:00

540 lines
39 KiB
ObjectPascal
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
unit WebPageHtml;
{
WebPageHtml.pas — Встроенный HTML/CSS/JS интерфейс веб-пульта HPSDR.
Содержит единственную функцию GetIndexHtml, которая возвращает полную
HTML-страницу (одним файлом) для отправки клиенту по HTTP GET /.
ИСПРАВЛЕНИЯ:
- Кнопка START/STOP добавлена в тулбар 1 (шлёт set_run, зелёная/красная)
- Кнопка RX/TX удалена из тулбара 2
- NR/NB/ANF: cur обновляется локально сразу при клике (иначе повторный
клик инвертировал уже отправленное значение)
- Opus: decodeFrame() правильный метод, async/await
- Opus: opQueue буферизует пакеты пока WASM не готов
- Opus: AudioContext создаётся только по user gesture (оверлей)
- SPANS: только 48K..1536K
- (Mode fix) modTglBtn теперь отображает ТЕКУЩИЙ активный режим
(LSB/USB, CWL/CWU, AM/SAM, FM), а не следующий. При клике
переключает на парный режим внутри группы.
- (Mode fix) cwBtn удалён — его роль выполняет modTglBtn при CW группе.
- (Mode fix) syncModGrp + updModTglLabel корректно синхронизируют UI
из server state: выпадающий список и кнопка отражают десктоп.
- (Mode fix) modGrpSel.onchange при смене группы сразу отправляет
команду mode с дефолтным режимом группы (LSB, CWL, FM, AM).
}
{$IFDEF FPC}
{$MODE Delphi}
{$LONGSTRINGS ON}
{$ENDIF}
interface
function GetIndexHtml: string;
implementation
function GetIndexHtml: string;
begin
Result :=
'<!DOCTYPE html><html lang="en"><head>' +
'<meta charset="UTF-8">' +
'<meta name="viewport" content="width=device-width,initial-scale=1">' +
'<title>HPSDR Web Remote v2</title>' +
'<link rel="preconnect" href="https://fonts.googleapis.com">' +
'<link href="https://fonts.googleapis.com/css2?family=Roboto+Mono:wght@300;400&family=Share+Tech+Mono&display=swap" rel="stylesheet">' +
'<style>' +
'*{box-sizing:border-box;margin:0;padding:0}' +
'body{background:#141414;color:#ccc;font-family:"Segoe UI",Tahoma,sans-serif;font-size:12px;overflow:hidden;height:100vh}' +
'.wrap{display:flex;flex-direction:column;height:100vh;gap:2px;padding:2px}' +
'@import url(''https://fonts.googleapis.com/css2?family=Orbitron:wght@400;500&display=swap'');' +
'.tbar{display:flex;align-items:center;gap:5px;background:#1e1e1e;border:1px solid #2e2e2e;border-radius:3px;padding:2px 6px;flex-shrink:0;height:32px}' +
'.tsep{flex:1}' +
'button{background:linear-gradient(180deg,#404040,#2c2c2c);color:#ccc;border:1px solid #505050;border-radius:3px;padding:0 9px;font-size:11px;cursor:pointer;height:22px;white-space:nowrap;line-height:1;display:inline-flex;align-items:center;justify-content:center}' +
'button:hover{background:linear-gradient(180deg,#4c4c4c,#383838)}' +
'button.on{background:linear-gradient(180deg,#1e4d1e,#163216);border-color:#3d7a3d;color:#7fff7f}' +
'button.rx-tx{background:linear-gradient(180deg,#4d1e1e,#321616);border-color:#7a3d3d;color:#ff8080}' +
'select{background:#222;color:#bbb;border:1px solid #484848;border-radius:3px;padding:0 4px;font-size:11px;height:22px;cursor:pointer}' +
'.sl-g{display:flex;align-items:center;gap:3px}' +
'.sl-lb{font-size:10px;color:#777;white-space:nowrap}' +
'.sl-val{font-size:10px;color:#9ab;white-space:nowrap;min-width:2.5em;text-align:right}' +
'.sl-sep{color:#444;padding:0 2px;font-size:11px;align-self:center}' +
'input[type=range]{-webkit-appearance:none;height:3px;background:#3a3a3a;border-radius:2px;cursor:pointer;width:72px;outline:none}' +
'input[type=range]::-webkit-slider-thumb{-webkit-appearance:none;width:11px;height:11px;border-radius:50%;background:#5a9ab0;border:1px solid #7ac;cursor:pointer}' +
'input[type=range]::-moz-range-thumb{width:11px;height:11px;border-radius:50%;background:#5a9ab0;border:1px solid #7ac;cursor:pointer}' +
'.vol-ic{font-size:13px;cursor:pointer;color:#7ab;user-select:none;line-height:1}' +
'.row2{height:74px;display:flex;align-items:center;gap:6px;background:#0c0c0c}' +
'#smeter{height:64px;width:340px;flex-shrink:0;border:1px solid #333;border-radius:2px;display:block}' +
'.vfo-mid{flex:1;display:flex;align-items:center;justify-content:center;gap:5px}' +
'.vbtn{width:38px;height:38px;display:flex;align-items:center;justify-content:center;font-size:13px;font-weight:700;cursor:pointer;border-radius:3px;color:#888;border:1px solid #444;background:linear-gradient(180deg,#2a2a2a,#1e1e1e);padding:0}' +
'.vbtn.active{color:#7fff7f;border-color:#3d7a3d;background:linear-gradient(180deg,#1e3e1e,#142814)}' +
'.vfo-ro{font:400 44px "Orbitron","Consolas",monospace;letter-spacing:0.02em;color:#7fff7f;border:none;border-radius:0;padding:0 6px;cursor:pointer;user-select:none;height:62px;display:inline-flex;align-items:baseline;padding-top:10px;white-space:nowrap;overflow:hidden;background:transparent}' +
'.vfo-ro.dim{color:#1e501e;background:transparent}' +
'.vfo-ro .dg{display:inline-block;min-width:0.55em;font-size:44px;line-height:1}' +
'.vfo-ro .dg.lead{color:#2a3a2a}.vfo-ro.dim .dg.lead{color:#162016}' +
'.vfo-ro .sp{display:inline-block;color:#2a6030;min-width:0.3em;font-size:44px;line-height:1}' +
'.vfo-ro.dim .sp{color:#142014}' +
'.vfo-ro .dg.sel{background:rgba(127,255,127,.18);outline:1px solid rgba(127,255,127,.35);border-radius:2px}' +
'.vfo-hz{display:inline-flex;align-items:baseline}' +
'.vfo-hz .dg{display:inline-block;font-size:22px;line-height:1;min-width:0.55em;font-weight:400}' +
'.vfo-ro.dim .vfo-hz .dg{color:#1e501e}' +
'.swap-g{display:flex;gap:3px}' +
'.swap-g button{padding:0 7px;height:22px;font-size:10px}' +
'.right-g{display:flex;align-items:center;gap:4px;margin-left:auto}' +
'.right-g button,.right-g select{height:62px;font-size:12px}' +
'.spec-area{flex:1 1 auto;min-height:80px;position:relative;background:#050a05;border:1px solid #252525;border-radius:3px;overflow:hidden}' +
'.spec-area canvas{position:absolute;top:0;left:0;width:100%;height:100%}' +
'.span-ov{position:absolute;top:4px;left:4px;display:flex;gap:3px;z-index:5}' +
'.sp-btn{background:rgba(15,25,15,0.78);color:#6a9a6a;border:1px solid rgba(60,100,60,0.5);border-radius:3px;padding:0 7px;font-size:10px;height:18px;cursor:pointer;backdrop-filter:blur(1px)}' +
'.sp-btn:hover{background:rgba(25,45,25,0.88);border-color:rgba(80,140,80,0.7);color:#9aca9a}' +
'.sp-btn.on{background:rgba(20,50,20,0.92);border-color:#4a9a4a;color:#aaffaa}' +
'.ruler-area{flex:0 0 24px;position:relative;background:#0c1210;border-left:1px solid #252525;border-right:1px solid #252525;overflow:hidden}' +
'.ruler-area canvas{position:absolute;top:0;left:0;width:100%;height:100%}' +
'.wf-area{flex:0 0 405px;position:relative;background:#000;border:1px solid #252525;border-radius:3px;overflow:hidden}' +
'.wf-area canvas{position:absolute;top:0;left:0;width:100%;height:100%}' +
'.sbar{flex:0 0 16px;display:flex;align-items:center;padding:0 6px;gap:10px;background:#0e0e0e;border-top:1px solid #222;font-size:10px;color:#555}' +
'#latEl{margin-left:auto}' +
'</style></head>' +
'<body><div class="wrap">' +
// Тулбар 1: громкость, AGC, режимы, DSP кнопки, START/STOP
'<div class="tbar">' +
'<span class="vol-ic" id="muteIc" title="Mute">&#128266;</span>' +
'<div class="sl-g"><input type="range" id="volSl" min="0" max="100" value="70"><span class="sl-val" id="volVal">70</span></div>' +
'<span class="sl-sep">|</span>' +
'<div class="sl-g"><span class="sl-lb">RF</span><input type="range" id="rfSl" min="0" max="120" value="90"><span class="sl-val" id="rfVal">90dB</span></div>' +
'<span class="sl-sep">|</span>' +
'<button id="toneBtn">TONE</button>' +
'<span class="sl-sep">|</span>' +
'<div class="sl-g"><span class="sl-lb">TX</span><input type="range" id="txSl" min="0" max="100" value="50"><span class="sl-val" id="txVal">50</span></div>' +
'<span class="sl-sep">|</span>' +
'<div class="tsep"></div>' +
'<select id="agcSel">' +
'<option value="0">AGC-F</option><option value="1" selected>AGC-M</option>' +
'<option value="2">AGC-S</option><option value="3">AGC-L</option><option value="4">AGC-OFF</option>' +
'</select>' +
'<select id="modGrpSel">' +
'<option value="ssb" selected>SSB</option><option value="cw">CW</option>' +
'<option value="fm">FM</option><option value="am">AM</option>' +
'</select>' +
'<select id="stepSel">' +
'<option value="10">10 Hz</option><option value="100" selected>100 Hz</option>' +
'<option value="1000">1 kHz</option><option value="10000">10 kHz</option>' +
'</select>' +
'<select id="attSel"><option value="0" selected>0 dB</option><option value="1">-10 dB</option><option value="2">-20 dB</option></select>' +
'<button id="nbBtn">NB</button>' +
'<button id="snbBtn">SNB</button>' +
'<button id="anfBtn">ANF</button>' +
'<button id="nrBtn">NR</button>' +
'<span class="sl-sep">|</span>' +
'<button id="startStopBtn" class="on">START</button>' +
'</div>' +
// Тулбар 2: S-метр, VFO A/B, диапазон, CW (кнопка TX/RX убрана)
'<div class="tbar row2">' +
'<canvas id="smeter" width="340" height="64"></canvas>' +
'<div class="vfo-mid">' +
'<button class="vbtn active" id="vBtnA">A</button>' +
'<div id="vDispA" class="vfo-ro"></div>' +
'<div class="swap-g">' +
'<button id="btnBA">B=A</button>' +
'<button id="btnSwap">SWAP</button>' +
'<button id="btnAB">A=B</button>' +
'</div>' +
'<span style="display:inline-block;width:12px"></span>' +
'<button class="vbtn" id="vBtnB">B</button>' +
'<div id="vDispB" class="vfo-ro dim"></div>' +
'</div>' +
'<div class="right-g">' +
'<button id="modTglBtn">LSB</button>' +
'<select id="bandSel"></select>' +
'<button id="fnpBtn">FNP</button>' +
'</div>' +
'</div>' +
'<div class="spec-area"><canvas id="spec"></canvas><div class="span-ov" id="spanOv"></div></div>' +
'<div class="ruler-area"><canvas id="ruler"></canvas></div>' +
'<div class="wf-area"><canvas id="wf"></canvas></div>' +
'<div class="sbar"><span id="stEl">connecting...</span><span id="latEl"></span></div>' +
'</div>' +
'<script>' +
'const specCv=document.getElementById("spec"),wfCv=document.getElementById("wf"),' +
'rulerCv=document.getElementById("ruler"),smCv=document.getElementById("smeter");' +
'const sctx=specCv.getContext("2d"),wctx=wfCv.getContext("2d"),' +
'rctx=rulerCv.getContext("2d"),mctx=smCv.getContext("2d");' +
'const wfBuf=document.createElement("canvas"),wfBCtx=wfBuf.getContext("2d");' +
'const stEl=document.getElementById("stEl"),latEl=document.getElementById("latEl");' +
'let cur={},vfoA=14200000,vfoB=14200000,activeVfo="A";' +
'let localCenter=14200000,localSpan=192000;' + // локальные копии — обновляются мгновенно при действии пользователя
'let dragging=false,dragX0=0,dragCen0=0,dragVfo0=0,dragMs=0;' +
'let hoverX=-1,wTarget="",markerOn=false,markerX=0;' +
'let specSm=[],wfAvg=[],wfHi=-50,wfLo=-120;' +
'let audioCtx=null,audioT=0;' +
'function audioKick(){if(!audioCtx){audioCtx=new(window.AudioContext||window.webkitAudioContext)({sampleRate:48000});audioT=audioCtx.currentTime+0.05;}if(audioCtx.state!=="running")audioCtx.resume();}' +
'const PAL=[0x000000,0x000003,0x000006,0x000009,0x00000C,0x00000F,0x000012,0x000015,0x000019,0x00001C,0x00001F,0x000022,0x000025,0x000028,0x00002B,0x00002F,0x000032,0x000035,0x000038,0x00003B,0x00003E,0x000041,0x000045,0x000048,0x00004B,0x00004E,0x000051,0x000054,0x000057,0x00005A,0x00005E,0x000061,0x000064,0x000067,0x00006A,0x00006D,0x000070,0x000074,0x000077,0x00007A,0x00007D,0x000080,0x000083,0x000086,0x00008A,0x00008D,0x000090,0x000093,0x000096,0x000099,0x00009C,0x0000A0,0x0002A2,0x0004A4,0x0006A7,0x0008A9,0x000AAC,0x000CAE,0x000EB1,0x0010B3,0x0012B6,0x0014B8,0x0017BB,0x0019BD,0x001BC0,0x001DC2,0x001FC5,0x0021C7,0x0023CA,0x0025CC,0x0027CF,0x0029D1,0x002BD4,0x002ED6,0x0030D9,0x0032DB,0x0034DE,0x0036E0,0x0038E3,0x003AE5,0x003CE8,0x003EEA,0x0040EC,0x0042EF,0x0045F1,0x0047F4,0x0049F6,0x004BF9,0x004DFB,0x004FFE,0x0052FE,0x0056FD,0x005AFC,0x005DFB,0x0061FA,0x0065F9,0x0068F8,0x006CF7,0x0070F6,0x0073F6,0x0077F5,0x007BF4,0x007EF3,0x0082F2,0x0085F1,0x0089F0,0x008DEF,0x0090EE,0x0094ED,0x0098EC,0x009BEC,0x009FEB,0x00A3EA,0x00A6E9,0x00AAE8,0x00AEE7,0x00B1E6,0x00B5E5,0x00B9E4,0x00BCE3,0x00C0E2,0x00C4E1,0x00C7E1,0x00CBE0,0x00CFDF,0x00D2DE,0x00D6DD,0x00DADC,0x00DCD9,0x00DDD3,0x00DECD,0x00DFC7,0x00E0C2,0x00E1BC,0x00E1B6,0x00E2B0,0x00E3AB,0x00E4A5,0x00E59F,0x00E699,0x00E794,0x00E88E,0x00E98' +
'8,0x00EA82,0x00EB7D,0x00EC77,0x00EC71,0x00ED6B,0x00EE66,0x00EF60,0x00F05A,0x00F154,0x00F24F,0x00F349,0x00F443,0x00F53D,0x00F638,0x00F632,0x00F72C,0x00F826,0x00F921,0x00FA1B,0x00FB15,0x00FC0F,0x00FD0A,0x00FE04,0x01FF00,0x08FF00,0x0EFF00,0x15FF00,0x1CFF00,0x22FF00,0x29FF00,0x30FF00,0x36FF00,0x3DFF00,0x44FF00,0x4AFF00,0x51FF00,0x58FF00,0x5FFF00,0x65FF00,0x6CFF00,0x72FF00,0x79FF00,0x80FF00,0x86FF00,0x8DFF00,0x94FF00,0x9BFF00,0xA1FF00,0xA8FF00,0xAEFF00,0xB5FF00,0xBCFF00,0xC2FF00,0xC9FF00,0xD0FF00,0xD6FF00,0xDDFF00,0xE4FF00,0xEAFF00,0xF1FF00,0xF8FF00,0xFFFF00,0xFFF900,0xFFF300,0xFFED00,0xFFE800,0xFFE200,0xFFDC00,0xFFD600,0xFFD100,0xFFCB00,0xFFC500,0xFFC000,0xFFBA00,0xFFB400,0xFFAE00,0xFFA900,0xFFA300,0xFF9D00,0xFF9800,0xFF9200,0xFF8C00,0xFF8600,0xFF8100,0xFF7B00,0xFF7500,0xFF7000,0xFF6A00,0xFF6400,0xFF5E00,0xFF5900,0xFF5300,0xFF5304,0xFF5C11,0xFF641D,0xFF6D2A,0xFF7536,0xFF7E43,0xFF864F,0xFF8F5C,0xFF9868,0xFFA075,0xFFA982,0xFFB18E,0xFFBA9A,0xFFC2A7,0xFFCBB3,0xFFD4C0,0xFFDCCC,0xFFE5D9,0xFFEDE6,0xFFF6F2,0xFFFFFF];' +
'const cl=(v,a,b)=>Math.max(a,Math.min(b,v));' +
'const q=id=>document.getElementById(id);' +
'function sw(id,on){const b=q(id);if(b)b.classList.toggle("on",!!on);}' +
'function ws2(obj){if(ws&&ws.readyState===1)ws.send(JSON.stringify(obj));}' +
'function cmd(c,v){ws2({cmd:c,on:v});}' +
'function updNrBtn(){const b=q("nrBtn");if(!b)return;const m=cur.nr_mode|0;b.textContent=(m===2)?"NR2":(m===3)?"NR3":(m===4)?"NR4":"NR";sw("nrBtn",m!==0);}' +
'function updNbBtn(){const b=q("nbBtn");if(!b)return;const m=cur.nb_mode|0;b.textContent=(m===2)?"NB2":"NB";sw("nbBtn",m!==0);}' +
'function updSnbBtn(){sw("snbBtn",!!cur.snb);}' +
'function updAnfBtn(){sw("anfBtn",!!cur.anf);}' +
'const MODE_N=["LSB","USB","DSB","CWL","CWU","FM","AM","SAM"];' +
'const GRP_MAP={ssb:[0,1],cw:[3,4],fm:[5,5],am:[6,7]};' +
'const MODE_TO_GRP=["ssb","ssb","ssb","cw","cw","fm","am","am"];' +
'function modGrpFromMode(m){return MODE_TO_GRP[m]||"ssb";}' +
'function modGrp(){return q("modGrpSel").value||"ssb";}' +
'function modTglTarget(){const grp=GRP_MAP[modGrp()];const m=cur.mode||0;return(m===grp[0])?grp[1]:grp[0];}' +
'function updModTglLabel(){const b=q("modTglBtn");if(!b)return;const grp=GRP_MAP[modGrp()];const m=cur.mode||0;const show=(m===grp[0]||m===grp[1])?m:grp[0];b.textContent=MODE_N[show];}' +
'function syncModGrp(m){const g=MODE_TO_GRP[m]||"ssb";const s=q("modGrpSel");if(s&&s.value!==g)s.value=g;updModTglLabel();}' +
'q("modGrpSel").onchange=function(){const grp=GRP_MAP[modGrp()];const defaultMode=grp[0];cur.mode=defaultMode;updModTglLabel();pend("mode");ws2({cmd:"mode",mode:defaultMode});};' +
'q("modTglBtn").onclick=function(){audioKick();const next=modTglTarget();cur.mode=next;updModTglLabel();pend("mode");ws2({cmd:"mode",mode:next});};' +
'q("agcSel").onchange=function(e){pend("agc");ws2({cmd:"agc",mode:+e.target.value});};' +
'q("volSl").oninput=function(e){ws2({cmd:"volume",v:+e.target.value});var v=q("volVal");if(v)v.textContent=e.target.value;};' +
'q("rfSl").oninput=function(e){ws2({cmd:"agctop",db:+e.target.value});var v=q("rfVal");if(v)v.textContent=e.target.value+"dB";};' +
'q("txSl").oninput=function(e){var v=q("txVal");if(v)v.textContent=e.target.value;};' +
// DSP кнопки — cur обновляется локально сразу, не ждём ответа сервера
'q("nrBtn").onclick=function(){cur.nr_mode=((cur.nr_mode|0)+1)%5;cur.nr=cur.nr_mode!==0;updNrBtn();pend("nr");ws2({cmd:"set_nr_mode",mode:cur.nr_mode});};' +
'q("nbBtn").onclick=function(){cur.nb_mode=((cur.nb_mode|0)+1)%3;cur.nb=cur.nb_mode!==0;updNbBtn();pend("nb");ws2({cmd:"set_nb_mode",mode:cur.nb_mode});};' +
'q("snbBtn").onclick=function(){cur.snb=!cur.snb;updSnbBtn();pend("snb");cmd("set_snb",cur.snb);};' +
'q("anfBtn").onclick=function(){cur.anf=!cur.anf;updAnfBtn();pend("anf");cmd("set_anf",cur.anf);};' +
// START/STOP
'function updRunBtn(r){var b=q("startStopBtn");if(!b)return;b.textContent=r?"STOP":"START";b.classList.toggle("rx-tx",!!r);b.classList.toggle("on",!r);}' +
'q("startStopBtn").onclick=function(){audioKick();cur.running=!cur.running;pend("run");updRunBtn(cur.running);cmd("set_run",cur.running);};' +
'q("muteIc").onclick=()=>{cur.mute=!cur.mute;q("muteIc").textContent=cur.mute?"\uD83D\uDD07":"\uD83D\uDD0A";cmd("set_mute",cur.mute);};' +
'let stA=0,stAmem=100,stB=0,stBmem=100;' +
'function fmtVfo(hz,step){' +
'const s=String(Math.max(0,Math.round(hz||0))).padStart(9,"0");' +
'let h="",seenNZ=false;' +
'for(let i=0;i<6;i++){' +
'const st=Math.pow(10,8-i);' +
'if(s[i]!=="0")seenNZ=true;' +
'const lead=(!seenNZ)?" lead":"";' +
'const sel=(step>0&&st===step)?" sel":"";' +
'h+="<span class=\"dg"+lead+sel+" data-s=\""+st+"\">"+s[i]+"</span>";' +
'if(i===2)h+="<span class=\"sp\">.</span>";}' +
'h+="<span class=\"sp\">.</span>";' +
'h+="<span class=\"vfo-hz\">";' +
'for(let i=6;i<9;i++){const st=Math.pow(10,8-i);' +
'const sel=(step>0&&st===step)?" sel":"";' +
'h+="<span class=\"dg"+sel+" data-s=\""+st+"\">"+s[i]+"</span>";}' +
'return h;}' +
'function renderVfos(){q("vDispA").innerHTML=fmtVfo(vfoA,stA);q("vDispB").innerHTML=fmtVfo(vfoB,stB);}' +
'function bindVfo(dispId,getHz,setHz,getSt,setSt,getMem,setMem,onSend){' +
'const el=q(dispId);' +
'el.addEventListener("mousemove",e=>{const t=e.target,s=Number(t?.dataset?.s||0);if(s>0){setSt(s);setMem(s);renderVfos();}});' +
'el.addEventListener("click",e=>{const t=e.target,s=Number(t?.dataset?.s||0);if(s>0){setSt(s);setMem(s);}});' +
'el.addEventListener("mouseleave",()=>{if(getSt()!==0){setSt(0);renderVfos();}});' +
'el.addEventListener("wheel",e=>{e.preventDefault();e.stopPropagation();audioKick();' +
'const dir=e.deltaY<0?1:-1,st=getSt()||getMem()||100;' +
'const nf=cl(Math.round(getHz())+st*dir,30000,60000000);' +
'setHz(nf);onSend(nf);},{passive:false});' +
'el.addEventListener("dblclick",()=>{' +
'const s=prompt("Set Hz",String(Math.round(getHz())));if(!s)return;' +
'const f=parseInt(s.replace(/[^0-9]/g,""),10);if(!isFinite(f))return;' +
'const nf=cl(f,30000,60000000);setHz(nf);renderVfos();onSend(nf);' +
'});}' +
'bindVfo("vDispA",()=>vfoA,v=>{vfoA=v;},()=>stA,v=>{stA=v;},()=>stAmem,v=>{stAmem=v;},' +
'function(nf){renderVfos();pend("freq");if(activeVfo==="A")ws2({cmd:"freq",hz:nf});else ws2({cmd:"freq_b",hz:nf});});' +
'bindVfo("vDispB",()=>vfoB,v=>{vfoB=v;},()=>stB,v=>{stB=v;},()=>stBmem,v=>{stBmem=v;},' +
'function(nf){renderVfos();pend("freq");if(activeVfo==="B")ws2({cmd:"freq",hz:nf});else ws2({cmd:"freq_b",hz:nf});});' +
'function selVfo(w){' +
'activeVfo=w;' +
'q("vBtnA").classList.toggle("active",w==="A");' +
'q("vBtnB").classList.toggle("active",w==="B");' +
'q("vDispA").classList.toggle("dim",w!=="A");' +
'q("vDispB").classList.toggle("dim",w!=="B");' +
// Notify server: switch active VFO (server calls ActivateVfo which retunes DDC)
'ws2({cmd:"set_active_vfo",idx:w==="A"?0:1});' +
'}' +
'q("vBtnA").onclick=()=>{if(activeVfo!=="A")selVfo("A");};' +
'q("vBtnB").onclick=()=>{if(activeVfo!=="B")selVfo("B");};' +
// B=A: copy A freq into B
'q("btnBA").onclick=()=>{vfoB=vfoA;renderVfos();ws2({cmd:"freq_b",hz:Math.round(vfoB)});};' +
// A=B: copy B freq into A
'q("btnAB").onclick=()=>{vfoA=vfoB;renderVfos();' +
'if(activeVfo==="A")ws2({cmd:"freq",hz:Math.round(vfoA)});' +
'else ws2({cmd:"freq_b",hz:Math.round(vfoA)});' +
// Actually A=B means set vfoA=vfoB, so send freq_a regardless of active
'};' +
// SWAP
'q("btnSwap").onclick=()=>{const t=vfoA;vfoA=vfoB;vfoB=t;renderVfos();' +
'ws2({cmd:"freq",hz:Math.round(activeVfo==="A"?vfoA:vfoB)});' +
'ws2({cmd:"freq_b",hz:Math.round(activeVfo==="A"?vfoB:vfoA)});' +
'};' +
// SPAN кнопки: только 48K..1536K
'const SPANS=[48000,96000,192000,384000,768000,1536000];' +
'const SPAN_L=["48K","96K","192K","384K","768K","1536K"];' +
'(function(){const c=q("spanOv");SPANS.forEach((hz,i)=>{const b=document.createElement("button");b.className="sp-btn";b.id="sb"+i;b.textContent=SPAN_L[i];b.onclick=()=>ws2({cmd:"span",hz});c.appendChild(b);});})();' +
'function updSpan(s){SPANS.forEach((hz,i)=>{const b=q("sb"+i);if(b)b.classList.toggle("on",hz===s);});}' +
'const BAND_N=["160m","80m","60m","40m","30m","20m","17m","15m","12m","10m","6m"];' +
'const BAND_F=[1900000,3750000,5357000,7100000,10125000,14200000,18120000,21200000,24940000,28500000,50150000];' +
'(function(){const s=q("bandSel");BAND_N.forEach((n,i)=>{const o=document.createElement("option");o.value=i;o.textContent=n;s.appendChild(o);});s.onchange=()=>ws2({cmd:"band",idx:+s.value});})();' +
'function updBand(vfo){let bi=0,bd=1e15;BAND_F.forEach((f,i)=>{const d=Math.abs((vfo||0)-f);if(d<bd){bd=d;bi=i;}});q("bandSel").value=bi;}' +
'let smAvg=-130,smPeak=-130,smMin=-130;' +
'function dbToX(db,bx,bw){const DB_MIN=-127,DB_MAX=-13;return Math.max(bx,Math.min(bx+bw,Math.round(bx+(db-DB_MIN)/(DB_MAX-DB_MIN)*bw)));}' +
'function paintSM(dbm){' +
'const w=smCv.clientWidth||340,h=smCv.clientHeight||64;' +
'const AVG_ALPHA=0.40,ZONE_DB=6,PEAK_BASE=0.12,MIN_BASE=0.12,ACCEL=0.030,MAX_A=0.92;' +
'const DBM_MARKS=[-120,-100,-80,-60,-40,-20];' +
'const DBM_LABELS=["-120","-100","-80","-60","-40","-20"];' +
'const S_MARKS_DBM=[-121,-109,-97,-85,-73,-53,-33,-13];' +
'const S_MARKS_LBL=["S1","S3","S5","S7","S9","+20","+40","+60"];' +
'const TICK_LONG=5,TICK_MED=3;' +
'smAvg=smAvg*(1-AVG_ALPHA)+dbm*AVG_ALPHA;' +
'const pTarget=Math.max(dbm,smAvg+ZONE_DB),mTarget=Math.min(dbm,smAvg-ZONE_DB);' +
'const pA=Math.min(MAX_A,PEAK_BASE+Math.abs(pTarget-smPeak)*ACCEL);' +
'const mA=Math.min(MAX_A,MIN_BASE+Math.abs(mTarget-smMin)*ACCEL);' +
'smPeak+=pA*(pTarget-smPeak);smMin+=mA*(mTarget-smMin);' +
'const BX=62,BW=Math.max(10,w-66);' +
'const YT=Math.round(h*36/100),YB=Math.round(h*66/100);' +
'const S9X=dbToX(-73,BX,BW),OVRX=dbToX(-43,BX,BW);' +
'mctx.fillStyle="#0c0c0c";mctx.fillRect(0,0,w,h);' +
'mctx.fillStyle="#061006";mctx.fillRect(BX,YT,S9X-BX,YB-YT);' +
'mctx.fillStyle="#0A1006";mctx.fillRect(S9X,YT,OVRX-S9X,YB-YT);' +
'mctx.fillStyle="#060A10";mctx.fillRect(OVRX,YT,BX+BW-OVRX,YB-YT);' +
'const barEnd=dbToX(dbm,BX,BW);' +
'if(barEnd>BX){mctx.fillStyle="#091F00";mctx.fillRect(BX,YT+1,Math.min(barEnd,S9X)-BX,YB-YT-2);' +
'if(barEnd>S9X){mctx.fillStyle="#091528";mctx.fillRect(S9X,YT+1,barEnd-S9X,YB-YT-2);}}' +
'const px1=Math.max(BX+1,Math.min(dbToX(smMin,BX,BW),dbToX(smPeak,BX,BW)));' +
'const px2=Math.min(BX+BW-1,Math.max(dbToX(smMin,BX,BW),dbToX(smPeak,BX,BW)));' +
'if(px2>px1){mctx.fillStyle="rgba(255,255,255,0.25)";mctx.fillRect(px1,YT+1,px2-px1,YB-YT-2);}' +
'const pX=dbToX(smPeak,BX,BW);' +
'mctx.strokeStyle="#F8F8FF";mctx.lineWidth=2;mctx.beginPath();mctx.moveTo(pX,YT);mctx.lineTo(pX,YB);mctx.stroke();' +
'mctx.lineWidth=1;mctx.strokeStyle="#303028";mctx.strokeRect(BX,YT,BW,YB-YT);' +
'mctx.font="10px Courier New";' +
'for(let i=0;i<DBM_MARKS.length;i++){const x=dbToX(DBM_MARKS[i],BX,BW);' +
'mctx.strokeStyle="#DCE0E0";mctx.lineWidth=1;mctx.beginPath();mctx.moveTo(x,YT-TICK_LONG);mctx.lineTo(x,YT-1);mctx.stroke();' +
'const t=DBM_LABELS[i],tw=mctx.measureText(t).width;' +
'mctx.fillStyle="#DCDCD0";mctx.fillText(t,Math.round(x-tw/2),YT-TICK_LONG-1);}' +
'for(let d=-125;d<-13;d+=5){const x=dbToX(d,BX,BW);' +
'mctx.strokeStyle="#708070";mctx.lineWidth=1;mctx.beginPath();mctx.moveTo(x,YT-TICK_MED);mctx.lineTo(x,YT-1);mctx.stroke();}' +
'for(let i=0;i<S_MARKS_DBM.length;i++){const x=dbToX(S_MARKS_DBM[i],BX,BW),ov=S_MARKS_DBM[i]>=-73;' +
'mctx.strokeStyle=ov?"#70A090":"#708070";mctx.lineWidth=1;mctx.beginPath();mctx.moveTo(x,YB+1);mctx.lineTo(x,YB+TICK_LONG);mctx.stroke();' +
'const t=S_MARKS_LBL[i],tw=mctx.measureText(t).width;' +
'mctx.fillStyle=ov?"#90D090":"#E0E0E0";mctx.fillText(t,Math.round(x-tw/2),YB+TICK_LONG+9);}' +
'mctx.fillStyle="#DCD896";mctx.font="bold 12px Courier New";' +
'mctx.fillText(dbm.toFixed(1).padStart(6," "),1,12);' +
'mctx.fillStyle="#707050";mctx.font="10px Courier New";mctx.fillText("dBm",1,YT-2);' +
'let sLbl;' +
'if(dbm>=-73){let over=Math.round(dbm+73);if(over<5)sLbl="S9";else{over=Math.round((over+5)/10)*10;if(over===0)over=10;sLbl="S9+"+over;}}' +
'else if(dbm<=-121){sLbl="S1";}' +
'else{sLbl="S1";for(let i=1;i<=8;i++){if(dbm>=(-121+(i-1)*6))sLbl="S"+i;}}' +
'mctx.fillStyle="#F0F0F0";mctx.font="bold 12px Courier New";mctx.fillText(sLbl,1,YB+TICK_LONG+10);}' +
// pendingCmds: команды в пути — не перезаписываем эти поля из server state
'var pendingCmds={};' +
'function pend(field){pendingCmds[field]=Date.now()+600;}' +
'function isPending(field){return !!(pendingCmds[field]&&pendingCmds[field]>Date.now());}' +
// Маппинг: server JSON field → pend key
'var PEND_MAP={vfo_a_hz:"freq",vfo_b_hz:"freq",nr:"nr",nr_mode:"nr",nb:"nb",nb_mode:"nb",snb:"snb",anf:"anf",running:"run",mode:"mode",agc_mode:"agc"};' +
'function applyState(msg){' +
'Object.keys(msg).forEach(function(k){var pk=PEND_MAP[k];if(!pk||!isPending(pk))cur[k]=msg[k];});' +
// Обновляем localCenter/localSpan из сервера только когда пользователь не тянет
'if(!dragging){' +
'if(msg.center_hz!=null)localCenter=msg.center_hz;' +
'if(msg.span_hz!=null)localSpan=msg.span_hz||192000;' +
'}' +
'if(!isPending("freq")){vfoA=cur.vfo_a_hz||vfoA;}' +
'if(msg.vfo_b_hz!=null)vfoB=msg.vfo_b_hz;' +
'if(msg.active_vfo!=null&&!isPending("freq")){' +
'const sv=msg.active_vfo===0?"A":"B";' +
'if(sv!==activeVfo){activeVfo=sv;' +
'q("vBtnA").classList.toggle("active",activeVfo==="A");' +
'q("vBtnB").classList.toggle("active",activeVfo==="B");' +
'q("vDispA").classList.toggle("dim",activeVfo!=="A");' +
'q("vDispB").classList.toggle("dim",activeVfo!=="B");}}' +
'renderVfos();' +
'const as=q("agcSel");if(as&&cur.agc_mode!=null&&!isPending("agc"))as.value=cur.agc_mode;' +
'const vs=q("volSl");if(vs&&!vs.matches(":active")){vs.value=cur.volume||70;var vv=q("volVal");if(vv)vv.textContent=cur.volume||70;}' +
'const rs=q("rfSl");if(rs&&!rs.matches(":active")){rs.value=cur.agc_top||90;var rv=q("rfVal");if(rv)rv.textContent=(cur.agc_top||90)+"dB";}' +
'if(!isPending("nr"))updNrBtn();if(!isPending("nb"))updNbBtn();' +
'if(!isPending("snb"))updSnbBtn();if(!isPending("anf"))updAnfBtn();' +
'if(!isPending("run"))updRunBtn(!!cur.running);' +
'if(!isPending("mode")){syncModGrp(cur.mode||0);}' +
'updBand(activeVfo==="A"?(cur.vfo_a_hz||0):(vfoB||0));updSpan(cur.span_hz||192000);' +
'paintSM(Number(cur.smeter_dbm||-130));' +
'const mn=cur.mode_name||(MODE_N[cur.mode||0]||"?");' +
'stEl.textContent=(cur.connected?"OK":"OFF")+" | "+(cur.running?"RUN":"STOP")+" | "+mn+" | "+((activeVfoHz()/1e6).toFixed(3))+" MHz";}' +
'function pal(db){const v=Math.round(cl((db-wfLo)/Math.max(1,wfHi-wfLo),0,1)*255);const c=PAL[v]|0;return[(c>>16)&255,(c>>8)&255,c&255];}' +
'function fltLH(){const bw=cur.filter_bw||2700;const m=cur.mode|0;if(m===0)return[-bw,-100];if(m===1)return[100,bw];return[-bw/2,bw/2];}' +
'function xToHz(x,w){return localCenter+((x/Math.max(1,w))-.5)*localSpan;}' +
'function hzToX(f,w){return((f-localCenter)/localSpan+.5)*w;}' +
'function drawMk(ctx,w,h){if(!markerOn)return;ctx.save();ctx.strokeStyle="rgba(255,80,80,.85)";ctx.lineWidth=1;ctx.beginPath();ctx.moveTo(markerX,0);ctx.lineTo(markerX,h);ctx.stroke();' +
'const lbl=((xToHz(markerX,w))/1e6).toFixed(4)+" M";ctx.fillStyle="rgba(35,5,5,.82)";ctx.fillRect(cl(markerX+5,0,w-88),h-16,84,13);ctx.fillStyle="#f88";ctx.font="10px monospace";ctx.fillText(lbl,cl(markerX+8,2,w-86),h-6);ctx.restore();}' +
'function drawRuler(){const w=rulerCv.clientWidth,h=rulerCv.clientHeight;rctx.fillStyle="#0c1210";rctx.fillRect(0,0,w,h);' +
'const st=localCenter-localSpan/2;' +
'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();}' +
'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(!bins?.length){drawMk(sctx,w,h);return;}' +
'if(specSm.length!==bins.length)specSm=new Array(bins.length).fill(-130);' +
'const avf=activeVfoHz();const[fl,fh]=fltLH(),x1=hzToX(avf+fl,w),x2=hzToX(avf+fh,w);' +
'sctx.fillStyle="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);}' +
'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.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();drawMk(sctx,w,h);}' +
'function drawWf(bins){const w=wfCv.clientWidth,h=wfCv.clientHeight;' +
'if(!bins?.length){drawMk(wctx,w,h);return;}' +
'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;' +
'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];}' +
'for(let i=0;i<bins.length;i++){if(wfAvg[i]<wfLo+15){nfSum+=wfAvg[i];nfCount++;}}' +
'if(peak>wfHi)wfHi=peak+5;else wfHi=wfHi+0.02*((peak+5)-wfHi);' +
'if(nfCount>10)wfLo=wfLo+0.005*(((nfSum/nfCount)-5)-wfLo);' +
'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;}' +
'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="rgba(220,230,230,.10)";wctx.fillRect(Math.min(x1w,x2w),0,Math.abs(x2w-x1w),h);' +
'const vxw=hzToX(avf2,w);wctx.strokeStyle="#60ff80";wctx.lineWidth=1;wctx.beginPath();wctx.moveTo(vxw,0);wctx.lineTo(vxw,h);wctx.stroke();drawMk(wctx,w,h);}' +
'function rcv(cv){const dpr=Math.max(1,devicePixelRatio||1),w=cv.clientWidth|0,h=cv.clientHeight|0;if(cv.width!==(w*dpr|0)||cv.height!==(h*dpr|0)){cv.width=w*dpr;cv.height=h*dpr;cv.getContext("2d").setTransform(dpr,0,0,dpr,0,0);}}' +
'function resize(){rcv(specCv);rcv(wfCv);rcv(rulerCv);const sw=smCv.clientWidth|0,sh=smCv.clientHeight|0;if(smCv.width!==sw||smCv.height!==sh){smCv.width=sw;smCv.height=sh;}if(wfBuf.width!==wfCv.clientWidth||wfBuf.height!==wfCv.clientHeight){wfBuf.width=wfCv.clientWidth;wfBuf.height=wfCv.clientHeight;wfBCtx.fillStyle="#000";wfBCtx.fillRect(0,0,wfBuf.width,wfBuf.height);}}' +
'function tuneStep(e){const s=+q("stepSel").value||100;if(e.ctrlKey&&e.shiftKey)return 1000000;if(e.ctrlKey)return 1000;if(e.shiftKey)return 10;return s;}' +
'function activeVfoHz(){return activeVfo==="A"?vfoA:vfoB;}' +
'function setActiveVfoHz(f){if(activeVfo==="A")vfoA=f;else vfoB=f;}' +
'function sendActiveFreq(f){if(activeVfo==="A")ws2({cmd:"freq",hz:f});else ws2({cmd:"freq",hz:f});}' +
'function doWheel(e){e.preventDefault();e.stopPropagation();audioKick();const dir=e.deltaY<0?1:-1,st=tuneStep(e),base=Math.floor(activeVfoHz()/st)*st;' +
'const nf=cl(dir>0?base+st:(Math.round(activeVfoHz())===base?base-st:base),30000,60000000);' +
'setActiveVfoHz(nf);localCenter=nf;' +
'if(activeVfo==="A"){cur.vfo_a_hz=nf;}else{cur.vfo_b_hz=nf;}' +
'renderVfos();pend("freq");ws2({cmd:"freq",hz:nf});}' +
'function bindCv(cv){let wT=0;const wh=e=>{const n=performance.now();if(n-wT<20)return;wT=n;wTarget=cv.id;doWheel(e);};' +
'cv.addEventListener("contextmenu",e=>e.preventDefault());' +
'cv.addEventListener("mouseenter",()=>{wTarget=cv.id;});' +
'cv.addEventListener("wheel",wh,{passive:false,capture:true});' +
'cv.addEventListener("mousedown",e=>{' +
'audioKick();wTarget=cv.id;' +
'if(e.button===2){markerOn=Math.abs(markerX-e.offsetX)>=8||!markerOn;markerX=e.offsetX;return;}' +
'if(e.button!==0)return;' +
'dragging=true;dragX0=e.offsetX;' +
'dragCen0=localCenter;' +
'dragVfo0=activeVfoHz();' +
'dragMs=0;cv.style.cursor="ew-resize";' +
'});' +
'cv.addEventListener("mousemove",e=>{' +
'wTarget=cv.id;hoverX=e.offsetX;' +
'if(markerOn&&!dragging)markerX=e.offsetX;' +
'if(!dragging)return;' +
'const n=Date.now();if(n-dragMs<16)return;dragMs=n;' +
'const dHz=(e.offsetX-dragX0)/Math.max(1,cv.clientWidth)*localSpan;' +
'if(cur.ctun){' +
'localCenter=Math.round(dragCen0-dHz);' +
'ws2({cmd:"set_center",hz:localCenter});' +
'}else{' +
'const nf=cl(Math.round(dragVfo0-dHz),30000,60000000);' +
'setActiveVfoHz(nf);localCenter=nf;' +
'if(activeVfo==="A")cur.vfo_a_hz=nf;else cur.vfo_b_hz=nf;' +
'renderVfos();pend("freq");ws2({cmd:"freq",hz:nf});' +
'}' +
'});' +
'cv.addEventListener("mouseup",e=>{' +
'cv.style.cursor="";' +
'if(e.button===2)return;' +
'if(dragging&&Math.abs(e.offsetX-dragX0)<4){' +
// клик — настраиваем на кликнутую частоту мгновенно
'const nf=cl(Math.round(xToHz(e.offsetX,cv.clientWidth)/100)*100,30000,60000000);' +
'setActiveVfoHz(nf);localCenter=nf;' +
'if(activeVfo==="A")cur.vfo_a_hz=nf;else cur.vfo_b_hz=nf;' +
'renderVfos();pend("freq");ws2({cmd:"freq",hz:nf});' +
'}' +
'dragging=false;' +
'});' +
'cv.addEventListener("mouseleave",()=>{cv.style.cursor="";if(!markerOn)hoverX=-1;wTarget="";dragging=false;});}' +
'bindCv(specCv);bindCv(wfCv);' +
'window.addEventListener("wheel",e=>{if(e.target?.closest?.(".vfo-ro"))return;if(!wTarget){if(e.target===specCv)wTarget="spec";else if(e.target===wfCv)wTarget="wf";}if(wTarget)doWheel(e);},{passive:false,capture:true});' +
// Opus: async decodeFrame, очередь, оверлей для user gesture
'let ws=null,opDec=null,opRdy=false,opQueue=[];' +
'async function initOpus(){if(opRdy)return;' +
'try{const m=await import("https://cdn.jsdelivr.net/npm/opus-decoder@0.7.7/+esm");' +
'opDec=new m.OpusDecoder({channels:1,sampleRate:48000});' +
'await opDec.ready;opRdy=true;' +
'const q2=opQueue;opQueue=[];for(const b of q2)await playOpus(b);' +
'latEl.textContent="Opus ready";}catch(e){console.error("Opus init:",e);latEl.textContent="Opus FAILED: "+e.message;}}' +
'async function playOpus(b){' +
'if(!b||!b.length)return;' +
'if(!opRdy){opQueue.push(b);return;}' +
'if(!audioCtx)return;' +
'try{const r=await opDec.decodeFrame(b);' +
'if(!r||!r.channelData||!r.samplesDecoded)return;' +
'const pcm=r.channelData[0];' +
'const buf=audioCtx.createBuffer(1,r.samplesDecoded,48000);' +
'buf.copyToChannel(pcm.subarray(0,r.samplesDecoded),0);' +
'const src=audioCtx.createBufferSource();src.buffer=buf;src.connect(audioCtx.destination);' +
'const now=audioCtx.currentTime;' +
'if(audioT<now-.05||audioT>now+.5)audioT=now+.02;' +
'src.start(audioT);audioT+=r.samplesDecoded/48000;' +
'latEl.textContent="lat: "+Math.max(0,(audioT-now)*1000).toFixed(0)+"ms | Opus";' +
'}catch(e){console.error("playOpus:",e);}}' +
'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=()=>{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();}' +
'else if(t===0x41){if(audioCtx&&audioCtx.state==="suspended")audioCtx.resume();playOpus(new Uint8Array(ab,1));}};}' +
// Оверлей для user gesture (AudioContext требует клика)
'function showAudioOverlay(){' +
'const ov=document.createElement("div");' +
'ov.style.cssText="position:fixed;inset:0;background:rgba(0,0,0,.72);display:flex;align-items:center;justify-content:center;z-index:9999;cursor:pointer;";' +
'const inner=document.createElement("div");' +
'inner.textContent="\u25BA Click to start audio";' +
'inner.style.cssText="color:#7fff7f;font-size:22px;font-family:monospace;text-align:center;padding:20px;border:1px solid #3d7a3d;border-radius:6px;background:#0c1a0c;";' +
'ov.appendChild(inner);' +
'ov.addEventListener("click",function(){audioKick();ov.remove();},{once:true});' +
'document.body.appendChild(ov);}' +
'window.addEventListener("resize",resize);resize();renderVfos();connect();' +
'document.addEventListener("click",function(){if(audioCtx&&audioCtx.state==="suspended")audioCtx.resume();});' +
'setTimeout(showAudioOverlay,300);' +
'</script></body></html>';
end;
end.