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:
voice_copy bot
2026-07-05 17:44:24 +09:00
parent 2dd55b643c
commit 1b691b071b
6 changed files with 617 additions and 0 deletions

208
static/index.html Normal file
View 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>