chenbhao commited on
Commit
9a9a9e0
·
1 Parent(s): f53960b

Replace web_demo.html with omni_o_web.html: self-contained frontend for omni-o call

Browse files

New 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

Files changed (2) hide show
  1. scripts/omni_o_call.py +2 -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('.', 'web_demo.html')
130
  @app.route('/call')
131
- def call_page(): return send_from_directory('.', 'web_demo.html')
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>