diff --git a/app/javascript/dashboard/components-next/message/bubbles/VoiceCall.vue b/app/javascript/dashboard/components-next/message/bubbles/VoiceCall.vue index 86ee640d1..bcf97b707 100644 --- a/app/javascript/dashboard/components-next/message/bubbles/VoiceCall.vue +++ b/app/javascript/dashboard/components-next/message/bubbles/VoiceCall.vue @@ -118,7 +118,13 @@ const bgColor = computed(() => BG_COLOR_MAP[status.value] || 'bg-n-teal-9'); {{ $t(labelKey) }} - {{ formattedDuration || $t(subtextKey) }} + + {{ + audioAttachment + ? $t(subtextKey) + : formattedDuration || $t(subtextKey) + }} diff --git a/app/javascript/dashboard/composables/useCallSession.js b/app/javascript/dashboard/composables/useCallSession.js index 12bdb5075..179d5818b 100644 --- a/app/javascript/dashboard/composables/useCallSession.js +++ b/app/javascript/dashboard/composables/useCallSession.js @@ -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; diff --git a/app/javascript/dashboard/composables/useWhatsappCallSession.js b/app/javascript/dashboard/composables/useWhatsappCallSession.js index cd4282b41..dab4cbc0f 100644 --- a/app/javascript/dashboard/composables/useWhatsappCallSession.js +++ b/app/javascript/dashboard/composables/useWhatsappCallSession.js @@ -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); }; diff --git a/app/javascript/dashboard/stores/calls.js b/app/javascript/dashboard/stores/calls.js index 717b85efe..6c3bdf7f3 100644 --- a/app/javascript/dashboard/stores/calls.js +++ b/app/javascript/dashboard/stores/calls.js @@ -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) { diff --git a/enterprise/app/models/enterprise/concerns/attachment.rb b/enterprise/app/models/enterprise/concerns/attachment.rb index 155dfcaad..24cc338cd 100644 --- a/enterprise/app/models/enterprise/concerns/attachment.rb +++ b/enterprise/app/models/enterprise/concerns/attachment.rb @@ -3,6 +3,11 @@ module Enterprise::Concerns::Attachment included do after_create_commit :enqueue_audio_transcription + # Broadcast the message update so the FE bubble picks up the new audio + # attachment immediately. Without this, the FE has to wait until Whisper + # finishes (or fall back to a page refresh) — and if Whisper returns blank, + # the bubble never gets the audio at all. + after_create_commit :broadcast_message_update_for_audio end private @@ -12,4 +17,11 @@ module Enterprise::Concerns::Attachment Messages::AudioTranscriptionJob.perform_later(id) end + + def broadcast_message_update_for_audio + return unless file_type.to_sym == :audio + return unless message + + message.reload.send_update_event + end end