| <!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'); |
| |
| |
| const TOKEN_SERVER = (window.TOKEN_SERVER || 'http://localhost:5050'); |
| |
| let pc = null; |
| let dc = null; |
| let localStream = null; |
| let authHeader = null; |
| let stopTimer = null; |
| let serverRemainingMs = null; |
| 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; |
| } |
| |
| |
| |
| 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(); |
| |
| |
| pc.ontrack = (e) => { |
| const [stream] = e.streams; |
| REMOTEAUDIO.srcObject = stream; |
| }; |
| |
| |
| for (const track of localStream.getTracks()) { |
| pc.addTrack(track, localStream); |
| } |
| |
| |
| dc = pc.createDataChannel('oai-events'); |
| |
| dc.onopen = () => { |
| STATUS.textContent = 'Connected'; |
| |
| |
| 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'] |
| } |
| }); |
| |
| |
| 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 = () => { |
| |
| }; |
| |
| |
| const offer = await pc.createOffer(); |
| await pc.setLocalDescription(offer); |
| |
| STATUS.textContent = 'Connecting to OpenAI…'; |
| const EPHEMERAL = await fetchEphemeralKey(); |
| |
| |
| 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; } |
| |
| 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(() => {}); |
| } |
| |
| |
| 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; |
| |
| 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> |
|
|