import { ref, nextTick } from "vue"; import { UpdateCommunicationTranscriptMutationDocument, CommunicationsQueryDocument, } from "~~/graphql/generated"; import { useMutation } from "@vue/apollo-composable"; import { isVoiceCaptureSupported, transcribeAudioBlob } from "~/composables/useVoiceTranscription"; import type { CommItem } from "~/composables/useContacts"; export function useCallAudio() { // --------------------------------------------------------------------------- // State // --------------------------------------------------------------------------- const commCallWaveHosts = new Map(); const commCallWaveSurfers = new Map(); const commCallPlayableById = ref>({}); const commCallPlayingById = ref>({}); const callTranscriptOpen = ref>({}); const callTranscriptLoading = ref>({}); const callTranscriptText = ref>({}); const callTranscriptError = ref>({}); // Event archive recording state const eventArchiveRecordingById = ref>({}); const eventArchiveTranscribingById = ref>({}); const eventArchiveMicErrorById = ref>({}); let eventArchiveMediaRecorder: MediaRecorder | null = null; let eventArchiveRecorderStream: MediaStream | null = null; let eventArchiveRecorderMimeType = "audio/webm"; let eventArchiveChunks: Blob[] = []; let eventArchiveTargetEventId = ""; // WaveSurfer module cache let waveSurferModulesPromise: Promise<{ WaveSurfer: any; RecordPlugin: any }> | null = null; // --------------------------------------------------------------------------- // Apollo Mutation // --------------------------------------------------------------------------- const { mutate: doUpdateCommunicationTranscript } = useMutation(UpdateCommunicationTranscriptMutationDocument, { refetchQueries: [{ query: CommunicationsQueryDocument }], }); // --------------------------------------------------------------------------- // WaveSurfer lazy loading // --------------------------------------------------------------------------- async function loadWaveSurferModules() { if (!waveSurferModulesPromise) { waveSurferModulesPromise = Promise.all([ import("wavesurfer.js"), import("wavesurfer.js/dist/plugins/record.esm.js"), ]).then(([ws, rec]) => ({ WaveSurfer: ws.default, RecordPlugin: rec.default, })); } return waveSurferModulesPromise; } // --------------------------------------------------------------------------- // Call wave helpers // --------------------------------------------------------------------------- function setCommCallPlaying(itemId: string, value: boolean) { commCallPlayingById.value = { ...commCallPlayingById.value, [itemId]: value, }; } function isCommCallPlaying(itemId: string) { return Boolean(commCallPlayingById.value[itemId]); } function getCallAudioUrl(item?: CommItem) { return String(item?.audioUrl ?? "").trim(); } function isCommCallPlayable(item: CommItem) { const known = commCallPlayableById.value[item.id]; if (typeof known === "boolean") return known; return Boolean(getCallAudioUrl(item)); } function pauseOtherCommCallWaves(currentItemId: string) { for (const [itemId, ws] of commCallWaveSurfers.entries()) { if (itemId === currentItemId) continue; ws.pause?.(); setCommCallPlaying(itemId, false); } } function parseDurationToSeconds(raw?: string) { if (!raw) return 0; const text = raw.trim().toLowerCase(); if (!text) return 0; const ms = text.match(/(\d+)\s*m(?:in)?\s*(\d+)?\s*s?/); if (ms) { const m = Number(ms[1] ?? 0); const s = Number(ms[2] ?? 0); return m * 60 + s; } const colon = text.match(/(\d+):(\d+)/); if (colon) { return Number(colon[1] ?? 0) * 60 + Number(colon[2] ?? 0); } const sec = text.match(/(\d+)\s*s/); if (sec) return Number(sec[1] ?? 0); return 0; } function buildCallWavePeaks(item: CommItem, size = 320) { const stored = Array.isArray(item.waveform) ? item.waveform.map((value) => Number(value)).filter((value) => Number.isFinite(value) && value > 0) : []; if (stored.length) { const sampled = new Float32Array(size); for (let i = 0; i < size; i += 1) { const t = size <= 1 ? 0 : i / (size - 1); const idx = Math.min(stored.length - 1, Math.round(t * (stored.length - 1))); sampled[i] = Math.max(0.05, Math.min(1, stored[idx] ?? 0.05)); } return sampled; } const source = `${item.text} ${(item.transcript ?? []).join(" ")}`.trim() || item.contact; let seed = 0; for (let i = 0; i < source.length; i += 1) { seed = (seed * 31 + source.charCodeAt(i)) >>> 0; } const rand = () => { seed = (seed * 1664525 + 1013904223) >>> 0; return seed / 0xffffffff; }; const out = new Float32Array(size); let smooth = 0; for (let i = 0; i < size; i += 1) { const t = i / Math.max(1, size - 1); const burst = Math.max(0, Math.sin(t * Math.PI * (3 + (source.length % 7)))); const noise = (rand() * 2 - 1) * 0.65; smooth = smooth * 0.7 + noise * 0.3; out[i] = Math.max(0.05, Math.min(1, 0.12 + Math.abs(smooth) * 0.48 + burst * 0.4)); } return out; } function destroyCommCallWave(itemId: string) { const ws = commCallWaveSurfers.get(itemId); if (!ws) return; ws.destroy(); commCallWaveSurfers.delete(itemId); const nextPlayable = { ...commCallPlayableById.value }; delete nextPlayable[itemId]; commCallPlayableById.value = nextPlayable; const nextPlaying = { ...commCallPlayingById.value }; delete nextPlaying[itemId]; commCallPlayingById.value = nextPlaying; } function destroyAllCommCallWaves() { for (const itemId of commCallWaveSurfers.keys()) { destroyCommCallWave(itemId); } commCallWaveHosts.clear(); } async function ensureCommCallWave(itemId: string, callItem?: CommItem) { const host = commCallWaveHosts.get(itemId); if (!host) return; if (commCallWaveSurfers.has(itemId)) return; if (!callItem) return; const { WaveSurfer } = await loadWaveSurferModules(); const durationSeconds = parseDurationToSeconds(callItem.duration) || Math.max(8, Math.min(120, Math.round(((callItem.transcript ?? []).join(" ").length || callItem.text.length) / 10))); const peaks = buildCallWavePeaks(callItem, 360); const audioUrl = getCallAudioUrl(callItem); const ws = WaveSurfer.create({ container: host, height: 30, waveColor: "rgba(180, 206, 255, 0.88)", progressColor: "rgba(118, 157, 248, 0.95)", cursorWidth: 0, interact: Boolean(audioUrl), normalize: true, barWidth: 0, }); ws.on("play", () => setCommCallPlaying(itemId, true)); ws.on("pause", () => setCommCallPlaying(itemId, false)); ws.on("finish", () => setCommCallPlaying(itemId, false)); let playable = false; if (audioUrl) { try { await ws.load(audioUrl, [peaks], durationSeconds); playable = true; } catch { await ws.load("", [peaks], durationSeconds); playable = false; } } else { await ws.load("", [peaks], durationSeconds); } commCallPlayableById.value = { ...commCallPlayableById.value, [itemId]: playable, }; commCallWaveSurfers.set(itemId, ws); } async function syncCommCallWaves(activeCallIds: Set, getCallItem: (id: string) => CommItem | undefined) { await nextTick(); for (const id of commCallWaveSurfers.keys()) { if (!activeCallIds.has(id) || !commCallWaveHosts.has(id)) { destroyCommCallWave(id); } } for (const id of activeCallIds) { if (commCallWaveHosts.has(id)) { await ensureCommCallWave(id, getCallItem(id)); } } } function setCommCallWaveHost(itemId: string, element: Element | null) { if (!(element instanceof HTMLDivElement)) { commCallWaveHosts.delete(itemId); destroyCommCallWave(itemId); return; } commCallWaveHosts.set(itemId, element); } // --------------------------------------------------------------------------- // Call playback toggle // --------------------------------------------------------------------------- async function toggleCommCallPlayback(item: CommItem) { if (!isCommCallPlayable(item)) return; const itemId = item.id; await ensureCommCallWave(itemId, item); const ws = commCallWaveSurfers.get(itemId); if (!ws) return; if (isCommCallPlaying(itemId)) { ws.pause?.(); return; } pauseOtherCommCallWaves(itemId); await ws.play?.(); } // --------------------------------------------------------------------------- // Call transcription // --------------------------------------------------------------------------- async function transcribeCallItem(item: CommItem) { const itemId = item.id; if (callTranscriptLoading.value[itemId]) return; if (callTranscriptText.value[itemId]) return; if (Array.isArray(item.transcript) && item.transcript.length) { const persisted = item.transcript.map((line) => String(line ?? "").trim()).filter(Boolean).join("\n"); if (persisted) { callTranscriptText.value[itemId] = persisted; return; } } const audioUrl = getCallAudioUrl(item); if (!audioUrl) { callTranscriptError.value[itemId] = "Audio source is missing"; return; } callTranscriptLoading.value[itemId] = true; callTranscriptError.value[itemId] = ""; try { const audioBlob = await fetch(audioUrl).then((res) => { if (!res.ok) throw new Error(`Audio fetch failed: ${res.status}`); return res.blob(); }); const text = await transcribeAudioBlob(audioBlob); callTranscriptText.value[itemId] = text || "(empty transcript)"; await doUpdateCommunicationTranscript({ id: itemId, transcript: text ? [text] : [] }); } catch (error: any) { callTranscriptError.value[itemId] = String(error?.message ?? error ?? "Transcription failed"); } finally { callTranscriptLoading.value[itemId] = false; } } function toggleCallTranscript(item: CommItem) { const itemId = item.id; const next = !callTranscriptOpen.value[itemId]; callTranscriptOpen.value[itemId] = next; if (next) { void transcribeCallItem(item); } } function isCallTranscriptOpen(itemId: string) { return Boolean(callTranscriptOpen.value[itemId]); } // --------------------------------------------------------------------------- // Event archive recording // --------------------------------------------------------------------------- function isEventArchiveRecording(eventId: string) { return Boolean(eventArchiveRecordingById.value[eventId]); } function isEventArchiveTranscribing(eventId: string) { return Boolean(eventArchiveTranscribingById.value[eventId]); } async function startEventArchiveRecording( eventId: string, opts: { pilotMicSupported: { value: boolean }; eventCloseDraft: { value: Record }; }, ) { if (eventArchiveMediaRecorder || isEventArchiveTranscribing(eventId)) return; eventArchiveMicErrorById.value = { ...eventArchiveMicErrorById.value, [eventId]: "" }; 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); eventArchiveRecorderStream = stream; eventArchiveRecorderMimeType = recorder.mimeType || "audio/webm"; eventArchiveMediaRecorder = recorder; eventArchiveChunks = []; eventArchiveTargetEventId = eventId; eventArchiveRecordingById.value = { ...eventArchiveRecordingById.value, [eventId]: true }; recorder.ondataavailable = (event: BlobEvent) => { if (event.data?.size) eventArchiveChunks.push(event.data); }; recorder.onstop = async () => { const targetId = eventArchiveTargetEventId; eventArchiveRecordingById.value = { ...eventArchiveRecordingById.value, [targetId]: false }; eventArchiveMediaRecorder = null; eventArchiveTargetEventId = ""; if (eventArchiveRecorderStream) { eventArchiveRecorderStream.getTracks().forEach((track) => track.stop()); eventArchiveRecorderStream = null; } const audioBlob = new Blob(eventArchiveChunks, { type: eventArchiveRecorderMimeType }); eventArchiveChunks = []; if (!targetId || audioBlob.size === 0) return; eventArchiveTranscribingById.value = { ...eventArchiveTranscribingById.value, [targetId]: true }; try { const text = await transcribeAudioBlob(audioBlob); if (!text) { eventArchiveMicErrorById.value = { ...eventArchiveMicErrorById.value, [targetId]: "Could not recognize speech" }; return; } const previous = String(opts.eventCloseDraft.value[targetId] ?? "").trim(); const merged = previous ? `${previous} ${text}` : text; opts.eventCloseDraft.value = { ...opts.eventCloseDraft.value, [targetId]: merged }; } catch (error: any) { eventArchiveMicErrorById.value = { ...eventArchiveMicErrorById.value, [targetId]: String(error?.data?.message ?? error?.message ?? "Voice transcription failed"), }; } finally { eventArchiveTranscribingById.value = { ...eventArchiveTranscribingById.value, [targetId]: false }; } }; recorder.start(); } catch { eventArchiveMicErrorById.value = { ...eventArchiveMicErrorById.value, [eventId]: "No microphone access" }; } } function stopEventArchiveRecording() { if (!eventArchiveMediaRecorder || eventArchiveMediaRecorder.state === "inactive") return; eventArchiveMediaRecorder.stop(); } function toggleEventArchiveRecording( eventId: string, opts: { pilotMicSupported: { value: boolean }; eventCloseDraft: { value: Record }; }, ) { if (!opts.pilotMicSupported.value) { eventArchiveMicErrorById.value = { ...eventArchiveMicErrorById.value, [eventId]: "Recording is not supported in this browser" }; return; } if (isEventArchiveRecording(eventId)) { stopEventArchiveRecording(); return; } void startEventArchiveRecording(eventId, opts); } // --------------------------------------------------------------------------- // Public API // --------------------------------------------------------------------------- return { commCallWaveHosts, commCallPlayableById, commCallPlayingById, callTranscriptOpen, callTranscriptLoading, callTranscriptText, callTranscriptError, ensureCommCallWave, destroyCommCallWave, destroyAllCommCallWaves, toggleCommCallPlayback, syncCommCallWaves, transcribeCallItem, toggleCallTranscript, isCallTranscriptOpen, eventArchiveRecordingById, eventArchiveTranscribingById, eventArchiveMicErrorById, startEventArchiveRecording, stopEventArchiveRecording, toggleEventArchiveRecording, isCommCallPlayable, isCommCallPlaying, setCommCallWaveHost, }; }