// Voice Assistant — LiveKit client const { Room, RoomEvent } = LivekitClient; // ── Config ────────────────────────────────────────────────────────────────── // LiveKit is reached through the same origin as the page (nginx proxies // /livekit -> localhost:7880). Keeps a single HTTPS port and avoids mixed // content when the UI is served over HTTPS. const LIVEKIT_URL = `${window.location.protocol === "https:" ? "wss" : "ws"}://${window.location.host}/livekit`; 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; } // ── Agent detection ───────────────────────────────────────────────────────── // The agent may already be in the room before we join (it persists between // page loads), so scan remoteParticipants after connect AND watch for new // joins — ParticipantConnected does not fire for pre-existing participants. function isAgent(participant) { // kind is the numeric proto enum; ParticipantKind.AGENT === 4. // Prefer the SDK's own getter where available. if (participant.isAgent !== undefined && participant.isAgent !== null) { return participant.isAgent; } return participant.identity === AGENT_NAME || participant.kind === LivekitClient.ParticipantKind.AGENT; } function findAgent(rm) { if (!rm) return null; for (const p of rm.remoteParticipants.values()) { if (isAgent(p)) return p; } return null; } function markAgentFound(rm, participant) { agentParticipant = participant; statusEl.textContent = "Agent connected — speak now"; // Send current voice selection to the agent sendVoiceMessage(voiceSelect.value, rm); } function createRoom(token) { const newRoom = new Room({ adaptiveStream: true, dynacast: true, }); // Listen for the agent joining after us newRoom.on(RoomEvent.ParticipantConnected, (participant) => { if (isAgent(participant)) markAgentFound(newRoom, participant); }); // Listen for transcripts from the agent newRoom.on(RoomEvent.DataReceived, (payload) => { try { const msg = JSON.parse(payload.decodeToString()); if (msg.type === "transcript") { addMessage(msg.role || "agent", msg.text); } } catch (e) { // Ignore non-JSON data } }); // Guard with `=== room` so a stale (retry-discarded) connection cannot // clobber UI state of the active one. newRoom.on(RoomEvent.ConnectionQualityChanged, (participant, quality) => { if (participant === newRoom.localParticipant && newRoom === room) { statusEl.textContent = `Connected (${quality} quality)`; } }); newRoom.on(RoomEvent.Disconnected, () => { if (newRoom !== room) return; statusEl.textContent = "Disconnected"; startBtn.disabled = false; stopBtn.disabled = true; agentParticipant = null; }); return newRoom.connect(LIVEKIT_URL, token, { autoSubscribe: true, }).then(() => { // Pick up an agent that was already in the room before we joined const existing = findAgent(newRoom); if (existing) markAgentFound(newRoom, existing); return newRoom; }); } function waitForAgent(timeoutMs) { return new Promise((resolve) => { if (findAgent(room)) return resolve(true); const deadline = Date.now() + timeoutMs; const timer = setInterval(() => { const found = findAgent(room); if (found) { if (!agentParticipant) markAgentFound(room, found); clearInterval(timer); resolve(true); } else if (Date.now() > deadline) { clearInterval(timer); resolve(false); } }, 250); }); } startBtn.addEventListener("click", async () => { try { statusEl.textContent = "Connecting..."; // Get a signed access token from the token endpoint const token = await fetchToken(ROOM_NAME); room = await createRoom(token); // The agent is dispatched when we join. If it misses the dispatch // (e.g. the server just started and the worker wasn't ready yet), // reconnect once to trigger a fresh dispatch. statusEl.textContent = "Waiting for assistant..."; if (!(await waitForAgent(10000))) { statusEl.textContent = "Assistant not ready, retrying..."; const stale = room; room = null; // detach first so its Disconnected handler agentParticipant = null; // cannot clobber the UI await stale.disconnect(); room = await createRoom(token); if (!(await waitForAgent(15000))) { throw new Error("Assistant did not join — reload the page and try again"); } } statusEl.textContent = "Requesting microphone..."; await room.localParticipant.setMicrophoneEnabled(true); statusEl.textContent = "Connected — speak now"; startBtn.disabled = true; stopBtn.disabled = false; // Live mic level readout: if this stays at 0 while you talk, the // phone is not capturing audio (iOS quirk), and the problem is on // the device side, not the server. startMicMeter(); } 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, targetRoom) { const r = targetRoom || room; if (!r || !r.localParticipant) return; const payload = new TextEncoder().encode(JSON.stringify({ type: "set_voice", voice })); r.localParticipant.publishData(payload, { topic: "voice-control", reliable: true, }); } // ── Helper: live mic level readout (diagnostic) ───────────────────────────── function startMicMeter() { const el = document.getElementById("micLevel"); if (!el) return; let audioCtx = null; let analyser = null; try { const pub = room.localParticipant.getTrackPublication(LivekitClient.Track.Source.Microphone); const mst = pub && pub.track && pub.track.mediaStreamTrack; if (mst) { audioCtx = new AudioContext(); analyser = audioCtx.createAnalyser(); analyser.fftSize = 512; audioCtx.createMediaStreamSource(new MediaStream([mst])).connect(analyser); } } catch (e) { /* fall back to SDK audioLevel below */ } const buf = analyser ? new Float32Array(analyser.fftSize) : null; window.micMeterTimer = setInterval(() => { if (!room || !room.localParticipant) { clearInterval(window.micMeterTimer); return; } let level = 0; if (analyser) { analyser.getFloatTimeDomainData(buf); for (let i = 0; i < buf.length; i++) level = Math.max(level, Math.abs(buf[i])); } else { level = room.localParticipant.audioLevel; } el.textContent = `mic: ${Math.round(level * 100)}%`; el.style.color = level > 0.02 ? "#4ade80" : "#94a3b8"; }, 300); } // ── 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; }