Replace web_demo.html with omni_o_web.html: self-contained frontend for omni-o call
Browse filesNew frontend features:
- Chat mode (SSE /chat): text messages with audio playback
- Call mode (WebSocket /ws/realtime): real-time voice with VAD orb,
animated audio visualizer, interruption feedback
- Camera integration: periodic snapshots as visual context
- Voice cloning: record via browser MediaRecorder, clone via
/clone_voice and /delete_voice endpoints
- History preserved between turns
- scripts/omni_o_call.py +2 -2
- scripts/omni_o_web.html +585 -0
scripts/omni_o_call.py
CHANGED
|
@@ -126,9 +126,9 @@ def init_web_app():
|
|
| 126 |
sock = Sock(app)
|
| 127 |
|
| 128 |
@app.route('/')
|
| 129 |
-
def index(): return send_from_directory('.', '
|
| 130 |
@app.route('/call')
|
| 131 |
-
def call_page(): return send_from_directory('.', '
|
| 132 |
|
| 133 |
@app.route('/voices')
|
| 134 |
def get_voices():
|
|
|
|
| 126 |
sock = Sock(app)
|
| 127 |
|
| 128 |
@app.route('/')
|
| 129 |
+
def index(): return send_from_directory('.', 'omni_o_web.html')
|
| 130 |
@app.route('/call')
|
| 131 |
+
def call_page(): return send_from_directory('.', 'omni_o_web.html')
|
| 132 |
|
| 133 |
@app.route('/voices')
|
| 134 |
def get_voices():
|
scripts/omni_o_web.html
ADDED
|
@@ -0,0 +1,585 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
<!DOCTYPE html>
|
| 2 |
+
<html lang="zh">
|
| 3 |
+
<head>
|
| 4 |
+
<meta charset="UTF-8">
|
| 5 |
+
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
| 6 |
+
<title>Omni-O Voice Call</title>
|
| 7 |
+
<style>
|
| 8 |
+
*{margin:0;padding:0;box-sizing:border-box}
|
| 9 |
+
html,body{height:100%}
|
| 10 |
+
body{background:#1a1a1e;color:#e8e8ed;font-family:system-ui,-apple-system,sans-serif;display:flex;justify-content:center;overflow:hidden}
|
| 11 |
+
.app{max-width:800px;width:100%;display:flex;flex-direction:column;padding:16px 20px;height:100vh}
|
| 12 |
+
|
| 13 |
+
/* top bar */
|
| 14 |
+
.top{display:flex;align-items:center;justify-content:space-between;padding:0 0 10px;flex-shrink:0;gap:8px}
|
| 15 |
+
.top h1{font-size:1.05rem;font-weight:600;color:#fff}
|
| 16 |
+
.top .sub{font-size:.68rem;color:#888}
|
| 17 |
+
.top-right{display:flex;gap:6px;align-items:center}
|
| 18 |
+
.btn{padding:5px 10px;font-size:.72rem;cursor:pointer;background:#2a2a30;border:1px solid #444;color:#e8e8ed;border-radius:4px;font-family:inherit}
|
| 19 |
+
.btn:hover:not(:disabled){background:#35353d;border-color:#666}
|
| 20 |
+
.btn:disabled{opacity:.4;cursor:not-allowed}
|
| 21 |
+
.btn.active{background:#2a4a6a;border-color:#4a8aba}
|
| 22 |
+
.btn.danger{color:#d84545;border-color:#6a3a3a}
|
| 23 |
+
select{font-size:.72rem;background:#2a2a30;border:1px solid #444;color:#e8e8ed;padding:4px 6px;border-radius:4px;outline:none;font-family:inherit}
|
| 24 |
+
select:focus{border-color:#666}
|
| 25 |
+
|
| 26 |
+
/* chat area */
|
| 27 |
+
.chat{flex:1;overflow-y:auto;padding:8px 0;display:flex;flex-direction:column;gap:4px;min-height:0}
|
| 28 |
+
.msg{display:flex;gap:8px;padding:8px 10px;border-radius:6px;max-width:85%;animation:fadeIn .2s}
|
| 29 |
+
.msg.user{align-self:flex-end;flex-direction:row-reverse;background:#2a4a6a}
|
| 30 |
+
.msg.assistant{background:#2a2a30}
|
| 31 |
+
.msg .role{font-size:.65rem;color:#888;flex-shrink:0;min-width:40px;padding-top:2px}
|
| 32 |
+
.msg .content{font-size:.85rem;line-height:1.5;word-break:break-word}
|
| 33 |
+
.msg .content.typing::after{content:'|';animation:blink .8s infinite}
|
| 34 |
+
@keyframes blink{50%{opacity:0}}
|
| 35 |
+
@keyframes fadeIn{from{opacity:0;transform:translateY(4px)}to{opacity:1;transform:translateY(0)}}
|
| 36 |
+
|
| 37 |
+
/* input area */
|
| 38 |
+
.input-bar{display:flex;gap:6px;padding:8px 0 4px;flex-shrink:0;align-items:center}
|
| 39 |
+
.input-bar input{flex:1;padding:8px 10px;background:#2a2a30;border:1px solid #444;border-radius:4px;color:#e8e8ed;font-size:.82rem;outline:none;font-family:inherit}
|
| 40 |
+
.input-bar input:focus{border-color:#666}
|
| 41 |
+
.tab-bar{display:flex;gap:4px;padding:4px 0;flex-shrink:0}
|
| 42 |
+
.tab-btn{padding:4px 14px;font-size:.72rem;cursor:pointer;background:transparent;border:none;border-bottom:2px solid transparent;color:#888;font-family:inherit}
|
| 43 |
+
.tab-btn.active{color:#e8e8ed;border-bottom-color:#4a8aba}
|
| 44 |
+
.tab-content{display:none}
|
| 45 |
+
.tab-content.active{display:flex;flex-direction:column;flex:1;min-height:0}
|
| 46 |
+
|
| 47 |
+
/* orb */
|
| 48 |
+
.orb-wrap{display:flex;justify-content:center;align-items:center;padding:20px 0;flex-shrink:0}
|
| 49 |
+
.orb{width:100px;height:100px;border-radius:50%;background:radial-gradient(circle at 40% 40%,#4a6a8a,#1a2a3a);
|
| 50 |
+
transition:box-shadow .3s,transform .3s;position:relative;cursor:pointer}
|
| 51 |
+
.orb.idle{box-shadow:0 0 20px #2a4a6a44}
|
| 52 |
+
.orb.listening{box-shadow:0 0 40px #4a8aba88;transform:scale(1.05);animation:pulse 1.5s infinite}
|
| 53 |
+
.orb.speaking{box-shadow:0 0 60px #e5b57a88;transform:scale(1.08)}
|
| 54 |
+
.orb.thinking{box-shadow:0 0 30px #88888844;animation:spin 2s linear infinite}
|
| 55 |
+
@keyframes pulse{0%,100%{transform:scale(1.05)}50%{transform:scale(1.12)}}
|
| 56 |
+
@keyframes spin{0%{transform:rotate(0deg)}100%{transform:rotate(360deg)}}
|
| 57 |
+
.orb .label{position:absolute;bottom:-24px;left:50%;transform:translateX(-50%);font-size:.65rem;color:#888;white-space:nowrap}
|
| 58 |
+
.orb-info{text-align:center;font-size:.68rem;color:#666;padding:2px 0;flex-shrink:0}
|
| 59 |
+
|
| 60 |
+
/* camera */
|
| 61 |
+
.cam-wrap{position:relative;display:none;flex-shrink:0}
|
| 62 |
+
.cam-wrap.active{display:block}
|
| 63 |
+
.cam-wrap video{width:120px;height:90px;border-radius:6px;object-fit:cover;border:1px solid #444}
|
| 64 |
+
.cam-wrap .cam-close{position:absolute;top:-6px;right:-6px;width:18px;height:18px;border-radius:50%;background:#d84545;color:#fff;font-size:10px;border:none;cursor:pointer;display:flex;align-items:center;justify-content:center}
|
| 65 |
+
|
| 66 |
+
/* voice clone modal */
|
| 67 |
+
.modal-overlay{position:fixed;top:0;left:0;right:0;bottom:0;background:#00000088;display:none;align-items:center;justify-content:center;z-index:100}
|
| 68 |
+
.modal-overlay.active{display:flex}
|
| 69 |
+
.modal{background:#2a2a30;border:1px solid #444;border-radius:8px;padding:20px;min-width:320px;max-width:400px}
|
| 70 |
+
.modal h3{margin-bottom:12px;font-size:.9rem}
|
| 71 |
+
.modal p{font-size:.78rem;color:#aaa;margin-bottom:12px}
|
| 72 |
+
.modal .btn-row{display:flex;gap:6px;justify-content:flex-end;margin-top:12px}
|
| 73 |
+
.modal input{width:100%;padding:6px 8px;background:#1a1a1e;border:1px solid #444;border-radius:4px;color:#e8e8ed;font-size:.8rem;outline:none;margin-bottom:8px;font-family:inherit}
|
| 74 |
+
.modal .rec-btn{width:100%;padding:20px;font-size:1rem;background:#2a4a6a;border:1px solid #4a8aba;border-radius:8px;color:#fff;cursor:pointer;font-family:inherit}
|
| 75 |
+
.modal .rec-btn:hover{background:#3a5a7a}
|
| 76 |
+
.modal .rec-btn.recording{background:#6a2a2a;border-color:#d84545;animation:pulse .8s infinite}
|
| 77 |
+
|
| 78 |
+
/* audio visualizer bars */
|
| 79 |
+
.viz{display:flex;justify-content:center;align-items:flex-end;gap:3px;height:40px;padding:4px 0;flex-shrink:0}
|
| 80 |
+
.viz .bar{width:4px;background:#4a8aba;border-radius:2px;transition:height .08s;height:4px}
|
| 81 |
+
.viz.active .bar{background:#e5b57a}
|
| 82 |
+
|
| 83 |
+
/* utility */
|
| 84 |
+
.hidden{display:none!important}
|
| 85 |
+
.text-muted{color:#888;font-size:.72rem}
|
| 86 |
+
.gap-4{display:flex;gap:4px}
|
| 87 |
+
</style>
|
| 88 |
+
</head>
|
| 89 |
+
<body>
|
| 90 |
+
<div class="app">
|
| 91 |
+
<div class="top">
|
| 92 |
+
<div><h1>Omni-O</h1><div class="sub">voice call · real-time</div></div>
|
| 93 |
+
<div class="top-right">
|
| 94 |
+
<button class="btn" id="camBtn" onclick="toggleCam()">📷</button>
|
| 95 |
+
<button class="btn" onclick="openCloneModal()">🎙️ Clone</button>
|
| 96 |
+
<select id="voiceSel" onchange="onVoiceChange()"></select>
|
| 97 |
+
<button class="btn" id="modeBtn" onclick="toggleMode()">📞 Call</button>
|
| 98 |
+
</div>
|
| 99 |
+
</div>
|
| 100 |
+
|
| 101 |
+
<!-- Chat Tab -->
|
| 102 |
+
<div class="tab-bar">
|
| 103 |
+
<button class="tab-btn active" onclick="switchTab('chat',this)">Chat</button>
|
| 104 |
+
<button class="tab-btn" onclick="switchTab('call',this)">Call</button>
|
| 105 |
+
</div>
|
| 106 |
+
|
| 107 |
+
<!-- Chat tab -->
|
| 108 |
+
<div class="tab-content active" id="tabChat">
|
| 109 |
+
<div class="chat" id="chatBox"></div>
|
| 110 |
+
<div class="input-bar">
|
| 111 |
+
<input id="chatInput" placeholder="Type a message..." onkeydown="if(event.key==='Enter'&&!event.shiftKey){event.preventDefault();sendChat()}">
|
| 112 |
+
<button class="btn" onclick="sendChat()">Send</button>
|
| 113 |
+
</div>
|
| 114 |
+
</div>
|
| 115 |
+
|
| 116 |
+
<!-- Call tab -->
|
| 117 |
+
<div class="tab-content" id="tabCall">
|
| 118 |
+
<div class="chat" id="callBox"></div>
|
| 119 |
+
<div class="viz" id="viz"><div class="bar"></div><div class="bar"></div><div class="bar"></div><div class="bar"></div><div class="bar"></div><div class="bar"></div><div class="bar"></div><div class="bar"></div></div>
|
| 120 |
+
<div class="cam-wrap" id="camWrap">
|
| 121 |
+
<video id="camVideo" autoplay muted playsinline></video>
|
| 122 |
+
<button class="cam-close" onclick="toggleCam()">✕</button>
|
| 123 |
+
</div>
|
| 124 |
+
<div class="orb-wrap">
|
| 125 |
+
<div class="orb idle" id="orb" onclick="toggleCall()">
|
| 126 |
+
<div class="label" id="orbLabel">Start Call</div>
|
| 127 |
+
</div>
|
| 128 |
+
</div>
|
| 129 |
+
<div class="orb-info" id="orbInfo">Press the orb or click Call to start</div>
|
| 130 |
+
</div>
|
| 131 |
+
</div>
|
| 132 |
+
|
| 133 |
+
<div class="modal-overlay" id="cloneModal">
|
| 134 |
+
<div class="modal">
|
| 135 |
+
<h3>Voice Clone</h3>
|
| 136 |
+
<p>Record a sentence (2-6s) to clone your voice</p>
|
| 137 |
+
<input id="cloneName" placeholder="Voice name" maxlength="24">
|
| 138 |
+
<button class="rec-btn" id="recBtn" onmousedown="startRec()" onmouseup="stopRec()" ontouchstart="startRec()" ontouchend="stopRec()">Hold to Record</button>
|
| 139 |
+
<div id="cloneStatus" class="text-muted" style="margin-top:8px"></div>
|
| 140 |
+
<div class="btn-row">
|
| 141 |
+
<button class="btn" onclick="closeCloneModal()">Cancel</button>
|
| 142 |
+
<button class="btn" id="cloneDelBtn" onclick="deleteVoice()" style="display:none">Delete</button>
|
| 143 |
+
</div>
|
| 144 |
+
</div>
|
| 145 |
+
</div>
|
| 146 |
+
|
| 147 |
+
<script>
|
| 148 |
+
// ======= State =======
|
| 149 |
+
let mode = 'chat' // chat | call
|
| 150 |
+
let ws = null
|
| 151 |
+
let mic = null, rtc = null, proc = null
|
| 152 |
+
let playing = []
|
| 153 |
+
let userBuf = []
|
| 154 |
+
let spk = false
|
| 155 |
+
let accPcm = null
|
| 156 |
+
let nextT = 0
|
| 157 |
+
let callActive = false
|
| 158 |
+
let camStream = null
|
| 159 |
+
let camFrame = null
|
| 160 |
+
let currentCallMsg = null
|
| 161 |
+
let currentCallText = ''
|
| 162 |
+
let history = []
|
| 163 |
+
let voices = []
|
| 164 |
+
let selectedVoice = 'default'
|
| 165 |
+
let abortController = null
|
| 166 |
+
|
| 167 |
+
const RT_SAMPLE_RATE = 16000
|
| 168 |
+
const RT_BLOCK = 4096
|
| 169 |
+
const AUDIO_SR = 24000
|
| 170 |
+
|
| 171 |
+
// ======= DOM refs =======
|
| 172 |
+
const $ = s => document.querySelector(s)
|
| 173 |
+
const chatBox = $('#chatBox')
|
| 174 |
+
const callBox = $('#callBox')
|
| 175 |
+
const chatInput = $('#chatInput')
|
| 176 |
+
const orb = $('#orb')
|
| 177 |
+
const orbLabel = $('#orbLabel')
|
| 178 |
+
const orbInfo = $('#orbInfo')
|
| 179 |
+
const viz = $('#viz')
|
| 180 |
+
const camVideo = $('#camVideo')
|
| 181 |
+
const camWrap = $('#camWrap')
|
| 182 |
+
const voiceSel = $('#voiceSel')
|
| 183 |
+
|
| 184 |
+
// ======= Utility =======
|
| 185 |
+
function addMsg(container, role, text, id) {
|
| 186 |
+
const el = document.createElement('div')
|
| 187 |
+
el.className = `msg ${role}`
|
| 188 |
+
if(id) el.id = id
|
| 189 |
+
el.innerHTML = `<div class="role">${role==='user'?'You':'AI'}</div><div class="content">${text||''}</div>`
|
| 190 |
+
container.appendChild(el)
|
| 191 |
+
container.scrollTop = container.scrollHeight
|
| 192 |
+
return el.querySelector('.content')
|
| 193 |
+
}
|
| 194 |
+
|
| 195 |
+
function fmtTime(ms) {
|
| 196 |
+
if(ms<1000) return ms.toFixed(0)+'ms'
|
| 197 |
+
return (ms/1000).toFixed(1)+'s'
|
| 198 |
+
}
|
| 199 |
+
|
| 200 |
+
function vizBars(active) {
|
| 201 |
+
viz.className = 'viz' + (active ? ' active' : '')
|
| 202 |
+
if(active) {
|
| 203 |
+
let bars = viz.querySelectorAll('.bar')
|
| 204 |
+
function anim(){
|
| 205 |
+
if(!viz.classList.contains('active')) return
|
| 206 |
+
bars.forEach(b => b.style.height = (2 + Math.random()*30) + 'px')
|
| 207 |
+
requestAnimationFrame(anim)
|
| 208 |
+
}
|
| 209 |
+
anim()
|
| 210 |
+
} else {
|
| 211 |
+
viz.querySelectorAll('.bar').forEach(b => b.style.height = '4px')
|
| 212 |
+
}
|
| 213 |
+
}
|
| 214 |
+
|
| 215 |
+
function floatToI16(f32) {
|
| 216 |
+
const i16 = new Int16Array(f32.length)
|
| 217 |
+
for(let i=0;i<f32.length;i++){
|
| 218 |
+
const v = f32[i]*32767
|
| 219 |
+
i16[i]=Math.max(-32768,Math.min(32767,v|0))
|
| 220 |
+
}
|
| 221 |
+
return i16
|
| 222 |
+
}
|
| 223 |
+
|
| 224 |
+
function resamplePcm(src, fromRate, toRate) {
|
| 225 |
+
if(fromRate===toRate) return src
|
| 226 |
+
const ratio = toRate/fromRate
|
| 227 |
+
const dst = new Float32Array(Math.ceil(src.length*ratio))
|
| 228 |
+
for(let i=0;i<dst.length;i++){
|
| 229 |
+
const p = i/ratio
|
| 230 |
+
const i0 = p|0, i1 = Math.min(i0+1,src.length-1)
|
| 231 |
+
const f = p-i0
|
| 232 |
+
dst[i] = src[i0]*(1-f)+src[i1]*f
|
| 233 |
+
}
|
| 234 |
+
return dst
|
| 235 |
+
}
|
| 236 |
+
|
| 237 |
+
// ======= Tab switching =======
|
| 238 |
+
function switchTab(name, btn) {
|
| 239 |
+
document.querySelectorAll('.tab-btn').forEach(b=>b.classList.remove('active'))
|
| 240 |
+
btn.classList.add('active')
|
| 241 |
+
document.querySelectorAll('.tab-content').forEach(t=>t.classList.remove('active'))
|
| 242 |
+
document.getElementById('tab'+(name==='chat'?'Chat':'Call')).classList.add('active')
|
| 243 |
+
mode = name
|
| 244 |
+
$('#modeBtn').textContent = name==='chat'?'📞 Call':'💬 Chat'
|
| 245 |
+
if(name==='call') initMic()
|
| 246 |
+
}
|
| 247 |
+
|
| 248 |
+
// ======= Chat mode (SSE) =======
|
| 249 |
+
async function sendChat() {
|
| 250 |
+
const text = chatInput.value.trim()
|
| 251 |
+
if(!text) return
|
| 252 |
+
chatInput.value = ''
|
| 253 |
+
addMsg(chatBox, 'user', text)
|
| 254 |
+
const contentEl = addMsg(chatBox, 'assistant', '', 'chatResp')
|
| 255 |
+
contentEl.classList.add('typing')
|
| 256 |
+
|
| 257 |
+
const body = JSON.stringify({text, history, voice:selectedVoice, max_tokens:256, temperature:0.7})
|
| 258 |
+
abortController = new AbortController()
|
| 259 |
+
try {
|
| 260 |
+
const resp = await fetch('/chat', {method:'POST', body, headers:{'Content-Type':'application/json'}, signal:abortController.signal})
|
| 261 |
+
const reader = resp.body.getReader()
|
| 262 |
+
const dec = new TextDecoder()
|
| 263 |
+
let buf = ''
|
| 264 |
+
let fullResp = ''
|
| 265 |
+
let audioBuf = []
|
| 266 |
+
let pcmChunks = []
|
| 267 |
+
while(true){
|
| 268 |
+
const {done,value} = await reader.read()
|
| 269 |
+
if(done) break
|
| 270 |
+
buf += dec.decode(value,{stream:true})
|
| 271 |
+
for(;;){
|
| 272 |
+
const idx = buf.indexOf('\n\n')
|
| 273 |
+
if(idx<0) break
|
| 274 |
+
const line = buf.slice(0,idx)
|
| 275 |
+
buf = buf.slice(idx+2)
|
| 276 |
+
if(!line.startsWith('data: ')) continue
|
| 277 |
+
const d = JSON.parse(line.slice(6))
|
| 278 |
+
if(d.type==='text'){
|
| 279 |
+
fullResp = d.content
|
| 280 |
+
contentEl.textContent = fullResp
|
| 281 |
+
chatBox.scrollTop = chatBox.scrollHeight
|
| 282 |
+
} else if(d.type==='user_prompt' && d.content) {
|
| 283 |
+
history.push({role:'user', content:d.content})
|
| 284 |
+
} else if(d.type==='pcm'){
|
| 285 |
+
pcmChunks.push(d)
|
| 286 |
+
} else if(d.type==='done'){
|
| 287 |
+
if(fullResp) history.push({role:'assistant', content:fullResp})
|
| 288 |
+
contentEl.classList.remove('typing')
|
| 289 |
+
// decode and play audio
|
| 290 |
+
if(pcmChunks.length){
|
| 291 |
+
let b64 = ''
|
| 292 |
+
const parts = []
|
| 293 |
+
for(const c of pcmChunks){
|
| 294 |
+
if(c.d) { b64 += c.c; parts.push(b64); b64 = '' }
|
| 295 |
+
else b64 += c.c
|
| 296 |
+
}
|
| 297 |
+
if(b64) parts.push(b64)
|
| 298 |
+
for(const p of parts){
|
| 299 |
+
try{
|
| 300 |
+
const ab = Uint8Array.from(atob(p), c=>c.charCodeAt(0)).buffer
|
| 301 |
+
playPcm(ab)
|
| 302 |
+
}catch(e){}
|
| 303 |
+
}
|
| 304 |
+
}
|
| 305 |
+
}
|
| 306 |
+
}
|
| 307 |
+
}
|
| 308 |
+
} catch(e){if(e.name!=='AbortError') throw e}
|
| 309 |
+
abortController = null
|
| 310 |
+
}
|
| 311 |
+
|
| 312 |
+
// ======= Call mode (WebSocket) =======
|
| 313 |
+
function toggleMode() {
|
| 314 |
+
if(mode==='chat'){ switchTab('call', document.querySelectorAll('.tab-btn')[1]) }
|
| 315 |
+
else{ switchTab('chat', document.querySelectorAll('.tab-btn')[0]) }
|
| 316 |
+
}
|
| 317 |
+
|
| 318 |
+
async function initMic() {
|
| 319 |
+
if(mic) return
|
| 320 |
+
try{
|
| 321 |
+
mic = await navigator.mediaDevices.getUserMedia({audio:{echoCancellation:true,noiseSuppression:true}})
|
| 322 |
+
}catch(e){ orbInfo.textContent = 'Mic access denied'; throw e }
|
| 323 |
+
}
|
| 324 |
+
|
| 325 |
+
async function toggleCall() {
|
| 326 |
+
if(!callActive) startCall()
|
| 327 |
+
else stopCall()
|
| 328 |
+
}
|
| 329 |
+
|
| 330 |
+
async function startCall() {
|
| 331 |
+
if(callActive) return
|
| 332 |
+
try{ await initMic() } catch(e){ return }
|
| 333 |
+
if(ws && ws.readyState===1) return
|
| 334 |
+
|
| 335 |
+
callActive = true
|
| 336 |
+
orb.className = 'orb idle'
|
| 337 |
+
orbLabel.textContent = 'Connecting...'
|
| 338 |
+
orbInfo.textContent = ''
|
| 339 |
+
currentCallText = ''
|
| 340 |
+
currentCallMsg = null
|
| 341 |
+
userBuf = []
|
| 342 |
+
spk = false
|
| 343 |
+
|
| 344 |
+
ws = new WebSocket('ws://'+location.host+'/ws/realtime')
|
| 345 |
+
ws.binaryType = 'arraybuffer'
|
| 346 |
+
|
| 347 |
+
ws.onopen = () => {
|
| 348 |
+
orbLabel.textContent = 'Listening'
|
| 349 |
+
orbInfo.textContent = 'Speak to start'
|
| 350 |
+
startAudioCapture()
|
| 351 |
+
}
|
| 352 |
+
|
| 353 |
+
ws.onmessage = e => {
|
| 354 |
+
const m = JSON.parse(e.data)
|
| 355 |
+
if(m.type==='vad'){
|
| 356 |
+
if(m.speaking && !spk) { sendContext(); stopPcm(); spk=true }
|
| 357 |
+
if(!m.speaking && spk) spk=false
|
| 358 |
+
if(m.speaking){ orb.className='orb listening'; orbLabel.textContent='You...'; vizBars(true) }
|
| 359 |
+
else if(!currentCallText && !callFinished){ orb.className='orb idle'; orbLabel.textContent='Listening'; vizBars(false) }
|
| 360 |
+
} else if(m.type==='generating'){
|
| 361 |
+
orb.className='orb thinking'; orbLabel.textContent='Thinking'; vizBars(false)
|
| 362 |
+
if(spk){ spk=false }
|
| 363 |
+
} else if(m.type==='text'){
|
| 364 |
+
orb.className='orb speaking'; orbLabel.textContent='Speaking'; vizBars(false)
|
| 365 |
+
currentCallText += m.content
|
| 366 |
+
if(!currentCallMsg) currentCallMsg = addMsg(callBox, 'assistant', '')
|
| 367 |
+
const el = callBox.querySelector('.msg.assistant:last-child .content')
|
| 368 |
+
if(el){ el.textContent = currentCallText; callBox.scrollTop = callBox.scrollHeight }
|
| 369 |
+
} else if(m.type==='pcm'){
|
| 370 |
+
try{
|
| 371 |
+
const ab = Uint8Array.from(atob(m.data), c=>c.charCodeAt(0)).buffer
|
| 372 |
+
playPcm(ab)
|
| 373 |
+
}catch(e){}
|
| 374 |
+
} else if(m.type==='done'){
|
| 375 |
+
orb.className='orb idle'; orbLabel.textContent='Listening'
|
| 376 |
+
if(m.interrupted){ stopPcm(); orbInfo.textContent='Interrupted' }
|
| 377 |
+
if(currentCallText){
|
| 378 |
+
if(!m.interrupted) history.push({role:'assistant', content:currentCallText})
|
| 379 |
+
currentCallText = ''
|
| 380 |
+
currentCallMsg = null
|
| 381 |
+
}
|
| 382 |
+
setTimeout(()=>{ if(callActive) orbInfo.textContent='Speak to start' }, 1000)
|
| 383 |
+
}
|
| 384 |
+
}
|
| 385 |
+
|
| 386 |
+
ws.onclose = () => {
|
| 387 |
+
stopAudioCapture()
|
| 388 |
+
if(callActive){
|
| 389 |
+
callActive = false
|
| 390 |
+
orb.className='orb idle'; orbLabel.textContent='Call ended'
|
| 391 |
+
orbInfo.textContent='Press orb to restart'
|
| 392 |
+
}
|
| 393 |
+
}
|
| 394 |
+
|
| 395 |
+
ws.onerror = () => {
|
| 396 |
+
orbLabel.textContent='Error'; orbInfo.textContent='Connection failed'
|
| 397 |
+
callActive = false
|
| 398 |
+
}
|
| 399 |
+
}
|
| 400 |
+
|
| 401 |
+
function stopCall() {
|
| 402 |
+
callActive = false
|
| 403 |
+
if(ws) { ws.close(); ws = null }
|
| 404 |
+
stopAudioCapture()
|
| 405 |
+
stopPcm()
|
| 406 |
+
orb.className = 'orb idle'
|
| 407 |
+
orbLabel.textContent = 'Start Call'
|
| 408 |
+
orbInfo.textContent = ''
|
| 409 |
+
vizBars(false)
|
| 410 |
+
}
|
| 411 |
+
|
| 412 |
+
function startAudioCapture() {
|
| 413 |
+
if(proc) return
|
| 414 |
+
rtc = new AudioContext()
|
| 415 |
+
const src = rtc.createMediaStreamSource(mic)
|
| 416 |
+
proc = rtc.createScriptProcessor(4096, 1, 1)
|
| 417 |
+
let acc = new Float32Array(0)
|
| 418 |
+
|
| 419 |
+
proc.onaudioprocess = e => {
|
| 420 |
+
if(!ws || ws.readyState!==1) return
|
| 421 |
+
const rs = resamplePcm(e.inputBuffer.getChannelData(0), e.inputBuffer.sampleRate, RT_SAMPLE_RATE)
|
| 422 |
+
const merged = new Float32Array(acc.length + rs.length)
|
| 423 |
+
merged.set(acc); merged.set(rs, acc.length)
|
| 424 |
+
let off = 0
|
| 425 |
+
for(; merged.length - off >= RT_BLOCK; off += RT_BLOCK){
|
| 426 |
+
const i16 = floatToI16(merged.subarray(off, off + RT_BLOCK))
|
| 427 |
+
ws.send(i16.buffer)
|
| 428 |
+
if(spk) userBuf.push(new Int16Array(i16))
|
| 429 |
+
}
|
| 430 |
+
acc = merged.slice(off)
|
| 431 |
+
}
|
| 432 |
+
src.connect(proc)
|
| 433 |
+
proc.connect(rtc.destination)
|
| 434 |
+
}
|
| 435 |
+
|
| 436 |
+
function stopAudioCapture() {
|
| 437 |
+
if(proc) { proc.disconnect(); proc = null }
|
| 438 |
+
if(rtc) { rtc.close(); rtc = null }
|
| 439 |
+
accPcm = null
|
| 440 |
+
}
|
| 441 |
+
|
| 442 |
+
function playPcm(arrayBuffer) {
|
| 443 |
+
if(!rtc) return
|
| 444 |
+
try{
|
| 445 |
+
const f32 = new Float32Array(arrayBuffer.byteLength / 2)
|
| 446 |
+
const i16 = new Int16Array(arrayBuffer)
|
| 447 |
+
for(let i=0;i<f32.length;i++) f32[i] = i16[i] / 32767
|
| 448 |
+
const buf = rtc.createBuffer(1, f32.length, AUDIO_SR)
|
| 449 |
+
buf.getChannelData(0).set(f32)
|
| 450 |
+
const src = rtc.createBufferSource()
|
| 451 |
+
src.buffer = buf
|
| 452 |
+
src.connect(rtc.destination)
|
| 453 |
+
if(!nextT || nextT < rtc.currentTime) nextT = rtc.currentTime
|
| 454 |
+
src.start(nextT)
|
| 455 |
+
nextT += f32.length / AUDIO_SR
|
| 456 |
+
playing.push(src)
|
| 457 |
+
src.onended = () => { const i = playing.indexOf(src); if(i>=0) playing.splice(i,1) }
|
| 458 |
+
} catch(e){}
|
| 459 |
+
}
|
| 460 |
+
|
| 461 |
+
function stopPcm() {
|
| 462 |
+
for(const s of playing) try{ s.stop() } catch{}
|
| 463 |
+
playing = []
|
| 464 |
+
if(rtc) nextT = rtc.currentTime
|
| 465 |
+
}
|
| 466 |
+
|
| 467 |
+
function sendContext() {
|
| 468 |
+
if(!ws || ws.readyState!==1) return
|
| 469 |
+
const msg = {type:'context', voice:selectedVoice}
|
| 470 |
+
if(history.length) msg.history = history.slice(-8)
|
| 471 |
+
if(camFrame) msg.image = camFrame
|
| 472 |
+
ws.send(JSON.stringify(msg))
|
| 473 |
+
}
|
| 474 |
+
|
| 475 |
+
// ======= Camera =======
|
| 476 |
+
async function toggleCam() {
|
| 477 |
+
if(camStream){
|
| 478 |
+
camStream.getTracks().forEach(t=>t.stop())
|
| 479 |
+
camStream = null
|
| 480 |
+
camWrap.classList.remove('active')
|
| 481 |
+
camFrame = null
|
| 482 |
+
$('#camBtn').classList.remove('active')
|
| 483 |
+
return
|
| 484 |
+
}
|
| 485 |
+
try{
|
| 486 |
+
camStream = await navigator.mediaDevices.getUserMedia({video:{width:320,height:240}})
|
| 487 |
+
camVideo.srcObject = camStream
|
| 488 |
+
camWrap.classList.add('active')
|
| 489 |
+
$('#camBtn').classList.add('active')
|
| 490 |
+
// Capture frames periodically
|
| 491 |
+
setInterval(() => {
|
| 492 |
+
if(!camStream) return
|
| 493 |
+
const c = document.createElement('canvas')
|
| 494 |
+
c.width = camVideo.videoWidth || 320
|
| 495 |
+
c.height = camVideo.videoHeight || 240
|
| 496 |
+
c.getContext('2d').drawImage(camVideo, 0, 0)
|
| 497 |
+
camFrame = c.toDataURL('image/jpeg', 0.6).split(',')[1]
|
| 498 |
+
}, 2000)
|
| 499 |
+
}catch(e){ orbInfo.textContent = 'Camera denied' }
|
| 500 |
+
}
|
| 501 |
+
|
| 502 |
+
// ======= Voice Clone =======
|
| 503 |
+
function openCloneModal() {
|
| 504 |
+
$('#cloneModal').classList.add('active')
|
| 505 |
+
$('#cloneStatus').textContent = ''
|
| 506 |
+
$('#cloneDelBtn').style.display = 'none'
|
| 507 |
+
}
|
| 508 |
+
|
| 509 |
+
function closeCloneModal() {
|
| 510 |
+
$('#cloneModal').classList.remove('active')
|
| 511 |
+
if(recMediaRec) { recMediaRec.stop(); recMediaRec = null }
|
| 512 |
+
}
|
| 513 |
+
|
| 514 |
+
let recMediaRec = null, recChunks = []
|
| 515 |
+
function startRec() {
|
| 516 |
+
recChunks = []
|
| 517 |
+
const btn = $('#recBtn')
|
| 518 |
+
btn.textContent = 'Recording...'
|
| 519 |
+
btn.classList.add('recording')
|
| 520 |
+
try{
|
| 521 |
+
const s = new MediaStream(mic.getAudioTracks())
|
| 522 |
+
recMediaRec = new MediaRecorder(s, {mimeType:'audio/webm;codecs=opus'})
|
| 523 |
+
recMediaRec.ondataavailable = e => { if(e.data.size) recChunks.push(e.data) }
|
| 524 |
+
recMediaRec.start()
|
| 525 |
+
}catch(e){ $('#cloneStatus').textContent = 'Record failed: '+e.message }
|
| 526 |
+
}
|
| 527 |
+
|
| 528 |
+
async function stopRec() {
|
| 529 |
+
const btn = $('#recBtn')
|
| 530 |
+
btn.textContent = 'Hold to Record'
|
| 531 |
+
btn.classList.remove('recording')
|
| 532 |
+
if(!recMediaRec) return
|
| 533 |
+
recMediaRec.stop()
|
| 534 |
+
await new Promise(r => recMediaRec.onstop = r)
|
| 535 |
+
const blob = new Blob(recChunks, {type:'audio/webm;codecs=opus'})
|
| 536 |
+
const reader = new FileReader()
|
| 537 |
+
reader.onload = async () => {
|
| 538 |
+
const b64 = reader.result.split(',')[1]
|
| 539 |
+
const name = $('#cloneName').value.trim() || 'voice_clone'
|
| 540 |
+
$('#cloneStatus').textContent = 'Cloning...'
|
| 541 |
+
try{
|
| 542 |
+
const r = await fetch('/clone_voice', {method:'POST', body:JSON.stringify({audio:b64, name}), headers:{'Content-Type':'application/json'}})
|
| 543 |
+
const d = await r.json()
|
| 544 |
+
if(d.ok){ $('#cloneStatus').textContent = 'Cloned!'; loadVoices() }
|
| 545 |
+
else $('#cloneStatus').textContent = 'Error: '+d.error
|
| 546 |
+
}catch(e){ $('#cloneStatus').textContent = 'Error: '+e.message }
|
| 547 |
+
}
|
| 548 |
+
reader.readAsDataURL(blob)
|
| 549 |
+
recMediaRec = null
|
| 550 |
+
}
|
| 551 |
+
|
| 552 |
+
async function deleteVoice() {
|
| 553 |
+
const name = $('#cloneName').value.trim()
|
| 554 |
+
if(!name) return
|
| 555 |
+
try{
|
| 556 |
+
await fetch('/delete_voice', {method:'POST', body:JSON.stringify({name}), headers:{'Content-Type':'application/json'}})
|
| 557 |
+
loadVoices()
|
| 558 |
+
closeCloneModal()
|
| 559 |
+
}catch(e){}
|
| 560 |
+
}
|
| 561 |
+
|
| 562 |
+
async function loadVoices() {
|
| 563 |
+
try{
|
| 564 |
+
const r = await fetch('/voices')
|
| 565 |
+
const d = await r.json()
|
| 566 |
+
voiceSel.innerHTML = '<option value="default">Default Voice</option>'
|
| 567 |
+
for(const g of ['builtin','unseen','manual']){
|
| 568 |
+
for(const v of (d[g]||[])){
|
| 569 |
+
const o = document.createElement('option')
|
| 570 |
+
o.value = v; o.textContent = v + (g==='manual'?' ★':'')
|
| 571 |
+
voiceSel.appendChild(o)
|
| 572 |
+
}
|
| 573 |
+
}
|
| 574 |
+
}catch(e){}
|
| 575 |
+
}
|
| 576 |
+
loadVoices()
|
| 577 |
+
|
| 578 |
+
function onVoiceChange() { selectedVoice = voiceSel.value }
|
| 579 |
+
|
| 580 |
+
// ======= Init =======
|
| 581 |
+
// Add welcome message
|
| 582 |
+
addMsg(chatBox, 'assistant', 'Hello! I\'m Omni-O. Type a message or switch to Call mode for real-time voice conversation.')
|
| 583 |
+
</script>
|
| 584 |
+
</body>
|
| 585 |
+
</html>
|