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

10
.gitignore vendored Normal file
View File

@@ -0,0 +1,10 @@
__pycache__/
*.pyc
.venv/
venv/
env/
tmp/
*.wav
!static/**
.DS_Store
.hf-cache/

View File

@@ -1,2 +1,79 @@
# voice_copy
참조 음성 몇 초만 있으면 아무 텍스트나 **그 목소리로** 합성해 주는 zero-shot 음성 클로닝 웹앱입니다.
[jamiepine/voicebox](https://github.com/jamiepine/voicebox)의 음성 클로닝 기능(Qwen3-TTS Base 엔진)만
떼어내 간단한 UI로 감쌌습니다.
- 엔진: **Qwen3-TTS-12Hz Base** (`qwen-tts` 패키지, zero-shot voice clone)
- 지원 언어: 한국어 · English · 中文 · 日本語 · 독일어 · 프랑스어 · 러시아어 · 포르투갈어 · 스페인어 · 이탈리아어
- UI: 파일 업로드 또는 마이크 녹음 → 전사 입력 → 합성할 텍스트 입력 → 생성/재생/다운로드
## 필요 사항
- Python 3.9+ (3.10~3.12 권장)
- NVIDIA GPU 권장 (CPU도 되지만 매우 느림). VRAM 6GB+ 권장.
- (선택) `ffmpeg` — mp3/m4a/webm 업로드를 디코딩할 때 필요. wav/flac와 브라우저 녹음(WAV)은 ffmpeg 없이 동작합니다.
## 설치
```bash
# 1) 자신의 환경에 맞는 torch를 먼저 설치
pip install torch --index-url https://download.pytorch.org/whl/cu128 # NVIDIA(CUDA 12.8)
# CPU만 있으면: pip install torch
# 2) 나머지 의존성
pip install -r requirements.txt
```
## 실행
```bash
python app.py
# 브라우저에서 http://localhost:8000 접속
```
첫 생성 요청 때 Qwen3-TTS 가중치(수 GB)가 HuggingFace에서 자동으로 다운로드됩니다. 이후에는 캐시됩니다.
### 환경 변수
| 변수 | 기본값 | 설명 |
|------|--------|------|
| `VOICE_COPY_MODEL_SIZE` | `1.7B` | 모델 크기. `1.7B`(품질↑) 또는 `0.6B`(가벼움) |
| `VOICE_COPY_DEVICE` | 자동 | `cuda` / `cpu` / `mps` 강제 지정 |
| `VOICE_COPY_HOST` | `0.0.0.0` | 바인드 호스트 |
| `VOICE_COPY_PORT` | `8000` | 포트 |
예: `VOICE_COPY_MODEL_SIZE=0.6B python app.py`
## 사용법
1. **참조 음성** — 클로닝할 목소리를 5~15초 정도 업로드하거나 마이크로 녹음합니다.
2. **전사** — 그 참조 음성에서 말한 내용을 그대로 텍스트로 적습니다. (클로닝 품질에 중요)
3. **합성할 텍스트** — 그 목소리로 말하게 할 문장을 입력하고 "목소리 생성"을 누릅니다.
## API
- `GET /api/health` — 디바이스/모델/언어 상태
- `POST /api/clone` — multipart form: `ref_audio`(파일), `ref_text`, `text`, `language`, `instruct`(선택), `seed`(선택) → `audio/wav` 반환
```bash
curl -X POST http://localhost:8000/api/clone \
-F ref_audio=@sample.wav -F ref_text="안녕하세요" \
-F text="복제된 목소리로 말합니다." -F language=ko \
--output cloned.wav
```
## 구조
```
app.py FastAPI 서버 (UI 제공 + /api/clone)
engine.py Qwen3-TTS 클로닝 래퍼 (voicebox pytorch_backend에서 추출/단순화)
static/index.html 단일 페이지 UI
requirements.txt
```
## 라이선스 / 출처
음성 클로닝 로직은 [jamiepine/voicebox](https://github.com/jamiepine/voicebox)에서 파생했습니다.
모델 가중치 및 사용은 각 모델(Qwen3-TTS)과 voicebox의 라이선스를 따르며, 목소리 클로닝은
반드시 본인 또는 동의를 받은 사람의 음성에만 사용하세요.

156
app.py Normal file
View File

@@ -0,0 +1,156 @@
"""
voice_copy — simple web UI for zero-shot voice cloning.
Run:
python app.py # then open http://localhost:8000
# or: uvicorn app:app --host 0.0.0.0 --port 8000
Env:
VOICE_COPY_MODEL_SIZE "1.7B" (default) or "0.6B"
VOICE_COPY_DEVICE force "cuda" / "cpu" / "mps" (default: auto)
VOICE_COPY_HOST bind host (default 0.0.0.0)
VOICE_COPY_PORT bind port (default 8000)
"""
from __future__ import annotations
import io
import logging
import os
import tempfile
from pathlib import Path
import numpy as np
import soundfile as sf
from fastapi import FastAPI, File, Form, HTTPException, UploadFile
from fastapi.concurrency import run_in_threadpool
from fastapi.responses import HTMLResponse, Response
from engine import HF_MODEL_MAP, LANGUAGE_CODE_TO_NAME, VoiceCloner
logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(name)s: %(message)s")
logger = logging.getLogger("voice_copy.app")
BASE = Path(__file__).resolve().parent
STATIC = BASE / "static"
TMP = BASE / "tmp"
TMP.mkdir(exist_ok=True)
MODEL_SIZE = os.environ.get("VOICE_COPY_MODEL_SIZE", "1.7B")
DEVICE = os.environ.get("VOICE_COPY_DEVICE") or None
cloner = VoiceCloner(model_size=MODEL_SIZE, device=DEVICE)
app = FastAPI(title="voice_copy", description="Zero-shot voice cloning UI")
def _decode_to_wav(raw_bytes: bytes, filename: str) -> str:
"""Decode uploaded audio (wav/flac/mp3/m4a/ogg/webm) into a mono wav on disk.
Tries soundfile first (wav/flac/ogg — no ffmpeg needed); falls back to
librosa/audioread (needs ffmpeg for mp3/m4a/webm from the mic recorder).
"""
suffix = Path(filename or "ref").suffix or ".bin"
src = tempfile.NamedTemporaryFile(dir=TMP, suffix=suffix, delete=False)
src.write(raw_bytes)
src.close()
try:
try:
audio, sr = sf.read(src.name, dtype="float32", always_2d=False)
if audio.ndim > 1: # stereo -> mono
audio = audio.mean(axis=1)
except Exception:
import librosa
audio, sr = librosa.load(src.name, sr=None, mono=True)
finally:
try:
os.unlink(src.name)
except OSError:
pass
wav = tempfile.NamedTemporaryFile(dir=TMP, suffix=".wav", delete=False)
wav.close()
sf.write(wav.name, np.asarray(audio, dtype=np.float32), int(sr), format="WAV")
return wav.name
@app.get("/", response_class=HTMLResponse)
def index() -> str:
return (STATIC / "index.html").read_text(encoding="utf-8")
@app.get("/api/health")
def health() -> dict:
return {
"device": cloner.device,
"model_size": cloner.model_size,
"model_repo": HF_MODEL_MAP[cloner.model_size],
"model_loaded": cloner.loaded,
"languages": list(LANGUAGE_CODE_TO_NAME.keys()),
}
@app.post("/api/clone")
async def clone(
ref_audio: UploadFile = File(...),
ref_text: str = Form(...),
text: str = Form(...),
language: str = Form("auto"),
instruct: str = Form(""),
seed: str = Form(""),
) -> Response:
if not text.strip():
raise HTTPException(400, "합성할 텍스트(text)를 입력하세요.")
if not ref_text.strip():
raise HTTPException(400, "참조 오디오의 전사(ref_text)를 입력하세요.")
raw = await ref_audio.read()
if not raw:
raise HTTPException(400, "참조 오디오 파일이 비어 있습니다.")
seed_int = None
if seed.strip():
try:
seed_int = int(seed)
except ValueError:
raise HTTPException(400, "seed는 정수여야 합니다.")
try:
wav_path = await run_in_threadpool(_decode_to_wav, raw, ref_audio.filename or "ref.wav")
except Exception as exc: # noqa: BLE001
logger.exception("audio decode failed")
raise HTTPException(
400,
f"오디오 디코딩 실패: {exc}. wav/flac는 그대로 되고, mp3/m4a/webm은 ffmpeg 설치가 필요합니다.",
)
try:
audio, sr = await run_in_threadpool(
cloner.clone, wav_path, ref_text, text, language, instruct or None, seed_int
)
except Exception as exc: # noqa: BLE001
logger.exception("clone failed")
raise HTTPException(500, f"생성 실패: {exc}")
finally:
try:
os.unlink(wav_path)
except OSError:
pass
buf = io.BytesIO()
sf.write(buf, audio, sr, format="WAV")
buf.seek(0)
return Response(
content=buf.read(),
media_type="audio/wav",
headers={"Content-Disposition": 'inline; filename="cloned.wav"'},
)
if __name__ == "__main__":
import uvicorn
host = os.environ.get("VOICE_COPY_HOST", "0.0.0.0")
port = int(os.environ.get("VOICE_COPY_PORT", "8000"))
uvicorn.run(app, host=host, port=port)

151
engine.py Normal file
View File

@@ -0,0 +1,151 @@
"""
Zero-shot voice cloning engine.
Extracted and simplified from jamiepine/voicebox's Qwen3-TTS "Base" backend
(backend/backends/pytorch_backend.py). Only the voice-cloning path is kept:
give a short reference clip + its transcript, then synthesize any text in
that cloned voice.
Model: Qwen/Qwen3-TTS-12Hz-{1.7B,0.6B}-Base via the `qwen-tts` package.
Weights download automatically from HuggingFace on first use.
"""
from __future__ import annotations
import logging
import threading
from typing import Optional, Tuple
import numpy as np
logger = logging.getLogger("voice_copy.engine")
# Qwen3-TTS speaks these languages. "auto" lets the model detect it.
LANGUAGE_CODE_TO_NAME = {
"auto": "auto",
"ko": "korean",
"en": "english",
"zh": "chinese",
"ja": "japanese",
"de": "german",
"fr": "french",
"ru": "russian",
"pt": "portuguese",
"es": "spanish",
"it": "italian",
}
HF_MODEL_MAP = {
"1.7B": "Qwen/Qwen3-TTS-12Hz-1.7B-Base",
"0.6B": "Qwen/Qwen3-TTS-12Hz-0.6B-Base",
}
def pick_device() -> str:
"""Best available torch device: CUDA GPU if present, else CPU."""
try:
import torch
except Exception:
return "cpu"
if torch.cuda.is_available():
return "cuda"
# Apple Silicon
if getattr(torch.backends, "mps", None) is not None and torch.backends.mps.is_available():
return "mps"
return "cpu"
class VoiceCloner:
"""Lazily-loaded Qwen3-TTS voice-clone wrapper.
The heavy model is only loaded on the first `clone()` call, so the web
server can start instantly and the (multi-GB) download happens on demand.
"""
def __init__(self, model_size: str = "1.7B", device: Optional[str] = None):
if model_size not in HF_MODEL_MAP:
raise ValueError(f"Unknown model size {model_size!r}; choose one of {list(HF_MODEL_MAP)}")
self.model = None
self.model_size = model_size
self.device = device or pick_device()
self._lock = threading.Lock()
@property
def loaded(self) -> bool:
return self.model is not None
def load(self) -> None:
"""Load the model (thread-safe, idempotent)."""
if self.model is not None:
return
with self._lock:
if self.model is not None:
return
import torch
from qwen_tts import Qwen3TTSModel
model_path = HF_MODEL_MAP[self.model_size]
logger.info("Loading %s on %s (first run downloads weights)...", model_path, self.device)
if self.device == "cpu":
self.model = Qwen3TTSModel.from_pretrained(
model_path,
torch_dtype=torch.float32,
low_cpu_mem_usage=False,
)
else:
self.model = Qwen3TTSModel.from_pretrained(
model_path,
device_map=self.device,
torch_dtype=torch.bfloat16,
)
logger.info("Model loaded.")
def clone(
self,
ref_audio_path: str,
ref_text: str,
text: str,
language: str = "auto",
instruct: Optional[str] = None,
seed: Optional[int] = None,
) -> Tuple[np.ndarray, int]:
"""Synthesize `text` in the voice of `ref_audio_path`.
Args:
ref_audio_path: Path to a short reference clip (a few seconds is enough).
ref_text: Exact transcript of the reference clip.
text: The text to speak in the cloned voice.
language: Language code from LANGUAGE_CODE_TO_NAME ("auto" to detect).
instruct: Optional natural-language style hint (e.g. "speak cheerfully").
seed: Optional random seed for reproducible output.
Returns:
(audio_float32_mono, sample_rate)
"""
self.load()
import torch
if seed is not None:
torch.manual_seed(seed)
if self.device == "cuda":
torch.cuda.manual_seed_all(seed)
# Build the speaker prompt from the reference clip + its transcript.
voice_prompt = self.model.create_voice_clone_prompt(
ref_audio=str(ref_audio_path),
ref_text=ref_text,
x_vector_only_mode=False,
)
wavs, sample_rate = self.model.generate_voice_clone(
text=text,
voice_clone_prompt=voice_prompt,
language=LANGUAGE_CODE_TO_NAME.get(language, "auto"),
instruct=instruct or None,
)
audio = wavs[0]
if isinstance(audio, torch.Tensor):
audio = audio.squeeze().cpu().numpy()
return np.asarray(audio, dtype=np.float32), int(sample_rate)

15
requirements.txt Normal file
View File

@@ -0,0 +1,15 @@
# voice_copy — zero-shot voice cloning UI (Qwen3-TTS)
#
# NOTE: install a torch build that matches your machine FIRST, e.g.
# CPU: pip install torch
# NVIDIA (CUDA): pip install torch --index-url https://download.pytorch.org/whl/cu128
# then: pip install -r requirements.txt
qwen-tts>=0.1.1 # Qwen3-TTS model + generate_voice_clone (pulls transformers/accelerate/torchaudio/soundfile/librosa)
fastapi>=0.109.0
uvicorn[standard]>=0.27.0
python-multipart>=0.0.6 # multipart form uploads
soundfile>=0.12.0
librosa>=0.10.0
numpy>=1.24
torch>=2.2.0 # keep the torch you installed above; listed so a bare install still works

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>