SADcase / web /index.html
pandion's picture
Upload index.html
3a40c26 verified
Raw
History Blame Contribute Delete
10.2 kB
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Client Role-Play Voice Simulator</title>
<style>
body { font-family: system-ui, -apple-system, Segoe UI, Roboto, sans-serif; padding: 16px; }
.row { display: flex; gap: 8px; align-items: center; flex-wrap: wrap; }
button { padding: 8px 12px; border-radius: 6px; border: 1px solid #ccc; background: #f7f7f7; cursor: pointer; }
button.primary { background: #4f46e5; color: white; border-color: #4f46e5; }
button.danger { background: #dc2626; color: white; border-color: #dc2626; }
#status { margin-left: 8px; font-size: 0.95rem; color: #555; }
.pill { display: inline-block; padding: 2px 8px; border-radius: 999px; background: #eef2ff; color: #3730a3; font-size: 12px; margin-right: 8px; }
.section { margin-top: 12px; }
</style>
</head>
<body>
<h2>Client Role-Play Voice Simulator</h2>
<div id="login">
<div style="margin-bottom:8px; color:#555">Login required (group1–group7)</div>
<div class="row">
<input id="groupUser" placeholder="group1" style="padding:6px;border:1px solid #ccc;border-radius:6px" />
<input id="groupPass" placeholder="password" type="password" style="padding:6px;border:1px solid #ccc;border-radius:6px" />
<button id="doLogin" class="primary">Login</button>
</div>
<div id="loginMsg" style="margin-top:6px; font-size:0.9rem; color:#b91c1c"></div>
<hr style="margin:16px 0; border:none; border-top:1px solid #eee"/>
</div>
<div class="row">
<button id="start" class="primary">Start Conversation</button>
<button id="stop" class="danger" disabled>Stop</button>
<span id="status">Idle</span>
<span id="countdown" class="pill" style="background:#fee2e2;color:#991b1b">05:00</span>
</div>
<div class="section">
<span class="pill">Catherine</span>
</div>
<audio id="remoteAudio" autoplay></audio>
<script>
const STATUS = document.getElementById('status');
const START = document.getElementById('start');
const STOP = document.getElementById('stop');
const REMOTEAUDIO = document.getElementById('remoteAudio');
const COUNTDOWN = document.getElementById('countdown');
const LOGIN = document.getElementById('login');
const GROUPUSER = document.getElementById('groupUser');
const GROUPPASS = document.getElementById('groupPass');
const DOLOGIN = document.getElementById('doLogin');
const LOGINMSG = document.getElementById('loginMsg');
// Configure your token server (running via FastAPI)
const TOKEN_SERVER = (window.TOKEN_SERVER || 'http://localhost:5050');
let pc = null; // RTCPeerConnection
let dc = null; // DataChannel for events
let localStream = null;
let authHeader = null; // Basic auth header for /session
let stopTimer = null; // Interview time limit timer
let serverRemainingMs = null; // Remaining ms from server
let countdownInterval = null;
let deadlineTs = null;
function sendEvent(evt) {
if (dc && dc.readyState === 'open') {
dc.send(JSON.stringify(evt));
}
}
async function fetchEphemeralKey() {
if (!authHeader) throw new Error('Not logged in');
const res = await fetch(`${TOKEN_SERVER}/session`, {
headers: { 'Authorization': authHeader }
});
if (!res.ok) {
const t = await res.text();
throw new Error(`Token server error: ${res.status} ${t}`);
}
const data = await res.json();
if (typeof data.remaining_ms === 'number') {
serverRemainingMs = data.remaining_ms;
}
const key = data?.client_secret?.value || data?.client_secret || data?.value;
if (!key) throw new Error('Invalid token response');
return key;
}
async function fetchRemaining() {
if (!authHeader) return null;
const res = await fetch(`${TOKEN_SERVER}/remaining`, {
headers: { 'Authorization': authHeader }
});
if (!res.ok) return null;
const data = await res.json();
if (typeof data.remaining_ms === 'number') {
serverRemainingMs = data.remaining_ms;
return serverRemainingMs;
}
return null;
}
// Session instructions are applied server-side at session creation.
async function startConversation() {
if (!authHeader) {
STATUS.textContent = 'Login required';
return;
}
START.disabled = true;
STOP.disabled = false;
STATUS.textContent = 'Requesting mic...';
localStream = await navigator.mediaDevices.getUserMedia({ audio: true });
pc = new RTCPeerConnection();
// Play remote audio
pc.ontrack = (e) => {
const [stream] = e.streams;
REMOTEAUDIO.srcObject = stream;
};
// Add microphone
for (const track of localStream.getTracks()) {
pc.addTrack(track, localStream);
}
// Data channel for events
dc = pc.createDataChannel('oai-events');
dc.onopen = () => {
STATUS.textContent = 'Connected';
// Brief greeting to set context (spoken) using selected voice
sendEvent({
type: 'response.create',
response: {
instructions: 'Hi, I’m Catherine. Thanks for taking the time—things have been hectic lately! How would you like to start?',
modalities: ['audio']
}
});
// Start 5-minute auto-stop timer
if (stopTimer) clearTimeout(stopTimer);
const initialMs = typeof serverRemainingMs === 'number' ? serverRemainingMs : (5 * 60 * 1000);
deadlineTs = Date.now() + initialMs;
const updateCountdown = () => {
const ms = Math.max(0, deadlineTs - Date.now());
const mm = String(Math.floor(ms / 60000)).padStart(2, '0');
const ss = String(Math.floor((ms % 60000) / 1000)).padStart(2, '0');
COUNTDOWN.textContent = `${mm}:${ss}`;
};
updateCountdown();
if (countdownInterval) clearInterval(countdownInterval);
countdownInterval = setInterval(updateCountdown, 1000);
stopTimer = setTimeout(() => {
COUNTDOWN.textContent = '00:00';
STATUS.textContent = 'Time limit reached (5 minutes).';
stopConversation();
}, initialMs);
};
dc.onmessage = () => {
// Voice-only UI: ignore textual events.
};
// Create and set local offer
const offer = await pc.createOffer();
await pc.setLocalDescription(offer);
STATUS.textContent = 'Connecting to OpenAI…';
const EPHEMERAL = await fetchEphemeralKey();
// Exchange SDP directly with OpenAI Realtime endpoint
const sdpResp = await fetch(`https://api.openai.com/v1/realtime?model=gpt-4o-realtime-preview-2024-12-17`, {
method: 'POST',
body: offer.sdp,
headers: {
'Authorization': `Bearer ${EPHEMERAL}`,
'Content-Type': 'application/sdp'
}
});
const answer = { type: 'answer', sdp: await sdpResp.text() };
await pc.setRemoteDescription(answer);
STATUS.textContent = 'Ready. Speak to Catherine.';
}
async function stopConversation() {
STOP.disabled = true;
START.disabled = false;
if (!STATUS.textContent.startsWith('Time limit')) {
STATUS.textContent = 'Disconnected';
}
try { if (dc) dc.close(); } catch {}
try { if (pc) pc.close(); } catch {}
dc = null; pc = null;
if (localStream) {
for (const t of localStream.getTracks()) t.stop();
localStream = null;
}
if (stopTimer) { clearTimeout(stopTimer); stopTimer = null; }
if (countdownInterval) { clearInterval(countdownInterval); countdownInterval = null; }
// Keep displaying the last known remaining time; refresh from server
fetchRemaining().then(ms => {
const remaining = typeof ms === 'number' ? ms : null;
if (remaining !== null) {
const mm = String(Math.floor(remaining / 60000)).padStart(2, '0');
const ss = String(Math.floor((remaining % 60000) / 1000)).padStart(2, '0');
COUNTDOWN.textContent = `${mm}:${ss}`;
if (remaining <= 0) {
STATUS.textContent = 'Time limit reached (5 minutes).';
START.disabled = true;
}
}
}).catch(() => {});
}
// Handle login: username group1..group7, password same
DOLOGIN.addEventListener('click', async () => {
const u = (GROUPUSER.value || '').trim();
const p = (GROUPPASS.value || '').trim();
if (!/^group[1-7]$/.test(u) || p !== u) {
LOGINMSG.textContent = 'Invalid credentials. Try group1 / group1 (…group7).';
authHeader = null;
return;
}
const token = btoa(`${u}:${p}`);
authHeader = `Basic ${token}`;
LOGIN.style.display = 'none';
STATUS.textContent = 'Logged in as ' + u;
// Show current remaining time upon login
const ms = await fetchRemaining();
if (typeof ms === 'number') {
const mm = String(Math.floor(ms / 60000)).padStart(2, '0');
const ss = String(Math.floor((ms % 60000) / 1000)).padStart(2, '0');
COUNTDOWN.textContent = `${mm}:${ss}`;
if (ms <= 0) {
STATUS.textContent = 'Time limit reached (5 minutes).';
START.disabled = true;
}
}
});
START.addEventListener('click', () => startConversation().catch(err => {
STATUS.textContent = 'Error: ' + err.message;
}));
STOP.addEventListener('click', () => stopConversation());
</script>
</body>
</html>