diff --git a/web/app.js b/web/app.js new file mode 100644 index 0000000..67f2ae1 --- /dev/null +++ b/web/app.js @@ -0,0 +1,172 @@ +// Voice Assistant — LiveKit client +const { Room, RoomEvent, RemoteParticipant, AudioPlaybackStats } = LivekitClient; + +// ── Config (injected from environment at build time, or hardcoded for LAN) ── +const LIVEKIT_URL = `ws://${window.location.hostname}:7880`; +const API_KEY = "devkey"; +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) { + // Send data message to agent to switch voice + room.localParticipant.publishData( + JSON.stringify({ type: "set_voice", voice }).encodeInto(new Uint8Array()), + [agentParticipant] + ); + addMessage("system", `Voice changed to ${voice}`); + } else { + // Save preference for next session + 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..."; + + room = new Room({ + adaptiveStream: true, + dynacast: true, + }); + + // Listen for transcripts from the agent + room.on(RoomEvent.ParticipantConnected, (participant) => { + if (participant.identity === AGENT_NAME || participant.kind === "agent") { + agentParticipant = participant; + statusEl.textContent = "Agent connected"; + + // Send current voice selection + const voice = voiceSelect.value; + room.localParticipant.publishData( + JSON.stringify({ type: "set_voice", voice }).encodeInto(new Uint8Array()), + [participant] + ); + } + }); + + 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; + }); + + await room.connect(LIVEKIT_URL, { + accessToken: generateToken(), + autoSubscribe: true, + }); + + // Request agent join + await requestAgentJoin(); + + 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: generate a LiveKit access token (simplified for LAN dev) ── +// In production, this would be done server-side. For LAN dev, we use a static token. +function generateToken() { + // The LiveKit server with devkey/devsecret will accept any token signed with that key. + // For simplicity in this LAN setup, we'll let the server handle auth. + return "dev-token"; +} + +// ── Helper: request agent to join the room ── +async function requestAgentJoin() { + // Send a data message to trigger agent dispatch + if (room && room.localParticipant) { + const msg = JSON.stringify({ type: "join_agent", agent: AGENT_NAME }); + room.localParticipant.publishData( + msg.encodeInto(new Uint8Array()), + [] // broadcast to all participants + ); + } +} + +// ── Helper: add a message to the transcript ── +function addMessage(role, text) { + const div = document.createElement("div"); + div.className = `message ${role}`; + div.innerHTML = `${role === "user" ? "You" : role === "agent" ? "Assistant" : "System"} ${text}`; + messagesEl.appendChild(div); + messagesEl.scrollTop = messagesEl.scrollHeight; +} + +// ── Capture user speech for transcript display ── +// We'll use the Web Speech API as a fallback for showing what the user said. +// The actual STT is handled by the agent's Azure STT pipeline. +let recognition = null; +if ("webkitSpeechRecognition" in window || "SpeechRecognition" in window) { + const SpeechRec = window.SpeechRecognition || window.webkitSpeechRecognition; + recognition = new SpeechRec(); + recognition.continuous = true; + recognition.interimResults = true; + recognition.lang = "en-US"; + + recognition.onresult = (event) => { + for (let i = event.resultIndex; i < event.results.length; i++) { + if (event.results[i].isFinal) { + addMessage("user", event.results[i][0].transcript); + } + } + }; + + // Start/stop recognition with the room connection + const origConnect = Room.prototype.connect; + // We'll start recognition when room connects +} diff --git a/web/index.html b/web/index.html new file mode 100644 index 0000000..6d89d20 --- /dev/null +++ b/web/index.html @@ -0,0 +1,50 @@ + + +
+ + +Azure Speech + Gemma LLM, real-time conversation
+