fix(voice): robust accept-with-fetch + remote-end recording race + single-duration bubble + audio playback signals

This commit is contained in:
Tanmay Deep Sharma
2026-05-01 18:29:54 +07:00
parent 1abb51992b
commit d9077c64d3
5 changed files with 71 additions and 5 deletions
@@ -118,7 +118,13 @@ const bgColor = computed(() => BG_COLOR_MAP[status.value] || 'bg-n-teal-9');
{{ $t(labelKey) }}
</span>
<span class="text-xs text-n-slate-11">
{{ formattedDuration || $t(subtextKey) }}
<!-- When the audio chip is rendered it already shows duration in
its own player; suppress here to avoid two competing numbers. -->
{{
audioAttachment
? $t(subtextKey)
: formattedDuration || $t(subtextKey)
}}
</span>
</div>
</div>
@@ -5,6 +5,7 @@ import { useCallsStore } from 'dashboard/stores/calls';
import {
useWhatsappCallSession,
sendWhatsappTerminateBeacon,
cleanupWhatsappSession,
} from 'dashboard/composables/useWhatsappCallSession';
import Timer from 'dashboard/helper/Timer';
@@ -127,6 +128,10 @@ export function useCallSession() {
} catch (error) {
// eslint-disable-next-line no-console
console.error('Failed to join call:', error);
// Tear down any half-built WebRTC state so the user's next click starts
// fresh; otherwise the leftover pc + mic stream survives and confuses
// the second-attempt SDP exchange.
cleanupWhatsappSession();
return null;
} finally {
isJoining.value = false;
@@ -38,7 +38,9 @@ const playRemoteStream = stream => {
});
};
const RECORDING_TIMESLICE_MS = 5000;
// Smaller timeslice → chunks flush to memory every second so a remote hangup
// that races cleanup still leaves data behind to upload.
const RECORDING_TIMESLICE_MS = 1000;
const ICE_GATHER_TIMEOUT_MS = 10000;
const RECORDER_MIME_CANDIDATES = [
'audio/webm;codecs=opus',
@@ -95,8 +97,15 @@ const cleanup = () => {
// Mix local mic + remote audio via Web Audio so the recording captures both legs.
const setupRecorder = () => {
if (!localStream || !remoteStream || mediaRecorder) return;
// Without at least one remote track, createMediaStreamSource on remoteStream
// wires up to nothing — the recorded mix is effectively just silence.
if (remoteStream.getAudioTracks().length === 0) return;
audioContext = new AudioContext({ sampleRate: 48000 });
// AudioContext starts suspended under most autoplay policies. Resume so the
// graph actually runs; otherwise the destination stream produces silence.
audioContext.resume().catch(() => {});
const destination = audioContext.createMediaStreamDestination();
audioContext.createMediaStreamSource(localStream).connect(destination);
audioContext.createMediaStreamSource(remoteStream).connect(destination);
@@ -188,7 +197,29 @@ export function useWhatsappCallSession() {
};
const acceptIncomingCall = async ({ callId, sdpOffer, iceServers }) => {
const sdpAnswer = await prepareInboundAnswer(sdpOffer, iceServers);
// The store may not have sdpOffer yet (cable's voice_call.incoming raced
// the click), so fall back to GET /whatsapp_calls/:id which exposes the
// SDP offer + ICE servers from the show jbuilder.
let offer = sdpOffer;
let ice = iceServers;
if (!offer && callId) {
try {
const fresh = await WhatsappCallsAPI.show(callId);
offer = fresh?.sdp_offer || fresh?.sdpOffer;
ice = ice || fresh?.ice_servers || fresh?.iceServers;
} catch (e) {
// eslint-disable-next-line no-console
console.error(
'[WhatsApp Call] failed to fetch call data for accept:',
e
);
}
}
if (!offer) {
throw new Error('Missing sdp_offer for accept — call may have ended.');
}
const sdpAnswer = await prepareInboundAnswer(offer, ice);
activeCallId = callId;
await WhatsappCallsAPI.accept(callId, sdpAnswer);
};
+14 -2
View File
@@ -25,9 +25,21 @@ export const useCallsStore = defineStore('calls', {
actions: {
handleCallStatusChanged({ callSid, status }) {
if (TERMINAL_STATUSES.includes(status)) {
this.removeCall(callSid);
if (!TERMINAL_STATUSES.includes(status)) return;
const call = this.calls.find(c => c.callSid === callSid);
// For WhatsApp, the upload-and-cleanup must happen before the recorder
// state is wiped — that runs from the voice_call.ended cable handler.
// If we tear down here (race-winning the cable end-event), the recorder
// chunks are gone before they get uploaded, so the recording is lost.
// Just drop the call from the store; voice_call.ended will idempotently
// finish cleanup once it arrives.
if (call?.provider === 'whatsapp') {
this.calls = this.calls.filter(c => c.callSid !== callSid);
return;
}
this.removeCall(callSid);
},
addCall(callData) {