fix vc android delay
This commit is contained in:
parent
4fcc268e80
commit
61e2911706
58 changed files with 105 additions and 136 deletions
|
|
@ -24,10 +24,6 @@ let voiceLoopRestarts = 0;
|
|||
let voiceDeviceChangeHandler = null;
|
||||
let voiceDeviceChangeTimer = null;
|
||||
let isVoiceSpeakerMode = false;
|
||||
let voiceTapNode = null;
|
||||
let pcmBatch = null;
|
||||
let pcmBatchLen = 0;
|
||||
let voicePcmFlushTimer = null;
|
||||
|
||||
const SPEAKING_HANGOVER_MS = 400;
|
||||
|
||||
|
|
@ -123,37 +119,15 @@ function int16ToBase64(view) {
|
|||
return btoa(bin);
|
||||
}
|
||||
|
||||
function flushPcmBatch() {
|
||||
if (!pcmBatchLen || !pcmBatch) return;
|
||||
const native = getNativeVoiceRouting();
|
||||
if (native && native.nativeAudioFeed && native.nativeAudioStart) {
|
||||
const slice = new Int16Array(pcmBatch.subarray(0, pcmBatchLen));
|
||||
try {
|
||||
native.nativeAudioFeed({ data: int16ToBase64(slice) }).catch(() => {});
|
||||
} catch (e) {}
|
||||
}
|
||||
pcmBatchLen = 0;
|
||||
}
|
||||
|
||||
function handlePcmChunk(ev) {
|
||||
const native = getNativeVoiceRouting();
|
||||
if (!native || !native.nativeAudioFeed || !ev.data) return;
|
||||
try {
|
||||
if (!getNativeVoiceRouting() || !pcmBatch) return;
|
||||
const chunk = new Int16Array(ev.data);
|
||||
for (let i = 0; i < chunk.length; i++) {
|
||||
if (pcmBatchLen >= pcmBatch.length) flushPcmBatch();
|
||||
pcmBatch[pcmBatchLen++] = chunk[i];
|
||||
}
|
||||
native.nativeAudioFeed({ data: int16ToBase64(new Int16Array(ev.data)) }).catch(() => {});
|
||||
} catch (e) {}
|
||||
}
|
||||
|
||||
function stopNativePlayback() {
|
||||
if (voicePcmFlushTimer) { clearInterval(voicePcmFlushTimer); voicePcmFlushTimer = null; }
|
||||
flushPcmBatch();
|
||||
if (voiceTapNode) {
|
||||
try { voiceTapNode.disconnect(); } catch (e) {}
|
||||
try { voiceTapNode.port.onmessage = null; } catch (e) {}
|
||||
voiceTapNode = null;
|
||||
}
|
||||
const native = getNativeVoiceRouting();
|
||||
if (native && native.nativeAudioStop) native.nativeAudioStop().catch(() => {});
|
||||
}
|
||||
|
|
@ -226,15 +200,10 @@ function setupVoiceOutput() {
|
|||
// App + native plugin: the mixed call audio is played by a native AudioTrack
|
||||
// (USAGE_VOICE_COMMUNICATION), so it follows the call volume slider and the
|
||||
// earpiece/speaker switch. Nothing is output through the WebAudio graph.
|
||||
// The receiver worklet forwards mixed PCM straight here (~20 ms packets).
|
||||
if (native && isMobileVoiceDevice()) {
|
||||
stopNativePlayback();
|
||||
pcmBatch = new Int16Array(1536); // 32 ms @ 48 kHz
|
||||
pcmBatchLen = 0;
|
||||
voiceTapNode = new AudioWorkletNode(audioContext, 'audio-tap');
|
||||
voiceTapNode.port.onmessage = handlePcmChunk;
|
||||
receiverNode.connect(voiceTapNode);
|
||||
if (voicePcmFlushTimer) clearInterval(voicePcmFlushTimer);
|
||||
voicePcmFlushTimer = setInterval(flushPcmBatch, 25);
|
||||
receiverNode.port.onmessage = handlePcmChunk;
|
||||
if (native.nativeAudioStart) native.nativeAudioStart({ sampleRate: audioContext.sampleRate }).catch(() => {});
|
||||
return;
|
||||
}
|
||||
|
|
@ -435,18 +404,28 @@ class AudioProcessor extends AudioWorkletProcessor {
|
|||
}
|
||||
}
|
||||
class AudioReceiver extends AudioWorkletProcessor {
|
||||
constructor() {
|
||||
super();
|
||||
// Jitter buffer targets in samples (@48 kHz):
|
||||
// PREBUF_START - initial prebuffer before first playback (~43 ms)
|
||||
// PREBUF_RESUME - re-prebuffer after a starvation event (~32 ms)
|
||||
// HARD_LIMIT - burst ceiling (~128 ms): snap back to target immediately
|
||||
// Backlog slightly above target is drained by a gentle trim (1 sample per
|
||||
// quantum) instead of letting latency creep up to the hard limit.
|
||||
constructor(options) {
|
||||
super();
|
||||
this.users = {};
|
||||
this.volumes = {};
|
||||
|
||||
this.volumes = {};
|
||||
const opts = (options && options.processorOptions) || {};
|
||||
this.forward = !!opts.forward;
|
||||
this.acc = new Int16Array(960); // ~20 ms @ 48 kHz
|
||||
this.accLen = 0;
|
||||
|
||||
this.port.onmessage = (e) => {
|
||||
if (e.data.action === 'clear') { this.users = {}; this.volumes = {}; return; }
|
||||
if (e.data.action === 'setVolume') { this.volumes[e.data.clientId] = e.data.volume; return; }
|
||||
if (e.data.action === 'remove') { delete this.users[e.data.clientId]; return; }
|
||||
|
||||
|
||||
const { clientId, audioData } = e.data;
|
||||
if (!this.users[clientId]) this.users[clientId] = { buffer: new Int16Array(65536), readPtr: 0, writePtr: 0, buffering: true };
|
||||
if (!this.users[clientId]) this.users[clientId] = { buffer: new Int16Array(65536), readPtr: 0, writePtr: 0, buffering: true, started: false };
|
||||
const user = this.users[clientId];
|
||||
const intData = new Int16Array(audioData);
|
||||
for (let i = 0; i < intData.length; i++) {
|
||||
|
|
@ -462,12 +441,13 @@ class AudioReceiver extends AudioWorkletProcessor {
|
|||
for (const id in this.users) {
|
||||
const user = this.users[id];
|
||||
let available = (user.writePtr - user.readPtr + 65536) % 65536;
|
||||
if (user.buffering && available >= 4096) user.buffering = false;
|
||||
|
||||
if (user.buffering && available >= (user.started ? 1536 : 2048)) { user.buffering = false; user.started = true; }
|
||||
|
||||
if (!user.buffering) {
|
||||
if (available < output.length) { user.buffering = true; }
|
||||
else {
|
||||
if (available > 24576) user.readPtr = (user.writePtr - 8192 + 65536) % 65536;
|
||||
if (available > 6144) user.readPtr = (user.writePtr - 2048 + 65536) % 65536;
|
||||
else if (available > 2049) user.readPtr = (user.readPtr + 1) % 65536;
|
||||
let vol = this.volumes[id] !== undefined ? this.volumes[id] : 1.0;
|
||||
for (let i = 0; i < output.length; i++) {
|
||||
output[i] += (user.buffer[user.readPtr] / 32768) * vol;
|
||||
|
|
@ -475,28 +455,21 @@ class AudioReceiver extends AudioWorkletProcessor {
|
|||
}
|
||||
}
|
||||
}
|
||||
} return true;
|
||||
}
|
||||
}
|
||||
class AudioTap extends AudioWorkletProcessor {
|
||||
constructor() { super(); this.acc = new Int16Array(512); this.accLen = 0; }
|
||||
process(inputs) {
|
||||
const ch = inputs[0] && inputs[0][0];
|
||||
if (ch && this.port) {
|
||||
for (let i = 0; i < ch.length; i++) {
|
||||
const s = Math.max(-1, Math.min(1, ch[i]));
|
||||
}
|
||||
if (this.forward && this.port) {
|
||||
for (let i = 0; i < output.length; i++) {
|
||||
const s = Math.max(-1, Math.min(1, output[i]));
|
||||
this.acc[this.accLen++] = s < 0 ? s * 0x8000 : s * 0x7FFF;
|
||||
}
|
||||
if (this.accLen >= this.acc.length) {
|
||||
this.port.postMessage(this.acc.buffer, [this.acc.buffer]);
|
||||
this.acc = new Int16Array(512);
|
||||
this.accLen = 0;
|
||||
if (this.accLen >= this.acc.length) {
|
||||
this.port.postMessage(this.acc.buffer, [this.acc.buffer]);
|
||||
this.acc = new Int16Array(960);
|
||||
this.accLen = 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
}
|
||||
registerProcessor('audio-tap', AudioTap);
|
||||
registerProcessor('audio-sender', AudioProcessor);
|
||||
registerProcessor('audio-receiver', AudioReceiver);`;
|
||||
|
||||
|
|
@ -599,7 +572,8 @@ async function startVoiceSession(serverUrl) {
|
|||
if (!isAudioWorkletLoaded) {
|
||||
await audioContext.audioWorklet.addModule(URL.createObjectURL(new Blob([voiceWorkletCode], {type: 'application/javascript'})));
|
||||
senderNode = new AudioWorkletNode(audioContext, 'audio-sender');
|
||||
receiverNode = new AudioWorkletNode(audioContext, 'audio-receiver');
|
||||
const forwardPcm = !!(getNativeVoiceRouting() && isMobileVoiceDevice());
|
||||
receiverNode = new AudioWorkletNode(audioContext, 'audio-receiver', {processorOptions: {forward: forwardPcm}});
|
||||
screenReceiverNode = new AudioWorkletNode(audioContext, 'audio-receiver');
|
||||
isAudioWorkletLoaded = true;
|
||||
}
|
||||
|
|
@ -1499,8 +1473,6 @@ function endVoiceSession() {
|
|||
isVoiceSpeakerMode = false;
|
||||
voiceLoopRestarts = 0;
|
||||
stopNativePlayback();
|
||||
pcmBatch = null;
|
||||
pcmBatchLen = 0;
|
||||
|
||||
if (currentVoiceCallId) {
|
||||
let payload = JSON.stringify({ string1: currentVoiceCallType, string2: currentVoiceCallId });
|
||||
|
|
|
|||
|
|
@ -47,8 +47,16 @@ public class VoiceRoutingPlugin extends Plugin {
|
|||
|
||||
private AudioTrack nativeTrack = null;
|
||||
private Thread nativeFeedThread = null;
|
||||
private final LinkedBlockingQueue<byte[]> pcmQueue = new LinkedBlockingQueue<>();
|
||||
private final LinkedBlockingQueue<PcmChunk> pcmQueue = new LinkedBlockingQueue<>();
|
||||
private volatile boolean nativePlaying = false;
|
||||
private double pipeDelayEmaMs = -1;
|
||||
private long lastPipeLogNanos = 0;
|
||||
|
||||
private static final class PcmChunk {
|
||||
final byte[] data;
|
||||
final long arrivalNanos;
|
||||
PcmChunk(byte[] data) { this.data = data; this.arrivalNanos = System.nanoTime(); }
|
||||
}
|
||||
|
||||
private final AudioManager.OnAudioFocusChangeListener focusChangeListener = fi -> {};
|
||||
|
||||
|
|
@ -266,6 +274,8 @@ public class VoiceRoutingPlugin extends Plugin {
|
|||
|
||||
nativeTrack = builder.build();
|
||||
pcmQueue.clear();
|
||||
pipeDelayEmaMs = -1;
|
||||
lastPipeLogNanos = 0;
|
||||
nativePlaying = true;
|
||||
try {
|
||||
nativeTrack.play();
|
||||
|
|
@ -278,7 +288,7 @@ public class VoiceRoutingPlugin extends Plugin {
|
|||
nativeFeedThread = new Thread(() -> {
|
||||
android.os.Process.setThreadPriority(android.os.Process.THREAD_PRIORITY_URGENT_AUDIO);
|
||||
while (nativePlaying) {
|
||||
byte[] chunk;
|
||||
PcmChunk chunk;
|
||||
try {
|
||||
chunk = pcmQueue.poll(100, TimeUnit.MILLISECONDS);
|
||||
} catch (InterruptedException ie) {
|
||||
|
|
@ -287,7 +297,22 @@ public class VoiceRoutingPlugin extends Plugin {
|
|||
if (chunk == null) continue;
|
||||
AudioTrack track = nativeTrack;
|
||||
if (track == null || !nativePlaying) break;
|
||||
int written = track.write(chunk, 0, chunk.length, AudioTrack.WRITE_BLOCKING);
|
||||
double delayMs = (System.nanoTime() - chunk.arrivalNanos) / 1e6;
|
||||
pipeDelayEmaMs = pipeDelayEmaMs < 0 ? delayMs : (pipeDelayEmaMs * 0.9 + delayMs * 0.1);
|
||||
long now = System.nanoTime();
|
||||
if (lastPipeLogNanos == 0) lastPipeLogNanos = now;
|
||||
if (now - lastPipeLogNanos >= 1_000_000_000L) {
|
||||
lastPipeLogNanos = now;
|
||||
int underruns = 0;
|
||||
try {
|
||||
underruns = track.getUnderrunCount();
|
||||
} catch (Exception ignored) {}
|
||||
log(String.format(java.util.Locale.US,
|
||||
"pipe: queue=%d emaDelay=%.1f ms buffer=%.0f ms underruns=%d",
|
||||
pcmQueue.size(), pipeDelayEmaMs,
|
||||
track.getBufferSizeInFrames() * 1000.0 / track.getSampleRate(), underruns));
|
||||
}
|
||||
int written = track.write(chunk.data, 0, chunk.data.length, AudioTrack.WRITE_BLOCKING);
|
||||
if (written < 0) {
|
||||
Log.w(TAG, "AudioTrack.write error: " + written);
|
||||
break;
|
||||
|
|
@ -320,7 +345,7 @@ public class VoiceRoutingPlugin extends Plugin {
|
|||
while (pcmQueue.size() > 10) {
|
||||
pcmQueue.poll(); // drop oldest to avoid unbounded growth
|
||||
}
|
||||
pcmQueue.offer(pcm);
|
||||
pcmQueue.offer(new PcmChunk(pcm));
|
||||
call.resolve();
|
||||
}
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue