omni / scripts /web_demo.html
chenbhao's picture
Restructure repo: rename classes, split datasets, organize trainers by modality, add YAML config + root trainer entry points
c6bc767
Raw
History Blame Contribute Delete
54.5 kB
<!DOCTYPE html>
<html lang="zh">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>MiniMind-O</title>
<link rel="icon" type="image/png" href="https://www.modelscope.cn/studio/gongjy/MiniMind/resolve/master/images/logo2.png">
<style>
:root {
--bg:#1e1e20; --p1:#2a2a2e; --p2:#35353b; --p3:#40404a;
--bd:#55555e; --bd2:#7a7a85;
--fg:#ececef; --mu:#9a9aa0; --dim:#8a8a92;
--u:#7fc5e0; --a:#e5b57a; --danger:#d84545; --cam:#7ad88a;
--serif:'Georgia','Times New Roman',serif; --mono:'SF Mono',Consolas,monospace;
}
*{margin:0;padding:0;box-sizing:border-box}
html,body{height:100%}
body{height:100vh;background:var(--bg);color:var(--fg);font-family:var(--serif);display:flex;align-items:center;justify-content:center;overflow:hidden}
.app{max-width:860px;width:100%;height:calc(100vh - 40px);display:flex;flex-direction:column;padding:20px 24px;background:var(--p1);border:1px solid var(--bd);overflow:hidden}
.topbar{display:flex;align-items:center;justify-content:space-between;padding:4px 0 12px;flex-shrink:0;gap:10px}
.brand{display:flex;align-items:center;gap:10px;min-width:0;flex:1}
.brand>div{min-width:0}
.brand img{height:34px}
.brand h1{font-size:1.15rem;font-weight:700;font-style:italic;color:#fff}
.brand .sub{font-size:.72rem;color:var(--mu);font-style:italic}
.topright{display:flex;gap:8px;align-items:center}
.btn{padding:5px 10px;font-size:.75rem;cursor:pointer;background:var(--p2);border:1px solid var(--bd);color:var(--fg);font-family:var(--serif);font-style:italic;display:inline-flex;align-items:center;gap:5px}
.btn:hover:not(:disabled){background:var(--p3);border-color:var(--bd2)}
.btn:disabled{opacity:.4;cursor:not-allowed}
.btn.danger{color:var(--danger);border-color:#6a3a3a}
.btn.danger:hover:not(:disabled){background:var(--danger);border-color:var(--danger);color:#fff}
.btn svg{width:12px;height:12px;fill:currentColor}
.model-sel{max-width:150px;padding:5px 8px;font-size:.72rem;background:var(--p2);border:1px solid var(--bd);color:var(--fg);font-family:var(--serif);font-style:italic;outline:none}
.brand .model-sel{max-width:260px;padding:0 34px 0 0;background:transparent;border:none;font-size:1.15rem;font-weight:700;color:#fff}
.model-sel:focus{border-color:var(--bd2)}
.mini-sel{max-width:130px;padding:5px 8px;font-size:.72rem;background:var(--p2);border:1px solid var(--bd);color:var(--fg);font-family:var(--serif);font-style:italic;outline:none}
.mini-sel:focus{border-color:var(--bd2)}
.voice-sel{display:none}
.more{position:relative}
.more-pop{top:100%;right:0;left:auto;margin-top:6px;min-width:130px}
.vgroup{padding:4px 10px;font-size:.6rem;color:var(--mu);font-style:italic}
.vitem{padding:5px 10px;display:flex;justify-content:space-between;align-items:center;cursor:pointer;font-size:.72rem;font-family:var(--serif);font-style:italic;color:var(--fg);gap:8px}
.vitem:hover{background:var(--p3)}
.vitem.on{color:var(--u)}
.vx{color:var(--mu);cursor:pointer;font-style:normal;font-size:.8rem;padding:0 2px;line-height:1}
.vx:hover{color:var(--danger)}
.vadd{padding:5px 10px;font-size:.72rem;color:var(--mu);cursor:pointer;font-family:var(--serif);font-style:italic;border-top:1px solid var(--bd)}
.vadd:hover{background:var(--p3);color:var(--fg)}
.status{display:inline-flex;align-items:center;gap:10px;padding:5px 10px;font-size:.65rem;font-family:var(--mono);background:var(--p2);border:1px solid var(--bd);transition:opacity .3s}
.status.hidden{opacity:0;pointer-events:none}
.status .row{display:inline-flex;gap:5px;white-space:nowrap}
.status .lab{color:var(--mu);font-style:italic}
.chat{flex:1;overflow-y:auto;padding:12px 0;min-height:0;display:flex;flex-direction:column;gap:18px;scrollbar-width:none;-ms-overflow-style:none}
.chat::-webkit-scrollbar{display:none}
.msg{display:flex;flex-direction:column;gap:6px;animation:fi .25s ease}
.msg.user{align-items:flex-end}
.msg.user .body{align-items:flex-end}
@keyframes fi{from{opacity:0;transform:translateY(3px)}to{opacity:1;transform:translateY(0)}}
.meta{font-size:.72rem;font-style:italic}
.msg.user .meta{color:var(--u)}
.msg.assistant .meta{color:var(--a)}
.body{display:flex;flex-direction:column;gap:8px}
.text{line-height:1.65;white-space:pre-wrap;word-break:break-word}
.img{display:block;max-width:160px;max-height:160px}
.msg.user .text:not(.voice){color:var(--u)}
.voice{font-family:var(--mono);font-size:1.05rem;line-height:1.2;color:var(--bd)}
.msg.user .voice{color:rgba(127,197,224,.5)}
.prog{text-decoration:underline dashed;text-decoration-color:var(--bd);text-underline-offset:4px;text-decoration-thickness:1px}
.prog .tk{cursor:pointer;padding:1px 0;transition:color .08s,background .08s}
.msg.assistant .prog .tk.played{color:var(--a)}
.msg.assistant .prog .tk:hover{background:rgba(229,181,122,.2)}
.msg.assistant .prog.on .tk.cur{background:rgba(229,181,122,.45)}
.msg.user .prog .tk.played{color:var(--u)}
.msg.user .prog .tk:hover{background:rgba(127,197,224,.18)}
.msg.user .prog.on .tk.cur{background:rgba(127,197,224,.35)}
.player{display:inline-flex;align-items:center;gap:10px;padding:6px 12px;width:320px;max-width:100%;background:var(--p2);border:1px solid var(--bd)}
.pp{background:transparent;border:none;color:inherit;cursor:pointer;font-family:var(--mono);font-size:.9rem;width:20px;text-align:left}
.pp:hover{opacity:.75}
.bar-wrap{flex:1;height:3px;cursor:pointer;background:var(--p1)}
.bar{height:100%;width:0;transition:width .08s linear}
.msg.user .bar,.msg.user .pp{background:var(--u);color:var(--u)}
.msg.assistant .bar,.msg.assistant .pp{background:var(--a);color:var(--a)}
.msg.user .bar-wrap{background:var(--p1)}
.time{font-size:.7rem;font-family:var(--mono);min-width:76px;text-align:right;color:var(--mu)}
.input-area{flex-shrink:0;padding:10px 0 4px}
.compose{display:flex;gap:6px}
.compose button{padding:0 12px;min-width:42px;cursor:pointer;font-size:.85rem;background:var(--p2);border:1px solid var(--bd);color:var(--fg);display:flex;align-items:center;justify-content:center;font-family:inherit}
.compose button svg{width:16px;height:16px;fill:currentColor}
.compose button:hover:not(:disabled){background:var(--p3);border-color:var(--bd2)}
.compose button:disabled{opacity:.4;cursor:not-allowed}
.compose button.primary{background:var(--fg);color:#1a1a1e;border-color:var(--fg)}
.compose button.primary:hover:not(:disabled){background:#fff}
.compose button.rec{animation:pulse 1s infinite;background:var(--danger);color:#fff;border-color:var(--danger)}
@keyframes pulse{0%,100%{opacity:1}50%{opacity:.5}}
.text-wrap{flex:1;display:flex;align-items:center;gap:6px;padding:0 6px 0 10px;min-width:0;background:var(--p2);border:1px solid var(--bd)}
.text-wrap:focus-within{border-color:var(--fg)}
.text-wrap input{flex:1;min-width:0;padding:10px 4px;font-size:.9rem;outline:none;background:transparent;border:none;color:inherit;font-family:inherit}
.text-wrap input::placeholder{color:var(--dim)}
.attach{display:flex;gap:4px;flex-wrap:nowrap;flex-shrink:0;max-width:45%;overflow-x:auto;scrollbar-width:none}
.attach::-webkit-scrollbar{display:none}
.attach:empty{display:none}
.chip{display:inline-flex;align-items:center;gap:6px;padding:3px 7px;font-size:.72rem;background:var(--p1);border:1px solid var(--bd);flex-shrink:0}
.chip .x{cursor:pointer;opacity:.55;font-size:.85rem}
.chip .x:hover{opacity:1}
.chip .play{cursor:pointer}
.chip img{width:22px;height:22px;object-fit:cover}
.pop{position:absolute;background:var(--p2);border:1px solid var(--bd);display:none;flex-direction:column;z-index:50;min-width:110px;font-family:var(--serif);font-style:italic;white-space:nowrap}
.pop.on{display:flex}
.pop button{padding:5px 10px;background:transparent;border:none;color:var(--fg);cursor:pointer;text-align:left;font-size:.72rem;font-family:inherit;font-style:inherit;line-height:1.2}
.pop button:hover{background:var(--p3)}
.snap{position:fixed;inset:0;z-index:200;background:rgba(0,0,0,.85);display:none;align-items:center;justify-content:center;flex-direction:column;gap:14px}
.snap.on{display:flex}
.snap video{max-width:80vw;max-height:70vh;background:#000;border:1px solid var(--bd)}
.snap .sr{display:flex;gap:10px}
.snap .sr button{padding:8px 18px;background:var(--p2);border:1px solid var(--bd);color:var(--fg);cursor:pointer;font-family:var(--serif);font-style:italic}
.snap .sr button.primary{background:var(--fg);color:#1a1a1e;border-color:var(--fg)}
.clone-card{width:min(92vw,520px);padding:22px;background:var(--p1);border:1px solid var(--bd);display:flex;flex-direction:column;gap:14px}
.clone-title{font-size:1rem;font-style:italic;color:#fff}
.clone-hint{font-size:.76rem;color:var(--mu);font-style:italic;line-height:1.6}
.clone-name{width:100%;padding:10px 12px;background:var(--p2);border:1px solid var(--bd);color:var(--fg);font-family:inherit;font-size:.85rem;outline:none}
.clone-name:focus{border-color:var(--fg)}
.clone-script{padding:12px 14px;background:var(--p2);border:1px solid var(--bd);font-size:1rem;line-height:1.85}
.clone-stat{min-height:18px;font-size:.72rem;color:var(--mu);font-family:var(--mono)}
/* ===== Call mode ===== */
body>.app,#call{will-change:transform,opacity,filter;transform-origin:center center}
body>.app{transition:opacity .55s cubic-bezier(.22,1,.36,1),transform .7s cubic-bezier(.22,1,.36,1),filter .55s cubic-bezier(.22,1,.36,1)}
body.call>.app{opacity:0;transform:scale(.92);filter:blur(8px);pointer-events:none}
#call{position:fixed;inset:0;z-index:100;background:var(--bg);overflow:hidden;opacity:0;transform:scale(1.08);filter:blur(10px);pointer-events:none;visibility:hidden;transition:opacity .55s cubic-bezier(.22,1,.36,1),transform .7s cubic-bezier(.22,1,.36,1),filter .55s cubic-bezier(.22,1,.36,1),visibility 0s .7s}
body.call #call{opacity:1;transform:scale(1);filter:blur(0);pointer-events:auto;visibility:visible;transition:opacity .55s cubic-bezier(.22,1,.36,1),transform .7s cubic-bezier(.22,1,.36,1),filter .55s cubic-bezier(.22,1,.36,1),visibility 0s 0s}
#call .app{max-width:560px;margin:20px auto;padding:18px 22px;position:relative}
#call .topbar{border-bottom:1px dashed var(--bd)}
#call .brand h1{font-size:1.05rem}
#call .brand .model-sel{max-width:230px;padding-right:38px;font-size:1.05rem}
#call .brand .sub{font-size:.68rem}
.pill{display:inline-flex;align-items:center;gap:6px;padding:5px 10px;font-size:.7rem;font-family:var(--mono);background:var(--p2);border:1px solid var(--bd);color:var(--mu);font-style:italic;line-height:1.2}
.pill .dot{width:6px;height:6px;background:var(--mu);border-radius:50%}
.pill.live{color:var(--u);border-color:#3c5966}
.pill.live .dot{background:var(--u);box-shadow:0 0 6px var(--u);animation:cp 1.4s ease-in-out infinite}
@keyframes cp{0%,100%{opacity:1}50%{opacity:.4}}
#call .btn.active{background:var(--u);color:#1a1a1e;border-color:var(--u)}
.hero{flex:1;display:flex;align-items:center;justify-content:center;min-height:0;padding:14px 0;position:relative}
.orb{position:relative;width:280px;height:280px;max-width:60vh;max-height:60vh;display:flex;align-items:center;justify-content:center;will-change:transform}
.orb svg{width:100%;height:100%;overflow:visible}
.blob{fill:none;stroke-linejoin:round;transition:stroke .35s,opacity .45s}
.blob.l0{stroke:#5a5a64;stroke-width:1.2;opacity:.55}
.blob.l1{stroke:var(--bd2);stroke-width:1.4;opacity:.85}
.blob.l2{stroke:var(--fg);stroke-width:1.6}
.orb.camon .blob.l0,.orb.camon .blob.l1{opacity:0}
.orb.camon .blob.l2{stroke:var(--cam);filter:drop-shadow(0 0 4px rgba(122,216,138,.45))}
svg.listen .blob.l2{stroke:var(--u);filter:drop-shadow(0 0 4px rgba(127,197,224,.45))}
svg.listen .blob.l1{stroke:rgba(127,197,224,.6)}
svg.speak .blob.l2{stroke:var(--a);filter:drop-shadow(0 0 5px rgba(229,181,122,.5))}
svg.speak .blob.l1{stroke:rgba(229,181,122,.6)}
svg.gen .blob.l2{stroke:var(--fg);filter:drop-shadow(0 0 4px rgba(236,236,239,.35))}
.core{display:none}
svg.listen .core{fill:var(--u)}
svg.speak .core{fill:var(--a)}
.ripple{fill:none;stroke:var(--mu);stroke-width:1;transform-origin:center;animation:rip 1.6s ease-out forwards}
.ripple.user{stroke:var(--u)}
.ripple.assistant{stroke:var(--a)}
@keyframes rip{0%{transform:scale(.55);opacity:.55}100%{transform:scale(1.35);opacity:0}}
.lbl{position:absolute;top:50%;left:50%;transform:translate(-50%,-50%);text-align:center;pointer-events:none;display:flex;flex-direction:column;align-items:center;gap:6px}
.ic{width:34px;height:34px;color:var(--mu);transition:color .3s;display:flex;align-items:center;justify-content:center}
.ic svg{width:100%;height:100%;stroke:currentColor;fill:none;stroke-width:2;stroke-linecap:round;stroke-linejoin:round}
.lbl.listen .ic{color:var(--u)}
.lbl.speak .ic{color:var(--a)}
.lbl.gen .ic{color:var(--fg)}
.lbl .st{display:none}
.ic svg *{transform-box:view-box;transform-origin:12px 12px}
.lbl.idle .ic .dot{animation:breathe 2.2s ease-in-out infinite;transform-origin:center;transform-box:view-box}
@keyframes breathe{0%,100%{opacity:.4;transform:scale(.8)}50%{opacity:1;transform:scale(1.15)}}
.lbl.listen .ic .w{animation:sigin 1.6s ease-out infinite}
.lbl.listen .ic .w2{animation-delay:.3s}
.lbl.listen .ic .w3{animation-delay:.6s}
@keyframes sigin{0%{opacity:0;transform:scale(1.2)}60%{opacity:1;transform:scale(1)}100%{opacity:0;transform:scale(.85)}}
.lbl.gen .ic svg{animation:spin 1.2s cubic-bezier(.5,.1,.5,.9) infinite}
@keyframes spin{to{transform:rotate(360deg)}}
.lbl.speak .ic .a1{animation:radout 1.3s ease-out infinite}
.lbl.speak .ic .a2{animation:radout 1.3s ease-out .25s infinite}
.lbl.speak .ic .a3{animation:radout 1.3s ease-out .5s infinite}
@keyframes radout{0%{opacity:0;transform:scale(.7)}40%{opacity:1}100%{opacity:0;transform:scale(1.15)}}
.up{display:none}
.cam{position:absolute;top:50%;left:50%;width:180px;height:180px;object-fit:cover;background:var(--bg);border:1px solid var(--bd);transform:translate(-50%,-50%) scale(.3);opacity:0;transition:transform .5s cubic-bezier(.4,0,.2,1),opacity .3s,border-color .35s,box-shadow .35s;pointer-events:none;will-change:transform,opacity}
.orb.camon .cam{transform:translate(-50%,-50%) scale(1);opacity:1;border-color:var(--cam);box-shadow:0 0 14px rgba(122,216,138,.35)}
.orb.camon .core{opacity:0}
.orb.camon .lbl{top:auto;bottom:-8px;transform:translate(-50%,100%)}
.orb.camon .lbl .st{font-size:.9rem}
svg.listen ~ .cam{border-color:var(--u);box-shadow:0 0 14px rgba(127,197,224,.45)}
svg.speak ~ .cam{border-color:var(--a);box-shadow:0 0 14px rgba(229,181,122,.45)}
.latest{width:100%;padding:10px 14px;background:var(--p2);border:1px solid var(--bd);display:flex;flex-direction:column;gap:5px;flex-shrink:0;max-height:120px;overflow-y:auto;scrollbar-width:none}
.latest::-webkit-scrollbar{display:none}
.latest .lm{font-family:var(--mono);font-size:.6rem;color:var(--mu);font-style:italic}
.latest .lt{font-size:.82rem;line-height:1.55;font-style:italic;opacity:.9;white-space:pre-wrap;word-break:break-word}
.latest.user .lm{color:var(--u)}
.latest.assistant .lm{color:var(--a)}
.latest.empty .lt{color:var(--bd)}
.ctrls{display:flex;justify-content:center;gap:10px;padding:14px 0 2px;margin-top:12px;border-top:1px dashed var(--bd);flex-shrink:0}
.cbtn{width:72px;height:58px;background:var(--p2);border:1px solid var(--bd);color:var(--fg);cursor:pointer;display:flex;flex-direction:column;align-items:center;justify-content:center;gap:3px;font-family:var(--mono);font-size:.58rem;letter-spacing:1px;text-transform:uppercase;transition:all .18s}
.cbtn svg{width:18px;height:18px;fill:currentColor}
.cbtn:hover{background:var(--p3);border-color:var(--bd2)}
.cbtn.active{background:var(--u);color:#1a1a1e;border-color:var(--u)}
.cbtn.danger{color:var(--danger);border-color:#6a3a3a}
.cbtn.danger:hover{background:var(--danger);color:#fff}
.trans{position:absolute;inset:0;background:var(--p1);z-index:20;display:flex;flex-direction:column;padding:18px 22px;transform:translateY(100%);transition:transform .3s ease;will-change:transform;contain:layout paint}
.trans.on{transform:translateY(0)}
.trans .th{display:flex;justify-content:space-between;padding-bottom:10px;border-bottom:1px dashed var(--bd)}
.trans .tt{font-style:italic;font-size:.95rem;display:flex;gap:10px;align-items:center}
.trans .cnt{font-family:var(--mono);font-size:.68rem;color:var(--mu)}
#call .chat:empty::before{content:'no turns yet · say something';display:block;text-align:center;color:var(--bd);font-style:italic;padding:40px 0;font-size:.82rem}
@media(max-width:540px){
body{display:block}
.app{height:100vh;height:100dvh;max-width:none;border:none;padding:14px 16px}
.brand img{height:28px}
.brand h1{font-size:1rem}
.brand .sub{display:none}
.topbar{gap:6px}
.status{display:none!important}
.player{width:100%}
.vitem,.vadd{padding:8px 12px}
.compose button{min-width:38px;padding:0 10px}
.text-wrap input{padding:8px 4px;font-size:.85rem}
.clone-card{padding:16px}
.clone-hint{font-size:.7rem}
#call .app{margin:0;height:100vh;height:100dvh;border:none;padding:14px 16px}
.orb{width:240px;height:240px}
.cbtn{width:58px;height:52px}
}
</style>
</head>
<body>
<div class="app">
<div class="topbar">
<div class="brand">
<img src="https://www.modelscope.cn/studio/gongjy/MiniMind/resolve/master/images/logo2.png" alt="logo">
<div><select class="model-sel" id="modelSel"></select><div class="sub">multimodal inference · text / image / audio → text + audio</div></div>
</div>
<div class="topright">
<div class="status hidden" id="stat"><div class="row"><span class="lab">text_ttft</span><span id="tt"></span></div><div class="row"><span class="lab">audio_ttft</span><span id="ta"></span></div><div class="row"><span class="lab">status</span><span id="ts">idle</span></div></div>
<select class="voice-sel" id="voiceSel"><option value="default">default</option></select>
<div class="more">
<button class="btn" id="moreBtn">default</button>
<div class="pop more-pop" id="morePop"></div>
</div>
<button class="btn" id="callBtn"><svg viewBox="0 0 24 24"><path d="M6.62 10.79a15.05 15.05 0 0 0 6.59 6.59l2.2-2.2a1 1 0 0 1 1.02-.24c1.12.37 2.32.57 3.57.57a1 1 0 0 1 1 1V20a1 1 0 0 1-1 1A17 17 0 0 1 3 4a1 1 0 0 1 1-1h3.5a1 1 0 0 1 1 1c0 1.25.2 2.45.57 3.57.1.35 0 .74-.25 1.02l-2.2 2.2z"/></svg>Call</button>
</div>
</div>
<div class="chat" id="chat"></div>
<div class="input-area"><div class="compose">
<div style="position:relative;display:flex">
<button id="imgBtn" title="image"><svg viewBox="0 0 24 24"><path d="M21 19V5a2 2 0 0 0-2-2H5a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2zm-2 0H5l3.5-4.5 2.5 3.01L14.5 13l4.5 6zM8.5 8A1.5 1.5 0 1 1 7 9.5 1.5 1.5 0 0 1 8.5 8z"/></svg></button>
<div class="pop" id="imgPop" style="bottom:100%;left:0;margin-bottom:6px"><button id="fileBtn">from file</button><button id="shotBtn">take photo</button></div>
</div>
<input type="file" id="imgIn" accept="image/*" hidden>
<button id="recBtn" title="录音"><svg viewBox="0 0 24 24"><path d="M12 14c1.66 0 3-1.34 3-3V5c0-1.66-1.34-3-3-3S9 3.34 9 5v6c0 1.66 1.34 3 3 3zm5.91-3c-.49 0-.9.36-.98.85C16.52 14.2 14.47 16 12 16s-4.52-1.8-4.93-4.15c-.08-.49-.49-.85-.98-.85-.61 0-1.09.54-1 1.14.49 3 2.89 5.35 5.91 5.78V20c0 .55.45 1 1 1s1-.45 1-1v-2.08c3.02-.43 5.42-2.78 5.91-5.78.1-.6-.39-1.14-1-1.14z"/></svg></button>
<div class="text-wrap"><div class="attach" id="att"></div><input type="text" id="txt" placeholder="Message MiniMind-O..." autocomplete="off"></div>
<button id="sendBtn" class="primary"><svg viewBox="0 0 24 24"><path d="M2.01 21L23 12 2.01 3 2 10l15 2-15 2z"/></svg></button>
</div></div>
</div>
<div class="snap" id="snap"><video id="snapV" autoplay playsinline muted></video><div class="sr"><button id="snapCancel">cancel</button><button id="snapOk" class="primary">capture</button></div></div>
<div class="snap" id="cloneModal"><div class="clone-card"><div class="clone-title">Clone Voice</div><div class="clone-hint">请自然朗读下面这句话。建议 3 到 6 秒,环境尽量安静。录完后会自动做 1.5 倍速、提取 `CAM++` speaker embedding 与 `Mimi` ref codes,然后立即加入音色列表。</div><input class="clone-name" id="cloneName" placeholder="音色名,例如:小王" maxlength="24"><div class="clone-script" id="cloneText">今天阳光很好,欢迎来到我的语音克隆小实验。</div><div class="clone-stat" id="cloneStat">点击 start 开始录音</div><div class="sr"><button id="cloneCancel">cancel</button><button id="cloneRec" class="primary">start</button></div></div></div>
<div id="call"><div class="app">
<div class="topbar">
<div class="brand"><img src="https://www.modelscope.cn/studio/gongjy/MiniMind/resolve/master/images/logo2.png" alt=""><div><select class="model-sel" id="callModelSel"></select><div class="sub">live voice · bidirectional streaming</div></div></div>
<div class="topright">
<select class="mini-sel" id="callVoiceSel"><option value="default">default</option></select>
<button class="btn" id="logBtn"><svg viewBox="0 0 24 24"><path d="M14 2H6c-1.1 0-1.99.9-1.99 2L4 20c0 1.1.89 2 1.99 2H18c1.1 0 2-.9 2-2V8l-6-6zM6 20V4h7v5h5v11H6zm2-3h8v-2H8v2zm0-4h8v-2H8v2z"/></svg>log</button>
<span class="pill" id="pill"><span class="dot"></span><span id="connT">connecting</span></span>
</div>
</div>
<div class="hero"><div class="orb" id="orb">
<svg viewBox="-160 -160 320 320" id="osvg"><g id="rip"></g><path class="blob l0" id="b0"/><path class="blob l1" id="b1"/><path class="blob l2" id="b2"/><circle class="core" id="core" cx="0" cy="0" r="4"/></svg>
<video class="cam" id="camV" autoplay playsinline muted></video>
<div class="lbl" id="lbl"><div class="ic" id="stIc"></div><div class="st" id="stT">init</div><div class="up" id="upT">00:00:00</div></div>
</div></div>
<div class="latest empty" id="lat"><span class="lm" id="lmE">latest · awaiting first turn</span><span class="lt" id="ltE">speak naturally — model responds with voice and text</span></div>
<div class="ctrls">
<button class="cbtn" id="camBtn"><svg viewBox="0 0 24 24"><path d="M17 10.5V7c0-.55-.45-1-1-1H4c-.55 0-1 .45-1 1v10c0 .55.45 1 1 1h12c.55 0 1-.45 1-1v-3.5l4 4v-11l-4 4z"/></svg><span>cam</span></button>
<button class="cbtn danger" id="endBtn"><svg viewBox="0 0 24 24"><path d="M12 9c-1.6 0-3.15.25-4.6.72v3.1c0 .39-.23.74-.56.9-.98.49-1.87 1.12-2.66 1.85-.18.18-.43.28-.7.28-.28 0-.53-.11-.71-.29L.29 13.08c-.18-.17-.29-.42-.29-.7 0-.28.11-.53.29-.71C3.34 8.78 7.46 7 12 7s8.66 1.78 11.71 4.67c.18.18.29.43.29.71 0 .28-.11.53-.29.71l-2.48 2.48c-.18.18-.43.29-.71.29-.27 0-.52-.11-.7-.28-.79-.74-1.69-1.36-2.67-1.85-.33-.16-.56-.5-.56-.9v-3.1C15.15 9.25 13.6 9 12 9z"/></svg><span>end</span></button>
</div>
<div class="trans" id="trans"><div class="th"><div class="tt">session transcript <span class="cnt" id="tcnt">0 turns</span></div><button class="btn" id="closeBtn">× close</button></div><div class="chat" id="cchat"></div></div>
</div></div>
<script>
const $=id=>document.getElementById(id);
const chat=$('chat'),txt=$('txt'),sendBtn=$('sendBtn'),recBtn=$('recBtn'),imgBtn=$('imgBtn'),imgIn=$('imgIn'),callBtn=$('callBtn'),att=$('att');
const stat=$('stat'),tt=$('tt'),ta=$('ta'),ts=$('ts'),voiceSel=$('voiceSel'),modelSel=$('modelSel'),callModelSel=$('callModelSel'),callVoiceSel=$('callVoiceSel');
const moreBtn=$('moreBtn'),morePop=$('morePop');
const CLONE_PROMPT='今天阳光很好,欢迎来到我的语音克隆小实验。';
let voiceGroups={builtin:[],unseen:[],manual:[]};
const closeMore=()=>morePop.classList.remove('on');
const modelLabel=m=>m;
async function loadVoices(sel){
const keep=sel||voiceSel.value||'default';
const keepCall=callVoiceSel.value||'default';
voiceSel.innerHTML='<option value="default">default</option>';
const d=await fetch('/voices').then(r=>r.json());
voiceGroups={builtin:d.builtin||[],unseen:d.unseen||[],manual:d.manual||[]};
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;
callVoiceSel.innerHTML=voiceSel.innerHTML;
if([...callVoiceSel.options].some(o=>o.value===keepCall))callVoiceSel.value=keepCall;
renderVoiceList();
}
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;
}
loadVoices().catch(()=>{});
async function loadModels(){
const d=await fetch('/models').then(r=>r.json());
modelSel.innerHTML='';callModelSel.innerHTML='';
for(const m of d.models||[]){const o=document.createElement('option');o.value=m;o.textContent=modelLabel(m);modelSel.appendChild(o);callModelSel.appendChild(o.cloneNode(true))}
if(d.current){modelSel.value=d.current;callModelSel.value=d.current}
}
async function switchModelSelect(sel, oldSel){
const old=sel.dataset.current||sel.value,name=sel.value;
const wasCall=document.body.classList.contains('call');
stopChat();if(callStop)callStop();stat.classList.remove('hidden');ts.textContent='switching';sel.disabled=true;
try{
const r=await fetch('/switch_model',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({name})});
const d=await r.json();
if(!r.ok||!d.ok)throw new Error(d.error||'switch failed');
modelSel.value=name;callModelSel.value=name;modelSel.dataset.current=name;callModelSel.dataset.current=name;ts.textContent=`loaded ${name}`;if(wasCall)openCall();
}catch(e){sel.value=old;if(oldSel)oldSel.value=old;ts.textContent='switch failed';alert(e.message)}
sel.disabled=false;
}
modelSel.onchange=()=>switchModelSelect(modelSel,callModelSel);
callModelSel.onchange=()=>switchModelSelect(callModelSel,modelSel);
callVoiceSel.onchange=()=>{if(window.callSendCtx)window.callSendCtx()};
loadModels().then(()=>{modelSel.dataset.current=modelSel.value;callModelSel.dataset.current=callModelSel.value}).catch(()=>{});
/* ------------- shared helpers ------------- */
function decPcm(b64){
const bin=atob(b64),i16=new Int16Array(bin.length/2);
for(let i=0;i<i16.length;i++)i16[i]=bin.charCodeAt(i*2)|(bin.charCodeAt(i*2+1)<<8);
const f32=new Float32Array(i16.length),fade=240;
for(let i=0;i<i16.length;i++)f32[i]=i16[i]/32768;
for(let i=0;i<fade&&i<f32.length;i++){f32[i]*=i/fade;f32[f32.length-1-i]*=i/fade;}
return {i16,f32};
}
function buildWav(chunks,sr){
let n=0;for(const c of chunks)n+=c.length;
const buf=new ArrayBuffer(44+n*2),v=new DataView(buf),w=(o,s)=>{for(let i=0;i<s.length;i++)v.setUint8(o+i,s.charCodeAt(i))};
w(0,'RIFF');v.setUint32(4,36+n*2,true);w(8,'WAVE');w(12,'fmt ');v.setUint32(16,16,true);v.setUint16(20,1,true);v.setUint16(22,1,true);
v.setUint32(24,sr,true);v.setUint32(28,sr*2,true);v.setUint16(32,2,true);v.setUint16(34,16,true);w(36,'data');v.setUint32(40,n*2,true);
let o=44;for(const c of chunks)for(let i=0;i<c.length;i++){v.setInt16(o,c[i],true);o+=2;}
return new Blob([buf],{type:'audio/wav'});
}
const blobToB64=blob=>new Promise(r=>{const fr=new FileReader();fr.onloadend=()=>r(fr.result.split(',')[1]);fr.readAsDataURL(blob)});
const fmtT=s=>{if(!isFinite(s)||s<0)s=0;s=Math.floor(s);return `${Math.floor(s/60)}:${String(s%60).padStart(2,'0')}`};
const fmtDur=s=>{s=Math.max(0,Math.floor(s));const h=s/3600|0,m=(s%3600/60)|0,x=s%60;return [h,m,x].map(n=>String(n).padStart(2,'0')).join(':')};
const waveChars=n=>{const c='▁▂▃▄▅▆▇';let s='';for(let i=0;i<n;i++){const v=(Math.sin(i*.7)+Math.sin(i*.31+1.3)+Math.sin(i*1.9+.4))/3;s+=c[Math.max(0,Math.min(c.length-1,Math.floor((v+1)/2*c.length)))];}return s};
function attachProg(audio,el,durHint,initPlayed){
const raw=el.textContent;if(!raw)return;
el.innerHTML='';el.classList.add('prog');
const chars=[...raw],wts=chars.map(c=>/\s/.test(c)?.2:/[,、;;::,]/.test(c)?.5:/[.。!!??…—]/.test(c)?1:1);
const cum=[0];for(const w of wts)cum.push(cum[cum.length-1]+w);
const tot=cum[cum.length-1]||1;
const spans=chars.map(c=>{const s=document.createElement('span');s.className='tk';s.textContent=c;el.appendChild(s);return s});
if(initPlayed)spans.forEach(s=>s.classList.add('played'));
const dur=()=>isFinite(audio.duration)&&audio.duration>0?audio.duration:(durHint||0);
const tOf=i=>dur()*cum[i]/tot;
const findCur=t=>{const d=dur();if(d<=0)return -1;const tg=t/d*tot;let lo=0,hi=spans.length-1;while(lo<hi){const m=(lo+hi+1)>>1;cum[m]<=tg?lo=m:hi=m-1}return lo};
spans.forEach((s,i)=>s.onclick=()=>{if(!audio.paused&&s.classList.contains('cur')){audio.pause();return}audio.currentTime=tOf(i);if(audio.paused)audio.play()});
const upd=()=>{const t=audio.currentTime,ci=!audio.paused?findCur(t):-1;spans.forEach((s,i)=>{s.classList.toggle('played',tOf(i)<=t);s.classList.toggle('cur',i===ci)});el.classList.toggle('on',!audio.paused)};
['timeupdate','seeked','play','pause'].forEach(e=>audio.addEventListener(e,upd));
}
function mkPlayer(src){
const w=document.createElement('div');w.className='player';
w.innerHTML='<button class="pp">▶</button><div class="bar-wrap"><div class="bar"></div></div><span class="time">0:00 / 0:00</span>';
const a=new Audio(src),pp=w.querySelector('.pp'),bw=w.querySelector('.bar-wrap'),b=w.querySelector('.bar'),tm=w.querySelector('.time');
pp.onclick=()=>a.paused?a.play():a.pause();
a.onplay=()=>pp.textContent='❚❚';a.onpause=()=>pp.textContent='▶';
a.ontimeupdate=()=>{const d=a.duration||0;b.style.width=d?(a.currentTime/d*100)+'%':'0';tm.textContent=`${fmtT(a.currentTime)} / ${fmtT(d)}`};
a.onended=()=>b.style.width='0';a.onloadedmetadata=()=>tm.textContent=`0:00 / ${fmtT(a.duration)}`;
bw.onclick=e=>{const r=bw.getBoundingClientRect();if(a.duration)a.currentTime=(e.clientX-r.left)/r.width*a.duration};
return w;
}
let msgN=0;
function addMsg(root,role,opts={}){
const d=document.createElement('div');d.className='msg '+role;msgN++;
const ts=new Date().toLocaleTimeString('en-US',{hour12:false});
d.innerHTML=`<div class="meta">[${msgN}] ${role} @ ${ts}</div><div class="body"></div>`;
const body=d.querySelector('.body');
if(opts.image){const i=document.createElement('img');i.className='img';i.src=opts.image;body.appendChild(i)}
if(opts.audioUrl){
if(role==='user'&&opts.audioDuration){
const n=Math.max(14,Math.min(80,Math.round(opts.audioDuration/.15)));
const v=document.createElement('div');v.className='text voice';v.textContent=waveChars(n);body.appendChild(v);
attachProg(new Audio(opts.audioUrl),v,opts.audioDuration);
} else body.appendChild(mkPlayer(opts.audioUrl));
}
if(opts.text!==undefined){const t=document.createElement('div');t.className='text';t.textContent=opts.text;body.appendChild(t)}
root.appendChild(d);root.scrollTop=root.scrollHeight;
return d;
}
/* ------------- Chat mode ------------- */
let chatHist=[],pendImg=null,pendAud=null,rec=null,recChunks=[],recording=false,gen=false,actx=null,nextT=0,aborter=null,chatPlaying=[],chatEpoch=0;
function stopChatAudio(){for(const s of chatPlaying)try{s.stop()}catch{}chatPlaying=[];if(actx){actx.close().catch(()=>{});actx=null;nextT=0}}
function stopChat(){chatEpoch++;if(aborter)try{aborter.abort()}catch{}stopChatAudio();gen=false;sendBtn.disabled=false;ts.textContent='idle'}
function renderChips(){
att.innerHTML='';txt.disabled=!!pendAud;txt.placeholder=pendAud?'(voice attached — text disabled)':'Message MiniMind-O...';
if(pendImg){const c=document.createElement('span');c.className='chip';c.innerHTML=`<img src="${pendImg.url}"><span>image</span><span class="x">×</span>`;c.querySelector('.x').onclick=()=>{URL.revokeObjectURL(pendImg.url);pendImg=null;renderChips()};att.appendChild(c)}
if(pendAud){const c=document.createElement('span');c.className='chip';c.innerHTML=`<span class="play">▶ voice · ${pendAud.duration.toFixed(1)}s</span><span class="x">×</span>`;c.querySelector('.play').onclick=()=>new Audio(pendAud.url).play();c.querySelector('.x').onclick=()=>{URL.revokeObjectURL(pendAud.url);pendAud=null;renderChips()};att.appendChild(c)}
}
async function send(){
if(gen)return;
stopChatAudio();
const t=txt.value.trim();if(!t&&!pendImg&&!pendAud)return;
const o={};if(pendImg)o.image=pendImg.url;if(pendAud){o.audioUrl=pendAud.url;o.audioDuration=pendAud.duration}if(t)o.text=t;
addMsg(chat,'user',o);
const payload={text:t||'',audio:pendAud?.b64||null,image:pendImg?.b64||null,temperature:.7,max_tokens:512,history:chatHist,voice:voiceSel.value};
txt.value='';pendImg=null;pendAud=null;renderChips();
sendBtn.disabled=true;gen=true;stat.classList.remove('hidden');tt.textContent='—';ta.textContent='—';ts.textContent='sending';
const ad=addMsg(chat,'assistant',{text:'…'}),ab=ad.querySelector('.body'),at=ab.querySelector('.text');
let full='',segs=[],acc='';
try{
const myEpoch=++chatEpoch;
aborter=new AbortController();
const res=await fetch('/chat',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify(payload),signal:aborter.signal});
const rd=res.body.getReader(),dec=new TextDecoder();ts.textContent='streaming';
while(true){
if(myEpoch!==chatEpoch)throw new DOMException('chat stopped','AbortError');
const {done,value}=await rd.read();if(done)break;
for(const ln of dec.decode(value).split('\n')){
if(myEpoch!==chatEpoch)throw new DOMException('chat stopped','AbortError');
if(!ln.startsWith('data: '))continue;
let d;try{d=JSON.parse(ln.slice(6))}catch{continue}
if(d.type==='user_prompt')chatHist.push({role:'user',content:d.content});
else if(d.type==='ttft'){if(d.text_ttft!==undefined)tt.textContent=d.text_ttft+' ms';if(d.audio_ttft!==undefined)ta.textContent=d.audio_ttft+' ms'}
else if(d.type==='text'){full+=d.content;at.textContent=full;chat.scrollTop=chat.scrollHeight}
else if(d.type==='pcm'){
acc+=d.c;
if(d.d){
if(!actx){actx=new AudioContext({sampleRate:24000});nextT=actx.currentTime+.1}
const {i16,f32}=decPcm(acc);
const buf=actx.createBuffer(1,f32.length,24000);buf.getChannelData(0).set(f32);
const s=actx.createBufferSource();s.buffer=buf;s.connect(actx.destination);
s.onended=()=>{chatPlaying=chatPlaying.filter(x=>x!==s)};
chatPlaying.push(s);
if(nextT<actx.currentTime)nextT=actx.currentTime;
s.start(nextT);nextT+=buf.duration-.01;segs.push(i16);acc='';
}
} else if(d.type==='error')at.textContent='⚠ '+d.content;
}
}
if(segs.length){const url=URL.createObjectURL(buildWav(segs,24000));attachProg(new Audio(url),at,null,true)}
if(full)chatHist.push({role:'assistant',content:full});
ts.textContent='done';
} catch(e){if(e.name!=='AbortError'){at.textContent='⚠ '+e.message;ts.textContent='error'}}
gen=false;sendBtn.disabled=false;aborter=null;
}
sendBtn.onclick=send;
txt.addEventListener('keydown',e=>{if(e.key==='Enter'&&!e.shiftKey){e.preventDefault();send()}});
const imgPop=$('imgPop'),fileBtn=$('fileBtn'),shotBtn=$('shotBtn'),snap=$('snap'),snapV=$('snapV'),snapOk=$('snapOk'),snapCancel=$('snapCancel');
const cloneModal=$('cloneModal'),cloneText=$('cloneText'),cloneStat=$('cloneStat'),cloneRecBtn=$('cloneRec'),cloneCancelBtn=$('cloneCancel'),cloneName=$('cloneName');
cloneText.textContent=CLONE_PROMPT;
moreBtn.onclick=e=>{e.stopPropagation();morePop.classList.toggle('on')};
morePop.onclick=e=>{
e.stopPropagation();
const del=e.target.closest('.vx');
if(del){const name=del.dataset.del;if(!confirm('确认删除 '+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(voiceSel.value===name?'default':voiceSel.value)}).catch(e=>alert(e.message));return}
const add=e.target.closest('.vadd');
if(add){closeMore();stopChat();cloneModal.classList.add('on');if(!cloneName.value.trim())cloneName.value='voice_clone';cloneStat.textContent='请自然读完,不用刻意加速';return}
const item=e.target.closest('.vitem');
if(item&&item.dataset.voice){voiceSel.value=item.dataset.voice;renderVoiceList();closeMore()}
};
imgBtn.onclick=e=>{e.stopPropagation();imgPop.classList.toggle('on')};
document.addEventListener('click',e=>{if(!imgPop.contains(e.target)&&e.target!==imgBtn)imgPop.classList.remove('on');if(!morePop.contains(e.target)&&e.target!==moreBtn)closeMore()});
fileBtn.onclick=()=>{imgPop.classList.remove('on');imgIn.click()};
const setImg=(b64,url)=>{if(pendImg)URL.revokeObjectURL(pendImg.url);pendImg={b64,url};renderChips()};
imgIn.onchange=()=>{const f=imgIn.files?.[0];if(!f)return;const r=new FileReader();r.onloadend=()=>setImg(r.result.split(',')[1],URL.createObjectURL(f));r.readAsDataURL(f);imgIn.value=''};
let snapStream=null;
const closeSnap=()=>{snap.classList.remove('on');if(snapStream){snapStream.getTracks().forEach(t=>t.stop());snapStream=null}snapV.srcObject=null};
shotBtn.onclick=async()=>{
imgPop.classList.remove('on');
try{snapStream=await navigator.mediaDevices.getUserMedia({video:{facingMode:'user',width:1280,height:720}});snapV.srcObject=snapStream;snap.classList.add('on')}
catch(e){alert('camera error: '+e.message)}
};
snapCancel.onclick=closeSnap;
snapOk.onclick=()=>{
if(!snapV.videoWidth)return;
const cv=document.createElement('canvas');cv.width=snapV.videoWidth;cv.height=snapV.videoHeight;cv.getContext('2d').drawImage(snapV,0,0);
const dataUrl=cv.toDataURL('image/jpeg',.9),b64=dataUrl.split(',')[1];
cv.toBlob(blob=>setImg(b64,URL.createObjectURL(blob)),'image/jpeg',.9);
closeSnap();
};
const CLONE_MAX_SECS=6;
let cloneRec=null,cloneChunks=[],cloneRecording=false,cloneBusy=false,cloneCanceled=false,cloneStream=null,cloneDeadline=0,cloneTimer=null;
const stopCloneTimer=()=>{if(cloneTimer){clearInterval(cloneTimer);cloneTimer=null}};
const resetClone=()=>{stopCloneTimer();if(cloneStream){cloneStream.getTracks().forEach(t=>t.stop());cloneStream=null}cloneRec=null;cloneChunks=[];cloneRecording=false;cloneBusy=false;cloneCanceled=false;cloneDeadline=0;cloneRecBtn.classList.remove('rec');cloneRecBtn.textContent='start';cloneRecBtn.disabled=false;cloneCancelBtn.disabled=false};
const hideClone=()=>{cloneModal.classList.remove('on');resetClone();cloneStat.textContent='点击 start 开始录音'};
cloneCancelBtn.onclick=()=>{if(cloneBusy)return;if(cloneRecording&&cloneRec){cloneCanceled=true;cloneStat.textContent='已取消';cloneRec.stop()}else hideClone()};
cloneRecBtn.onclick=async()=>{
if(cloneBusy)return;
if(!cloneRecording){
try{
cloneStream=await navigator.mediaDevices.getUserMedia({audio:true});
cloneRec=new MediaRecorder(cloneStream);cloneChunks=[];cloneCanceled=false;
cloneRec.ondataavailable=e=>cloneChunks.push(e.data);
cloneRec.onstop=async()=>{
const canceled=cloneCanceled,blob=new Blob(cloneChunks,{type:(cloneRec&&cloneRec.mimeType)||'audio/webm'});
resetClone();
if(canceled){cloneModal.classList.remove('on');cloneStat.textContent='点击 start 开始录音';return}
cloneBusy=true;cloneRecBtn.disabled=true;cloneCancelBtn.disabled=true;cloneStat.textContent='正在提取音色…';
try{
const audio=await blobToB64(blob);
const res=await fetch('/clone_voice',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({audio,name:cloneName.value.trim()})});
const d=await res.json();
if(!res.ok||!d.ok)throw new Error(d.error||'clone failed');
await loadVoices(d.voice);
voiceSel.value=d.voice;
cloneStat.textContent='音色已生成,已自动切换到 '+d.voice;
setTimeout(()=>hideClone(),600);
}catch(e){cloneBusy=false;cloneRecBtn.disabled=false;cloneCancelBtn.disabled=false;cloneStat.textContent='失败:'+e.message}
};
cloneRec.start();cloneRecording=true;cloneRecBtn.classList.add('rec');cloneRecBtn.textContent='stop';cloneDeadline=Date.now()+CLONE_MAX_SECS*1000;
const tick=()=>{
const left=Math.max(0,Math.ceil((cloneDeadline-Date.now())/1000));
cloneStat.textContent=`正在录音,请读完这句话 · ${left}s`;
if(left<=0&&cloneRecording&&cloneRec){cloneBusy=true;cloneRecording=false;cloneRecBtn.disabled=true;cloneRecBtn.classList.remove('rec');cloneRecBtn.textContent='start';cloneStat.textContent='录音结束,正在上传…';cloneRec.stop()}
};
tick();
cloneTimer=setInterval(tick,200);
}catch(e){alert('mic error: '+e.message)}
}else{
stopCloneTimer();cloneBusy=true;cloneRecording=false;cloneRecBtn.disabled=true;cloneRecBtn.classList.remove('rec');cloneRecBtn.textContent='start';cloneStat.textContent='录音结束,正在上传…';cloneRec.stop();
}
};
recBtn.onclick=async()=>{
if(!recording){
try{
const st=await navigator.mediaDevices.getUserMedia({audio:true});
rec=new MediaRecorder(st);recChunks=[];const t0=Date.now();
rec.ondataavailable=e=>recChunks.push(e.data);
rec.onstop=()=>{
st.getTracks().forEach(t=>t.stop());
const blob=new Blob(recChunks,{type:'audio/webm'}),dur=(Date.now()-t0)/1000,r=new FileReader();
r.onloadend=()=>{const b64=r.result.split(',')[1],url=URL.createObjectURL(blob);if(pendAud)URL.revokeObjectURL(pendAud.url);pendAud={b64,url,duration:dur};renderChips()};
r.readAsDataURL(blob);
};
rec.start();recording=true;recBtn.classList.add('rec');
} catch(e){alert('mic error: '+e.message)}
} else {rec.stop();recording=false;recBtn.classList.remove('rec')}
};
/* ------------- Call mode ------------- */
let callInit=false,callStop=null;
callBtn.onclick=openCall;
function openCall(){stopChat();document.body.classList.add('call');history.pushState({call:1},'','/call');if(!callInit){callInit=true;initCall()}}
function closeCall(){document.body.classList.remove('call');if(callStop)callStop();if(location.pathname==='/call')history.pushState({},'','/')}
window.addEventListener('popstate',()=>location.pathname==='/call'?openCall():closeCall());
if(location.pathname==='/call')openCall();
function initCall(){
const stT=$('stT'),upT=$('upT'),stIc=$('stIc'),lbl=$('lbl'),osvg=$('osvg'),core=$('core'),ripG=$('rip');
const ICONS={
idle:'<svg viewBox="0 0 24 24"><circle class="dot" cx="12" cy="12" r="3" fill="currentColor" stroke="none"/></svg>',
listen:'<svg viewBox="0 0 24 24"><circle cx="12" cy="12" r="2.2" fill="currentColor" stroke="none"/><path class="w w3" d="M7 8.5a6 6 0 0 0 0 7"/><path class="w w3" d="M17 8.5a6 6 0 0 1 0 7"/><path class="w w2" d="M4 6a9.5 9.5 0 0 0 0 12"/><path class="w w2" d="M20 6a9.5 9.5 0 0 1 0 12"/></svg>',
gen:'<svg viewBox="0 0 24 24"><circle cx="12" cy="4" r="1.8" fill="currentColor" stroke="none"/><circle cx="18.93" cy="15.5" r="1.4" fill="currentColor" stroke="none" opacity=".65"/><circle cx="5.07" cy="15.5" r="1" fill="currentColor" stroke="none" opacity=".35"/></svg>',
speak:'<svg viewBox="0 0 24 24"><circle cx="12" cy="12" r="2.2" fill="currentColor" stroke="none"/><path class="a1" d="M8 9a5 5 0 0 1 0 6"/><path class="a1" d="M16 9a5 5 0 0 0 0 6"/><path class="a2" d="M5 6.5a9 9 0 0 1 0 11"/><path class="a2" d="M19 6.5a9 9 0 0 0 0 11"/><path class="a3" d="M2 4a13 13 0 0 1 0 16"/><path class="a3" d="M22 4a13 13 0 0 0 0 16"/></svg>',
err:'<svg viewBox="0 0 24 24"><circle cx="12" cy="12" r="9"/><line x1="8.5" y1="8.5" x2="15.5" y2="15.5"/><line x1="15.5" y1="8.5" x2="8.5" y2="15.5"/></svg>',
};
const cchat=$('cchat'),lat=$('lat'),lmE=$('lmE'),ltE=$('ltE');
const camBtn=$('camBtn'),logBtn=$('logBtn'),endBtn=$('endBtn'),orb=$('orb'),camV=$('camV');
const pill=$('pill'),connT=$('connT'),tcnt=$('tcnt'),trans=$('trans'),closeBtn=$('closeBtn');
const blobs=[$('b0'),$('b1'),$('b2')];
let ac,ana,mic,camStream=null,camOn=false,hist=[],nextT=0;
let ws=null,wsActive=false,proc=null,rtc=null,rtSrc=null,playing=[],turns=0,mn=0;
let curMsg=null,curText='',segs=[],start=0,ripT=0,sVol=0,asstPlay=false,raf=0;
let spk=false,userBuf=[];
const RT_SAMPLE_RATE=16000,RT_BLOCK=4096;
const resamplePcm=(input,inRate,outRate)=>{
if(!input.length||inRate===outRate)return input;
const outLen=Math.max(1,Math.round(input.length*outRate/inRate));
const out=new Float32Array(outLen);
const step=inRate/outRate;
for(let i=0;i<outLen;i++){
const pos=i*step,idx=Math.floor(pos),frac=pos-idx;
const a=input[idx]||0;
const b=input[Math.min(idx+1,input.length-1)]||a;
out[i]=a+(b-a)*frac;
}
return out;
};
const floatToI16=input=>{const out=new Int16Array(input.length);for(let i=0;i<input.length;i++)out[i]=Math.max(-32768,Math.min(32767,input[i]*32768));return out};
const flushUser=()=>{
if(!userBuf.length)return;
const dur=userBuf.reduce((a,c)=>a+c.length,0)/16000;
const url=URL.createObjectURL(buildWav(userBuf,16000));
userBuf=[];
const d=addCM('user'),body=d.querySelector('.body'),n=Math.max(14,Math.min(80,Math.round(dur/.15)));
const v=document.createElement('div');v.className='text voice';v.textContent=waveChars(n);body.appendChild(v);
attachProg(new Audio(url),v,dur);
setLatest('user',`[voice · ${dur.toFixed(1)}s]`);
};
const N=32,NC=2,NR=5;let shT=0;
const LAY=[{c:56,r:112,a:1.8,sp:.0005,ph:.6,vc:34,vr:18},{c:80,r:124,a:2.6,sp:.0008,ph:1.3,vc:52,vr:26},{c:104,r:138,a:3.6,sp:.0011,ph:2.1,vc:74,vr:34}];
const catmull=pts=>{let d=`M ${pts[0][0].toFixed(2)} ${pts[0][1].toFixed(2)} `;const n=pts.length;for(let i=0;i<n;i++){const p0=pts[(i-1+n)%n],p1=pts[i],p2=pts[(i+1)%n],p3=pts[(i+2)%n];const c1x=p1[0]+(p2[0]-p0[0])/6,c1y=p1[1]+(p2[1]-p0[1])/6,c2x=p2[0]-(p3[0]-p1[0])/6,c2y=p2[1]-(p3[1]-p1[1])/6;d+=`C ${c1x.toFixed(2)} ${c1y.toFixed(2)}, ${c2x.toFixed(2)} ${c2y.toFixed(2)}, ${p2[0].toFixed(2)} ${p2[1].toFixed(2)} `}return d+'Z'};
const setSt=(cls,t)=>{osvg.setAttribute('class',cls||'');lbl.className='lbl '+(cls||'');stT.textContent=t;const key=cls||(/error/i.test(t)?'err':'idle');stIc.innerHTML=ICONS[key]||ICONS.idle};
const setConn=(live,t)=>{pill.className='pill'+(live?' live':'');connT.textContent=t};
const ripple=k=>{const now=performance.now();if(now-ripT<550)return;ripT=now;const c=document.createElementNS('http://www.w3.org/2000/svg','circle');c.setAttribute('cx',0);c.setAttribute('cy',0);c.setAttribute('r',85);c.setAttribute('class','ripple '+k);ripG.appendChild(c);c.addEventListener('animationend',()=>c.remove())};
const upTimer=setInterval(()=>{if(start)upT.textContent=fmtDur((Date.now()-start)/1000)},1000);
const setLatest=(role,t)=>{if(!t)return;lat.classList.remove('empty','user','assistant');lat.classList.add(role);const ts=new Date().toLocaleTimeString('en-US',{hour12:false});lmE.textContent=`latest · ${role} @ ${ts}`;ltE.textContent=t;lat.scrollTop=0};
const addCM=role=>{const d=document.createElement('div');d.className='msg '+role;mn++;const ts=new Date().toLocaleTimeString('en-US',{hour12:false});d.innerHTML=`<div class="meta">[${mn}] ${role} @ ${ts}</div><div class="body"></div>`;cchat.appendChild(d);cchat.scrollTop=cchat.scrollHeight;return d};
const playPcm=b64=>{const {i16,f32}=decPcm(b64);segs.push(i16);const buf=ac.createBuffer(1,f32.length,24000);buf.getChannelData(0).set(f32);const s=ac.createBufferSource();s.buffer=buf;s.connect(ac.destination);s.onended=()=>{playing=playing.filter(x=>x!==s);if(!playing.length){asstPlay=false;if(!curMsg)setSt('','idle')}};playing.push(s);asstPlay=true;if(nextT<ac.currentTime)nextT=ac.currentTime;s.start(nextT);nextT+=buf.duration-.01};
const stopPcm=()=>{for(const s of playing)try{s.stop()}catch{}playing=[];asstPlay=false;if(ac)nextT=ac.currentTime};
const capCam=()=>{if(!camOn||!camV.videoWidth)return null;const cv=document.createElement('canvas');cv.width=camV.videoWidth;cv.height=camV.videoHeight;cv.getContext('2d').drawImage(camV,0,0);return cv.toDataURL('image/jpeg',.8).split(',')[1]};
const sendCtx=()=>{if(!ws||ws.readyState!==1)return;ws.send(JSON.stringify({type:'context',history:hist,image:camOn?capCam():null,voice:callVoiceSel.value}))};
window.callSendCtx=sendCtx;
function render(){
const now=performance.now();let vol=0,fq=null;
if(ana){const d=new Uint8Array(ana.frequencyBinCount);ana.getByteFrequencyData(d);fq=d;let s=0;for(let i=0;i<d.length;i++)s+=d[i];vol=s/d.length/255}
if(asstPlay)vol=Math.max(vol,.6+.25*Math.sin(now*.013));
sVol=sVol*.7+vol*.3;
const tgt=camOn?1:0;shT+=(tgt-shT)*.08;
const shN=NC+(NR-NC)*shT,inv=-1/shN;
for(let li=0;li<LAY.length;li++){
const L=LAY[li],hw=L.c+(L.r-L.c)*shT,hh=hw,vs=L.vc+(L.vr-L.vc)*shT;
const pts=new Array(N);
for(let i=0;i<N;i++){
const ang=(i/N)*Math.PI*2,cA=Math.cos(ang),sA=Math.sin(ang);
const n=Math.sin(now*L.sp+i*L.ph)+.55*Math.sin(now*L.sp*1.7+i*L.ph*.7+li);
let fv=0;if(fq){const idx=Math.floor((i/N)*Math.min(64,fq.length))+li*4;fv=(fq[idx%fq.length]||0)/255}
if(asstPlay)fv=Math.max(fv,.5+.45*Math.abs(Math.sin(now*.011+i*.55+li*.9)));
const g=Math.min(1,fv*.65+sVol*.55);
const tx=Math.pow(Math.abs(cA)/hw,shN),ty=Math.pow(Math.abs(sA)/hh,shN);
const r=Math.pow(tx+ty,inv)+n*L.a+g*vs;
pts[i]=[cA*r,sA*r];
}
blobs[li].setAttribute('d',catmull(pts));
}
core.setAttribute('r',(3+sVol*10).toFixed(1));
raf=requestAnimationFrame(render);
}
raf=requestAnimationFrame(render);
async function initMic(){
try{mic=await navigator.mediaDevices.getUserMedia({audio:true});ac=new AudioContext();const s=ac.createMediaStreamSource(mic);ana=ac.createAnalyser();ana.fftSize=256;s.connect(ana);setSt('','connecting');setConn(false,'connecting')}
catch(e){setSt('','mic error');setConn(false,'mic error')}
}
camBtn.onclick=async()=>{
if(camOn){if(camStream){camStream.getTracks().forEach(t=>t.stop());camStream=null}camV.srcObject=null;orb.classList.remove('camon');camBtn.classList.remove('active');camOn=false}
else{try{camStream=await navigator.mediaDevices.getUserMedia({video:{facingMode:'user',width:640,height:480}});camV.srcObject=camStream;orb.classList.add('camon');camBtn.classList.add('active');camOn=true}catch{setSt('','cam error')}}
if(wsActive)sendCtx();
};
logBtn.onclick=()=>{trans.classList.toggle('on');logBtn.classList.toggle('active',trans.classList.contains('on'))};
closeBtn.onclick=()=>{trans.classList.remove('on');logBtn.classList.remove('active')};
endBtn.onclick=closeCall;
async function startRT(){
wsActive=true;if(!mic)await initMic();
rtc=new AudioContext();rtSrc=rtc.createMediaStreamSource(mic);proc=rtc.createScriptProcessor(4096,1,1);
let rtAcc=new Float32Array(0);
const pr=location.protocol==='https:'?'wss':'ws';
ws=new WebSocket(`${pr}://${location.host}/ws/realtime`);ws.binaryType='arraybuffer';
ws.onmessage=e=>{
const m=JSON.parse(e.data);
if(m.type==='vad'){
if(m.speaking&&!spk){sendCtx();stopPcm();ripple('user');userBuf=[]}
if(!m.speaking&&spk)flushUser();
spk=m.speaking;
if(m.speaking)setSt('listen','listening');else if(!asstPlay&&!curMsg)setSt('','idle');
} else if(m.type==='generating'){
setSt('gen','thinking');if(spk){flushUser();spk=false}
turns++;tcnt.textContent=turns+(turns===1?' turn':' turns');
curText='';segs=[];
const d=addCM('assistant'),body=d.querySelector('.body'),t=document.createElement('div');t.className='text';body.appendChild(t);
curMsg={div:d,body,textEl:t};stopPcm();
} else if(m.type==='text'){
curText+=m.content;if(curMsg)curMsg.textEl.textContent=curText;
setLatest('assistant',curText);cchat.scrollTop=cchat.scrollHeight;
} else if(m.type==='pcm'){setSt('speak','speaking');ripple('assistant');playPcm(m.data)}
else if(m.type==='done'){
if(curText)hist.push({role:'assistant',content:curText});
if(m.interrupted)stopPcm();
if(curMsg&&segs.length){const url=URL.createObjectURL(buildWav(segs,24000));attachProg(new Audio(url),curMsg.textEl,null,true)}
curMsg=null;if(!asstPlay)setSt('','idle');
}
};
ws.onclose=()=>{if(wsActive)stopRT()};
ws.onopen=()=>{
start=Date.now();setConn(true,'live');setSt('','idle');sendCtx();
proc.onaudioprocess=e=>{
if(ws.readyState!==1)return;
const rs=resamplePcm(e.inputBuffer.getChannelData(0),e.inputBuffer.sampleRate,RT_SAMPLE_RATE);
const merged=new Float32Array(rtAcc.length+rs.length);merged.set(rtAcc);merged.set(rs,rtAcc.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));
}
rtAcc=merged.slice(off);
};
rtSrc.connect(proc);proc.connect(rtc.destination);
};
}
function stopRT(){wsActive=false;if(ws){ws.close();ws=null}if(proc){proc.disconnect();proc.onaudioprocess=null;proc=null}if(rtSrc){rtSrc.disconnect();rtSrc=null}if(rtc){rtc.close();rtc=null}stopPcm();setSt('','disconnected');setConn(false,'offline')}
callStop=()=>{
stopRT();
window.callSendCtx=null;
if(camStream){camStream.getTracks().forEach(t=>t.stop());camStream=null;camOn=false;orb.classList.remove('camon');camV.srcObject=null;camBtn.classList.remove('active')}
if(mic){mic.getTracks().forEach(t=>t.stop());mic=null}
if(ac){try{ac.close()}catch{}ac=null}
if(raf)cancelAnimationFrame(raf);
clearInterval(upTimer);callInit=false;callStop=null;
};
initMic().then(startRT);
}
</script>
</body>
</html>