ybtts / index.html
masbudjj's picture
Update index.html
8666e0b verified
<!doctype html>
<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>