Initial commit: Korean voice-cloning TTS prototype
FastAPI backend, web UI, CosyVoice3/F5-TTS setup scripts, and handoff docs for GPU PC continuation. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
9
.env.example
Normal file
9
.env.example
Normal file
@@ -0,0 +1,9 @@
|
||||
# 기본 TTS 엔진: cosyvoice | f5_tts
|
||||
TTS_MODEL=cosyvoice
|
||||
|
||||
# 기본 reference (선택)
|
||||
# TTS_REF_AUDIO=samples/my_voice_30s.wav
|
||||
# TTS_REF_TEXT=참조 음성 대본...
|
||||
|
||||
TTS_HOST=0.0.0.0
|
||||
TTS_PORT=8000
|
||||
16
.gitignore
vendored
Normal file
16
.gitignore
vendored
Normal file
@@ -0,0 +1,16 @@
|
||||
.venvs/
|
||||
venv/
|
||||
__pycache__/
|
||||
*.py[cod]
|
||||
.env
|
||||
*.wav
|
||||
!samples/.gitkeep
|
||||
outputs/
|
||||
models/
|
||||
external/CosyVoice/
|
||||
*.egg-info/
|
||||
.DS_Store
|
||||
.pytest_cache/
|
||||
.mypy_cache/
|
||||
backend/data/uploads/*
|
||||
!backend/data/uploads/.gitkeep
|
||||
81
README.md
Normal file
81
README.md
Normal file
@@ -0,0 +1,81 @@
|
||||
# 한국어 보이스 클로닝 TTS
|
||||
|
||||
한국어 자연스러움과 **내 목소리 유사도**를 우선하는 로컬 TTS 프로토타입입니다.
|
||||
기본 엔진은 **CosyVoice3**, 비교·대안으로 **F5-TTS**를 지원합니다.
|
||||
|
||||
## 빠른 시작 (NVIDIA GPU Linux)
|
||||
|
||||
```bash
|
||||
cd /path/to/tts
|
||||
|
||||
# 1) 환경 점검
|
||||
chmod +x scripts/*.sh
|
||||
./scripts/check_env.sh
|
||||
|
||||
# 2) API + 모델 venv (모델별 격리)
|
||||
./scripts/setup_api.sh
|
||||
./scripts/setup_f5tts.sh # 약 5~15분, GPU/CUDA 필요
|
||||
./scripts/setup_cosyvoice.sh # 레포 클론 + 모델 다운로드, 시간 소요
|
||||
|
||||
# 3) A/B 비교 (동일 텍스트·reference)
|
||||
./scripts/run_ab_compare.py --ref-audio auto
|
||||
# 본인 목소리:
|
||||
# samples/my_voice_30s.wav 녹음 + my_voice_ref.txt 작성 후
|
||||
./scripts/run_ab_compare.py --ref-audio samples/my_voice_30s.wav
|
||||
|
||||
# 4) API + 웹 UI
|
||||
cp .env.example .env # 필요 시 TTS_MODEL 수정
|
||||
./scripts/run_server.sh
|
||||
# 브라우저: http://localhost:8000
|
||||
```
|
||||
|
||||
## 디렉터리
|
||||
|
||||
| 경로 | 설명 |
|
||||
|------|------|
|
||||
| `config/` | 설정, 테스트 문장, 모델 선택 |
|
||||
| `samples/` | reference WAV + 대본 |
|
||||
| `outputs/` | A/B 비교 및 API 생성 결과 |
|
||||
| `models/` | CosyVoice3 체크포인트 |
|
||||
| `backend/` | FastAPI |
|
||||
| `web/` | 간단한 웹 UI |
|
||||
| `.venvs/f5tts`, `.venvs/cosyvoice`, `.venvs/api` | 격리 Python 환경 |
|
||||
|
||||
## 모델 선택
|
||||
|
||||
`config/model_choice.json` — 품질 우선 시 **cosyvoice** 권장.
|
||||
F5-TTS가 더 나으면 `.env`에서 `TTS_MODEL=f5_tts`로 변경.
|
||||
|
||||
## API
|
||||
|
||||
| Method | Path | 설명 |
|
||||
|--------|------|------|
|
||||
| GET | `/api/health` | 상태 |
|
||||
| POST | `/api/tts` | 텍스트 → WAV |
|
||||
| GET | `/api/audio/{job_id}` | 결과 재생 |
|
||||
| GET | `/api/voice-samples` | 샘플 목록 |
|
||||
| POST | `/api/voice-sample` | WAV 업로드 |
|
||||
|
||||
### 예시
|
||||
|
||||
```bash
|
||||
curl -X POST http://localhost:8000/api/tts \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"text":"안녕하세요. 테스트입니다."}'
|
||||
```
|
||||
|
||||
## 내 목소리 녹음
|
||||
|
||||
[samples/README.md](samples/README.md) 참고.
|
||||
30초 / 1분 / 3분 샘플을 각각 녹음해 A/B 비교하는 것을 권장합니다.
|
||||
|
||||
## 문제 해결
|
||||
|
||||
- `nvidia-smi` 없음 → NVIDIA 드라이버/CUDA 설치 후 재시도
|
||||
- CosyVoice import/sox 오류 → `sudo apt install sox libsox-dev`
|
||||
- F5-TTS `ref_text` 필수 → `samples/my_voice_ref.txt` 작성
|
||||
- API 503 → 해당 모델 venv setup 스크립트 재실행
|
||||
|
||||
## 라이선스
|
||||
|
||||
각 모델(F5-TTS, CosyVoice)의 원저작권·라이선스를 따릅니다.
|
||||
1
backend/app/__init__.py
Normal file
1
backend/app/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
"""Korean voice-cloning TTS API."""
|
||||
65
backend/app/config.py
Normal file
65
backend/app/config.py
Normal file
@@ -0,0 +1,65 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from functools import lru_cache
|
||||
from pathlib import Path
|
||||
|
||||
import yaml
|
||||
from pydantic import Field
|
||||
from pydantic_settings import BaseSettings, SettingsConfigDict
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[2]
|
||||
|
||||
|
||||
class AppSettings(BaseSettings):
|
||||
model_config = SettingsConfigDict(
|
||||
env_file=str(ROOT / ".env"),
|
||||
env_file_encoding="utf-8",
|
||||
extra="ignore",
|
||||
)
|
||||
|
||||
tts_model: str = Field(default="cosyvoice", validation_alias="TTS_MODEL")
|
||||
host: str = Field(default="0.0.0.0", validation_alias="TTS_HOST")
|
||||
port: int = Field(default=8000, validation_alias="TTS_PORT")
|
||||
samples_dir: Path = Field(default=ROOT / "samples")
|
||||
outputs_dir: Path = Field(default=ROOT / "outputs" / "api")
|
||||
uploads_dir: Path = Field(default=ROOT / "backend" / "data" / "uploads")
|
||||
default_ref_audio: str | None = Field(default=None, validation_alias="TTS_REF_AUDIO")
|
||||
default_ref_text: str | None = Field(default=None, validation_alias="TTS_REF_TEXT")
|
||||
cosyvoice_model_dir: Path = Field(default=ROOT / "models" / "Fun-CosyVoice3-0.5B")
|
||||
cosyvoice_prompt_prefix: str = (
|
||||
"You are a helpful assistant.<|endofprompt|>"
|
||||
)
|
||||
chunk_max_chars: int = 120
|
||||
|
||||
|
||||
@lru_cache
|
||||
def get_settings() -> AppSettings:
|
||||
yaml_path = ROOT / "config" / "settings.yaml"
|
||||
data: dict = {}
|
||||
if yaml_path.is_file():
|
||||
with open(yaml_path, encoding="utf-8") as f:
|
||||
raw = yaml.safe_load(f) or {}
|
||||
data["tts_model"] = raw.get("default_model", "cosyvoice")
|
||||
gen = raw.get("generation") or {}
|
||||
data["chunk_max_chars"] = gen.get("chunk_max_chars", 120)
|
||||
cv = raw.get("cosyvoice") or {}
|
||||
if cv.get("model_dir"):
|
||||
data["cosyvoice_model_dir"] = ROOT / cv["model_dir"]
|
||||
if cv.get("prompt_prefix"):
|
||||
data["cosyvoice_prompt_prefix"] = cv["prompt_prefix"]
|
||||
srv = raw.get("server") or {}
|
||||
data["host"] = srv.get("host", "0.0.0.0")
|
||||
data["port"] = srv.get("port", 8000)
|
||||
paths = raw.get("paths") or {}
|
||||
if paths.get("samples_dir"):
|
||||
data["samples_dir"] = ROOT / paths["samples_dir"]
|
||||
if paths.get("outputs_dir"):
|
||||
data["outputs_dir"] = ROOT / paths["outputs_dir"] / "api"
|
||||
if paths.get("uploads_dir"):
|
||||
data["uploads_dir"] = ROOT / paths["uploads_dir"]
|
||||
|
||||
return AppSettings(**{k: v for k, v in data.items() if v is not None})
|
||||
|
||||
|
||||
def project_root() -> Path:
|
||||
return ROOT
|
||||
170
backend/app/main.py
Normal file
170
backend/app/main.py
Normal file
@@ -0,0 +1,170 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import shutil
|
||||
import uuid
|
||||
from pathlib import Path
|
||||
|
||||
from fastapi import FastAPI, File, Form, HTTPException, UploadFile
|
||||
from fastapi.middleware.cors import CORSMiddleware
|
||||
from fastapi.responses import FileResponse
|
||||
from fastapi.staticfiles import StaticFiles
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
from backend.app.config import get_settings, project_root
|
||||
from backend.app.text_preprocess import preprocess_korean
|
||||
from backend.app.tts.service import TTSService
|
||||
|
||||
ROOT = project_root()
|
||||
WEB_DIR = ROOT / "web"
|
||||
|
||||
app = FastAPI(
|
||||
title="Korean Voice Cloning TTS",
|
||||
description="CosyVoice / F5-TTS 기반 한국어 보이스 클로닝 API",
|
||||
version="0.1.0",
|
||||
)
|
||||
app.add_middleware(
|
||||
CORSMiddleware,
|
||||
allow_origins=["*"],
|
||||
allow_methods=["*"],
|
||||
allow_headers=["*"],
|
||||
)
|
||||
|
||||
_tts: TTSService | None = None
|
||||
|
||||
|
||||
def get_tts() -> TTSService:
|
||||
global _tts
|
||||
if _tts is None:
|
||||
_tts = TTSService()
|
||||
return _tts
|
||||
|
||||
|
||||
class TTSRequest(BaseModel):
|
||||
text: str = Field(..., min_length=1, max_length=5000)
|
||||
ref_audio: str | None = Field(
|
||||
default=None, description="samples/ 또는 uploads/ 기준 상대/절대 경로"
|
||||
)
|
||||
ref_text: str | None = None
|
||||
preprocess: bool = True
|
||||
|
||||
|
||||
class TTSResponse(BaseModel):
|
||||
job_id: str
|
||||
audio_url: str
|
||||
model: str
|
||||
text_preview: str
|
||||
|
||||
|
||||
class HealthResponse(BaseModel):
|
||||
status: str
|
||||
model: str
|
||||
samples_count: int
|
||||
|
||||
|
||||
@app.get("/api/health", response_model=HealthResponse)
|
||||
def health() -> HealthResponse:
|
||||
s = get_settings()
|
||||
samples = list(s.samples_dir.glob("*.wav"))
|
||||
return HealthResponse(
|
||||
status="ok",
|
||||
model=s.tts_model,
|
||||
samples_count=len(samples),
|
||||
)
|
||||
|
||||
|
||||
@app.post("/api/tts", response_model=TTSResponse)
|
||||
def create_tts(body: TTSRequest) -> TTSResponse:
|
||||
text = preprocess_korean(body.text) if body.preprocess else body.text.strip()
|
||||
if not text:
|
||||
raise HTTPException(400, "text is empty")
|
||||
|
||||
ref_path: Path | None = None
|
||||
if body.ref_audio:
|
||||
p = Path(body.ref_audio)
|
||||
if not p.is_absolute():
|
||||
for base in (get_settings().samples_dir, get_settings().uploads_dir):
|
||||
candidate = base / p
|
||||
if candidate.is_file():
|
||||
p = candidate
|
||||
break
|
||||
if not p.is_file():
|
||||
raise HTTPException(404, f"ref_audio not found: {body.ref_audio}")
|
||||
ref_path = p
|
||||
|
||||
try:
|
||||
job_id, _ = get_tts().synthesize_to_file(
|
||||
text, ref_audio=ref_path, ref_text=body.ref_text
|
||||
)
|
||||
except FileNotFoundError as e:
|
||||
raise HTTPException(404, str(e)) from e
|
||||
except RuntimeError as e:
|
||||
raise HTTPException(503, str(e)) from e
|
||||
|
||||
return TTSResponse(
|
||||
job_id=job_id,
|
||||
audio_url=f"/api/audio/{job_id}",
|
||||
model=get_settings().tts_model,
|
||||
text_preview=text[:80] + ("…" if len(text) > 80 else ""),
|
||||
)
|
||||
|
||||
|
||||
@app.get("/api/audio/{job_id}")
|
||||
def get_audio(job_id: str) -> FileResponse:
|
||||
path = get_settings().outputs_dir / job_id / "output.wav"
|
||||
if not path.is_file():
|
||||
alt = get_settings().outputs_dir / job_id / "part_000.wav"
|
||||
path = alt if alt.is_file() else path
|
||||
if not path.is_file():
|
||||
raise HTTPException(404, "audio not found")
|
||||
return FileResponse(path, media_type="audio/wav", filename=f"{job_id}.wav")
|
||||
|
||||
|
||||
@app.get("/api/voice-samples")
|
||||
def list_voice_samples() -> dict:
|
||||
s = get_settings()
|
||||
samples = []
|
||||
for d, label in ((s.samples_dir, "samples"), (s.uploads_dir, "uploads")):
|
||||
for wav in sorted(d.glob("*.wav")):
|
||||
txt = wav.with_suffix(".txt")
|
||||
samples.append(
|
||||
{
|
||||
"id": wav.stem,
|
||||
"path": str(wav),
|
||||
"label": label,
|
||||
"has_transcript": txt.is_file(),
|
||||
}
|
||||
)
|
||||
return {"samples": samples, "default_model": s.tts_model}
|
||||
|
||||
|
||||
@app.post("/api/voice-sample")
|
||||
async def upload_voice_sample(
|
||||
file: UploadFile = File(...),
|
||||
ref_text: str = Form(""),
|
||||
) -> dict:
|
||||
if not file.filename or not file.filename.lower().endswith(".wav"):
|
||||
raise HTTPException(400, "WAV 파일만 업로드 가능합니다")
|
||||
|
||||
sample_id = uuid.uuid4().hex[:10]
|
||||
dest = get_settings().uploads_dir / f"{sample_id}.wav"
|
||||
with open(dest, "wb") as f:
|
||||
shutil.copyfileobj(file.file, f)
|
||||
|
||||
if ref_text.strip():
|
||||
(dest.with_suffix(".txt")).write_text(ref_text.strip(), encoding="utf-8")
|
||||
|
||||
return {
|
||||
"id": sample_id,
|
||||
"path": str(dest),
|
||||
"message": "업로드 완료. TTS 요청 시 ref_audio에 이 path를 사용하세요.",
|
||||
}
|
||||
|
||||
|
||||
if WEB_DIR.is_dir():
|
||||
app.mount("/", StaticFiles(directory=str(WEB_DIR), html=True), name="web")
|
||||
|
||||
|
||||
@app.on_event("startup")
|
||||
def startup() -> None:
|
||||
get_settings().outputs_dir.mkdir(parents=True, exist_ok=True)
|
||||
get_settings().uploads_dir.mkdir(parents=True, exist_ok=True)
|
||||
95
backend/app/text_preprocess.py
Normal file
95
backend/app/text_preprocess.py
Normal file
@@ -0,0 +1,95 @@
|
||||
"""한국어 TTS용 간단한 텍스트 정규화."""
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
|
||||
|
||||
_RE_MULTI_SPACE = re.compile(r"\s+")
|
||||
_RE_EMAIL = re.compile(r"[\w.+-]+@[\w.-]+\.\w+")
|
||||
_RE_URL = re.compile(r"https?://\S+")
|
||||
|
||||
|
||||
def _digits_to_korean(num_str: str) -> str:
|
||||
"""정수 문자열을 한글 읽기로 변환 (간단 버전)."""
|
||||
if not num_str.isdigit():
|
||||
return num_str
|
||||
n = int(num_str.replace(",", ""))
|
||||
if n == 0:
|
||||
return "영"
|
||||
units = ["", "만", "억", "조"]
|
||||
small = ["", "일", "이", "삼", "사", "오", "육", "칠", "팔", "구"]
|
||||
ten = ["", "십", "백", "천"]
|
||||
|
||||
def chunk_to_korean(x: int) -> str:
|
||||
if x == 0:
|
||||
return ""
|
||||
parts: list[str] = []
|
||||
s = f"{x:04d}"
|
||||
for i, d in enumerate(s):
|
||||
di = int(d)
|
||||
if di == 0:
|
||||
continue
|
||||
if i == 0 and di == 1 and len(s) > 1:
|
||||
parts.append(ten[3 - i])
|
||||
elif di == 1 and i > 0:
|
||||
parts.append(ten[3 - i])
|
||||
else:
|
||||
parts.append(small[di] + ten[3 - i])
|
||||
return "".join(parts)
|
||||
|
||||
if n < 10000:
|
||||
return chunk_to_korean(n)
|
||||
|
||||
result: list[str] = []
|
||||
u = 0
|
||||
while n > 0 and u < len(units):
|
||||
part = n % 10000
|
||||
n //= 10000
|
||||
if part:
|
||||
result.append(chunk_to_korean(part) + units[u])
|
||||
u += 1
|
||||
return "".join(reversed(result)) or num_str
|
||||
|
||||
|
||||
def _replace_numbers(text: str) -> str:
|
||||
def repl(m: re.Match[str]) -> str:
|
||||
raw = m.group(0).replace(",", "")
|
||||
return _digits_to_korean(raw)
|
||||
|
||||
return re.sub(r"\d[\d,]*", repl, text)
|
||||
|
||||
|
||||
def preprocess_korean(text: str) -> str:
|
||||
t = text.strip()
|
||||
t = _RE_URL.sub(" 링크 ", t)
|
||||
t = _RE_EMAIL.sub(" 이메일 ", t)
|
||||
t = t.replace("&", " 앤드 ")
|
||||
t = t.replace("%", " 퍼센트 ")
|
||||
t = _replace_numbers(t)
|
||||
t = _RE_MULTI_SPACE.sub(" ", t)
|
||||
return t.strip()
|
||||
|
||||
|
||||
def split_sentences(text: str, max_chars: int = 120) -> list[str]:
|
||||
"""긴 텍스트를 문장 단위로 분리."""
|
||||
parts = re.split(r"(?<=[.!?…])\s+|\n+", preprocess_korean(text))
|
||||
chunks: list[str] = []
|
||||
buf = ""
|
||||
for p in parts:
|
||||
p = p.strip()
|
||||
if not p:
|
||||
continue
|
||||
if len(buf) + len(p) + 1 <= max_chars:
|
||||
buf = f"{buf} {p}".strip() if buf else p
|
||||
else:
|
||||
if buf:
|
||||
chunks.append(buf)
|
||||
if len(p) <= max_chars:
|
||||
buf = p
|
||||
else:
|
||||
for i in range(0, len(p), max_chars):
|
||||
chunks.append(p[i : i + max_chars])
|
||||
buf = ""
|
||||
if buf:
|
||||
chunks.append(buf)
|
||||
return chunks or [text]
|
||||
3
backend/app/tts/__init__.py
Normal file
3
backend/app/tts/__init__.py
Normal file
@@ -0,0 +1,3 @@
|
||||
from backend.app.tts.service import TTSService
|
||||
|
||||
__all__ = ["TTSService"]
|
||||
18
backend/app/tts/base.py
Normal file
18
backend/app/tts/base.py
Normal file
@@ -0,0 +1,18 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from abc import ABC, abstractmethod
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
class TTSEngine(ABC):
|
||||
name: str
|
||||
|
||||
@abstractmethod
|
||||
def synthesize(
|
||||
self,
|
||||
text: str,
|
||||
ref_audio: Path,
|
||||
ref_text: str,
|
||||
out_path: Path,
|
||||
) -> Path:
|
||||
"""단일 텍스트 청크를 WAV로 생성."""
|
||||
101
backend/app/tts/engines_subprocess.py
Normal file
101
backend/app/tts/engines_subprocess.py
Normal file
@@ -0,0 +1,101 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import subprocess
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
from backend.app.config import project_root
|
||||
from backend.app.tts.base import TTSEngine
|
||||
|
||||
ROOT = project_root()
|
||||
|
||||
|
||||
class SubprocessEngine(TTSEngine):
|
||||
def __init__(self, venv_name: str, worker_name: str) -> None:
|
||||
self._python = ROOT / ".venvs" / venv_name / "bin" / "python"
|
||||
self._worker = ROOT / "scripts" / "workers" / worker_name
|
||||
|
||||
def _run(self, args: list[str]) -> None:
|
||||
if not self._python.is_file():
|
||||
raise RuntimeError(
|
||||
f"{self._python.parent.parent.name} venv 없음. "
|
||||
f"scripts/setup_{self._python.parent.parent.name}.sh 실행"
|
||||
)
|
||||
cmd = [str(self._python), str(self._worker), *args]
|
||||
proc = subprocess.run(cmd, cwd=str(ROOT), capture_output=True, text=True)
|
||||
if proc.returncode != 0:
|
||||
raise RuntimeError(
|
||||
f"{self.name} inference failed:\n{proc.stderr or proc.stdout}"
|
||||
)
|
||||
|
||||
|
||||
class F5TTSEngine(SubprocessEngine):
|
||||
name = "f5_tts"
|
||||
|
||||
def __init__(self) -> None:
|
||||
super().__init__("f5tts", "f5_infer.py")
|
||||
|
||||
def synthesize(
|
||||
self,
|
||||
text: str,
|
||||
ref_audio: Path,
|
||||
ref_text: str,
|
||||
out_path: Path,
|
||||
) -> Path:
|
||||
out_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
self._run(
|
||||
[
|
||||
"--ref-audio",
|
||||
str(ref_audio),
|
||||
"--ref-text",
|
||||
ref_text or "reference audio transcript",
|
||||
"--gen-text",
|
||||
text,
|
||||
"--out",
|
||||
str(out_path),
|
||||
]
|
||||
)
|
||||
return out_path
|
||||
|
||||
|
||||
class CosyVoiceEngine(SubprocessEngine):
|
||||
name = "cosyvoice"
|
||||
|
||||
def __init__(self, model_dir: Path, prompt_prefix: str) -> None:
|
||||
super().__init__("cosyvoice", "cosy_infer.py")
|
||||
self._model_dir = model_dir
|
||||
self._prompt_prefix = prompt_prefix
|
||||
|
||||
def synthesize(
|
||||
self,
|
||||
text: str,
|
||||
ref_audio: Path,
|
||||
ref_text: str,
|
||||
out_path: Path,
|
||||
) -> Path:
|
||||
out_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
self._run(
|
||||
[
|
||||
"--ref-audio",
|
||||
str(ref_audio),
|
||||
"--gen-text",
|
||||
text,
|
||||
"--prompt-text",
|
||||
ref_text or "",
|
||||
"--out",
|
||||
str(out_path),
|
||||
"--model-dir",
|
||||
str(self._model_dir),
|
||||
"--prompt-prefix",
|
||||
self._prompt_prefix,
|
||||
]
|
||||
)
|
||||
return out_path
|
||||
|
||||
|
||||
def create_engine(model: str, model_dir: Path, prompt_prefix: str) -> TTSEngine:
|
||||
if model == "f5_tts":
|
||||
return F5TTSEngine()
|
||||
if model == "cosyvoice":
|
||||
return CosyVoiceEngine(model_dir, prompt_prefix)
|
||||
raise ValueError(f"Unknown model: {model}. Use cosyvoice or f5_tts.")
|
||||
97
backend/app/tts/service.py
Normal file
97
backend/app/tts/service.py
Normal file
@@ -0,0 +1,97 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import uuid
|
||||
import wave
|
||||
from pathlib import Path
|
||||
|
||||
from backend.app.config import AppSettings, get_settings, project_root
|
||||
from backend.app.text_preprocess import split_sentences
|
||||
from backend.app.tts.engines_subprocess import create_engine
|
||||
|
||||
ROOT = project_root()
|
||||
|
||||
|
||||
class TTSService:
|
||||
def __init__(self, settings: AppSettings | None = None) -> None:
|
||||
self.settings = settings or get_settings()
|
||||
self.engine = create_engine(
|
||||
self.settings.tts_model,
|
||||
self.settings.cosyvoice_model_dir,
|
||||
self.settings.cosyvoice_prompt_prefix,
|
||||
)
|
||||
self.settings.outputs_dir.mkdir(parents=True, exist_ok=True)
|
||||
self.settings.uploads_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
def resolve_reference(
|
||||
self,
|
||||
ref_audio: Path | None = None,
|
||||
ref_text: str | None = None,
|
||||
) -> tuple[Path, str]:
|
||||
if ref_audio and ref_audio.is_file():
|
||||
audio = ref_audio
|
||||
elif self.settings.default_ref_audio:
|
||||
audio = Path(self.settings.default_ref_audio)
|
||||
else:
|
||||
samples = sorted(self.settings.samples_dir.glob("*.wav"))
|
||||
if not samples:
|
||||
raise FileNotFoundError(
|
||||
"reference WAV 없음. samples/에 녹음하거나 TTS_REF_AUDIO 설정"
|
||||
)
|
||||
audio = samples[0]
|
||||
|
||||
text = ref_text or self.settings.default_ref_text or ""
|
||||
if not text:
|
||||
for candidate in (
|
||||
audio.with_suffix(".txt"),
|
||||
self.settings.samples_dir / "my_voice_ref.txt",
|
||||
):
|
||||
if candidate.is_file():
|
||||
text = candidate.read_text(encoding="utf-8").strip()
|
||||
break
|
||||
if not text and self.settings.tts_model == "f5_tts":
|
||||
text = "참조 음성의 대본을 samples/my_voice_ref.txt 에 저장하세요."
|
||||
return audio, text
|
||||
|
||||
def synthesize_to_file(
|
||||
self,
|
||||
text: str,
|
||||
ref_audio: Path | None = None,
|
||||
ref_text: str | None = None,
|
||||
job_id: str | None = None,
|
||||
) -> tuple[str, Path]:
|
||||
ref_path, ref_txt = self.resolve_reference(ref_audio, ref_text)
|
||||
chunks = split_sentences(text, self.settings.chunk_max_chars)
|
||||
job_id = job_id or uuid.uuid4().hex[:12]
|
||||
job_dir = self.settings.outputs_dir / job_id
|
||||
job_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
chunk_paths: list[Path] = []
|
||||
for i, chunk in enumerate(chunks):
|
||||
out = job_dir / f"part_{i:03d}.wav"
|
||||
self.engine.synthesize(chunk, ref_path, ref_txt, out)
|
||||
chunk_paths.append(out)
|
||||
|
||||
final = job_dir / "output.wav"
|
||||
if len(chunk_paths) == 1:
|
||||
chunk_paths[0].replace(final)
|
||||
else:
|
||||
_concat_wav(chunk_paths, final)
|
||||
|
||||
return job_id, final
|
||||
|
||||
|
||||
def _concat_wav(paths: list[Path], out: Path) -> None:
|
||||
"""동일 포맷 WAV 단순 연결."""
|
||||
with wave.open(str(paths[0]), "rb") as w0:
|
||||
params = w0.getparams()
|
||||
frames = [w0.readframes(w0.getnframes())]
|
||||
for p in paths[1:]:
|
||||
with wave.open(str(p), "rb") as w:
|
||||
if w.getparams() != params:
|
||||
raise ValueError(f"WAV format mismatch: {p}")
|
||||
frames.append(w.readframes(w.getframes()))
|
||||
out.parent.mkdir(parents=True, exist_ok=True)
|
||||
with wave.open(str(out), "wb") as wo:
|
||||
wo.setparams(params)
|
||||
for f in frames:
|
||||
wo.writeframes(f)
|
||||
0
backend/data/uploads/.gitkeep
Normal file
0
backend/data/uploads/.gitkeep
Normal file
9
backend/requirements.txt
Normal file
9
backend/requirements.txt
Normal file
@@ -0,0 +1,9 @@
|
||||
fastapi>=0.115.0
|
||||
uvicorn[standard]>=0.32.0
|
||||
python-multipart>=0.0.12
|
||||
pydantic>=2.9.0
|
||||
pydantic-settings>=2.6.0
|
||||
pyyaml>=6.0.2
|
||||
aiofiles>=24.1.0
|
||||
soundfile>=0.12.1
|
||||
librosa>=0.10.2
|
||||
10
config/model_choice.json
Normal file
10
config/model_choice.json
Normal file
@@ -0,0 +1,10 @@
|
||||
{
|
||||
"selected_model": "cosyvoice",
|
||||
"selection_criteria": [
|
||||
"korean_naturalness",
|
||||
"prosody",
|
||||
"speaker_similarity",
|
||||
"long_sentence_stability"
|
||||
],
|
||||
"notes": "품질 우선 기준으로 CosyVoice3를 기본 엔진으로 사용합니다. F5-TTS는 scripts/run_ab_compare.py로 동일 조건 비교 후 변경 가능합니다."
|
||||
}
|
||||
26
config/settings.yaml
Normal file
26
config/settings.yaml
Normal file
@@ -0,0 +1,26 @@
|
||||
# TTS 프로토타입 설정 (한국어 품질 우선)
|
||||
default_model: cosyvoice # cosyvoice | f5_tts
|
||||
|
||||
paths:
|
||||
samples_dir: samples
|
||||
outputs_dir: outputs
|
||||
models_dir: models
|
||||
uploads_dir: backend/data/uploads
|
||||
|
||||
cosyvoice:
|
||||
repo_dir: external/CosyVoice
|
||||
model_dir: models/Fun-CosyVoice3-0.5B
|
||||
# reference WAV에 대응하는 프롬프트 텍스트 (CosyVoice3 zero-shot 형식)
|
||||
prompt_prefix: "You are a helpful assistant.<|endofprompt|>"
|
||||
|
||||
f5_tts:
|
||||
model: F5TTS_v1_Base
|
||||
|
||||
generation:
|
||||
chunk_max_chars: 120
|
||||
cross_fade_duration: 0.15
|
||||
speed: 1.0
|
||||
|
||||
server:
|
||||
host: 0.0.0.0
|
||||
port: 8000
|
||||
29
config/test_sentences.json
Normal file
29
config/test_sentences.json
Normal file
@@ -0,0 +1,29 @@
|
||||
{
|
||||
"cases": [
|
||||
{
|
||||
"id": "short",
|
||||
"label": "짧은 문장",
|
||||
"text": "안녕하세요. 오늘 날씨가 정말 좋네요."
|
||||
},
|
||||
{
|
||||
"id": "long",
|
||||
"label": "긴 문장",
|
||||
"text": "인공지능 음성 합성 기술은 짧은 문장뿐 아니라 긴 설명문에서도 자연스러운 억양과 호흡을 유지해야 하며, 특히 한국어에서는 조사와 어미 변화가 발음 품질에 큰 영향을 줍니다."
|
||||
},
|
||||
{
|
||||
"id": "numbers",
|
||||
"label": "숫자/단위",
|
||||
"text": "회의는 3월 15일 오후 2시 30분에 시작하며, 예산은 약 1,250,000원입니다."
|
||||
},
|
||||
{
|
||||
"id": "mixed",
|
||||
"label": "영어/기호 혼합",
|
||||
"text": "GitHub에서 API 키를 발급받은 뒤, README.md 파일을 확인해 주세요."
|
||||
},
|
||||
{
|
||||
"id": "emotion",
|
||||
"label": "감정/강조",
|
||||
"text": "정말 기뻐요! 드디어 프로젝트가 완성됐어요. 고생 많으셨습니다."
|
||||
}
|
||||
]
|
||||
}
|
||||
28
docker-compose.yml
Normal file
28
docker-compose.yml
Normal file
@@ -0,0 +1,28 @@
|
||||
# NVIDIA GPU 서버용 (모델 venv는 호스트에서 setup 후 볼륨 마운트 권장)
|
||||
services:
|
||||
tts-api:
|
||||
image: nvidia/cuda:12.4.1-runtime-ubuntu22.04
|
||||
working_dir: /app
|
||||
volumes:
|
||||
- .:/app
|
||||
- ./.venvs:/app/.venvs
|
||||
- ./models:/app/models
|
||||
- ./samples:/app/samples
|
||||
- ./outputs:/app/outputs
|
||||
ports:
|
||||
- "8000:8000"
|
||||
environment:
|
||||
- TTS_MODEL=cosyvoice
|
||||
- PYTHONPATH=/app
|
||||
deploy:
|
||||
resources:
|
||||
reservations:
|
||||
devices:
|
||||
- driver: nvidia
|
||||
count: 1
|
||||
capabilities: [gpu]
|
||||
command: >
|
||||
bash -c "
|
||||
apt-get update -qq && apt-get install -y -qq python3 python3-venv python3-pip sox libsox-dev ffmpeg git &&
|
||||
./scripts/run_server.sh
|
||||
"
|
||||
274
docs/HANDOFF.md
Normal file
274
docs/HANDOFF.md
Normal file
@@ -0,0 +1,274 @@
|
||||
# 다음 작업자 인수인계 문서
|
||||
|
||||
이 문서는 한국어 보이스 클로닝 TTS 프로젝트를 다른 PC 또는 다음 AI가 이어받을 때 가장 먼저 읽어야 하는 문서입니다.
|
||||
|
||||
## 한 줄 요약
|
||||
|
||||
한국어 자연스러움과 본인 목소리 유사도를 우선하는 로컬 TTS 프로토타입입니다. 기본 모델은 `CosyVoice3`, 비교 모델은 `F5-TTS`이며, FastAPI 백엔드와 간단한 웹 UI까지 만들어져 있습니다.
|
||||
|
||||
## 현재 작업 상태
|
||||
|
||||
- 프로젝트 뼈대 생성 완료
|
||||
- FastAPI 서버 구현 완료
|
||||
- 웹 UI 구현 완료
|
||||
- 한국어 텍스트 전처리 구현 완료
|
||||
- 내 목소리 reference 녹음 가이드 작성 완료
|
||||
- `CosyVoice3` / `F5-TTS` A/B 비교 스크립트 작성 완료
|
||||
- 모델별 격리 venv 설치 스크립트 작성 완료
|
||||
- 현재 Mac에서 API/UI smoke test 완료
|
||||
- 실제 고품질 TTS 추론은 아직 NVIDIA GPU 환경에서 검증 필요
|
||||
|
||||
## 중요한 전제
|
||||
|
||||
현재 작업한 Mac은 Apple Silicon 환경이고 `nvidia-smi`가 없습니다. 따라서 여기서는 API/UI 구조 확인은 가능하지만, 계획한 고품질 보이스 클로닝 추론은 Windows 또는 Linux의 NVIDIA GPU PC에서 이어가는 것이 맞습니다.
|
||||
|
||||
메인 PC가 Windows + NVIDIA GPU라면 다음 순서로 진행하세요. 가능하면 WSL2 Ubuntu 환경을 권장합니다. Windows 네이티브도 가능할 수 있지만, `CosyVoice`는 Linux/WSL2 쪽이 문제 해결 자료가 많습니다.
|
||||
|
||||
## 깃에 올릴 때 주의
|
||||
|
||||
올리면 좋은 것:
|
||||
|
||||
- `README.md`
|
||||
- `docs/HANDOFF.md`
|
||||
- `backend/`
|
||||
- `web/`
|
||||
- `scripts/`
|
||||
- `config/`
|
||||
- `samples/README.md`
|
||||
- `samples/my_voice_ref.txt`
|
||||
- `.env.example`
|
||||
- `.gitignore`
|
||||
- `docker-compose.yml`
|
||||
- `tests/`
|
||||
|
||||
올리지 말아야 할 것:
|
||||
|
||||
- `.venvs/`
|
||||
- `models/`
|
||||
- `outputs/`
|
||||
- `external/CosyVoice/`
|
||||
- 실제 내 목소리 WAV 파일
|
||||
- `.env` 안에 개인 경로나 민감한 값이 들어간 경우
|
||||
|
||||
현재 `.gitignore`에는 `.venvs/`, `models/`, `outputs/`, `external/CosyVoice/`, `.env`, WAV 파일 등이 제외되도록 설정되어 있습니다.
|
||||
|
||||
## 메인 PC에서 처음 할 일
|
||||
|
||||
```bash
|
||||
git clone <repo-url>
|
||||
cd tts
|
||||
|
||||
chmod +x scripts/*.sh
|
||||
./scripts/check_env.sh
|
||||
```
|
||||
|
||||
Windows라면 WSL2 Ubuntu에서 실행하는 것을 권장합니다.
|
||||
|
||||
```bash
|
||||
sudo apt update
|
||||
sudo apt install -y git python3 python3-venv python3-pip ffmpeg sox libsox-dev
|
||||
```
|
||||
|
||||
NVIDIA GPU가 제대로 잡히는지 확인하세요.
|
||||
|
||||
```bash
|
||||
nvidia-smi
|
||||
```
|
||||
|
||||
## 환경 설치 순서
|
||||
|
||||
API 서버용 venv:
|
||||
|
||||
```bash
|
||||
./scripts/setup_api.sh
|
||||
```
|
||||
|
||||
F5-TTS 비교용 venv:
|
||||
|
||||
```bash
|
||||
./scripts/setup_f5tts.sh
|
||||
```
|
||||
|
||||
CosyVoice3 기본 모델용 venv:
|
||||
|
||||
```bash
|
||||
./scripts/setup_cosyvoice.sh
|
||||
```
|
||||
|
||||
`setup_cosyvoice.sh`는 `external/CosyVoice` 레포를 클론하고 `models/Fun-CosyVoice3-0.5B` 모델을 다운로드합니다. 시간이 오래 걸릴 수 있습니다.
|
||||
|
||||
## 현재 기본 모델
|
||||
|
||||
기본값은 `cosyvoice`입니다.
|
||||
|
||||
관련 파일:
|
||||
|
||||
- `.env.example`
|
||||
- `.env`
|
||||
- `config/settings.yaml`
|
||||
- `config/model_choice.json`
|
||||
|
||||
모델 변경:
|
||||
|
||||
```bash
|
||||
./scripts/select_model.sh cosyvoice
|
||||
./scripts/select_model.sh f5_tts
|
||||
```
|
||||
|
||||
또는 `.env`에서 직접 변경:
|
||||
|
||||
```env
|
||||
TTS_MODEL=cosyvoice
|
||||
```
|
||||
|
||||
## 내 목소리 샘플 준비
|
||||
|
||||
자세한 가이드는 `samples/README.md`에 있습니다.
|
||||
|
||||
권장 파일:
|
||||
|
||||
```text
|
||||
samples/my_voice_30s.wav
|
||||
samples/my_voice_1m.wav
|
||||
samples/my_voice_3m.wav
|
||||
samples/my_voice_ref.txt
|
||||
```
|
||||
|
||||
중요:
|
||||
|
||||
- WAV는 mono, 24kHz 또는 16kHz 권장
|
||||
- 녹음 대본과 `my_voice_ref.txt` 내용은 일치해야 함
|
||||
- 조용한 환경에서 마이크 거리 일정하게 유지
|
||||
- 30초, 1분, 3분 샘플을 각각 비교
|
||||
|
||||
reference WAV 전처리:
|
||||
|
||||
```bash
|
||||
./scripts/prepare_reference.sh samples/my_voice_30s.wav
|
||||
```
|
||||
|
||||
## 모델 A/B 비교
|
||||
|
||||
설치 검증용:
|
||||
|
||||
```bash
|
||||
./scripts/run_ab_compare.py --ref-audio auto
|
||||
```
|
||||
|
||||
본인 목소리 비교:
|
||||
|
||||
```bash
|
||||
./scripts/run_ab_compare.py --ref-audio samples/my_voice_30s.wav
|
||||
```
|
||||
|
||||
길이별 reference 비교:
|
||||
|
||||
```bash
|
||||
./scripts/compare_voice_lengths.sh
|
||||
```
|
||||
|
||||
결과는 보통 아래에 생성됩니다.
|
||||
|
||||
```text
|
||||
outputs/ab_compare/
|
||||
outputs/voice_length_compare/
|
||||
```
|
||||
|
||||
평가 기준:
|
||||
|
||||
- 한국어 발음 정확도
|
||||
- 조사/어미 자연스러움
|
||||
- 억양과 호흡
|
||||
- 내 목소리 유사도
|
||||
- 긴 문장 안정성
|
||||
- 숫자, 영어, 기호 포함 문장 처리
|
||||
|
||||
## 서버 실행
|
||||
|
||||
```bash
|
||||
cp .env.example .env
|
||||
./scripts/run_server.sh
|
||||
```
|
||||
|
||||
브라우저:
|
||||
|
||||
```text
|
||||
http://localhost:8000
|
||||
```
|
||||
|
||||
주요 API:
|
||||
|
||||
```text
|
||||
GET /api/health
|
||||
POST /api/tts
|
||||
GET /api/audio/{job_id}
|
||||
GET /api/voice-samples
|
||||
POST /api/voice-sample
|
||||
```
|
||||
|
||||
간단 테스트:
|
||||
|
||||
```bash
|
||||
curl -X POST http://localhost:8000/api/tts \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"text":"안녕하세요. 한국어 음성 합성 테스트입니다."}'
|
||||
```
|
||||
|
||||
## Mac에서 가능한 것과 불가능한 것
|
||||
|
||||
현재 Mac에서 가능한 것:
|
||||
|
||||
- FastAPI 서버 실행
|
||||
- 웹 UI 확인
|
||||
- `/api/health` 확인
|
||||
- 샘플 업로드 UI 확인
|
||||
- 텍스트 전처리 테스트
|
||||
|
||||
현재 Mac에서 어려운 것:
|
||||
|
||||
- `CosyVoice3` 고품질 추론
|
||||
- `F5-TTS` CUDA 기반 추론
|
||||
- 최종 품질 평가
|
||||
|
||||
즉, Mac은 개발/구조 확인용이고, 최종 모델 품질 검증은 NVIDIA GPU PC에서 해야 합니다.
|
||||
|
||||
## 다음 AI에게 요청할 때 권장 문장
|
||||
|
||||
다음 AI에게는 아래처럼 시작하면 됩니다.
|
||||
|
||||
```text
|
||||
먼저 docs/HANDOFF.md를 읽고, 현재 한국어 보이스 클로닝 TTS 프로젝트 상태를 파악한 뒤 이어서 작업해줘.
|
||||
목표는 Windows/NVIDIA GPU PC에서 CosyVoice3와 F5-TTS를 설치하고, 내 목소리 샘플로 A/B 비교 후 더 자연스러운 모델을 선택하는 거야.
|
||||
```
|
||||
|
||||
## 다음 작업자가 우선 확인할 파일
|
||||
|
||||
1. `docs/HANDOFF.md`
|
||||
2. `README.md`
|
||||
3. `config/settings.yaml`
|
||||
4. `config/model_choice.json`
|
||||
5. `.env.example`
|
||||
6. `samples/README.md`
|
||||
7. `scripts/setup_cosyvoice.sh`
|
||||
8. `scripts/setup_f5tts.sh`
|
||||
9. `scripts/run_ab_compare.py`
|
||||
10. `backend/app/main.py`
|
||||
|
||||
## 알려진 주의점
|
||||
|
||||
- `FastAPI TestClient`는 현재 API venv에서 `httpx` 계열 패키지가 없어 직접 테스트가 실패했습니다. 대신 실제 `uvicorn` 서버를 띄우고 `/api/health`와 웹 UI 200 응답은 확인했습니다.
|
||||
- `scripts/setup_f5tts.sh`와 `scripts/setup_cosyvoice.sh`는 CUDA 12.4 PyTorch index를 사용합니다. 메인 PC CUDA 버전에 맞지 않으면 `cu124`를 `cu121`, `cu126`, `cu128` 등으로 조정해야 할 수 있습니다.
|
||||
- `CosyVoice`는 `sox`, `libsox-dev`, `ffmpeg`가 필요할 수 있습니다.
|
||||
- 실제 음성 파일은 개인정보성이 있으므로 깃에 올리지 않는 것을 권장합니다.
|
||||
- `.env`도 깃에 올리지 말고 `.env.example`만 공유하세요.
|
||||
|
||||
## 완료 기준
|
||||
|
||||
이 프로젝트의 다음 큰 완료 기준은 다음과 같습니다.
|
||||
|
||||
- NVIDIA GPU PC에서 `./scripts/setup_cosyvoice.sh` 성공
|
||||
- NVIDIA GPU PC에서 `./scripts/setup_f5tts.sh` 성공
|
||||
- 본인 목소리 샘플로 `./scripts/run_ab_compare.py` 실행 성공
|
||||
- `outputs/ab_compare/` 결과를 듣고 모델 선택
|
||||
- 선택 모델로 `./scripts/run_server.sh` 실행
|
||||
- 웹 UI에서 텍스트 입력 후 본인 목소리 WAV 재생 성공
|
||||
0
samples/.gitkeep
Normal file
0
samples/.gitkeep
Normal file
42
samples/README.md
Normal file
42
samples/README.md
Normal file
@@ -0,0 +1,42 @@
|
||||
# Reference 음성 샘플
|
||||
|
||||
내 목소리로 TTS를 만들려면 **조용한 환경**에서 아래 길이별로 녹음하세요.
|
||||
|
||||
## 권장 녹음 방식
|
||||
|
||||
1. 마이크와 입 사이 거리를 일정하게 유지 (15~20cm)
|
||||
2. 평서문으로 자연스럽게 읽기 (연기·과장 금지)
|
||||
3. 포맷: **mono WAV, 24kHz** (또는 16kHz)
|
||||
4. 파일명 예시:
|
||||
- `my_voice_30s.wav`
|
||||
- `my_voice_1m.wav`
|
||||
- `my_voice_3m.wav`
|
||||
|
||||
## reference 텍스트
|
||||
|
||||
녹음한 내용과 **동일한 대본**을 `my_voice_ref.txt`에 저장하세요.
|
||||
F5-TTS는 이 텍스트가 필수이고, CosyVoice는 WAV만으로도 동작하지만 품질 비교 시 동일 샘플을 사용하세요.
|
||||
|
||||
### 예시 대본 (약 30초)
|
||||
|
||||
```
|
||||
안녕하세요. 저는 한국어 음성 합성 테스트를 위한 참조 음성을 녹음하고 있습니다.
|
||||
오늘은 날씨가 맑고, 목소리가 자연스럽게 들리도록 천천히 말하겠습니다.
|
||||
숫자도 포함해 볼게요. 회의는 3월 15일 오후 2시에 있습니다.
|
||||
```
|
||||
|
||||
## 전처리
|
||||
|
||||
```bash
|
||||
./scripts/prepare_reference.sh samples/my_voice_30s.wav
|
||||
```
|
||||
|
||||
## 기본 샘플 (모델 설치 검증용)
|
||||
|
||||
모델 설치 직후에는 F5-TTS 기본 예제 음성으로 먼저 테스트할 수 있습니다:
|
||||
|
||||
```bash
|
||||
./scripts/run_ab_compare.py --ref-audio auto
|
||||
```
|
||||
|
||||
`auto`는 F5-TTS 패키지 내장 영어 샘플을 사용합니다. 한국어 품질 비교는 **본인 녹음 샘플**로 다시 실행하세요.
|
||||
3
samples/my_voice_ref.txt
Normal file
3
samples/my_voice_ref.txt
Normal file
@@ -0,0 +1,3 @@
|
||||
안녕하세요. 저는 한국어 음성 합성 테스트를 위한 참조 음성을 녹음하고 있습니다.
|
||||
오늘은 날씨가 맑고, 목소리가 자연스럽게 들리도록 천천히 말하겠습니다.
|
||||
숫자도 포함해 볼게요. 회의는 3월 15일 오후 2시에 있습니다.
|
||||
77
scripts/check_env.sh
Executable file
77
scripts/check_env.sh
Executable file
@@ -0,0 +1,77 @@
|
||||
#!/usr/bin/env bash
|
||||
# NVIDIA GPU + CUDA 환경 점검
|
||||
set -euo pipefail
|
||||
|
||||
ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||
cd "$ROOT"
|
||||
|
||||
echo "=== TTS 환경 점검 ==="
|
||||
echo "프로젝트: $ROOT"
|
||||
echo
|
||||
|
||||
echo "--- OS / CPU ---"
|
||||
uname -a
|
||||
echo
|
||||
|
||||
echo "--- Python ---"
|
||||
if command -v python3 &>/dev/null; then
|
||||
python3 --version
|
||||
which python3
|
||||
else
|
||||
echo "python3: 없음"
|
||||
fi
|
||||
echo
|
||||
|
||||
echo "--- NVIDIA GPU ---"
|
||||
if command -v nvidia-smi &>/dev/null; then
|
||||
nvidia-smi
|
||||
echo
|
||||
nvidia-smi --query-gpu=name,memory.total,driver_version --format=csv
|
||||
else
|
||||
echo "nvidia-smi: 사용 불가 (NVIDIA GPU 서버에서 실행하세요)"
|
||||
fi
|
||||
echo
|
||||
|
||||
echo "--- CUDA (nvcc) ---"
|
||||
if command -v nvcc &>/dev/null; then
|
||||
nvcc --version | head -4
|
||||
else
|
||||
echo "nvcc: 없음 (PyTorch CUDA 빌드로도 동작 가능)"
|
||||
fi
|
||||
echo
|
||||
|
||||
echo "--- PyTorch (API venv) ---"
|
||||
API_VENV="$ROOT/.venvs/api"
|
||||
if [[ -x "$API_VENV/bin/python" ]]; then
|
||||
"$API_VENV/bin/python" -c "
|
||||
import sys
|
||||
try:
|
||||
import torch
|
||||
print('torch:', torch.__version__)
|
||||
print('cuda available:', torch.cuda.is_available())
|
||||
if torch.cuda.is_available():
|
||||
print('device:', torch.cuda.get_device_name(0))
|
||||
except ImportError:
|
||||
print('torch: 미설치 (API만 사용 시 정상)')
|
||||
" 2>/dev/null || true
|
||||
else
|
||||
echo "API venv 없음 → ./scripts/setup_api.sh 실행"
|
||||
fi
|
||||
echo
|
||||
|
||||
echo "--- 모델 venv ---"
|
||||
for name in f5tts cosyvoice; do
|
||||
V="$ROOT/.venvs/$name"
|
||||
if [[ -x "$V/bin/python" ]]; then
|
||||
echo "[$name] OK: $V"
|
||||
else
|
||||
echo "[$name] 없음 → setup_${name}.sh (f5tts는 setup_f5tts.sh)"
|
||||
fi
|
||||
done
|
||||
echo
|
||||
|
||||
echo "--- 디렉터리 ---"
|
||||
for d in samples outputs models config backend web; do
|
||||
[[ -d "$ROOT/$d" ]] && echo " $d: OK" || echo " $d: MISSING"
|
||||
done
|
||||
echo "점검 완료."
|
||||
45
scripts/compare_voice_lengths.sh
Executable file
45
scripts/compare_voice_lengths.sh
Executable file
@@ -0,0 +1,45 @@
|
||||
#!/usr/bin/env bash
|
||||
# 내 목소리 reference 녹음 가이드 출력 + 길이별 비교 실행
|
||||
set -euo pipefail
|
||||
|
||||
ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||
echo "=== 내 목소리 녹음 가이드 ==="
|
||||
echo "자세한 내용: $ROOT/samples/README.md"
|
||||
echo
|
||||
echo "1) 30초 / 1분 / 3분 WAV를 samples/ 에 저장"
|
||||
echo "2) my_voice_ref.txt 에 녹음 대본 작성"
|
||||
echo "3) ./scripts/prepare_reference.sh samples/my_voice_30s.wav"
|
||||
echo
|
||||
|
||||
shopt -s nullglob
|
||||
WAVS=("$ROOT"/samples/my_voice_*.wav)
|
||||
if [[ ${#WAVS[@]} -eq 0 ]]; then
|
||||
echo "아직 my_voice_*.wav 없음. 녹음 후 다시 실행하세요."
|
||||
exit 0
|
||||
fi
|
||||
|
||||
OUT="$ROOT/outputs/voice_length_compare"
|
||||
mkdir -p "$OUT"
|
||||
PY="$ROOT/.venvs/cosyvoice/bin/python"
|
||||
WORKER="$ROOT/scripts/workers/cosy_infer.py"
|
||||
TEXT="안녕하세요. 이 문장은 reference 길이별 품질 비교를 위한 테스트입니다."
|
||||
|
||||
if [[ ! -x "$PY" ]]; then
|
||||
echo "cosyvoice venv 없음. ./scripts/setup_cosyvoice.sh 후 재실행"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
REF_TXT=""
|
||||
[[ -f "$ROOT/samples/my_voice_ref.txt" ]] && REF_TXT=$(cat "$ROOT/samples/my_voice_ref.txt")
|
||||
|
||||
for wav in "${WAVS[@]}"; do
|
||||
name=$(basename "$wav" .wav)
|
||||
echo "생성: $name"
|
||||
"$PY" "$WORKER" \
|
||||
--ref-audio "$wav" \
|
||||
--gen-text "$TEXT" \
|
||||
--prompt-text "$REF_TXT" \
|
||||
--out "$OUT/${name}_test.wav" || true
|
||||
done
|
||||
|
||||
echo "결과: $OUT"
|
||||
38
scripts/prepare_reference.sh
Executable file
38
scripts/prepare_reference.sh
Executable file
@@ -0,0 +1,38 @@
|
||||
#!/usr/bin/env bash
|
||||
# reference WAV를 mono 24kHz로 정규화
|
||||
set -euo pipefail
|
||||
|
||||
if [[ $# -lt 1 ]]; then
|
||||
echo "Usage: $0 input.wav [output.wav]"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
IN="$1"
|
||||
OUT="${2:-${IN%.wav}_24k_mono.wav}"
|
||||
|
||||
ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||
PY="${ROOT}/.venvs/api/bin/python"
|
||||
if [[ ! -x "$PY" ]]; then
|
||||
PY=python3
|
||||
fi
|
||||
|
||||
"$PY" - <<PY
|
||||
import sys
|
||||
try:
|
||||
import soundfile as sf
|
||||
import numpy as np
|
||||
except ImportError:
|
||||
print("soundfile 필요: pip install soundfile")
|
||||
sys.exit(1)
|
||||
|
||||
data, sr = sf.read("$IN", always_2d=False)
|
||||
if data.ndim > 1:
|
||||
data = data.mean(axis=1)
|
||||
target_sr = 24000
|
||||
if sr != target_sr:
|
||||
import librosa
|
||||
data = librosa.resample(data.astype(float), orig_sr=sr, target_sr=target_sr)
|
||||
sr = target_sr
|
||||
sf.write("$OUT", data, sr, subtype="PCM_16")
|
||||
print(f"Saved: $OUT ({sr} Hz mono)")
|
||||
PY
|
||||
149
scripts/run_ab_compare.py
Normal file
149
scripts/run_ab_compare.py
Normal file
@@ -0,0 +1,149 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
F5-TTS vs CosyVoice3 A/B 비교.
|
||||
각 모델 전용 venv의 worker를 subprocess로 호출합니다.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import subprocess
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
CONFIG = ROOT / "config"
|
||||
F5_PY = ROOT / ".venvs" / "f5tts" / "bin" / "python"
|
||||
COSY_PY = ROOT / ".venvs" / "cosyvoice" / "bin" / "python"
|
||||
F5_WORKER = ROOT / "scripts" / "workers" / "f5_infer.py"
|
||||
COSY_WORKER = ROOT / "scripts" / "workers" / "cosy_infer.py"
|
||||
|
||||
|
||||
def load_sentences() -> list[dict]:
|
||||
with open(CONFIG / "test_sentences.json", encoding="utf-8") as f:
|
||||
return json.load(f)["cases"]
|
||||
|
||||
|
||||
def resolve_ref_audio(ref_arg: str) -> tuple[Path, str]:
|
||||
"""(wav_path, ref_text for F5)"""
|
||||
if ref_arg == "auto":
|
||||
try:
|
||||
from importlib.resources import files
|
||||
|
||||
wav = files("f5_tts").joinpath("infer/examples/basic/basic_ref_en.wav")
|
||||
ref_path = Path(str(wav))
|
||||
ref_text = "some call me nature, others call me mother nature."
|
||||
return ref_path, ref_text
|
||||
except Exception:
|
||||
samples = list((ROOT / "samples").glob("*.wav"))
|
||||
if not samples:
|
||||
raise SystemExit(
|
||||
"reference 없음: samples/*.wav 녹음하거나 f5-tts venv 설치 후 --ref-audio auto"
|
||||
)
|
||||
ref_path = samples[0]
|
||||
else:
|
||||
ref_path = Path(ref_arg)
|
||||
if not ref_path.is_file():
|
||||
raise SystemExit(f"ref audio not found: {ref_path}")
|
||||
|
||||
ref_text = ""
|
||||
txt_candidates = [
|
||||
ref_path.with_suffix(".txt"),
|
||||
ROOT / "samples" / "my_voice_ref.txt",
|
||||
]
|
||||
for t in txt_candidates:
|
||||
if t.is_file():
|
||||
ref_text = t.read_text(encoding="utf-8").strip()
|
||||
break
|
||||
if not ref_text and ref_arg != "auto":
|
||||
ref_text = "참조 음성의 대본을 여기에 입력하세요."
|
||||
return ref_path, ref_text
|
||||
|
||||
|
||||
def run_worker(python: Path, worker: Path, cmd: list[str]) -> bool:
|
||||
if not python.is_file():
|
||||
print(f"SKIP: venv missing ({python.parent.parent.name})", file=sys.stderr)
|
||||
return False
|
||||
r = subprocess.run([str(python), str(worker), *cmd], cwd=str(ROOT))
|
||||
return r.returncode == 0
|
||||
|
||||
|
||||
def main() -> int:
|
||||
parser = argparse.ArgumentParser()
|
||||
parser.add_argument("--ref-audio", default="auto", help="WAV path or 'auto'")
|
||||
parser.add_argument("--models", default="both", choices=("both", "f5_tts", "cosyvoice"))
|
||||
parser.add_argument("--out-dir", default=str(ROOT / "outputs" / "ab_compare"))
|
||||
args = parser.parse_args()
|
||||
|
||||
ref_path, ref_text = resolve_ref_audio(args.ref_audio)
|
||||
out_base = Path(args.out_dir)
|
||||
out_base.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
cases = load_sentences()
|
||||
print(f"Reference: {ref_path}")
|
||||
print(f"Cases: {len(cases)}")
|
||||
print(f"Output: {out_base}\n")
|
||||
|
||||
ok = 0
|
||||
fail = 0
|
||||
for case in cases:
|
||||
cid = case["id"]
|
||||
text = case["text"]
|
||||
print(f"=== {cid}: {case['label']} ===")
|
||||
|
||||
if args.models in ("both", "f5_tts"):
|
||||
out_f5 = out_base / "f5_tts" / f"{cid}.wav"
|
||||
if run_worker(
|
||||
F5_PY,
|
||||
F5_WORKER,
|
||||
[
|
||||
"--ref-audio",
|
||||
str(ref_path),
|
||||
"--ref-text",
|
||||
ref_text,
|
||||
"--gen-text",
|
||||
text,
|
||||
"--out",
|
||||
str(out_f5),
|
||||
],
|
||||
):
|
||||
ok += 1
|
||||
else:
|
||||
fail += 1
|
||||
|
||||
if args.models in ("both", "cosyvoice"):
|
||||
out_cosy = out_base / "cosyvoice" / f"{cid}.wav"
|
||||
if run_worker(
|
||||
COSY_PY,
|
||||
COSY_WORKER,
|
||||
[
|
||||
"--ref-audio",
|
||||
str(ref_path),
|
||||
"--gen-text",
|
||||
text,
|
||||
"--prompt-text",
|
||||
ref_text,
|
||||
"--out",
|
||||
str(out_cosy),
|
||||
],
|
||||
):
|
||||
ok += 1
|
||||
else:
|
||||
fail += 1
|
||||
|
||||
manifest = {
|
||||
"ref_audio": str(ref_path),
|
||||
"ref_text": ref_text,
|
||||
"cases": cases,
|
||||
"output_dir": str(out_base),
|
||||
}
|
||||
(out_base / "manifest.json").write_text(
|
||||
json.dumps(manifest, ensure_ascii=False, indent=2), encoding="utf-8"
|
||||
)
|
||||
print(f"\n완료: success={ok} fail={fail}")
|
||||
print(f"manifest: {out_base / 'manifest.json'}")
|
||||
return 0 if fail == 0 else 1
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
19
scripts/run_server.sh
Executable file
19
scripts/run_server.sh
Executable file
@@ -0,0 +1,19 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||
cd "$ROOT"
|
||||
|
||||
if [[ ! -x "$ROOT/.venvs/api/bin/uvicorn" ]]; then
|
||||
echo "API venv 없음. ./scripts/setup_api.sh 실행"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
export PYTHONPATH="$ROOT"
|
||||
# shellcheck disable=SC1091
|
||||
[[ -f "$ROOT/.env" ]] && source "$ROOT/.env"
|
||||
|
||||
exec "$ROOT/.venvs/api/bin/uvicorn" backend.app.main:app \
|
||||
--host "${TTS_HOST:-0.0.0.0}" \
|
||||
--port "${TTS_PORT:-8000}" \
|
||||
--reload
|
||||
34
scripts/select_model.sh
Executable file
34
scripts/select_model.sh
Executable file
@@ -0,0 +1,34 @@
|
||||
#!/usr/bin/env bash
|
||||
# 최종 모델 선택을 .env 와 config에 반영
|
||||
set -euo pipefail
|
||||
|
||||
MODEL="${1:-}"
|
||||
if [[ -z "$MODEL" || ! "$MODEL" =~ ^(cosyvoice|f5_tts)$ ]]; then
|
||||
echo "Usage: $0 cosyvoice|f5_tts"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||
ENV_FILE="$ROOT/.env"
|
||||
|
||||
if [[ -f "$ENV_FILE" ]]; then
|
||||
if grep -q '^TTS_MODEL=' "$ENV_FILE"; then
|
||||
sed -i.bak "s/^TTS_MODEL=.*/TTS_MODEL=$MODEL/" "$ENV_FILE"
|
||||
rm -f "$ENV_FILE.bak"
|
||||
else
|
||||
echo "TTS_MODEL=$MODEL" >> "$ENV_FILE"
|
||||
fi
|
||||
else
|
||||
cp "$ROOT/.env.example" "$ENV_FILE"
|
||||
echo "TTS_MODEL=$MODEL" >> "$ENV_FILE"
|
||||
fi
|
||||
|
||||
python3 - <<PY
|
||||
import json
|
||||
from pathlib import Path
|
||||
p = Path("$ROOT/config/model_choice.json")
|
||||
data = json.loads(p.read_text(encoding="utf-8"))
|
||||
data["selected_model"] = "$MODEL"
|
||||
p.write_text(json.dumps(data, ensure_ascii=False, indent=2) + "\n", encoding="utf-8")
|
||||
print("selected_model=$MODEL")
|
||||
PY
|
||||
13
scripts/setup_api.sh
Executable file
13
scripts/setup_api.sh
Executable file
@@ -0,0 +1,13 @@
|
||||
#!/usr/bin/env bash
|
||||
# FastAPI 서버용 경량 venv
|
||||
set -euo pipefail
|
||||
|
||||
ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||
VENV="$ROOT/.venvs/api"
|
||||
|
||||
python3 -m venv "$VENV"
|
||||
"$VENV/bin/pip" install -U pip wheel
|
||||
"$VENV/bin/pip" install -r "$ROOT/backend/requirements.txt"
|
||||
|
||||
echo "API venv 준비 완료: $VENV"
|
||||
echo "실행: $VENV/bin/uvicorn backend.app.main:app --host 0.0.0.0 --port 8000"
|
||||
38
scripts/setup_cosyvoice.sh
Executable file
38
scripts/setup_cosyvoice.sh
Executable file
@@ -0,0 +1,38 @@
|
||||
#!/usr/bin/env bash
|
||||
# CosyVoice3 전용 venv + 레포 클론 + 모델 다운로드
|
||||
set -euo pipefail
|
||||
|
||||
ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||
VENV="$ROOT/.venvs/cosyvoice"
|
||||
REPO="$ROOT/external/CosyVoice"
|
||||
MODEL_DIR="$ROOT/models/Fun-CosyVoice3-0.5B"
|
||||
|
||||
mkdir -p "$ROOT/external" "$ROOT/models"
|
||||
|
||||
if [[ ! -d "$REPO/.git" ]]; then
|
||||
echo "CosyVoice 레포 클론..."
|
||||
git clone --recursive https://github.com/FunAudioLLM/CosyVoice.git "$REPO"
|
||||
cd "$REPO"
|
||||
git submodule update --init --recursive
|
||||
else
|
||||
echo "CosyVoice 레포 이미 존재: $REPO"
|
||||
fi
|
||||
|
||||
python3 -m venv "$VENV"
|
||||
"$VENV/bin/pip" install -U pip wheel
|
||||
"$VENV/bin/pip" install torch torchaudio --index-url https://download.pytorch.org/whl/cu124
|
||||
"$VENV/bin/pip" install -r "$REPO/requirements.txt"
|
||||
"$VENV/bin/pip" install huggingface_hub modelscope
|
||||
|
||||
echo "CosyVoice3 모델 다운로드 (Hugging Face)..."
|
||||
"$VENV/bin/python" - <<PY
|
||||
from huggingface_hub import snapshot_download
|
||||
snapshot_download(
|
||||
'FunAudioLLM/Fun-CosyVoice3-0.5B-2512',
|
||||
local_dir='$MODEL_DIR',
|
||||
)
|
||||
print('Model saved to $MODEL_DIR')
|
||||
PY
|
||||
|
||||
echo "CosyVoice venv 준비 완료: $VENV"
|
||||
echo "테스트: $VENV/bin/python $ROOT/scripts/workers/cosy_infer.py --help"
|
||||
16
scripts/setup_f5tts.sh
Executable file
16
scripts/setup_f5tts.sh
Executable file
@@ -0,0 +1,16 @@
|
||||
#!/usr/bin/env bash
|
||||
# F5-TTS 전용 venv (NVIDIA CUDA)
|
||||
set -euo pipefail
|
||||
|
||||
ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||
VENV="$ROOT/.venvs/f5tts"
|
||||
|
||||
python3 -m venv "$VENV"
|
||||
"$VENV/bin/pip" install -U pip wheel
|
||||
|
||||
# CUDA 12.x PyTorch (서버 CUDA 버전에 맞게 cu124/cu128 조정)
|
||||
"$VENV/bin/pip" install torch torchaudio --index-url https://download.pytorch.org/whl/cu124
|
||||
"$VENV/bin/pip" install f5-tts
|
||||
|
||||
echo "F5-TTS venv 준비 완료: $VENV"
|
||||
echo "테스트: $VENV/bin/python $ROOT/scripts/workers/f5_infer.py --help"
|
||||
73
scripts/workers/cosy_infer.py
Normal file
73
scripts/workers/cosy_infer.py
Normal file
@@ -0,0 +1,73 @@
|
||||
#!/usr/bin/env python3
|
||||
"""CosyVoice3 zero-shot 추론 워커 (cosyvoice venv에서 실행)."""
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
def main() -> int:
|
||||
parser = argparse.ArgumentParser(description="CosyVoice3 inference worker")
|
||||
parser.add_argument("--ref-audio", required=True)
|
||||
parser.add_argument("--prompt-text", default="", help="Text spoken in ref audio (with prefix)")
|
||||
parser.add_argument("--gen-text", required=True)
|
||||
parser.add_argument("--out", required=True)
|
||||
parser.add_argument(
|
||||
"--model-dir",
|
||||
default=None,
|
||||
help="Path to Fun-CosyVoice3-0.5B (default: PROJECT/models/Fun-CosyVoice3-0.5B)",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--prompt-prefix",
|
||||
default="You are a helpful assistant.<|endofprompt|>",
|
||||
)
|
||||
args = parser.parse_args()
|
||||
|
||||
root = Path(__file__).resolve().parents[2]
|
||||
repo = root / "external" / "CosyVoice"
|
||||
model_dir = Path(args.model_dir or root / "models" / "Fun-CosyVoice3-0.5B")
|
||||
ref = Path(args.ref_audio)
|
||||
out = Path(args.out)
|
||||
|
||||
if not repo.is_dir():
|
||||
print(f"CosyVoice repo missing: {repo}. Run ./scripts/setup_cosyvoice.sh", file=sys.stderr)
|
||||
return 1
|
||||
if not model_dir.is_dir():
|
||||
print(f"Model dir missing: {model_dir}", file=sys.stderr)
|
||||
return 1
|
||||
if not ref.is_file():
|
||||
print(f"ref audio not found: {ref}", file=sys.stderr)
|
||||
return 1
|
||||
|
||||
sys.path.insert(0, str(repo))
|
||||
sys.path.append(str(repo / "third_party" / "Matcha-TTS"))
|
||||
|
||||
try:
|
||||
import torchaudio
|
||||
from cosyvoice.cli.cosyvoice import AutoModel
|
||||
except ImportError as e:
|
||||
print("CosyVoice import failed. Run ./scripts/setup_cosyvoice.sh", file=sys.stderr)
|
||||
print(e, file=sys.stderr)
|
||||
return 1
|
||||
|
||||
prompt = args.prompt_prefix + (args.prompt_text or "")
|
||||
out.parent.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
cosyvoice = AutoModel(model_dir=str(model_dir))
|
||||
for i, result in enumerate(
|
||||
cosyvoice.inference_zero_shot(
|
||||
args.gen_text,
|
||||
prompt,
|
||||
str(ref),
|
||||
stream=False,
|
||||
)
|
||||
):
|
||||
path = out if i == 0 else out.with_stem(f"{out.stem}_{i}")
|
||||
torchaudio.save(str(path), result["tts_speech"], cosyvoice.sample_rate)
|
||||
print(f"OK: {path}")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
47
scripts/workers/f5_infer.py
Normal file
47
scripts/workers/f5_infer.py
Normal file
@@ -0,0 +1,47 @@
|
||||
#!/usr/bin/env python3
|
||||
"""F5-TTS 추론 워커 (f5tts venv에서 실행)."""
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
def main() -> int:
|
||||
parser = argparse.ArgumentParser(description="F5-TTS inference worker")
|
||||
parser.add_argument("--ref-audio", required=True, help="Reference WAV path")
|
||||
parser.add_argument("--ref-text", required=True, help="Transcript of reference audio")
|
||||
parser.add_argument("--gen-text", required=True, help="Text to synthesize")
|
||||
parser.add_argument("--out", required=True, help="Output WAV path")
|
||||
parser.add_argument("--model", default="F5TTS_v1_Base")
|
||||
args = parser.parse_args()
|
||||
|
||||
ref = Path(args.ref_audio)
|
||||
if not ref.is_file():
|
||||
print(f"ref audio not found: {ref}", file=sys.stderr)
|
||||
return 1
|
||||
|
||||
out = Path(args.out)
|
||||
out.parent.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
try:
|
||||
from f5_tts.api import F5TTS
|
||||
except ImportError as e:
|
||||
print("f5-tts not installed. Run: ./scripts/setup_f5tts.sh", file=sys.stderr)
|
||||
print(e, file=sys.stderr)
|
||||
return 1
|
||||
|
||||
tts = F5TTS(model=args.model)
|
||||
tts.infer(
|
||||
ref_file=str(ref),
|
||||
ref_text=args.ref_text,
|
||||
gen_text=args.gen_text,
|
||||
file_wave=str(out),
|
||||
remove_silence=True,
|
||||
)
|
||||
print(f"OK: {out}")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
13
tests/test_preprocess.py
Normal file
13
tests/test_preprocess.py
Normal file
@@ -0,0 +1,13 @@
|
||||
from backend.app.text_preprocess import preprocess_korean, split_sentences
|
||||
|
||||
|
||||
def test_preprocess_numbers():
|
||||
out = preprocess_korean("예산은 1,250,000원입니다.")
|
||||
assert "원" in out
|
||||
assert "1,250,000" not in out
|
||||
|
||||
|
||||
def test_split_sentences():
|
||||
chunks = split_sentences("첫 문장입니다. 두 번째 문장입니다.", max_chars=50)
|
||||
assert len(chunks) >= 1
|
||||
assert all(len(c) <= 50 for c in chunks)
|
||||
101
web/app.js
Normal file
101
web/app.js
Normal file
@@ -0,0 +1,101 @@
|
||||
const $ = (id) => document.getElementById(id);
|
||||
|
||||
async function fetchHealth() {
|
||||
try {
|
||||
const res = await fetch("/api/health");
|
||||
const data = await res.json();
|
||||
$("healthInfo").textContent = `모델: ${data.model} · 샘플 ${data.samples_count}개`;
|
||||
} catch {
|
||||
$("healthInfo").textContent = "API 서버에 연결할 수 없습니다.";
|
||||
}
|
||||
}
|
||||
|
||||
async function loadSamples() {
|
||||
const select = $("sampleSelect");
|
||||
try {
|
||||
const res = await fetch("/api/voice-samples");
|
||||
const data = await res.json();
|
||||
for (const s of data.samples) {
|
||||
const opt = document.createElement("option");
|
||||
opt.value = s.path;
|
||||
opt.textContent = `${s.label}/${s.id}${s.has_transcript ? "" : " (대본 없음)"}`;
|
||||
select.appendChild(opt);
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn("samples load failed", e);
|
||||
}
|
||||
}
|
||||
|
||||
async function uploadIfNeeded() {
|
||||
const fileInput = $("fileUpload");
|
||||
if (!fileInput.files?.length) return null;
|
||||
|
||||
const form = new FormData();
|
||||
form.append("file", fileInput.files[0]);
|
||||
const refText = $("refText").value.trim();
|
||||
if (refText) form.append("ref_text", refText);
|
||||
|
||||
const res = await fetch("/api/voice-sample", { method: "POST", body: form });
|
||||
if (!res.ok) {
|
||||
const err = await res.json().catch(() => ({}));
|
||||
throw new Error(err.detail || "업로드 실패");
|
||||
}
|
||||
const data = await res.json();
|
||||
return data.path;
|
||||
}
|
||||
|
||||
$("generateBtn").addEventListener("click", async () => {
|
||||
const text = $("text").value.trim();
|
||||
if (!text) {
|
||||
$("status").textContent = "텍스트를 입력하세요.";
|
||||
return;
|
||||
}
|
||||
|
||||
const btn = $("generateBtn");
|
||||
btn.disabled = true;
|
||||
$("status").textContent = "음성 생성 중… (GPU 추론은 수십 초 걸릴 수 있습니다)";
|
||||
$("resultSection").hidden = true;
|
||||
|
||||
try {
|
||||
let refAudio = $("sampleSelect").value || null;
|
||||
const uploaded = await uploadIfNeeded();
|
||||
if (uploaded) refAudio = uploaded;
|
||||
|
||||
const body = {
|
||||
text,
|
||||
preprocess: true,
|
||||
ref_text: $("refText").value.trim() || null,
|
||||
ref_audio: refAudio,
|
||||
};
|
||||
|
||||
const res = await fetch("/api/tts", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
const err = await res.json().catch(() => ({}));
|
||||
const detail =
|
||||
typeof err.detail === "string"
|
||||
? err.detail
|
||||
: JSON.stringify(err.detail || err);
|
||||
throw new Error(detail || res.statusText);
|
||||
}
|
||||
|
||||
const data = await res.json();
|
||||
const url = data.audio_url + "?t=" + Date.now();
|
||||
$("player").src = url;
|
||||
$("downloadLink").href = url;
|
||||
$("downloadLink").download = `${data.job_id}.wav`;
|
||||
$("resultSection").hidden = false;
|
||||
$("status").textContent = `완료 (모델: ${data.model}, job: ${data.job_id})`;
|
||||
} catch (e) {
|
||||
$("status").textContent = `오류: ${e.message}`;
|
||||
} finally {
|
||||
btn.disabled = false;
|
||||
}
|
||||
});
|
||||
|
||||
fetchHealth();
|
||||
loadSamples();
|
||||
64
web/index.html
Normal file
64
web/index.html
Normal file
@@ -0,0 +1,64 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="ko">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>한국어 보이스 클로닝 TTS</title>
|
||||
<link rel="stylesheet" href="/style.css" />
|
||||
</head>
|
||||
<body>
|
||||
<main class="container">
|
||||
<header>
|
||||
<h1>한국어 보이스 클로닝 TTS</h1>
|
||||
<p class="subtitle">텍스트를 입력하면 reference 음성을 바탕으로 음성을 생성합니다.</p>
|
||||
</header>
|
||||
|
||||
<section class="card">
|
||||
<label for="text">읽을 텍스트</label>
|
||||
<textarea
|
||||
id="text"
|
||||
rows="5"
|
||||
placeholder="안녕하세요. 오늘 날씨가 정말 좋네요."
|
||||
></textarea>
|
||||
|
||||
<div class="row">
|
||||
<div class="field">
|
||||
<label for="sampleSelect">Reference 음성</label>
|
||||
<select id="sampleSelect">
|
||||
<option value="">기본 샘플 사용</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label for="refText">Reference 대본 (선택)</label>
|
||||
<input
|
||||
id="refText"
|
||||
type="text"
|
||||
placeholder="녹음한 내용과 동일한 텍스트"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="field">
|
||||
<label for="fileUpload">새 음성 업로드 (WAV)</label>
|
||||
<input id="fileUpload" type="file" accept=".wav,audio/wav" />
|
||||
</div>
|
||||
|
||||
<button id="generateBtn" type="button">음성 생성</button>
|
||||
<p id="status" class="status" aria-live="polite"></p>
|
||||
</section>
|
||||
|
||||
<section class="card" id="resultSection" hidden>
|
||||
<h2>결과</h2>
|
||||
<audio id="player" controls></audio>
|
||||
<p>
|
||||
<a id="downloadLink" href="#" download>WAV 다운로드</a>
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<footer>
|
||||
<span id="healthInfo">서버 확인 중…</span>
|
||||
</footer>
|
||||
</main>
|
||||
<script src="/app.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
133
web/style.css
Normal file
133
web/style.css
Normal file
@@ -0,0 +1,133 @@
|
||||
:root {
|
||||
--bg: #0f1419;
|
||||
--card: #1a2332;
|
||||
--text: #e7ecf3;
|
||||
--muted: #8b9bb4;
|
||||
--accent: #3d8bfd;
|
||||
--accent-hover: #5ca0ff;
|
||||
--border: #2a3a52;
|
||||
}
|
||||
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
font-family: "Pretendard", "Apple SD Gothic Neo", system-ui, sans-serif;
|
||||
background: var(--bg);
|
||||
color: var(--text);
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.container {
|
||||
max-width: 720px;
|
||||
margin: 0 auto;
|
||||
padding: 2rem 1.25rem 3rem;
|
||||
}
|
||||
|
||||
header h1 {
|
||||
margin: 0 0 0.25rem;
|
||||
font-size: 1.75rem;
|
||||
}
|
||||
|
||||
.subtitle {
|
||||
color: var(--muted);
|
||||
margin: 0 0 1.5rem;
|
||||
}
|
||||
|
||||
.card {
|
||||
background: var(--card);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 12px;
|
||||
padding: 1.25rem;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
label {
|
||||
display: block;
|
||||
font-size: 0.875rem;
|
||||
color: var(--muted);
|
||||
margin-bottom: 0.35rem;
|
||||
}
|
||||
|
||||
textarea,
|
||||
input,
|
||||
select {
|
||||
width: 100%;
|
||||
padding: 0.65rem 0.75rem;
|
||||
border-radius: 8px;
|
||||
border: 1px solid var(--border);
|
||||
background: #0d1218;
|
||||
color: var(--text);
|
||||
font-size: 1rem;
|
||||
}
|
||||
|
||||
textarea {
|
||||
resize: vertical;
|
||||
min-height: 120px;
|
||||
}
|
||||
|
||||
.row {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 1rem;
|
||||
margin-top: 1rem;
|
||||
}
|
||||
|
||||
@media (max-width: 600px) {
|
||||
.row {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
|
||||
.field {
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
button {
|
||||
width: 100%;
|
||||
padding: 0.85rem;
|
||||
border: none;
|
||||
border-radius: 8px;
|
||||
background: var(--accent);
|
||||
color: #fff;
|
||||
font-size: 1rem;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
button:hover:not(:disabled) {
|
||||
background: var(--accent-hover);
|
||||
}
|
||||
|
||||
button:disabled {
|
||||
opacity: 0.6;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.status {
|
||||
margin-top: 0.75rem;
|
||||
font-size: 0.9rem;
|
||||
color: var(--muted);
|
||||
min-height: 1.25rem;
|
||||
}
|
||||
|
||||
footer {
|
||||
font-size: 0.8rem;
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
#resultSection h2 {
|
||||
margin-top: 0;
|
||||
font-size: 1.1rem;
|
||||
}
|
||||
|
||||
audio {
|
||||
width: 100%;
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
a {
|
||||
color: var(--accent);
|
||||
}
|
||||
Reference in New Issue
Block a user