30sAI / index.html
jojo007unfi's picture
Update index.html
316f871 verified
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no">
<title>30sAI</title>
<link href="https://fonts.googleapis.com/css2?family=Outfit:wght@300;400;500;600;700&family=DM+Mono:wght@400;500&family=DM+Sans:opsz,wght@9..40,300;9..40,400;9..40,500;9..40,600&display=swap" rel="stylesheet">
<style>
:root {
--bg: #0B0D12;
--surface: #131720;
--surface-2: #1A2030;
--surface-3: #212840;
--border: #252E45;
--border-2: #2E3A55;
--accent: #00C896;
--accent-dim: rgba(0,200,150,0.15);
--accent-glow: rgba(0,200,150,0.35);
--red: #FF4B6A;
--red-dim: rgba(255,75,106,0.15);
--amber: #FFB340;
--amber-dim: rgba(255,179,64,0.15);
--blue: #4D8FFF;
--blue-dim: rgba(77,143,255,0.15);
--text: #EDF2FF;
--text-2: #8C9BBF;
--text-3: #4D5C80;
--partial: #6B7DB3;
--mono: 'DM Mono', monospace;
--sans: 'DM Sans', sans-serif;
--display: 'Outfit', sans-serif;
--r-sm: 10px;
--r-md: 16px;
--r-lg: 24px;
--r-xl: 32px;
}
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
html, body { height: 100%; overflow: hidden; font-family: var(--sans); background: #000; }
button { border: none; background: none; cursor: pointer; font-family: inherit; }
input, textarea { font-family: inherit; outline: none; border: none; background: none; color: var(--text); }
.phone-wrap {
width: 100%; height: 100%;
display: flex; align-items: center; justify-content: center;
background: radial-gradient(ellipse 80% 60% at 50% 100%, rgba(0,200,150,0.06) 0%, transparent 70%),
radial-gradient(ellipse 60% 40% at 20% 0%, rgba(77,143,255,0.05) 0%, transparent 60%), #050710;
}
.phone {
width: min(420px, 100vw); height: min(860px, 100vh);
background: var(--bg);
border-radius: clamp(0px, 4vw, 44px);
box-shadow: 0 40px 120px rgba(0,0,0,0.9), 0 0 0 1px var(--border);
display: flex; flex-direction: column;
overflow: hidden; position: relative;
}
.status-bar {
display: flex; justify-content: space-between; align-items: center;
padding: 12px 24px 0;
font-size: 12px; font-weight: 600; letter-spacing: 0.3px;
color: var(--text-2); font-family: var(--display);
flex-shrink: 0;
}
.status-bar .time { font-size: 15px; font-weight: 700; color: var(--text); }
.status-icons { display: flex; gap: 6px; align-items: center; }
.status-icons svg { width: 16px; height: 16px; }
.screens { flex: 1; position: relative; overflow: hidden; }
.screen {
position: absolute; inset: 0;
display: flex; flex-direction: column;
opacity: 0; pointer-events: none;
transform: translateX(30px);
transition: opacity 0.3s ease, transform 0.3s ease;
}
.screen.active { opacity: 1; pointer-events: all; transform: translateX(0); }
.screen.exit-left { transform: translateX(-30px); }
.scroll-area { flex: 1; overflow-y: auto; overflow-x: hidden; scrollbar-width: none; }
.scroll-area::-webkit-scrollbar { display: none; }
.top-nav {
display: flex; align-items: center; justify-content: space-between;
padding: 16px 20px 8px; flex-shrink: 0;
}
.nav-title { font-family: var(--display); font-size: 22px; font-weight: 700; color: var(--text); letter-spacing: -0.3px; }
/* ── DIALER ── */
.dialer-body { flex: 1; display: flex; flex-direction: column; padding: 0 20px 20px; gap: 12px; }
.recent-label { font-size: 12px; font-weight: 600; letter-spacing: 0.8px; text-transform: uppercase; color: var(--text-3); padding: 4px 0; }
.contact-list { display: flex; flex-direction: column; gap: 4px; }
.contact-item {
display: flex; align-items: center; gap: 14px;
padding: 12px 14px; border-radius: var(--r-md);
cursor: pointer; transition: background 0.15s; position: relative;
}
.contact-item:hover { background: var(--surface); }
.contact-item:active { background: var(--surface-2); }
.avatar {
width: 46px; height: 46px; border-radius: 50%;
display: flex; align-items: center; justify-content: center;
font-family: var(--display); font-weight: 700; font-size: 18px; flex-shrink: 0;
}
.contact-info { flex: 1; min-width: 0; }
.contact-name { font-weight: 600; font-size: 15px; color: var(--text); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.contact-meta { font-size: 12px; color: var(--text-2); margin-top: 1px; }
.contact-call-btn {
width: 36px; height: 36px; border-radius: 50%;
background: var(--accent-dim); color: var(--accent);
display: flex; align-items: center; justify-content: center;
}
.contact-call-btn svg { width: 16px; height: 16px; }
.keypad-display {
background: var(--surface); border: 1px solid var(--border);
border-radius: var(--r-md); padding: 14px 18px;
display: flex; align-items: center; gap: 12px;
}
.keypad-number {
flex: 1; font-family: var(--display); font-size: 28px; font-weight: 300;
color: var(--text); letter-spacing: 3px; min-height: 36px; display: flex; align-items: center;
}
.keypad-number.empty { color: var(--text-3); font-size: 15px; font-weight: 400; letter-spacing: 0; }
.keypad-delete { color: var(--text-2); padding: 4px; }
.keypad-delete svg { width: 22px; height: 22px; }
.keypad-grid { display: grid; grid-template-columns: repeat(3, 1fr); gap: 10px; }
.key {
aspect-ratio: 1.4;
background: var(--surface); border: 1px solid var(--border);
border-radius: var(--r-md); cursor: pointer;
display: flex; flex-direction: column; align-items: center; justify-content: center;
transition: background 0.1s, transform 0.1s; user-select: none;
}
.key:hover { background: var(--surface-2); }
.key:active { background: var(--surface-3); transform: scale(0.95); }
.key-digit { font-family: var(--display); font-size: 22px; font-weight: 500; color: var(--text); }
.key-alpha { font-size: 9px; font-weight: 600; letter-spacing: 1px; color: var(--text-3); margin-top: 1px; }
.btn-call {
background: var(--accent); border-radius: 50%; width: 68px; height: 68px;
display: flex; align-items: center; justify-content: center; margin: 0 auto;
transition: transform 0.15s, box-shadow 0.15s;
}
.btn-call:hover { transform: scale(1.06); box-shadow: 0 0 0 8px var(--accent-glow); }
.btn-call:active { transform: scale(0.96); }
.btn-call svg { width: 28px; height: 28px; color: #000; }
/* ── CALL SCREEN ── */
#screen-call { background: var(--bg); }
.call-header {
padding: 14px 20px 10px;
display: flex; flex-direction: column; gap: 8px;
border-bottom: 1px solid var(--border); flex-shrink: 0;
}
.call-contact-row { display: flex; align-items: center; gap: 12px; width: 100%; }
.call-avatar { width: 40px; height: 40px; border-radius: 50%; flex-shrink: 0; }
.call-contact-info { flex: 1; }
.call-name { font-family: var(--display); font-size: 17px; font-weight: 600; color: var(--text); }
.call-status { font-size: 12px; color: var(--accent); margin-top: 1px; }
.call-timer-badge {
font-family: var(--mono); font-size: 13px; font-weight: 500;
color: var(--text-2); background: var(--surface); border: 1px solid var(--border);
padding: 4px 10px; border-radius: 20px;
}
.waveform-bar {
display: flex; align-items: center; justify-content: center; gap: 3px;
height: 24px; padding: 0 4px;
}
.wave-dot { width: 3px; border-radius: 3px; background: var(--accent); }
.wave-dot:nth-child(1) { animation: wave 1.2s ease-in-out infinite 0.00s; }
.wave-dot:nth-child(2) { animation: wave 1.2s ease-in-out infinite 0.10s; }
.wave-dot:nth-child(3) { animation: wave 1.2s ease-in-out infinite 0.20s; }
.wave-dot:nth-child(4) { animation: wave 1.2s ease-in-out infinite 0.10s; }
.wave-dot:nth-child(5) { animation: wave 1.2s ease-in-out infinite 0.05s; }
.wave-dot:nth-child(6) { animation: wave 1.2s ease-in-out infinite 0.15s; }
.wave-dot:nth-child(7) { animation: wave 1.2s ease-in-out infinite 0.25s; }
.wave-dot:nth-child(8) { animation: wave 1.2s ease-in-out infinite 0.08s; }
.wave-dot:nth-child(9) { animation: wave 1.2s ease-in-out infinite 0.18s; }
.wave-dot:nth-child(10) { animation: wave 1.2s ease-in-out infinite 0.12s; }
.wave-dot:nth-child(11) { animation: wave 1.2s ease-in-out infinite 0.22s; }
.wave-dot:nth-child(12) { animation: wave 1.2s ease-in-out infinite 0.06s; }
@keyframes wave { 0%,100%{height:4px;opacity:0.4} 50%{height:20px;opacity:1} }
.waveform-bar.silent .wave-dot { animation: none; height: 4px; opacity: 0.2; }
.service-status {
display: flex; gap: 6px; align-items: center; flex-wrap: wrap;
}
.service-actions { display: flex; align-items: center; gap: 8px; min-height: 22px; }
.btn-health-check {
font-size: 11px; font-weight: 700;
color: var(--blue); background: var(--blue-dim);
border: 1px solid rgba(77,143,255,0.35);
border-radius: 999px; padding: 3px 10px;
}
.service-health-msg { font-size: 11px; color: var(--text-2); }
.status-dot {
display: flex; align-items: center; gap: 4px;
font-size: 10px; font-weight: 600; color: var(--text-3);
background: var(--surface); border: 1px solid var(--border);
border-radius: 20px; padding: 3px 8px;
}
.status-dot::before { content:''; width:6px; height:6px; border-radius:50%; background:var(--text-3); }
.status-dot.ok::before { background: var(--accent); }
.status-dot.connecting::before { background: var(--amber); animation: blink 1s ease infinite; }
.status-dot.error::before { background: var(--red); }
@keyframes blink { 0%,100%{opacity:1} 50%{opacity:0.3} }
/* ══════════════════════════════════════════════
UNIFIED TRANSCRIPT BOX ← the key change
══════════════════════════════════════════════ */
.transcript-wrap {
flex: 1; display: flex; flex-direction: column;
padding: 12px 16px 8px; min-height: 0;
}
.transcript-header {
display: flex; align-items: center; justify-content: space-between;
margin-bottom: 6px;
}
.transcript-header-label {
font-size: 10px; font-weight: 600; letter-spacing: 0.8px;
text-transform: uppercase; color: var(--text-3);
}
.transcript-header-right { display: flex; align-items: center; gap: 8px; }
.feedback-badge {
font-size: 10px; font-weight: 600; color: var(--accent);
background: var(--accent-dim); border: 1px solid rgba(0,200,150,0.25);
border-radius: 999px; padding: 2px 8px;
opacity: 0; transition: opacity 0.3s;
}
.feedback-badge.visible { opacity: 1; }
.btn-clear-transcript {
font-size: 11px; color: var(--text-3);
background: var(--surface); border: 1px solid var(--border);
border-radius: 999px; padding: 2px 10px;
transition: color 0.15s, border-color 0.15s;
}
.btn-clear-transcript:hover { color: var(--red); border-color: rgba(255,75,106,0.4); }
/*
The single box. It is a contenteditable div that:
- shows confirmed text normally
- shows the live partial at the end in a muted colour
- is fully user-editable
We use a thin wrapper + an inner div so we can absolutely-
position a placeholder without fighting contenteditable quirks.
*/
.transcript-box-outer {
flex: 1; position: relative;
background: var(--surface);
border: 1px solid var(--border-2);
border-radius: var(--r-md);
overflow: hidden;
display: flex; flex-direction: column;
}
/* green left glow when live */
.transcript-box-outer.live {
border-color: rgba(0,200,150,0.35);
box-shadow: inset 3px 0 0 0 var(--accent);
}
.transcript-box-outer.editing {
border-color: rgba(77,143,255,0.5);
box-shadow: inset 3px 0 0 0 var(--blue);
}
.transcript-box {
flex: 1;
font-family: var(--mono); font-size: 15px; line-height: 1.75;
color: var(--text);
padding: 12px 14px;
overflow-y: auto; overflow-x: hidden;
scrollbar-width: none;
outline: none;
white-space: pre-wrap; word-break: break-word;
min-height: 0;
}
.transcript-box::-webkit-scrollbar { display: none; }
/* placeholder when empty */
.transcript-placeholder {
position: absolute; top: 12px; left: 14px; right: 14px;
font-family: var(--mono); font-size: 15px; line-height: 1.75;
color: var(--text-3); pointer-events: none;
transition: opacity 0.2s;
}
/* the live partial span β€” appended inside the box, not editable separately */
.partial-span {
color: var(--partial);
}
/* bottom bar inside the box: char count + feedback hint */
.transcript-box-footer {
display: flex; align-items: center; justify-content: space-between;
padding: 6px 14px 8px;
border-top: 1px solid var(--border);
flex-shrink: 0;
}
.char-count { font-size: 11px; color: var(--text-3); font-family: var(--mono); }
.edit-hint { font-size: 11px; color: var(--text-3); }
/* ── PREDICTIONS ── */
.predictions-panel {
padding: 6px 16px 4px; border-top: 1px solid var(--border); flex-shrink: 0;
}
.predictions-label {
font-size: 10px; font-weight: 600; letter-spacing: 0.8px;
text-transform: uppercase; color: var(--text-3); margin-bottom: 5px;
}
.predictions-chips { display: flex; flex-wrap: wrap; gap: 6px; min-height: 30px; }
.prediction-chip {
font-size: 13px; font-weight: 500; color: var(--text);
background: var(--surface-2); border: 1px solid var(--border-2);
border-radius: 999px; padding: 4px 12px; cursor: pointer;
transition: background 0.15s, color 0.15s, border-color 0.15s;
}
.prediction-chip:hover { background: var(--accent-dim); color: var(--accent); border-color: var(--accent); }
.prediction-chip.skeleton { color: transparent; min-width: 64px; background: var(--surface-3); animation: shimmer 1.2s ease infinite; }
@keyframes shimmer { 0%,100%{opacity:0.6} 50%{opacity:1} }
/* ── COMPOSE ── */
.compose-panel { padding: 6px 12px 8px; border-top: 1px solid var(--border); flex-shrink: 0; }
.compose-area {
display: flex; align-items: flex-end; gap: 6px;
background: var(--surface); border: 1px solid var(--border-2);
border-radius: var(--r-lg); padding: 8px 8px 8px 14px;
}
.compose-textarea {
flex: 1; font-size: 14px; line-height: 1.5; color: var(--text);
max-height: 72px; overflow-y: auto; scrollbar-width: none;
min-height: 22px; word-break: break-word;
}
.compose-textarea:empty::before { content: attr(placeholder); color: var(--text-3); }
.compose-textarea::-webkit-scrollbar { display: none; }
.compose-btn {
width: 34px; height: 34px; border-radius: 50%; flex-shrink: 0;
display: flex; align-items: center; justify-content: center;
transition: background 0.15s, color 0.15s;
}
.compose-btn svg { width: 17px; height: 17px; }
.btn-clear-compose { color: var(--text-3); }
.btn-clear-compose:hover { background: var(--red-dim); color: var(--red); }
.btn-speak { background: var(--accent-dim); color: var(--accent); }
.btn-speak:hover { background: var(--accent-glow); }
.btn-speak.speaking { background: var(--accent); color: #000; animation: pulse-btn 1s ease infinite; }
@keyframes pulse-btn { 0%,100%{opacity:1} 50%{opacity:0.7} }
/* ── CALL CONTROLS ── */
.call-controls {
display: flex; align-items: center; justify-content: space-around;
padding: 6px 20px 10px; flex-shrink: 0; border-top: 1px solid var(--border);
}
.ctrl-btn {
display: flex; flex-direction: column; align-items: center; gap: 5px;
padding: 6px 12px; border-radius: var(--r-md);
transition: background 0.15s; cursor: pointer;
}
.ctrl-btn:hover { background: var(--surface-2); }
.ctrl-icon {
width: 44px; height: 44px; border-radius: 50%;
background: var(--surface-2); border: 1px solid var(--border);
display: flex; align-items: center; justify-content: center;
color: var(--text-2); transition: background 0.15s, color 0.15s;
}
.ctrl-icon svg { width: 20px; height: 20px; }
.ctrl-label { font-size: 11px; font-weight: 500; color: var(--text-3); }
.ctrl-btn.active .ctrl-icon { background: var(--accent-dim); color: var(--accent); border-color: rgba(0,200,150,0.3); }
.ctrl-btn.muted .ctrl-icon { background: var(--red-dim); color: var(--red); border-color: rgba(255,75,106,0.3); }
.btn-end-call .ctrl-icon { background: var(--red); color: #fff; border-color: var(--red); }
.btn-end-call:hover .ctrl-icon { background: #ff2244; }
/* ── MIC MODAL ── */
.mic-modal {
position: absolute; inset: 0; z-index: 100;
background: rgba(11,13,18,0.92);
display: none; align-items: flex-end; justify-content: center; padding: 20px;
}
.mic-modal-card {
background: var(--surface); border: 1px solid var(--border-2);
border-radius: var(--r-xl); padding: 28px 24px 24px;
width: 100%; max-width: 380px;
display: flex; flex-direction: column; align-items: center; gap: 16px;
box-shadow: 0 8px 32px rgba(0,0,0,0.6);
}
.mic-modal-icon {
width: 64px; height: 64px; border-radius: 50%;
background: var(--accent-dim); border: 1px solid rgba(0,200,150,0.2);
display: flex; align-items: center; justify-content: center; color: var(--accent);
}
.mic-modal-icon svg { width: 28px; height: 28px; }
.mic-modal-title { font-family: var(--display); font-size: 20px; font-weight: 700; color: var(--text); text-align: center; }
.mic-modal-body { font-size: 14px; line-height: 1.6; color: var(--text-2); text-align: center; }
.btn-allow-mic {
width: 100%; padding: 15px;
background: var(--accent); color: #000;
font-family: var(--display); font-weight: 700; font-size: 16px;
border-radius: var(--r-md); transition: opacity 0.15s, transform 0.1s;
}
.btn-allow-mic:hover { opacity: 0.9; }
.btn-allow-mic:active { transform: scale(0.98); }
.btn-deny-mic { font-size: 14px; color: var(--text-3); padding: 6px 12px; }
.btn-deny-mic:hover { color: var(--text-2); }
/* ── TOAST ── */
#toast {
position: absolute; bottom: 32px; left: 50%; transform: translateX(-50%) translateY(10px);
background: var(--surface-3); border: 1px solid var(--border-2);
color: var(--text); font-size: 13px; font-weight: 500;
padding: 10px 18px; border-radius: 999px; white-space: nowrap;
box-shadow: 0 8px 32px rgba(0,0,0,0.6); z-index: 200;
opacity: 0; pointer-events: none; transition: opacity 0.2s, transform 0.2s;
}
#toast.show { opacity: 1; transform: translateX(-50%) translateY(0); }
.home-indicator { height: 22px; display: flex; align-items: center; justify-content: center; flex-shrink: 0; }
.home-bar { width: 120px; height: 4px; background: var(--border-2); border-radius: 2px; }
.cors-banner {
position: absolute; top: 0; left: 0; right: 0; z-index: 300;
background: var(--amber-dim); border-bottom: 1px solid rgba(255,179,64,0.4);
padding: 10px 16px; display: none; flex-direction: column; gap: 4px;
}
.cors-banner.visible { display: flex; }
.cors-banner-title { font-size: 12px; font-weight: 700; color: var(--amber); }
.cors-banner-body { font-size: 11px; color: var(--text-2); line-height: 1.5; font-family: var(--mono); }
.cors-banner-close { position: absolute; top: 8px; right: 12px; color: var(--text-3); font-size: 16px; cursor: pointer; }
.cors-banner-close:hover { color: var(--amber); }
</style>
</head>
<body>
<div class="phone-wrap">
<div class="phone" id="phone">
<div class="cors-banner" id="cors-banner">
<div class="cors-banner-title">⚠ CORS Configuration Required</div>
<div class="cors-banner-body">Add <strong style="color:var(--amber)">*.hf.space</strong> to your Modal service's allowed origins.</div>
<span class="cors-banner-close" onclick="document.getElementById('cors-banner').classList.remove('visible')">βœ•</span>
</div>
<div class="status-bar">
<span class="time" id="clock">9:41</span>
<div class="status-icons">
<svg viewBox="0 0 24 24" fill="currentColor"><path d="M1.5 8.5C5.5 4.5 10 3 12 3s6.5 1.5 10.5 5.5L21 10.5C17.8 7.3 15 6 12 6S6.2 7.3 3 10.5L1.5 8.5z"/><path d="M4.5 11.5C7.5 8.5 10 7.5 12 7.5s4.5 1 7.5 4L18 13C15.8 10.8 14 10 12 10s-3.8.8-6 3l-1.5-1.5z"/><circle cx="12" cy="17" r="2"/></svg>
<svg viewBox="0 0 24 24" fill="currentColor"><rect x="1" y="7" width="4" height="13" rx="1"/><rect x="6" y="4" width="4" height="16" rx="1"/><rect x="11" y="2" width="4" height="18" rx="1"/><rect x="16" y="0" width="4" height="20" rx="1" opacity="0.3"/></svg>
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5"><rect x="2" y="7" width="18" height="11" rx="2"/><path d="M22 11v3"/><rect x="4" y="9" width="11" height="7" rx="1" fill="currentColor"/></svg>
</div>
</div>
<div class="screens">
<!-- DIALER -->
<div class="screen active" id="screen-dialer">
<div class="top-nav">
<span class="nav-title">30sAI</span>
<div style="width:36px"></div>
</div>
<div class="scroll-area">
<div class="dialer-body">
<div class="recent-label">Recents</div>
<div class="contact-list">
<div class="contact-item" onclick="dialContact('Stanbic Bank Support','0800 601 0203','SB','rgba(0,200,150,0.15)','var(--accent)')">
<div class="avatar" style="background:rgba(0,200,150,0.15);color:var(--accent)">SB</div>
<div class="contact-info">
<div class="contact-name">Stanbic Bank Support</div>
<div class="contact-meta">0800 601 0203 Β· Missed</div>
</div>
<div class="contact-call-btn"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M22 16.92v3a2 2 0 0 1-2.18 2 19.79 19.79 0 0 1-8.63-3.07A19.5 19.5 0 0 1 4.69 12 19.79 19.79 0 0 1 1.61 3.18 2 2 0 0 1 3.59 1h3a2 2 0 0 1 2 1.72c.127.96.361 1.903.7 2.81a2 2 0 0 1-.45 2.11L7.91 8.56a16 16 0 0 0 6 6l.92-.92a2 2 0 0 1 2.11-.45c.907.339 1.85.573 2.81.7A2 2 0 0 1 22 16.92z"/></svg></div>
</div>
<div class="contact-item" onclick="dialContact('MTN Customer Care','0800 165','MT','rgba(255,179,64,0.15)','var(--amber)')">
<div class="avatar" style="background:rgba(255,179,64,0.15);color:var(--amber)">MT</div>
<div class="contact-info">
<div class="contact-name">MTN Customer Care</div>
<div class="contact-meta">0800 165 Β· Yesterday</div>
</div>
<div class="contact-call-btn"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M22 16.92v3a2 2 0 0 1-2.18 2 19.79 19.79 0 0 1-8.63-3.07A19.5 19.5 0 0 1 4.69 12 19.79 19.79 0 0 1 1.61 3.18 2 2 0 0 1 3.59 1h3a2 2 0 0 1 2 1.72c.127.96.361 1.903.7 2.81a2 2 0 0 1-.45 2.11L7.91 8.56a16 16 0 0 0 6 6l.92-.92a2 2 0 0 1 2.11-.45c.907.339 1.85.573 2.81.7A2 2 0 0 1 22 16.92z"/></svg></div>
</div>
<div class="contact-item" onclick="dialContact('Airtel Uganda','0800 100 066','AU','rgba(255,75,106,0.15)','var(--red)')">
<div class="avatar" style="background:rgba(255,75,106,0.15);color:var(--red)">AU</div>
<div class="contact-info">
<div class="contact-name">Airtel Uganda</div>
<div class="contact-meta">0800 100 066 Β· 3 days ago</div>
</div>
<div class="contact-call-btn"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M22 16.92v3a2 2 0 0 1-2.18 2 19.79 19.79 0 0 1-8.63-3.07A19.5 19.5 0 0 1 4.69 12 19.79 19.79 0 0 1 1.61 3.18 2 2 0 0 1 3.59 1h3a2 2 0 0 1 2 1.72c.127.96.361 1.903.7 2.81a2 2 0 0 1-.45 2.11L7.91 8.56a16 16 0 0 0 6 6l.92-.92a2 2 0 0 1 2.11-.45c.907.339 1.85.573 2.81.7A2 2 0 0 1 22 16.92z"/></svg></div>
</div>
</div>
<div class="keypad-display">
<div class="keypad-number empty" id="dial-number">Enter number</div>
<button class="keypad-delete" onclick="dialDelete()"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M21 4H8l-7 8 7 8h13a2 2 0 0 0 2-2V6a2 2 0 0 0-2-2z"/><line x1="18" y1="9" x2="12" y2="15"/><line x1="12" y1="9" x2="18" y2="15"/></svg></button>
</div>
<div class="keypad-grid">
<button class="key" onclick="dialKey('1','')"><span class="key-digit">1</span><span class="key-alpha">&nbsp;</span></button>
<button class="key" onclick="dialKey('2','ABC')"><span class="key-digit">2</span><span class="key-alpha">ABC</span></button>
<button class="key" onclick="dialKey('3','DEF')"><span class="key-digit">3</span><span class="key-alpha">DEF</span></button>
<button class="key" onclick="dialKey('4','GHI')"><span class="key-digit">4</span><span class="key-alpha">GHI</span></button>
<button class="key" onclick="dialKey('5','JKL')"><span class="key-digit">5</span><span class="key-alpha">JKL</span></button>
<button class="key" onclick="dialKey('6','MNO')"><span class="key-digit">6</span><span class="key-alpha">MNO</span></button>
<button class="key" onclick="dialKey('7','PQRS')"><span class="key-digit">7</span><span class="key-alpha">PQRS</span></button>
<button class="key" onclick="dialKey('8','TUV')"><span class="key-digit">8</span><span class="key-alpha">TUV</span></button>
<button class="key" onclick="dialKey('9','WXYZ')"><span class="key-digit">9</span><span class="key-alpha">WXYZ</span></button>
<button class="key" onclick="dialKey('*','')"><span class="key-digit" style="font-size:26px">*</span><span class="key-alpha">&nbsp;</span></button>
<button class="key" onclick="dialKey('0','+')"><span class="key-digit">0</span><span class="key-alpha">+</span></button>
<button class="key" onclick="dialKey('#','')"><span class="key-digit">#</span><span class="key-alpha">&nbsp;</span></button>
</div>
<button class="btn-call" onclick="startCall()">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5"><path d="M22 16.92v3a2 2 0 0 1-2.18 2 19.79 19.79 0 0 1-8.63-3.07A19.5 19.5 0 0 1 4.69 12 19.79 19.79 0 0 1 1.61 3.18 2 2 0 0 1 3.59 1h3a2 2 0 0 1 2 1.72c.127.96.361 1.903.7 2.81a2 2 0 0 1-.45 2.11L7.91 8.56a16 16 0 0 0 6 6l.92-.92a2 2 0 0 1 2.11-.45c.907.339 1.85.573 2.81.7A2 2 0 0 1 22 16.92z"/></svg>
</button>
</div>
</div>
</div>
<!-- ACTIVE CALL -->
<div class="screen" id="screen-call">
<div class="call-header">
<div class="call-contact-row">
<div class="call-avatar avatar" id="call-avatar" style="background:rgba(0,200,150,0.15);color:var(--accent);width:40px;height:40px;font-size:15px">SB</div>
<div class="call-contact-info">
<div class="call-name" id="call-name">Stanbic Bank</div>
<div class="call-status" id="call-status">Connected Β· Active</div>
</div>
<div class="call-timer-badge" id="call-timer">00:00</div>
</div>
<div class="waveform-bar silent" id="waveform">
<div class="wave-dot"></div><div class="wave-dot"></div><div class="wave-dot"></div>
<div class="wave-dot"></div><div class="wave-dot"></div><div class="wave-dot"></div>
<div class="wave-dot"></div><div class="wave-dot"></div><div class="wave-dot"></div>
<div class="wave-dot"></div><div class="wave-dot"></div><div class="wave-dot"></div>
</div>
<div class="service-status">
<span class="status-dot" id="dot-whisper">Whisper</span>
<span class="status-dot" id="dot-gpt2">GPT-2</span>
<span class="status-dot" id="dot-tts">TTS</span>
<span class="status-dot" id="dot-feedback">Feedback</span>
</div>
<div class="service-actions">
<button class="btn-health-check" onclick="checkServiceHealth()">Check APIs</button>
<span class="service-health-msg" id="service-health-msg">Not checked yet</span>
</div>
</div>
<!-- ══ UNIFIED TRANSCRIPT BOX ══ -->
<div class="transcript-wrap">
<div class="transcript-header">
<span class="transcript-header-label">Transcript</span>
<div class="transcript-header-right">
<span class="feedback-badge" id="feedback-badge">Saved βœ“</span>
<button class="btn-clear-transcript" onclick="clearTranscript()">Clear</button>
</div>
</div>
<div class="transcript-box-outer" id="transcript-box-outer">
<div class="transcript-placeholder" id="transcript-placeholder">
Start speaking β€” your words will appear here.<br>
<span style="font-size:12px;color:var(--text-3)">Edit anything you see to send feedback.</span>
</div>
<!--
The box is a single contenteditable div.
Confirmed text lives as plain text nodes.
The live partial is the last child <span class="partial-span">.
On every input event we diff against the last-known confirmed
text and fire a feedback action over the WebSocket.
-->
<div class="transcript-box"
id="transcript-box"
contenteditable="true"
spellcheck="false"
autocorrect="off"
autocapitalize="off"
oninput="onTranscriptInput()"
onkeydown="onTranscriptKeydown(event)"></div>
<div class="transcript-box-footer">
<span class="char-count" id="char-count">0 chars</span>
<span class="edit-hint">Edit to correct Β· feedback auto-sends</span>
</div>
</div>
</div>
<!-- PREDICTIONS -->
<div class="predictions-panel">
<div class="predictions-label">Predicted next</div>
<div class="predictions-chips" id="predictions-chips"></div>
</div>
<!-- COMPOSE -->
<div class="compose-panel">
<div class="compose-area">
<div class="compose-textarea" id="compose-text"
contenteditable="true" placeholder="Type a reply…"
onkeydown="composeKeydown(event)"
oninput="composeChanged()"></div>
<button class="compose-btn btn-clear-compose" onclick="clearCompose()">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg>
</button>
<button class="compose-btn btn-speak" id="btn-speak" onclick="speakCompose()">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polygon points="11 5 6 9 2 9 2 15 6 15 11 19 11 5"/><path d="M15.54 8.46a5 5 0 0 1 0 7.07M19.07 4.93a10 10 0 0 1 0 14.14"/></svg>
</button>
</div>
</div>
<!-- CALL CONTROLS -->
<div class="call-controls">
<div class="ctrl-btn" id="ctrl-mute" onclick="toggleMute()">
<div class="ctrl-icon">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" id="mute-icon">
<path d="M12 1a3 3 0 0 0-3 3v8a3 3 0 0 0 6 0V4a3 3 0 0 0-3-3z"/>
<path d="M19 10v2a7 7 0 0 1-14 0v-2M12 19v4M8 23h8"/>
</svg>
</div>
<span class="ctrl-label" id="mute-label">Mic</span>
</div>
<div class="ctrl-btn" id="ctrl-speaker" onclick="toggleSpeaker()">
<div class="ctrl-icon">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<polygon points="11 5 6 9 2 9 2 15 6 15 11 19 11 5"/>
<path d="M15.54 8.46a5 5 0 0 1 0 7.07"/>
</svg>
</div>
<span class="ctrl-label">Speaker</span>
</div>
<div class="ctrl-btn btn-end-call" onclick="endCall()">
<div class="ctrl-icon">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5">
<path d="M10.68 13.31a16 16 0 0 0 3.41 2.6l1.27-1.27a2 2 0 0 1 2.11-.45c.907.339 1.85.573 2.81.7A2 2 0 0 1 22 16.92v3a2 2 0 0 1-2.18 2 19.79 19.79 0 0 1-8.63-3.07A19.42 19.42 0 0 1 4.88 12a19.73 19.73 0 0 1-3.06-8.67A2 2 0 0 1 3.59 1h3a2 2 0 0 1 2 1.72c.127.96.361 1.903.7 2.81a2 2 0 0 1-.45 2.11L7.91 8.56"/>
<line x1="23" y1="1" x2="1" y2="23"/>
</svg>
</div>
<span class="ctrl-label">End</span>
</div>
</div>
</div>
</div>
<div class="home-indicator"><div class="home-bar"></div></div>
<!-- MIC MODAL -->
<div class="mic-modal" id="mic-permission-modal">
<div class="mic-modal-card">
<div class="mic-modal-icon">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M12 1a3 3 0 0 0-3 3v8a3 3 0 0 0 6 0V4a3 3 0 0 0-3-3z"/>
<path d="M19 10v2a7 7 0 0 1-14 0v-2M12 19v4M8 23h8"/>
</svg>
</div>
<div class="mic-modal-title">Allow Microphone Access</div>
<div class="mic-modal-body">30sAI needs your microphone to transcribe speech in real time. Your audio is processed securely and never stored.</div>
<button class="btn-allow-mic" id="btn-allow-mic" onclick="grantMicPermission()">Allow Microphone</button>
<button class="btn-deny-mic" onclick="denyMicPermission()">Not now</button>
</div>
</div>
<div id="toast"></div>
</div>
</div>
<script>
/* ══════════════════════════════════════════════════════════════════
ENDPOINTS
══════════════════════════════════════════════════════════════════ */
const _EP = Object.freeze({
whisperWs: 'wss://nabacwamariajema--streaming-whisper-severity-streamingwh-84e61e.modal.run/ws',
gpt2: 'https://nabacwamariajema--gpt2-service-v2-gpt2service-web.modal.run',
tts: 'https://nabacwamariajema--tts-service-ttsservice-web.modal.run',
feedback: 'https://nabacwamariajema--feedback-service-feedbackservice-web.modal.run',
});
const HF_TOKEN = (typeof window !== 'undefined' && window.huggingface?.variables?.HF_TOKEN) || null;
async function hfFetch(url, options = {}) {
const headers = {
...(options.headers || {}),
...(HF_TOKEN ? { 'Authorization': `Bearer ${HF_TOKEN}` } : {}),
};
try {
return await fetch(url, { ...options, headers, mode: 'cors' });
} catch (err) {
const msg = String(err);
if (msg.includes('Failed to fetch') || msg.includes('NetworkError') || msg.includes('CORS')) {
showToast('⚠ CORS blocked β€” add *.hf.space to Modal allowed origins');
document.getElementById('cors-banner').classList.add('visible');
}
throw err;
}
}
/* ══════════════════════════════════════════════════════════════════
STATE
══════════════════════════════════════════════════════════════════ */
const state = {
dialNumber: '', callContact: {},
callActive: false, callTimer: null, callSeconds: 0,
muted: false, speakerOn: false,
ws: null, micStream: null, audioCtx: null, scriptProc: null,
audioBuffer: [],
/* ── transcript state ── */
confirmedText: '', // all text that has been finalised by Whisper
partialText: '', // the live in-progress partial from Whisper
userEditedText: '', // what the user last manually typed (for diffing)
feedbackTimer: null,
/* ── other ── */
predictionTimer: null, isSpeaking: false, _pendingStream: null,
};
/* ── Clock ── */
function updateClock() {
const now = new Date(), h = now.getHours(), m = now.getMinutes();
document.getElementById('clock').textContent = `${h%12||12}:${String(m).padStart(2,'0')}`;
}
updateClock(); setInterval(updateClock, 15000);
/* ══════════════════════════════════════════════════════════════════
SCREEN NAV
══════════════════════════════════════════════════════════════════ */
function goTo(name) {
document.querySelectorAll('.screen').forEach(s => {
s.classList.remove('active','exit-left');
if (!s.id.endsWith(name)) s.classList.add('exit-left');
});
setTimeout(() => {
document.querySelectorAll('.screen').forEach(s => s.classList.remove('exit-left'));
document.getElementById('screen-'+name).classList.add('active');
}, 10);
}
/* ══════════════════════════════════════════════════════════════════
DIALER
══════════════════════════════════════════════════════════════════ */
function dialKey(digit) { state.dialNumber += digit; refreshDialDisplay(); }
function dialDelete() { state.dialNumber = state.dialNumber.slice(0,-1); refreshDialDisplay(); }
function refreshDialDisplay() {
const el = document.getElementById('dial-number');
if (state.dialNumber) { el.textContent = state.dialNumber; el.classList.remove('empty'); }
else { el.textContent = 'Enter number'; el.classList.add('empty'); }
}
function dialContact(name, number, initials, bg, fg) {
state.callContact = { name, number, initials, bg, fg };
state.dialNumber = number;
startCall();
}
/* ══════════════════════════════════════════════════════════════════
CALL LIFECYCLE
══════════════════════════════════════════════════════════════════ */
function startCall() {
if (!state.dialNumber && !state.callContact.number) { showToast('Enter a number first'); return; }
const num = state.callContact.number || state.dialNumber;
const name = state.callContact.name || num;
const init = state.callContact.initials || name.slice(0,2).toUpperCase();
const bg = state.callContact.bg || 'rgba(0,200,150,0.15)';
const fg = state.callContact.fg || 'var(--accent)';
const avatar = document.getElementById('call-avatar');
avatar.textContent = init; avatar.style.background = bg; avatar.style.color = fg;
document.getElementById('call-name').textContent = name;
document.getElementById('call-status').textContent = 'Connected Β· Active';
resetTranscriptState();
goTo('call');
state.callSeconds = 0;
state.callTimer = setInterval(() => {
state.callSeconds++;
const m = String(Math.floor(state.callSeconds/60)).padStart(2,'0');
const s = String(state.callSeconds%60).padStart(2,'0');
document.getElementById('call-timer').textContent = `${m}:${s}`;
}, 1000);
state.callActive = true;
updateServiceDots();
requestMicPermission();
}
function endCall() {
state.callActive = false;
clearInterval(state.callTimer); state.callTimer = null;
disconnectWhisper(); stopMic(); setWaveform(false);
goTo('dialer');
state.dialNumber = ''; refreshDialDisplay();
state.callContact = {};
showToast('Call ended');
}
/* ══════════════════════════════════════════════════════════════════
CONTROLS
══════════════════════════════════════════════════════════════════ */
function toggleMute() {
state.muted = !state.muted;
if (state.micStream) state.micStream.getAudioTracks().forEach(t => t.enabled = !state.muted);
const btn = document.getElementById('ctrl-mute');
const lbl = document.getElementById('mute-label');
const icon = document.getElementById('mute-icon');
btn.classList.toggle('muted', state.muted);
lbl.textContent = state.muted ? 'Unmute' : 'Mic';
icon.innerHTML = state.muted
? '<line x1="1" y1="1" x2="23" y2="23"/><path d="M9 9v3a3 3 0 0 0 5.12 2.12M15 9.34V4a3 3 0 0 0-5.94-.6"/><path d="M17 16.95A7 7 0 0 1 5 12v-2m14 0v2a7 7 0 0 1-.11 1.23M12 19v4m-4 0h8"/>'
: '<path d="M12 1a3 3 0 0 0-3 3v8a3 3 0 0 0 6 0V4a3 3 0 0 0-3-3z"/><path d="M19 10v2a7 7 0 0 1-14 0v-2M12 19v4M8 23h8"/>';
setWaveform(!state.muted);
showToast(state.muted ? 'πŸ”‡ Muted' : '🎀 Unmuted');
}
function toggleSpeaker() {
state.speakerOn = !state.speakerOn;
document.getElementById('ctrl-speaker').classList.toggle('active', state.speakerOn);
showToast(state.speakerOn ? 'πŸ”Š Speaker on' : 'πŸ“± Earpiece');
}
/* ══════════════════════════════════════════════════════════════════
MIC PERMISSION
══════════════════════════════════════════════════════════════════ */
function requestMicPermission() { document.getElementById('mic-permission-modal').style.display = 'flex'; }
async function grantMicPermission() {
const btn = document.getElementById('btn-allow-mic');
btn.textContent = 'Requesting…'; btn.style.opacity = '0.7'; btn.style.pointerEvents = 'none';
try {
const stream = await navigator.mediaDevices.getUserMedia({audio:{channelCount:1,echoCancellation:true,noiseSuppression:true}});
state._pendingStream = stream;
document.getElementById('mic-permission-modal').style.display = 'none';
btn.textContent = 'Allow Microphone'; btn.style.opacity = '1'; btn.style.pointerEvents = 'auto';
connectWhisper();
} catch(e) {
btn.textContent = 'Allow Microphone'; btn.style.opacity = '1'; btn.style.pointerEvents = 'auto';
document.getElementById('mic-permission-modal').style.display = 'none';
showToast('⚠ Microphone access denied'); setDot('whisper','error');
}
}
function denyMicPermission() { document.getElementById('mic-permission-modal').style.display='none'; endCall(); }
/* ══════════════════════════════════════════════════════════════════
WHISPER WEBSOCKET
══════════════════════════════════════════════════════════════════ */
const TARGET_SR = 16000, CHUNK_MS = 250;
function connectWhisper() {
setDot('whisper','connecting');
try {
state.ws = new WebSocket(_EP.whisperWs);
state.ws.binaryType = 'arraybuffer';
state.ws.onopen = () => { setDot('whisper','ok'); startMic(state._pendingStream); state._pendingStream = null; };
state.ws.onmessage = (ev) => {
if (ev.data instanceof ArrayBuffer) return; // ignore binary (WAV for agent)
try { const msg = JSON.parse(ev.data); handleWsMessage(msg); } catch(e) {}
};
state.ws.onerror = () => { setDot('whisper','error'); showToast('⚠ Whisper connection error'); };
state.ws.onclose = () => { if (state.callActive) setDot('whisper','error'); };
} catch(e) { setDot('whisper','error'); showToast('⚠ Could not connect to Whisper'); }
}
function disconnectWhisper() {
if (state.ws) { try { state.ws.send(JSON.stringify({action:'finalize'})); state.ws.close(); } catch(e) {} state.ws = null; }
}
function handleWsMessage(msg) {
if (msg.type === 'ping') { /* heartbeat β€” ignore */ return; }
if (msg.type === 'done') { state.partialText = ''; renderTranscriptBox(); return; }
if (msg.type === 'feedback_ack') { flashFeedbackBadge('Saved βœ“'); setDot('feedback','ok'); return; }
if (msg.error) { showToast('⚠ ' + msg.error); return; }
const text = (msg.text || '').trim();
if (!text) return;
if (msg.is_partial) {
state.partialText = text;
renderTranscriptBox();
} else {
// Confirmed segment β€” append to confirmedText with a space separator
state.confirmedText = (state.confirmedText + (state.confirmedText ? ' ' : '') + text).trim();
state.partialText = '';
// Sync userEditedText so we don't immediately fire a spurious feedback
state.userEditedText = state.confirmedText;
renderTranscriptBox();
if (true /*cfg.autoPredict*/) schedulePredictions(text);
}
}
/* ── Mic + Audio Pipeline ── */
async function startMic(existingStream) {
try {
const stream = existingStream || await navigator.mediaDevices.getUserMedia({audio:{channelCount:1,echoCancellation:true,noiseSuppression:true}});
state.micStream = stream;
state.audioCtx = new (window.AudioContext || window.webkitAudioContext)();
const src = state.audioCtx.createMediaStreamSource(stream);
const nativeSR = state.audioCtx.sampleRate;
const bufSize = Math.floor(nativeSR * CHUNK_MS / 1000);
const proc = state.audioCtx.createScriptProcessor(4096,1,1);
let accum = new Float32Array(0);
proc.onaudioprocess = (ev) => {
if (!state.callActive || state.muted) return;
const input = ev.inputBuffer.getChannelData(0);
let energy = 0; for (let i=0;i<input.length;i++) energy += input[i]*input[i];
setWaveformLevel(Math.sqrt(energy/input.length));
const resampled = resampleLinear(input, nativeSR, TARGET_SR);
state.audioBuffer.push(...Array.from(resampled));
if (state.audioBuffer.length > 160000) state.audioBuffer.splice(0, state.audioBuffer.length-160000);
const next = new Float32Array(accum.length + resampled.length);
next.set(accum); next.set(resampled, accum.length); accum = next;
if (state.ws && state.ws.readyState === WebSocket.OPEN && accum.length >= bufSize) {
const toSend = accum.slice(0, bufSize); accum = accum.slice(bufSize);
state.ws.send(floatToInt16(toSend).buffer);
}
};
src.connect(proc); proc.connect(state.audioCtx.destination);
state.scriptProc = proc; setWaveform(true); setDot('whisper','ok');
} catch(e) { showToast('⚠ Microphone access denied'); setDot('whisper','error'); }
}
function stopMic() {
if (state.scriptProc) { state.scriptProc.disconnect(); state.scriptProc = null; }
if (state.audioCtx) { state.audioCtx.close(); state.audioCtx = null; }
if (state.micStream) { state.micStream.getTracks().forEach(t => t.stop()); state.micStream = null; }
}
function resampleLinear(input, fromSR, toSR) {
if (fromSR === toSR) return input;
const ratio = fromSR/toSR, out = new Float32Array(Math.floor(input.length/ratio));
for (let i=0;i<out.length;i++) {
const pos=i*ratio, idx=Math.floor(pos), frac=pos-idx;
out[i] = idx+1 < input.length ? input[idx]*(1-frac)+input[idx+1]*frac : input[idx];
}
return out;
}
function floatToInt16(f32) {
const i16 = new Int16Array(f32.length);
for (let i=0;i<f32.length;i++) i16[i] = Math.max(-32768, Math.min(32767, Math.round(f32[i]*32767)));
return i16;
}
/* ══════════════════════════════════════════════════════════════════
UNIFIED TRANSCRIPT BOX
─────────────────────────────────────────────────────────────────
The box contains a single contenteditable div.
We never reconstruct it from scratch during a partial update β€”
instead we locate or create the partial <span> and update only it,
leaving the user's cursor position intact for confirmed text.
══════════════════════════════════════════════════════════════════ */
function resetTranscriptState() {
state.confirmedText = '';
state.partialText = '';
state.userEditedText = '';
clearTimeout(state.feedbackTimer);
const box = document.getElementById('transcript-box');
box.innerHTML = '';
updatePlaceholder();
updateCharCount();
setBoxMode('idle');
document.getElementById('predictions-chips').innerHTML = '';
clearCompose();
}
function clearTranscript() {
const prev = getBoxConfirmedText();
resetTranscriptState();
// If there was content, send feedback that the user cleared it
if (prev.trim()) sendFeedbackNow(prev, '');
}
/*
renderTranscriptBox β€” called whenever confirmedText or partialText changes
due to Whisper output (NOT user edits).
Strategy:
1. Find or create a <span class="partial-span"> as the last child.
2. Everything before that span = confirmed text (plain text node).
3. Update the text node to match state.confirmedText.
4. Update the span to match state.partialText.
5. Scroll to bottom.
We avoid setting innerHTML or innerText on the whole box so we don't
reset the user's cursor or trigger spurious input events.
*/
function renderTranscriptBox() {
const box = document.getElementById('transcript-box');
// Find or build the confirmed text node and partial span
let confirmedNode = null;
let partialSpan = null;
for (const child of Array.from(box.childNodes)) {
if (child.nodeType === Node.ELEMENT_NODE && child.classList.contains('partial-span')) {
partialSpan = child;
} else if (child.nodeType === Node.TEXT_NODE && !partialSpan) {
confirmedNode = child; // take the last text node before the span
}
}
// Set confirmed text node
const confirmedContent = state.confirmedText + (state.confirmedText && state.partialText ? ' ' : '');
if (!confirmedNode) {
confirmedNode = document.createTextNode(confirmedContent);
box.insertBefore(confirmedNode, partialSpan || null);
} else {
if (confirmedNode.textContent !== confirmedContent) {
confirmedNode.textContent = confirmedContent;
}
}
// Set partial span
if (state.partialText) {
if (!partialSpan) {
partialSpan = document.createElement('span');
partialSpan.className = 'partial-span';
box.appendChild(partialSpan);
}
partialSpan.textContent = state.partialText;
} else if (partialSpan) {
partialSpan.remove();
}
updatePlaceholder();
updateCharCount();
setBoxMode(state.partialText ? 'live' : 'idle');
box.scrollTop = box.scrollHeight;
}
/* getBoxConfirmedText β€” reads only the non-partial text from the box */
function getBoxConfirmedText() {
const box = document.getElementById('transcript-box');
let text = '';
for (const child of Array.from(box.childNodes)) {
if (child.nodeType === Node.TEXT_NODE) text += child.textContent;
else if (child.nodeType === Node.ELEMENT_NODE && !child.classList.contains('partial-span')) {
text += child.textContent;
}
}
return text;
}
/* getBoxFullText β€” confirmed + partial for display/char-count */
function getBoxFullText() {
return document.getElementById('transcript-box').innerText || '';
}
function updatePlaceholder() {
const hasContent = state.confirmedText || state.partialText;
document.getElementById('transcript-placeholder').style.opacity = hasContent ? '0' : '1';
}
function updateCharCount() {
const n = getBoxFullText().length;
document.getElementById('char-count').textContent = `${n} char${n===1?'':'s'}`;
}
function setBoxMode(mode) {
const outer = document.getElementById('transcript-box-outer');
outer.classList.toggle('live', mode === 'live');
outer.classList.toggle('editing', mode === 'editing');
}
/* ── User edits the box ──────────────────────────────────────────
When the user types, we:
1. Remove the partial span (they're correcting confirmed text now)
2. Capture what they wrote as the new confirmed text
3. Debounce β†’ send feedback action over the WS
*/
function onTranscriptInput() {
// After a user edit, remove any partial span β€” they've taken ownership
const box = document.getElementById('transcript-box');
const partialSpan = box.querySelector('.partial-span');
if (partialSpan) {
state.partialText = '';
partialSpan.remove();
}
const currentText = getBoxConfirmedText().trim();
updatePlaceholder();
updateCharCount();
setBoxMode('editing');
// Debounce feedback β€” wait 800 ms after the user stops typing
clearTimeout(state.feedbackTimer);
state.feedbackTimer = setTimeout(() => {
const original = state.confirmedText;
const corrected = currentText;
if (corrected !== original) {
// Update our record so subsequent Whisper appends are relative to the edit
state.confirmedText = corrected;
state.userEditedText = corrected;
sendFeedbackNow(original, corrected);
}
setBoxMode('idle');
}, 800);
}
function onTranscriptKeydown(e) {
// Allow all editing keys; just prevent Enter from inserting <div> on some browsers
if (e.key === 'Enter') { e.preventDefault(); document.execCommand('insertText', false, '\n'); }
}
/* ── Send feedback over the existing WebSocket ─────────────────── */
function sendFeedbackNow(original, corrected) {
setDot('feedback', 'connecting');
const payload = JSON.stringify({ action:'feedback', original, corrected });
if (state.ws && state.ws.readyState === WebSocket.OPEN) {
state.ws.send(payload);
// ack comes back as feedback_ack message β€” handled in handleWsMessage
} else {
// Fallback: POST to the HTTP feedback endpoint if WS is not available
hfFetch(_EP.feedback, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ original, corrected, audio_b64: getRecentAudioB64() }),
}).then(r => r.json()).then(d => {
setDot('feedback','ok');
flashFeedbackBadge(d.message || 'Saved βœ“');
}).catch(() => setDot('feedback','error'));
}
}
function flashFeedbackBadge(text) {
const badge = document.getElementById('feedback-badge');
badge.textContent = text;
badge.classList.add('visible');
setTimeout(() => badge.classList.remove('visible'), 2000);
}
function getRecentAudioB64() {
const samples = state.audioBuffer.slice(-80000);
const int16 = new Int16Array(samples.length);
for (let i=0;i<samples.length;i++) int16[i] = Math.max(-32768, Math.min(32767, Math.round(samples[i]*32767)));
const bytes = new Uint8Array(int16.buffer);
let binary = ''; bytes.forEach(b => binary += String.fromCharCode(b));
return btoa(binary);
}
/* ══════════════════════════════════════════════════════════════════
GPT-2 PREDICTIONS
══════════════════════════════════════════════════════════════════ */
function schedulePredictions(text) {
clearTimeout(state.predictionTimer);
state.predictionTimer = setTimeout(() => fetchPredictions(text), 300);
}
async function fetchPredictions(text) {
setDot('gpt2','connecting');
const chips = document.getElementById('predictions-chips');
chips.innerHTML = Array(4).fill('<div class="prediction-chip skeleton">…</div>').join('');
try {
const u = new URL(_EP.gpt2);
u.pathname = u.pathname.replace(/\/?$/,'/predict');
u.searchParams.set('text', text); u.searchParams.set('n', '4');
const res = await hfFetch(u.toString());
const data = await res.json();
const preds = Array.isArray(data) ? data : (data.predictions || data.result || []);
setDot('gpt2','ok');
chips.innerHTML = '';
preds.slice(0,4).forEach(p => {
const chip = document.createElement('button');
chip.className = 'prediction-chip'; chip.textContent = p;
chip.onclick = () => appendToCompose(p);
chips.appendChild(chip);
});
} catch(e) { setDot('gpt2','error'); chips.innerHTML = ''; }
}
/* ══════════════════════════════════════════════════════════════════
COMPOSE & TTS
══════════════════════════════════════════════════════════════════ */
function appendToCompose(text) {
const el = document.getElementById('compose-text');
const curr = el.innerText.trim();
el.innerText = curr ? curr + ' ' + text : text;
const r = document.createRange(), s = window.getSelection();
r.selectNodeContents(el); r.collapse(false); s.removeAllRanges(); s.addRange(r);
}
function clearCompose() { document.getElementById('compose-text').innerText = ''; }
function composeKeydown(e) { if (e.key==='Enter' && !e.shiftKey) { e.preventDefault(); speakCompose(); } }
function composeChanged() {
const text = document.getElementById('compose-text').innerText.trim();
if (text.length > 2) schedulePredictions(text);
}
async function speakCompose() {
const text = document.getElementById('compose-text').innerText.trim();
if (!text) { showToast('Nothing to speak'); return; }
if (state.isSpeaking) { stopSpeaking(); return; }
state.isSpeaking = true;
document.getElementById('btn-speak').classList.add('speaking');
try {
setDot('tts','connecting');
const res = await hfFetch(_EP.tts, { method:'POST', headers:{'Content-Type':'application/json'}, body: JSON.stringify({text}) });
const data = await res.json();
setDot('tts','ok');
if (data.audio_b64) {
const bytes = Uint8Array.from(atob(data.audio_b64), c => c.charCodeAt(0));
const ctx = new AudioContext();
const buf = await ctx.decodeAudioData(bytes.buffer);
const src = ctx.createBufferSource();
src.buffer = buf; src.connect(ctx.destination);
await new Promise((res,rej) => { src.onended=res; src.onerror=rej; src.start(0); });
}
} catch(e) {
setDot('tts', 'ok');
if ('speechSynthesis' in window) {
const utt = new SpeechSynthesisUtterance(text);
utt.rate = 0.95; await new Promise(r => { utt.onend=r; utt.onerror=r; speechSynthesis.speak(utt); });
} else { showToast('⚠ TTS unavailable'); }
}
state.isSpeaking = false;
document.getElementById('btn-speak').classList.remove('speaking');
}
function stopSpeaking() {
if ('speechSynthesis' in window) speechSynthesis.cancel();
state.isSpeaking = false; document.getElementById('btn-speak').classList.remove('speaking');
}
/* ══════════════════════════════════════════════════════════════════
SERVICE DOTS & HEALTH
══════════════════════════════════════════════════════════════════ */
function setDot(service, status) { const el = document.getElementById('dot-'+service); if (el) el.className = 'status-dot '+status; }
function updateServiceDots() { setDot('whisper','connecting'); setDot('gpt2','connecting'); setDot('tts','ok'); setDot('feedback','connecting'); }
async function checkServiceHealth() {
document.getElementById('service-health-msg').textContent = 'Checking…';
const r = { whisper:false, gpt2:false, feedback:false };
try {
setDot('whisper','connecting');
const u = new URL(_EP.whisperWs); u.protocol = u.protocol==='wss:'?'https:':'http:'; u.pathname='/health'; u.search='';
const res = await hfFetch(u.toString()); r.whisper = res.ok; setDot('whisper', res.ok?'ok':'error');
} catch { setDot('whisper','error'); }
try {
setDot('gpt2','connecting');
const u = new URL(_EP.gpt2); u.pathname = u.pathname.replace(/\/?$/,'/health');
const res = await hfFetch(u.toString()); r.gpt2 = res.ok; setDot('gpt2', res.ok?'ok':'error');
} catch { setDot('gpt2','error'); }
try {
setDot('feedback','connecting');
const res = await hfFetch(_EP.feedback); r.feedback = res.ok || [405,422].includes(res.status);
setDot('feedback', r.feedback?'ok':'error');
} catch { setDot('feedback','error'); }
const ok = Object.values(r).filter(Boolean).length;
document.getElementById('service-health-msg').textContent =
`${ok}/3 ready Β· W:${r.whisper?'OK':'ERR'} G:${r.gpt2?'OK':'ERR'} F:${r.feedback?'OK':'ERR'}`;
showToast(ok===3 ? 'Services ready βœ“' : 'Some services failed');
}
/* ══════════════════════════════════════════════════════════════════
WAVEFORM
══════════════════════════════════════════════════════════════════ */
function setWaveform(active) { document.getElementById('waveform').classList.toggle('silent',!active); }
function setWaveformLevel(rms) {
const level = Math.min(rms*8,1);
document.querySelectorAll('.wave-dot').forEach(d => {
const r = 0.3 + Math.random()*0.7*level;
d.style.height = Math.max(4, Math.round(r*20))+'px';
d.style.opacity = 0.3 + r*0.7;
});
}
/* ══════════════════════════════════════════════════════════════════
TOAST
══════════════════════════════════════════════════════════════════ */
let toastTimer = null;
function showToast(msg) {
const el = document.getElementById('toast');
el.textContent = msg; el.classList.add('show');
clearTimeout(toastTimer); toastTimer = setTimeout(() => el.classList.remove('show'), 2500);
}
/* ── Boot ── */
goTo('dialer');
setTimeout(() => checkServiceHealth(), 400);
</script>
</body>
</html>