diff --git a/WebPageHtml.pas b/WebPageHtml.pas index 347f167..9f87104 100644 --- a/WebPageHtml.pas +++ b/WebPageHtml.pas @@ -154,6 +154,8 @@ begin '' + '|' + '
TX50
' + + // MIC gain — чисто web-side (Web Audio GainNode перед Opus-кодером). Сервер не знает. + '
MIC100%
' + '' + '|' + '
' + @@ -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);' + diff --git a/WebServer.pas b/WebServer.pas index bae2bab..2606ca8 100644 --- a/WebServer.pas +++ b/WebServer.pas @@ -80,6 +80,17 @@ const OPUS_FRAME_SAMP = OPUS_SAMPLE_RATE * OPUS_FRAME_MS div 1000; // 960 samples OPUS_BITRATE = 32000; OPUS_CHANNELS = 1; + // ── TX-mic jitter handling (web → server) ── + // Pre-roll: при старте новой TX-сессии задерживаем 40мс аудио, чтобы дать + // FTXMicRing запас перед началом потребления — иначе при джиттере WiFi + // ring мгновенно пустеет и WDSP TX-thread заливает тишину = "робот". + MIC_PREROLL_MS = 40; + MIC_PREROLL_SAMP = OPUS_SAMPLE_RATE * MIC_PREROLL_MS div 1000; // 1920 + // Опоздание > MIC_IDLE_MS считаем концом TX-сессии: сбрасываем pre-roll + // (на следующий пакет — снова накапливаем cushion). + MIC_IDLE_MS = 200; + // Максимум подряд синтезированных PLC-кадров (Opus PLC деградирует после ~3–5 кадров). + MIC_MAX_PLC = 5; MAX_WS_CLIENTS = 4; WS_GUID = '258EAFA5-E914-47DA-95CA-C5AB0DC85B11'; @@ -168,6 +179,12 @@ type FOpusDecDecode: TOpus_decode_float; FOpusDecDestroy: TOpus_decoder_destroy; FOnWebMic: TWebMicCB; + // ── Jitter buffer для TX-mic от веб-клиента ── + // FMicLastTick = 0 → нет активной TX-сессии (на следующий пакет — pre-roll сброс) + // FMicPreRollPos < MIC_PREROLL_SAMP → ещё копим pre-roll + FMicLastTick: QWord; + FMicPreRollPos: Integer; + FMicPreRoll: array[0..MIC_PREROLL_SAMP-1] of Single; // ── TCP ── FListenSock: TSocket; @@ -262,6 +279,7 @@ type // ── Внутренние методы ── function LoadOpus: Boolean; procedure UnloadOpus; + procedure PushMicSamples(P: PSingle; N: Integer); function InitListen: Boolean; procedure AcceptLoop; procedure PushLoop; @@ -525,6 +543,39 @@ begin Result := True; end; +procedure TWebServer.PushMicSamples(P: PSingle; N: Integer); +// Прокладка между Opus-декодером и FOnWebMic с pre-roll cushion. +// Первые MIC_PREROLL_SAMP сэмплов TX-сессии копим в FMicPreRoll и сливаем +// одним блоком — это даёт FTXMicRing запас глубины ~40мс на джиттер. +// Дальше всё пушим напрямую. +var + Want, Remainder: Integer; + P2: PSingle; +begin + if not Assigned(FOnWebMic) or (N <= 0) then Exit; + if FMicPreRollPos < MIC_PREROLL_SAMP then + begin + Want := MIC_PREROLL_SAMP - FMicPreRollPos; + if N <= Want then + begin + Move(P^, FMicPreRoll[FMicPreRollPos], N * SizeOf(Single)); + Inc(FMicPreRollPos, N); + end + else + begin + Move(P^, FMicPreRoll[FMicPreRollPos], Want * SizeOf(Single)); + FMicPreRollPos := MIC_PREROLL_SAMP; + FOnWebMic(@FMicPreRoll[0], MIC_PREROLL_SAMP); + Remainder := N - Want; + P2 := P; + Inc(P2, Want); + FOnWebMic(P2, Remainder); + end; + end + else + FOnWebMic(P, N); +end; + procedure TWebServer.UnloadOpus; begin if FOpusReady and Assigned(FOpusDestroy) and (FOpusEnc <> nil) then @@ -793,6 +844,10 @@ var RawLen: Integer; PcmBuf: array[0..5759] of Single; // 120ms max @ 48kHz (для RX TX-mic) Decoded: Integer; + NowTick: QWord; + MissedFrames: Integer; + PlcDecoded: Integer; + k: Integer; begin WsHandled := False; RawLen := 0; @@ -948,10 +1003,32 @@ begin Assigned(FOpusDec) and Assigned(FOpusDecDecode) and Assigned(FOnWebMic) then begin + NowTick := GetTickCount64; + if (FMicLastTick = 0) or ((NowTick - FMicLastTick) > MIC_IDLE_MS) then + // Новая TX-сессия (первый пакет или длинный простой): + // сбрасываем pre-roll, PLC не применяем — нет состояния для предсказания. + FMicPreRollPos := 0 + else + begin + // PLC для опоздавших/потерянных кадров по wall-clock. + // expected_packets ≈ elapsed_ms / 20ms; out of them один — текущий, + // остальные считаем "потерянными" и синтезируем Opus PLC. + MissedFrames := Integer((NowTick - FMicLastTick) div OPUS_FRAME_MS); + if MissedFrames > 0 then Dec(MissedFrames); + if MissedFrames > MIC_MAX_PLC then MissedFrames := MIC_MAX_PLC; + for k := 1 to MissedFrames do + begin + // opus_decode_float(dec, NULL, 0, pcm, frame_size, 0) → PLC frame + PlcDecoded := FOpusDecDecode(FOpusDec, nil, 0, + @PcmBuf[0], OPUS_FRAME_SAMP, 0); + if PlcDecoded > 0 then PushMicSamples(@PcmBuf[0], PlcDecoded); + end; + end; + FMicLastTick := NowTick; Decoded := FOpusDecDecode(FOpusDec, @Payload[1], PayLen - 1, @PcmBuf[0], Length(PcmBuf), 0); if Decoded > 0 then - FOnWebMic(@PcmBuf[0], Decoded); + PushMicSamples(@PcmBuf[0], Decoded); end; end; $08: // Close