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:
0
audio-studio-tts/app/__init__.py
Normal file
0
audio-studio-tts/app/__init__.py
Normal file
280
audio-studio-tts/app/main.py
Normal file
280
audio-studio-tts/app/main.py
Normal 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)}")
|
||||
0
audio-studio-tts/app/services/__init__.py
Normal file
0
audio-studio-tts/app/services/__init__.py
Normal file
272
audio-studio-tts/app/services/qwen_tts.py
Normal file
272
audio-studio-tts/app/services/qwen_tts.py
Normal 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()
|
||||
Reference in New Issue
Block a user