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>
438 lines
15 KiB
TypeScript
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,
|
|
};
|
|
}
|