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 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,18 +404,28 @@ class AudioProcessor extends AudioWorkletProcessor {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
class AudioReceiver extends AudioWorkletProcessor {
|
class AudioReceiver extends AudioWorkletProcessor {
|
||||||
constructor() {
|
// Jitter buffer targets in samples (@48 kHz):
|
||||||
super();
|
// 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.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; }
|
||||||
if (e.data.action === 'setVolume') { this.volumes[e.data.clientId] = e.data.volume; 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; }
|
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) {
|
||||||
}
|
for (let i = 0; i < output.length; i++) {
|
||||||
class AudioTap extends AudioWorkletProcessor {
|
const s = Math.max(-1, Math.min(1, output[i]));
|
||||||
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]));
|
|
||||||
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(960);
|
||||||
this.acc = new Int16Array(512);
|
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 });
|
||||||
|
|
|
||||||
|
|
@ -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 |
BIN
icons/icon.png
BIN
icons/icon.png
Binary file not shown.
|
Before Width: | Height: | Size: 14 KiB After Width: | Height: | Size: 14 KiB |
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
BIN
node_modules/@capacitor/android/capacitor/build/outputs/aar/capacitor-android-release.aar
generated
vendored
BIN
node_modules/@capacitor/android/capacitor/build/outputs/aar/capacitor-android-release.aar
generated
vendored
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
|
|
@ -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"
|
||||||
|
|
|
||||||
Binary file not shown.
BIN
node_modules/@capacitor/app/android/build/intermediates/local_aar_for_lint/release/out.aar
generated
vendored
BIN
node_modules/@capacitor/app/android/build/intermediates/local_aar_for_lint/release/out.aar
generated
vendored
Binary file not shown.
Binary file not shown.
Binary file not shown.
BIN
node_modules/@capacitor/app/android/build/outputs/aar/capacitor-app-release.aar
generated
vendored
BIN
node_modules/@capacitor/app/android/build/outputs/aar/capacitor-app-release.aar
generated
vendored
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
|
|
@ -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"
|
||||||
|
|
|
||||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
100
webroot/voice.js
100
webroot/voice.js
|
|
@ -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,18 +404,28 @@ class AudioProcessor extends AudioWorkletProcessor {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
class AudioReceiver extends AudioWorkletProcessor {
|
class AudioReceiver extends AudioWorkletProcessor {
|
||||||
constructor() {
|
// Jitter buffer targets in samples (@48 kHz):
|
||||||
super();
|
// 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.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; }
|
||||||
if (e.data.action === 'setVolume') { this.volumes[e.data.clientId] = e.data.volume; 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; }
|
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) {
|
||||||
}
|
for (let i = 0; i < output.length; i++) {
|
||||||
class AudioTap extends AudioWorkletProcessor {
|
const s = Math.max(-1, Math.min(1, output[i]));
|
||||||
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]));
|
|
||||||
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(960);
|
||||||
this.acc = new Int16Array(512);
|
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 });
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue