// Voice Assistant — LiveKit client const { Room, RoomEvent } = LivekitClient; // ── Config ────────────────────────────────────────────────────────────────── // LiveKit server runs on the same host. When served over HTTPS, use wss://. const LIVEKIT_URL = `${window.location.protocol === "https:" ? "wss" : "ws"}://${window.location.hostname}:7880`; const ROOM_NAME = "voice-room"; const AGENT_NAME = "voice-assistant"; // ── State ─────────────────────────────────────────────────────────────────── let room = null; let agentParticipant = null; // ── DOM ───────────────────────────────────────────────────────────────────── const startBtn = document.getElementById("startBtn"); const stopBtn = document.getElementById("stopBtn"); const statusEl = document.getElementById("status"); const messagesEl = document.getElementById("messages"); const voiceSelect = document.getElementById("voiceSelect"); // ── Voice switching ───────────────────────────────────────────────────────── voiceSelect.addEventListener("change", () => { const voice = voiceSelect.value; if (room && agentParticipant) { sendVoiceMessage(voice); addMessage("system", `Voice changed to ${voice}`); } else { localStorage.setItem("preferred_voice", voice); } }); // Restore saved voice preference const savedVoice = localStorage.getItem("preferred_voice"); if (savedVoice) { voiceSelect.value = savedVoice; } // ── Start ─────────────────────────────────────────────────────────────────── startBtn.addEventListener("click", async () => { try { statusEl.textContent = "Connecting..."; // Get a signed access token from the token endpoint const token = await fetchToken(ROOM_NAME); room = new Room({ adaptiveStream: true, dynacast: true, }); // Listen for agent participant joining room.on(RoomEvent.ParticipantConnected, (participant) => { if (participant.identity === AGENT_NAME || participant.kind === "agent") { agentParticipant = participant; statusEl.textContent = "Agent connected — speak now"; // Send current voice selection to the agent const voice = voiceSelect.value; sendVoiceMessage(voice); } }); // Listen for transcripts from the agent room.on(RoomEvent.DataReceived, (payload, participant) => { try { const msg = JSON.parse(payload.decodeToString()); if (msg.type === "transcript") { addMessage(msg.role || "agent", msg.text); } } catch (e) { // Ignore non-JSON data } }); room.on(RoomEvent.ConnectionQualityChanged, (participant, quality) => { if (participant === room.localParticipant) { statusEl.textContent = `Connected (${quality} quality)`; } }); room.on(RoomEvent.Disconnected, () => { statusEl.textContent = "Disconnected"; startBtn.disabled = false; stopBtn.disabled = true; agentParticipant = null; }); await room.connect(LIVEKIT_URL, { accessToken: token, autoSubscribe: true, }); statusEl.textContent = "Connected — speak now"; startBtn.disabled = true; stopBtn.disabled = false; } catch (err) { console.error("Connection failed:", err); statusEl.textContent = `Error: ${err.message}`; } }); // ── Stop ──────────────────────────────────────────────────────────────────── stopBtn.addEventListener("click", () => { if (room) { room.disconnect(); room = null; agentParticipant = null; } statusEl.textContent = "Disconnected"; startBtn.disabled = false; stopBtn.disabled = true; }); // ── Helper: fetch a signed access token from the token endpoint ──────────── async function fetchToken(roomName) { const resp = await fetch("/token", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ room: roomName }), }); if (!resp.ok) throw new Error(`Token endpoint returned ${resp.status}`); const data = await resp.json(); return data.token; } // ── Helper: send voice selection to the agent via data channel ───────────── function sendVoiceMessage(voice) { if (!room || !room.localParticipant) return; const payload = new TextEncoder().encode(JSON.stringify({ type: "set_voice", voice })); room.localParticipant.publishData(payload, { topic: "voice-control", reliable: true, }); } // ── Helper: add a message to the transcript ───────────────────────────────── function addMessage(role, text) { const div = document.createElement("div"); div.className = `message ${role}`; const label = role === "user" ? "You" : role === "agent" ? "Assistant" : "System"; div.innerHTML = `${label} ${text}`; messagesEl.appendChild(div); messagesEl.scrollTop = messagesEl.scrollHeight; }