{
  Copyright (C)
  2026 - Uladzimir Karpenka, EW8BAK

  This program is free software; you can redistribute it and/or
  modify it under the terms of the GNU General Public License
  as published by the Free Software Foundation; either version 2
  of the License, or (at your option) any later version.

  This program is distributed in the hope that it will be useful,
  but WITHOUT ANY WARRANTY; without even the implied warranty of
  MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
  GNU General Public License for more details.

  You should have received a copy of the GNU General Public License
  along with this program; if not, write to the Free Software
  Foundation, Inc., 59 Temple Place - Suite 330, Boston, MA  02111-1307, USA.
}

unit WebPageHtml;

{
  WebPageHtml.pas — Встроенный HTML/CSS/JS интерфейс веб-пульта HPSDR.

  Содержит единственную функцию GetIndexHtml, которая возвращает полную
  HTML-страницу (одним файлом) для отправки клиенту по HTTP GET /.

  ИСПРАВЛЕНИЯ:
    - Device-overlay (DEV): discovery/connect/saved-CRUD, паритет с десктоп-диалогом.
      START теперь открывает overlay (если не подключены); STOP шлёт disconnect.
    - Кнопка START/STOP добавлена в тулбар 1 (зелёная/красная)
    - Кнопка RX/TX удалена из тулбара 2
    - NR/NB/ANF: cur обновляется локально сразу при клике (иначе повторный
      клик инвертировал уже отправленное значение)
    - Opus: decodeFrame() правильный метод, async/await
    - Opus: opQueue буферизует пакеты пока WASM не готов
    - Opus: AudioContext создаётся только по user gesture (оверлей)
    - SPANS: строятся из state.rate_presets (HPSDR/Pluto разные наборы)
    - (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).
    - (Opus) По флагу state.opus=false (на сервере нет libopus) показывается
      баннер "No audio — libopus is not installed on the server", а оверлей
      "Click to start audio" не показывается вовсе — звука всё равно не будет.
}

{$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}' +
    'button.mox-on{background:linear-gradient(180deg,#6d1e1e,#4d0a0a);border-color:#cc3333;color:#ffaaaa;font-weight:bold}' +
    '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:0;border-radius:0;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 24px;display:grid;grid-template-columns:minmax(120px,170px) minmax(190px,280px) minmax(120px,160px) minmax(135px,170px) minmax(120px,185px) minmax(120px,190px) minmax(120px,180px) minmax(90px,1fr) minmax(82px,96px) minmax(92px,112px);align-items:stretch;background:#0e0e0e;border-top:1px solid #222;font:10px Courier New,monospace;color:#ccc;overflow-x:auto;overflow-y:hidden}' +
    '.sb-cell{min-width:0;display:flex;align-items:center;padding:0 8px;border-left:1px solid #222;background:#181818;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}' +
    '.sb-cell:first-child{border-left:0}' +
    '.sb-dim{color:#777}.sb-seq{color:#ffaa00}.sb-status{color:#90c090}.sb-time{justify-content:center;color:#bbb}.sb-lat{display:none}' +
    '@media screen and (max-width:640px){.sbar{grid-template-columns:minmax(72px,1fr) minmax(104px,1.25fr) minmax(74px,1fr) minmax(78px,1fr);overflow:hidden}.sbar #sbBoard,.sbar #sbIP,.sbar #sbPLL,.sbar #sbRX,.sbar #sbTX,.sbar #sbLocal{display:none}.sb-cell{padding:0 5px;font-size:9px}}' +
    '@media screen and (max-width:1300px) and (min-width:641px){' +
    '.tbar{gap:3px;padding-left:4px;padding-right:4px;}' +
    'button{padding:0 6px;font-size:10px;}' +
    'select{padding:0 3px;font-size:10px;}' +
    '.sl-g{gap:2px;}' +
    '.sl-lb,.sl-val{font-size:9px;}' +
    '.sl-val{min-width:2.1em;}' +
    '.sl-sep{padding:0 1px;}' +
    'input[type=range]{width:54px;}' +
    '#smeter{width:260px;height:58px;}' +
    '.row2{height:66px;gap:4px;}' +
    '.vfo-mid{gap:3px;min-width:0;}' +
    '.vbtn{width:30px;height:30px;font-size:11px;}' +
    '.vfo-ro{font-size:32px;height:54px;padding-left:3px;padding-right:3px;padding-top:9px;}' +
    '.vfo-ro .dg,.vfo-ro .sp{font-size:32px;}' +
    '.vfo-hz .dg{font-size:16px;}' +
    '.swap-g{gap:2px;}' +
    '.swap-g button{padding:0 4px;font-size:9px;}' +
    '.vfo-sp{width:4px!important;}' +
    '.right-g{gap:3px;}' +
    '.right-g button,.right-g select{height:54px;font-size:10px;}' +
    '}' +
    '@media screen and (max-width:640px){' +
    'body{overflow:auto;height:auto;min-height:100vh;}' +
    '.wrap{height:auto;min-height:100vh;padding:3px;}' +
    // Тулбар 1: переносим группы по строкам, чтобы не было скрытых контролов
    '.tbar:not(.row2){height:auto;min-height:32px;flex-wrap:wrap;align-items:center;overflow:visible;padding:4px;gap:4px;}' +
    '.tbar:not(.row2)>*{flex-shrink:0;}' +
    '.tbar:not(.row2) .tsep,.tbar:not(.row2) .sl-sep{display:none;}' +
    '.sl-g{height:30px;}' +
    '.sl-val{min-width:2.8em;}' +
    // Крупнее зоны касания
    'button{height:30px;}select{height:30px;}' +
    // VFO-строка: стекаем smeter / vfo-mid / right-g каждый на своей строке
    '.row2{height:auto;flex-wrap:wrap;padding:4px;gap:4px;}' +
    '#smeter{width:100%;height:64px;}' +
    '.vfo-mid{width:100%;display:grid;grid-template-columns:24px minmax(0,1fr) auto 24px minmax(0,1fr);gap:3px;align-items:center;}' +
    '#vBtnA{grid-column:1;grid-row:1;}#vDispA{grid-column:2;grid-row:1;}' +
    '.swap-g{grid-column:3;grid-row:1;justify-content:center;gap:2px;}' +
    '#vBtnB{grid-column:4;grid-row:1;}#vDispB{grid-column:5;grid-row:1;}' +
    '.vfo-ro{width:100%;font-size:16px;height:30px;padding:0 1px;padding-top:6px;justify-content:flex-start;}' +
    '.vfo-ro .dg,.vfo-ro .sp{font-size:16px;}' +
    '.vfo-hz .dg{font-size:8px;}' +
    '.vbtn{width:24px;height:24px;font-size:10px;}' +
    '.swap-g button{height:22px;padding:0 3px;font-size:8px;min-width:30px;}' +
    '.vfo-sp{display:none;}' +
    '.right-g{width:100%;margin-left:0;display:grid;grid-template-columns:58px minmax(0,1fr) 52px;gap:4px;}' +
    '.right-g button,.right-g select{height:30px;font-size:11px;width:100%;min-width:0;}' +
    '#bandSel{min-width:0;}' +
    '.span-ov{right:4px;overflow-x:auto;scrollbar-width:none;}' +
    '.span-ov::-webkit-scrollbar{display:none;}' +
    '.sp-btn{flex:0 0 auto;height:22px;padding:0 6px;}' +
    '.spec-area{min-height:130px;flex:0 0 30vh;}' +
    '.ruler-area{flex:0 0 22px;}' +
    // Водопад короче на телефоне
    '.wf-area{flex:0 0 32vh;min-height:150px;}' +
    // Слайдеры чуть уже
    'input[type=range]{width:64px;}' +
    // TX+MOX пинним в начало тулбара чтобы были видны без скролла
    '#toneBtn{order:-3;}'  +
    '.tx-g{order:-2;}'     +
    '#moxBtn{order:-1;min-width:52px;height:30px;font-weight:bold;}' +
    // iOS Safari: 100vh включает адресную строку — используем реальную высоту
    'body,.wrap{min-height:-webkit-fill-available;}' +
    '}' +
    '</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 tx-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>' +
    // MIC gain — чисто web-side (Web Audio GainNode перед Opus-кодером). Сервер не знает.
    '<div class="sl-g tx-g"><span class="sl-lb">MIC</span><input type="range" id="micSl" min="0" max="300" value="100"><span class="sl-val" id="micVal">100%</span></div>' +
    '<button id="moxBtn">MOX</button>' +
    '<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>' +
    '<option value="digi">DIGI</option><option value="dmr">DMR</option><option value="raw">FM RAW</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>' +
    '<div class="sl-g"><span class="sl-lb">ATT</span><input type="range" id="attSl" min="0" max="31" value="0"><span class="sl-val" id="attVal">0</span></div>' +
    '<button id="nbBtn">NB</button>' +
    '<button id="snbBtn">SNB</button>' +
    '<button id="anfBtn">ANF</button>' +
    '<button id="nrBtn">NR</button>' +
    '<button id="bcnBtn" title="QO-100 beacon lock" style="display:none">BCN</button>' +
    '<button id="rxMuteBtn" title="QO-100 RX self-monitor mute on TX" style="display:none">RX MUTE</button>' +
    // Pluto RF-AGC (hw gain): кнопка → оверлей FAST/SLOW/HYBRID/MANUAL + ползунок (MANUAL). Видна только на Pluto.
    '<button id="rfagcBtn" title="Pluto RF AGC" style="display:none">RF AGC</button>' +
    '<span class="sl-sep">|</span>' +
    '<button id="devBtn" title="Devices">DEV</button>' +
    '<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 class="vfo-sp" 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 class="sb-cell sb-status" id="stEl">connecting...</span>' +
    '<span class="sb-cell" id="sbBoard">Board --</span>' +
    '<span class="sb-cell" id="sbIP">IP --</span>' +
    '<span class="sb-cell" id="sbSupply">Supply --</span>' +
    '<span class="sb-cell" id="sbPLL">PLL --</span>' +
    '<span class="sb-cell sb-dim" id="sbRX">RX idle</span>' +
    '<span class="sb-cell sb-dim" id="sbTX">TX idle</span>' +
    '<span class="sb-cell sb-seq" id="sbSEQ">SEQ --</span>' +
    '<span class="sb-cell sb-time" id="sbUTC">UTC --:--:--</span>' +
    '<span class="sb-cell sb-time" id="sbLocal">Local --:--:--</span>' +
    '<span class="sb-lat" 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";' +
    'const FM_STEP_HZ=[6250,12500,20000,25000];' +
    'const FM_STEP_LBL=["6.25k","12.5k","20k","25k"];' +
    'let fmStepIdx=3,lastHFStep=100,inFMStep=false;' +
    'let localCenter=14200000,localSpan=192000;' +  // локальные копии — обновляются мгновенно при действии пользователя
    'let dragging=false,dragX0=0,dragCen0=0,dragVfo0=0,dragMs=0,wheelActiveTs=0;' +
    'let hoverX=-1,wTarget="",markerOn=false,markerX=0;' +
    'let beaconArm=false;' +   // одноразовое наведение после включения BCN (как десктоп)

    '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 sb(id,txt){const e=q(id);if(e){e.textContent=txt||"";e.title=txt||"";}}' +
    'function fmtTime(d,utc){const p=n=>String(n).padStart(2,"0");return(utc?p(d.getUTCHours()):p(d.getHours()))+":"+p(utc?d.getUTCMinutes():d.getMinutes())+":"+p(utc?d.getUTCSeconds():d.getSeconds());}' +
    'function updClock(){const d=new Date();sb("sbUTC","UTC "+fmtTime(d,true));sb("sbLocal","Local "+fmtTime(d,false));}' +
    'function updStatusBar(){sb("stEl",cur.status_text||((cur.connected?"OK":"OFF")+" | "+(cur.running?"RUN":"STOP")));sb("sbBoard",cur.board_text||"Board --");sb("sbIP",cur.ip_text||"IP --");sb("sbSupply",cur.supply_text||"Supply --");sb("sbPLL",cur.beacon_visible?(cur.beacon_text||"BCN --"):(cur.pll_text||"PLL --"));sb("sbRX",cur.rx_text||"RX idle");sb("sbTX",cur.tx_text||"TX idle");sb("sbSEQ",cur.seq_text||"SEQ --");}' +
    'updClock();setInterval(updClock,1000);' +
    '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);}' +
    'function updBcnBtn(){const b=q("bcnBtn");if(!b)return;b.style.display=cur.beacon_visible?"":"none";sw("bcnBtn",!!cur.beacon_on);}' +
    'function updRxMute(){const b=q("rxMuteBtn");if(!b)return;b.style.display=cur.rx_mute_visible?"":"none";sw("rxMuteBtn",!!cur.rx_mute_on);}' +
    'function rfagcModeName(m){m=m|0;return m===1?"FAST":m===2?"SLOW":m===3?"HYB":"MAN";}' +
    'function updRfAgc(){const b=q("rfagcBtn");if(!b)return;b.style.display=cur.rf_agc_visible?"":"none";b.textContent="RF AGC "+rfagcModeName(cur.rx_gain_mode);if(rfagcEls)rfagcRefresh();}' +


    'const MODE_N=["LSB","USB","DSB","CWL","CWU","FM","AM","SAM","DIGU","DIGL","WFM","DMR","FM RAW"];' +
    'const M_LSB=MODE_N.indexOf("LSB"),M_USB=MODE_N.indexOf("USB"),M_DSB=MODE_N.indexOf("DSB"),M_CWL=MODE_N.indexOf("CWL"),M_CWU=MODE_N.indexOf("CWU"),M_FM=MODE_N.indexOf("FM"),M_AM=MODE_N.indexOf("AM"),M_SAM=MODE_N.indexOf("SAM"),M_DIGU=MODE_N.indexOf("DIGU"),M_DIGL=MODE_N.indexOf("DIGL"),M_WFM=MODE_N.indexOf("WFM"),M_DMR=MODE_N.indexOf("DMR"),M_FMRAW=MODE_N.indexOf("FM RAW");' +
    'const GRP_MAP={ssb:[M_LSB,M_USB],cw:[M_CWL,M_CWU],fm:[M_FM,M_WFM],am:[M_AM,M_SAM],digi:[M_DIGU,M_DIGL],dmr:[M_DMR,M_DMR],raw:[M_FMRAW,M_FMRAW]};' +
    'const MODE_TO_GRP=[];Object.entries(GRP_MAP).forEach(([g,ms])=>ms.forEach(m=>MODE_TO_GRP[m]=g));MODE_TO_GRP[M_DSB]="ssb";' +
    '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;const f=q("fnpBtn");if(f)f.style.display=(m===M_DMR)?"none":"";updModTglLabel();}' +
    'q("modGrpSel").onchange=function(){const grp=GRP_MAP[modGrp()];const defaultMode=grp[0];cur.mode=defaultMode;syncModGrp(defaultMode);pend("mode");ws2({cmd:"mode",mode:defaultMode});};' +
    'q("modTglBtn").onclick=function(){audioKick();const next=modTglTarget();cur.mode=next;syncModGrp(next);pend("mode");ws2({cmd:"mode",mode:next});};' +
    'q("agcSel").onchange=function(e){pend("agc");ws2({cmd:"agc",mode:+e.target.value});};' +
    'q("attSl").oninput=function(e){var v=+e.target.value;var lbl=q("attVal");if(lbl)lbl.textContent=v;pend("attn");ws2({cmd:"attn",db:v});};' +

    // FNP — фильтровый оверлей
    'var FNP_N={ssb:["5.0k","4.4k","3.8k","3.3k","2.9k","2.7k","2.4k","2.1k","1.8k","1.0k"],' +
               'dsb:["16k","12k","10k","8.0k","6.6k","5.2k","4.0k","3.1k","2.9k","2.4k"],' +
               'cw:["1.0k","800","600","500","400","250","150","100","50","25"],' +
               'am:["20k","18k","16k","12k","10k","9.0k","8.0k","7.0k","6.0k","5.0k"],' +
               'fm:["NFM","FM"],' +
               'wfm:["200k","160k","120k"],' +
               'dmr:["12.5k"],' +
               'raw:["6.25k","10k","12.5k","20k","25k"],' +
               'digi:["3.0k","2.7k","2.4k","2.1k","1.8k","1.5k","1.2k","900","600","300"]};' +
    'var FNP_BW={ssb:[5000,4400,3800,3300,2900,2700,2400,2100,1800,1000],' +
                'dsb:[16000,12000,10000,8000,6600,5200,4000,3100,2900,2400],' +
                'cw:[1000,800,600,500,400,250,150,100,50,25],' +
                'am:[20000,18000,16000,12000,10000,9000,8000,7000,6000,5000],' +
                'fm:[11000,16000],' +
                'wfm:[200000,160000,120000],' +
                'dmr:[12500],' +
                'raw:[6250,10000,12500,20000,25000],' +
                'digi:[3000,2700,2400,2100,1800,1500,1200,900,600,300]};' +
    'var fnpOv=null;' +
    'function fnpGrp(){var m=cur.mode|0;return(m===M_CWL||m===M_CWU)?"cw":(m===M_FM)?"fm":(m===M_WFM)?"wfm":(m===M_DMR)?"dmr":(m===M_FMRAW)?"raw":(m===M_DIGU||m===M_DIGL)?"digi":(m===M_DSB)?"dsb":(m===M_AM||m===M_SAM)?"am":"ssb";}' +
    'function closeFnp(){if(fnpOv){fnpOv.remove();fnpOv=null;}document.removeEventListener("click",fnpOutside);}' +
    'function fnpOutside(e){if(fnpOv&&!fnpOv.contains(e.target)&&e.target!==q("fnpBtn"))closeFnp();}' +
    'function showFnp(){' +
      'if((cur.mode|0)===M_DMR)return;' +
      'if(fnpOv){closeFnp();return;}' +
      'var g=fnpGrp(),ns=FNP_N[g],bws=FNP_BW[g],cb=cur.filter_bw||2700;' +
      'fnpOv=document.createElement("div");' +
      'fnpOv.style.cssText="position:fixed;top:50%;left:50%;transform:translate(-50%,-50%);' +
        'background:rgba(6,14,6,0.93);border:1px solid rgba(70,120,70,0.65);border-radius:8px;' +
        'padding:12px 14px;z-index:2000;backdrop-filter:blur(6px);box-shadow:0 4px 24px rgba(0,0,0,0.7);min-width:260px;";' +
      // заголовок
      'var hd=document.createElement("div");' +
      'hd.style.cssText="display:flex;justify-content:space-between;align-items:center;margin-bottom:10px;";' +
      'var tl=document.createElement("span");' +
      'tl.textContent="Filter — "+g.toUpperCase();' +
      'tl.style.cssText="color:#80b880;font-family:monospace;font-size:12px;letter-spacing:1px;";' +
      'var xb=document.createElement("button");' +
      'xb.textContent="×";' +
      'xb.style.cssText="background:none;border:none;color:#6a9a6a;font-size:16px;cursor:pointer;padding:0 2px;line-height:1;";' +
      'xb.onclick=closeFnp;' +
      'hd.appendChild(tl);hd.appendChild(xb);fnpOv.appendChild(hd);' +
      // сетка кнопок
      'var gd=document.createElement("div");' +
      'var cols=(g==="fm")?2:5;' +
      'gd.style.cssText="display:grid;grid-template-columns:repeat("+cols+",1fr);gap:5px;";' +
      'ns.forEach(function(n,i){' +
        'var b=document.createElement("button"),act=bws[i]===cb;' +
        'b.textContent=n;' +
        'b.style.cssText="background:"+(act?"rgba(25,70,25,0.95)":"rgba(12,28,12,0.85)")+";' +
          'color:"+(act?"#b0ffb0":"#7aaa7a")+";border:1px solid "+(act?"rgba(120,220,120,0.7)":"rgba(55,95,55,0.45)")+";' +
          'border-radius:4px;padding:5px 0;font-family:monospace;font-size:12px;cursor:pointer;' +
          'transition:background 0.1s;";' +
        'b.onmouseenter=function(){if(!act)this.style.background="rgba(20,50,20,0.95)";}; ' +
        'b.onmouseleave=function(){if(!act)this.style.background="rgba(12,28,12,0.85)";}; ' +
        'b.onclick=function(){cur.filter_bw=bws[i];pend("filter");ws2({cmd:"filter",bw:bws[i]});closeFnp();};' +
        'gd.appendChild(b);' +
      '});' +
      'fnpOv.appendChild(gd);' +
      'document.body.appendChild(fnpOv);' +
      'setTimeout(function(){document.addEventListener("click",fnpOutside);},0);' +
    '}' +
    'q("fnpBtn").onclick=showFnp;' +

    // ── Device discovery / connect / saved-CRUD overlay (паритет с десктоп-диалогом) ──
    'var devOv=null,devList=null;' +
    'function closeDev(){if(devOv){devOv.remove();devOv=null;devList=null;}}' +
    'function devBtnS(bg,fg,bd){return "background:"+bg+";color:"+fg+";border:1px solid "+bd+";border-radius:4px;padding:4px 8px;font-family:monospace;font-size:11px;cursor:pointer;margin-left:4px;";}' +
    'function renderDevList(){if(!devList)return;devList.innerHTML="";' +
      'var devs=cur.devices||[];var conn=!!cur.connected;' +
      'function mkRow(d,sIdx){' +
        'var row=document.createElement("div");' +
        'row.style.cssText="display:flex;align-items:center;justify-content:space-between;gap:6px;padding:5px 6px;border:1px solid rgba(55,95,55,0.4);border-radius:4px;margin-bottom:4px;background:rgba(12,28,12,0.7);";' +
        'var lbl=document.createElement("span");' +
        'lbl.textContent=d.saved?((d.autostart?"★ ":"")+(d.name||"HPSDR")+"  "+d.ip):d.name;' +
        'lbl.style.cssText="color:#9cc99c;font-family:monospace;font-size:11px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;";' +
        'var btns=document.createElement("span");btns.style.cssText="display:flex;flex:none;";' +
        'var cb=document.createElement("button");cb.textContent="Connect";' +
        'cb.style.cssText=devBtnS("rgba(25,70,25,0.95)","#b0ffb0","rgba(120,220,120,0.7)");' +
        'cb.onclick=function(){ws2({cmd:"connect",ip:d.ip});closeDev();};if(conn)cb.disabled=true;' +
        'btns.appendChild(cb);' +
        'if(d.saved){' +
          'var ab=document.createElement("button");ab.textContent="★";ab.title="Auto-start";' +
          'ab.style.cssText=devBtnS("rgba(12,28,12,0.85)",d.autostart?"#ffd060":"#7aaa7a","rgba(55,95,55,0.45)");' +
          'ab.onclick=function(){ws2({cmd:"dev_autostart",idx:sIdx});};' +
          'var rb=document.createElement("button");rb.textContent="✕";rb.title="Remove";' +
          'rb.style.cssText=devBtnS("rgba(40,12,12,0.85)","#cc8080","rgba(95,55,55,0.45)");' +
          'rb.onclick=function(){ws2({cmd:"dev_remove",idx:sIdx});};' +
          'btns.appendChild(ab);btns.appendChild(rb);' +
        '}else{' +
          'var sv=document.createElement("button");sv.textContent="Save";' +
          'sv.style.cssText=devBtnS("rgba(12,28,12,0.85)","#7aaa7a","rgba(55,95,55,0.45)");' +
          'sv.onclick=function(){ws2({cmd:"dev_add",name:d.name||"",ip:d.ip});};' +
          'btns.appendChild(sv);' +
        '}' +
        'row.appendChild(lbl);row.appendChild(btns);return row;' +
      '}' +
      'var si=-1,hasSaved=false,hasFound=false;' +
      'devs.forEach(function(d){if(!d.saved)return;si++;' +
        'if(!hasSaved){var h=document.createElement("div");h.textContent="Saved";h.style.cssText="color:#6a9a6a;font-size:10px;margin:2px 0 4px;letter-spacing:1px;";devList.appendChild(h);hasSaved=true;}' +
        'devList.appendChild(mkRow(d,si));});' +
      'devs.forEach(function(d){if(d.saved)return;' +
        'if(!hasFound){var h=document.createElement("div");h.textContent="Discovered";h.style.cssText="color:#6a9a6a;font-size:10px;margin:8px 0 4px;letter-spacing:1px;";devList.appendChild(h);hasFound=true;}' +
        'devList.appendChild(mkRow(d,-1));});' +
      'if(!hasSaved&&!hasFound){var e=document.createElement("div");e.textContent="No devices — Discover or add manually";e.style.cssText="color:#6a8a6a;font-size:11px;font-style:italic;padding:6px 2px;";devList.appendChild(e);}' +
    '}' +
    'function showDev(){' +
      'if(devOv){closeDev();return;}' +
      'devOv=document.createElement("div");' +
      'devOv.style.cssText="position:fixed;top:50%;left:50%;transform:translate(-50%,-50%);background:rgba(6,14,6,0.95);border:1px solid rgba(70,120,70,0.65);border-radius:8px;padding:14px 16px;z-index:2000;backdrop-filter:blur(6px);box-shadow:0 4px 24px rgba(0,0,0,0.7);width:340px;max-width:92vw;max-height:80vh;overflow:auto;";' +
      'var hd=document.createElement("div");hd.style.cssText="display:flex;justify-content:space-between;align-items:center;margin-bottom:10px;";' +
      'var tl=document.createElement("span");tl.textContent="Devices";tl.style.cssText="color:#80b880;font-family:monospace;font-size:12px;letter-spacing:1px;";' +
      'var xb=document.createElement("button");xb.textContent="×";xb.style.cssText="background:none;border:none;color:#6a9a6a;font-size:16px;cursor:pointer;padding:0 2px;line-height:1;";xb.onclick=closeDev;' +
      'hd.appendChild(tl);hd.appendChild(xb);devOv.appendChild(hd);' +
      'var ar=document.createElement("div");ar.style.cssText="display:flex;gap:6px;margin-bottom:10px;";' +
      'var db=document.createElement("button");db.textContent="Discover";db.style.cssText=devBtnS("rgba(25,55,80,0.95)","#a0d0ff","rgba(90,150,210,0.7)")+"margin-left:0;flex:1;";db.onclick=function(){ws2({cmd:"discover"});};ar.appendChild(db);' +
      'var dc=document.createElement("button");dc.textContent="Disconnect";dc.style.cssText=devBtnS("rgba(60,20,20,0.9)","#e0a0a0","rgba(150,80,80,0.6)")+"margin-left:0;flex:1;";dc.onclick=function(){ws2({cmd:"disconnect"});closeDev();};if(!cur.connected)dc.disabled=true;ar.appendChild(dc);' +
      'devOv.appendChild(ar);' +
      'devList=document.createElement("div");devOv.appendChild(devList);' +
      'var mf=document.createElement("div");mf.style.cssText="margin-top:12px;border-top:1px solid rgba(55,95,55,0.35);padding-top:10px;display:flex;flex-direction:column;gap:6px;";' +
      'var inS="background:rgba(0,0,0,0.4);border:1px solid rgba(55,95,55,0.5);border-radius:4px;color:#b0e0b0;font-family:monospace;font-size:11px;padding:5px 6px;width:100%;box-sizing:border-box;";' +
      'var nm=document.createElement("input");nm.placeholder="Name (optional)";nm.style.cssText=inS;' +
      'var ip=document.createElement("input");ip.placeholder="IP address";ip.style.cssText=inS;' +
      'mf.appendChild(nm);mf.appendChild(ip);' +
      'var mb=document.createElement("div");mb.style.cssText="display:flex;gap:6px;";' +
      'var cob=document.createElement("button");cob.textContent="Connect";cob.style.cssText=devBtnS("rgba(25,70,25,0.95)","#b0ffb0","rgba(120,220,120,0.7)")+"margin-left:0;flex:1;";cob.onclick=function(){var v=ip.value.trim();if(v){ws2({cmd:"connect",ip:v});closeDev();}};' +
      'var svb=document.createElement("button");svb.textContent="Save";svb.style.cssText=devBtnS("rgba(12,28,12,0.85)","#7aaa7a","rgba(55,95,55,0.45)")+"margin-left:0;flex:1;";svb.onclick=function(){var v=ip.value.trim();if(v){ws2({cmd:"dev_add",name:nm.value.trim(),ip:v});nm.value="";ip.value="";}};' +
      'mb.appendChild(cob);mb.appendChild(svb);mf.appendChild(mb);devOv.appendChild(mf);' +
      'renderDevList();document.body.appendChild(devOv);' +
    '}' +
    'q("devBtn").onclick=showDev;' +

    '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=+e.target.value;var lbl=q("txVal");if(lbl)lbl.textContent=v;pend("drive");ws2({cmd:"drive",v:v});};' +
    // MIC gain — только клиент, GainNode применяется к захваченному микрофону до Opus.
    // Сохраняется в localStorage. 100 = 1.0× (нейтрально), 300 = 3.0× (макс).
    'let micGainVal=parseInt(localStorage.getItem("micGain")||"100",10);if(!isFinite(micGainVal))micGainVal=100;' +
    '(function(){var s=q("micSl"),l=q("micVal");if(!s)return;s.value=micGainVal;if(l)l.textContent=micGainVal+"%";s.oninput=function(e){var v=+e.target.value;micGainVal=v;if(l)l.textContent=v+"%";if(txMicGain)txMicGain.gain.value=v/100;try{localStorage.setItem("micGain",String(v));}catch(ex){}};})();' +
    'function updMoxBtn(tx){var b=q("moxBtn");if(!b)return;b.textContent=tx?"TX":"MOX";b.classList.toggle("mox-on",!!tx);}' +
    // TX-mic пре-инициализация в user-gesture: AudioContext СОЗДАЁТСЯ И РЕЗЮМИТСЯ в самом click,
    // ДО любого await — иначе на Android (особенно Honor/HarmonyOS) после await getUserMedia
    // user-gesture считается истёкшим и AudioContext навсегда остаётся в suspended.
    'q("moxBtn").onclick=function(){audioKick();cur.transmitting=!cur.transmitting;pend("mox");updMoxBtn(cur.transmitting);if(cur.transmitting){if(!txMicACtx){try{txMicACtx=new AudioContext({sampleRate:48000});}catch(e){}}if(txMicACtx&&txMicACtx.state!=="running")txMicACtx.resume();startTXMic();}cmd("set_mox",cur.transmitting);};' +
    'function updToneBtn(t){var b=q("toneBtn");if(!b)return;b.classList.toggle("on",!!t);}' +
    'q("toneBtn").onclick=function(){audioKick();cur.tuning=!cur.tuning;pend("tun");updToneBtn(cur.tuning);cmd("set_tun",cur.tuning);};' +

    // 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("bcnBtn").onclick=function(){cur.beacon_on=!cur.beacon_on;beaconArm=cur.beacon_on;updBcnBtn();pend("beacon");cmd("set_beacon",cur.beacon_on);};' +
    'q("rxMuteBtn").onclick=function(){cur.rx_mute_on=!cur.rx_mute_on;updRxMute();pend("rxmute");cmd("set_rx_mute",cur.rx_mute_on);};' +
    // RF-AGC оверлей: кнопка RF AGC → попап FAST/SLOW/HYBRID/MANUAL + ползунок (активен в MANUAL).
    'var rfagcOv=null,rfagcEls=null;' +
    'var RFAGC_MODES=[["FAST",1],["SLOW",2],["HYBRID",3],["MANUAL",0]];' +
    'function closeRfagc(){if(rfagcOv){rfagcOv.remove();rfagcOv=null;rfagcEls=null;}document.removeEventListener("click",rfagcOutside);}' +
    'function rfagcOutside(e){if(rfagcOv&&!rfagcOv.contains(e.target)&&e.target!==q("rfagcBtn"))closeRfagc();}' +
    'function rfagcRefresh(){if(!rfagcEls)return;var m=cur.rx_gain_mode|0,g=cur.rx_gain_db|0;' +
      'rfagcEls.btns.forEach(function(o){var a=o.mode===m;o.el.style.background=a?"rgba(25,70,25,0.95)":"rgba(12,28,12,0.85)";o.el.style.color=a?"#b0ffb0":"#7aaa7a";o.el.style.borderColor=a?"rgba(120,220,120,0.7)":"rgba(55,95,55,0.45)";});' +
      'if(!rfagcEls.sl.matches(":active"))rfagcEls.sl.value=g;rfagcEls.sl.disabled=(m!==0);rfagcEls.sl.style.opacity=(m!==0)?"0.4":"1";rfagcEls.val.textContent=g+"dB";}' +
    'function showRfagc(){' +
      'if(rfagcOv){closeRfagc();return;}' +
      'rfagcOv=document.createElement("div");' +
      'rfagcOv.style.cssText="position:fixed;top:50%;left:50%;transform:translate(-50%,-50%);background:rgba(6,14,6,0.93);border:1px solid rgba(70,120,70,0.65);border-radius:8px;padding:12px 14px;z-index:2000;backdrop-filter:blur(6px);box-shadow:0 4px 24px rgba(0,0,0,0.7);min-width:240px;";' +
      'var hd=document.createElement("div");hd.style.cssText="display:flex;justify-content:space-between;align-items:center;margin-bottom:10px;";' +
      'var tl=document.createElement("span");tl.textContent="RF AGC";tl.style.cssText="color:#80b880;font-family:monospace;font-size:12px;letter-spacing:1px;";' +
      'var xb=document.createElement("button");xb.textContent="×";xb.style.cssText="background:none;border:none;color:#6a9a6a;font-size:16px;cursor:pointer;padding:0 2px;line-height:1;";xb.onclick=closeRfagc;' +
      'hd.appendChild(tl);hd.appendChild(xb);rfagcOv.appendChild(hd);' +
      'var gd=document.createElement("div");gd.style.cssText="display:grid;grid-template-columns:repeat(2,1fr);gap:5px;margin-bottom:10px;";' +
      'var btns=[];' +
      'RFAGC_MODES.forEach(function(pm){var b=document.createElement("button");b.textContent=pm[0];b.style.cssText="border:1px solid;border-radius:4px;padding:6px 0;font-family:monospace;font-size:12px;cursor:pointer;transition:background 0.1s;";' +
        'b.onclick=function(){cur.rx_gain_mode=pm[1];pend("rxgain");ws2({cmd:"set_rx_gain_mode",mode:pm[1]});rfagcRefresh();updRfAgc();};' +
        'gd.appendChild(b);btns.push({el:b,mode:pm[1]});});' +
      'rfagcOv.appendChild(gd);' +
      'var sr=document.createElement("div");sr.style.cssText="display:flex;align-items:center;gap:8px;";' +
      'var lb=document.createElement("span");lb.textContent="GAIN";lb.style.cssText="color:#7aaa7a;font-family:monospace;font-size:11px;";' +
      'var sl=document.createElement("input");sl.type="range";sl.min="0";sl.max="73";sl.value=String(cur.rx_gain_db|0);sl.style.cssText="flex:1;";' +
      'var vv=document.createElement("span");vv.style.cssText="color:#9c9;font-family:monospace;font-size:11px;min-width:34px;text-align:right;";vv.textContent=(cur.rx_gain_db|0)+"dB";' +
      'sl.oninput=function(e){var v=+e.target.value;cur.rx_gain_db=v;cur.rx_gain_mode=0;vv.textContent=v+"dB";pend("rxgain");ws2({cmd:"set_rx_gain",db:v});rfagcRefresh();updRfAgc();};' +
      'sr.appendChild(lb);sr.appendChild(sl);sr.appendChild(vv);rfagcOv.appendChild(sr);' +
      'rfagcEls={btns:btns,sl:sl,val:vv};rfagcRefresh();' +
      'document.body.appendChild(rfagcOv);' +
      'setTimeout(function(){document.addEventListener("click",rfagcOutside);},0);' +
    '}' +
    'q("rfagcBtn").onclick=showRfagc;' +
    '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);}' +
    // Подключённы → STOP (disconnect). Иначе → device-overlay (выбор/подключение).
    // set_run:true больше не открывает устройство (web стартует через connect).
    'q("startStopBtn").onclick=function(){audioKick();if(cur.connected||cur.running){ws2({cmd:"disconnect"});cur.running=false;pend("run");updRunBtn(false);}else{showDev();}};' +

    'function updMuteIc(){var mi=q("muteIc");if(mi)mi.textContent=cur.mute?"\uD83D\uDD07":"\uD83D\uDD0A";}' +
    'q("muteIc").onclick=()=>{cur.mute=!cur.mute;updMuteIc();pend("mute");cmd("set_mute",cur.mute);};' +

    'let stA=0,stAmem=100,stB=0,stBmem=100;' +
    'let freqMhzDigits=3,freqMaxHz=999999999;' +
    'let freqMin=30000,freqMax=60000000;' +   // диапазон бэкенда (из state.freq_min/max)
    'function fmtVfo(hz,step){' +
    'const N=freqMhzDigits+6;' +
    'const s=String(Math.max(0,Math.round(hz||0))).padStart(N,"0");' +
    'let h="",seenNZ=false;' +
    'for(let i=0;i<freqMhzDigits;i++){' +
    'const st=Math.pow(10,freqMhzDigits+5-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>";}' +
    'h+="<span class=\"sp\">.</span>";' +
    'for(let i=freqMhzDigits;i<freqMhzDigits+3;i++){' +
    'const st=Math.pow(10,freqMhzDigits+5-i);' +
    'const sel=(step>0&&st===step)?" sel":"";' +
    'h+="<span class=\"dg"+sel+"\" data-s=\""+st+"\">"+s[i]+"</span>";}' +
    'h+="<span class=\"sp\">.</span><span class=\"vfo-hz\">";' +
    'for(let i=freqMhzDigits+3;i<N;i++){' +
    'const st=Math.pow(10,freqMhzDigits+5-i);' +
    'const sel=(step>0&&st===step)?" sel":"";' +
    'h+="<span class=\"dg"+sel+"\" data-s=\""+st+"\">"+s[i]+"</span>";}' +
    'h+="</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,freqMaxHz);' +
    '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,freqMaxHz);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"){localCenter=nf;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"){localCenter=nf;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();pend("freq");ws2({cmd:"freq_b",hz:Math.round(vfoB)});};' +
    // A=B: copy B freq into A — always freq_a (sets VFO A regardless of active VFO)
    'q("btnAB").onclick=()=>{vfoA=vfoB;renderVfos();pend("freq");ws2({cmd:"freq_a",hz:Math.round(vfoA)});};' +
    // SWAP — freq_a always sets VFO A, freq_b always sets VFO B
    'q("btnSwap").onclick=()=>{const t=vfoA;vfoA=vfoB;vfoB=t;renderVfos();pend("freq");' +
    'ws2({cmd:"freq_a",hz:Math.round(vfoA)});ws2({cmd:"freq_b",hz:Math.round(vfoB)});};' +

    // SPAN/sample-rate кнопки: строятся из state.rate_presets (HPSDR и Pluto
    // имеют разные наборы; единый источник — backend caps в контроллере).
    'let SPANS=[48000,96000,192000,384000,768000,1536000];' +
    'function fmtRate(hz){return Math.round(hz/1000)+"K";}' +
    'function buildSpanButtons(){const c=q("spanOv");if(!c)return;c.innerHTML="";SPANS.forEach((hz,i)=>{const b=document.createElement("button");b.className="sp-btn";b.id="sb"+i;b.textContent=fmtRate(hz);b.onclick=()=>ws2({cmd:"span",hz});c.appendChild(b);});updSpan(localSpan);}' +
    'function updSpan(s){SPANS.forEach((hz,i)=>{const b=q("sb"+i);if(b)b.classList.toggle("on",hz===s);});}' +
    'buildSpanButtons();' +

    // BAND_N/BAND_F строятся из state.bands (HPSDR HF / Pluto VHF-UHF); ниже —
    // bootstrap-значения до первого state. Единый источник — backend в контроллере.
    'let BAND_N=["160m","80m","60m","40m","30m","20m","17m","15m","12m","10m","6m"];' +
    'let BAND_F=[1840000,3700000,5357000,7100000,10120000,14200000,18130000,21300000,24940000,28400000,50150000];' +
    // XVTR-band list, обновляется из state ("xvtr_bands":[{idx,name},...]).
    // Активное значение — "xvtr_current" (-1 = HF). В <select> используем
    // значения "h<n>" для HF и "x<idx>" для XVTR, чтобы различать.
    'let XVTR_BANDS=[];let XVTR_CUR=-1;' +
    'function rebuildBandSel(){const s=q("bandSel");if(!s)return;' +
    'const saved=s.value;s.innerHTML="";' +
    'BAND_N.forEach((n,i)=>{if(!n)return;const o=document.createElement("option");o.value="h"+i;o.textContent=n;s.appendChild(o);});' +
    'XVTR_BANDS.forEach(xb=>{const o=document.createElement("option");o.value="x"+xb.idx;o.textContent=xb.name||("X"+(xb.idx+1));s.appendChild(o);});' +
    // Восстановить выбор: предпочитаем "current XVTR"; иначе сохраним прежнее
    'if(XVTR_CUR>=0)s.value="x"+XVTR_CUR; else if(saved)s.value=saved;' +
    '}' +
    '(function(){const s=q("bandSel");BAND_N.forEach((n,i)=>{if(!n)return;const o=document.createElement("option");o.value="h"+i;o.textContent=n;s.appendChild(o);});' +
    's.onchange=()=>{const v=s.value;if(!v)return;if(v[0]==="x")ws2({cmd:"xvtr_band",idx:+v.slice(1)});else if(v[0]==="h")ws2({cmd:"band",idx:+v.slice(1)});};' +
    '})();' +
    'function updBand(vfo){if(XVTR_CUR>=0){q("bandSel").value="x"+XVTR_CUR;return;}let bi=0,bd=1e15;BAND_F.forEach((f,i)=>{if(!BAND_N[i])return;const d=Math.abs((vfo||0)-f);if(d<bd){bd=d;bi=i;}});q("bandSel").value="h"+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){' +
    'if(cur.transmitting||cur.tuning){paintTXMeter();return;}' +
    '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);' +
    'let sLbl;if(dbm>=-73){let over=Math.round(dbm+73);if(over<5)sLbl="S9";else{over=Math.floor((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.font="bold 12px Courier New";const lblDbm=dbm.toFixed(1),twDbm=mctx.measureText(lblDbm).width,maxTwDbm=mctx.measureText("-120.0").width;' +
    'mctx.font="8px Courier New";const labelNeed=Math.max(maxTwDbm+2+mctx.measureText("dBm ").width,mctx.measureText("S9+60").width)+8;' +
    'const leftInfo=Math.max(62,labelNeed+4),BX=leftInfo,BW=Math.max(10,w-leftInfo-8),lblZoneL=BX-labelNeed;' +
    'const YT=Math.round(h*44/100),YB=Math.round(h*58/100),barH=YB-YT;' +
    'const S9X=dbToX(-73,BX,BW),OVRX=dbToX(-43,BX,BW);' +
    'mctx.clearRect(0,0,w,h);' +
    'mctx.fillStyle="#001F09";mctx.fillRect(BX,YT,S9X-BX,barH);' +
    'mctx.fillStyle="#001F09";mctx.fillRect(S9X,YT,OVRX-S9X,barH);' +
    'mctx.fillStyle="#281509";mctx.fillRect(OVRX,YT,BX+BW-OVRX,barH);' +
    'const barEnd=dbToX(dbm,BX,BW);' +
    'if(barEnd>BX){if(barEnd<=S9X){mctx.fillStyle="#001F09";mctx.fillRect(BX,YT+1,barEnd-BX,barH-2);}' +
    'else{mctx.fillStyle="#001F09";mctx.fillRect(BX,YT+1,S9X-BX,barH-2);mctx.fillStyle="#281509";mctx.fillRect(S9X,YT+1,barEnd-S9X,barH-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.save();mctx.globalCompositeOperation="lighter";mctx.fillStyle="rgba(64,64,64,1)";mctx.fillRect(px1,YT+1,px2-px1,barH-2);mctx.restore();}' +
    'const pX=dbToX(smPeak,BX,BW);' +
    'if(pX>BX&&pX<=BX+BW){mctx.strokeStyle="#FFF8F8";mctx.lineWidth=2;mctx.beginPath();mctx.moveTo(pX,YT-barH);mctx.lineTo(pX,YB+barH);mctx.stroke();}' +
    'mctx.lineWidth=1;mctx.strokeStyle="#283030";mctx.strokeRect(lblZoneL,YT,BX+BW-lblZoneL,barH);' +
    'mctx.strokeStyle="#404040";mctx.beginPath();mctx.moveTo(BX,YT+1);mctx.lineTo(BX,YB-1);mctx.stroke();' +
    'mctx.font="8px 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-3);}' +
    '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?"#90A070":"#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+11);}' +
    'mctx.font="bold 12px Courier New";const th12=12;mctx.fillStyle="#DCD896";mctx.fillText(lblDbm,lblZoneL+4,YT-th12-2);' +
    'mctx.font="8px Courier New";mctx.fillStyle="#90A070";mctx.fillText("dBm",lblZoneL+4+twDbm+2,YT-10);' +
    'mctx.fillStyle="#F0F0F0";mctx.font="bold 12px Courier New";mctx.fillText(sLbl,lblZoneL+4,YB+14);}' +

    'function paintTXMeter(){' +
    'const w=smCv.clientWidth||340,h=smCv.clientHeight||64;' +
    'const fwdW=cur.fwd_w||0,swr=Math.max(1,cur.swr||1),maxW=cur.pa_max_power||100;' +
    'const SWR_HIGH=2.5,SWR_MAX=10.0,TICK_LONG=5;' +
    'const swrHigh=swr>SWR_HIGH;' +
    'mctx.font="bold 12px Courier New";' +
    'const lblPwr=fwdW.toFixed(0)+"W",lblSWR="SWR:"+swr.toFixed(1);' +
    'const maxTWPwr=mctx.measureText(maxW.toFixed(0)+"W").width;' +
    'const maxTWSWR=mctx.measureText("SWR:10.0").width;' +
    'mctx.font="8px Courier New";' +
    'const labelNeed=Math.max(maxTWPwr,maxTWSWR)+8;' +
    'const leftInfo=Math.max(62,labelNeed+4),BX=leftInfo,BW=Math.max(10,w-leftInfo-8),lblZoneL=BX-labelNeed;' +
    'const YT=Math.round(h*44/100),YB=Math.round(h*58/100);' +
    'const YM=Math.round((YT+YB)/2);' +
    'const PY1=YT,PY2=YM-1,SY1=YM+1,SY2=YB;' +
    'const swarnX=BX+Math.round(Math.max(0,Math.min(1,(SWR_HIGH-1)/(SWR_MAX-1)))*BW);' +
    'function pwrX(p){return BX+Math.round(Math.max(0,Math.min(1,p))*BW);}' +
    'function swrX(s){return BX+Math.round(Math.max(0,Math.min(1,(s-1)/(SWR_MAX-1)))*BW);}' +
    'mctx.clearRect(0,0,w,h);' +
    // фоновые зоны
    'mctx.fillStyle="#001F09";mctx.fillRect(BX,PY1,BW,PY2-PY1);' +
    'mctx.fillStyle="#001F09";mctx.fillRect(BX,SY1,swarnX-BX,SY2-SY1);' +
    'mctx.fillStyle="#281509";mctx.fillRect(swarnX,SY1,BX+BW-swarnX,SY2-SY1);' +
    // бар мощности
    'const pEnd=pwrX(maxW>0?fwdW/maxW:0);' +
    'if(pEnd>BX){mctx.fillStyle="#44CC00";mctx.fillRect(BX,PY1+1,pEnd-BX,PY2-PY1-2);}' +
    // бар КСВ
    'const sEnd=swrX(swr);' +
    'if(sEnd>BX){' +
    'if(!swrHigh){mctx.fillStyle="#FFAA00";mctx.fillRect(BX,SY1+1,sEnd-BX,SY2-SY1-2);}' +
    'else{const safeEnd=Math.min(swarnX,sEnd);' +
    'if(safeEnd>BX){mctx.fillStyle="#FFAA00";mctx.fillRect(BX,SY1+1,safeEnd-BX,SY2-SY1-2);}' +
    'if(sEnd>swarnX){mctx.fillStyle="#CC0000";mctx.fillRect(swarnX,SY1+1,sEnd-swarnX,SY2-SY1-2);}}}' +
    // делитель 2.5
    'mctx.strokeStyle="#404040";mctx.lineWidth=1;mctx.beginPath();mctx.moveTo(swarnX,SY1);mctx.lineTo(swarnX,SY2);mctx.stroke();' +
    // рамки
    'mctx.lineWidth=1;mctx.strokeStyle="#283030";mctx.strokeRect(lblZoneL,PY1,BX+BW-lblZoneL,PY2-PY1);' +
    'mctx.strokeStyle=swrHigh?"#CC0000":"#283030";mctx.strokeRect(lblZoneL,SY1,BX+BW-lblZoneL,SY2-SY1);' +
    // разделители зона надписей / шкала
    'mctx.strokeStyle="#404040";mctx.lineWidth=1;' +
    'mctx.beginPath();mctx.moveTo(BX,PY1+1);mctx.lineTo(BX,PY2-1);mctx.stroke();' +
    'mctx.beginPath();mctx.moveTo(BX,SY1+1);mctx.lineTo(BX,SY2-1);mctx.stroke();' +
    // тики мощности выше power-бара
    'mctx.font="8px Courier New";const TICK_MED=3;' +
    // промежуточные тики каждые 5%, пропуская кратные 25%
    'for(let p=5;p<100;p+=5){if(p%25!==0){const x=pwrX(p/100);' +
    'mctx.strokeStyle="#708070";mctx.lineWidth=1;mctx.beginPath();mctx.moveTo(x,PY1-TICK_MED);mctx.lineTo(x,PY1-1);mctx.stroke();}}' +
    // главные тики с подписями
    'const pwrMarks=[0,0.25,0.5,0.75,1.0];' +
    'for(let i=0;i<pwrMarks.length;i++){const x=pwrX(pwrMarks[i]);' +
    'mctx.strokeStyle="#DCE0E0";mctx.lineWidth=1;mctx.beginPath();mctx.moveTo(x,PY1-TICK_LONG);mctx.lineTo(x,PY1-1);mctx.stroke();' +
    'const t=(pwrMarks[i]*maxW).toFixed(0),tw=mctx.measureText(t).width;' +
    'mctx.fillStyle="#DCDCD0";mctx.fillText(t,Math.round(x-tw/2),PY1-TICK_LONG-3);}' +
    // промежуточные тики КСВ на целых 2,3,4,6,7,8,9
    'for(let s=2;s<=9;s++){if(s===5)continue;const x=swrX(s),isW=s>=3;' +
    'mctx.strokeStyle=isW?"#90A070":"#708070";mctx.lineWidth=1;mctx.beginPath();mctx.moveTo(x,SY2+1);mctx.lineTo(x,SY2+TICK_MED);mctx.stroke();}' +
    // тики КСВ ниже SWR-бара
    'const swrMarks=[1,2.5,5,10],swrLbls=["1","2.5","5","10"];' +
    'for(let i=0;i<swrMarks.length;i++){const x=swrX(swrMarks[i]),isW=swrMarks[i]>=SWR_HIGH;' +
    'mctx.strokeStyle=isW?"#90A070":"#708070";mctx.lineWidth=1;mctx.beginPath();mctx.moveTo(x,SY2+1);mctx.lineTo(x,SY2+TICK_LONG);mctx.stroke();' +
    'const t=swrLbls[i],tw=mctx.measureText(t).width;' +
    'mctx.fillStyle=isW?(swrHigh?"#CC0000":"#90D090"):"#E0E0E0";' +
    'mctx.fillText(t,Math.round(x-tw/2),SY2+TICK_LONG+11);}' +
    // надписи в левой зоне
    'mctx.font="bold 12px Courier New";' +
    'mctx.fillStyle="#DCD896";mctx.fillText(lblPwr,lblZoneL+4,PY1-12-2);' +
    'mctx.fillStyle=swrHigh?"#CC0000":"#F0F0F0";mctx.fillText(lblSWR,lblZoneL+4,SY2+14);' +
    // "SWR High" рядом с мощностью
    'if(swrHigh){const twP=mctx.measureText(lblPwr).width;' +
    'mctx.font="bold 8px Courier New";mctx.fillStyle="#CC0000";' +
    'mctx.fillText("SWR High",lblZoneL+4+twP+2,PY1-8);}}' +

    // 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",transmitting:"mox",drive:"drive",attn_db:"attn",tuning:"tun",filter_bw:"filter",mute:"mute",beacon_on:"beacon",rx_mute_on:"rxmute",rx_gain_mode:"rxgain",rx_gain_db:"rxgain"};' +

    'function applyState(msg){' +
    'Object.keys(msg).forEach(function(k){var pk=PEND_MAP[k];if(!pk||!isPending(pk))cur[k]=msg[k];});' +
    // На сервере нет libopus — аудио и микрофон мертвы молча, показываем баннер.
    'if(msg.opus!=null){srvOpus=!!msg.opus;setOpusWarn(!srvOpus);}' +
    // Перестраиваем bandSel если изменился список XVTR или текущий индекс
    'if(msg.xvtr_bands!=null||msg.xvtr_current!=null){' +
    'const newBands=msg.xvtr_bands||XVTR_BANDS;' +
    'const newCur=(msg.xvtr_current!=null)?msg.xvtr_current:XVTR_CUR;' +
    'const bandsChanged=JSON.stringify(newBands)!==JSON.stringify(XVTR_BANDS);' +
    'const curChanged=newCur!==XVTR_CUR;' +
    'XVTR_BANDS=newBands;XVTR_CUR=newCur;' +
    'if(bandsChanged)rebuildBandSel();' +
    'else if(curChanged){const s=q("bandSel");if(s&&XVTR_CUR>=0)s.value="x"+XVTR_CUR;}' +
    '}' +
    // Пресеты sample-rate сменились (другой бэкенд) — перестраиваем span-кнопки.
    'if(msg.rate_presets!=null&&Array.isArray(msg.rate_presets)&&msg.rate_presets.length){' +
    'if(JSON.stringify(msg.rate_presets)!==JSON.stringify(SPANS)){SPANS=msg.rate_presets;buildSpanButtons();}}' +
    // Band-план сменился (другой бэкенд: HF↔VHF/UHF) — перестраиваем band-selector.
    'if(msg.bands!=null&&Array.isArray(msg.bands)&&msg.bands.length){' +
    'const nn=msg.bands.map(function(b){return b.name;});' +
    'if(JSON.stringify(nn)!==JSON.stringify(BAND_N)){BAND_N=nn;BAND_F=msg.bands.map(function(b){return b.freq;});rebuildBandSel();}}' +
    'if(msg.freq_min!=null)freqMin=msg.freq_min;if(msg.freq_max!=null)freqMax=msg.freq_max;' +
    // Обновляем localCenter/localSpan из сервера только когда пользователь не тянет
    'if(!dragging&&Date.now()-wheelActiveTs>300){' +
    // В CTUN без DUP: TX-спектр WDSP центрирован по vfo_a_hz, а не по DDC LO.
    // В CTUN с DUP: показываем RX-спектр → center_hz остаётся.
    'if(msg.center_hz!=null)localCenter=(cur.transmitting&&cur.ctun&&!cur.dup)?cur.vfo_a_hz: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.freq_mhz_digits!=null){freqMhzDigits=Math.max(3,Math.min(5,msg.freq_mhz_digits|0));freqMaxHz=Math.pow(10,freqMhzDigits+6)-1;}' +
    'if(msg.fmstep_idx!=null){fmStepIdx=Math.max(0,Math.min(3,msg.fmstep_idx|0));if(inFMStep)syncStepSel();}' +
    '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("beacon"))updBcnBtn();' +
    'if(!isPending("rxmute"))updRxMute();updRfAgc();' +
    'if(!isPending("run"))updRunBtn(!!cur.running);' +
    'if(!isPending("mox"))updMoxBtn(!!cur.transmitting);' +
    'if(!isPending("tun"))updToneBtn(!!cur.tuning);' +
    'if(!isPending("mute"))updMuteIc();' +
    'if(!isPending("drive")){const ts=q("txSl");if(ts&&!ts.matches(":active")){ts.value=cur.drive||0;var tv=q("txVal");if(tv)tv.textContent=cur.drive||0;}}' +
    'if(!isPending("mode")){syncModGrp(cur.mode||0);syncStepSel();}' +
    'if(!isPending("attn")){const at=q("attSl");if(at&&!at.matches(":active")){at.value=cur.attn_db||0;var av=q("attVal");if(av)av.textContent=cur.attn_db||0;}}' +
    'updBand(activeVfo==="A"?(cur.vfo_a_hz||0):(vfoB||0));updSpan(cur.span_hz||192000);' +
    'paintSM(Number(cur.smeter_dbm||-130));' +
    'updStatusBar();' +
    'if(devList)renderDevList();' +
    'const nowTx=!!cur.transmitting;if(nowTx!==prevTx){prevTx=nowTx;if(nowTx)startTXMic();else stopTXMic();}}' +

    '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===M_LSB)return[-bw,-100];if(m===M_USB)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();}' +
    // Маркеры QO-100 маяка: опорная (зелёная пунктир) + ФИЛЬТР по ширине маяка из
    // ДВУХ ПОЛОВИНОК (две несущие/лепестка Manchester ±400Гц, нуль по центру).
    'var BCN_HALF=450;' +   // полуширина фильтра маяка, Гц (как десктоп dec-маркер)
    'function vbar(ctx,x,h,col,w){if(x<0||x>w)return;ctx.strokeStyle=col;ctx.lineWidth=1;ctx.beginPath();ctx.moveTo(x+.5,0);ctx.lineTo(x+.5,h);ctx.stroke();}' +
    'function drawBcn(ctx,w,h){if(!cur.beacon_on)return;ctx.save();' +
    // Опорная — где маяк ДОЛЖЕН быть.
    'if(cur.beacon_ref_hz>0){ctx.setLineDash([4,3]);vbar(ctx,hzToX(cur.beacon_ref_hz,w),h,"rgba(80,255,120,.85)",w);ctx.setLineDash([]);}' +
    // Фильтр маяка вокруг измеренной частоты: левая+правая половины (лепестки) с
    // разделом по центру (нуль несущей) и гранями полосы.
    'var t=cur.beacon_track_hz;' +
    'if(t>0){var xL=hzToX(t-BCN_HALF,w),xC=hzToX(t,w),xR=hzToX(t+BCN_HALF,w);' +
    'ctx.fillStyle="rgba(255,170,60,.14)";' +
    'ctx.fillRect(xL,0,Math.max(0,xC-xL-1),h);' +     // левая половина
    'ctx.fillRect(xC+1,0,Math.max(0,xR-xC-1),h);' +   // правая половина
    'vbar(ctx,xL,h,"rgba(255,170,60,.75)",w);vbar(ctx,xR,h,"rgba(255,170,60,.75)",w);' +  // грани
    'vbar(ctx,xC,h,"rgba(255,170,60,.4)",w);}' +       // центр (нуль/раздел)
    '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;' +
    'const isFM=(cur.mode===M_FM||cur.mode===M_DMR||cur.mode===M_FMRAW),fmStep=FM_STEP_HZ[fmStepIdx];' +
    'if(isFM&&fmStep>0){' +
    'const pxPerStep=w*fmStep/localSpan;' +
    'const labelMult=Math.max(1,Math.ceil((rctx.measureText("000.000").width+6)/pxPerStep));' +
    'const firstN=Math.ceil(st/fmStep);' +
    'for(let n=firstN;n*fmStep<=localCenter+localSpan/2+0.5;n++){' +
    'const f=n*fmStep,x=(f-st)/localSpan*w;if(x<0||x>=w)continue;' +
    'rctx.strokeStyle="#2a3a2e";rctx.lineWidth=1;rctx.beginPath();rctx.moveTo(x+.5,0);' +
    'if((n%labelMult)===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-rctx.measureText(t).width/2,21);}' +
    'else{rctx.lineTo(x+.5,4);rctx.stroke();}}}' +
    '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){' +
    'const fmSt=FM_STEP_HZ[fmStepIdx],st2=localCenter-localSpan/2;' +
    'const pxSt=w*fmSt/localSpan,gm=Math.max(1,Math.ceil(4/pxSt));' +
    'const fn=Math.ceil(st2/fmSt);' +
    'for(let n=fn;n*fmSt<=localCenter+localSpan/2+0.5;n++){' +
    'if((n%gm)!==0)continue;const x=(n*fmSt-st2)/localSpan*w;' +
    '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 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 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);}' +

    '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;' +
    // 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];}' +
    '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[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);' +
    'const vxw=hzToX(avf2,w);wctx.strokeStyle="#60ff80";wctx.lineWidth=1;wctx.beginPath();wctx.moveTo(vxw,0);wctx.lineTo(vxw,h);wctx.stroke();drawBcn(wctx,w,h);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 dpr=Math.max(1,devicePixelRatio||1),sw=smCv.clientWidth|0,sh=smCv.clientHeight|0;' +
    'if(smCv.width!==(sw*dpr|0)||smCv.height!==(sh*dpr|0)){smCv.width=sw*dpr|0;smCv.height=sh*dpr|0;mctx.setTransform(dpr,0,0,dpr,0,0);}' +
    '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 syncStepSel(){const isFM=(cur.mode===M_FM||cur.mode===M_DMR||cur.mode===M_FMRAW);const sel=q("stepSel");if(!sel)return;' +
    'if(isFM&&!inFMStep){lastHFStep=+sel.value||100;sel.innerHTML="";' +
    'FM_STEP_LBL.forEach((l,i)=>{const o=document.createElement("option");o.value=FM_STEP_HZ[i];o.textContent=l;sel.appendChild(o);});' +
    'sel.value=FM_STEP_HZ[fmStepIdx];' +
    'sel.onchange=function(){if(!inFMStep)return;const hz=+sel.value;const idx=FM_STEP_HZ.indexOf(hz);if(idx>=0){fmStepIdx=idx;ws2({cmd:"fm_step",idx:idx});};};' +
    'inFMStep=true;}' +
    'else if(!isFM&&inFMStep){sel.onchange=null;sel.innerHTML=["10 Hz","100 Hz","1 kHz","10 kHz"].map((l,i)=>`<option value="${[10,100,1000,10000][i]}">${l}</option>`).join("");' +
    'sel.value=lastHFStep;inFMStep=false;}' +
    'else if(isFM){sel.value=FM_STEP_HZ[fmStepIdx];}}' +
    '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});}' +
    // Границы перестройки берём из бэкенда (freqMin/freqMax из state), а не
    // хардкодим HF — иначе на Pluto (VHF/UHF) drag по спектру клампило в 60 МГц.
    'function vfoMin(){return XVTR_CUR>=0?0:Math.max(30000,freqMin);}' +
    'function vfoMax(){return XVTR_CUR>=0?999999999999:freqMax;}' +
    '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),vfoMin(),vfoMax());' +
    'setActiveVfoHz(nf);wheelActiveTs=Date.now();' +
    '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;' +
    // Наведение маяка — на mousedown с немедленным return (как десктоп FormMouseDown):
    // НЕ стартуем drag, чтобы не уехал set_center/VFO и не порвался IQ под локом.
    // Только когда «вооружено» (1-й клик после BCN) или с Shift (перенаведение).
    'if(cur.beacon_on&&(beaconArm||e.shiftKey)){beaconArm=false;' +
    'ws2({cmd:"beacon_seed",frac:cl(e.offsetX/Math.max(1,cv.clientWidth),0,1)});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),vfoMin(),vfoMax());' +
    '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){' +
    // клик — не трогаем localCenter: фильтр встаёт точно туда куда кликнули.
    // Спектр остаётся на месте, сервер пришлёт center_hz через ~200ms и всё перецентрируется.
    'const snap=((cur.mode===M_FM||cur.mode===M_DMR||cur.mode===M_FMRAW)&&inFMStep)?FM_STEP_HZ[fmStepIdx]:100;' +
    'const nf=cl(Math.round(xToHz(e.offsetX,cv.clientWidth)/snap)*snap,vfoMin(),vfoMax());' +
    'setActiveVfoHz(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);' +
    // Touch→mouse мост для канвасов: drag-to-pan и tap-to-tune на мобильных
    'function addTouch(cv){' +
    'function mkMs(type,t){return new MouseEvent(type,{bubbles:true,cancelable:true,clientX:t.clientX,clientY:t.clientY});}' +
    'cv.addEventListener("touchstart",e=>{e.preventDefault();audioKick();cv.dispatchEvent(mkMs("mousedown",e.touches[0]));},{passive:false});' +
    'cv.addEventListener("touchmove",e=>{e.preventDefault();cv.dispatchEvent(mkMs("mousemove",e.touches[0]));},{passive:false});' +
    'cv.addEventListener("touchend",e=>{e.preventDefault();cv.dispatchEvent(mkMs("mouseup",e.changedTouches[0]));},{passive:false});}' +
    'addTouch(specCv);addTouch(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=[];' +
    'let srvOpus=true,opusWarnEl=null,opusWarnHidden=false,audioOv=null;' +
    'let txMicStream=null,txMicACtx=null,txMicProc=null,txMicEnc=null,txMicGain=null,prevTx=false,txMicStarting=false;' +
    '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);}}' +

    // TX mic pipeline — persistent between MOX presses to avoid macOS/Android
    // hardware acquire/release delay (rapid stop+start fails silently on these OSes).
    // cleanupTXMic: full teardown, called only on WS disconnect.
    // stopTXMic: called on MOX off — keeps pipeline warm, just clears the guard flag.
    // startTXMic: if pipeline healthy — nothing to do; if broken stream — cleanup+restart.
    'function cleanupTXMic(){' +
    'txMicStarting=false;' +
    'if(txMicProc){txMicProc.disconnect();txMicProc=null;}' +
    'if(txMicGain){try{txMicGain.disconnect();}catch(ex){}txMicGain=null;}' +
    'if(txMicEnc){try{txMicEnc.close();}catch(ex){}txMicEnc=null;}' +
    'if(txMicACtx){txMicACtx.close();txMicACtx=null;}' +
    'if(txMicStream){txMicStream.getTracks().forEach(function(t){t.stop();});txMicStream=null;}}' +

    // stopTXMic: no-op. Поток заглушается флагом cur.transmitting в worklet/output callback.
    // Раньше тут сбрасывался txMicStarting — это создавало race: при быстром RX→TX во время
    // await getUserMedia запускался параллельный startTXMic с дублирующими ресурсами.
    'function stopTXMic(){}' +

    'async function startTXMic(){' +
    // Pipeline already running and healthy — just re-resume ctx (Android может уронить его в suspended между TX)
    'if(txMicStream&&txMicEnc&&txMicEnc.state==="configured"){if(txMicACtx&&txMicACtx.state!=="running"){try{await txMicACtx.resume();}catch(e){}}return;}' +
    // Broken state (stream alive but encoder dead) — full cleanup before restart
    'if(txMicStream)cleanupTXMic();' +
    'if(txMicStarting)return;' +
    'txMicStarting=true;' +
    'try{' +
    // autoGainControl:false ОБЯЗАТЕЛЬНО — иначе браузер нормализует громкость
    // и ручной MIC GainNode (txMicGain) не даёт никакого эффекта.
    'txMicStream=await navigator.mediaDevices.getUserMedia({audio:{channelCount:1,sampleRate:48000,echoCancellation:true,noiseSuppression:true,autoGainControl:false}});' +
    'if(!txMicStream)return;' +
    // Контекст обычно уже создан в click-обработчике (user-gesture); создаём fallback только если нет
    'if(!txMicACtx)txMicACtx=new AudioContext({sampleRate:48000});' +
    'if(txMicACtx.state!=="running"){try{await txMicACtx.resume();}catch(e){}}' +
    'const msrc=txMicACtx.createMediaStreamSource(txMicStream);' +
    'txMicEnc=new AudioEncoder({' +
    'output:(chunk)=>{' +
    'if(!cur.transmitting||!ws||ws.readyState!==1)return;' +
    'const pkt=new Uint8Array(1+chunk.byteLength);pkt[0]=0x4D;' +
    'chunk.copyTo(pkt.subarray(1));ws.send(pkt.buffer);},' +
    'error:(e)=>console.error("txenc:",e)});' +
    'txMicEnc.configure({codec:"opus",sampleRate:48000,numberOfChannels:1,bitrate:32000});' +
    // AudioWorklet processor: accumulates render quanta (128 samples) into 960-sample Opus frames
    'const wcSrc=' +
    '"class P extends AudioWorkletProcessor{" +' +
    '"constructor(){super();this.a=new Float32Array(960);this.p=0;}" +' +
    '"process(inp){const d=inp[0]&&inp[0][0];if(!d)return true;" +' +
    '"let o=0;while(o<d.length){const t=Math.min(960-this.p,d.length-o);" +' +
    '"this.a.set(d.subarray(o,o+t),this.p);this.p+=t;o+=t;" +' +
    '"if(this.p===960){this.port.postMessage(this.a.slice());this.p=0;}}" +' +
    '"return true;}}registerProcessor(\"txmic\",P);";' +
    'const burl=URL.createObjectURL(new Blob([wcSrc],{type:"application/javascript"}));' +
    'await txMicACtx.audioWorklet.addModule(burl);URL.revokeObjectURL(burl);' +
    'let txTs=0;' +
    'txMicProc=new AudioWorkletNode(txMicACtx,"txmic");' +
    'txMicProc.port.onmessage=function(ev){' +
    // Skip encoding when TX off — pipeline warm but not transmitting
    'if(!cur.transmitting)return;' +
    'if(!txMicEnc||txMicEnc.state!=="configured")return;' +
    'const ad=new AudioData({format:"f32-planar",sampleRate:48000,numberOfFrames:960,numberOfChannels:1,timestamp:txTs,data:ev.data});' +
    'txTs+=20000;txMicEnc.encode(ad);ad.close();};' +
    // GainNode для регулировки усиления микрофона (значение из слайдера micSl).
    'txMicGain=txMicACtx.createGain();txMicGain.gain.value=micGainVal/100;' +
    'msrc.connect(txMicGain);txMicGain.connect(txMicProc);txMicProc.connect(txMicACtx.destination);' +
    '}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);' +
    // Число точек берём из длины фрейма — сервер шлёт ровно столько, сколько
    // выдал анализатор (<=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));}};}' +

    // Баннер «на сервере нет libopus»: без него отказ выглядит как немой звук.
    'function setOpusWarn(on){' +
    'if(!on){if(opusWarnEl){opusWarnEl.remove();opusWarnEl=null;}opusWarnHidden=false;return;}' +
    'if(audioOv){audioOv.remove();audioOv=null;}' +
    // Состояние прилетает по нескольку раз в секунду, а setOpusWarn зовётся с
    // каждым — без opusWarnHidden закрытый баннер тут же возвращался бы.
    'if(opusWarnEl||opusWarnHidden)return;' +
    'const w=document.createElement("div");' +
    'w.style.cssText="position:fixed;top:8px;left:50%;transform:translateX(-50%);z-index:9998;max-width:92vw;padding:8px 12px;border:1px solid #7a3d3d;border-radius:6px;background:#1a0c0c;color:#ffb0b0;font-family:monospace;font-size:12px;line-height:1.5;text-align:center;cursor:pointer;";' +
    'w.title="Click to dismiss";' +
    'const t1=document.createElement("b");t1.textContent="No audio \u2014 libopus is not installed on the server";' +
    'const t2=document.createElement("div");t2.style.color="#c98f8f";' +
    't2.textContent="RX audio and the web microphone stay silent until it is installed.";' +
    'w.appendChild(t1);w.appendChild(t2);' +
    'w.addEventListener("click",function(){w.remove();opusWarnHidden=true;if(opusWarnEl===w)opusWarnEl=null;},{once:true});' +
    'document.body.appendChild(w);opusWarnEl=w;}' +

    // Оверлей для user gesture (AudioContext требует клика).
    // Без libopus звука не будет вовсе — «Click to start audio» только врёт, не показываем.
    'function showAudioOverlay(){if(!srvOpus)return;' +
    '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();audioOv=null;},{once:true});' +
    'document.body.appendChild(ov);audioOv=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.
