/* ======================================================================== voice-client.jsx, useWonderfulCall hook Wraps WonderfulVoice.Client (telephony WS, mic, playback) and a second WS for the live transcript stream. ======================================================================== */ function fmtMMSS(seconds) { const s = Math.max(0, Math.floor(seconds || 0)); const m = Math.floor(s / 60); const r = s % 60; return `${String(m).padStart(2, "0")}:${String(r).padStart(2, "0")}`; } function mapSpeaker(raw) { const s = String(raw || "").toLowerCase(); if (s === "agent" || s === "assistant" || s === "ai" || s === "bot") return "hala"; if (s === "user" || s === "human" || s === "customer" || s === "human_agent") return "sarah"; return "system"; } function summarizeServerEvent(msg) { const name = msg?.event || msg?.data?.event || "event"; const d = msg?.data || {}; const parts = []; if (d.instruction) parts.push(d.instruction); else { for (const [k, v] of Object.entries(d)) { if (k === "event" || k === "type") continue; if (typeof v === "string" || typeof v === "number" || typeof v === "boolean") { parts.push(`${k}: ${v}`); } } } return `${name}${parts.length ? " · " + parts.join(" · ") : ""}`; } // Server events we never want to surface in the UI. Matches by event name // regardless of whether the event arrives as a top-level server.event message // or embedded as JSON text inside a system transcript row. const HIDDEN_SERVER_EVENTS = new Set(["gender_classified"]); function isHiddenEventPayload(value) { if (!value || typeof value !== "object") return false; const name = value.event || value?.data?.event; return !!(name && HIDDEN_SERVER_EVENTS.has(name)); } function textLooksHidden(text) { if (typeof text !== "string") return false; const trimmed = text.trim(); if (!trimmed || (trimmed[0] !== "{" && trimmed[0] !== "[")) return false; try { return isHiddenEventPayload(JSON.parse(trimmed)); } catch { return false; } } function extractEntry(msg) { // Server events render as system rows, deduped by data.id when present. if (msg?.type === "server.event" || msg?.event) { if (isHiddenEventPayload(msg)) return null; return { id: msg?.data?.id || null, who: "system", text: summarizeServerEvent(msg), }; } const data = msg?.data || {}; const speakerRaw = msg?.speaker || data?.speaker || msg?.role || data?.role; if (!speakerRaw) return null; const text = data?.text || data?.transcript || msg?.text || msg?.transcript || (Array.isArray(data?.content) ? data.content.map((c) => c.text || c.transcript).filter(Boolean).join(" ") : ""); if (!text || typeof text !== "string") return null; // Some backends pipe server events through the transcript stream as a // system row whose text is the stringified event. Drop those too. if (mapSpeaker(speakerRaw) === "system" && textLooksHidden(text)) return null; return { id: data?.id || null, who: mapSpeaker(speakerRaw), text: text.trim(), tool: data?.tool_details || null, }; } function useWonderfulCall({ active }) { const [status, setStatus] = React.useState("idle"); const [elapsed, setElapsed] = React.useState(0); const [turns, setTurns] = React.useState([]); const [error, setError] = React.useState(null); const [muted, setMuted] = React.useState(false); const clientRef = React.useRef(null); const transcriptWsRef = React.useRef(null); const elapsedRef = React.useRef(0); const tickRef = React.useRef(null); const cleanup = React.useCallback(() => { if (transcriptWsRef.current) { try { transcriptWsRef.current.close(); } catch {} transcriptWsRef.current = null; } if (clientRef.current) { try { clientRef.current.disconnect(); } catch {} try { clientRef.current.destroy(); } catch {} clientRef.current = null; } if (tickRef.current) { clearInterval(tickRef.current); tickRef.current = null; } }, []); const openTranscriptWs = React.useCallback((communicationId) => { const cfg = window.WONDERFUL_CONFIG; if (!cfg || !communicationId) return; const url = `wss://${cfg.host}/api/v1/communications/${communicationId}/live`; let ws; try { ws = new WebSocket(url, ["apikey", cfg.apiKey]); } catch (e) { console.warn("[voice] transcript ws ctor failed", e); return; } transcriptWsRef.current = ws; ws.onopen = () => console.log("[voice] transcript ws open", communicationId); ws.onerror = (e) => console.warn("[voice] transcript ws error", e); ws.onclose = (e) => console.log("[voice] transcript ws close", e.code, e.reason); ws.onmessage = (ev) => { let msg; try { msg = typeof ev.data === "string" ? JSON.parse(ev.data) : ev.data; } catch { console.log("[voice] transcript non-json", ev.data); return; } console.log("[voice] transcript msg", msg); const entry = extractEntry(msg); if (!entry) return; setTurns((prev) => { if (entry.id) { const idx = prev.findIndex((t) => t.id === entry.id); if (idx >= 0) { const next = prev.slice(); next[idx] = { ...next[idx], text: entry.text, who: entry.who, tool: entry.tool || next[idx].tool }; return next; } } const last = prev[prev.length - 1]; if (last && last.who === entry.who && last.text === entry.text && !entry.tool) return prev; return [ ...prev, { id: entry.id, who: entry.who, t: fmtMMSS(elapsedRef.current), text: entry.text, tool: entry.tool || null }, ]; }); }; }, []); const start = React.useCallback(async () => { const cfg = window.WONDERFUL_CONFIG; if (!cfg) { setError("Missing WONDERFUL_CONFIG"); return; } if (!window.WonderfulVoice?.Client) { setError("WonderfulVoice client not loaded"); return; } if (clientRef.current) return; setError(null); setTurns([]); elapsedRef.current = 0; setElapsed(0); let client; try { client = new window.WonderfulVoice.Client({ host: cfg.host, agentId: cfg.agentId, apiKey: cfg.apiKey, }); } catch (e) { setError(e?.message || "Failed to create client"); return; } clientRef.current = client; client.on("statusChange", (s) => { setStatus(s); if (s === "connected" && !tickRef.current) { const t0 = Date.now(); tickRef.current = setInterval(() => { const v = Math.floor((Date.now() - t0) / 1000); elapsedRef.current = v; setElapsed(v); }, 250); } if (s === "disconnected" || s === "idle") { if (tickRef.current) { clearInterval(tickRef.current); tickRef.current = null; } } }); client.on("communicationId", (id) => { console.log("[voice] communicationId", id); openTranscriptWs(id); }); client.on("error", (m) => setError(m)); try { await client.connect(); } catch (e) { setError(e?.message || "Failed to connect"); cleanup(); } }, [openTranscriptWs, cleanup]); const end = React.useCallback(() => { cleanup(); setStatus("disconnected"); setMuted(false); }, [cleanup]); const toggleMute = React.useCallback(() => { const c = clientRef.current; if (!c) return; if (c.isMuted) { c.unmute(); setMuted(false); } else { c.mute(); setMuted(true); } }, []); // Auto-end if user navigates away from the scene mid-call. React.useEffect(() => { if (!active && clientRef.current) { cleanup(); setStatus("idle"); setElapsed(0); elapsedRef.current = 0; } }, [active, cleanup]); React.useEffect(() => () => cleanup(), [cleanup]); return { status, elapsed, turns, start, end, error, muted, toggleMute }; } window.useWonderfulCall = useWonderfulCall;