diff --git a/frontend/app/components/workspace/CrmWorkspaceApp.vue b/frontend/app/components/workspace/CrmWorkspaceApp.vue index 210b704..b3671fe 100644 --- a/frontend/app/components/workspace/CrmWorkspaceApp.vue +++ b/frontend/app/components/workspace/CrmWorkspaceApp.vue @@ -4,6 +4,7 @@ import CrmAuthLoading from "~~/app/components/workspace/auth/CrmAuthLoading.vue" import CrmCalendarPanel from "~~/app/components/workspace/calendar/CrmCalendarPanel.vue"; import CrmCommunicationsContextSidebar from "~~/app/components/workspace/communications/CrmCommunicationsContextSidebar.vue"; import CrmCommunicationsListSidebar from "~~/app/components/workspace/communications/CrmCommunicationsListSidebar.vue"; +import CrmVoiceDictationButton from "~~/app/components/workspace/communications/CrmVoiceDictationButton.client.vue"; import CrmDocumentsPanel from "~~/app/components/workspace/documents/CrmDocumentsPanel.vue"; import CrmWorkspaceTopbar from "~~/app/components/workspace/header/CrmWorkspaceTopbar.vue"; import CrmPilotSidebar from "~~/app/components/workspace/pilot/CrmPilotSidebar.vue"; @@ -42,6 +43,7 @@ import { formatDocumentScope, isDocumentLinkedToContact, } from "~~/app/composables/useWorkspaceDocuments"; +import { isVoiceCaptureSupported, transcribeAudioBlob } from "~~/app/composables/useVoiceTranscription"; import { Chat as AiChat } from "@ai-sdk/vue"; import { DefaultChatTransport, isTextUIPart, type UIMessage } from "ai"; type TabId = "communications" | "documents"; @@ -1463,98 +1465,6 @@ function appendPilotTranscript(text: string) { return merged; } -function getAudioContextCtor(): typeof AudioContext { - const ctor = (globalThis as any).AudioContext ?? (globalThis as any).webkitAudioContext; - if (!ctor) { - throw new Error("AudioContext is not supported in this browser"); - } - return ctor as typeof AudioContext; -} - -function toMonoFloat32(buffer: AudioBuffer) { - if (buffer.numberOfChannels === 1) { - return buffer.getChannelData(0); - } - - const out = new Float32Array(buffer.length); - for (let channel = 0; channel < buffer.numberOfChannels; channel += 1) { - const input = buffer.getChannelData(channel); - for (let i = 0; i < buffer.length; i += 1) { - const prev = out[i] ?? 0; - out[i] = prev + (input[i] ?? 0); - } - } - for (let i = 0; i < out.length; i += 1) { - out[i] = (out[i] ?? 0) / buffer.numberOfChannels; - } - return out; -} - -function resampleFloat32Linear(input: Float32Array, fromRate: number, toRate: number) { - if (fromRate === toRate) return input; - const ratio = fromRate / toRate; - const outLength = Math.max(1, Math.round(input.length / ratio)); - const out = new Float32Array(outLength); - - for (let i = 0; i < outLength; i += 1) { - const position = i * ratio; - const left = Math.floor(position); - const right = Math.min(input.length - 1, left + 1); - const frac = position - left; - out[i] = (input[left] ?? 0) * (1 - frac) + (input[right] ?? 0) * frac; - } - - return out; -} - -function floatToPcm16Bytes(input: Float32Array) { - const out = new Uint8Array(input.length * 2); - const view = new DataView(out.buffer); - for (let i = 0; i < input.length; i += 1) { - const sample = Math.max(-1, Math.min(1, input[i] ?? 0)); - const value = sample < 0 ? sample * 0x8000 : sample * 0x7fff; - view.setInt16(i * 2, Math.round(value), true); - } - return out; -} - -function bytesToBase64(bytes: Uint8Array) { - let binary = ""; - const chunk = 0x8000; - for (let i = 0; i < bytes.length; i += chunk) { - binary += String.fromCharCode(...bytes.subarray(i, i + chunk)); - } - return btoa(binary); -} - -async function decodeAudioBlobToPcm16(blob: Blob) { - const AudioContextCtor = getAudioContextCtor(); - const context = new AudioContextCtor(); - try { - const arrayBuffer = await blob.arrayBuffer(); - const decoded = await context.decodeAudioData(arrayBuffer); - const mono = toMonoFloat32(decoded); - const targetSampleRate = 16000; - const resampled = resampleFloat32Linear(mono, decoded.sampleRate, targetSampleRate); - const pcm16 = floatToPcm16Bytes(resampled); - return { - audioBase64: bytesToBase64(pcm16), - sampleRate: targetSampleRate, - }; - } finally { - await context.close(); - } -} - -async function transcribeAudioBlob(blob: Blob) { - const payload = await decodeAudioBlobToPcm16(blob); - const result = await $fetch<{ text?: string }>("/api/pilot-transcribe", { - method: "POST", - body: payload, - }); - return String(result?.text ?? "").trim(); -} - async function transcribeRecordedPilotAudio(blob: Blob) { pilotMicError.value = null; pilotTranscribing.value = true; @@ -2242,10 +2152,7 @@ if (process.server) { onMounted(() => { pilotHeaderText.value = pilotHeaderPhrases[Math.floor(Math.random() * pilotHeaderPhrases.length)] ?? "Every step moves you forward"; - pilotMicSupported.value = - typeof navigator !== "undefined" && - typeof MediaRecorder !== "undefined" && - Boolean(navigator.mediaDevices?.getUserMedia); + pilotMicSupported.value = isVoiceCaptureSupported(); lifecycleClock = setInterval(() => { lifecycleNowMs.value = Date.now(); }, 15000); @@ -2295,7 +2202,6 @@ onMounted(() => { onBeforeUnmount(() => { stopCrmRealtime(); - stopCommRecording(true); if (pilotRecording.value) { stopPilotRecording("fill"); } @@ -3496,11 +3402,6 @@ 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"; @@ -3508,7 +3409,6 @@ let eventArchiveChunks: Blob[] = []; let eventArchiveTargetEventId = ""; watch(selectedCommThreadId, () => { - stopCommRecording(true); stopEventArchiveRecording(); destroyAllCommCallWaves(); callTranscriptOpen.value = {}; @@ -3517,6 +3417,8 @@ watch(selectedCommThreadId, () => { callTranscriptError.value = {}; commPinnedOnly.value = false; commDraft.value = ""; + commRecording.value = false; + commTranscribing.value = false; commMicError.value = ""; commComposerMode.value = "message"; commQuickMenuOpen.value = false; @@ -4245,12 +4147,7 @@ async function transcribeCallItem(item: CommItem) { if (!res.ok) throw new Error(`Audio fetch failed: ${res.status}`); return res.blob(); }); - const payload = await decodeAudioBlobToPcm16(audioBlob); - const result = await $fetch<{ text?: string }>("/api/pilot-transcribe", { - method: "POST", - body: payload, - }); - const text = String(result?.text ?? "").trim(); + const text = await transcribeAudioBlob(audioBlob); callTranscriptText.value[itemId] = text || "(empty transcript)"; await gqlFetch<{ updateCommunicationTranscript: { ok: boolean; id: string } }>(updateCommunicationTranscriptMutation, { id: itemId, @@ -4603,99 +4500,12 @@ async function sendCommMessage() { } } -async function startCommRecording() { - if (commRecording.value || commTranscribing.value) return; +function onCommDictationTranscript(text: string) { + const next = String(text ?? "").trim(); + if (!next) return; + const previous = String(commDraft.value ?? "").trim(); + commDraft.value = previous ? `${previous} ${next}` : next; 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() { - if (commTranscribing.value) return; - if (commRecording.value) { - stopCommRecording(); - return; - } - void startCommRecording(); } function handleCommComposerEnter(event: KeyboardEvent) { @@ -5574,17 +5384,19 @@ async function decideFeedCard(card: FeedCard, decision: "accepted" | "rejected") - + :disabled="commSending || commEventSaving" + :session-key="selectedCommThreadId" + idle-title="Voice input" + recording-title="Stop and insert transcript" + transcribing-title="Transcribing..." + @update:recording="commRecording = $event" + @update:transcribing="commTranscribing = $event" + @transcript="onCommDictationTranscript" + @error="commMicError = $event" + />