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:
2026-05-19 16:59:42 +03:00
co-authored by Claude Opus 4.7
parent d8050576a9
commit 922d5e20b2
2 changed files with 130 additions and 16 deletions
+78 -1
View File
@@ -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