matcha-tts-pl / index.html
mcPear's picture
Edge engine: streaming with Matcha-TTS-PL-Edge (chunk / ODE / vocoder context controls); sentences merged to >= 40 chars
528265b verified
Raw History Blame Contribute Delete
45.4 kB
<!doctype html><html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
<title>Matcha-TTS-PL &#xb7; browser playground</title>
<link href="https://fonts.googleapis.com/css2?family=Big+Shoulders+Display:wght@500;600&family=IBM+Plex+Sans:wght@400;500&display=swap" rel="stylesheet">
<style>
:root{--red:#bd3e3e;--ink:#0d0f10;--mut:#4e5558;--line:#e6e8ea;--bg:#fff;--panel:#f6f7f8}
*{box-sizing:border-box}body{margin:0;background:var(--bg);color:var(--ink);font-family:"IBM Plex Sans",system-ui,sans-serif;font-size:14px}
header{display:flex;align-items:center;gap:14px;padding:10px 18px;border-bottom:1px solid var(--line)}
header h1{font-family:"Big Shoulders Display",sans-serif;font-weight:600;font-size:22px;letter-spacing:.04em;margin:0}
header small{color:var(--mut)}header a{margin-left:auto;color:var(--red);font-size:12px;text-decoration:none}
main{display:grid;grid-template-columns:380px 1fr 400px;min-height:calc(100vh - 48px)}
@media(max-width:1300px){main{grid-template-columns:380px 1fr}.fx{grid-column:1/-1;border-left:0;border-top:1px solid var(--line)}}
@media(max-width:900px){main{grid-template-columns:1fr}}
.side{border-right:1px solid var(--line);padding:14px 16px;display:flex;flex-direction:column;gap:12px;background:var(--panel)}
.out{padding:14px 18px;display:flex;flex-direction:column;gap:12px}
.fx{border-left:1px solid var(--line);padding:14px 16px;display:flex;flex-direction:column;gap:10px;background:var(--panel)}
.fx h2{font-size:13px;margin:0;color:var(--mut);font-weight:500;letter-spacing:.04em;text-transform:uppercase}
label.f{display:flex;flex-direction:column;gap:4px;font-size:12px;color:var(--mut)}
textarea,select,input[type=number]{font:inherit;padding:6px 8px;border:1px solid var(--line);border-radius:6px;background:#fff;color:var(--ink)}
textarea{min-height:84px;resize:vertical}
.knob{display:grid;grid-template-columns:120px 1fr 48px;gap:8px;align-items:center;font-size:12px;color:var(--mut)}
.knob b{color:var(--ink);font-weight:500;text-align:right}
button{font:inherit;padding:8px 14px;border-radius:6px;border:1px solid var(--red);background:var(--red);color:#fff;cursor:pointer;font-weight:500}
button.sec{background:#fff;color:var(--ink);border-color:var(--line);padding:5px 9px;font-size:12px}
.chips button.on{border-color:var(--red);box-shadow:inset 0 0 0 1px var(--red)}
button:disabled{opacity:.5;cursor:default}
.row{display:flex;gap:8px;align-items:center;flex-wrap:wrap}
#status,.hint{font-size:12px;color:var(--mut);min-height:16px;line-height:1.5}
.card{border:1px solid var(--line);border-radius:8px;padding:10px 12px;background:#fff;display:grid;gap:6px}
.card .t{font-size:14px}.card .m{font-size:12px;color:var(--mut);display:flex;flex-wrap:wrap;gap:6px 14px}.card .m b{color:var(--ink);font-weight:500}
.card.on{box-shadow:inset 3px 0 0 var(--red)}.card audio{width:100%;height:32px}.card .act{display:flex;gap:6px;flex-wrap:wrap}
progress{width:100%;height:6px}
.fxgrid{display:grid;grid-template-columns:118px 1fr 58px 22px;gap:5px 8px;align-items:center;font-size:12px;color:var(--mut)}
.fxgrid b{color:var(--ink);font-weight:500;text-align:right;font-variant-numeric:tabular-nums}
.fxgrid .rst{background:none;border:0;color:var(--mut);cursor:pointer;font-size:14px;padding:0}
.fxgrid .grp{grid-column:1/-1;color:var(--red);font-size:11px;letter-spacing:.06em;text-transform:uppercase;margin-top:8px}
.fxgrid .desc{grid-column:1/-1;font-size:11px;color:var(--mut);margin:-4px 0 2px 0;opacity:.8}
.vrow{display:flex;gap:8px;align-items:center;font-size:12px;padding:4px 0;border-bottom:1px solid var(--line)}
.vrow .nm{flex:1}.vrow small{color:var(--mut)}.vrow .x{background:none;border:0;color:var(--mut);cursor:pointer}
.chips{display:flex;flex-wrap:wrap;gap:6px}
footer{display:flex;align-items:center;justify-content:center;gap:14px;padding:18px;border-top:1px solid var(--line);font-size:12px;color:var(--mut)}
footer img{height:30px;display:block}
[hidden]{display:none!important}
</style></head><body>
<header><h1>MATCHA-TTS-PL</h1><small>Polish TTS with English words &#xb7; running in your browser &#xb7; WebGPU / WebAssembly &#xb7; nothing leaves your machine</small><a href="https://huggingface.co/machinekind/Matcha-TTS-PL" target="_blank">model card &#x2197;</a></header>
<main>
<section class="side">
<label class="f">Text<textarea id="text">Cze&#x15b;&#x107;, jestem HVSR. Jestem humanoidem zbudowanym przez spo&#x142;eczno&#x15b;&#x107; Machinekind. Zapytacie, po co to wszystko? Chcemy stworzy&#x107; miejsce, w kt&#xf3;rym ka&#x17c;dy kto ma ochot&#x119; mo&#x17c;e zdoby&#x107; kompetencje i wymieni&#x107; si&#x119; do&#x15b;wiadczeniami w budowaniu robot&#xf3;w.</textarea></label>
<div class="chips" id="presets"></div>
<div class="f" style="display:flex;flex-direction:column;gap:6px;font-size:12px;color:var(--mut)">Engine
<div class="chips" id="engines"><button class="sec on" data-e="studio" title="Matcha-TTS-PL: 4 voices, each with its large vocoder; one sentence at a time">Studio &#xb7; 4 voices</button><button class="sec" data-e="edge" title="Matcha-TTS-PL-Edge: small vocoder, fp32 or int8, streamed in 0.5 s chunks (first audio in tens of ms)">Edge &#xb7; streaming &#x26a1;</button></div>
<div class="hint" id="enginehint">Studio: the four voices of Matcha-TTS-PL, each with its large vocoder.</div>
</div>
<div id="edgebox" hidden style="display:flex;flex-direction:column;gap:6px;border:1px solid var(--line);border-radius:8px;padding:8px 10px;background:#fff">
<div class="row" style="gap:10px"><label class="f" style="margin:0;flex:1">Precision<select id="e_prec"><option value="int8">int8 (CPU / WebAssembly, ~25 MB)</option><option value="fp32">fp32 (~90 MB)</option></select></label>
<label class="f" style="margin:0;flex:1">ODE steps<select id="e_steps"><option value="2">2 (default)</option><option value="4">4 (smoother, slower)</option></select></label></div>
<label class="row" style="gap:6px;font-size:12px;color:var(--mut)" title="off: each sentence is decoded whole before it plays (sentences still play one after another)"><input type="checkbox" id="e_stream" checked> stream: decoder and vocoder in chunks</label>
<div class="knob" title="mel frames per chunk; one frame = 11.6 ms; 43 = 0.5 s. Smaller chunks start sooner but can stutter on slow CPUs"><span>chunk (frames)</span><input type="range" id="e_chunk" min="20" max="128" step="1" value="43"><b id="e_chunk_v"></b></div>
<div class="knob" title="decoder context frames on each side of a chunk; below 8 the seams are audible"><span>ODE context</span><input type="range" id="e_ode" min="0" max="32" step="1" value="16"><b id="e_ode_v"></b></div>
<div class="knob" title="vocoder context frames; 12 = identical to vocoding the whole sentence, 8 = a little less compute"><span>vocoder context</span><input type="range" id="e_voc" min="8" max="12" step="1" value="12"><b id="e_voc_v"></b></div>
<div class="knob" title="0 = same as chunk. A smaller first chunk starts ~45 ms sooner but may leave a gap right after"><span>first chunk</span><input type="range" id="e_first" min="0" max="86" step="1" value="0"><b id="e_first_v"></b></div>
<div class="row" style="justify-content:space-between"><span class="hint">Defaults (43 / 16 / 12) = the sweet spot of a 210-setting sweep.</span><button class="sec" id="e_reset">defaults</button></div>
</div>
<div class="f" style="display:flex;flex-direction:column;gap:6px;font-size:12px;color:var(--mut)">Voice
<div class="chips" id="voices"></div>
<div class="chips" id="vpresets" style="display:none"></div>
<details id="dsv" hidden><summary style="cursor:pointer">training dataset voices and custom mix</summary>
<div style="display:flex;flex-direction:column;gap:6px;margin-top:6px">
<div class="hint">These are the audiobook readers the model was trained on. Their voices are theirs: see the licence note below before using a recognisable reader. Moving a slider makes a custom mix.</div>
<div class="chips" id="rpresets"></div>
<div id="mixer"></div></div>
</details>
</div>
<details><summary style="font-size:12px;color:var(--mut);cursor:pointer">advanced: pitch range, temperature, tempo, English words, ODE steps, backend</summary>
<div style="margin-top:8px;display:flex;flex-direction:column;gap:8px">
<label class="f" style="margin:0" title="how much the pitch moves within a sentence (style token, normal tempo, statement)">Pitch range<select id="style"></select></label>
<div class="knob"><span>temperature</span><input type="range" id="temperature" min="0" max="1.5" step="0.05" value="0.8" oninput="upd()"><b id="temperature_v"></b></div>
<div class="knob"><span>tempo (length scale)</span><input type="range" id="length_scale" min="0.6" max="1.5" step="0.05" value="0.8" oninput="upd()"><b id="length_scale_v"></b></div>
<label class="f" style="margin:0;flex-direction:row;align-items:center;gap:6px" title="On: English words in Polish text (meeting, feedback, YouTube) are found automatically and read in English. Off: everything is read as Polish; words in {braces} are still read in English."><input type="checkbox" id="english" checked> read English words in English</label>
<details style="margin-top:2px"><summary style="font-size:12px;color:var(--mut);cursor:pointer">pronunciation dictionary</summary>
<textarea id="lexicon" rows="4" spellcheck="false" style="width:100%;margin-top:6px;font-family:ui-monospace,monospace;font-size:12px" placeholder="word -> pronunciation, one per line"></textarea>
<div class="hint">English words (meeting, feedback, YouTube&#x2026;) are detected and pronounced in English automatically (switch above); mark any other word as English with braces: <code>{Hugging Face}</code>. Rules here: one per line, <code>word -> how to say it in Polish spelling</code>, whole words, case-insensitive, applied first. Saved in this browser.</div>
</details>
</div>
<label class="f" style="margin-top:6px" id="modelrow">Model<select id="model"><option value="t4">4 ODE steps (default)</option><option value="t2">2 ODE steps (faster, rougher)</option></select></label>
<label class="f" style="margin-top:6px">Backend<select id="backend"><option value="webgpu">WebGPU (GPU, fastest)</option><option value="wasm">WebAssembly (CPU)</option></select></label>
</details>
<button id="go" disabled>Synthesize (&#x2318;/Ctrl+Enter)</button>
<label class="row" id="rtrow" style="gap:6px;font-size:12px;color:var(--mut)" title="split the text into sentences, start playing the first sentence as soon as it is ready and synthesize the next ones while it plays"><input type="checkbox" id="rt"> real-time: speak chunk by chunk</label>
<progress id="prog" hidden max="100" value="0"></progress>
<div id="status">loading&#x2026;</div>
<div class="hint" style="border-top:1px solid var(--line);padding-top:8px"><b style="color:var(--ink)">About the voices.</b> Matcha-TTS-PL has four synthetic voices (two male, two female), one model each with its own vocoder: designed from text descriptions with VoxCPM2 (OpenBMB, Apache-2.0), not recordings or clones of real people. Each model was distilled from about 15 h of its voice reading the same modern Polish texts (assistant speech, questions, open corpora, YouTube transcripts), English words included. Switching voices downloads that voice's model (about 140 MB). Weights: <a href="https://creativecommons.org/licenses/by-sa/4.0/" target="_blank">CC BY-SA 4.0</a>; every text source is credited in the model's <a href="https://huggingface.co/machinekind/Matcha-TTS-PL/blob/main/ATTRIBUTION.md" target="_blank">ATTRIBUTION.md</a>. Playground code MIT; espeak-ng GPL-3.0.</div>
<div class="hint">Fine-tuned from the pretraining checkpoint <a href="https://huggingface.co/machinekind/Matcha-TTS-PL-Pretrain" target="_blank">Matcha-TTS-PL-Pretrain</a> (audiobook readers; not intended for production).</div>
</section>
<section class="out">
<div class="row" style="justify-content:space-between"><span class="hint">History (newest first). Each clip keeps its settings; send it back to the effects panel with &#x201c;&#x2192; effects&#x201d;.</span><button class="sec" onclick="document.getElementById('hist').innerHTML=''">clear</button></div>
<div id="hist"></div>
</section>
<section class="fx">
<div class="row"><h2>Effects</h2>
<label class="row" style="font-size:13px;gap:6px"><input type="checkbox" id="fxon" checked> <b>enabled</b></label>
<label class="row" style="gap:6px"><input type="checkbox" id="fxloop"> loop</label>
<button class="sec" id="fxreset" style="margin-left:auto">Reset</button></div>
<div id="fxsrc" class="hint">Effects apply to the clip playing in History (the newest one until you play another). Its &#x2b07; wav saves what you hear.</div>
<div class="fxgrid" id="fxgrid"></div>
<div id="fxstatus" class="hint"></div>
</section>
</main>
<footer><span>Synthetic voice by <a href="https://github.com/OpenBMB/VoxCPM" target="_blank" rel="noopener">VoxCPM2</a> &#xb7; fine-tuned from <a href="https://huggingface.co/machinekind/Matcha-TTS-PL-Pretrain" target="_blank" rel="noopener">Matcha-TTS-PL-Pretrain</a> (Wolne Lektury, AZON) &#xb7; text sources in <a href="https://huggingface.co/machinekind/Matcha-TTS-PL/blob/main/ATTRIBUTION.md" target="_blank" rel="noopener">ATTRIBUTION.md</a></span></footer>
<script>window.coi={coepCredentialless:()=>true}</script><script src="coi-serviceworker.js"></script>
<script src="https://cdn.jsdelivr.net/npm/onnxruntime-web@1.30.0/dist/ort.all.min.js" crossorigin="anonymous"></script>
<script type="module">
import { textToIpa, ipaToIds, initPhonemizer } from "./cleaner.js?v=20261012a";
import { initEnglish, markEnglish } from "./english.js?v=20261012a";
import { EFFECTS, DEFAULTS, UI_DEFAULTS, applyChain } from "./effects.js?v=20261012a";
const $=id=>document.getElementById(id); const SR=22050;
const LOCAL = location.hostname==="localhost"||location.hostname==="127.0.0.1";
const MODEL_BASE = LOCAL ? "/release-v2/" : "https://huggingface.co/machinekind/Matcha-TTS-PL/resolve/main/";
const EDGE_BASE = LOCAL ? "/release-edge/" : "https://huggingface.co/machinekind/Matcha-TTS-PL-Edge/resolve/main/";
let ENGINE = "studio"; // "studio": Matcha-TTS-PL (one graph text -> wav per voice); "edge": Matcha-TTS-PL-Edge (encoder + decoder + vocoder, streamed)
const BASE = () => ENGINE === "edge" ? EDGE_BASE : MODEL_BASE;
const EDGE_DEF = {e_chunk: 43, e_ode: 16, e_voc: 12, e_first: 0};
const PRESETS=["Cze\u015b\u0107, jestem HVSR. Jestem humanoidem zbudowanym przez spo\u0142eczno\u015b\u0107 Machinekind. Zapytacie, po co to wszystko? Chcemy stworzy\u0107 miejsce, w kt\u00f3rym ka\u017cdy kto ma ochot\u0119 mo\u017ce zdoby\u0107 kompetencje i wymieni\u0107 si\u0119 do\u015bwiadczeniami w budowaniu robot\u00f3w.","Bateria na\u0142adowana do osiemdziesi\u0119ciu procent. Pobieram update przez Wi-Fi, a potem wracam do warsztatu i ko\u0144cz\u0119 kalibracj\u0119 lewej r\u0119ki.","Cze\u015b\u0107, jestem robotem. Ch\u0119tnie ci w czym\u015b pomog\u0119.","Uwaga, podnosz\u0119 praw\u0105 r\u0119k\u0119. Teraz powoli obracam g\u0142ow\u0119 w lewo.","To niesamowite! Opowiedz mi o tym wi\u0119cej.","Dobrze, ju\u017c id\u0119. Za chwil\u0119 wszystko b\u0119dzie gotowe."];
let VJ=null, W={}; // onnx/voices.json: {default, targets:[ids], speakers:{id:{name,emb}}, styles:{id:{label,emb}}, presets:{key:{label,weights}}}
let VOICES=[{id:'male_voxcpm_1',label:'male_voxcpm_1',path:'onnx/',defaults:{length_scale:0.8}}], VOICE=VOICES[0]; // onnx/voices_index.json: one model + vocoder per voice
let sess=null, sessKey=null, ACTIVE=null, fxTimer=null; const CLIPS=[];
window.upd=()=>{for(const k of ["temperature","length_scale"]) $(k+"_v").textContent=$(k).value; for(const k of Object.keys(EDGE_DEF)) $(k+"_v").textContent=(k==="e_first"&&+$(k).value===0)?"= chunk":$(k).value}; upd();
for(const k of Object.keys(EDGE_DEF)) $(k).addEventListener('input', upd);
$('e_reset').onclick=()=>{ for(const [k,v] of Object.entries(EDGE_DEF)) $(k).value=v; $('e_stream').checked=true; upd() };
if(!navigator.gpu){ $('backend').value='wasm'; $('backend').querySelector('option[value=webgpu]').disabled=true }
ort.env.wasm.wasmPaths="https://cdn.jsdelivr.net/npm/onnxruntime-web@1.30.0/dist/";
ort.env.wasm.numThreads = (self.crossOriginIsolated ? Math.min(4, navigator.hardwareConcurrency||2) : 1);
$('presets').innerHTML=PRESETS.map((p,i)=>`<button class="sec" data-i="${i}">${p.slice(0,28)}${p.length>28?'\u2026':''}</button>`).join(''); $('presets').onclick=e=>{ const b=e.target.closest('button'); if(b) $('text').value=PRESETS[+b.dataset.i] };
async function fetchWithRetry(url, onp, tries=4){
for(let i=1;;i++){
try{ return await fetchWithProgress(url, onp) }
catch(e){ if(i>=tries) throw e; $('status').textContent=`download interrupted, retrying (${i+1}/${tries})\u2026`; await new Promise(r=>setTimeout(r, 1500*i)) }
}
}
async function fetchWithProgress(url, onp){ const r=await fetch(url); if(!r.ok) throw new Error(r.status+" "+url); const total=+r.headers.get("content-length")||0; const reader=r.body.getReader(); const chunks=[]; let got=0; for(;;){ const {done,value}=await reader.read(); if(done) break; chunks.push(value); got+=value.length; if(total) onp(got/total) } const buf=new Uint8Array(got); let o=0; for(const c of chunks){ buf.set(c,o); o+=c.length } return buf }
function tensor(seq){ return {x:new ort.Tensor('int64',BigInt64Array.from(seq.map(BigInt)),[1,seq.length]), x_lengths:new ort.Tensor('int64',BigInt64Array.from([BigInt(seq.length)]),[1])} }
let LOADING=null, LOADING_KEY=null;
function loadModel(){ if(ENGINE==="edge") return loadEdge(); const key=VOICE.id+'|'+$('model').value+'|'+$('backend').value; if(sess && sessKey===key) return Promise.resolve(sess); if(LOADING && LOADING_KEY===key) return LOADING; if(LOADING) return LOADING.catch(()=>{}).then(()=>loadModel()); /* one load at a time */ LOADING_KEY=key; LOADING=_loadModel().finally(()=>{ LOADING=null; LOADING_KEY=null }); return LOADING }
async function _loadModel(){ const kind=$('model').value, be=$('backend').value, key=VOICE.id+'|'+kind+'|'+be; if(sess && sessKey===key) return sess; $('prog').hidden=false; $('status').textContent='downloading model\u2026'; const t0=performance.now(); const bytes=await cachedModel(BASE()+VOICE.path+`matcha_pl_${kind}.onnx`, VOICE.rev||'1', p=>{$('prog').value=Math.round(p*100)}); $('status').textContent=`model ${(bytes.length/1e6).toFixed(0)} MB in ${((performance.now()-t0)/1000).toFixed(1)} s \u00b7 creating session\u2026`; if(sess && sessKey===key){ $('prog').hidden=true; return sess } /* loaded meanwhile by a queued call */ await releaseSession(); sess=await ort.InferenceSession.create(bytes,{executionProviders:[be==='webgpu'?{name:'webgpu',preferredLayout:'NCHW'}:'wasm','wasm'],graphOptimizationLevel:'all'}); sessKey=key; $('status').textContent='warming up\u2026'; try{ await sess.run({...tensor([0,73,0]), scales:new ort.Tensor('float32',Float32Array.from([0.5,1]),[2]), spk_emb:new ort.Tensor('float32',Float32Array.from(VJ.speakers[0].emb),[1,64])}) }catch(e){ console.warn('warm-up',e) } $('prog').hidden=true; return sess }
// only one model in memory: free the previous voice's session (after any synthesis still running on it) before creating the next
let RUNS=0;
async function releaseSession(){ if(!sess) return; const old=sess; sess=null; sessKey=null; while(RUNS>0) await new Promise(r=>setTimeout(r,50)); try{ await old.release() }catch(e){ console.warn('release',e) } }
// downloaded models are kept in the browser's Cache Storage, so switching back to a voice does not download 140 MB again
async function cachedModel(url, rev, onp){
const key=url+(url.includes('?')?'&':'?')+'rev='+encodeURIComponent(rev); let c=null;
try{ c=await caches.open('matcha-tts-pl-models'); const hit=await c.match(key); if(hit){ $('status').textContent='loading model from the browser cache\u2026'; return new Uint8Array(await hit.arrayBuffer()) } }catch(e){ c=null }
const bytes=await fetchWithRetry(url, onp);
if(c){ try{ await c.put(key, new Response(bytes, {headers:{'content-type':'application/octet-stream'}})) }catch(e){ console.warn('model cache full or unavailable', e) } }
return bytes }
async function selectVoice(id, load=true){ if(RT) RT.stop=true; /* a running real-time stream ends before its model is released */ VOICE=VOICES.find(v=>v.id===id)||VOICES[0];
document.querySelectorAll('#voices button').forEach(b=>b.classList.toggle('on', b.dataset.v===VOICE.id));
VJ=await (await fetch(BASE()+VOICE.path+'voices.json',{cache:'no-cache'})).json(); setMix(VJ.presets[VJ.default].weights);
if(VOICE.defaults){ if(VOICE.defaults.length_scale) $('length_scale').value=VOICE.defaults.length_scale; if(VOICE.defaults.temperature!=null) $('temperature').value=VOICE.defaults.temperature; upd() }
if(load) await prefetch() }
async function loadVoiceIndex(){ try{ const ix=await (await fetch(ENGINE==="edge"?EDGE_BASE+'voices_index.json':MODEL_BASE+'onnx/voices_index.json',{cache:'no-cache'})).json(); if(ix.voices&&ix.voices.length) VOICES=ix.voices }catch(e){ console.warn('voices_index', e) }
$('voices').innerHTML=VOICES.map(v=>`<button class="sec" data-v="${v.id}" title="${(v.description||'').replace(/"/g,'&quot;')}">${v.label}</button>`).join('') }
async function setEngine(e){ if(e===ENGINE) return; if(RT){ RT.stop=true; RT.sources.forEach(x=>{ try{ x.stop() }catch(_){} }) }
ENGINE=e; document.querySelectorAll('#engines button').forEach(b=>b.classList.toggle('on', b.dataset.e===e));
const hl=document.querySelector('header a'); if(hl) hl.href = e==="edge" ? "https://huggingface.co/machinekind/Matcha-TTS-PL-Edge" : "https://huggingface.co/machinekind/Matcha-TTS-PL";
$('edgebox').hidden=e!=="edge"; $('modelrow').hidden=e==="edge"; $('rtrow').hidden=e==="edge";
$('enginehint').textContent = e==="edge" ? "Edge: the small-vocoder models of Matcha-TTS-PL-Edge (2 voices), streamed: the decoder and vocoder run on 0.5 s chunks and play as they are ready. Effects apply to the clip in History." : "Studio: the four voices of Matcha-TTS-PL, each with its large vocoder.";
if(e!=="edge") await releaseEdge(); else await releaseSession();
await loadVoiceIndex(); await selectVoice(VOICES[0].id) }
$('engines').onclick=ev=>{ const b=ev.target.closest('button'); if(b) setEngine(b.dataset.e) };
$('e_prec').onchange=()=>{ if(ENGINE==="edge") prefetch() }; $('e_steps').onchange=()=>{ if(ENGINE==="edge") prefetch() };
async function init(){ try{ await loadVoiceIndex();
$('voices').onclick=e=>{ const b=e.target.closest('button'); if(b && b.dataset.v!==VOICE.id) selectVoice(b.dataset.v) };
try{ localStorage.removeItem('tts_pl_v2_voice') }catch(e){} // no voice memory: every visit starts with the first (best) voice
await selectVoice(VOICES[0].id, false); const pchips=Object.entries(VJ.presets).map(([k,p])=>[p.label,p.weights]); const rchips=VJ.targets.map(i=>[VJ.speakers[i].name,{[i]:1}]); const mk=(el,list)=>{ $(el).innerHTML=list.map(([l],j)=>`<button class="sec" data-j="${j}">${l}</button>`).join(''); $(el).onclick=e=>{ const b=e.target.closest('button'); if(b) setMix(list[+b.dataset.j][1]) } }; mk('vpresets',pchips); mk('rpresets',rchips); $('style').innerHTML='<option value="">default (as trained)</option><option value="2">flat (calmer, less pitch movement)</option><option value="14">wide (livelier, more pitch movement)</option>'; setMix(VJ.presets[VJ.default].weights); $('status').textContent='loading espeak-ng\u2026'; await initEnglish(); await initPhonemizer(); await prefetch() }catch(e){ $('status').textContent='init error: '+e } }
async function prefetch(){ $('go').disabled=true; try{ await loadModel(); $('status').textContent='ready \u00b7 press Synthesize' }catch(e){ $('status').textContent='model load error: '+e } finally{ if(!RT) $('go').disabled=false } }
function setMix(weights){ W={}; for(const i of VJ.targets) W[i]=0; for(const [i,w] of Object.entries(weights)) if(+w>0) W[+i]=+w; const ids=[...VJ.targets, ...Object.keys(W).map(Number).filter(i=>!VJ.targets.includes(i))]; $('mixer').innerHTML=ids.map(i=>`<div class="vrow"><span class="nm">${VJ.speakers[i].name}${VJ.targets.includes(i)?'':' <span style="opacity:.6">(base model)</span>'}</span><input type="range" min="0" max="1" step="0.05" value="${W[i]}" data-i="${i}" style="flex:1"><b style="width:34px;text-align:right;color:var(--ink)">${W[i].toFixed(2)}</b></div>`).join(''); markChip() }
function normW(w){ const t=Object.values(w).reduce((a,b)=>a+(+b),0)||1; const o={}; for(const [i,x] of Object.entries(w)) if(+x>0) o[i]=+x/t; return o }
function presetOf(){ const a=normW(W); for(const p of Object.values(VJ.presets)){ const b=normW(p.weights); const ks=new Set([...Object.keys(a),...Object.keys(b)]); if([...ks].every(k=>Math.abs((a[k]||0)-(b[k]||0))<1e-6)) return p.label } return null }
function markChip(){ const pl=presetOf(); const one=Object.entries(W).filter(([i,w])=>w>0); document.querySelectorAll('#vpresets button').forEach(b=>b.classList.toggle('on', b.textContent===pl)); document.querySelectorAll('#rpresets button').forEach(b=>b.classList.toggle('on', !pl && one.length===1 && b.textContent===VJ.speakers[one[0][0]].name)) }
document.addEventListener('input',e=>{ const i=e.target.dataset&&e.target.dataset.i; if(i===undefined||!e.target.closest('#mixer')) return; W[i]=+e.target.value; e.target.nextElementSibling.textContent=W[i].toFixed(2); markChip() });
function spkEmb(){ const tot=Object.values(W).reduce((a,b)=>a+b,0); if(tot<=0) throw new Error('set a weight above 0 for at least one voice'); const e=new Float32Array(64); let nrm=0; for(const [i,w] of Object.entries(W)) if(w>0){ const v=VJ.speakers[i].emb; let q=0; for(let k=0;k<64;k++){ e[k]+=w/tot*v[k]; q+=v[k]*v[k] } nrm+=w/tot*Math.sqrt(q) } { let q=0; for(let k=0;k<64;k++) q+=e[k]*e[k]; const sc=nrm/Math.sqrt(q||1); for(let k=0;k<64;k++) e[k]*=sc } const st=$('style').value; if(st!==''){ const v=VJ.styles[st].emb; for(let k=0;k<64;k++) e[k]+=v[k] } return e }
function voiceLabel(){ const pl=presetOf(); const st0=$('style').value; if(pl) return pl + (st0!=='' ? ' \u00b7 pitch range '+(st0==='2'?'flat':'wide') : ''); const tot=Object.values(W).reduce((a,b)=>a+b,0); const parts=Object.entries(W).filter(([i,w])=>w>0).map(([i,w])=>`${VJ.speakers[i].name.split(' ').pop()} ${(w/tot).toFixed(2)}`); const st=$('style').value; return parts.join(' + ') + (st!=='' ? ' \u00b7 pitch range '+(st==='2'?'flat':'wide') : '') }
const LEX_DEFAULT='';
function lexRules(){
const rules=[];
for(const line of $('lexicon').value.split('\n')){
const m=line.match(/^\s*(.+?)\s*(?:->|=>|\u2192|=|\s[-\u2013\u2014]\s)\s*(.+?)\s*$/); if(!m) continue;
const w=m[1].replace(/[.*+?^${}()|[\]\\]/g,'\\$&');
rules.push([new RegExp('(?<![\\p{L}\\p{N}])'+w+'(?![\\p{L}\\p{N}])','giu'), m[2]]);
}
return rules;
}
function applyLexicon(text){ let t=text; for(const [rx,rep] of lexRules()) t=t.replace(rx, rep); return t }
async function ttsChunk(s, text){
const t=applyLexicon(text); const ipa=await textToIpa($('english').checked?markEnglish(t):t); const {seq,unknown}=ipaToIds(ipa); if(unknown.length) console.warn('unknown symbols',unknown);
// the session must still be the live one when the run starts (a queued voice switch may have released it meanwhile);
// once RUNS is counted, releaseSession() waits for this run to finish
for(;;){ if(!sess || s!==sess) s=await loadModel(); RUNS++; if(s===sess) break; RUNS-- }
let out; try{ out=await s.run({...tensor(seq), scales:new ort.Tensor('float32',Float32Array.from([+$('temperature').value,+$('length_scale').value]),[2]), spk_emb:new ort.Tensor('float32',spkEmb(),[1,64])}) } finally{ RUNS-- }
const n=Number(out.wav_lengths.data[0]); const w=Float32Array.from(out.wav.data.slice(0,n));
return VOICE.highpass_hz ? highpass(w, SR, VOICE.highpass_hz) : w; // per-voice post-filter (voices_index.json), e.g. female_voxcpm_1: removes "p" pops learned from its reference clip
}
// zero-phase 4th-order Butterworth high-pass (two biquads, run forward and backward) = scipy sosfiltfilt(butter(4, fc, "highpass"))
function highpass(x, sr, fc){
const sec=[0.5411961, 1.3065630].map(Q=>{ const w0=2*Math.PI*fc/sr, c=Math.cos(w0), al=Math.sin(w0)/(2*Q), a0=1+al; return [(1+c)/2/a0, -(1+c)/a0, (1+c)/2/a0, -2*c/a0, (1-al)/a0] });
const run=y=>{ for(const [b0,b1,b2,a1,a2] of sec){ let x1=0,x2=0,y1=0,y2=0; for(let i=0;i<y.length;i++){ const xi=y[i], yi=b0*xi+b1*x1+b2*x2-a1*y1-a2*y2; x2=x1; x1=xi; y2=y1; y1=yi; y[i]=yi } } return y };
const y=run(Float32Array.from(x)); y.reverse(); run(y); y.reverse(); return y;
}
function clipMeta(text, tts){ return {text, voice:voiceLabel(), T:$('temperature').value, ls:$('length_scale').value, model:$('model').value, tts} }
async function synthOnce(){
const s=await loadModel(); const text=$('text').value.trim(); if(!text) return;
$('status').textContent='synthesizing\u2026'; const t1=performance.now(); const wav=await ttsChunk(s, text); const t2=performance.now();
const p=clipMeta(text, Math.round(t2-t1)); addCard(p, wav, true);
$('status').textContent=`ok \u00b7 ${$('backend').value} \u00b7 synthesis ${p.tts} ms \u00b7 audio ${(wav.length/SR).toFixed(2)} s \u00b7 RTF ${((t2-t1)/1000/(wav.length/SR)).toFixed(2)}`;
}
function splitSentences(text, minChars=40){
const parts=(text.replace(/\s+/g,' ').match(/[^.!?\u2026]+[.!?\u2026]+[\u201d"\u00bb)]*\s*|[^.!?\u2026]+$/g)||[]).map(x=>x.trim()).filter(Boolean);
const out=[]; // a piece without any letter (stray punctuation) joins the previous sentence; a sentence shorter than minChars is
// merged with the next one (very short inputs synthesise less stably)
for(const x of parts){ if(out.length && (!/\p{L}/u.test(x) || out[out.length-1].length<minChars)) out[out.length-1]+=' '+x; else out.push(x) }
if(out.length>1 && out[out.length-1].length<minChars){ const last=out.pop(); out[out.length-1]+=' '+last }
return out;
}
// ---------- Edge engine: encoder + decoder + vocoder, streamed (same loop as tts.py of Matcha-TTS-PL-Edge) ----------
let ES=null, ESKey=null, ELOADING=null, ERUNS=0;
async function releaseEdge(){ if(!ES) return; const old=ES; ES=null; ESKey=null; while(ERUNS>0) await new Promise(r=>setTimeout(r,50)); for(const s of Object.values(old)) try{ await s.release() }catch(e){} }
async function loadEdge(){ const prec=$('e_prec').value, steps=$('e_steps').value, be=$('backend').value, key=[VOICE.id,prec,steps,be].join('|');
if(ES && ESKey===key) return ES; if(ELOADING){ await ELOADING.catch(()=>{}); if(ES && ESKey===key) return ES }
ELOADING=(async()=>{ const g=VJ.graphs[prec], files=[g.encoder, g.decoder[steps], VJ.graphs.vocoder.fp32]; // the fp32 vocoder in the browser (fp16 kernels vary by backend)
$('prog').hidden=false; const t0=performance.now(); const bufs=[];
for(let i=0;i<files.length;i++){ $('status').textContent=`downloading ${files[i]}\u2026`; bufs.push(await cachedModel(BASE()+VOICE.path+files[i], VOICE.rev||'1', p=>{$('prog').value=Math.round((i+p)/files.length*100)})) }
$('status').textContent=`${(bufs.reduce((a,b)=>a+b.length,0)/1e6).toFixed(0)} MB in ${((performance.now()-t0)/1000).toFixed(1)} s \u00b7 creating sessions\u2026`;
await releaseEdge();
const opt={executionProviders:[be==='webgpu'?{name:'webgpu',preferredLayout:'NCHW'}:'wasm','wasm'],graphOptimizationLevel:'all'};
const optQ=prec==="int8"?{executionProviders:['wasm'],graphOptimizationLevel:'all'}:opt; // WebGPU has no usable int8 (QDQ) kernels: int8 runs on WebAssembly
const S={enc:await ort.InferenceSession.create(bufs[0],optQ), dec:await ort.InferenceSession.create(bufs[1],optQ), voc:await ort.InferenceSession.create(bufs[2],opt)};
$('status').textContent='warming up\u2026'; try{ const spk=new ort.Tensor('float32',Float32Array.from(VJ.speakers[0].emb),[1,64]);
await edgeSentence(S, null, [0,73,0,73,0], spk, 0.5, 1, {stream:false}, async()=>{}) }catch(e){ console.warn('edge warm-up', e) }
ES=S; ESKey=key; $('prog').hidden=true; return S })();
try{ return await ELOADING } finally{ ELOADING=null } }
function gauss(n){ const a=new Float32Array(n); for(let i=0;i<n;i+=2){ const u=1-Math.random(), v=Math.random(), r=Math.sqrt(-2*Math.log(u)); a[i]=r*Math.cos(2*Math.PI*v); if(i+1<n) a[i+1]=r*Math.sin(2*Math.PI*v) } return a }
function sliceT(src, T, a, b, L){ const out=new Float32Array(80*L); for(let c=0;c<80;c++) out.set(src.subarray(c*T+a, c*T+b), c*L); return out } // [80,T] frames a..b -> [80,L] (zero-padded)
async function edgeSentence(S, text, seq, spk, temperature, tempo, prm, onPiece){
if(seq===null){ const t=applyLexicon(text); const ipa=await textToIpa($('english').checked?markEnglish(t):t); const r=ipaToIds(ipa); if(r.unknown.length) console.warn('unknown symbols',r.unknown); seq=r.seq }
ERUNS++; try{
const eo=await S.enc.run({...tensor(seq), scales:new ort.Tensor('float32',Float32Array.from([temperature,tempo]),[2]), spk_emb:spk});
const T=Number(eo.mel_lengths.data[0]), Tf=eo.mu.dims[2], mu=sliceT(eo.mu.data, Tf, 0, T, T);
const z=gauss(80*T), mel=new Float32Array(80*T), st=prm.stream;
const chunk=st?prm.chunk:T, first=st?(prm.first||prm.chunk):T, oc=st?prm.ode:0, vc=st?prm.voc:0;
const bounds=[0]; while(bounds[bounds.length-1]<T) bounds.push(Math.min(T, bounds[bounds.length-1]+(bounds.length===1?first:chunk)));
let p=0; const temp=new ort.Tensor('float32',Float32Array.from([temperature]),[]);
for(let k=0;k<bounds.length-1;k++){
if(RT && RT.stop) return;
const s=bounds[k], e=bounds[k+1], a=Math.max(0,s-oc), b=Math.min(T,e+oc), L=Math.ceil((b-a)/4)*4;
const mk=new Float32Array(L); mk.fill(1,0,b-a);
const d=await S.dec.run({mu:new ort.Tensor('float32',sliceT(mu,T,a,b,L),[1,80,L]), mask:new ort.Tensor('float32',mk,[1,1,L]), z:new ort.Tensor('float32',sliceT(z,T,a,b,L),[1,80,L]), spk_emb:spk, temperature:temp});
const md=d.mel.data; for(let c=0;c<80;c++) mel.set(md.subarray(c*L+(s-a), c*L+(e-a)), c*T+s);
const q = e>=T ? T : e-vc; // the vocoder runs vc frames behind the decoder
if(q>p){ const va=Math.max(0,p-vc), vb=Math.min(T,q+vc);
const w=await S.voc.run({mel:new ort.Tensor('float32',sliceT(mel,T,va,vb,vb-va),[1,80,vb-va])});
const y=Float32Array.from(w.wav.data.subarray((p-va)*256,(q-va)*256), x=>Math.max(-1,Math.min(1,x))); p=q; await onPiece(y, q>=T) }
}
} finally{ ERUNS-- } }
async function edgeStream(){
const S=await loadEdge(); const text=$('text').value.trim(); if(!text) return;
const prm={stream:$('e_stream').checked, chunk:+$('e_chunk').value, ode:+$('e_ode').value, voc:+$('e_voc').value, first:+$('e_first').value};
const sents=splitSentences(text, 40), GAP=0.2; const AC=window.AudioContext||window.webkitAudioContext; let ctx;
try{ ctx=new AC({sampleRate:SR}) }catch(e){ ctx=new AC() }
await ctx.resume(); RT={ctx, sources:[], stop:false, wake:null}; $('go').disabled=false; $('go').textContent='\u25a0 Stop';
const spk=new ort.Tensor('float32',spkEmb(),[1,64]), temperature=+$('temperature').value, tempo=+$('length_scale').value;
const t0=performance.now(); let cursor=0, first=null, pieces=0, gaps=0, gapMs=0; const all=[];
for(let i=0;i<sents.length && !RT.stop;i++){
await edgeSentence(S, sents[i], null, spk, temperature, tempo, prm, async (y, last)=>{
if(RT.stop) return; const yy=(last && i<sents.length-1) ? Float32Array.from([...y, ...new Float32Array(Math.round(GAP*SR))]) : y; all.push(yy);
const now=ctx.currentTime+0.02; if(first!==null && now>cursor){ gaps++; gapMs+=1000*(now-cursor) }
const buf=ctx.createBuffer(1,yy.length,SR); buf.copyToChannel(yy,0); const src=ctx.createBufferSource(); src.buffer=buf; src.connect(ctx.destination);
const at=Math.max(cursor, now); src.start(at); RT.sources.push(src); cursor=at+yy.length/SR; pieces++;
if(first===null) first=Math.round(performance.now()-t0);
$('status').textContent=`edge \u00b7 sentence ${i+1}/${sents.length} \u00b7 first audio after ${first} ms` });
}
const total=Math.round(performance.now()-t0);
if(!RT.stop){ const rest=cursor-ctx.currentTime; if(rest>0){ $('status').textContent=`edge \u00b7 playing \u00b7 first audio after ${first} ms`; await new Promise(r=>{ RT.wake=r; setTimeout(r, rest*1000) }) } }
const stopped=RT.stop, n=all.reduce((a,w)=>a+w.length,0);
if(n){ const wav=new Float32Array(n); let o=0; for(const w of all){ wav.set(w,o); o+=w.length }
const p=clipMeta(text, total); p.model=`Edge ${$('e_prec').value} \u00b7 ${$('e_steps').value} steps \u00b7 `+(prm.stream?`stream ${prm.chunk}/${prm.ode}/${prm.voc}${prm.first?'/f'+prm.first:''}`:'whole sentences')+` \u00b7 first audio ${first} ms`; addCard(p, wav) }
$('status').textContent=`${stopped?'stopped':'ok'} \u00b7 edge \u00b7 ${$('e_prec').value==='int8'?'int8 on WebAssembly':'fp32 on '+$('backend').value} \u00b7 first audio after ${first??'\u2013'} ms \u00b7 ${pieces} pieces in ${total} ms (RTF ${(total/1000/(n/SR||1)).toFixed(2)})` + (gaps?` \u00b7 ${gaps} playback gap${gaps>1?'s':''} (${Math.round(gapMs)} ms)`:' \u00b7 no gaps');
RT=null; try{ ctx.close() }catch(e){}
}
let RT=null;
async function synthStream(){
const s=await loadModel(); const text=$('text').value.trim(); if(!text) return;
const chunks=splitSentences(text); const GAP=0.25;
const AC=window.AudioContext||window.webkitAudioContext; let ctx;
try{ ctx=new AC({sampleRate:SR}) }catch(e){ ctx=new AC() }
await ctx.resume();
RT={ctx, sources:[], stop:false, wake:null}; $('go').disabled=false; $('go').textContent='\u25a0 Stop';
const t0=performance.now(); let cursor=0, first=null; const wavs=[]; const fx=$('fxon').checked?fxParams():null;
for(let i=0;i<chunks.length && !RT.stop;i++){
$('status').textContent=`real-time \u00b7 chunk ${i+1}/${chunks.length}` + (first!==null?` \u00b7 first audio after ${first} ms`:'');
const dry=await ttsChunk(s, chunks[i]); if(RT.stop) break; wavs.push(dry);
const y=fx?applyChain(dry, SR, fx):dry;
const buf=ctx.createBuffer(1, y.length, SR); buf.copyToChannel(y, 0);
const src=ctx.createBufferSource(); src.buffer=buf; src.connect(ctx.destination);
const at=Math.max(cursor, ctx.currentTime+0.02); src.start(at); RT.sources.push(src); cursor=at+y.length/SR+GAP;
if(first===null) first=Math.round(performance.now()-t0);
}
const total=Math.round(performance.now()-t0);
if(!RT.stop){ const rest=cursor-GAP-ctx.currentTime; if(rest>0){ $('status').textContent=`real-time \u00b7 playing \u00b7 first audio after ${first} ms`; await new Promise(r=>{ RT.wake=r; setTimeout(r, rest*1000) }) } }
const stopped=RT.stop;
if(wavs.length){
const gap=new Float32Array(Math.round(SR*GAP)); const n=wavs.reduce((a,w)=>a+w.length,0)+gap.length*(wavs.length-1);
const all=new Float32Array(n); let o=0; wavs.forEach((w,i)=>{ all.set(w,o); o+=w.length; if(i<wavs.length-1) o+=gap.length });
const p=clipMeta(text, total); p.model+=' \u00b7 real-time, '+wavs.length+' chunks'; addCard(p, all);
}
$('status').textContent=`${stopped?'stopped':'ok'} \u00b7 real-time \u00b7 first audio after ${first??'\u2013'} ms \u00b7 ${wavs.length}/${chunks.length} chunks synthesized in ${total} ms`;
RT=null; try{ ctx.close() }catch(e){}
}
async function synth(){
if(RT){ RT.stop=true; RT.sources.forEach(x=>{ try{ x.stop() }catch(e){} }); try{ RT.ctx.suspend() }catch(e){} if(RT.wake) RT.wake(); $('go').disabled=true; $('status').textContent='stopping\u2026'; return }
$('go').disabled=true;
try{ if(ENGINE==="edge") await edgeStream(); else if($('rt').checked) await synthStream(); else await synthOnce() }
catch(e){ $('status').textContent='error: '+e; console.error(e) }
finally{ RT=null; $('go').disabled=false; $('go').textContent='Synthesize (\u2318/Ctrl+Enter)' }
}
function wavBytes(f32){ const n=f32.length, buf=new ArrayBuffer(44+n*2), v=new DataView(buf); const 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); for(let i=0;i<n;i++){ const s=Math.max(-1,Math.min(1,f32[i])); v.setInt16(44+i*2, s<0?s*32768:s*32767, true) } return buf }
const wavUrl=f32=>URL.createObjectURL(new Blob([wavBytes(f32)],{type:'audio/wav'}));
function addCard(p, wav, play=false){ const el=document.createElement('div'); el.className='card'; el.innerHTML=`<div class="t">${p.text}</div><div class="m"><span>voice <b>${p.voice}</b></span><span>T <b>${p.T}</b></span><span>tempo <b>${p.ls}</b></span><span>${p.model}</span><span>synthesis <b>${p.tts}</b> ms</span><span>${(wav.length/SR).toFixed(2)} s</span></div><div class="act"><a class="sec" download="matcha-pl.wav" href="#" style="text-decoration:none">\u2b07 wav</a></div><audio controls></audio>`;
const c={p, wav, el, audio:el.querySelector('audio'), dl:el.querySelector('a'), key:null, url:null}; CLIPS.push(c); c.audio.loop=$('fxloop').checked;
c.audio.addEventListener('play',()=>{ setActive(c); CLIPS.forEach(o=>{ if(o!==c && !o.audio.paused) o.audio.pause() }); if(c.key!==fxKey()) renderClip(c); else fxStatus(c) });
$('hist').prepend(el); setActive(c); renderClip(c); if(play) c.audio.play().catch(()=>{}) }
// ---------------- effects (client-side DSP, effects.js)
let g=''; $('fxgrid').innerHTML=EFFECTS.map(e=>{ const head=e.group!==g?`<div class="grp">${(g=e.group)}</div>`:''; const d=UI_DEFAULTS[e.key]; return head+`<span>${e.label}</span><input type="range" id="fx_${e.key}" min="${e.min}" max="${e.max}" step="${e.step}" value="${d}"><b id="fxv_${e.key}">${d} ${e.unit}</b><button class="rst" title="reset" data-k="${e.key}">\u21ba</button><div class="desc">${e.desc}</div>` }).join('');
$('fxgrid').addEventListener('input',e=>{ const inp=e.target; if(inp.type!=='range') return; const k=inp.id.slice(3); $('fxv_'+k).textContent=inp.value+' '+EFFECTS.find(x=>x.key===k).unit; fxChanged() });
$('fxgrid').addEventListener('dblclick',e=>{ if(e.target.type==='range') resetOne(e.target.id.slice(3)) });
$('fxgrid').addEventListener('click',e=>{ const b=e.target.closest('button.rst'); if(b) resetOne(b.dataset.k) });
function fxParams(){ const o={}; for(const e of EFFECTS) o[e.key]=+$('fx_'+e.key).value; return o }
function resetOne(k){ $('fx_'+k).value=UI_DEFAULTS[k]; $('fxv_'+k).textContent=UI_DEFAULTS[k]+' '+EFFECTS.find(x=>x.key===k).unit; fxChanged() }
$('fxreset').onclick=()=>{ for(const e of EFFECTS){ $('fx_'+e.key).value=UI_DEFAULTS[e.key]; $('fxv_'+e.key).textContent=UI_DEFAULTS[e.key]+' '+e.unit } applyFx() };
$('fxon').onchange=applyFx; $('fxloop').onchange=()=>{ const on=$('fxloop').checked; CLIPS.forEach(c=>{ c.audio.loop=on }); if(on && ACTIVE) ACTIVE.audio.play().catch(()=>{}) };
function fxChanged(){ clearTimeout(fxTimer); fxTimer=setTimeout(applyFx,150) }
function fxSnapshot(){ return $('fxon').checked?fxParams():null }
function fxSummary(p){ if(!p) return 'no effects'; const d=Object.entries(p).filter(([k,v])=>v!=DEFAULTS[k]); return d.length?d.map(([k,v])=>`${k} ${v}`).join(', '):'neutral' }
function fxKey(){ return JSON.stringify(fxSnapshot()) }
function setActive(c){ if(ACTIVE && ACTIVE!==c) ACTIVE.el.classList.remove('on'); ACTIVE=c; c.el.classList.add('on') }
function renderClip(c){ const fx=fxSnapshot(); const t0=performance.now(); const y=fx?applyChain(c.wav, SR, fx):c.wav; const a=c.audio; const playing=!a.paused && !a.ended; const pos=a.ended?0:a.currentTime;
if(c.url) URL.revokeObjectURL(c.url); c.url=wavUrl(y); c.key=fxKey(); a.src=c.url; a.loop=$('fxloop').checked; if(pos>0) a.currentTime=Math.min(pos, Math.max(0, y.length/SR-0.05));
c.dl.href=c.url; c.dl.download=fx?'matcha-pl-fx.wav':'matcha-pl.wav'; c.dl.textContent=fx?'\u2b07 wav (with effects)':'\u2b07 wav'; if(playing) a.play().catch(()=>{});
fxStatus(c, Math.round(performance.now()-t0)) }
function fxStatus(c, ms){ const fx=JSON.parse(c.key); const t=c.p.text; $('fxstatus').textContent=`${fx?'effects on':'effects off'} \u00b7 \u201c${t.slice(0,50)}${t.length>50?'\u2026':''}\u201d` + (fx?` \u00b7 ${fxSummary(fx)}`+(ms!==undefined?` \u00b7 DSP ${ms} ms`:''):'') }
function applyFx(){ if(!ACTIVE){ $('fxstatus').textContent='synthesize something first'; return } renderClip(ACTIVE) }
$('model').onchange=prefetch; $('backend').onchange=prefetch;
try{ const sv=localStorage.getItem('tts_pl_lexicon'); if(sv!==null) $('lexicon').value=sv }catch(e){}
try{ const en=localStorage.getItem('tts_pl_v2_english'); if(en!==null) $('english').checked=en==='1' }catch(e){}
$('english').addEventListener('change',()=>{ try{ localStorage.setItem('tts_pl_v2_english',$('english').checked?'1':'0') }catch(e){} });
$('lexicon').addEventListener('input',()=>{ try{ localStorage.setItem('tts_pl_lexicon',$('lexicon').value) }catch(e){} });
window.__lex=applyLexicon;
$('go').onclick=synth; document.addEventListener('keydown',e=>{ if((e.metaKey||e.ctrlKey)&&e.key==='Enter') synth() });
window.__viz={textToIpa, ipaToIds};
init();
</script></body></html>