PrimeTTS-Streaming / static /index.html
Luigi
Remove MOSS-TTS-Nano backend (not real-time on free CPU tier)
99cb6cb
Raw
History Blame Contribute Delete
12.6 kB
<!doctype html>
<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 &amp; 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>