Add Qwen3-TTS zero-shot voice cloning web UI
Extract the voice-cloning feature from jamiepine/voicebox (Qwen3-TTS Base engine) into a small standalone app: FastAPI backend (engine.py + app.py) wrapping create_voice_clone_prompt/generate_voice_clone, and a single-page UI (upload or mic-record a reference clip + transcript -> synthesize any text in that voice). Supports 10 languages incl. Korean; model loads lazily and downloads on first use.
This commit is contained in:
208
static/index.html
Normal file
208
static/index.html
Normal file
@@ -0,0 +1,208 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="ko">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<title>voice_copy · 음성 클로닝</title>
|
||||
<style>
|
||||
:root { --bg:#0f1115; --card:#181b22; --line:#272b35; --fg:#e8eaed; --mut:#9aa0ab; --acc:#6ea8fe; --accd:#3d6fd0; --err:#ff6b6b; }
|
||||
* { box-sizing:border-box; }
|
||||
body { margin:0; font-family:system-ui,-apple-system,"Segoe UI",Roboto,"Noto Sans KR",sans-serif; background:var(--bg); color:var(--fg); line-height:1.5; }
|
||||
.wrap { max-width:760px; margin:0 auto; padding:28px 18px 64px; }
|
||||
h1 { font-size:22px; margin:0 0 4px; }
|
||||
.sub { color:var(--mut); font-size:14px; margin:0 0 20px; }
|
||||
.badge { display:inline-block; font-size:12px; color:var(--mut); background:var(--card); border:1px solid var(--line); border-radius:999px; padding:3px 10px; margin-left:8px; }
|
||||
.card { background:var(--card); border:1px solid var(--line); border-radius:14px; padding:18px; margin-bottom:16px; }
|
||||
label { display:block; font-weight:600; font-size:14px; margin:0 0 6px; }
|
||||
.hint { color:var(--mut); font-weight:400; font-size:12px; }
|
||||
input[type=text], input[type=number], textarea, select {
|
||||
width:100%; background:#0e1015; color:var(--fg); border:1px solid var(--line);
|
||||
border-radius:9px; padding:10px 12px; font-size:15px; font-family:inherit; }
|
||||
textarea { resize:vertical; min-height:64px; }
|
||||
.row { display:flex; gap:12px; flex-wrap:wrap; }
|
||||
.row > div { flex:1 1 160px; }
|
||||
.fieldgap { margin-bottom:14px; }
|
||||
button { cursor:pointer; border:none; border-radius:9px; padding:10px 16px; font-size:15px; font-weight:600; }
|
||||
.btn-primary { background:var(--acc); color:#0b0d12; width:100%; padding:13px; }
|
||||
.btn-primary:disabled { background:#394252; color:#8a90a0; cursor:not-allowed; }
|
||||
.btn-ghost { background:#232833; color:var(--fg); border:1px solid var(--line); }
|
||||
.rec { display:flex; align-items:center; gap:10px; flex-wrap:wrap; margin-top:10px; }
|
||||
.dot { width:10px; height:10px; border-radius:50%; background:#556; }
|
||||
.dot.on { background:var(--err); animation:pulse 1s infinite; }
|
||||
@keyframes pulse { 50% { opacity:.35; } }
|
||||
audio { width:100%; margin-top:10px; }
|
||||
#status { min-height:20px; font-size:14px; margin-top:12px; }
|
||||
.err { color:var(--err); }
|
||||
.ok { color:#63d68b; }
|
||||
a.dl { color:var(--acc); font-size:14px; }
|
||||
.spin { display:inline-block; width:14px; height:14px; border:2px solid #6ea8fe55; border-top-color:var(--acc); border-radius:50%; animation:sp .8s linear infinite; vertical-align:-2px; margin-right:6px; }
|
||||
@keyframes sp { to { transform:rotate(360deg); } }
|
||||
code { background:#0e1015; padding:1px 5px; border-radius:5px; font-size:13px; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="wrap">
|
||||
<h1>voice_copy <span class="badge" id="badge">…</span></h1>
|
||||
<p class="sub">참조 음성 몇 초 + 그 전사를 주면, 아무 텍스트나 그 목소리로 합성합니다. (Qwen3-TTS zero-shot 클로닝)</p>
|
||||
|
||||
<div class="card">
|
||||
<label>1. 참조 음성 <span class="hint">(클로닝할 목소리 · 5~15초 권장)</span></label>
|
||||
<input type="file" id="file" accept="audio/*" />
|
||||
<div class="rec">
|
||||
<button type="button" class="btn-ghost" id="recBtn">● 녹음</button>
|
||||
<button type="button" class="btn-ghost" id="stopBtn" disabled>■ 정지</button>
|
||||
<span class="dot" id="dot"></span><span class="hint" id="recInfo">마이크로 직접 녹음할 수도 있어요</span>
|
||||
</div>
|
||||
<audio id="refPlayer" controls hidden></audio>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<div class="fieldgap">
|
||||
<label>2. 참조 음성의 전사 <span class="hint">(위 오디오에서 말한 내용 그대로)</span></label>
|
||||
<textarea id="refText" placeholder="예) 안녕하세요, 오늘 날씨가 정말 좋네요."></textarea>
|
||||
</div>
|
||||
<div class="row fieldgap">
|
||||
<div>
|
||||
<label>언어</label>
|
||||
<select id="language"></select>
|
||||
</div>
|
||||
<div>
|
||||
<label>스타일 지시 <span class="hint">(선택)</span></label>
|
||||
<input type="text" id="instruct" placeholder="예) 밝고 활기차게" />
|
||||
</div>
|
||||
<div>
|
||||
<label>seed <span class="hint">(선택)</span></label>
|
||||
<input type="number" id="seed" placeholder="랜덤" />
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<label>3. 합성할 텍스트 <span class="hint">(이 내용을 클로닝된 목소리로 말합니다)</span></label>
|
||||
<textarea id="text" placeholder="여기에 말하게 할 문장을 입력하세요."></textarea>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button class="btn-primary" id="go">🎙️ 목소리 생성</button>
|
||||
<div id="status"></div>
|
||||
|
||||
<div class="card" id="resultCard" hidden>
|
||||
<label>결과</label>
|
||||
<audio id="outPlayer" controls></audio>
|
||||
<div><a class="dl" id="dl" download="cloned.wav">⬇ 다운로드 (cloned.wav)</a></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
const $ = (id) => document.getElementById(id);
|
||||
let recordedBlob = null, mediaRec = null, chunks = [], stream = null;
|
||||
|
||||
// health / language list
|
||||
fetch("/api/health").then(r => r.json()).then(h => {
|
||||
$("badge").textContent = `${h.model_size} · ${h.device}`;
|
||||
const names = {auto:"자동감지", ko:"한국어", en:"English", zh:"中文", ja:"日本語", de:"Deutsch", fr:"Français", ru:"Русский", pt:"Português", es:"Español", it:"Italiano"};
|
||||
const sel = $("language");
|
||||
h.languages.forEach(code => {
|
||||
const o = document.createElement("option");
|
||||
o.value = code; o.textContent = names[code] || code;
|
||||
if (code === "auto") o.selected = true;
|
||||
sel.appendChild(o);
|
||||
});
|
||||
}).catch(() => { $("badge").textContent = "offline"; });
|
||||
|
||||
// file upload clears recording
|
||||
$("file").addEventListener("change", () => {
|
||||
if ($("file").files[0]) {
|
||||
recordedBlob = null;
|
||||
const url = URL.createObjectURL($("file").files[0]);
|
||||
$("refPlayer").src = url; $("refPlayer").hidden = false;
|
||||
}
|
||||
});
|
||||
|
||||
// ── Mic recording → in-browser WAV (no ffmpeg needed) ──
|
||||
$("recBtn").addEventListener("click", async () => {
|
||||
try {
|
||||
stream = await navigator.mediaDevices.getUserMedia({ audio: true });
|
||||
} catch (e) { setStatus("마이크 접근 실패: " + e.message, "err"); return; }
|
||||
const ctx = new (window.AudioContext || window.webkitAudioContext)();
|
||||
const src = ctx.createMediaStreamSource(stream);
|
||||
const proc = ctx.createScriptProcessor(4096, 1, 1);
|
||||
const buffers = []; const sr = ctx.sampleRate;
|
||||
proc.onaudioprocess = (e) => buffers.push(new Float32Array(e.inputBuffer.getChannelData(0)));
|
||||
src.connect(proc); proc.connect(ctx.destination);
|
||||
mediaRec = { ctx, proc, src, buffers, sr };
|
||||
$("recBtn").disabled = true; $("stopBtn").disabled = false;
|
||||
$("dot").classList.add("on"); $("recInfo").textContent = "녹음 중…";
|
||||
});
|
||||
|
||||
$("stopBtn").addEventListener("click", () => {
|
||||
if (!mediaRec) return;
|
||||
const { ctx, proc, src, buffers, sr } = mediaRec;
|
||||
proc.disconnect(); src.disconnect(); ctx.close();
|
||||
stream.getTracks().forEach(t => t.stop());
|
||||
const wav = encodeWav(buffers, sr);
|
||||
recordedBlob = wav; $("file").value = "";
|
||||
$("refPlayer").src = URL.createObjectURL(wav); $("refPlayer").hidden = false;
|
||||
$("recBtn").disabled = false; $("stopBtn").disabled = true;
|
||||
$("dot").classList.remove("on"); $("recInfo").textContent = "녹음 완료 ✔ (다시 녹음 가능)";
|
||||
mediaRec = null;
|
||||
});
|
||||
|
||||
function encodeWav(buffers, sampleRate) {
|
||||
let len = 0; buffers.forEach(b => len += b.length);
|
||||
const pcm = new Float32Array(len); let o = 0;
|
||||
buffers.forEach(b => { pcm.set(b, o); o += b.length; });
|
||||
const buf = new ArrayBuffer(44 + pcm.length * 2);
|
||||
const view = new DataView(buf);
|
||||
const ws = (off, s) => { for (let i = 0; i < s.length; i++) view.setUint8(off + i, s.charCodeAt(i)); };
|
||||
ws(0, "RIFF"); view.setUint32(4, 36 + pcm.length * 2, true); ws(8, "WAVE");
|
||||
ws(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);
|
||||
ws(36, "data"); view.setUint32(40, pcm.length * 2, true);
|
||||
let p = 44;
|
||||
for (let i = 0; i < pcm.length; i++) { const s = Math.max(-1, Math.min(1, pcm[i])); view.setInt16(p, s < 0 ? s * 0x8000 : s * 0x7fff, true); p += 2; }
|
||||
return new Blob([view], { type: "audio/wav" });
|
||||
}
|
||||
|
||||
function setStatus(msg, cls) { const s = $("status"); s.className = cls || ""; s.innerHTML = msg; }
|
||||
|
||||
// ── Generate ──
|
||||
$("go").addEventListener("click", async () => {
|
||||
const refText = $("refText").value.trim();
|
||||
const text = $("text").value.trim();
|
||||
const fileBlob = $("file").files[0] || recordedBlob;
|
||||
if (!fileBlob) return setStatus("참조 음성을 업로드하거나 녹음하세요.", "err");
|
||||
if (!refText) return setStatus("참조 음성의 전사를 입력하세요.", "err");
|
||||
if (!text) return setStatus("합성할 텍스트를 입력하세요.", "err");
|
||||
|
||||
const fd = new FormData();
|
||||
fd.append("ref_audio", fileBlob, fileBlob.name || "ref.wav");
|
||||
fd.append("ref_text", refText);
|
||||
fd.append("text", text);
|
||||
fd.append("language", $("language").value);
|
||||
fd.append("instruct", $("instruct").value.trim());
|
||||
fd.append("seed", $("seed").value.trim());
|
||||
|
||||
$("go").disabled = true;
|
||||
setStatus('<span class="spin"></span>생성 중… (첫 실행은 모델 다운로드로 수 분 걸릴 수 있어요)', "");
|
||||
try {
|
||||
const r = await fetch("/api/clone", { method: "POST", body: fd });
|
||||
if (!r.ok) {
|
||||
let msg = r.statusText;
|
||||
try { const j = await r.json(); msg = j.detail || msg; } catch {}
|
||||
throw new Error(msg);
|
||||
}
|
||||
const blob = await r.blob();
|
||||
const url = URL.createObjectURL(blob);
|
||||
$("outPlayer").src = url; $("dl").href = url;
|
||||
$("resultCard").hidden = false;
|
||||
$("outPlayer").play().catch(() => {});
|
||||
setStatus("완료 ✔", "ok");
|
||||
} catch (e) {
|
||||
setStatus("오류: " + e.message, "err");
|
||||
} finally {
|
||||
$("go").disabled = false;
|
||||
}
|
||||
});
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
Reference in New Issue
Block a user