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