omni / scripts /omni_o_web.html
chenbhao's picture
Fix: remove duplicate voiceSel declaration breaking UI
dbc88a2
Raw
History Blame Contribute Delete
25.2 kB
<!DOCTYPE html>
<html lang="zh">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Omni-O Voice Call</title>
<style>
*{margin:0;padding:0;box-sizing:border-box}
html,body{height:100%}
body{background:#1a1a1e;color:#e8e8ed;font-family:system-ui,-apple-system,sans-serif;display:flex;justify-content:center;overflow:hidden}
.app{max-width:800px;width:100%;display:flex;flex-direction:column;padding:16px 20px;height:100vh}
/* top bar */
.top{display:flex;align-items:center;justify-content:space-between;padding:0 0 10px;flex-shrink:0;gap:8px}
.top h1{font-size:1.05rem;font-weight:600;color:#fff}
.top .sub{font-size:.68rem;color:#888}
.top-right{display:flex;gap:6px;align-items:center}
.btn{padding:5px 10px;font-size:.72rem;cursor:pointer;background:#2a2a30;border:1px solid #444;color:#e8e8ed;border-radius:4px;font-family:inherit}
.btn:hover:not(:disabled){background:#35353d;border-color:#666}
.btn:disabled{opacity:.4;cursor:not-allowed}
.btn.active{background:#2a4a6a;border-color:#4a8aba}
.btn.danger{color:#d84545;border-color:#6a3a3a}
select{font-size:.72rem;background:#2a2a30;border:1px solid #444;color:#e8e8ed;padding:4px 6px;border-radius:4px;outline:none;font-family:inherit}
select:focus{border-color:#666}
/* chat area */
.chat{flex:1;overflow-y:auto;padding:8px 0;display:flex;flex-direction:column;gap:4px;min-height:0}
.msg{display:flex;gap:8px;padding:8px 10px;border-radius:6px;max-width:85%;animation:fadeIn .2s}
.msg.user{align-self:flex-end;flex-direction:row-reverse;background:#2a4a6a}
.msg.assistant{background:#2a2a30}
.msg .role{font-size:.65rem;color:#888;flex-shrink:0;min-width:40px;padding-top:2px}
.msg .content{font-size:.85rem;line-height:1.5;word-break:break-word}
.msg .content.typing::after{content:'|';animation:blink .8s infinite}
@keyframes blink{50%{opacity:0}}
@keyframes fadeIn{from{opacity:0;transform:translateY(4px)}to{opacity:1;transform:translateY(0)}}
/* input area */
.input-bar{display:flex;gap:6px;padding:8px 0 4px;flex-shrink:0;align-items:center}
.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}
.input-bar input:focus{border-color:#666}
.tab-bar{display:flex;gap:4px;padding:4px 0;flex-shrink:0}
.tab-btn{padding:4px 14px;font-size:.72rem;cursor:pointer;background:transparent;border:none;border-bottom:2px solid transparent;color:#888;font-family:inherit}
.tab-btn.active{color:#e8e8ed;border-bottom-color:#4a8aba}
.tab-content{display:none}
.tab-content.active{display:flex;flex-direction:column;flex:1;min-height:0}
/* orb */
.orb-wrap{display:flex;justify-content:center;align-items:center;padding:20px 0;flex-shrink:0}
.orb{width:100px;height:100px;border-radius:50%;background:radial-gradient(circle at 40% 40%,#4a6a8a,#1a2a3a);
transition:box-shadow .3s,transform .3s;position:relative;cursor:pointer}
.orb.idle{box-shadow:0 0 20px #2a4a6a44}
.orb.listening{box-shadow:0 0 40px #4a8aba88;transform:scale(1.05);animation:pulse 1.5s infinite}
.orb.speaking{box-shadow:0 0 60px #e5b57a88;transform:scale(1.08)}
.orb.thinking{box-shadow:0 0 30px #88888844;animation:spin 2s linear infinite}
@keyframes pulse{0%,100%{transform:scale(1.05)}50%{transform:scale(1.12)}}
@keyframes spin{0%{transform:rotate(0deg)}100%{transform:rotate(360deg)}}
.orb .label{position:absolute;bottom:-24px;left:50%;transform:translateX(-50%);font-size:.65rem;color:#888;white-space:nowrap}
.orb-info{text-align:center;font-size:.68rem;color:#666;padding:2px 0;flex-shrink:0}
/* camera */
.cam-wrap{position:relative;display:none;flex-shrink:0}
.cam-wrap.active{display:block}
.cam-wrap video{width:120px;height:90px;border-radius:6px;object-fit:cover;border:1px solid #444}
.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}
/* voice clone modal */
.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}
.modal-overlay.active{display:flex}
.modal{background:#2a2a30;border:1px solid #444;border-radius:8px;padding:20px;min-width:320px;max-width:400px}
.modal h3{margin-bottom:12px;font-size:.9rem}
.modal p{font-size:.78rem;color:#aaa;margin-bottom:12px}
.modal .btn-row{display:flex;gap:6px;justify-content:flex-end;margin-top:12px}
.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}
.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}
.modal .rec-btn:hover{background:#3a5a7a}
.modal .rec-btn.recording{background:#6a2a2a;border-color:#d84545;animation:pulse .8s infinite}
/* audio visualizer bars */
.viz{display:flex;justify-content:center;align-items:flex-end;gap:3px;height:40px;padding:4px 0;flex-shrink:0}
.viz .bar{width:4px;background:#4a8aba;border-radius:2px;transition:height .08s;height:4px}
.viz.active .bar{background:#e5b57a}
/* utility */
.hidden{display:none!important}
.text-muted{color:#888;font-size:.72rem}
.gap-4{display:flex;gap:4px}
/* voice popup */
.more-pop{display:none;position:absolute;top:100%;right:0;background:#2a2a30;border:1px solid #444;border-radius:6px;min-width:140px;z-index:50;padding:4px 0;margin-top:4px}
.more-pop.on{display:block}
.top-right{position:relative}
.vgroup{padding:4px 12px;font-size:.62rem;color:#666;text-transform:uppercase;letter-spacing:.5px}
.vitem{padding:6px 12px;font-size:.78rem;cursor:pointer;display:flex;justify-content:space-between;align-items:center}
.vitem:hover{background:#35353d}
.vitem.on{color:#4a8aba}
.vx{color:#d84545;cursor:pointer;font-size:.85rem;padding:0 4px}
.vadd{padding:6px 12px;font-size:.72rem;cursor:pointer;color:#4a8aba;border-top:1px solid #444;margin-top:4px}
.vadd:hover{background:#35353d}
</style>
</head>
<body>
<div class="app">
<div class="top">
<div><h1>Omni-O</h1><div class="sub">voice call · real-time</div></div>
<div class="top-right">
<button class="btn" id="camBtn" onclick="toggleCam()">📷</button>
<button class="btn" onclick="openCloneModal()">🎙️ Clone</button>
<button class="btn" id="moreBtn" onclick="morePop.classList.toggle('on')">default</button>
<div class="more-pop" id="morePop"></div>
<select id="voiceSel" style="display:none"></select>
<button class="btn" id="modeBtn" onclick="toggleMode()">📞 Call</button>
</div>
</div>
<!-- Chat Tab -->
<div class="tab-bar">
<button class="tab-btn active" onclick="switchTab('chat',this)">Chat</button>
<button class="tab-btn" onclick="switchTab('call',this)">Call</button>
</div>
<!-- Chat tab -->
<div class="tab-content active" id="tabChat">
<div class="chat" id="chatBox"></div>
<div class="input-bar">
<input id="chatInput" placeholder="Type a message..." onkeydown="if(event.key==='Enter'&&!event.shiftKey){event.preventDefault();sendChat()}">
<button class="btn" onclick="sendChat()">Send</button>
</div>
</div>
<!-- Call tab -->
<div class="tab-content" id="tabCall">
<div class="chat" id="callBox"></div>
<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>
<div class="cam-wrap" id="camWrap">
<video id="camVideo" autoplay muted playsinline></video>
<button class="cam-close" onclick="toggleCam()"></button>
</div>
<div class="orb-wrap">
<div class="orb idle" id="orb" onclick="toggleCall()">
<div class="label" id="orbLabel">Start Call</div>
</div>
</div>
<div class="orb-info" id="orbInfo">Press the orb or click Call to start</div>
</div>
</div>
<div class="modal-overlay" id="cloneModal">
<div class="modal">
<h3>Voice Clone</h3>
<p>Record a sentence (2-6s) to clone your voice</p>
<input id="cloneName" placeholder="Voice name" maxlength="24">
<button class="rec-btn" id="recBtn" onmousedown="startRec()" onmouseup="stopRec()" ontouchstart="startRec()" ontouchend="stopRec()">Hold to Record</button>
<div id="cloneStatus" class="text-muted" style="margin-top:8px"></div>
<div class="btn-row">
<button class="btn" onclick="closeCloneModal()">Cancel</button>
<button class="btn" id="cloneDelBtn" onclick="deleteVoice()" style="display:none">Delete</button>
</div>
</div>
</div>
<script>
// ======= State =======
let mode = 'chat' // chat | call
let ws = null
let mic = null, rtc = null, proc = null
let playing = []
let userBuf = []
let spk = false
let accPcm = null
let nextT = 0
let callActive = false
let camStream = null
let camFrame = null
let currentCallMsg = null
let currentCallText = ''
let history = []
let voices = []
let selectedVoice = 'default'
let abortController = null
const RT_SAMPLE_RATE = 16000
const RT_BLOCK = 4096
const AUDIO_SR = 24000
// ======= DOM refs =======
const $ = s => document.querySelector(s)
const chatBox = $('#chatBox')
const callBox = $('#callBox')
const chatInput = $('#chatInput')
const orb = $('#orb')
const orbLabel = $('#orbLabel')
const voiceSel = $('#voiceSel')
const moreBtn = $('#moreBtn')
const morePop = $('#morePop')
const orbInfo = $('#orbInfo')
const viz = $('#viz')
const camVideo = $('#camVideo')
const camWrap = $('#camWrap')
// ======= Utility =======
function addMsg(container, role, text, id) {
const el = document.createElement('div')
el.className = `msg ${role}`
if(id) el.id = id
el.innerHTML = `<div class="role">${role==='user'?'You':'AI'}</div><div class="content">${text||''}</div>`
container.appendChild(el)
container.scrollTop = container.scrollHeight
return el.querySelector('.content')
}
function fmtTime(ms) {
if(ms<1000) return ms.toFixed(0)+'ms'
return (ms/1000).toFixed(1)+'s'
}
function vizBars(active) {
viz.className = 'viz' + (active ? ' active' : '')
if(active) {
let bars = viz.querySelectorAll('.bar')
function anim(){
if(!viz.classList.contains('active')) return
bars.forEach(b => b.style.height = (2 + Math.random()*30) + 'px')
requestAnimationFrame(anim)
}
anim()
} else {
viz.querySelectorAll('.bar').forEach(b => b.style.height = '4px')
}
}
function floatToI16(f32) {
const i16 = new Int16Array(f32.length)
for(let i=0;i<f32.length;i++){
const v = f32[i]*32767
i16[i]=Math.max(-32768,Math.min(32767,v|0))
}
return i16
}
function resamplePcm(src, fromRate, toRate) {
if(fromRate===toRate) return src
const ratio = toRate/fromRate
const dst = new Float32Array(Math.ceil(src.length*ratio))
for(let i=0;i<dst.length;i++){
const p = i/ratio
const i0 = p|0, i1 = Math.min(i0+1,src.length-1)
const f = p-i0
dst[i] = src[i0]*(1-f)+src[i1]*f
}
return dst
}
// ======= Tab switching =======
function switchTab(name, btn) {
document.querySelectorAll('.tab-btn').forEach(b=>b.classList.remove('active'))
btn.classList.add('active')
document.querySelectorAll('.tab-content').forEach(t=>t.classList.remove('active'))
document.getElementById('tab'+(name==='chat'?'Chat':'Call')).classList.add('active')
mode = name
$('#modeBtn').textContent = name==='chat'?'📞 Call':'💬 Chat'
if(name==='call') initMic()
}
// ======= Chat mode (SSE) =======
async function sendChat() {
const text = chatInput.value.trim()
if(!text) return
chatInput.value = ''
addMsg(chatBox, 'user', text)
const contentEl = addMsg(chatBox, 'assistant', '', 'chatResp')
contentEl.classList.add('typing')
const body = JSON.stringify({text, history, voice:selectedVoice, max_tokens:256, temperature:0.7})
abortController = new AbortController()
try {
const resp = await fetch('/chat', {method:'POST', body, headers:{'Content-Type':'application/json'}, signal:abortController.signal})
const reader = resp.body.getReader()
const dec = new TextDecoder()
let buf = ''
let fullResp = ''
let audioBuf = []
let pcmChunks = []
while(true){
const {done,value} = await reader.read()
if(done) break
buf += dec.decode(value,{stream:true})
for(;;){
const idx = buf.indexOf('\n\n')
if(idx<0) break
const line = buf.slice(0,idx)
buf = buf.slice(idx+2)
if(!line.startsWith('data: ')) continue
const d = JSON.parse(line.slice(6))
if(d.type==='text'){
fullResp = d.content
contentEl.textContent = fullResp
chatBox.scrollTop = chatBox.scrollHeight
} else if(d.type==='user_prompt' && d.content) {
history.push({role:'user', content:d.content})
} else if(d.type==='pcm'){
pcmChunks.push(d)
} else if(d.type==='done'){
if(fullResp) history.push({role:'assistant', content:fullResp})
contentEl.classList.remove('typing')
// decode and play audio
if(pcmChunks.length){
let b64 = ''
const parts = []
for(const c of pcmChunks){
if(c.d) { b64 += c.c; parts.push(b64); b64 = '' }
else b64 += c.c
}
if(b64) parts.push(b64)
for(const p of parts){
try{
const ab = Uint8Array.from(atob(p), c=>c.charCodeAt(0)).buffer
playPcm(ab)
}catch(e){}
}
}
}
}
}
} catch(e){if(e.name!=='AbortError') throw e}
abortController = null
}
// ======= Call mode (WebSocket) =======
function toggleMode() {
if(mode==='chat'){ switchTab('call', document.querySelectorAll('.tab-btn')[1]) }
else{ switchTab('chat', document.querySelectorAll('.tab-btn')[0]) }
}
async function initMic() {
if(mic) return
try{
mic = await navigator.mediaDevices.getUserMedia({audio:{echoCancellation:true,noiseSuppression:true}})
}catch(e){ orbInfo.textContent = 'Mic access denied'; throw e }
}
async function toggleCall() {
if(!callActive) startCall()
else stopCall()
}
async function startCall() {
if(callActive) return
try{ await initMic() } catch(e){ return }
if(ws && ws.readyState===1) return
callActive = true
orb.className = 'orb idle'
orbLabel.textContent = 'Connecting...'
orbInfo.textContent = ''
currentCallText = ''
currentCallMsg = null
userBuf = []
spk = false
ws = new WebSocket('ws://'+location.host+'/ws/realtime')
ws.binaryType = 'arraybuffer'
ws.onopen = () => {
orbLabel.textContent = 'Listening'
orbInfo.textContent = 'Speak to start'
startAudioCapture()
}
ws.onmessage = e => {
const m = JSON.parse(e.data)
if(m.type==='vad'){
if(m.speaking && !spk) { sendContext(); stopPcm(); spk=true }
if(!m.speaking && spk) spk=false
if(m.speaking){ orb.className='orb listening'; orbLabel.textContent='You...'; vizBars(true) }
else if(!currentCallText && !callFinished){ orb.className='orb idle'; orbLabel.textContent='Listening'; vizBars(false) }
} else if(m.type==='generating'){
orb.className='orb thinking'; orbLabel.textContent='Thinking'; vizBars(false)
if(spk){ spk=false }
} else if(m.type==='text'){
orb.className='orb speaking'; orbLabel.textContent='Speaking'; vizBars(false)
currentCallText += m.content
if(!currentCallMsg) currentCallMsg = addMsg(callBox, 'assistant', '')
const el = callBox.querySelector('.msg.assistant:last-child .content')
if(el){ el.textContent = currentCallText; callBox.scrollTop = callBox.scrollHeight }
} else if(m.type==='pcm'){
try{
const ab = Uint8Array.from(atob(m.data), c=>c.charCodeAt(0)).buffer
playPcm(ab)
}catch(e){}
} else if(m.type==='done'){
orb.className='orb idle'; orbLabel.textContent='Listening'
if(m.interrupted){ stopPcm(); orbInfo.textContent='Interrupted' }
if(currentCallText){
if(!m.interrupted) history.push({role:'assistant', content:currentCallText})
currentCallText = ''
currentCallMsg = null
}
setTimeout(()=>{ if(callActive) orbInfo.textContent='Speak to start' }, 1000)
}
}
ws.onclose = () => {
stopAudioCapture()
if(callActive){
callActive = false
orb.className='orb idle'; orbLabel.textContent='Call ended'
orbInfo.textContent='Press orb to restart'
}
}
ws.onerror = () => {
orbLabel.textContent='Error'; orbInfo.textContent='Connection failed'
callActive = false
}
}
function stopCall() {
callActive = false
if(ws) { ws.close(); ws = null }
stopAudioCapture()
stopPcm()
orb.className = 'orb idle'
orbLabel.textContent = 'Start Call'
orbInfo.textContent = ''
vizBars(false)
}
function startAudioCapture() {
if(proc) return
rtc = new AudioContext()
const src = rtc.createMediaStreamSource(mic)
proc = rtc.createScriptProcessor(4096, 1, 1)
let acc = new Float32Array(0)
proc.onaudioprocess = e => {
if(!ws || ws.readyState!==1) return
const rs = resamplePcm(e.inputBuffer.getChannelData(0), e.inputBuffer.sampleRate, RT_SAMPLE_RATE)
const merged = new Float32Array(acc.length + rs.length)
merged.set(acc); merged.set(rs, acc.length)
let off = 0
for(; merged.length - off >= RT_BLOCK; off += RT_BLOCK){
const i16 = floatToI16(merged.subarray(off, off + RT_BLOCK))
ws.send(i16.buffer)
if(spk) userBuf.push(new Int16Array(i16))
}
acc = merged.slice(off)
}
src.connect(proc)
proc.connect(rtc.destination)
}
function stopAudioCapture() {
if(proc) { proc.disconnect(); proc = null }
if(rtc) { rtc.close(); rtc = null }
accPcm = null
}
function playPcm(arrayBuffer) {
if(!rtc) return
try{
const f32 = new Float32Array(arrayBuffer.byteLength / 2)
const i16 = new Int16Array(arrayBuffer)
for(let i=0;i<f32.length;i++) f32[i] = i16[i] / 32767
const buf = rtc.createBuffer(1, f32.length, AUDIO_SR)
buf.getChannelData(0).set(f32)
const src = rtc.createBufferSource()
src.buffer = buf
src.connect(rtc.destination)
if(!nextT || nextT < rtc.currentTime) nextT = rtc.currentTime
src.start(nextT)
nextT += f32.length / AUDIO_SR
playing.push(src)
src.onended = () => { const i = playing.indexOf(src); if(i>=0) playing.splice(i,1) }
} catch(e){}
}
function stopPcm() {
for(const s of playing) try{ s.stop() } catch{}
playing = []
if(rtc) nextT = rtc.currentTime
}
function sendContext() {
if(!ws || ws.readyState!==1) return
const msg = {type:'context', voice:selectedVoice}
if(history.length) msg.history = history.slice(-8)
if(camFrame) msg.image = camFrame
ws.send(JSON.stringify(msg))
}
// ======= Camera =======
async function toggleCam() {
if(camStream){
camStream.getTracks().forEach(t=>t.stop())
camStream = null
camWrap.classList.remove('active')
camFrame = null
$('#camBtn').classList.remove('active')
return
}
try{
camStream = await navigator.mediaDevices.getUserMedia({video:{width:320,height:240}})
camVideo.srcObject = camStream
camWrap.classList.add('active')
$('#camBtn').classList.add('active')
// Capture frames periodically
setInterval(() => {
if(!camStream) return
const c = document.createElement('canvas')
c.width = camVideo.videoWidth || 320
c.height = camVideo.videoHeight || 240
c.getContext('2d').drawImage(camVideo, 0, 0)
camFrame = c.toDataURL('image/jpeg', 0.6).split(',')[1]
}, 2000)
}catch(e){ orbInfo.textContent = 'Camera denied' }
}
// ======= Voice Clone =======
function openCloneModal() {
$('#cloneModal').classList.add('active')
$('#cloneStatus').textContent = ''
$('#cloneDelBtn').style.display = 'none'
}
function closeCloneModal() {
$('#cloneModal').classList.remove('active')
if(recMediaRec) { recMediaRec.stop(); recMediaRec = null }
}
function resetRecBtn() {
const btn = $('#recBtn')
btn.textContent = 'Hold to Record'
btn.classList.remove('recording')
}
let recMediaRec = null, recChunks = []
function startRec() {
recChunks = []
const btn = $('#recBtn')
btn.textContent = 'Recording...'
btn.classList.add('recording')
try{
const stream = mic ? new MediaStream(mic.getAudioTracks()) : null
if (!stream) {
navigator.mediaDevices.getUserMedia({audio:{echoCancellation:true,noiseSuppression:true}}).then(s => {
const r = new MediaRecorder(s, {mimeType:'audio/webm;codecs=opus'})
recMediaRec = r
r.ondataavailable = e => { if(e.data.size) recChunks.push(e.data) }
r.start()
}).catch(e => { $('#cloneStatus').textContent = 'Record failed: '+e.message; resetRecBtn() })
return
}
recMediaRec = new MediaRecorder(stream, {mimeType:'audio/webm;codecs=opus'})
recMediaRec.ondataavailable = e => { if(e.data.size) recChunks.push(e.data) }
recMediaRec.start()
}catch(e){ $('#cloneStatus').textContent = 'Record failed: '+e.message; resetRecBtn() }
}
async function stopRec() {
const btn = $('#recBtn')
btn.textContent = 'Hold to Record'
btn.classList.remove('recording')
if(!recMediaRec) return
recMediaRec.stop()
await new Promise(r => recMediaRec.onstop = r)
const blob = new Blob(recChunks, {type:'audio/webm;codecs=opus'})
const reader = new FileReader()
reader.onload = async () => {
const b64 = reader.result.split(',')[1]
const name = $('#cloneName').value.trim() || 'voice_clone'
$('#cloneStatus').textContent = 'Cloning...'
try{
const r = await fetch('/clone_voice', {method:'POST', body:JSON.stringify({audio:b64, name}), headers:{'Content-Type':'application/json'}})
const d = await r.json()
if(d.ok){ $('#cloneStatus').textContent = 'Cloned!'; loadVoices() }
else $('#cloneStatus').textContent = 'Error: '+d.error
}catch(e){ $('#cloneStatus').textContent = 'Error: '+e.message }
}
reader.readAsDataURL(blob)
recMediaRec = null
}
async function deleteVoice() {
const name = $('#cloneName').value.trim()
if(!name) return
try{
await fetch('/delete_voice', {method:'POST', body:JSON.stringify({name}), headers:{'Content-Type':'application/json'}})
document.addEventListener('click', e => { if(!e.target.closest('.top-right')) morePop.classList.remove('on') })
loadVoices()
closeCloneModal()
}catch(e){}
}
let voiceGroups = {builtin:[], unseen:[], manual:[]}
async function loadVoices(sel) {
try{
const keep = sel || voiceSel.value || 'default'
const r = await fetch('/voices')
const d = await r.json()
voiceGroups = {builtin:d.builtin||[], unseen:d.unseen||[], manual:d.manual||[]}
voiceSel.innerHTML = '<option value="default">default</option>'
for(const [label,names] of [['builtin',voiceGroups.builtin],['unseen',voiceGroups.unseen],['manual',voiceGroups.manual]]){
if(!names.length) continue
const g = document.createElement('optgroup')
g.label = label
names.forEach(v => { const o = document.createElement('option'); o.value=v; o.textContent=v; g.appendChild(o) })
voiceSel.appendChild(g)
}
if([...voiceSel.options].some(o => o.value === keep)) voiceSel.value = keep
renderVoiceList()
}catch(e){}
}
function renderVoiceList() {
let h = ''
const sel = voiceSel.value
moreBtn.textContent = sel
h += `<div class="vitem${sel==='default'?' on':''}" data-voice="default">default</div>`
for(const [g,ns] of [['builtin',voiceGroups.builtin],['unseen',voiceGroups.unseen],['manual',voiceGroups.manual]]){
if(!ns.length) continue
h += `<div class="vgroup">${g}</div>`
for(const v of ns)
h += `<div class="vitem${v===sel?' on':''}" data-voice="${v}"><span>${v}</span>${g==='manual'?`<span class="vx" data-del="${v}">×</span>`:''}</div>`
}
h += '<div class="vadd">+ clone</div>'
morePop.innerHTML = h
}
morePop.onclick = e => {
const item = e.target.closest('[data-voice]')
if (item) {
voiceSel.value = item.dataset.voice
selectedVoice = item.dataset.voice
renderVoiceList()
morePop.classList.remove('on')
return
}
const del = e.target.closest('[data-del]')
if (del) {
e.stopPropagation()
const name = del.dataset.del
if (!confirm('Delete voice: ' + name + '?')) return
fetch('/delete_voice', {method:'POST', headers:{'Content-Type':'application/json'}, body:JSON.stringify({name})})
.then(r => r.json()).then(d => { if(!d.ok) throw new Error(d.error); loadVoices(selectedVoice===name?'default':selectedVoice) }).catch(e => alert(e.message))
return
}
if (e.target.classList.contains('vadd')) {
morePop.classList.remove('on')
openCloneModal()
}
}
loadVoices()
function onVoiceChange() { selectedVoice = voiceSel.value }
// ======= Init =======
// Add welcome message
addMsg(chatBox, 'assistant', 'Hello! I\'m Omni-O. Type a message or switch to Call mode for real-time voice conversation.')
</script>
</body>
</html>