feat: Drama Studio 프로젝트 초기 구조 설정

- FastAPI 백엔드 (audio-studio-api)
- Next.js 프론트엔드 (audio-studio-ui)
- Qwen3-TTS 엔진 (audio-studio-tts)
- MusicGen 서비스 (audio-studio-musicgen)
- Docker Compose 개발/운영 환경

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
jungwoo choi
2026-01-26 11:39:38 +09:00
commit cc547372c0
70 changed files with 18399 additions and 0 deletions

View File

@ -0,0 +1,57 @@
# Audio Studio TTS Engine - GPU Dockerfile
# Qwen3-TTS 1.7B 모델 서빙
FROM nvidia/cuda:12.4.1-devel-ubuntu22.04
# 환경 변수
ENV PYTHONUNBUFFERED=1
ENV DEBIAN_FRONTEND=noninteractive
ENV CUDA_HOME=/usr/local/cuda
ENV PATH="${CUDA_HOME}/bin:${PATH}"
ENV LD_LIBRARY_PATH="${CUDA_HOME}/lib64:${LD_LIBRARY_PATH}"
# 시스템 패키지 설치
RUN apt-get update && apt-get install -y --no-install-recommends \
python3.11 \
python3.11-dev \
python3-pip \
git \
curl \
libsndfile1 \
ffmpeg \
&& rm -rf /var/lib/apt/lists/*
# Python 심볼릭 링크
RUN ln -sf /usr/bin/python3.11 /usr/bin/python && \
ln -sf /usr/bin/python3.11 /usr/bin/python3
# pip 업그레이드
RUN python -m pip install --upgrade pip setuptools wheel
# 작업 디렉토리
WORKDIR /app
# 의존성 설치 (캐시 활용)
COPY requirements.txt .
# PyTorch + CUDA 설치
RUN pip install --no-cache-dir torch torchaudio --index-url https://download.pytorch.org/whl/cu124
# FlashAttention 2 설치 (VRAM 최적화)
RUN pip install --no-cache-dir flash-attn --no-build-isolation || echo "FlashAttention 설치 실패, SDPA 사용"
# 나머지 의존성 설치
RUN pip install --no-cache-dir -r requirements.txt
# 소스 코드 복사
COPY app/ ./app/
# 포트 노출
EXPOSE 8001
# 헬스체크
HEALTHCHECK --interval=30s --timeout=30s --start-period=180s --retries=3 \
CMD curl -f http://localhost:8001/health || exit 1
# 서버 실행
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8001"]

View File

View File

@ -0,0 +1,280 @@
"""Audio Studio TTS Engine
Qwen3-TTS 기반 음성 합성 API 서버
"""
import logging
from contextlib import asynccontextmanager
from typing import Optional, List
from fastapi import FastAPI, HTTPException, UploadFile, File, Form
from fastapi.responses import Response, JSONResponse
from pydantic import BaseModel, Field
from app.services.qwen_tts import tts_service, PRESET_SPEAKERS, LANGUAGE_MAP
# 로깅 설정
logging.basicConfig(
level=logging.INFO,
format="%(asctime)s - %(name)s - %(levelname)s - %(message)s"
)
logger = logging.getLogger(__name__)
# ========================================
# Pydantic 모델
# ========================================
class SynthesizeRequest(BaseModel):
"""기본 TTS 합성 요청"""
text: str = Field(..., min_length=1, max_length=5000, description="합성할 텍스트")
speaker: str = Field(default="Chelsie", description="프리셋 스피커 이름")
language: str = Field(default="ko", description="언어 코드 (ko, en, ja 등)")
instruct: Optional[str] = Field(default=None, description="감정/스타일 지시")
class VoiceDesignRequest(BaseModel):
"""Voice Design 요청"""
text: str = Field(..., min_length=1, max_length=5000, description="합성할 텍스트")
instruct: str = Field(..., min_length=10, description="음성 디자인 프롬프트")
language: str = Field(default="ko", description="언어 코드")
class HealthResponse(BaseModel):
"""헬스체크 응답"""
status: str
initialized: bool
loaded_models: List[str]
device: str
class SpeakersResponse(BaseModel):
"""스피커 목록 응답"""
speakers: List[str]
class LanguagesResponse(BaseModel):
"""언어 목록 응답"""
languages: dict
# ========================================
# 앱 생명주기
# ========================================
@asynccontextmanager
async def lifespan(app: FastAPI):
"""앱 시작/종료 시 실행"""
# 시작 시 모델 초기화
logger.info("TTS 엔진 시작...")
try:
await tts_service.initialize(preload_models=["custom"])
logger.info("TTS 엔진 준비 완료")
except Exception as e:
logger.error(f"TTS 엔진 초기화 실패: {e}")
# 초기화 실패해도 서버는 시작 (lazy loading 시도)
yield
# 종료 시 정리
logger.info("TTS 엔진 종료")
# ========================================
# FastAPI 앱
# ========================================
app = FastAPI(
title="Audio Studio TTS Engine",
description="Qwen3-TTS 기반 음성 합성 API",
version="0.1.0",
lifespan=lifespan,
)
# ========================================
# API 엔드포인트
# ========================================
@app.get("/health", response_model=HealthResponse)
async def health_check():
"""헬스체크 엔드포인트"""
return HealthResponse(
status="healthy",
initialized=tts_service.is_initialized(),
loaded_models=tts_service.get_loaded_models(),
device=tts_service.device,
)
@app.get("/speakers", response_model=SpeakersResponse)
async def get_speakers():
"""프리셋 스피커 목록 조회"""
return SpeakersResponse(speakers=tts_service.get_preset_speakers())
@app.get("/languages", response_model=LanguagesResponse)
async def get_languages():
"""지원 언어 목록 조회"""
return LanguagesResponse(languages=tts_service.get_supported_languages())
@app.post("/synthesize")
async def synthesize(request: SynthesizeRequest):
"""프리셋 음성으로 TTS 합성
CustomVoice 모델을 사용하여 텍스트를 음성으로 변환합니다.
"""
try:
# 스피커 유효성 검사
if request.speaker not in PRESET_SPEAKERS:
raise HTTPException(
status_code=400,
detail=f"Invalid speaker. Available: {PRESET_SPEAKERS}"
)
# 언어 유효성 검사
if request.language not in LANGUAGE_MAP:
raise HTTPException(
status_code=400,
detail=f"Invalid language. Available: {list(LANGUAGE_MAP.keys())}"
)
# TTS 합성
audio_bytes, sr = await tts_service.synthesize_custom(
text=request.text,
speaker=request.speaker,
language=request.language,
instruct=request.instruct,
)
return Response(
content=audio_bytes,
media_type="audio/wav",
headers={
"X-Sample-Rate": str(sr),
"Content-Disposition": 'attachment; filename="output.wav"',
}
)
except HTTPException:
raise
except Exception as e:
logger.error(f"TTS 합성 실패: {e}")
raise HTTPException(status_code=500, detail=f"TTS synthesis failed: {str(e)}")
@app.post("/voice-clone")
async def voice_clone(
text: str = Form(..., description="합성할 텍스트"),
ref_text: str = Form(..., description="레퍼런스 오디오의 트랜스크립트"),
language: str = Form(default="ko", description="언어 코드"),
ref_audio: UploadFile = File(..., description="레퍼런스 오디오 파일"),
):
"""Voice Clone으로 TTS 합성
레퍼런스 오디오를 기반으로 목소리를 복제하여 새 텍스트를 합성합니다.
3초 이상의 오디오가 권장됩니다.
"""
try:
# 언어 유효성 검사
if language not in LANGUAGE_MAP:
raise HTTPException(
status_code=400,
detail=f"Invalid language. Available: {list(LANGUAGE_MAP.keys())}"
)
# 오디오 파일 읽기
audio_content = await ref_audio.read()
if len(audio_content) < 1000: # 최소 크기 체크
raise HTTPException(
status_code=400,
detail="Reference audio is too small"
)
# Voice Clone 합성
audio_bytes, sr = await tts_service.synthesize_clone(
text=text,
ref_audio=audio_content,
ref_text=ref_text,
language=language,
)
return Response(
content=audio_bytes,
media_type="audio/wav",
headers={
"X-Sample-Rate": str(sr),
"Content-Disposition": 'attachment; filename="cloned.wav"',
}
)
except HTTPException:
raise
except Exception as e:
logger.error(f"Voice Clone 실패: {e}")
raise HTTPException(status_code=500, detail=f"Voice clone failed: {str(e)}")
@app.post("/voice-design")
async def voice_design(request: VoiceDesignRequest):
"""Voice Design으로 TTS 합성
텍스트 프롬프트를 기반으로 새로운 음성을 생성합니다.
예: "30대 남성, 부드럽고 차분한 목소리"
"""
try:
# 언어 유효성 검사
if request.language not in LANGUAGE_MAP:
raise HTTPException(
status_code=400,
detail=f"Invalid language. Available: {list(LANGUAGE_MAP.keys())}"
)
# Voice Design 합성
audio_bytes, sr = await tts_service.synthesize_design(
text=request.text,
instruct=request.instruct,
language=request.language,
)
return Response(
content=audio_bytes,
media_type="audio/wav",
headers={
"X-Sample-Rate": str(sr),
"Content-Disposition": 'attachment; filename="designed.wav"',
}
)
except HTTPException:
raise
except Exception as e:
logger.error(f"Voice Design 실패: {e}")
raise HTTPException(status_code=500, detail=f"Voice design failed: {str(e)}")
@app.post("/load-model")
async def load_model(model_type: str):
"""특정 모델 로드 (관리용)
Args:
model_type: custom | base | design
"""
valid_types = ["custom", "base", "design"]
if model_type not in valid_types:
raise HTTPException(
status_code=400,
detail=f"Invalid model type. Available: {valid_types}"
)
try:
await tts_service._load_model(model_type)
return JSONResponse({
"status": "loaded",
"model_type": model_type,
"loaded_models": tts_service.get_loaded_models(),
})
except Exception as e:
logger.error(f"모델 로드 실패: {e}")
raise HTTPException(status_code=500, detail=f"Model load failed: {str(e)}")

View File

@ -0,0 +1,272 @@
"""Qwen3-TTS 모델 서비스 래퍼
Voice Clone, Voice Design, Custom Voice 기능 제공
"""
import os
import io
import logging
from typing import Optional, Tuple, List
from enum import Enum
import torch
import soundfile as sf
import numpy as np
logger = logging.getLogger(__name__)
class VoiceType(str, Enum):
"""지원하는 음성 타입"""
CLONE = "clone" # 레퍼런스 오디오 기반 복제
DESIGN = "design" # 텍스트 프롬프트 기반 설계
CUSTOM = "custom" # 프리셋 음성
# CustomVoice 모델의 프리셋 스피커
PRESET_SPEAKERS = [
"Chelsie", # 여성, 밝고 활기찬
"Ethan", # 남성, 차분한
"Vivian", # 여성, 부드러운
"Benjamin", # 남성, 깊은
"Aurora", # 여성, 우아한
"Oliver", # 남성, 친근한
"Luna", # 여성, 따뜻한
"Jasper", # 남성, 전문적인
"Aria", # 여성, 표현력 있는
]
# 언어 코드 매핑
LANGUAGE_MAP = {
"ko": "Korean",
"en": "English",
"ja": "Japanese",
"zh": "Chinese",
"de": "German",
"fr": "French",
"ru": "Russian",
"pt": "Portuguese",
"es": "Spanish",
"it": "Italian",
}
class QwenTTSService:
"""Qwen3-TTS 통합 서비스
세 가지 모델을 관리:
- Base: Voice Clone용
- CustomVoice: 프리셋 음성용
- VoiceDesign: 음성 설계용
"""
def __init__(self):
self.device = "cuda:0" if torch.cuda.is_available() else "cpu"
self.dtype = torch.bfloat16 if torch.cuda.is_available() else torch.float32
# 모델 ID (환경변수에서 가져오거나 기본값 사용)
self.model_ids = {
"base": os.getenv("MODEL_ID", "Qwen/Qwen3-TTS-12Hz-1.7B-Base"),
"custom": os.getenv("CLONE_MODEL_ID", "Qwen/Qwen3-TTS-12Hz-1.7B-CustomVoice"),
"design": os.getenv("DESIGN_MODEL_ID", "Qwen/Qwen3-TTS-12Hz-1.7B-VoiceDesign"),
}
# 모델 인스턴스 (lazy loading)
self._models = {}
self._initialized = False
async def initialize(self, preload_models: Optional[List[str]] = None):
"""모델 초기화 (서버 시작 시 호출)
Args:
preload_models: 미리 로드할 모델 리스트 (예: ["custom", "design"])
"""
if self._initialized:
return
logger.info(f"Qwen3-TTS 서비스 초기화 (device: {self.device})")
# 기본적으로 CustomVoice 모델만 로드 (가장 많이 사용)
if preload_models is None:
preload_models = ["custom"]
for model_type in preload_models:
await self._load_model(model_type)
self._initialized = True
logger.info("Qwen3-TTS 서비스 초기화 완료")
async def _load_model(self, model_type: str):
"""모델 로딩 (lazy loading)"""
if model_type in self._models:
return self._models[model_type]
model_id = self.model_ids.get(model_type)
if not model_id:
raise ValueError(f"Unknown model type: {model_type}")
logger.info(f"모델 로딩 중: {model_id}")
try:
from qwen_tts import Qwen3TTSModel
# FlashAttention 사용 가능 여부 확인
attn_impl = "flash_attention_2"
try:
import flash_attn
except ImportError:
attn_impl = "sdpa"
logger.warning("FlashAttention 미설치, SDPA 사용")
model = Qwen3TTSModel.from_pretrained(
model_id,
device_map=self.device,
dtype=self.dtype,
attn_implementation=attn_impl,
)
self._models[model_type] = model
logger.info(f"모델 로드 완료: {model_id}")
return model
except Exception as e:
logger.error(f"모델 로드 실패: {model_id} - {e}")
raise
def _get_language(self, lang_code: str) -> str:
"""언어 코드를 모델 언어명으로 변환"""
return LANGUAGE_MAP.get(lang_code, "English")
def _to_wav_bytes(self, audio: np.ndarray, sample_rate: int) -> bytes:
"""numpy 배열을 WAV 바이트로 변환"""
buffer = io.BytesIO()
sf.write(buffer, audio, sample_rate, format='WAV')
buffer.seek(0)
return buffer.read()
async def synthesize_custom(
self,
text: str,
speaker: str = "Chelsie",
language: str = "ko",
instruct: Optional[str] = None,
) -> Tuple[bytes, int]:
"""프리셋 음성으로 TTS 합성
Args:
text: 합성할 텍스트
speaker: 프리셋 스피커 이름
language: 언어 코드 (ko, en, ja 등)
instruct: 감정/스타일 지시 (선택)
Returns:
(WAV 바이트, 샘플레이트)
"""
model = await self._load_model("custom")
lang = self._get_language(language)
logger.info(f"Custom TTS 합성: speaker={speaker}, lang={lang}, text_len={len(text)}")
wavs, sr = model.generate_custom_voice(
text=text,
language=lang,
speaker=speaker,
instruct=instruct,
)
audio_bytes = self._to_wav_bytes(wavs[0], sr)
return audio_bytes, sr
async def synthesize_clone(
self,
text: str,
ref_audio: bytes,
ref_text: str,
language: str = "ko",
) -> Tuple[bytes, int]:
"""Voice Clone으로 TTS 합성
Args:
text: 합성할 텍스트
ref_audio: 레퍼런스 오디오 바이트 (3초 이상 권장)
ref_text: 레퍼런스 오디오의 트랜스크립트
language: 언어 코드
Returns:
(WAV 바이트, 샘플레이트)
"""
model = await self._load_model("base")
lang = self._get_language(language)
# 레퍼런스 오디오를 임시 파일로 저장
import tempfile
with tempfile.NamedTemporaryFile(suffix=".wav", delete=False) as f:
f.write(ref_audio)
ref_audio_path = f.name
try:
logger.info(f"Voice Clone 합성: lang={lang}, text_len={len(text)}")
wavs, sr = model.generate_voice_clone(
text=text,
language=lang,
ref_audio=ref_audio_path,
ref_text=ref_text,
)
audio_bytes = self._to_wav_bytes(wavs[0], sr)
return audio_bytes, sr
finally:
# 임시 파일 삭제
os.unlink(ref_audio_path)
async def synthesize_design(
self,
text: str,
instruct: str,
language: str = "ko",
) -> Tuple[bytes, int]:
"""Voice Design으로 TTS 합성
Args:
text: 합성할 텍스트
instruct: 음성 디자인 프롬프트 (예: "30대 남성, 부드럽고 차분한 목소리")
language: 언어 코드
Returns:
(WAV 바이트, 샘플레이트)
"""
model = await self._load_model("design")
lang = self._get_language(language)
logger.info(f"Voice Design 합성: lang={lang}, instruct={instruct[:50]}...")
wavs, sr = model.generate_voice_design(
text=text,
language=lang,
instruct=instruct,
)
audio_bytes = self._to_wav_bytes(wavs[0], sr)
return audio_bytes, sr
def get_preset_speakers(self) -> List[str]:
"""프리셋 스피커 목록 반환"""
return PRESET_SPEAKERS.copy()
def get_supported_languages(self) -> dict:
"""지원 언어 목록 반환"""
return LANGUAGE_MAP.copy()
def is_initialized(self) -> bool:
"""초기화 상태 확인"""
return self._initialized
def get_loaded_models(self) -> List[str]:
"""현재 로드된 모델 목록"""
return list(self._models.keys())
# 싱글톤 인스턴스
tts_service = QwenTTSService()

View File

@ -0,0 +1,29 @@
# Audio Studio TTS Engine - Dependencies
# FastAPI
fastapi==0.115.6
uvicorn[standard]==0.34.0
python-multipart==0.0.20
# Qwen3-TTS
qwen-tts>=0.0.5
# PyTorch (CUDA 12.x)
--extra-index-url https://download.pytorch.org/whl/cu124
torch>=2.5.0
torchaudio>=2.5.0
# Audio Processing
soundfile>=0.12.1
numpy>=1.26.0
scipy>=1.14.0
librosa>=0.10.2
# FlashAttention 2 (optional, 별도 설치 권장)
# flash-attn>=2.7.0
# Utilities
httpx>=0.28.0
pydantic>=2.10.0
pydantic-settings>=2.7.0
python-dotenv>=1.0.1