Spaces:
Running
Running
| <html lang="en"> | |
| <head> | |
| <meta charset="UTF-8" /> | |
| <title>Vidya β Interview Session</title> | |
| <style> | |
| :root { | |
| --bg: #0a0e1a; | |
| --panel: #131826; | |
| --panel-2: #1a2030; | |
| --border: #232a3d; | |
| --text: #e6e9f2; | |
| --muted: #8a93a8; | |
| --accent: #6366f1; | |
| --accent-2: #8b5cf6; | |
| --success: #10b981; | |
| --warn: #f59e0b; | |
| --error: #ef4444; | |
| } | |
| * { box-sizing: border-box; } | |
| body { | |
| margin: 0; | |
| font-family: -apple-system, BlinkMacSystemFont, "Inter", sans-serif; | |
| background: var(--bg); | |
| color: var(--text); | |
| min-height: 100vh; | |
| } | |
| .container { | |
| max-width: 1100px; | |
| margin: 0 auto; | |
| padding: 24px; | |
| } | |
| header { | |
| display: flex; | |
| justify-content: space-between; | |
| align-items: center; | |
| padding: 16px 20px; | |
| background: var(--panel); | |
| border: 1px solid var(--border); | |
| border-radius: 12px; | |
| margin-bottom: 20px; | |
| } | |
| header h1 { margin: 0; font-size: 18px; letter-spacing: 0.5px; } | |
| .status-pill { | |
| display: inline-flex; | |
| align-items: center; | |
| gap: 8px; | |
| padding: 6px 12px; | |
| border-radius: 999px; | |
| font-size: 12px; | |
| background: var(--panel-2); | |
| border: 1px solid var(--border); | |
| } | |
| .dot { | |
| width: 8px; height: 8px; border-radius: 50%; | |
| background: var(--muted); | |
| } | |
| .dot.live { background: var(--success); box-shadow: 0 0 8px var(--success); } | |
| .dot.err { background: var(--error); } | |
| .config { | |
| display: grid; | |
| grid-template-columns: 1fr 1fr auto; | |
| gap: 12px; | |
| padding: 16px; | |
| background: var(--panel); | |
| border: 1px solid var(--border); | |
| border-radius: 12px; | |
| margin-bottom: 20px; | |
| } | |
| .config label { | |
| display: block; font-size: 11px; color: var(--muted); | |
| margin-bottom: 4px; text-transform: uppercase; letter-spacing: 0.5px; | |
| } | |
| .config input { | |
| width: 100%; padding: 10px 12px; | |
| background: var(--panel-2); color: var(--text); | |
| border: 1px solid var(--border); border-radius: 8px; | |
| font-size: 14px; font-family: inherit; | |
| } | |
| .btn { | |
| padding: 10px 20px; | |
| background: linear-gradient(135deg, var(--accent), var(--accent-2)); | |
| color: white; border: none; border-radius: 8px; | |
| font-weight: 600; cursor: pointer; font-size: 14px; | |
| transition: transform 0.1s, opacity 0.2s; | |
| } | |
| .btn:hover { transform: translateY(-1px); } | |
| .btn:disabled { opacity: 0.4; cursor: not-allowed; transform: none; } | |
| .btn.danger { background: var(--error); } | |
| .main { | |
| display: grid; | |
| grid-template-columns: 1fr 380px; | |
| gap: 20px; | |
| } | |
| @media (max-width: 900px) { | |
| .main { grid-template-columns: 1fr; } | |
| } | |
| .panel { | |
| background: var(--panel); | |
| border: 1px solid var(--border); | |
| border-radius: 12px; | |
| padding: 20px; | |
| } | |
| .panel h2 { | |
| margin: 0 0 14px 0; | |
| font-size: 13px; | |
| color: var(--muted); | |
| text-transform: uppercase; | |
| letter-spacing: 1px; | |
| } | |
| /* Orb */ | |
| .orb-wrap { | |
| display: flex; justify-content: center; align-items: center; | |
| padding: 20px 0 32px 0; | |
| } | |
| .orb { | |
| width: 140px; height: 140px; border-radius: 50%; | |
| background: radial-gradient(circle at 30% 30%, var(--accent), var(--accent-2)); | |
| box-shadow: 0 0 60px rgba(99, 102, 241, 0.4); | |
| transition: transform 0.2s, box-shadow 0.2s; | |
| } | |
| .orb.speaking { | |
| animation: pulse 1.2s ease-in-out infinite; | |
| } | |
| .orb.listening { | |
| background: radial-gradient(circle at 30% 30%, var(--success), #059669); | |
| box-shadow: 0 0 60px rgba(16, 185, 129, 0.4); | |
| animation: pulse 0.8s ease-in-out infinite; | |
| } | |
| @keyframes pulse { | |
| 0%, 100% { transform: scale(1); } | |
| 50% { transform: scale(1.08); } | |
| } | |
| /* Question */ | |
| .question-box { | |
| padding: 16px; | |
| background: var(--panel-2); | |
| border-radius: 10px; | |
| border-left: 3px solid var(--accent); | |
| margin-bottom: 16px; | |
| } | |
| .q-meta { | |
| font-size: 11px; color: var(--muted); | |
| text-transform: uppercase; letter-spacing: 1px; | |
| margin-bottom: 8px; | |
| } | |
| .q-text { font-size: 15px; line-height: 1.5; } | |
| /* Transcript */ | |
| .transcript { | |
| max-height: 360px; | |
| overflow-y: auto; | |
| padding: 4px; | |
| } | |
| .turn { | |
| margin-bottom: 16px; | |
| padding: 12px 14px; | |
| border-radius: 10px; | |
| background: var(--panel-2); | |
| font-size: 14px; | |
| line-height: 1.5; | |
| } | |
| .turn .who { | |
| font-size: 11px; color: var(--muted); | |
| text-transform: uppercase; letter-spacing: 1px; | |
| margin-bottom: 6px; | |
| } | |
| .turn.user .who { color: var(--accent); } | |
| .turn.ai .who { color: var(--accent-2); } | |
| /* Signal pills */ | |
| .signals { display: grid; grid-template-columns: repeat(2, 1fr); gap: 10px; } | |
| .sig { | |
| background: var(--panel-2); | |
| border: 1px solid var(--border); | |
| border-radius: 8px; | |
| padding: 10px 12px; | |
| } | |
| .sig .lbl { font-size: 10px; color: var(--muted); text-transform: uppercase; letter-spacing: 1px; } | |
| .sig .val { font-size: 18px; font-weight: 600; margin-top: 4px; } | |
| /* Log */ | |
| .log { | |
| font-family: "SF Mono", Menlo, monospace; | |
| font-size: 11px; | |
| max-height: 200px; | |
| overflow-y: auto; | |
| background: #050810; | |
| padding: 12px; | |
| border-radius: 8px; | |
| border: 1px solid var(--border); | |
| color: var(--muted); | |
| } | |
| .log .row { margin-bottom: 2px; white-space: pre-wrap; } | |
| .log .ok { color: var(--success); } | |
| .log .err { color: var(--error); } | |
| .log .info { color: var(--accent); } | |
| /* Scrollbars */ | |
| ::-webkit-scrollbar { width: 8px; } | |
| ::-webkit-scrollbar-track { background: transparent; } | |
| ::-webkit-scrollbar-thumb { background: var(--border); border-radius: 4px; } | |
| </style> | |
| </head> | |
| <body> | |
| <div class="container"> | |
| <header> | |
| <h1>ποΈ Vidya</h1> | |
| <div class="status-pill"> | |
| <span class="dot" id="statusDot"></span> | |
| <span id="statusText">Disconnected</span> | |
| </div> | |
| </header> | |
| <div class="config"> | |
| <div> | |
| <label>User ID</label> | |
| <input id="userId" value="test_user_001" /> | |
| </div> | |
| <div> | |
| <label>Session ID</label> | |
| <input id="sessionId" value="test_sess_001" /> | |
| </div> | |
| <div style="display:flex; align-items:flex-end;"> | |
| <button class="btn" id="connectBtn">Start Interview</button> | |
| </div> | |
| </div> | |
| <div class="main"> | |
| <div> | |
| <!-- Question + orb --> | |
| <div class="panel"> | |
| <h2>Current Question</h2> | |
| <div class="orb-wrap"> | |
| <div class="orb" id="orb"></div> | |
| </div> | |
| <div class="question-box"> | |
| <div class="q-meta" id="qMeta">Awaiting sessionβ¦</div> | |
| <div class="q-text" id="qText">Click <strong>Start Interview</strong> to begin.</div> | |
| </div> | |
| </div> | |
| <!-- Transcript --> | |
| <div class="panel" style="margin-top: 20px;"> | |
| <h2>Conversation</h2> | |
| <div class="transcript" id="transcript"> | |
| <div style="color: var(--muted); text-align: center; padding: 20px;"> | |
| Transcript will appear here⦠| |
| </div> | |
| </div> | |
| </div> | |
| </div> | |
| <div> | |
| <!-- Session info --> | |
| <div class="panel"> | |
| <h2>Session</h2> | |
| <div class="signals"> | |
| <div class="sig"><div class="lbl">Role</div><div class="val" id="sRole">β</div></div> | |
| <div class="sig"><div class="lbl">Round</div><div class="val" id="sRound">β</div></div> | |
| <div class="sig"><div class="lbl">Progress</div><div class="val" id="sProgress">0 / 0</div></div> | |
| <div class="sig"><div class="lbl">Resuming</div><div class="val" id="sResume">No</div></div> | |
| </div> | |
| </div> | |
| <!-- Signals --> | |
| <div class="panel" style="margin-top: 20px;"> | |
| <h2>Latest Answer Signals</h2> | |
| <div class="signals"> | |
| <div class="sig"><div class="lbl">WPM</div><div class="val" id="sigWpm">β</div></div> | |
| <div class="sig"><div class="lbl">Fillers</div><div class="val" id="sigFillers">β</div></div> | |
| <div class="sig"><div class="lbl">Structure</div><div class="val" id="sigStructure">β</div></div> | |
| <div class="sig"><div class="lbl">Words</div><div class="val" id="sigWords">β</div></div> | |
| </div> | |
| </div> | |
| <!-- Log --> | |
| <div class="panel" style="margin-top: 20px;"> | |
| <h2>Event Log</h2> | |
| <div class="log" id="log"></div> | |
| </div> | |
| </div> | |
| </div> | |
| </div> | |
| <script> | |
| // ===================================================================== | |
| // CONFIG | |
| // ===================================================================== | |
| const SEND_RATE = 16000; | |
| const RECV_RATE = 24000; | |
| const CHUNK_SIZE = 1024; | |
| const ENDPOINT = "ws://live-assistant-alb-401098210.ap-south-1.elb.amazonaws.com:80"; | |
| // ===================================================================== | |
| // STATE | |
| // ===================================================================== | |
| let ws = null; | |
| let audioCtx = null; | |
| let playbackCtx = null; | |
| let micStream = null; | |
| let micProcessor = null; | |
| let micSource = null; | |
| let nextPlayTime = 0; | |
| let isConnected = false; | |
| let isSpeaking = false; | |
| const state = { | |
| totalQuestions: 0, | |
| answered: 0, | |
| userBuf: "", | |
| aiBuf: "", | |
| userTurnEl: null, | |
| aiTurnEl: null, | |
| }; | |
| // ===================================================================== | |
| // UI HELPERS | |
| // ===================================================================== | |
| const $ = (id) => document.getElementById(id); | |
| function setStatus(text, cls = "") { | |
| $("statusText").textContent = text; | |
| const dot = $("statusDot"); | |
| dot.classList.remove("live", "err"); | |
| if (cls) dot.classList.add(cls); | |
| } | |
| function log(msg, cls = "") { | |
| const row = document.createElement("div"); | |
| row.className = "row " + cls; | |
| const ts = new Date().toTimeString().slice(0, 8); | |
| row.textContent = `[${ts}] ${msg}`; | |
| const el = $("log"); | |
| el.appendChild(row); | |
| el.scrollTop = el.scrollHeight; | |
| } | |
| function setOrbState(s) { | |
| const orb = $("orb"); | |
| orb.classList.remove("speaking", "listening"); | |
| if (s === "speaking") orb.classList.add("speaking"); | |
| else if (s === "listening") orb.classList.add("listening"); | |
| } | |
| function appendTurn(who, text, isFinal = false) { | |
| const tr = $("transcript"); | |
| if (tr.children.length === 1 && tr.children[0].tagName === "DIV" && !tr.children[0].classList.contains("turn")) { | |
| tr.innerHTML = ""; | |
| } | |
| const div = document.createElement("div"); | |
| div.className = "turn " + (who === "user" ? "user" : "ai"); | |
| div.innerHTML = `<div class="who">${who === "user" ? "π€ You" : "π€ Interviewer"}</div><div class="text"></div>`; | |
| div.querySelector(".text").textContent = text; | |
| tr.appendChild(div); | |
| tr.scrollTop = tr.scrollHeight; | |
| return div; | |
| } | |
| function updateTurnText(el, text) { | |
| if (el) el.querySelector(".text").textContent = text; | |
| $("transcript").scrollTop = $("transcript").scrollHeight; | |
| } | |
| // ===================================================================== | |
| // AUDIO: MICROPHONE (16kHz Int16 PCM) | |
| // ===================================================================== | |
| async function startMic() { | |
| micStream = await navigator.mediaDevices.getUserMedia({ | |
| audio: { | |
| channelCount: 1, | |
| echoCancellation: true, | |
| noiseSuppression: true, | |
| autoGainControl: true, | |
| }, | |
| }); | |
| audioCtx = new AudioContext({ sampleRate: SEND_RATE }); | |
| micSource = audioCtx.createMediaStreamSource(micStream); | |
| micProcessor = audioCtx.createScriptProcessor(CHUNK_SIZE, 1, 1); | |
| micProcessor.onaudioprocess = (e) => { | |
| if (!ws || ws.readyState !== WebSocket.OPEN) return; | |
| const float32 = e.inputBuffer.getChannelData(0); | |
| const pcm16 = floatToPCM16(float32); | |
| const b64 = bytesToBase64(new Uint8Array(pcm16.buffer)); | |
| ws.send(JSON.stringify({ type: "audio_chunk", audio: b64 })); | |
| }; | |
| micSource.connect(micProcessor); | |
| micProcessor.connect(audioCtx.destination); | |
| log("π€ Microphone capturing at 16kHz", "ok"); | |
| } | |
| function stopMic() { | |
| if (micProcessor) { micProcessor.disconnect(); micProcessor = null; } | |
| if (micSource) { micSource.disconnect(); micSource = null; } | |
| if (audioCtx) { audioCtx.close(); audioCtx = null; } | |
| if (micStream) { micStream.getTracks().forEach((t) => t.stop()); micStream = null; } | |
| } | |
| function floatToPCM16(float32) { | |
| const out = new Int16Array(float32.length); | |
| for (let i = 0; i < float32.length; i++) { | |
| let s = Math.max(-1, Math.min(1, float32[i])); | |
| out[i] = s < 0 ? s * 0x8000 : s * 0x7fff; | |
| } | |
| return out; | |
| } | |
| function bytesToBase64(bytes) { | |
| let bin = ""; | |
| const chunk = 0x8000; | |
| for (let i = 0; i < bytes.length; i += chunk) { | |
| bin += String.fromCharCode.apply(null, bytes.subarray(i, i + chunk)); | |
| } | |
| return btoa(bin); | |
| } | |
| function base64ToBytes(b64) { | |
| const bin = atob(b64); | |
| const out = new Uint8Array(bin.length); | |
| for (let i = 0; i < bin.length; i++) out[i] = bin.charCodeAt(i); | |
| return out; | |
| } | |
| // ===================================================================== | |
| // AUDIO: PLAYBACK (24kHz Int16 PCM) | |
| // ===================================================================== | |
| function ensurePlaybackCtx() { | |
| if (!playbackCtx) { | |
| playbackCtx = new AudioContext({ sampleRate: RECV_RATE }); | |
| nextPlayTime = playbackCtx.currentTime; | |
| } | |
| } | |
| function playPCM(int16) { | |
| ensurePlaybackCtx(); | |
| const float32 = new Float32Array(int16.length); | |
| for (let i = 0; i < int16.length; i++) float32[i] = int16[i] / 0x8000; | |
| const buf = playbackCtx.createBuffer(1, float32.length, RECV_RATE); | |
| buf.copyToChannel(float32, 0); | |
| const src = playbackCtx.createBufferSource(); | |
| src.buffer = buf; | |
| src.connect(playbackCtx.destination); | |
| const now = playbackCtx.currentTime; | |
| const startAt = Math.max(now, nextPlayTime); | |
| src.start(startAt); | |
| nextPlayTime = startAt + buf.duration; | |
| if (!isSpeaking) { | |
| isSpeaking = true; | |
| setOrbState("speaking"); | |
| } | |
| src.onended = () => { | |
| if (playbackCtx && playbackCtx.currentTime >= nextPlayTime - 0.05) { | |
| isSpeaking = false; | |
| setOrbState(""); | |
| } | |
| }; | |
| } | |
| // ===================================================================== | |
| // WEBSOCKET | |
| // ===================================================================== | |
| async function connect() { | |
| const userId = $("userId").value.trim(); | |
| const sessionId = $("sessionId").value.trim(); | |
| const url = `${ENDPOINT}/ws/interview/session/${userId}?session_id=${sessionId}`; | |
| log(`Connecting β ${url}`, "info"); | |
| setStatus("Connectingβ¦"); | |
| try { | |
| ws = new WebSocket(url); | |
| } catch (e) { | |
| log("Failed to create WebSocket: " + e.message, "err"); | |
| return; | |
| } | |
| ws.binaryType = "arraybuffer"; | |
| ws.onopen = async () => { | |
| isConnected = true; | |
| setStatus("Connected", "live"); | |
| log("β WebSocket connected", "ok"); | |
| $("connectBtn").textContent = "Disconnect"; | |
| $("connectBtn").classList.add("danger"); | |
| try { | |
| await startMic(); | |
| } catch (e) { | |
| log("Mic error: " + e.message, "err"); | |
| } | |
| }; | |
| ws.onmessage = (event) => { | |
| if (event.data instanceof ArrayBuffer) { | |
| const int16 = new Int16Array(event.data); | |
| playPCM(int16); | |
| return; | |
| } | |
| handleMessage(event.data); | |
| }; | |
| ws.onerror = (e) => { | |
| log("WebSocket error", "err"); | |
| setStatus("Error", "err"); | |
| }; | |
| ws.onclose = () => { | |
| isConnected = false; | |
| setStatus("Disconnected"); | |
| setOrbState(""); | |
| log("Connection closed", "info"); | |
| $("connectBtn").textContent = "Start Interview"; | |
| $("connectBtn").classList.remove("danger"); | |
| stopMic(); | |
| }; | |
| } | |
| function disconnect() { | |
| if (ws) ws.close(); | |
| stopMic(); | |
| } | |
| function handleMessage(raw) { | |
| let data; | |
| try { data = JSON.parse(raw); } catch { return; } | |
| const t = data.type; | |
| const d = data.data || ""; | |
| switch (t) { | |
| case "audio_chunk": { | |
| if (data.is_last_chunk) { | |
| isSpeaking = false; | |
| setOrbState(""); | |
| return; | |
| } | |
| if (data.audio) { | |
| const bytes = base64ToBytes(data.audio); | |
| const int16 = new Int16Array(bytes.buffer, bytes.byteOffset, bytes.byteLength / 2); | |
| playPCM(int16); | |
| } | |
| break; | |
| } | |
| case "session_ready": { | |
| state.totalQuestions = data.total_questions || 0; | |
| $("sRole").textContent = data.role || "β"; | |
| $("sRound").textContent = data.round_type || "β"; | |
| $("sProgress").textContent = `${data.current_index || 0} / ${state.totalQuestions}`; | |
| $("sResume").textContent = data.is_resuming ? "Yes" : "No"; | |
| log(`Session ready β ${state.totalQuestions} questions`, "ok"); | |
| break; | |
| } | |
| case "question_start": { | |
| const idx = (data.index || 0) + 1; | |
| const total = data.total || state.totalQuestions; | |
| $("qMeta").textContent = `Q${idx} / ${total} β’ ${(data.question_type || "").toUpperCase()}`; | |
| $("qText").textContent = data.text || ""; | |
| log(`Q${idx}: ${(data.text || "").slice(0, 60)}β¦`, "info"); | |
| state.aiBuf = ""; | |
| state.aiTurnEl = appendTurn("ai", ""); | |
| break; | |
| } | |
| case "listening": { | |
| setOrbState("listening"); | |
| log("π€ Your turn β speak now", "info"); | |
| state.userBuf = ""; | |
| state.userTurnEl = appendTurn("user", ""); | |
| break; | |
| } | |
| case "user_transcript": { | |
| state.userBuf += d; | |
| if (!state.userTurnEl) state.userTurnEl = appendTurn("user", ""); | |
| updateTurnText(state.userTurnEl, state.userBuf); | |
| break; | |
| } | |
| case "model_text": { | |
| state.aiBuf += d; | |
| if (!state.aiTurnEl) state.aiTurnEl = appendTurn("ai", ""); | |
| updateTurnText(state.aiTurnEl, state.aiBuf); | |
| break; | |
| } | |
| case "turn_complete": { | |
| log("Turn complete", "ok"); | |
| state.userTurnEl = null; | |
| state.aiTurnEl = null; | |
| break; | |
| } | |
| case "signal_update": { | |
| const s = data.data || {}; | |
| $("sigWpm").textContent = s.wpm ?? "β"; | |
| $("sigFillers").textContent = s.filler_count ?? "β"; | |
| $("sigStructure").textContent = s.structure ?? "β"; | |
| $("sigWords").textContent = s.word_count ?? "β"; | |
| break; | |
| } | |
| case "answer_saved": { | |
| state.answered += 1; | |
| const idx = (data.index || 0) + 1; | |
| const next = data.next_index; | |
| $("sProgress").textContent = `${state.answered} / ${state.totalQuestions}`; | |
| if (next != null) log(`β Q${idx} saved β Q${next + 1}`, "ok"); | |
| else log(`β Q${idx} saved β all done`, "ok"); | |
| break; | |
| } | |
| case "ai_start_speaking": | |
| isSpeaking = true; | |
| setOrbState("speaking"); | |
| break; | |
| case "ai_stop_speaking": | |
| isSpeaking = false; | |
| setOrbState(""); | |
| break; | |
| case "interview_complete": { | |
| log(`π Interview complete β ${data.questions_answered} answered`, "ok"); | |
| $("qMeta").textContent = "COMPLETE"; | |
| $("qText").textContent = data.message || "All questions answered. Report generatingβ¦"; | |
| setOrbState(""); | |
| setTimeout(() => disconnect(), 1500); | |
| break; | |
| } | |
| case "error": | |
| log("β " + (data.message || "Unknown error"), "err"); | |
| break; | |
| } | |
| } | |
| // ===================================================================== | |
| // WIRE UP | |
| // ===================================================================== | |
| $("connectBtn").addEventListener("click", () => { | |
| if (isConnected) disconnect(); | |
| else connect(); | |
| }); | |
| window.addEventListener("beforeunload", () => { | |
| if (ws) ws.close(); | |
| stopMic(); | |
| }); | |
| </script> | |
| </body> | |
| </html> |