better android app voice chat integration
All checks were successful
Android Build / publish (push) Successful in 47s
Linux Build / publish (push) Successful in 1m2s

This commit is contained in:
olcxja 2026-08-23 21:09:59 +02:00
commit 4fcc268e80
182 changed files with 1403 additions and 62 deletions

View file

@ -7,8 +7,8 @@ android {
applicationId "olcxja.miarven"
minSdkVersion rootProject.ext.minSdkVersion
targetSdkVersion rootProject.ext.targetSdkVersion
versionCode 2
versionName "0.1.1"
versionCode 3
versionName "0.1.2"
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
aaptOptions {
// Files and dirs to omit from the packaged assets dir, modified to accommodate modern web apps.

View file

@ -6,6 +6,7 @@
<uses-permission android:name="android.permission.CAMERA" />
<uses-permission android:name="android.permission.RECORD_AUDIO" />
<uses-permission android:name="android.permission.MODIFY_AUDIO_SETTINGS" />
<uses-permission android:name="android.permission.BLUETOOTH_CONNECT" />
<application
android:allowBackup="true"

View file

@ -298,6 +298,7 @@
"action.call.camera": "camera",
"action.call.screen": "share screen",
"action.call.disconnect": "disconnect :3",
"action.call.speaker": "speaker :3",
"action.call.join": "join :3",
"action.call.decline": "decline :c",
"title.incoming.call": "incoming voice chat :3",

View file

@ -306,6 +306,7 @@
"action.call.mute": "Mute Microphone",
"action.call.camera": "Camera",
"action.call.screen": "Share Screen",
"action.call.speaker": "Speaker",
"action.call.disconnect": "Disconnect",
"action.call.join": "Join",
"action.call.decline": "Decline",

View file

@ -306,6 +306,7 @@
"action.call.mute": "Wycisz mikrofon",
"action.call.camera": "Kamera",
"action.call.screen": "Udostępnij ekran",
"action.call.speaker": "Głośnik",
"action.call.disconnect": "Rozłącz",
"action.call.join": "Dołącz",
"action.call.decline": "Odrzuć",

View file

@ -49,6 +49,7 @@ const ICONS = {
video: (props) => ICONS._feather(`<polygon points="23 7 16 12 23 17 23 7"/><rect x="1" y="5" width="15" height="14" rx="2" ry="2"/>`, props),
file: (props) => ICONS._feather(`<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><polyline points="14 2 14 8 20 8"/>`, props),
mic: (props) => ICONS._feather(`<path d="M12 1a3 3 0 0 0-3 3v8a3 3 0 0 0 6 0V4a3 3 0 0 0-3-3z"></path><path d="M19 10v2a7 7 0 0 1-14 0v-2"></path><line x1="12" y1="19" x2="12" y2="23"></line><line x1="8" y1="23" x2="16" y2="23"></line>`, props),
speaker: (props) => ICONS._create(`<path d="M3 9v6h4l5 5V4L7 9H3zm13.5 3c0-1.77-1.02-3.29-2.5-4.03v8.05c1.48-.73 2.5-2.25 2.5-4.02zM14 3.23v2.06c2.89.86 5 3.54 5 6.71s-2.11 5.85-5 6.71v2.06c4.01-.91 7-4.49 7-8.77s-2.99-7.86-7-8.77z"></path>`, { viewBox: "0 0 24 24", width: "1.4rem", height: "1.4rem", fill: "var(--text-color)", stroke: "none" }, props),
home: (props) => ICONS._create(`<path d="M1 6V15H6V11C6 9.89543 6.89543 9 8 9C9.10457 9 10 9.89543 10 11V15H15V6L8 0L1 6Z" stroke="currentColor" stroke-width="1.28" stroke-linejoin="round" stroke-linecap="round" transform="translate(0 0.5)"/>`, { viewBox: "-1 -1 18 18", width: "1.5rem", height: "1.5rem", fill: "none", stroke: "none" }, props),
inbox: (props) => ICONS._material(`<path d="M216-144q-29 0-50.5-21.5T144-216v-528q0-29.7 21.5-50.85Q187-816 216-816h528q29.7 0 50.85 21.15Q816-773.7 816-744v528q0 29-21.15 50.5T744-144H216Zm0-72h528v-144H632q-23 43-63.5 69.5T480-264q-49 0-89.5-26T328-360H216v144Zm332-148q28-28 28-68h168v-312H216v312h168q0 40 28 68t68 28q40 0 68-28ZM216-216h528-528Z"/>`, props),

View file

@ -1729,4 +1729,17 @@ input:checked + .toggle-slider:before {
.voice-call-btn {
padding: 0 0.75rem;
}
#voice-users {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(min(5.5rem, 100%), 1fr));
grid-auto-rows: minmax(6.25rem, 1fr);
align-content: stretch;
justify-items: stretch;
}
.voice-tile {
width: auto !important;
height: auto !important;
min-width: 0;
}
}

View file

@ -1 +1 @@
const CLIENT_VERSION = "0.1.1";
const CLIENT_VERSION = "0.1.2";

View file

@ -14,6 +14,20 @@ let knownVoiceUsers = new Set();
let isMicMuted = false;
let remoteVideos = {}, videoDecryptChains = {};
let camControl = null, screenControl = null;
let screenReceiverNode = null;
let voiceOutputAudio = null;
let voiceLoopSendPc = null, voiceLoopRecvPc = null;
let voicePlayoutDest = null;
let voiceLoopWatchdog = null;
let voiceRemoteStream = null;
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;
@ -62,6 +76,269 @@ function updateVoicePing() {
if (pingEl) pingEl.innerText = voicePingMs !== null ? `${voicePingMs} ms` : "";
}
function isMobileVoiceDevice() {
return /Android|iPhone|iPad|iPod|Mobile/i.test(navigator.userAgent) && !!window.RTCPeerConnection;
}
function getNativeVoiceRouting() {
try {
return window.Capacitor && window.Capacitor.Plugins && window.Capacitor.Plugins.VoiceRouting ? window.Capacitor.Plugins.VoiceRouting : null;
} catch (e) {
return null;
}
}
function teardownVoiceLoopback() {
if (voiceLoopWatchdog) { clearTimeout(voiceLoopWatchdog); voiceLoopWatchdog = null; }
document.removeEventListener("click", voicePlayRetry, true);
window.removeEventListener("voicemodechanged", voiceModeChangedHandler);
if (voiceDeviceChangeHandler && navigator.mediaDevices && navigator.mediaDevices.removeEventListener) {
navigator.mediaDevices.removeEventListener("devicechange", voiceDeviceChangeHandler);
}
voiceDeviceChangeHandler = null;
clearTimeout(voiceDeviceChangeTimer);
if (voiceLoopSendPc) { try { voiceLoopSendPc.close(); } catch (e) {} voiceLoopSendPc = null; }
if (voiceLoopRecvPc) { try { voiceLoopRecvPc.close(); } catch (e) {} voiceLoopRecvPc = null; }
if (voicePlayoutDest) { try { voicePlayoutDest.disconnect(); } catch (e) {} voicePlayoutDest = null; }
if (voiceOutputAudio) {
try { voiceOutputAudio.pause(); } catch (e) {}
voiceOutputAudio.srcObject = null;
voiceOutputAudio.remove();
voiceOutputAudio = null;
}
voiceRemoteStream = null;
}
function voicePlayRetry() {
if (voiceOutputAudio) voiceOutputAudio.play().catch(() => {});
}
function int16ToBase64(view) {
let bin = '';
const u8 = new Uint8Array(view.buffer, view.byteOffset, view.byteLength);
const STEP = 8192;
for (let i = 0; i < u8.length; i += STEP) {
bin += String.fromCharCode.apply(null, u8.subarray(i, Math.min(i + STEP, u8.length)));
}
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) {
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];
}
} 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(() => {});
}
// Fired natively when Android reverted MODE_IN_COMMUNICATION and the plugin
// restored it - Chromium's playout track must be re-initialized afterwards.
function voiceModeChangedHandler() {
console.debug("Voice: native mode restore detected, replugging playout");
setTimeout(() => replugVoiceOutputElement(), 150);
setTimeout(() => replugVoiceOutputElement(), 700);
}
function voicePlayoutOk() {
let connected = voiceLoopRecvPc && voiceLoopRecvPc.connectionState === "connected";
let playing = voiceOutputAudio && !voiceOutputAudio.paused;
return !!connected && !!playing;
}
// Re-attaching the stream forces the browser audio stack to re-initialize its
// output track - needed after communication device changes on Android.
async function replugVoiceOutputElement() {
if (!voiceOutputAudio || !voiceRemoteStream) return;
console.debug("Voice: replugging WebRTC playout element");
try { voiceOutputAudio.pause(); } catch (e) {}
voiceOutputAudio.srcObject = null;
voiceOutputAudio.srcObject = voiceRemoteStream;
try { await voiceOutputAudio.play(); } catch (e) {}
}
function fallbackVoiceToMediaOutput() {
if (!receiverNode || !audioContext || !voicePlayoutDest) return;
console.warn("Voice: WebRTC playout unavailable, falling back to media output");
teardownVoiceLoopback();
try { receiverNode.disconnect(); } catch (e) {}
try { receiverNode.connect(audioContext.destination); } catch (e) {}
}
function recoverVoiceLoopback() {
if (!receiverNode || !audioContext || !voicePlayoutDest) return;
const native = getNativeVoiceRouting();
// First aid: re-plug the playout element (fixes stuck routes after device switches)
replugVoiceOutputElement().then(() => {
setTimeout(() => {
if (!voicePlayoutDest || voicePlayoutOk()) return;
if (native && voiceLoopRestarts < 2) {
voiceLoopRestarts++;
console.warn("Voice: restarting WebRTC loopback (attempt " + voiceLoopRestarts + ")");
const wasSpeaker = isVoiceSpeakerMode;
teardownVoiceLoopback();
isVoiceSpeakerMode = wasSpeaker;
setupVoiceOutput();
} else {
fallbackVoiceToMediaOutput();
}
}, 800);
});
}
function setupVoiceOutput() {
if (!audioContext || !receiverNode) return;
try { receiverNode.disconnect(); } catch (e) {}
if (screenReceiverNode) {
try { screenReceiverNode.disconnect(); } catch (e) {}
screenReceiverNode.connect(audioContext.destination);
}
const native = getNativeVoiceRouting();
// 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.
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);
if (native.nativeAudioStart) native.nativeAudioStart({ sampleRate: audioContext.sampleRate }).catch(() => {});
return;
}
if (!isMobileVoiceDevice()) {
receiverNode.connect(audioContext.destination);
return;
}
// Browser-only speaker mode plays through plain media output.
// With the native plugin present, routing stays on the call path
// and the speaker switch is handled natively.
if (isVoiceSpeakerMode) {
receiverNode.connect(audioContext.destination);
return;
}
voiceLoopRestarts = 0;
teardownVoiceLoopback();
voicePlayoutDest = audioContext.createMediaStreamDestination();
receiverNode.connect(voicePlayoutDest);
voiceLoopSendPc = new RTCPeerConnection();
voiceLoopRecvPc = new RTCPeerConnection();
voiceLoopSendPc.onicecandidate = e => { if (e.candidate) try { voiceLoopRecvPc.addIceCandidate(e.candidate); } catch (err) {} };
voiceLoopRecvPc.onicecandidate = e => { if (e.candidate) try { voiceLoopSendPc.addIceCandidate(e.candidate); } catch (err) {} };
voiceLoopRecvPc.onconnectionstatechange = () => {
const st = voiceLoopRecvPc && voiceLoopRecvPc.connectionState;
if (st === "failed") recoverVoiceLoopback();
};
voiceLoopRecvPc.ontrack = e => {
voiceRemoteStream = e.streams[0];
if (!voiceOutputAudio) {
voiceOutputAudio = document.createElement("audio");
voiceOutputAudio.autoplay = true;
voiceOutputAudio.setAttribute("playsinline", "");
document.body.appendChild(voiceOutputAudio);
}
voiceOutputAudio.srcObject = voiceRemoteStream;
voiceOutputAudio.play().catch(() => {});
};
document.addEventListener("click", voicePlayRetry, true);
window.addEventListener("voicemodechanged", voiceModeChangedHandler);
if (navigator.mediaDevices && navigator.mediaDevices.addEventListener) {
voiceDeviceChangeHandler = () => {
clearTimeout(voiceDeviceChangeTimer);
// Audio route changed (BT/headset/speaker) - re-init the playout track
voiceDeviceChangeTimer = setTimeout(() => replugVoiceOutputElement(), 400);
};
navigator.mediaDevices.addEventListener("devicechange", voiceDeviceChangeHandler);
}
voiceLoopSendPc.addTrack(voicePlayoutDest.stream.getAudioTracks()[0], voicePlayoutDest.stream);
voiceLoopSendPc.createOffer()
.then(offer => voiceLoopSendPc.setLocalDescription(offer))
.then(() => voiceLoopRecvPc.setRemoteDescription(voiceLoopSendPc.localDescription))
.then(() => voiceLoopRecvPc.createAnswer())
.then(answer => voiceLoopRecvPc.setLocalDescription(answer))
.then(() => voiceLoopSendPc.setRemoteDescription(voiceLoopRecvPc.localDescription))
.catch(() => {
recoverVoiceLoopback();
});
// Safety net: verify the WebRTC playout actually produces sound and repair it if not
if (voiceLoopWatchdog) clearTimeout(voiceLoopWatchdog);
voiceLoopWatchdog = setTimeout(() => {
voiceLoopWatchdog = null;
if (!voicePlayoutDest) return;
voicePlayRetry();
setTimeout(() => {
if (!voicePlayoutDest || voicePlayoutOk()) return;
recoverVoiceLoopback();
}, 600);
}, 4000);
}
window.toggleVoiceSpeakerMode = async function() {
isVoiceSpeakerMode = !isVoiceSpeakerMode;
let btn = document.getElementById("voice-btn-speaker");
if (btn) btn.classList.toggle("voice-btn-active", isVoiceSpeakerMode);
const native = getNativeVoiceRouting();
if (native && native.setSpeaker) {
// Native routing keeps playback on the call volume stream - only the
// physical output (earpiece <-> loudspeaker) changes, like a phone call.
// The native AudioTrack follows the communication device automatically.
try { await native.setSpeaker({ enabled: isVoiceSpeakerMode }); } catch (e) {}
setTimeout(() => replugVoiceOutputElement(), 250);
setTimeout(() => replugVoiceOutputElement(), 900);
setTimeout(() => { if (voicePlayoutDest && !voicePlayoutOk()) recoverVoiceLoopback(); }, 1700);
return;
}
if (!receiverNode) return;
try { receiverNode.disconnect(); } catch (e) {}
if (!isVoiceSpeakerMode && voicePlayoutDest) {
receiverNode.connect(voicePlayoutDest);
if (voiceOutputAudio) voiceOutputAudio.play().catch(() => {});
} else {
receiverNode.connect(audioContext.destination);
if (voiceOutputAudio) { try { voiceOutputAudio.pause(); } catch (e) {} }
}
};
function setVoiceStatus(state) {
let el = document.getElementById("voice-status");
if (!el) return;
@ -88,8 +365,12 @@ function setVoiceStatus(state) {
}
}
function setTileSpeaking(clientId, speaking) {
function setTileSpeaking(clientId, speaking, streamDecoderId = null) {
let tile = document.getElementById(`voice-tile-${clientId}`);
if (streamDecoderId) {
let streamTile = document.getElementById(`voice-tile-stream-${streamDecoderId}`);
if (streamTile) tile = streamTile;
}
if (!tile) return;
if (tile._speakingTimer) {
@ -197,6 +478,25 @@ 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]));
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;
}
}
return true;
}
}
registerProcessor('audio-tap', AudioTap);
registerProcessor('audio-sender', AudioProcessor);
registerProcessor('audio-receiver', AudioReceiver);`;
@ -289,16 +589,22 @@ async function startVoiceSession(serverUrl) {
const micConstraints = {audio: {echoCancellation: false, noiseSuppression: false, autoGainControl: false}};
micStream = await navigator.mediaDevices.getUserMedia(micConstraints);
const nativeRouting = getNativeVoiceRouting();
if (nativeRouting && nativeRouting.startCall) {
try { await nativeRouting.startCall(); } catch (e) {}
}
if (!audioContext) audioContext = new (window.AudioContext || window.webkitAudioContext)({sampleRate: 48000});
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');
receiverNode.connect(audioContext.destination);
screenReceiverNode = new AudioWorkletNode(audioContext, 'audio-receiver');
isAudioWorkletLoaded = true;
}
if (audioContext.state === 'suspended') await audioContext.resume();
setupVoiceOutput();
micSourceNode = audioContext.createMediaStreamSource(micStream);
micSourceNode.connect(senderNode);
@ -361,13 +667,14 @@ async function startVoiceSession(serverUrl) {
remoteAudio[audioId].lastUpdate = Date.now();
if (!audioDecryptChains[audioId]) audioDecryptChains[audioId] = new TaskQueue();
const targetReceiver = isScreenAudio ? screenReceiverNode : receiverNode;
audioDecryptChains[audioId].enqueue(async () => {
try {
const decryptedBuffer = await crypto.subtle.decrypt({name: "AES-GCM", iv}, voiceAesCryptoKey, ciphertext);
if(receiverNode) receiverNode.port.postMessage({ clientId: audioId, audioData: decryptedBuffer }, [decryptedBuffer]);
setTileSpeaking(clientId, true);
if(targetReceiver) targetReceiver.port.postMessage({ clientId: audioId, audioData: decryptedBuffer }, [decryptedBuffer]);
setTileSpeaking(clientId, true, isScreenAudio ? `${clientId}-2` : null);
} catch (err) {}
});
} else if (type === 1 || type === 2) {
@ -443,7 +750,7 @@ async function startVoiceSession(serverUrl) {
if (remoteAudio[audioId]) {
if(audioDecryptChains[audioId]) audioDecryptChains[audioId].clear();
delete audioDecryptChains[audioId]; delete remoteAudio[audioId];
if(receiverNode) receiverNode.port.postMessage({ action: 'remove', clientId: audioId });
if(screenReceiverNode) screenReceiverNode.port.postMessage({ action: 'remove', clientId: audioId });
}
} else {
const decoderId = `${clientId}-${stoppedTypeId}`;
@ -499,7 +806,8 @@ function handleCallUpdate(data) {
if (audioDecoders[u]) try { audioDecoders[u].close(); } catch(e){}
delete audioDecoders[u];
delete remoteAudio[u];
if(receiverNode) receiverNode.port.postMessage({ action: 'remove', clientId: u });
const rn = u.endsWith("_screen") ? screenReceiverNode : receiverNode;
if(rn) rn.port.postMessage({ action: 'remove', clientId: u });
}
}
@ -727,6 +1035,9 @@ function showVoiceUI() {
<button id="voice-btn-screen" class="voice-btn" onclick="toggleVoiceScreen()" title="${processBlah("action.call.screen")}">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="2" y="3" width="20" height="14" rx="2" ry="2"></rect><line x1="8" y1="21" x2="16" y2="21"></line><line x1="12" y1="17" x2="12" y2="21"></line></svg>
</button>
${(isMobileVoiceDevice() && getNativeVoiceRouting()) ? `<button id="voice-btn-speaker" class="voice-btn${isVoiceSpeakerMode ? " voice-btn-active" : ""}" onclick="toggleVoiceSpeakerMode()" title="${processBlah("action.call.speaker")}">
${ICONS.speaker({width: "1.4rem", height: "1.4rem"})}
</button>` : ""}
<button id="voice-btn-hangup" class="voice-btn voice-btn-danger" onclick="endVoiceSession()" title="${processBlah("action.call.disconnect")}">
${ICONS.call({width: "1.4rem", height: "1.4rem"})}
</button>
@ -770,11 +1081,34 @@ function showVoiceUI() {
header.addEventListener('pointerup', stopDrag);
header.addEventListener('pointercancel', stopDrag);
ensureVoiceFooterFits(voiceUiContainer);
ensureVoiceGridObserver();
setupVoiceWindowResize();
}
// Raises the popup's min-width so the footer buttons always fit with the same
// side padding the footer uses, regardless of how many buttons are rendered.
function ensureVoiceFooterFits(popupEl) {
let footer = popupEl.querySelector("#voice-footer");
if (!footer) return;
const cs = getComputedStyle(footer);
let need = (parseFloat(cs.paddingLeft) || 0) + (parseFloat(cs.paddingRight) || 0);
const kids = Array.from(footer.children);
const gap = parseFloat(cs.columnGap) || parseFloat(cs.gap) || 0;
kids.forEach((kid, i) => {
if (i > 0) need += gap;
need += kid.offsetWidth;
});
const bcs = getComputedStyle(popupEl);
need += ((parseFloat(bcs.borderLeftWidth) || 0) + (parseFloat(bcs.borderRightWidth) || 0));
if (need > 0) {
popupEl.style.minWidth = Math.ceil(need) + "px";
clampVoiceWindowToViewport(popupEl);
}
}
function setupVoiceWindowResize() {
const popup = voiceUiContainer;
if (!popup || popup.dataset.resizeInit) return;
@ -1046,6 +1380,14 @@ function relayoutVoiceTiles() {
const cs = getComputedStyle(container);
const innerW = container.clientWidth - parseFloat(cs.paddingLeft) - parseFloat(cs.paddingRight);
const innerH = container.clientHeight - parseFloat(cs.paddingTop) - parseFloat(cs.paddingBottom);
if (!isFinite(innerW) || !isFinite(innerH) || !isFinite(rootFs) || innerW <= 0 || innerH <= 0 || rootFs <= 0) {
container.style.removeProperty("--voice-tile-w");
container.style.removeProperty("--voice-tile-h");
delete container.dataset.layoutSig;
return;
}
const minCol = 6.25 * rootFs;
const minRow = 7 * rootFs;
const gap = 0.75 * rootFs;
@ -1128,8 +1470,9 @@ window.updateVoiceVol = function(clientId, val, type) {
if (el) el.innerText = val + '%';
const audioId = type === 'screen' ? `${clientId}_screen` : clientId;
const volumeMultiplier = parseInt(val) / 100.0;
if (receiverNode) {
receiverNode.port.postMessage({ action: 'setVolume', clientId: audioId, volume: volumeMultiplier });
const rn = type === 'screen' ? screenReceiverNode : receiverNode;
if (rn) {
rn.port.postMessage({ action: 'setVolume', clientId: audioId, volume: volumeMultiplier });
}
};
@ -1149,6 +1492,16 @@ function toggleVoiceMic() {
function endVoiceSession() {
setVoiceStatus("disconnected");
const nativeRouting = getNativeVoiceRouting();
if (nativeRouting && nativeRouting.stopCall) {
try { nativeRouting.stopCall(); } catch (e) {}
}
isVoiceSpeakerMode = false;
voiceLoopRestarts = 0;
stopNativePlayback();
pcmBatch = null;
pcmBatchLen = 0;
if (currentVoiceCallId) {
let payload = JSON.stringify({ string1: currentVoiceCallType, string2: currentVoiceCallId });
if (window.fetchEncrypted) window.fetchEncrypted("room/voice/leave", payload).catch(()=>{});
@ -1182,7 +1535,13 @@ function endVoiceSession() {
if (receiverNode) {
receiverNode.port.postMessage({ action: 'clear' });
try { receiverNode.disconnect(); } catch (e) {}
}
if (screenReceiverNode) {
screenReceiverNode.port.postMessage({ action: 'clear' });
try { screenReceiverNode.disconnect(); } catch (e) {}
}
teardownVoiceLoopback();
if (camControl) { camControl.stop(); camControl = null; }
if (screenControl) { screenControl.stop(); screenControl = null; }

View file

@ -9,6 +9,13 @@ public class MainActivity extends BridgeActivity {
@Override
public void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
}
@Override
protected void load() {
// Must be added to the builder BEFORE super.load() creates the bridge,
// otherwise the plugin is silently dropped (bridge already built).
registerPlugin(VoiceRoutingPlugin.class);
super.load();
}
}

View file

@ -0,0 +1,471 @@
package olcxja.miarven;
import android.Manifest;
import android.app.Activity;
import android.content.Context;
import android.content.pm.PackageManager;
import android.media.AudioAttributes;
import android.media.AudioDeviceInfo;
import android.media.AudioFocusRequest;
import android.media.AudioFormat;
import android.media.AudioManager;
import android.media.AudioTrack;
import android.os.Build;
import android.os.Handler;
import android.os.Looper;
import android.util.Log;
import androidx.core.content.ContextCompat;
import com.getcapacitor.JSObject;
import com.getcapacitor.Plugin;
import com.getcapacitor.PluginCall;
import com.getcapacitor.PluginMethod;
import com.getcapacitor.annotation.CapacitorPlugin;
import com.getcapacitor.annotation.Permission;
import com.getcapacitor.annotation.PermissionCallback;
import java.util.List;
import java.util.concurrent.LinkedBlockingQueue;
import java.util.concurrent.TimeUnit;
@CapacitorPlugin(name = "VoiceRouting", permissions = {
@Permission(alias = "bluetooth", strings = { Manifest.permission.BLUETOOTH_CONNECT })
})
public class VoiceRoutingPlugin extends Plugin {
private static final String TAG = "VoiceRouting";
private static final long MODE_GUARD_INTERVAL_MS = 2500;
private AudioManager audioManager;
private boolean inCall = false;
private boolean speakerOn = false;
private AudioFocusRequest focusRequest26 = null;
private PluginCall pendingStartCall = null;
private Handler mainHandler;
private boolean modeListenerAdded = false;
private AudioTrack nativeTrack = null;
private Thread nativeFeedThread = null;
private final LinkedBlockingQueue<byte[]> pcmQueue = new LinkedBlockingQueue<>();
private volatile boolean nativePlaying = false;
private final AudioManager.OnAudioFocusChangeListener focusChangeListener = fi -> {};
private void log(String msg) {
// android.util.Log is always visible in logcat, even for release builds
Log.i(TAG, msg);
}
private AudioManager am() {
if (audioManager == null) {
audioManager = (AudioManager) getContext().getSystemService(Context.AUDIO_SERVICE);
}
return audioManager;
}
/**
* Holding call audio focus is what makes Android treat the app as being in
* a real call: without it the system reverts MODE_IN_COMMUNICATION back to
* MODE_NORMAL every few seconds (AudioManager.MSG_CHECK_MODE_FOR_UID).
*/
private void requestCallFocus() {
AudioManager am = am();
if (am == null) return;
if (Build.VERSION.SDK_INT >= 26) {
focusRequest26 = new AudioFocusRequest.Builder(AudioManager.AUDIOFOCUS_GAIN)
.setAudioAttributes(new AudioAttributes.Builder()
.setUsage(AudioAttributes.USAGE_VOICE_COMMUNICATION)
.setContentType(AudioAttributes.CONTENT_TYPE_SPEECH)
.build())
.setOnAudioFocusChangeListener(focusChangeListener)
.build();
int res = am.requestAudioFocus(focusRequest26);
log("audio focus request (26+): " + res);
} else {
@SuppressWarnings("deprecation")
int res = am.requestAudioFocus(focusChangeListener, AudioManager.STREAM_VOICE_CALL, AudioManager.AUDIOFOCUS_GAIN);
log("audio focus request (legacy): " + res);
}
}
private void abandonCallFocus() {
AudioManager am = am();
if (am == null) return;
if (Build.VERSION.SDK_INT >= 26 && focusRequest26 != null) {
am.abandonAudioFocusRequest(focusRequest26);
focusRequest26 = null;
} else {
@SuppressWarnings("deprecation")
int res = am.abandonAudioFocus(focusChangeListener);
log("audio focus abandoned: " + res);
}
}
private Handler handler() {
if (mainHandler == null) {
mainHandler = new Handler(Looper.getMainLooper());
}
return mainHandler;
}
private boolean needsBluetoothPermission() {
return Build.VERSION.SDK_INT >= 31
&& ContextCompat.checkSelfPermission(getContext(), Manifest.permission.BLUETOOTH_CONNECT)
!= PackageManager.PERMISSION_GRANTED;
}
/**
* Android 12+ silently reverts MODE_IN_COMMUNICATION back to MODE_NORMAL.
* When that happens, Chromium rebuilds its playout track with media usage
* and the call stops following the call volume stream. This guard restores
* the mode (and the route with it).
*/
private void assertModeAndRoute() {
AudioManager am = am();
if (am == null || !inCall) return;
if (am.getMode() != AudioManager.MODE_IN_COMMUNICATION) {
log("MODE_IN_COMMUNICATION was reverted, restoring");
am.setMode(AudioManager.MODE_IN_COMMUNICATION);
applyRoute();
// Tell the web layer so it re-initializes its WebRTC playout track,
// which Chromium otherwise leaves tagged as media after the revert.
try {
getBridge().triggerWindowJSEvent("voicemodechanged");
} catch (Exception e) {
Log.w(TAG, "failed to notify web layer", e);
}
}
}
private void startModeGuard() {
AudioManager am = am();
// Instant restore on API 30+ (fires the moment the system touches the mode)
if (Build.VERSION.SDK_INT >= 30 && am != null && !modeListenerAdded) {
try {
am.addOnModeChangedListener(getContext().getMainExecutor(), modeChangedListener);
modeListenerAdded = true;
} catch (Exception e) {
Log.w(TAG, "addOnModeChangedListener failed", e);
}
}
handler().postDelayed(guardRunnable, MODE_GUARD_INTERVAL_MS);
}
private void stopModeGuard() {
AudioManager am = am();
if (modeListenerAdded && am != null) {
try {
am.removeOnModeChangedListener(modeChangedListener);
} catch (Exception e) {}
modeListenerAdded = false;
}
handler().removeCallbacks(guardRunnable);
}
private final Runnable guardRunnable = new Runnable() {
@Override
public void run() {
if (!inCall) return;
assertModeAndRoute();
handler().postDelayed(this, MODE_GUARD_INTERVAL_MS);
}
};
private final AudioManager.OnModeChangedListener modeChangedListener = new AudioManager.OnModeChangedListener() {
@Override
public void onModeChanged(int mode) {
if (!inCall) return;
log("onModeChanged -> " + mode);
assertModeAndRoute();
}
};
@PluginMethod
public void startCall(PluginCall call) {
if (needsBluetoothPermission()) {
pendingStartCall = call;
requestAllPermissions(call, "onBtPermissionResult");
return;
}
beginCall(call);
}
@PermissionCallback
private void onBtPermissionResult(PluginCall call) {
// Bluetooth permission is optional - continue regardless of the outcome.
if (pendingStartCall == call) {
pendingStartCall = null;
beginCall(call);
}
}
private void beginCall(PluginCall call) {
AudioManager am = am();
if (am == null) {
call.reject("AudioManager unavailable");
return;
}
inCall = true;
speakerOn = false;
requestCallFocus();
am.setMode(AudioManager.MODE_IN_COMMUNICATION);
applyRoute();
startModeGuard();
setVolumeControlStreamCompat(AudioManager.STREAM_VOICE_CALL);
call.resolve();
}
private void setVolumeControlStreamCompat(int stream) {
try {
Context ctx = getContext();
if (ctx instanceof Activity) {
((Activity) ctx).setVolumeControlStream(stream);
}
} catch (Exception e) {
Log.w(TAG, "setVolumeControlStream failed", e);
}
}
@PluginMethod
public void nativeAudioStart(PluginCall call) {
Integer sr = call.getInt("sampleRate");
int sampleRate = (sr == null || sr < 8000) ? 48000 : sr;
stopNativeAudioInternal();
int minBuf = AudioTrack.getMinBufferSize(sampleRate, AudioFormat.CHANNEL_OUT_MONO, AudioFormat.ENCODING_PCM_16BIT);
if (minBuf <= 0) {
call.reject("getMinBufferSize failed: " + minBuf);
return;
}
AudioAttributes attrs = new AudioAttributes.Builder()
.setUsage(AudioAttributes.USAGE_VOICE_COMMUNICATION)
.setContentType(AudioAttributes.CONTENT_TYPE_SPEECH)
.build();
AudioFormat format = new AudioFormat.Builder()
.setEncoding(AudioFormat.ENCODING_PCM_16BIT)
.setSampleRate(sampleRate)
.setChannelMask(AudioFormat.CHANNEL_OUT_MONO)
.build();
// Keep the buffer tight - its capacity directly adds to playback latency,
// because blocking writes keep it filled close to 100% in steady state.
int bufBytes = Math.max(minBuf, 1920); // >= 20 ms @ 48 kHz mono 16-bit
AudioTrack.Builder builder = new AudioTrack.Builder()
.setAudioAttributes(attrs)
.setAudioFormat(format)
.setTransferMode(AudioTrack.MODE_STREAM)
.setBufferSizeInBytes(bufBytes);
if (Build.VERSION.SDK_INT >= 26) {
builder.setPerformanceMode(AudioTrack.PERFORMANCE_MODE_LOW_LATENCY);
}
nativeTrack = builder.build();
pcmQueue.clear();
nativePlaying = true;
try {
nativeTrack.play();
} catch (IllegalStateException e) {
stopNativeAudioInternal();
call.reject("AudioTrack.play failed", e);
return;
}
nativeFeedThread = new Thread(() -> {
android.os.Process.setThreadPriority(android.os.Process.THREAD_PRIORITY_URGENT_AUDIO);
while (nativePlaying) {
byte[] chunk;
try {
chunk = pcmQueue.poll(100, TimeUnit.MILLISECONDS);
} catch (InterruptedException ie) {
break;
}
if (chunk == null) continue;
AudioTrack track = nativeTrack;
if (track == null || !nativePlaying) break;
int written = track.write(chunk, 0, chunk.length, AudioTrack.WRITE_BLOCKING);
if (written < 0) {
Log.w(TAG, "AudioTrack.write error: " + written);
break;
}
}
}, "VoiceRoutingFeed");
nativeFeedThread.start();
log("native audio started, rate=" + sampleRate
+ " minBuf=" + minBuf
+ " bufBytes=" + bufBytes
+ " (~" + (bufBytes * 500 / sampleRate) + " ms)");
call.resolve();
}
@PluginMethod
public void nativeAudioFeed(PluginCall call) {
String b64 = call.getString("data");
if (!nativePlaying || b64 == null || nativeTrack == null) {
call.resolve();
return;
}
byte[] pcm;
try {
pcm = android.util.Base64.decode(b64, android.util.Base64.NO_WRAP);
} catch (IllegalArgumentException e) {
call.resolve();
return;
}
while (pcmQueue.size() > 10) {
pcmQueue.poll(); // drop oldest to avoid unbounded growth
}
pcmQueue.offer(pcm);
call.resolve();
}
@PluginMethod
public void nativeAudioStop(PluginCall call) {
stopNativeAudioInternal();
call.resolve();
}
private void stopNativeAudioInternal() {
nativePlaying = false;
if (nativeFeedThread != null) {
nativeFeedThread.interrupt();
nativeFeedThread = null;
}
if (nativeTrack != null) {
try {
nativeTrack.pause();
nativeTrack.flush();
nativeTrack.release();
} catch (Exception e) {
Log.w(TAG, "AudioTrack release failed", e);
}
nativeTrack = null;
}
pcmQueue.clear();
}
@PluginMethod
public void stopCall(PluginCall call) {
inCall = false;
stopModeGuard();
abandonCallFocus();
stopNativeAudioInternal();
setVolumeControlStreamCompat(AudioManager.STREAM_MUSIC);
AudioManager am = am();
if (am != null) {
if (Build.VERSION.SDK_INT >= 31) {
am.clearCommunicationDevice();
}
am.setSpeakerphoneOn(false);
am.setMode(AudioManager.MODE_NORMAL);
}
speakerOn = false;
call.resolve();
}
@PluginMethod
public void setSpeaker(PluginCall call) {
speakerOn = Boolean.TRUE.equals(call.getBoolean("enabled", false));
applyRoute();
JSObject ret = new JSObject();
ret.put("enabled", speakerOn);
call.resolve(ret);
}
@PluginMethod
public void debugInfo(PluginCall call) {
AudioManager am = am();
JSObject ret = new JSObject();
if (am == null) {
ret.put("error", "no audio manager");
call.resolve(ret);
return;
}
ret.put("sdkInt", Build.VERSION.SDK_INT);
ret.put("mode", am.getMode());
ret.put("inCall", inCall);
ret.put("speakerOn", speakerOn);
java.util.List<String> comm = new java.util.ArrayList<>();
for (AudioDeviceInfo d : am.getAvailableCommunicationDevices()) {
comm.add(String.valueOf(d.getType()));
}
ret.put("commDevices", String.join(",", comm));
if (Build.VERSION.SDK_INT >= 31) {
AudioDeviceInfo cur = am.getCommunicationDevice();
ret.put("commDeviceType", cur == null ? -1 : cur.getType());
}
call.resolve(ret);
}
private boolean applyRoute() {
AudioManager am = am();
if (am == null) return false;
// Communication mode must be active for the whole duration of the call,
// otherwise Chromium tags its playout track as media.
if (am.getMode() != AudioManager.MODE_IN_COMMUNICATION) {
am.setMode(AudioManager.MODE_IN_COMMUNICATION);
}
boolean routed = false;
if (Build.VERSION.SDK_INT >= 31) {
AudioDeviceInfo target;
if (speakerOn) {
target = findDevice(am, AudioDeviceInfo.TYPE_BUILTIN_SPEAKER);
} else {
target = findPreferredNonSpeaker(am);
}
if (target != null) {
routed = am.setCommunicationDevice(target);
}
log("route -> speaker=" + speakerOn
+ " device=" + (target == null ? "default" : String.valueOf(target.getType()))
+ " applied=" + routed
+ " mode=" + am.getMode());
if (target == null && !speakerOn) {
// No earpiece/headset available (e.g. tablet): keep default comm routing
routed = true;
}
}
if (!routed) {
// Legacy path (Android < 12, or communication device rejected)
am.setMode(AudioManager.MODE_IN_COMMUNICATION);
am.setSpeakerphoneOn(speakerOn);
routed = true;
}
return routed;
}
private static AudioDeviceInfo findPreferredNonSpeaker(AudioManager am) {
int[] preference = {
AudioDeviceInfo.TYPE_WIRED_HEADSET,
AudioDeviceInfo.TYPE_WIRED_HEADPHONES,
AudioDeviceInfo.TYPE_USB_HEADSET,
AudioDeviceInfo.TYPE_BLUETOOTH_SCO,
AudioDeviceInfo.TYPE_BLE_HEADSET,
AudioDeviceInfo.TYPE_BUILTIN_EARPIECE
};
for (int type : preference) {
AudioDeviceInfo d = findDevice(am, type);
if (d != null) return d;
}
return null;
}
private static AudioDeviceInfo findDevice(AudioManager am, int type) {
List<AudioDeviceInfo> devices = am.getAvailableCommunicationDevices();
for (AudioDeviceInfo d : devices) {
if (d.getType() == type) return d;
}
return null;
}
}