AI Interview

Software Engineer Position • Round 1

Connecting to AI Interviewer...
Interviewer Idle
Welcome! I'm your AI recruiter. When you're ready, click "Start Interview" and I will ask you your first question.
You •••
Camera starting…
Listening…
Listening…
// Set backend URL globally before initializing // This is needed since interview.html doesn't load main.js window.BACKEND_URL = window.BACKEND_URL || 'http://localhost:8000'; const avatarIframe = document.getElementById('avatar-iframe'); const loadingOverlay = document.getElementById('loading-overlay'); const btnStart = document.getElementById('btn-start'); const btnMic = document.getElementById('btn-mic'); const recordingIndicator = document.getElementById('recording-indicator'); const interviewerStatus = document.getElementById('interviewer-status'); const userVideoWrapper = document.getElementById('user-video-wrapper'); const candidatePrompt = document.getElementById('candidate-prompt'); const micIndicator = document.getElementById('mic-indicator'); const transcriptSwitch = document.getElementById('transcript-switch'); const liveCaption = document.getElementById('live-caption'); let isMicMuted = false; let isRecording = false; let userStream = null; let interviewStarted = false; // Target origin used for every postMessage call to the avatar iframe. // Centralized so it's a one-line change when you move off localhost. const AVATAR_ORIGIN = window.location.origin; // URL of your FastAPI backend (backend.py) — update to match wherever it runs. const BACKEND_URL = window.BACKEND_URL; let currentSessionId = null; // Default resting expression — used whenever the avatar isn't actively // speaking. Change to 'neutral' if you'd rather it not smile at rest. const DEFAULT_EXPRESSION = 'smile'; // ── Candidate expression tracking ── // Model weights for face-api.js's lightweight face detector + the // expression classifier. Self-hosted under our own domain (public/models/) // instead of pulled from a third-party GitHub/CDN host at interview time — // that CDN dependency was intermittently unreachable and silently // disabled this whole feature with no visible error to the candidate. const EXPRESSION_MODEL_URL = '/models'; const EXPRESSION_POLL_MS = 800; // how often we sample a frame const EXPRESSION_CONFIDENCE_MIN = 0.55; // ignore low-confidence reads const EXPRESSION_STABILITY_READS = 2; // reads in a row before reacting // Maps a detected candidate expression to how the avatar should // respond. "gesture" is a semantic hint (not a CSS animation) that // the main VRM/Three.js app maps to an actual animation clip. const EXPRESSION_REACTIONS = { happy: { avatarExpression: 'smile', gesture: 'nod' }, surprised: { avatarExpression: 'surprised', gesture: 'headTilt' }, sad: { avatarExpression: 'concerned', gesture: 'leanIn' }, fearful: { avatarExpression: 'concerned', gesture: 'leanIn' }, // face-api's stock classifier doesn't output "confused" — if your // main app derives it from a custom model, this mapping is ready. confused: { avatarExpression: 'concerned', gesture: 'leanIn' }, angry: { avatarExpression: 'neutral', gesture: 'attentive' }, disgusted: { avatarExpression: 'neutral', gesture: 'attentive' }, neutral: { avatarExpression: DEFAULT_EXPRESSION, gesture: 'idle' } }; let expressionModelsReady = false; let expressionLoopHandle = null; let lastDetectedExpression = null; let stableExpressionCount = 0; let pendingCloseout = false; let awaitingFinalSpeechEnd = false; // 1. Listen for messages from the main app (iframe) window.addEventListener('message', (event) => { if (event.origin !== AVATAR_ORIGIN) return; if (event.data.type === 'AKADEMIA_READY') { loadingOverlay.classList.add('hidden'); document.getElementById('start-screen').classList.remove('hidden'); console.log('✅ Avatar iframe is fully loaded and ready.'); initAvatarPresence(); } if (event.data.type === 'AVATAR_SPEECH_END') { interviewerStatus.textContent = 'Idle'; interviewerStatus.className = 'interviewer-status'; setAvatarExpression(DEFAULT_EXPRESSION); lookAtCamera(); if (pendingCloseout) { pendingCloseout = false; endInterview(); } else if (awaitingFinalSpeechEnd) { awaitingFinalSpeechEnd = false; document.getElementById('end-screen').classList.remove('hidden'); } } }); // 1b. Establish the avatar's resting presence: looking at the camera, // locked in place (no idle wandering/sway), smiling. // NOTE: These three message types are new — the main Three.js/VRM // app needs a handler for each. If it doesn't have one yet, this // is the contract to implement on that side: // SET_AVATAR_LOOK_TARGET { target: 'camera' } // -> point the VRM's LookAt / eye bones at the webcam-facing // camera instead of tracking cursor/idle targets. // LOCK_AVATAR_POSITION { locked: true } // -> freeze root position/idle sway animations so the avatar // doesn't drift or shift out of the bust-framing crop. // SET_AVATAR_EXPRESSION { expression: 'smile' } // -> apply a blendshape/expression preset for a warm smile. function initAvatarPresence() { lookAtCamera(); lockAvatarPosition(true); setAvatarExpression(DEFAULT_EXPRESSION); } function lookAtCamera() { if (!avatarIframe || !avatarIframe.contentWindow) return; avatarIframe.contentWindow.postMessage({ type: 'SET_AVATAR_LOOK_TARGET', payload: { target: 'camera' } }, AVATAR_ORIGIN); } function lockAvatarPosition(locked) { if (!avatarIframe || !avatarIframe.contentWindow) return; avatarIframe.contentWindow.postMessage({ type: 'LOCK_AVATAR_POSITION', payload: { locked: locked } }, AVATAR_ORIGIN); } function setAvatarExpression(expression) { if (!avatarIframe || !avatarIframe.contentWindow) return; avatarIframe.contentWindow.postMessage({ type: 'SET_AVATAR_EXPRESSION', payload: { expression: expression } }, AVATAR_ORIGIN); } // ── Candidate expression → avatar reaction pipeline ── async function loadExpressionModels() { if (typeof faceapi === 'undefined') { console.warn('️ face-api.js failed to load — candidate expression tracking disabled.'); return false; } try { await faceapi.nets.tinyFaceDetector.loadFromUri(EXPRESSION_MODEL_URL); await faceapi.nets.faceExpressionNet.loadFromUri(EXPRESSION_MODEL_URL); console.log('✅ Candidate expression models loaded.'); return true; } catch (err) { console.warn('⚠️ Could not load expression models — check network access to', EXPRESSION_MODEL_URL, err); return false; } } // Call once the candidate's camera stream is live. async function startExpressionTracking() { expressionModelsReady = await loadExpressionModels(); if (!expressionModelsReady) return; const video = document.getElementById('user-video'); const detectorOptions = new faceapi.TinyFaceDetectorOptions({ inputSize: 224, scoreThreshold: 0.5 }); expressionLoopHandle = setInterval(async () => { // Runs continuously for the whole interview (not just while the // mic is live) so the avatar can react to the candidate at any // point. Gate this on isRecording if you'd rather it only react // while they're actively answering. if (!userStream || video.readyState < 2) return; try { const result = await faceapi .detectSingleFace(video, detectorOptions) .withFaceExpressions(); if (!result) return; handleCandidateExpression(getDominantExpression(result.expressions)); } catch (err) { // Skip a bad frame silently rather than spamming the console. } }, EXPRESSION_POLL_MS); } function getDominantExpression(expressions) { let topName = 'neutral'; let topScore = 0; for (const [name, score] of Object.entries(expressions)) { if (score > topScore) { topScore = score; topName = name; } } return { name: topName, score: topScore }; } function handleCandidateExpression(detected) { if (detected.score < EXPRESSION_CONFIDENCE_MIN) return; if (detected.name === lastDetectedExpression) { stableExpressionCount++; } else { lastDetectedExpression = detected.name; stableExpressionCount = 1; } // Require the expression to hold for a few consecutive reads before // reacting, so the avatar doesn't twitch on every passing micro-expression. if (stableExpressionCount !== EXPRESSION_STABILITY_READS) return; const reaction = EXPRESSION_REACTIONS[detected.name] || EXPRESSION_REACTIONS.neutral; reactToCandidate(detected.name, reaction); } // NOTE for the main app: this is a new inbound message type. // AVATAR_REACT_TO_CANDIDATE { candidateExpression, avatarExpression, gesture } // -> apply the avatarExpression blendshape/preset AND play the short // gesture animation clip mapped from `gesture` (nod / headTilt / // leanIn / attentive / idle). Eye contact should stay locked on // the camera throughout — this call intentionally re-sends // SET_AVATAR_LOOK_TARGET right after so a gesture never breaks it. function reactToCandidate(candidateExpression, reaction) { if (!avatarIframe || !avatarIframe.contentWindow) return; avatarIframe.contentWindow.postMessage({ type: 'AVATAR_REACT_TO_CANDIDATE', payload: { candidateExpression: candidateExpression, avatarExpression: reaction.avatarExpression, gesture: reaction.gesture } }, AVATAR_ORIGIN); lookAtCamera(); updateMoodBadge(candidateExpression); } function updateMoodBadge(expression) { const badge = document.getElementById('mood-badge'); if (!badge) return; badge.textContent = expression.charAt(0).toUpperCase() + expression.slice(1); badge.classList.add('visible'); } // 2. Function to command the avatar to speak function makeAvatarSpeak(text, expression = DEFAULT_EXPRESSION) { if (!avatarIframe || !avatarIframe.contentWindow) return; interviewerStatus.textContent = 'Speaking…'; interviewerStatus.className = 'interviewer-status speaking'; // Keep eye contact locked in for the duration of speech too. lookAtCamera(); // Send command to the main app avatarIframe.contentWindow.postMessage({ type: 'MAKE_AVATAR_SPEAK', payload: { text: text, expression: expression, // 'smile', 'thinking', 'neutral', etc. language: 'en' } }, AVATAR_ORIGIN); } // 4. Start Interview Logic — camera/mic are already running by this point; // this just kicks off the Q&A flow. async function startInterview() { if (interviewStarted) return; interviewStarted = true; document.getElementById('start-screen').classList.add('hidden'); btnStart.style.display = 'none'; if (!userStream) { await initUserCamera(); } btnMic.disabled = false; try { const res = await fetch(`${BACKEND_URL}/interview/start`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ role_title: 'Software Engineer', mode: 'short', round_label: 'Round 1' }), }); if (!res.ok) throw new Error(`HTTP ${res.status}`); const turn = await res.json(); currentSessionId = turn.session_id; handleServerTurn(turn); } catch (err) { console.error('❌ Failed to start interview:', err); document.getElementById('interviewer-question').textContent = 'Could not reach the interviewer right now. Please refresh and try again.'; interviewStarted = false; btnStart.disabled = false; btnStart.textContent = 'Start Interview'; } } function handleServerTurn(turn) { document.getElementById('interviewer-question').textContent = turn.reply; makeAvatarSpeak(turn.reply, DEFAULT_EXPRESSION); if (turn.state === 'closing') { pendingCloseout = true; // don't call endInterview() until this line finishes speaking } else if (turn.session_complete) { btnMic.disabled = true; } } async function submitAnswer(transcript) { if (!currentSessionId) return; interviewerStatus.textContent = 'Thinking…'; interviewerStatus.className = 'interviewer-status'; try { const res = await fetch(`${BACKEND_URL}/interview/${currentSessionId}/answer`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ transcript }), }); if (!res.ok) throw new Error(`HTTP ${res.status}`); handleServerTurn(await res.json()); } catch (err) { console.error('❌ Failed to submit answer:', err); document.getElementById('interviewer-question').textContent = "Sorry, I had trouble processing that — could you try again?"; } } async function endInterview() { try { const res = await fetch(`${BACKEND_URL}/interview/${currentSessionId}/end`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ include_feedback: true }), }); const summary = await res.json(); document.getElementById('interviewer-question').textContent = summary.reply; const feedbackLines = (summary.strengths || []).join('\n'); document.getElementById('end-screen-message').textContent = `${summary.closing_remarks}\n\n${feedbackLines}`; awaitingFinalSpeechEnd = true; makeAvatarSpeak(summary.reply, DEFAULT_EXPRESSION); btnMic.disabled = true; btnStart.textContent = 'Interview Complete'; } catch (err) { console.error('❌ Failed to end interview:', err); } finally { currentSessionId = null; } } // 6. Candidate camera + mic — triggered automatically on load async function initUserCamera() { try { userStream = await navigator.mediaDevices.getUserMedia({ video: { width: { ideal: 1280 }, height: { ideal: 720 }, facingMode: 'user' }, audio: true }); document.getElementById('user-video').srcObject = userStream; userVideoWrapper.classList.remove('no-stream'); candidatePrompt.textContent = 'Camera & mic are live'; console.log('✅ User camera & mic initialized'); // Candidate video is local now — start reading expressions so the // avatar can react. This never sends frames anywhere; detection // runs fully in this browser tab. startExpressionTracking(); } catch (err) { console.error('❌ Failed to access camera/mic:', err); candidatePrompt.textContent = 'Camera/mic access denied — enable permissions to continue'; } } // Shared UI state setter — called both from manual button clicks AND from // recognition auto-stopping on its own, so the two can never desync. function setListeningUI(listening) { isMicMuted = !listening; isRecording = listening; if (userStream) { userStream.getAudioTracks().forEach(track => { track.enabled = listening; }); } btnMic.classList.toggle('muted', !listening); micIndicator.classList.toggle('muted', !listening); recordingIndicator.classList.toggle('active', listening); interviewerStatus.textContent = listening ? 'Listening…' : 'Idle'; interviewerStatus.className = 'interviewer-status' + (listening ? ' listening' : ''); if (listening) { lookAtCamera(); setAvatarExpression(DEFAULT_EXPRESSION); } else { hideLiveCaption(); } } function updateLiveCaption(text) { const trimmed = (text || '').trim(); if (!trimmed) { hideLiveCaption(); return; } liveCaption.textContent = trimmed; liveCaption.classList.add('active'); liveCaption.scrollTop = liveCaption.scrollHeight; } function hideLiveCaption() { liveCaption.classList.remove('active'); liveCaption.textContent = ''; } // 7. Toggle Microphone function toggleMic() { if (isRecording) { manualStop = true; finishListening(); console.log('🎤 Candidate stopped mic manually.'); } else { finalTranscript = ''; manualStop = false; setListeningUI(true); startListening(); console.log('🎤 Listening…'); } } function finishListening() { if (recognition && recognitionActive) { try { recognition.stop(); } catch (_) {} } setListeningUI(false); const transcript = finalTranscript.trim(); finalTranscript = ''; hideLiveCaption(); if (transcript) { // Go straight to "Thinking" — the candidate just handed off control, // the avatar is processing, not sitting idle. interviewerStatus.textContent = 'Thinking…'; interviewerStatus.className = 'interviewer-status'; submitAnswer(transcript); } else { interviewerStatus.textContent = 'Idle'; interviewerStatus.className = 'interviewer-status'; } } // 8. Transcript toggle (visual only — wire up to your captions source) function toggleTranscript() { transcriptSwitch.classList.toggle('off'); } // 9. Interrupt the avatar mid-speech function interruptSpeech() { if (!avatarIframe || !avatarIframe.contentWindow) return; avatarIframe.contentWindow.postMessage({ type: 'INTERRUPT_AVATAR_SPEECH' }, AVATAR_ORIGIN); interviewerStatus.textContent = 'Idle'; interviewerStatus.className = 'interviewer-status'; // Return to resting smile + eye contact immediately after interrupt setAvatarExpression(DEFAULT_EXPRESSION); lookAtCamera(); } // 10. Cleanup on page unload window.addEventListener('beforeunload', () => { if (userStream) { userStream.getTracks().forEach(track => track.stop()); } if (expressionLoopHandle) { clearInterval(expressionLoopHandle); } }); // ── Speech recognition state ── let recognition = null; let speechSupported = false; let recognitionActive = false; let manualStop = false; // true only when the candidate clicked the mic button let finalTranscript = ''; // accumulates across the whole continuous session function initSpeechRecognition() { const SR = window.SpeechRecognition || window.webkitSpeechRecognition; if (!SR) { speechSupported = false; return; } speechSupported = true; recognition = new SR(); recognition.lang = 'en-US'; recognition.continuous = true; // was false — this is what caused pause-triggered cutoffs recognition.interimResults = true; recognition.maxAlternatives = 1; recognition.onstart = () => { recognitionActive = true; }; recognition.onresult = (event) => { let interimChunk = ''; for (let i = event.resultIndex; i < event.results.length; i++) { if (event.results[i].isFinal) { finalTranscript += event.results[i][0].transcript + ' '; } else { interimChunk += event.results[i][0].transcript; } } // Live caption shows what's been locked in plus whatever the // engine is still guessing at, so the candidate can visually // confirm their words are actually being captured correctly — // rather than silently trusting the mic icon. updateLiveCaption(finalTranscript + interimChunk); }; recognition.onerror = (event) => { recognitionActive = false; console.warn('Speech recognition error:', event.error); // 'no-speech' fires constantly in continuous mode during normal pauses — // not a real error, let onend decide whether to restart. if (event.error !== 'no-speech') finishListening(); }; recognition.onend = () => { recognitionActive = false; // Some browsers still time out a continuous session on their own after // ~60s. If the candidate hasn't clicked stop, restart silently instead // of leaving them talking into a dead mic. if (isRecording && !manualStop) { try { recognition.start(); } catch (_) {} } }; } function returnToHome() { // No SPA router here, so a reload is the simplest way back to the // pre-interview welcome screen. If this page lives inside a larger app, // point this at the actual landing route instead, e.g.: // window.location.href = '/'; window.location.href = '/app.html'; } // Guards against an accidental click wiping out an in-progress // interview: only interrupt/confirm once the candidate has actually // started, otherwise just leave straight away. function confirmReturnHome() { if (!interviewStarted || currentSessionId === null && !isRecording) { // Nothing meaningful in progress yet (or interview already ended) — leave immediately. if (!interviewStarted) { returnToHome(); return; } } const confirmed = window.confirm('Leave this interview and return to the home page? Your progress on this session will not be saved.'); if (!confirmed) return; if (isRecording) { manualStop = true; if (recognition && recognitionActive) { try { recognition.stop(); } catch (_) {} } } if (userStream) { userStream.getTracks().forEach(track => track.stop()); } if (expressionLoopHandle) { clearInterval(expressionLoopHandle); } returnToHome(); } function startListening() { if (!speechSupported) { console.warn('Speech recognition not supported in this browser.'); return; } try { recognition.start(); } catch (err) { console.warn('Could not start recognition:', err); } } // Expose to global scope for HTML onclick attributes window.startInterview = startInterview; window.toggleMic = toggleMic; window.toggleTranscript = toggleTranscript; window.interruptSpeech = interruptSpeech; window.returnToHome = returnToHome; window.confirmReturnHome = confirmReturnHome; window.addEventListener('DOMContentLoaded', () => { initUserCamera(); initSpeechRecognition(); });