Files
clientsflow/frontend/app/composables/useCallAudio.ts
Ruslan Bakiev d892d0c604 refactor: distribute types from crm-types.ts to owning composables
Each composable now owns its types and exports them. Other composables
import types from the owning composable. Deleted centralized crm-types.ts.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-24 15:21:30 +07:00

438 lines
15 KiB
TypeScript

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<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,
};
}