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:
10
.gitignore
vendored
Normal file
10
.gitignore
vendored
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
__pycache__/
|
||||||
|
*.pyc
|
||||||
|
.venv/
|
||||||
|
venv/
|
||||||
|
env/
|
||||||
|
tmp/
|
||||||
|
*.wav
|
||||||
|
!static/**
|
||||||
|
.DS_Store
|
||||||
|
.hf-cache/
|
||||||
77
README.md
77
README.md
@@ -1,2 +1,79 @@
|
|||||||
# voice_copy
|
# 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
156
app.py
Normal 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
151
engine.py
Normal 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
15
requirements.txt
Normal 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
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