This is a short practice interview for the Software Engineer position — Round 1. Click the mic to answer each question, and click it again when you're done speaking.
Interview Complete
Thanks for completing this practice interview. Keep an eye on your email — we'll follow up with next steps shortly.
InterviewerIdle
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();
});