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.
157 lines
4.8 KiB
Python
157 lines
4.8 KiB
Python
"""
|
|
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)
|