diff --git a/frontend/app/components/workspace/CrmWorkspaceApp.vue b/frontend/app/components/workspace/CrmWorkspaceApp.vue index 479e45a..210b704 100644 --- a/frontend/app/components/workspace/CrmWorkspaceApp.vue +++ b/frontend/app/components/workspace/CrmWorkspaceApp.vue @@ -2295,6 +2295,7 @@ onMounted(() => { onBeforeUnmount(() => { stopCrmRealtime(); + stopCommRecording(true); if (pilotRecording.value) { stopPilotRecording("fill"); } @@ -3459,6 +3460,8 @@ const commPinnedOnly = ref(false); const commDraft = ref(""); const commSending = ref(false); const commRecording = ref(false); +const commTranscribing = ref(false); +const commMicError = ref(""); const commComposerMode = ref<"message" | "planned" | "logged" | "document">("message"); const commQuickMenuOpen = ref(false); const commPinContextMenu = ref<{ @@ -3493,6 +3496,11 @@ const eventCloseError = ref>({}); const eventArchiveRecordingById = ref>({}); const eventArchiveTranscribingById = ref>({}); const eventArchiveMicErrorById = ref>({}); +let commMediaRecorder: MediaRecorder | null = null; +let commRecorderStream: MediaStream | null = null; +let commRecorderMimeType = "audio/webm"; +let commRecordingChunks: Blob[] = []; +let commDiscardOnStop = false; let eventArchiveMediaRecorder: MediaRecorder | null = null; let eventArchiveRecorderStream: MediaStream | null = null; let eventArchiveRecorderMimeType = "audio/webm"; @@ -3500,6 +3508,7 @@ let eventArchiveChunks: Blob[] = []; let eventArchiveTargetEventId = ""; watch(selectedCommThreadId, () => { + stopCommRecording(true); stopEventArchiveRecording(); destroyAllCommCallWaves(); callTranscriptOpen.value = {}; @@ -3508,6 +3517,7 @@ watch(selectedCommThreadId, () => { callTranscriptError.value = {}; commPinnedOnly.value = false; commDraft.value = ""; + commMicError.value = ""; commComposerMode.value = "message"; commQuickMenuOpen.value = false; commPinContextMenu.value = { open: false, x: 0, y: 0, entry: null }; @@ -4593,8 +4603,99 @@ async function sendCommMessage() { } } +async function startCommRecording() { + if (commRecording.value || commTranscribing.value) return; + commMicError.value = ""; + if (!pilotMicSupported.value) { + commMicError.value = "Recording is not supported in this browser"; + return; + } + + try { + const stream = await navigator.mediaDevices.getUserMedia({ audio: true }); + const preferredMime = "audio/webm;codecs=opus"; + const recorder = MediaRecorder.isTypeSupported(preferredMime) + ? new MediaRecorder(stream, { mimeType: preferredMime }) + : new MediaRecorder(stream); + + commRecorderStream = stream; + commRecorderMimeType = recorder.mimeType || "audio/webm"; + commMediaRecorder = recorder; + commRecordingChunks = []; + commDiscardOnStop = false; + commRecording.value = true; + + recorder.ondataavailable = (event: BlobEvent) => { + if (event.data?.size) commRecordingChunks.push(event.data); + }; + + recorder.onstop = async () => { + const discard = commDiscardOnStop; + commDiscardOnStop = false; + commRecording.value = false; + commMediaRecorder = null; + + if (commRecorderStream) { + commRecorderStream.getTracks().forEach((track) => track.stop()); + commRecorderStream = null; + } + + const audioBlob = new Blob(commRecordingChunks, { type: commRecorderMimeType }); + commRecordingChunks = []; + if (discard || audioBlob.size === 0) return; + + commTranscribing.value = true; + try { + const text = await transcribeAudioBlob(audioBlob); + if (!text) { + commMicError.value = "Could not recognize speech"; + return; + } + const previous = String(commDraft.value ?? "").trim(); + commDraft.value = previous ? `${previous} ${text}` : text; + commMicError.value = ""; + } catch (error: any) { + commMicError.value = String(error?.data?.message ?? error?.message ?? "Voice transcription failed"); + } finally { + commTranscribing.value = false; + } + }; + + recorder.start(); + } catch { + commRecording.value = false; + commMicError.value = "No microphone access"; + if (commRecorderStream) { + commRecorderStream.getTracks().forEach((track) => track.stop()); + commRecorderStream = null; + } + commMediaRecorder = null; + } +} + +function stopCommRecording(discard = false) { + if (!commMediaRecorder || commMediaRecorder.state === "inactive") { + commRecording.value = false; + commDiscardOnStop = false; + commRecordingChunks = []; + if (commRecorderStream) { + commRecorderStream.getTracks().forEach((track) => track.stop()); + commRecorderStream = null; + } + commMediaRecorder = null; + return; + } + commDiscardOnStop = discard; + commMediaRecorder.stop(); +} + function toggleCommRecording() { - commRecording.value = !commRecording.value; + if (commTranscribing.value) return; + if (commRecording.value) { + stopCommRecording(); + return; + } + void startCommRecording(); } function handleCommComposerEnter(event: KeyboardEvent) { @@ -5435,6 +5536,9 @@ async function decideFeedCard(card: FeedCard, decision: "accepted" | "rejected")

{{ commEventError }}

+

+ {{ commMicError }} +