fix vc android delay
All checks were successful
Android Build / publish (push) Successful in 30s
Linux Build / publish (push) Successful in 56s

This commit is contained in:
olcxja 2026-08-23 21:46:55 +02:00
commit 61e2911706
58 changed files with 105 additions and 136 deletions

View file

@ -24,10 +24,6 @@ let voiceLoopRestarts = 0;
let voiceDeviceChangeHandler = null; let voiceDeviceChangeHandler = null;
let voiceDeviceChangeTimer = null; let voiceDeviceChangeTimer = null;
let isVoiceSpeakerMode = false; let isVoiceSpeakerMode = false;
let voiceTapNode = null;
let pcmBatch = null;
let pcmBatchLen = 0;
let voicePcmFlushTimer = null;
const SPEAKING_HANGOVER_MS = 400; const SPEAKING_HANGOVER_MS = 400;
@ -123,37 +119,15 @@ function int16ToBase64(view) {
return btoa(bin); 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) { function handlePcmChunk(ev) {
const native = getNativeVoiceRouting();
if (!native || !native.nativeAudioFeed || !ev.data) return;
try { try {
if (!getNativeVoiceRouting() || !pcmBatch) return; native.nativeAudioFeed({ data: int16ToBase64(new Int16Array(ev.data)) }).catch(() => {});
const chunk = new Int16Array(ev.data);
for (let i = 0; i < chunk.length; i++) {
if (pcmBatchLen >= pcmBatch.length) flushPcmBatch();
pcmBatch[pcmBatchLen++] = chunk[i];
}
} catch (e) {} } catch (e) {}
} }
function stopNativePlayback() { 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(); const native = getNativeVoiceRouting();
if (native && native.nativeAudioStop) native.nativeAudioStop().catch(() => {}); 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 // 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 // (USAGE_VOICE_COMMUNICATION), so it follows the call volume slider and the
// earpiece/speaker switch. Nothing is output through the WebAudio graph. // earpiece/speaker switch. Nothing is output through the WebAudio graph.
// The receiver worklet forwards mixed PCM straight here (~20 ms packets).
if (native && isMobileVoiceDevice()) { if (native && isMobileVoiceDevice()) {
stopNativePlayback(); stopNativePlayback();
pcmBatch = new Int16Array(1536); // 32 ms @ 48 kHz receiverNode.port.onmessage = handlePcmChunk;
pcmBatchLen = 0;
voiceTapNode = new AudioWorkletNode(audioContext, 'audio-tap');
voiceTapNode.port.onmessage = handlePcmChunk;
receiverNode.connect(voiceTapNode);
if (voicePcmFlushTimer) clearInterval(voicePcmFlushTimer);
voicePcmFlushTimer = setInterval(flushPcmBatch, 25);
if (native.nativeAudioStart) native.nativeAudioStart({ sampleRate: audioContext.sampleRate }).catch(() => {}); if (native.nativeAudioStart) native.nativeAudioStart({ sampleRate: audioContext.sampleRate }).catch(() => {});
return; return;
} }
@ -435,10 +404,20 @@ class AudioProcessor extends AudioWorkletProcessor {
} }
} }
class AudioReceiver extends AudioWorkletProcessor { class AudioReceiver extends AudioWorkletProcessor {
constructor() { // 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(); super();
this.users = {}; 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) => { this.port.onmessage = (e) => {
if (e.data.action === 'clear') { this.users = {}; this.volumes = {}; return; } if (e.data.action === 'clear') { this.users = {}; this.volumes = {}; return; }
@ -446,7 +425,7 @@ class AudioReceiver extends AudioWorkletProcessor {
if (e.data.action === 'remove') { delete this.users[e.data.clientId]; return; } if (e.data.action === 'remove') { delete this.users[e.data.clientId]; return; }
const { clientId, audioData } = e.data; 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 user = this.users[clientId];
const intData = new Int16Array(audioData); const intData = new Int16Array(audioData);
for (let i = 0; i < intData.length; i++) { for (let i = 0; i < intData.length; i++) {
@ -462,12 +441,13 @@ class AudioReceiver extends AudioWorkletProcessor {
for (const id in this.users) { for (const id in this.users) {
const user = this.users[id]; const user = this.users[id];
let available = (user.writePtr - user.readPtr + 65536) % 65536; 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 (!user.buffering) {
if (available < output.length) { user.buffering = true; } if (available < output.length) { user.buffering = true; }
else { 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; let vol = this.volumes[id] !== undefined ? this.volumes[id] : 1.0;
for (let i = 0; i < output.length; i++) { for (let i = 0; i < output.length; i++) {
output[i] += (user.buffer[user.readPtr] / 32768) * vol; output[i] += (user.buffer[user.readPtr] / 32768) * vol;
@ -475,28 +455,21 @@ class AudioReceiver extends AudioWorkletProcessor {
} }
} }
} }
} return true;
} }
} if (this.forward && this.port) {
class AudioTap extends AudioWorkletProcessor { for (let i = 0; i < output.length; i++) {
constructor() { super(); this.acc = new Int16Array(512); this.accLen = 0; } const s = Math.max(-1, Math.min(1, output[i]));
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]));
this.acc[this.accLen++] = s < 0 ? s * 0x8000 : s * 0x7FFF; this.acc[this.accLen++] = s < 0 ? s * 0x8000 : s * 0x7FFF;
}
if (this.accLen >= this.acc.length) { if (this.accLen >= this.acc.length) {
this.port.postMessage(this.acc.buffer, [this.acc.buffer]); this.port.postMessage(this.acc.buffer, [this.acc.buffer]);
this.acc = new Int16Array(512); this.acc = new Int16Array(960);
this.accLen = 0; this.accLen = 0;
} }
} }
}
return true; return true;
} }
} }
registerProcessor('audio-tap', AudioTap);
registerProcessor('audio-sender', AudioProcessor); registerProcessor('audio-sender', AudioProcessor);
registerProcessor('audio-receiver', AudioReceiver);`; registerProcessor('audio-receiver', AudioReceiver);`;
@ -599,7 +572,8 @@ async function startVoiceSession(serverUrl) {
if (!isAudioWorkletLoaded) { if (!isAudioWorkletLoaded) {
await audioContext.audioWorklet.addModule(URL.createObjectURL(new Blob([voiceWorkletCode], {type: 'application/javascript'}))); await audioContext.audioWorklet.addModule(URL.createObjectURL(new Blob([voiceWorkletCode], {type: 'application/javascript'})));
senderNode = new AudioWorkletNode(audioContext, 'audio-sender'); 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'); screenReceiverNode = new AudioWorkletNode(audioContext, 'audio-receiver');
isAudioWorkletLoaded = true; isAudioWorkletLoaded = true;
} }
@ -1499,8 +1473,6 @@ function endVoiceSession() {
isVoiceSpeakerMode = false; isVoiceSpeakerMode = false;
voiceLoopRestarts = 0; voiceLoopRestarts = 0;
stopNativePlayback(); stopNativePlayback();
pcmBatch = null;
pcmBatchLen = 0;
if (currentVoiceCallId) { if (currentVoiceCallId) {
let payload = JSON.stringify({ string1: currentVoiceCallType, string2: currentVoiceCallId }); let payload = JSON.stringify({ string1: currentVoiceCallType, string2: currentVoiceCallId });

View file

@ -47,8 +47,16 @@ public class VoiceRoutingPlugin extends Plugin {
private AudioTrack nativeTrack = null; private AudioTrack nativeTrack = null;
private Thread nativeFeedThread = 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 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 -> {}; private final AudioManager.OnAudioFocusChangeListener focusChangeListener = fi -> {};
@ -266,6 +274,8 @@ public class VoiceRoutingPlugin extends Plugin {
nativeTrack = builder.build(); nativeTrack = builder.build();
pcmQueue.clear(); pcmQueue.clear();
pipeDelayEmaMs = -1;
lastPipeLogNanos = 0;
nativePlaying = true; nativePlaying = true;
try { try {
nativeTrack.play(); nativeTrack.play();
@ -278,7 +288,7 @@ public class VoiceRoutingPlugin extends Plugin {
nativeFeedThread = new Thread(() -> { nativeFeedThread = new Thread(() -> {
android.os.Process.setThreadPriority(android.os.Process.THREAD_PRIORITY_URGENT_AUDIO); android.os.Process.setThreadPriority(android.os.Process.THREAD_PRIORITY_URGENT_AUDIO);
while (nativePlaying) { while (nativePlaying) {
byte[] chunk; PcmChunk chunk;
try { try {
chunk = pcmQueue.poll(100, TimeUnit.MILLISECONDS); chunk = pcmQueue.poll(100, TimeUnit.MILLISECONDS);
} catch (InterruptedException ie) { } catch (InterruptedException ie) {
@ -287,7 +297,22 @@ public class VoiceRoutingPlugin extends Plugin {
if (chunk == null) continue; if (chunk == null) continue;
AudioTrack track = nativeTrack; AudioTrack track = nativeTrack;
if (track == null || !nativePlaying) break; 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) { if (written < 0) {
Log.w(TAG, "AudioTrack.write error: " + written); Log.w(TAG, "AudioTrack.write error: " + written);
break; break;
@ -320,7 +345,7 @@ public class VoiceRoutingPlugin extends Plugin {
while (pcmQueue.size() > 10) { while (pcmQueue.size() > 10) {
pcmQueue.poll(); // drop oldest to avoid unbounded growth pcmQueue.poll(); // drop oldest to avoid unbounded growth
} }
pcmQueue.offer(pcm); pcmQueue.offer(new PcmChunk(pcm));
call.resolve(); call.resolve();
} }

Binary file not shown.

Before

Width:  |  Height:  |  Size: 14 KiB

After

Width:  |  Height:  |  Size: 14 KiB

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 14 KiB

After

Width:  |  Height:  |  Size: 14 KiB

Before After
Before After

View file

@ -1,9 +1,9 @@
<libraries> <libraries>
<library <library
name=":@@:capacitor-android::release" name=":@@:capacitor-android::release"
jars="/home/olcxja/.gradle/caches/8.14.3/transforms/a60990329a178b65acb9987d031670ff/transformed/out/jars/classes.jar:/home/olcxja/.gradle/caches/8.14.3/transforms/a60990329a178b65acb9987d031670ff/transformed/out/jars/libs/R.jar" jars="/home/olcxja/.gradle/caches/8.14.3/transforms/7687406e09940ba1a8559cc8a3590167/transformed/out/jars/classes.jar:/home/olcxja/.gradle/caches/8.14.3/transforms/7687406e09940ba1a8559cc8a3590167/transformed/out/jars/libs/R.jar"
resolved="android:capacitor-android:unspecified" resolved="android:capacitor-android:unspecified"
folder="/home/olcxja/.gradle/caches/8.14.3/transforms/a60990329a178b65acb9987d031670ff/transformed/out" folder="/home/olcxja/.gradle/caches/8.14.3/transforms/7687406e09940ba1a8559cc8a3590167/transformed/out"
manifest="AndroidManifest.xml" manifest="AndroidManifest.xml"
resFolder="res" resFolder="res"
assetsFolder="assets" assetsFolder="assets"

View file

@ -1,9 +1,9 @@
<libraries> <libraries>
<library <library
name=":@@:capacitor-android::release" name=":@@:capacitor-android::release"
jars="/home/olcxja/.gradle/caches/8.14.3/transforms/a60990329a178b65acb9987d031670ff/transformed/out/jars/classes.jar:/home/olcxja/.gradle/caches/8.14.3/transforms/a60990329a178b65acb9987d031670ff/transformed/out/jars/libs/R.jar" jars="/home/olcxja/.gradle/caches/8.14.3/transforms/7687406e09940ba1a8559cc8a3590167/transformed/out/jars/classes.jar:/home/olcxja/.gradle/caches/8.14.3/transforms/7687406e09940ba1a8559cc8a3590167/transformed/out/jars/libs/R.jar"
resolved="android:capacitor-android:unspecified" resolved="android:capacitor-android:unspecified"
folder="/home/olcxja/.gradle/caches/8.14.3/transforms/a60990329a178b65acb9987d031670ff/transformed/out" folder="/home/olcxja/.gradle/caches/8.14.3/transforms/7687406e09940ba1a8559cc8a3590167/transformed/out"
manifest="AndroidManifest.xml" manifest="AndroidManifest.xml"
resFolder="res" resFolder="res"
assetsFolder="assets" assetsFolder="assets"

View file

@ -24,10 +24,6 @@ let voiceLoopRestarts = 0;
let voiceDeviceChangeHandler = null; let voiceDeviceChangeHandler = null;
let voiceDeviceChangeTimer = null; let voiceDeviceChangeTimer = null;
let isVoiceSpeakerMode = false; let isVoiceSpeakerMode = false;
let voiceTapNode = null;
let pcmBatch = null;
let pcmBatchLen = 0;
let voicePcmFlushTimer = null;
const SPEAKING_HANGOVER_MS = 400; const SPEAKING_HANGOVER_MS = 400;
@ -123,37 +119,15 @@ function int16ToBase64(view) {
return btoa(bin); 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) { function handlePcmChunk(ev) {
const native = getNativeVoiceRouting();
if (!native || !native.nativeAudioFeed || !ev.data) return;
try { try {
if (!getNativeVoiceRouting() || !pcmBatch) return; native.nativeAudioFeed({ data: int16ToBase64(new Int16Array(ev.data)) }).catch(() => {});
const chunk = new Int16Array(ev.data);
for (let i = 0; i < chunk.length; i++) {
if (pcmBatchLen >= pcmBatch.length) flushPcmBatch();
pcmBatch[pcmBatchLen++] = chunk[i];
}
} catch (e) {} } catch (e) {}
} }
function stopNativePlayback() { 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(); const native = getNativeVoiceRouting();
if (native && native.nativeAudioStop) native.nativeAudioStop().catch(() => {}); 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 // 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 // (USAGE_VOICE_COMMUNICATION), so it follows the call volume slider and the
// earpiece/speaker switch. Nothing is output through the WebAudio graph. // earpiece/speaker switch. Nothing is output through the WebAudio graph.
// The receiver worklet forwards mixed PCM straight here (~20 ms packets).
if (native && isMobileVoiceDevice()) { if (native && isMobileVoiceDevice()) {
stopNativePlayback(); stopNativePlayback();
pcmBatch = new Int16Array(1536); // 32 ms @ 48 kHz receiverNode.port.onmessage = handlePcmChunk;
pcmBatchLen = 0;
voiceTapNode = new AudioWorkletNode(audioContext, 'audio-tap');
voiceTapNode.port.onmessage = handlePcmChunk;
receiverNode.connect(voiceTapNode);
if (voicePcmFlushTimer) clearInterval(voicePcmFlushTimer);
voicePcmFlushTimer = setInterval(flushPcmBatch, 25);
if (native.nativeAudioStart) native.nativeAudioStart({ sampleRate: audioContext.sampleRate }).catch(() => {}); if (native.nativeAudioStart) native.nativeAudioStart({ sampleRate: audioContext.sampleRate }).catch(() => {});
return; return;
} }
@ -435,10 +404,20 @@ class AudioProcessor extends AudioWorkletProcessor {
} }
} }
class AudioReceiver extends AudioWorkletProcessor { class AudioReceiver extends AudioWorkletProcessor {
constructor() { // 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(); super();
this.users = {}; 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) => { this.port.onmessage = (e) => {
if (e.data.action === 'clear') { this.users = {}; this.volumes = {}; return; } if (e.data.action === 'clear') { this.users = {}; this.volumes = {}; return; }
@ -446,7 +425,7 @@ class AudioReceiver extends AudioWorkletProcessor {
if (e.data.action === 'remove') { delete this.users[e.data.clientId]; return; } if (e.data.action === 'remove') { delete this.users[e.data.clientId]; return; }
const { clientId, audioData } = e.data; 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 user = this.users[clientId];
const intData = new Int16Array(audioData); const intData = new Int16Array(audioData);
for (let i = 0; i < intData.length; i++) { for (let i = 0; i < intData.length; i++) {
@ -462,12 +441,13 @@ class AudioReceiver extends AudioWorkletProcessor {
for (const id in this.users) { for (const id in this.users) {
const user = this.users[id]; const user = this.users[id];
let available = (user.writePtr - user.readPtr + 65536) % 65536; 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 (!user.buffering) {
if (available < output.length) { user.buffering = true; } if (available < output.length) { user.buffering = true; }
else { 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; let vol = this.volumes[id] !== undefined ? this.volumes[id] : 1.0;
for (let i = 0; i < output.length; i++) { for (let i = 0; i < output.length; i++) {
output[i] += (user.buffer[user.readPtr] / 32768) * vol; output[i] += (user.buffer[user.readPtr] / 32768) * vol;
@ -475,28 +455,21 @@ class AudioReceiver extends AudioWorkletProcessor {
} }
} }
} }
} return true;
} }
} if (this.forward && this.port) {
class AudioTap extends AudioWorkletProcessor { for (let i = 0; i < output.length; i++) {
constructor() { super(); this.acc = new Int16Array(512); this.accLen = 0; } const s = Math.max(-1, Math.min(1, output[i]));
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]));
this.acc[this.accLen++] = s < 0 ? s * 0x8000 : s * 0x7FFF; this.acc[this.accLen++] = s < 0 ? s * 0x8000 : s * 0x7FFF;
}
if (this.accLen >= this.acc.length) { if (this.accLen >= this.acc.length) {
this.port.postMessage(this.acc.buffer, [this.acc.buffer]); this.port.postMessage(this.acc.buffer, [this.acc.buffer]);
this.acc = new Int16Array(512); this.acc = new Int16Array(960);
this.accLen = 0; this.accLen = 0;
} }
} }
}
return true; return true;
} }
} }
registerProcessor('audio-tap', AudioTap);
registerProcessor('audio-sender', AudioProcessor); registerProcessor('audio-sender', AudioProcessor);
registerProcessor('audio-receiver', AudioReceiver);`; registerProcessor('audio-receiver', AudioReceiver);`;
@ -599,7 +572,8 @@ async function startVoiceSession(serverUrl) {
if (!isAudioWorkletLoaded) { if (!isAudioWorkletLoaded) {
await audioContext.audioWorklet.addModule(URL.createObjectURL(new Blob([voiceWorkletCode], {type: 'application/javascript'}))); await audioContext.audioWorklet.addModule(URL.createObjectURL(new Blob([voiceWorkletCode], {type: 'application/javascript'})));
senderNode = new AudioWorkletNode(audioContext, 'audio-sender'); 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'); screenReceiverNode = new AudioWorkletNode(audioContext, 'audio-receiver');
isAudioWorkletLoaded = true; isAudioWorkletLoaded = true;
} }
@ -1499,8 +1473,6 @@ function endVoiceSession() {
isVoiceSpeakerMode = false; isVoiceSpeakerMode = false;
voiceLoopRestarts = 0; voiceLoopRestarts = 0;
stopNativePlayback(); stopNativePlayback();
pcmBatch = null;
pcmBatchLen = 0;
if (currentVoiceCallId) { if (currentVoiceCallId) {
let payload = JSON.stringify({ string1: currentVoiceCallType, string2: currentVoiceCallId }); let payload = JSON.stringify({ string1: currentVoiceCallType, string2: currentVoiceCallId });