Files
tts_site/app/server.py
claude c0fbc4d881 feat: 단어·문장 간격을 음수까지 허용(0 미만은 경계 무음 트리밍)
- word_gap 범위 -0.2~0.5, sentence_gap 범위 -0.5~1.5 로 확장(음수=더 붙임)
- 음수 간격은 이전 조각 끝 + 다음 조각 시작의 무음을 |gap| 초까지 잘라 붙이는
  _trim_end/start_silence + _append_unit 로 처리
- API Field 범위/설명, 프론트 슬라이더 min/스케일 라벨 갱신
2026-08-26 20:37:31 +09:00

107 lines
3.2 KiB
Python

"""한국어 우선 다중 엔진 TTS 웹 서비스.
- 로그인/과금 없이 무제한 사용
- 엔진(MeloTTS, Coqui GlowTTS-KSS 등) 선택 → 언어/목소리 선택
- 속도 / 피치 조절
- GPU 자동 사용
"""
from __future__ import annotations
import os
from fastapi import FastAPI, HTTPException
from fastapi.middleware.cors import CORSMiddleware
from fastapi.responses import FileResponse, Response
from fastapi.staticfiles import StaticFiles
from pydantic import BaseModel, Field
from .engines import (
default_engine_id,
get_engine,
list_engines,
warmup_all,
)
FRONTEND_DIR = os.path.join(os.path.dirname(os.path.dirname(__file__)), "frontend")
app = FastAPI(title="한국어 TTS 스튜디오", version="2.0.0")
app.add_middleware(
CORSMiddleware,
allow_origins=["*"],
allow_methods=["*"],
allow_headers=["*"],
)
class TTSRequest(BaseModel):
text: str = Field(..., description="읽을 텍스트")
engine: str | None = Field(None, description="엔진 ID (melo / coqui-kss 등)")
language: str = Field("KR", description="언어 코드")
speaker: str | None = Field(None, description="화자/모델 ID")
# 기본값은 각 범위의 가운데. 슬라이더 양 끝이 조절 가능한 최소/최대치.
speed: float = Field(1.25, ge=0.5, le=2.0, description="글자(음소) 발화 속도(0.5~2.0, 기본 1.25)")
pitch: float = Field(0.0, ge=-12.0, le=12.0, description="피치(반음)")
word_gap: float = Field(0.25, ge=-0.2, le=0.5, description="단어 사이 간격 초(-0.2~0.5, 음수=더 붙임, 기본 0.25)")
sentence_gap: float = Field(0.75, ge=-0.5, le=1.5, description="문장 사이 간격 초(-0.5~1.5, 음수=더 붙임, 기본 0.75)")
@app.on_event("startup")
def _startup() -> None:
warmup_all()
@app.get("/api/health")
def health() -> dict:
import torch
return {
"status": "ok",
"cuda": torch.cuda.is_available(),
"gpu": torch.cuda.get_device_name(0) if torch.cuda.is_available() else None,
"engines": [e.id for e in list_engines()],
"default_engine": default_engine_id(),
}
@app.get("/api/engines")
def engines() -> dict:
return {
"default": default_engine_id(),
"engines": [e.describe() for e in list_engines()],
}
@app.post("/api/tts")
def tts(req: TTSRequest) -> Response:
engine_id = req.engine or default_engine_id()
try:
engine = get_engine(engine_id)
wav = engine.synth_wav(
text=req.text,
language=req.language,
speaker=req.speaker,
speed=req.speed,
pitch=req.pitch,
word_gap=req.word_gap,
sentence_gap=req.sentence_gap,
)
except ValueError as exc:
raise HTTPException(status_code=400, detail=str(exc))
except Exception as exc: # pragma: no cover
raise HTTPException(status_code=500, detail=f"합성 실패: {exc}")
return Response(
content=wav,
media_type="audio/wav",
headers={"Content-Disposition": 'inline; filename="tts.wav"'},
)
@app.get("/")
def index() -> FileResponse:
return FileResponse(os.path.join(FRONTEND_DIR, "index.html"))
app.mount("/static", StaticFiles(directory=FRONTEND_DIR), name="static")