Spaces:
Running
Running
| <html lang="en"> | |
| <head> | |
| <meta charset="utf-8"/> | |
| <meta name="viewport" content="width=device-width,initial-scale=1"/> | |
| <title>YBTTS — Multi Engine TTS (Kokoro / Piper / Kitten)</title> | |
| <style> | |
| :root{--bg:#fff;--fg:#111827;--muted:#6b7280;--chip:#f3f4f6;--b:#e5e7eb} | |
| *{box-sizing:border-box} body{font-family:system-ui,-apple-system,Segoe UI,Roboto,Arial;margin:16px;color:var(--fg)} | |
| h1{margin:0 0 8px} .muted{color:var(--muted)} .row{display:flex;gap:8px;align-items:center;flex-wrap:wrap} | |
| .grid{display:grid;grid-template-columns:1fr 1fr;gap:12px} @media(max-width:900px){.grid{grid-template-columns:1fr}} | |
| fieldset{border:1px solid var(--b);border-radius:10px;padding:10px 12px} legend{padding:0 6px;font-weight:600} | |
| label{display:block;margin:6px 0 4px} input[type="range"]{width:100%} textarea{width:100%;min-height:120px} | |
| button{padding:10px 14px;border:1px solid #d1d5db;border-radius:10px;background:#fff;cursor:pointer} | |
| button:disabled{opacity:.6;cursor:not-allowed} | |
| .chip{display:inline-block;padding:4px 8px;border-radius:999px;background:var(--chip);margin-right:6px} | |
| .mono{font-family:ui-monospace,SFMono-Regular,Menlo,Consolas,monospace} | |
| .hidden{display:none} .pad{padding:8px;border:1px dashed var(--b);border-radius:10px} | |
| .log{white-space:pre-wrap;background:#f9fafb;border:1px solid var(--b);border-radius:8px;padding:8px;min-height:100px} | |
| .ok{color:#065f46} .warn{color:#92400e} .err{color:#b91c1c} | |
| </style> | |
| </head> | |
| <body> | |
| <h1>YBTTS — Multi Engine TTS</h1> | |
| <p class="muted">Kokoro via <code>kokoro-js</code>, Piper via Rhasspy, Kitten (placeholder). Fully client-side di Static Space.</p> | |
| <div class="row"> | |
| <span id="statusBackend" class="chip">init…</span> | |
| <span id="statusEngine" class="chip">engine: kokoro</span> | |
| <span id="statusModel" class="chip">model: -</span> | |
| </div> | |
| <div class="grid" style="margin-top:12px"> | |
| <div> | |
| <fieldset> | |
| <legend>Engine</legend> | |
| <label><input type="radio" name="engine" value="kokoro" checked> Kokoro</label> | |
| <div class="pad" id="kokoroPanel"> | |
| <label>Voice preset</label> | |
| <select id="kokoroVoice"> | |
| <!-- daftar resmi di kokoro-js (contoh umum) --> | |
| <option value="af_bella" selected>af_bella (female)</option> | |
| <option value="af_sarah">af_sarah (female)</option> | |
| <option value="am_michael">am_michael (male)</option> | |
| <option value="bf_emma">bf_emma (female)</option> | |
| <option value="bm_george">bm_george (male)</option> | |
| </select> | |
| <div class="muted" style="margin-top:6px">Model: onnx-community/Kokoro-82M-v1.0-ONNX (q8 default)</div> | |
| </div> | |
| <label><input type="radio" name="engine" value="piper"> Piper</label> | |
| <div class="pad hidden" id="piperPanel"> | |
| <div class="row"> | |
| <div style="flex:1"> | |
| <label>Lang</label> | |
| <input id="pLang" value="en" placeholder="en"> | |
| </div> | |
| <div style="flex:1"> | |
| <label>Speaker</label> | |
| <input id="pSpeaker" value="en_US-lessac" placeholder="en_US-lessac"> | |
| </div> | |
| <div style="flex:1"> | |
| <label>Quality</label> | |
| <select id="pQuality"> | |
| <option value="high" selected>high</option> | |
| <option value="medium">medium</option> | |
| <option value="low">low</option> | |
| </select> | |
| </div> | |
| </div> | |
| <div class="muted" style="margin-top:6px">Repo: rhasspy/piper-voices (struktur folder ketat; salah kombinasi → 404)</div> | |
| </div> | |
| <label><input type="radio" name="engine" value="kitten"> Kitten</label> | |
| <div class="pad hidden" id="kittenPanel"> | |
| <div class="muted">Sementara placeholder. (Bisa diaktifkan nanti jika model/URL siap)</div> | |
| </div> | |
| </fieldset> | |
| <fieldset style="margin-top:12px"> | |
| <legend>Teks</legend> | |
| <textarea id="text">Hello from YBTTS on Hugging Face Spaces!</textarea> | |
| <div class="row" style="margin-top:6px"> | |
| <button id="btnGenerate">Generate</button> | |
| <button id="btnStop" disabled>Stop</button> | |
| <span id="runStatus" class="chip">idle</span> | |
| </div> | |
| </fieldset> | |
| <fieldset style="margin-top:12px"> | |
| <legend>Output</legend> | |
| <audio id="player" controls></audio> | |
| <div style="margin-top:6px"> | |
| <a id="download" class="hidden" download="tts.wav">Download WAV</a> | |
| </div> | |
| </fieldset> | |
| </div> | |
| <div> | |
| <fieldset> | |
| <legend>Advanced</legend> | |
| <label>Speed (playback) <span id="spVal">1.00×</span></label> | |
| <input id="speed" type="range" min="0.5" max="2" step="0.05" value="1.0"> | |
| <div class="muted">Catatan: perubahan speed di browser hanya playbackRate (tanpa re-synthesis).</div> | |
| <div class="muted" style="margin-top:8px">ONNX Runtime Web diatur <code>numThreads = 1</code> untuk stabilitas di Static Space.</div> | |
| </fieldset> | |
| <fieldset style="margin-top:12px"> | |
| <legend>Log</legend> | |
| <pre id="log" class="mono log"></pre> | |
| </fieldset> | |
| </div> | |
| </div> | |
| <script type="module"> | |
| // ===== Utilities ===== | |
| const $ = (q)=>document.querySelector(q); | |
| const $$ = (q)=>document.querySelectorAll(q); | |
| const log = (m)=>{ console.log(m); $("#log").textContent += (m + "\\n"); }; | |
| const setChip = (id, txt)=>{ document.getElementById(id).textContent = txt; }; | |
| // slider reflect | |
| const speed = $("#speed"); const spVal = $("#spVal"); | |
| speed.addEventListener("input", ()=> spVal.textContent = Number(speed.value).toFixed(2) + "×"); | |
| // audio | |
| const player = $("#player"); const aDownload = $("#download"); | |
| function useAudio(float32, sr){ | |
| // encode WAV via transformers utils jika tersedia; di sini manual minimal | |
| // Agar tanpa lib tambahan, kita simpan Float32Array -> WAV 16-bit | |
| const wav = pcm16Wav(float32, sr); | |
| const blob = new Blob([wav], { type: "audio/wav" }); | |
| const url = URL.createObjectURL(blob); | |
| player.src = url; | |
| player.playbackRate = parseFloat(speed.value); | |
| aDownload.href = url; aDownload.classList.remove("hidden"); | |
| } | |
| function floatTo16BitPCM(float32){ | |
| const s = Math.max(-1, Math.min(1, float32)); | |
| return s < 0 ? s * 0x8000 : s * 0x7FFF; | |
| } | |
| function pcm16Wav(float32Array, sampleRate){ | |
| const len = float32Array.length; | |
| const buffer = new ArrayBuffer(44 + len * 2); | |
| const view = new DataView(buffer); | |
| const writeStr = (o,s)=>{ for(let i=0;i<s.length;i++) view.setUint8(o+i, s.charCodeAt(i)); }; | |
| writeStr(0,"RIFF"); view.setUint32(4, 36 + len*2, true); writeStr(8,"WAVE"); | |
| writeStr(12,"fmt "); view.setUint32(16,16,true); view.setUint16(20,1,true); view.setUint16(22,1,true); | |
| view.setUint32(24, sampleRate, true); view.setUint32(28, sampleRate*2, true); view.setUint16(32,2,true); view.setUint16(34,16,true); | |
| writeStr(36,"data"); view.setUint32(40, len*2, true); | |
| let off = 44; | |
| for(let i=0;i<len;i++,off+=2) view.setInt16(off, floatTo16BitPCM(float32Array[i]), true); | |
| return buffer; | |
| } | |
| // ===== ONNX Runtime Web safe defaults ===== | |
| // (Hanya diperlukan untuk Piper/Kitten yang pakai onnxruntime-web) | |
| import * as ort from "https://cdn.jsdelivr.net/npm/[email protected]/dist/ort.min.js"; | |
| ort.env.wasm.numThreads = 1; // penting utk Static Space | |
| ort.env.wasm.proxy = true; // jalankan WASM di worker | |
| // ===== Engine state ===== | |
| let currentEngine = "kokoro"; | |
| setChip("statusEngine", "engine: " + currentEngine); | |
| $$('input[name="engine"]').forEach(r => { | |
| r.addEventListener("change", ()=>{ | |
| currentEngine = r.value; | |
| setChip("statusEngine", "engine: " + currentEngine); | |
| $("#kokoroPanel").classList.toggle("hidden", currentEngine!=="kokoro"); | |
| $("#piperPanel").classList.toggle("hidden", currentEngine!=="piper"); | |
| $("#kittenPanel").classList.toggle("hidden", currentEngine!=="kitten"); | |
| }); | |
| }); | |
| // ===== Kokoro via kokoro-js ===== | |
| let kokoro = null; | |
| async function ensureKokoro(dtype="q8"){ | |
| if (kokoro) return kokoro; | |
| setChip("statusModel", "loading kokoro..."); | |
| const { KokoroTTS } = await import("https://esm.sh/kokoro-js@latest"); | |
| // Model resmi: onnx-community/Kokoro-82M-v1.0-ONNX | |
| kokoro = await KokoroTTS.from_pretrained("onnx-community/Kokoro-82M-v1.0-ONNX", { dtype }); | |
| setChip("statusModel", "kokoro: " + dtype); | |
| log("Kokoro ready."); | |
| return kokoro; | |
| } | |
| // ===== Piper (Rhasspy) ===== | |
| async function exists(url){ | |
| try { | |
| const r = await fetch(url, { method: "HEAD" }); | |
| return r.ok; | |
| } catch { return false; } | |
| } | |
| function makePiperUrls(lang, speaker, quality){ | |
| // contoh: https://huggingface.co/rhasspy/piper-voices/resolve/main/en/en_US-lessac/high/en_US-lessac-high.onnx | |
| const base = `https://huggingface.co/rhasspy/piper-voices/resolve/main/${lang}/${lang}_${speaker}/${quality}/`; | |
| const stem = `${lang}_${speaker}-${quality}`; | |
| return { | |
| model: `${base}${stem}.onnx`, | |
| config: `${base}${stem}.onnx.json` | |
| }; | |
| } | |
| async function loadPiperSession(){ | |
| const lang = $("#pLang").value.trim(); // ex: en | |
| const spk = $("#pSpeaker").value.trim(); // ex: en_US-lessac | |
| const q = $("#pQuality").value; // high|medium|low | |
| const { model, config } = makePiperUrls(lang, spk, q); | |
| // Validasi dulu supaya tidak silent-fail | |
| if (!(await exists(model))) { | |
| throw new Error(`Piper model not found: ${model}`); | |
| } | |
| if (!(await exists(config))) { | |
| throw new Error(`Piper config not found: ${config}`); | |
| } | |
| setChip("statusModel", "piper: loading..."); | |
| // Pakai onnxruntime-web langsung (contoh minimal). Idealnya gunakan lib wrapper piper jika ada. | |
| // Di sini, karena format Rhasspy ONNX bervariasi, bagian inferensi detail perlu disesuaikan model piper. | |
| // Untuk sekarang, kita munculkan error terarah agar kamu tahu path sudah benar. | |
| throw new Error("Piper engine wiring belum diaktifkan (but URL valid). Pastikan operator & I/O mapping sesuai model Rhasspy ONNX yang dipilih."); | |
| } | |
| // ===== Kitten (placeholder) ===== | |
| async function loadKittenSession(){ | |
| throw new Error("Kitten engine belum dihubungkan. Siapkan model/URL ONNX & I/O mapping."); | |
| } | |
| // ===== Backend info (WebGPU/WASM) ===== | |
| if (navigator.gpu) setChip("statusBackend","WebGPU ready"); | |
| else setChip("statusBackend","WASM fallback"); | |
| // ===== Generate ===== | |
| let abortFlag = false; | |
| $("#btnStop").addEventListener("click", ()=>{ abortFlag = true; $("#btnStop").disabled = true; }); | |
| $("#btnGenerate").addEventListener("click", async ()=>{ | |
| const text = $("#text").value.trim(); | |
| if (!text){ alert("Teks kosong"); return; } | |
| $("#btnGenerate").disabled = true; $("#btnStop").disabled = false; $("#runStatus").textContent = "generating…"; | |
| aDownload.classList.add("hidden"); | |
| try { | |
| if (currentEngine === "kokoro") { | |
| const t = await ensureKokoro("q8"); // q8 => cepat & ringan | |
| const voice = $("#kokoroVoice").value || "af_bella"; | |
| const out = await t.generate(text, { voice }); // { data: Float32Array(…); sampleRate: 24000 } | |
| if (abortFlag) throw new Error("Aborted"); | |
| useAudio(out.data, 24000); | |
| $("#runStatus").textContent = "done"; | |
| } | |
| else if (currentEngine === "piper") { | |
| await loadPiperSession(); // akan throw arahkan kamu untuk wiring | |
| } | |
| else if (currentEngine === "kitten") { | |
| await loadKittenSession(); // placeholder | |
| } | |
| } catch(e){ | |
| console.error(e); | |
| $("#runStatus").textContent = "error"; | |
| log("[error] " + (e?.message || e)); | |
| } finally { | |
| abortFlag = false; | |
| $("#btnGenerate").disabled = false; $("#btnStop").disabled = true; | |
| } | |
| }); | |
| </script> | |
| </body> | |
| </html> | |