fix(voice): pipe Meta's remote audio to a hidden audio element so the agent hears the contact

This commit is contained in:
Tanmay Deep Sharma
2026-04-30 20:17:19 +07:00
parent 112a8f9e9e
commit c25ed094fa
@@ -7,12 +7,37 @@ import WhatsappCallsAPI from 'dashboard/api/channel/whatsapp/whatsappCallsAPI';
let pc = null;
let localStream = null;
let remoteStream = null;
let remoteAudioEl = null;
let mediaRecorder = null;
let recorderChunks = [];
let audioContext = null;
let activeCallId = null;
let intentionallyClosing = false;
// Lazily attach a hidden <audio autoplay> to the document so Meta's track
// actually plays through the speakers — without this, mic flows to Meta but
// the user hears nothing back.
const ensureRemoteAudioElement = () => {
if (remoteAudioEl) return remoteAudioEl;
remoteAudioEl = document.createElement('audio');
remoteAudioEl.id = 'whatsapp-call-remote-audio';
remoteAudioEl.autoplay = true;
remoteAudioEl.playsInline = true;
remoteAudioEl.style.display = 'none';
document.body.appendChild(remoteAudioEl);
return remoteAudioEl;
};
const playRemoteStream = stream => {
const el = ensureRemoteAudioElement();
el.srcObject = stream;
// play() may reject under autoplay policies; surface to console but don't crash the call.
el.play().catch(err => {
// eslint-disable-next-line no-console
console.warn('[WhatsApp Call] remote audio play() failed:', err);
});
};
const RECORDING_TIMESLICE_MS = 5000;
const ICE_GATHER_TIMEOUT_MS = 10000;
const RECORDER_MIME_CANDIDATES = [
@@ -50,6 +75,7 @@ const cleanup = () => {
if (localStream) localStream.getTracks().forEach(t => t.stop());
if (remoteStream) remoteStream.getTracks().forEach(t => t.stop());
if (pc) pc.close();
if (remoteAudioEl) remoteAudioEl.srcObject = null;
pc = null;
localStream = null;
@@ -61,18 +87,6 @@ const cleanup = () => {
intentionallyClosing = false;
};
const buildPeerConnection = iceServers => {
const config = iceServers && iceServers.length ? { iceServers } : {};
pc = new RTCPeerConnection(config);
remoteStream = new MediaStream();
pc.ontrack = event => {
event.streams.forEach(stream =>
stream.getTracks().forEach(track => remoteStream.addTrack(track))
);
};
return pc;
};
// Mix local mic + remote audio via Web Audio so the recording captures both legs.
const setupRecorder = () => {
if (!localStream || !remoteStream || mediaRecorder) return;
@@ -95,6 +109,30 @@ const setupRecorder = () => {
mediaRecorder.start(RECORDING_TIMESLICE_MS);
};
const buildPeerConnection = iceServers => {
const config = iceServers && iceServers.length ? { iceServers } : {};
pc = new RTCPeerConnection(config);
remoteStream = new MediaStream();
pc.ontrack = event => {
// Add to the stable placeholder stream so any sources/recorders referencing
// it stay connected — never reassign the variable, since the recorder's
// audioContext source taps the original MediaStream object.
const tracks =
event.streams && event.streams[0]
? event.streams[0].getTracks()
: [event.track];
tracks.forEach(track => {
if (!remoteStream.getTracks().includes(track))
remoteStream.addTrack(track);
});
playRemoteStream(remoteStream);
// Defer recorder setup until we actually have remote tracks; createMediaStreamSource
// on an empty MediaStream is unreliable across browsers.
setupRecorder();
};
return pc;
};
const stopRecorderAndUpload = async callId => {
if (mediaRecorder && mediaRecorder.state !== 'inactive') {
await new Promise(resolve => {
@@ -129,7 +167,7 @@ export function useWhatsappCallSession() {
const answer = await pc.createAnswer();
await pc.setLocalDescription(answer);
await waitForIceGatheringComplete(pc);
setupRecorder();
// Recorder fires from ontrack once remote tracks arrive.
return pc.localDescription.sdp;
};
@@ -214,7 +252,7 @@ export const applyOutboundAnswer = async (callId, sdpAnswer) => {
if (!pc) return;
activeCallId = callId;
await pc.setRemoteDescription({ type: 'answer', sdp: sdpAnswer });
setupRecorder();
// Recorder + audio playback fire from ontrack as soon as Meta's tracks arrive.
};
export const hasActiveWhatsappCall = () => Boolean(activeCallId);