Spaces:
Running
Running
| <html lang="en"> | |
| <head> | |
| <meta charset="utf-8"> | |
| <meta name="viewport" content="width=device-width, initial-scale=1"> | |
| <title>PrimeTTS · real-time streaming</title> | |
| <style> | |
| :root { --bg:#0e1116; --card:#171c26; --fg:#e6edf3; --mut:#8b98a9; --acc:#5eead4; --acc2:#38bdf8; } | |
| * { box-sizing:border-box; } | |
| body { margin:0; font:15px/1.5 -apple-system,Segoe UI,Roboto,sans-serif; background:var(--bg); color:var(--fg); } | |
| .wrap { max-width:720px; margin:0 auto; padding:32px 20px 64px; } | |
| h1 { font-size:22px; margin:0 0 4px; } | |
| .sub { color:var(--mut); margin:0 0 24px; font-size:13px; } | |
| .badge { display:inline-block; padding:2px 8px; border-radius:999px; background:#0b2b28; color:var(--acc); font-size:11px; font-weight:600; } | |
| .card { background:var(--card); border:1px solid #232a36; border-radius:14px; padding:18px; } | |
| textarea { width:100%; min-height:96px; resize:vertical; background:#0b0f16; color:var(--fg); border:1px solid #2a3342; border-radius:10px; padding:12px; font-size:15px; } | |
| .row { display:flex; gap:12px; align-items:center; margin-top:14px; flex-wrap:wrap; } | |
| button { background:linear-gradient(135deg,var(--acc),var(--acc2)); color:#04121a; border:0; font-weight:700; padding:11px 22px; border-radius:10px; cursor:pointer; font-size:15px; } | |
| button:disabled { opacity:.5; cursor:default; } | |
| .stat { font-variant-numeric:tabular-nums; color:var(--mut); font-size:13px; } | |
| .stat b { color:var(--fg); } | |
| .meter { height:6px; background:#0b0f16; border-radius:6px; overflow:hidden; margin-top:16px; } | |
| .meter > i { display:block; height:100%; width:0; background:linear-gradient(90deg,var(--acc),var(--acc2)); transition:width .1s; } | |
| .chips { margin-top:12px; display:flex; gap:8px; flex-wrap:wrap; } | |
| .chip { font-size:12px; color:var(--mut); border:1px solid #2a3342; padding:4px 10px; border-radius:999px; cursor:pointer; } | |
| .chip:hover { color:var(--fg); border-color:var(--acc); } | |
| label.opt { color:var(--mut); font-size:13px; display:flex; align-items:center; gap:6px; } | |
| .sectitle { font-size:12px; font-weight:700; letter-spacing:.08em; text-transform:uppercase; color:var(--mut); margin:30px 0 12px; } | |
| .grid2 { display:grid; grid-template-columns:1fr 1fr; gap:16px; } | |
| @media (max-width:560px){ .grid2 { grid-template-columns:1fr; } } | |
| .subcard { background:var(--card); border:1px solid #232a36; border-radius:14px; padding:16px; display:flex; flex-direction:column; } | |
| .subcard h3 { font-size:15px; margin:0 0 4px; display:flex; align-items:center; gap:6px; } | |
| .subcard .tag { font-size:11px; color:var(--mut); font-weight:400; } | |
| .subcard p { color:var(--mut); font-size:12.5px; margin:0 0 14px; flex:1; } | |
| .subcard button { width:100%; } | |
| #transcript:empty { display:none; } | |
| </style> | |
| </head> | |
| <body> | |
| <div class="wrap"> | |
| <h1>PrimeTTS · real-time streaming <span class="badge" id="backend">…</span></h1> | |
| <p class="sub">Token-level MB-iSTFT-VITS · audio streams chunk-by-chunk (24 frames ≈ 384 ms) — first sound in ~100 ms. zh-TW + English.</p> | |
| <div class="sectitle">① Type & speak</div> | |
| <div class="card"> | |
| <textarea id="text" placeholder="輸入中文或 English…">您好,幫您轉接給 Jason 王經理,他的分機是 2580。</textarea> | |
| <div class="chips" id="chips"></div> | |
| <div class="row"> | |
| <button id="go">▶ Speak (stream)</button> | |
| <label class="opt">voice | |
| <select id="voice" style="background:#0b0f16;color:var(--fg);border:1px solid #2a3342;border-radius:8px;padding:5px 8px;font-size:13px;"></select> | |
| </label> | |
| <label class="opt"><input type="checkbox" id="onenter" checked> stream on Enter</label> | |
| <span class="stat">first-audio <b id="first">–</b> · chunks <b id="nc">0</b> · <b id="audio">0.0s</b></span> | |
| <a id="dl" download="primetts.wav" style="display:none;color:var(--acc);font-size:13px;text-decoration:none;border:1px solid #2a3342;border-radius:8px;padding:5px 10px;">⬇ WAV</a> | |
| </div> | |
| <div class="meter"><i id="bar"></i></div> | |
| </div> | |
| <div id="chatcard" style="display:none;"> | |
| <div class="sectitle">② Streaming input · text is generated live, then spoken (zh-TW)</div> | |
| <div class="grid2"> | |
| <div class="subcard" id="storyopt" style="display:none;"> | |
| <h3>📖 Story generator <span class="tag">llama2.c-zh 15M</span></h3> | |
| <p>No input needed — generates a fresh short tale and speaks it as it writes.</p> | |
| <button id="storygo">▶ Generate story</button> | |
| </div> | |
| <div class="subcard" id="chatopt" style="display:none;"> | |
| <h3>💬 Real-time chat <span class="tag">Gemma-3-270m-it</span></h3> | |
| <p>Ask anything — it replies in zh-TW as it generates, spoken clause-by-clause.</p> | |
| <textarea id="prompt" style="min-height:52px; margin-bottom:10px;" placeholder="輸入訊息…">請用一百字介紹台灣</textarea> | |
| <button id="chatgo">▶ Send</button> | |
| </div> | |
| </div> | |
| <div class="row"><span class="stat">first-audio <b id="cfirst">–</b> · <b id="caudio">0.0s</b></span> | |
| <a id="cdl" download="primetts.wav" style="display:none;color:var(--acc);font-size:13px;text-decoration:none;border:1px solid #2a3342;border-radius:8px;padding:5px 10px;">⬇ WAV</a> | |
| </div> | |
| <div id="transcript" style="margin-top:12px; padding:14px; background:var(--card); border:1px solid #232a36; border-radius:12px; color:#cdd9e5; font-size:15px; line-height:1.8;"></div> | |
| </div> | |
| </div> | |
| <script> | |
| const SR = 16000; | |
| const $ = id => document.getElementById(id); | |
| const examples = [ | |
| "Thank you for calling. How may I help you today?", | |
| "您好,這裡是台灣電力公司客服中心,很高興為您服務。", | |
| "會議在 2024年3月15日下午三點,共 5 位 guest 出席。", | |
| "Anderson 先生您好,您訂的 3 件商品總共 NT$1,299,發票已寄到 anderson.wang@gmail.com。", | |
| ]; | |
| examples.forEach(t => { const c=document.createElement('span'); c.className='chip'; c.textContent=t.slice(0,22)+'…'; c.onclick=()=>{$('text').value=t;}; $('chips').appendChild(c); }); | |
| fetch('/healthz').then(r=>r.json()).then(j=>{ $('backend').textContent = j.backend==='sherpa' ? 'sherpa-onnx' : 'onnxruntime'; }).catch(()=>{}); | |
| // populate voice selector; default = BiBiTalker (sid 3) | |
| fetch('/voices').then(r=>r.json()).then(j=>{ | |
| const sel=$('voice'); (j.voices||[]).forEach(v=>{ const o=document.createElement('option'); o.value=v.sid; o.textContent=v.name; sel.appendChild(o); }); | |
| sel.value = '0'; // default Xinran | |
| }).catch(()=>{}); | |
| const getSid = () => parseInt(($('voice')||{}).value || '0', 10); | |
| let ac, nextStart = 0; | |
| let curWs = null, srcs = []; // active socket + scheduled audio sources (for Stop) | |
| function ensureCtx(){ if(!ac) ac = new (window.AudioContext||window.webkitAudioContext)({sampleRate:SR}); if(ac.state==='suspended') ac.resume(); } | |
| function playPCM(buf){ | |
| const i16 = new Int16Array(buf); | |
| const f32 = new Float32Array(i16.length); | |
| for (let i=0;i<i16.length;i++) f32[i] = i16[i] / 32768; | |
| const ab = ac.createBuffer(1, f32.length, SR); | |
| ab.copyToChannel(f32, 0); | |
| const src = ac.createBufferSource(); src.buffer = ab; src.connect(ac.destination); | |
| const now = ac.currentTime; | |
| if (nextStart < now) nextStart = now + 0.02; | |
| src.start(nextStart); | |
| nextStart += ab.duration; | |
| srcs.push(src); src.onended = () => { srcs = srcs.filter(s => s !== src); }; | |
| } | |
| // ---- WAV capture / export ---- | |
| let capBytes = 0, capChunks = []; // raw 16-bit PCM chunks for the current run | |
| function capReset(anchor){ capBytes=0; capChunks=[]; if(anchor){ anchor.style.display='none'; if(anchor._url){ URL.revokeObjectURL(anchor._url); anchor._url=null; } } } | |
| function capPush(buf){ const b=new Uint8Array(buf.slice(0)); capChunks.push(b); capBytes+=b.length; } | |
| function capFinish(anchor){ | |
| if(!capBytes || !anchor) return; | |
| const wav = new Uint8Array(44 + capBytes), dv = new DataView(wav.buffer); | |
| const wr = (o,s)=>{ for(let i=0;i<s.length;i++) dv.setUint8(o+i, s.charCodeAt(i)); }; | |
| wr(0,'RIFF'); dv.setUint32(4, 36+capBytes, true); wr(8,'WAVE'); wr(12,'fmt '); | |
| dv.setUint32(16,16,true); dv.setUint16(20,1,true); dv.setUint16(22,1,true); | |
| dv.setUint32(24,SR,true); dv.setUint32(28,SR*2,true); dv.setUint16(32,2,true); dv.setUint16(34,16,true); | |
| wr(36,'data'); dv.setUint32(40, capBytes, true); | |
| let off=44; for(const c of capChunks){ wav.set(c, off); off+=c.length; } | |
| const url = URL.createObjectURL(new Blob([wav], {type:'audio/wav'})); | |
| if(anchor._url) URL.revokeObjectURL(anchor._url); | |
| anchor._url = url; anchor.href = url; | |
| anchor.download = 'primetts-' + new Date().toISOString().slice(0,19).replace(/[:T]/g,'-') + '.wav'; | |
| anchor.style.display = 'inline-block'; | |
| } | |
| function stopAll(){ // stop server stream + kill queued audio | |
| if (curWs){ try { curWs.close(); } catch(e){} curWs = null; } | |
| srcs.forEach(s => { try { s.stop(); } catch(e){} }); srcs = []; | |
| nextStart = 0; | |
| } | |
| function setBtn(btn, running, label){ btn.textContent = running ? '■ Stop' : label; btn.dataset.run = running ? '1' : ''; } | |
| function speak(){ | |
| if($('go').dataset.run){ stopAll(); return; } // toggle: Stop | |
| const text = $('text').value.trim(); if(!text) return; | |
| ensureCtx(); stopAll(); | |
| setBtn($('go'), true); $('first').textContent='…'; $('nc').textContent='0'; $('audio').textContent='0.0s'; $('bar').style.width='0'; | |
| capReset($('dl')); | |
| const t0 = performance.now(); let first=null, n=0, samples=0; | |
| const proto = location.protocol==='https:' ? 'wss':'ws'; | |
| const ws = new WebSocket(`${proto}://${location.host}/ws`); curWs = ws; | |
| ws.binaryType = 'arraybuffer'; | |
| const done = () => { setBtn($('go'), false, '▶ Speak (stream)'); if(curWs===ws) curWs=null; }; | |
| ws.onopen = () => ws.send(JSON.stringify({text, sid: getSid()})); | |
| ws.onmessage = ev => { | |
| if (typeof ev.data === 'string'){ const m=JSON.parse(ev.data); | |
| if(m.type==='end'){ ws.close(); } | |
| if(m.type==='error'){ $('first').textContent='—'; ws.close(); } | |
| return; } | |
| if(first===null){ first=performance.now()-t0; $('first').textContent=first.toFixed(0)+' ms'; } | |
| playPCM(ev.data); capPush(ev.data); | |
| n++; samples += ev.data.byteLength/2; | |
| $('nc').textContent=n; $('audio').textContent=(samples/SR).toFixed(1)+'s'; | |
| $('bar').style.width = Math.min(100, n*8)+'%'; | |
| }; | |
| ws.onclose = () => { done(); $('bar').style.width='100%'; capFinish($('dl')); }; | |
| ws.onerror = () => { done(); }; | |
| } | |
| $('go').onclick = speak; | |
| $('text').addEventListener('keydown', e => { if(e.key==='Enter' && !e.shiftKey && $('onenter').checked){ e.preventDefault(); speak(); }}); | |
| // ---- streaming-input: two options (story generator / real-time chat) ---- | |
| fetch('/has_chat').then(r=>r.json()).then(j=>{ | |
| if(j.chat) $('chatcard').style.display='block'; | |
| if(j.story) $('storyopt').style.display='block'; | |
| if(j.chat_llm) $('chatopt').style.display='block'; | |
| }).catch(()=>{}); | |
| function runGen(mode, prompt, btn, label){ | |
| if(btn.dataset.run){ stopAll(); return; } // toggle: Stop | |
| ensureCtx(); stopAll(); | |
| // disable the other option's button while one runs | |
| const other = btn===$('storygo') ? $('chatgo') : $('storygo'); if(other) other.disabled=true; | |
| setBtn(btn, true); $('cfirst').textContent='…'; $('caudio').textContent='0.0s'; $('transcript').textContent=''; | |
| capReset($('cdl')); | |
| const t0 = performance.now(); let first=null, samples=0; | |
| const proto = location.protocol==='https:' ? 'wss':'ws'; | |
| const ws = new WebSocket(`${proto}://${location.host}/chat`); curWs = ws; | |
| ws.binaryType = 'arraybuffer'; | |
| const done = () => { setBtn(btn, false, label); if(other) other.disabled=false; if(curWs===ws) curWs=null; }; | |
| ws.onopen = () => ws.send(JSON.stringify({prompt, mode, sid: getSid()})); | |
| ws.onmessage = ev => { | |
| if (typeof ev.data === 'string'){ const m=JSON.parse(ev.data); | |
| if(m.type==='token'){ $('transcript').textContent += m.t; } | |
| if(m.type==='end'){ ws.close(); } | |
| if(m.type==='error'){ $('transcript').textContent='['+m.msg+']'; ws.close(); } | |
| return; } | |
| if(first===null){ first=performance.now()-t0; $('cfirst').textContent=first.toFixed(0)+' ms'; } | |
| playPCM(ev.data); capPush(ev.data); samples += ev.data.byteLength/2; $('caudio').textContent=(samples/SR).toFixed(1)+'s'; | |
| }; | |
| ws.onclose = () => { done(); capFinish($('cdl')); }; | |
| ws.onerror = () => { done(); }; | |
| } | |
| $('storygo') && ($('storygo').onclick = () => runGen('story', '', $('storygo'), '▶ Generate story')); | |
| $('chatgo') && ($('chatgo').onclick = () => { const p=$('prompt').value.trim(); if(p || $('chatgo').dataset.run) runGen('chat', p, $('chatgo'), '▶ Send'); }); | |
| </script> | |
| </body> | |
| </html> | |