mirror of
https://git.vladimir.cc/vladimir/ewsdr.git
synced 2026-08-25 17:27:32 +00:00
1078 lines
85 KiB
ObjectPascal
1078 lines
85 KiB
ObjectPascal
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).
|
||
}
|
||
|
||
{$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">🔊</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>' +
|
||
'</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>' +
|
||
'<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"];' +
|
||
'const GRP_MAP={ssb:[0,1],cw:[3,4],fm:[5,10],am:[6,7],digi:[8,9],dmr:[11,11]};' +
|
||
'const MODE_TO_GRP=["ssb","ssb","ssb","cw","cw","fm","am","am","digi","digi","fm","dmr"];' +
|
||
'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===11)?"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("attSel").onchange=function(e){pend("attn");ws2({cmd:"attn",idx:+e.target.value});};' +
|
||
|
||
// 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"],' +
|
||
'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],' +
|
||
'digi:[3000,2700,2400,2100,1800,1500,1200,900,600,300]};' +
|
||
'var fnpOv=null;' +
|
||
'function fnpGrp(){var m=cur.mode|0;return(m===3||m===4)?"cw":(m===5)?"fm":(m===10)?"wfm":(m===11)?"dmr":(m===8||m===9)?"digi":(m===2)?"dsb":(m===6||m===7)?"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)===11)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_idx:"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];});' +
|
||
// Перестраиваем 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("attSel");if(at)at.value=cur.attn_idx||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===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();}' +
|
||
// Маркеры 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===5),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();}' +
|
||
|
||
'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===5&&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);' +
|
||
'const avf=activeVfoHz();const[fl,fh]=fltLH(),x1=hzToX(avf+fl,w),x2=hzToX(avf+fh,w);' +
|
||
'sctx.fillStyle=(cur.transmitting||cur.tuning)?"rgba(255,60,60,.18)":"rgba(80,150,255,.12)";sctx.fillRect(Math.min(x1,x2),0,Math.abs(x2-x1),h);' +
|
||
'sctx.beginPath();for(let x=0;x<w;x++){const i=Math.floor(x*(bins.length-1)/Math.max(1,w-1));specSm[i]=specSm[i]*.70+bins[i]*.30;const y=cl(((-specSm[i]-20)/120)*h,0,h-1);x===0?sctx.moveTo(0,y):sctx.lineTo(x,y);}' +
|
||
'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();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;' +
|
||
'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=(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===5);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===5&&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 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 connect(){const p=location.protocol==="https:"?"wss":"ws";ws=new WebSocket(p+"://"+location.host+"/ws");ws.binaryType="arraybuffer";' +
|
||
'ws.onopen=()=>{stEl.textContent="connected";initOpus();};' +
|
||
'ws.onclose=()=>{cleanupTXMic();stEl.textContent="disconnected...";setTimeout(connect,3000);};' +
|
||
'ws.onerror=()=>ws.close();' +
|
||
'ws.onmessage=e=>{if(typeof e.data==="string"){try{applyState(JSON.parse(e.data));}catch(x){}return;}' +
|
||
'const ab=e.data,t=(new DataView(ab)).getUint8(0);' +
|
||
'if(t===0x53){const n=(ab.byteLength-1)>>2;const tmp=new Uint8Array(ab,1);const al=new ArrayBuffer(n*4);new Uint8Array(al).set(tmp);const b=Array.from(new Float32Array(al));drawSpec(b);drawWf(b);drawRuler();}' +
|
||
'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.
|