interview-conductor / index.html
banao-tech's picture
Update index.html
f219779 verified
Raw
History Blame Contribute Delete
19.1 kB
<!DOCTYPE html>
<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>