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 @@ + + + + + + Voice Assistant + + + +
+
+

Voice Assistant

+

Azure Speech + Gemma LLM, real-time conversation

+
+ +
+ + +
+ +
+ + +
+ +
Disconnected
+ +
+
Conversation
+
+
+
+ + + + + diff --git a/web/style.css b/web/style.css new file mode 100644 index 0000000..733f319 --- /dev/null +++ b/web/style.css @@ -0,0 +1,173 @@ +* { margin: 0; padding: 0; box-sizing: border-box; } + +body { + font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif; + background: #0f1117; + color: #e4e4e7; + min-height: 100vh; + display: flex; + justify-content: center; + align-items: flex-start; + padding: 2rem 1rem; +} + +.container { + width: 100%; + max-width: 680px; +} + +header { + text-align: center; + margin-bottom: 2rem; +} + +h1 { + font-size: 2rem; + font-weight: 700; + background: linear-gradient(135deg, #60a5fa, #a78bfa); + -webkit-background-clip: text; + -webkit-text-fill-color: transparent; +} + +.subtitle { + color: #71717a; + margin-top: 0.5rem; + font-size: 0.9rem; +} + +.controls { + display: flex; + gap: 1rem; + justify-content: center; + margin-bottom: 1.5rem; +} + +.btn { + padding: 0.75rem 2rem; + border: none; + border-radius: 8px; + font-size: 1rem; + font-weight: 600; + cursor: pointer; + transition: all 0.2s; +} + +.btn:disabled { + opacity: 0.4; + cursor: not-allowed; +} + +.btn-primary { + background: #3b82f6; + color: white; +} + +.btn-primary:hover:not(:disabled) { + background: #2563eb; + transform: translateY(-1px); +} + +.btn-danger { + background: #ef4444; + color: white; +} + +.btn-danger:hover:not(:disabled) { + background: #dc2626; +} + +.voice-panel { + display: flex; + align-items: center; + gap: 0.75rem; + justify-content: center; + margin-bottom: 1.5rem; + padding: 1rem; + background: #1c1e26; + border-radius: 8px; +} + +.voice-panel label { + font-weight: 600; + color: #a1a1aa; +} + +.voice-panel select { + padding: 0.5rem 1rem; + background: #27272a; + color: #e4e4e7; + border: 1px solid #3f3f46; + border-radius: 6px; + font-size: 0.9rem; + cursor: pointer; +} + +.voice-panel select:focus { + outline: none; + border-color: #3b82f6; +} + +.status { + text-align: center; + padding: 0.75rem; + background: #1c1e26; + border-radius: 8px; + margin-bottom: 1.5rem; + font-size: 0.9rem; + color: #a1a1aa; +} + +.transcript { + background: #1c1e26; + border-radius: 8px; + padding: 1rem; + max-height: 50vh; + overflow-y: auto; +} + +.transcript-header { + font-weight: 700; + color: #a1a1aa; + margin-bottom: 1rem; + font-size: 0.85rem; + text-transform: uppercase; + letter-spacing: 0.05em; +} + +.message { + padding: 0.6rem 0.8rem; + margin-bottom: 0.5rem; + border-radius: 6px; + font-size: 0.95rem; + line-height: 1.5; +} + +.message.user { + background: #1e3a5f; + border-left: 3px solid #3b82f6; +} + +.message.agent { + background: #2d1f4e; + border-left: 3px solid #a78bfa; +} + +.message.system { + background: #27272a; + color: #71717a; + font-style: italic; + font-size: 0.85rem; +} + +.role { + font-weight: 700; + margin-right: 0.5rem; +} + +.message.user .role { color: #60a5fa; } +.message.agent .role { color: #a78bfa; } + +/* Scrollbar */ +.transcript::-webkit-scrollbar { width: 6px; } +.transcript::-webkit-scrollbar-track { background: transparent; } +.transcript::-webkit-scrollbar-thumb { background: #3f3f46; border-radius: 3px; }