refactor: decompose CrmWorkspaceApp.vue into 15 composables
Split the 6000+ line monolithic component into modular composables: - crm-types.ts: shared types and utility functions - useAuth, useContacts, useContactInboxes, useCalendar, useDeals, useDocuments, useFeed, useTimeline, usePilotChat, useCallAudio, usePins, useChangeReview, useCrmRealtime, useWorkspaceRouting CrmWorkspaceApp.vue is now a thin orchestrator (~2500 lines) that wires composables together with glue code, keeping template and styles intact. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
436
frontend/app/composables/useCallAudio.ts
Normal file
436
frontend/app/composables/useCallAudio.ts
Normal file
@@ -0,0 +1,436 @@
|
||||
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/crm-types";
|
||||
|
||||
export function useCallAudio() {
|
||||
// ---------------------------------------------------------------------------
|
||||
// State
|
||||
// ---------------------------------------------------------------------------
|
||||
const commCallWaveHosts = new Map<string, HTMLDivElement>();
|
||||
const commCallWaveSurfers = new Map<string, any>();
|
||||
const commCallPlayableById = ref<Record<string, boolean>>({});
|
||||
const commCallPlayingById = ref<Record<string, boolean>>({});
|
||||
const callTranscriptOpen = ref<Record<string, boolean>>({});
|
||||
const callTranscriptLoading = ref<Record<string, boolean>>({});
|
||||
const callTranscriptText = ref<Record<string, string>>({});
|
||||
const callTranscriptError = ref<Record<string, string>>({});
|
||||
|
||||
// Event archive recording state
|
||||
const eventArchiveRecordingById = ref<Record<string, boolean>>({});
|
||||
const eventArchiveTranscribingById = ref<Record<string, boolean>>({});
|
||||
const eventArchiveMicErrorById = ref<Record<string, string>>({});
|
||||
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<string>, 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<string, string> };
|
||||
},
|
||||
) {
|
||||
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<string, string> };
|
||||
},
|
||||
) {
|
||||
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,
|
||||
setCommCallWaveHost,
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user