- 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>
273 lines
8.0 KiB
Python
273 lines
8.0 KiB
Python
"""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()
|