mirror of
https://git.vladimir.cc/vladimir/ewsdr.git
synced 2026-08-25 17:27:32 +00:00
Web TX mic: gain slider, jitter buffer, Opus PLC, user-gesture fixes
WebPageHtml.pas:
- Add MIC gain slider (0..300%) with localStorage persistence; inserts
Web Audio GainNode between MediaStreamSource and Opus encoder.
- Disable autoGainControl in getUserMedia so the manual gain actually works.
- Pre-create+resume AudioContext synchronously inside the MOX click handler
(before any await) so user-gesture context isn't lost during getUserMedia
on macOS/Android.
- Call startTXMic() from the click handler too — the call from applyState
runs in a WS-message context which mobile browsers reject.
- Re-resume txMicACtx on every MOX→TX (Android can suspend it between
cycles) and reuse the existing context instead of recreating per session.
- stopTXMic now a no-op: the previous flag reset created a race that spun
up duplicate pipelines on rapid RX→TX toggles.
WebServer.pas:
- Pre-roll cushion: hold first 40ms of decoded mic samples before flushing
to FTXMicRing, raising steady-state ring depth from ~9ms to ~50ms so
WiFi jitter no longer drains the buffer into silence ("robotic" voice).
- Wall-clock-based Opus PLC: synthesize up to 5 frames via
opus_decode_float(NULL) when packet gap exceeds expected 20ms cadence.
- A gap > 200ms is treated as a new TX session: pre-roll resets, PLC
is skipped (no useful decoder state).
- Local (non-web) mic paths are untouched — they bypass WebOnMic entirely.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
+52
-15
@@ -154,6 +154,8 @@ begin
|
||||
'<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>' +
|
||||
@@ -311,8 +313,15 @@ begin
|
||||
'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);}' +
|
||||
'q("moxBtn").onclick=function(){audioKick();cur.transmitting=!cur.transmitting;pend("mox");updMoxBtn(cur.transmitting);cmd("set_mox",cur.transmitting);};' +
|
||||
// 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);};' +
|
||||
|
||||
@@ -738,7 +747,7 @@ begin
|
||||
|
||||
// Opus: async decodeFrame, очередь, оверлей для user gesture
|
||||
'let ws=null,opDec=null,opRdy=false,opQueue=[];' +
|
||||
'let txMicStream=null,txMicACtx=null,txMicProc=null,txMicEnc=null,prevTx=false;' +
|
||||
'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});' +
|
||||
@@ -762,14 +771,43 @@ begin
|
||||
'latEl.textContent="lat: "+Math.max(0,(audioT-now)*1000).toFixed(0)+"ms | Opus";' +
|
||||
'}catch(e){console.error("playOpus:",e);}}' +
|
||||
|
||||
'async function startTXMic(){if(txMicStream)return;' +
|
||||
// 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{' +
|
||||
'txMicStream=await navigator.mediaDevices.getUserMedia({audio:{channelCount:1,sampleRate:48000,echoCancellation:true,noiseSuppression:true}});' +
|
||||
'txMicACtx=new AudioContext({sampleRate:48000});' +
|
||||
// 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(!ws||ws.readyState!==1)return;' +
|
||||
'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)});' +
|
||||
@@ -788,21 +826,20 @@ begin
|
||||
'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();};' +
|
||||
'msrc.connect(txMicProc);txMicProc.connect(txMicACtx.destination);' +
|
||||
'}catch(e){console.error("startTXMic:",e);stopTXMic();}}' +
|
||||
|
||||
'function stopTXMic(){' +
|
||||
'if(txMicProc){txMicProc.disconnect();txMicProc=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;}}' +
|
||||
// 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=()=>{stEl.textContent="disconnected...";setTimeout(connect,3000);};' +
|
||||
'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);' +
|
||||
|
||||
Reference in New Issue
Block a user