Spaces:
Running
Running
| <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"> </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"> </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"> </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> |