mirror of
https://git.vladimir.cc/vladimir/ewsdr.git
synced 2026-08-25 19:45:09 +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);' +
|
||||
|
||||
+78
-1
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user