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,352 @@
# Project Knowledge
이 문서는 프로젝트의 개발 표준 및 패턴을 정의합니다.
Claude.ai Projects 또는 Claude Code에서 참조하여 일관된 코드를 작성하세요.
---
## 1. 프로젝트 개요
### 기술 스택
- **Frontend**: Next.js 16 + React 19 + TypeScript + Tailwind CSS 4 + shadcn/ui
- **Backend**: FastAPI + Python 3.11 + Pydantic v2
- **Database**: MongoDB 7.0 (motor async driver) + Redis 7
- **AI**: Claude API (Anthropic) + OpenAI API
- **Containerization**: Docker + Docker Compose
- **Repository**: Gitea (http://gitea.yakenator.io/yakenator/)
---
## 2. Docker 배포 규칙
### Dockerfile 패턴 (Python Worker)
```dockerfile
FROM python:3.11-slim
WORKDIR /app
RUN apt-get update && apt-get install -y --no-install-recommends git && rm -rf /var/lib/apt/lists/*
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
COPY *.py .
CMD ["python", "worker.py"]
```
### docker-compose 서비스 패턴
```yaml
services:
{서비스명}:
build:
context: ./repos/{서비스명}
dockerfile: Dockerfile
container_name: {프로젝트}-{서비스명}
restart: unless-stopped
environment:
- REDIS_URL=redis://redis:6379
- MONGODB_URL=mongodb://${MONGO_USER}:${MONGO_PASSWORD}@mongodb:27017/
- DB_NAME={데이터베이스명}
depends_on:
redis:
condition: service_healthy
mongodb:
condition: service_healthy
networks:
- {프로젝트}-network
```
### 네이밍 규칙
- 컨테이너: `{프로젝트}-{서비스명}`
- 볼륨: `{프로젝트}_{데이터유형}_data`
- 네트워크: `{프로젝트}-network`
---
## 3. 한국어 개발 컨벤션
### Docstring
```python
async def get_data(self, name: str, context: List[str] = None) -> DataInfo:
"""
데이터 조회 (context 기반 후보 선택)
Args:
name: 데이터 이름
context: 컨텍스트 키워드
Returns:
DataInfo 객체
"""
```
### 로깅 메시지 (한글 + 영문 혼용)
```python
logger.info(f"Found {len(items)} item(s) for '{name}'")
logger.warning(f"Operation failed (non-critical): {e}")
```
### 커밋 메시지
```
feat: 기능 설명
- 변경사항 1
- 변경사항 2
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
```
---
## 4. FastAPI 패턴
### 앱 초기화
```python
from fastapi import FastAPI, HTTPException
from pydantic import BaseModel
app = FastAPI(
title="{서비스명} API",
description="서비스 설명",
version="1.0.0"
)
```
### Pydantic 모델
```python
class CreateRequest(BaseModel):
name: str
description: Optional[str] = ""
data_type: Optional[str] = "default"
```
### 엔드포인트
```python
@app.get("/health")
async def health_check():
return {"status": "healthy", "timestamp": datetime.now().isoformat()}
@app.post("/create")
async def create_item(request: CreateRequest):
try:
# 처리 로직
return {"success": True, "data": result}
except Exception as e:
raise HTTPException(status_code=500, detail=str(e))
```
---
## 5. React/Next.js 패턴
### 디렉토리 구조
```
src/
├── app/ # Next.js App Router
├── components/
│ ├── ui/ # shadcn/ui 컴포넌트
│ └── providers.tsx # Context Providers
├── hooks/ # 커스텀 훅
├── lib/utils.ts # cn() 등 유틸리티
└── types/ # TypeScript 타입
```
### cn() 유틸리티
```typescript
import { clsx, type ClassValue } from "clsx"
import { twMerge } from "tailwind-merge"
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs))
}
```
### 폼 패턴 (react-hook-form + zod)
```typescript
const formSchema = z.object({
title: z.string().min(1, "제목을 입력하세요"),
content: z.string().min(10, "내용은 10자 이상"),
})
const form = useForm<z.infer<typeof formSchema>>({
resolver: zodResolver(formSchema),
})
```
---
## 6. MongoDB 패턴
### 연결 (motor async)
```python
from motor.motor_asyncio import AsyncIOMotorClient
client = AsyncIOMotorClient(os.getenv("MONGODB_URL"))
db = client[os.getenv("DB_NAME")]
```
### 인덱스 생성
```python
await collection.create_index("unique_field", unique=True, sparse=True)
await collection.create_index("name")
await collection.create_index("updated_at")
```
### Upsert 패턴
```python
result = await collection.update_one(
{"unique_field": data["unique_field"]},
{
"$set": update_doc,
"$setOnInsert": {"created_at": datetime.now()}
},
upsert=True
)
```
### TTL 캐시
```python
CACHE_TTL_DAYS = 7
def _is_cache_fresh(self, cached_data: Dict) -> bool:
updated_at = cached_data.get("updated_at")
expiry_date = updated_at + timedelta(days=CACHE_TTL_DAYS)
return datetime.now() < expiry_date
```
---
## 7. AI API 통합
### Claude API
```python
from anthropic import AsyncAnthropic
client = AsyncAnthropic(api_key=os.getenv("CLAUDE_API_KEY"))
response = await client.messages.create(
model="claude-sonnet-4-20250514",
max_tokens=8192,
messages=[{"role": "user", "content": prompt}]
)
```
### 프롬프트 템플릿 (MongoDB 저장)
```python
async def _get_prompt_template(self) -> str:
# 캐시 확인
if self._cached_prompt and time.time() - self._prompt_cache_time < 300:
return self._cached_prompt
# DB에서 조회
custom_prompt = await self.db.prompts.find_one({"service": "{서비스명}"})
return custom_prompt.get("content") if custom_prompt else self._default_prompt
```
---
## 8. 환경 변수
```bash
# .env
MONGO_USER=admin
MONGO_PASSWORD={비밀번호}
REDIS_URL=redis://redis:6379
CLAUDE_API_KEY=sk-ant-...
OPENAI_API_KEY=sk-...
JWT_SECRET_KEY={시크릿키}
DB_NAME={데이터베이스명}
```
---
## 9. 헬스체크
### FastAPI
```python
@app.get("/health")
async def health():
return {"status": "healthy"}
```
### Docker Compose
```yaml
healthcheck:
test: ["CMD", "curl", "-f", "http://localhost:8000/health"]
interval: 30s
timeout: 10s
retries: 3
```
---
## 10. 데이터베이스 백업 정책
### 백업 규칙
- **주기**: 하루에 한 번 (daily)
- **보관 기간**: 최소 7일
- **백업 위치**: 프로젝트 루트의 `./backups/` 디렉토리
### MongoDB 백업 명령어
```bash
BACKUP_NAME="mongodb_backup_$(date +%Y%m%d_%H%M%S)"
docker exec {프로젝트}-mongodb mongodump \
--uri="mongodb://{user}:{password}@localhost:27017" \
--authenticationDatabase=admin \
--out="/tmp/$BACKUP_NAME"
docker cp {프로젝트}-mongodb:/tmp/$BACKUP_NAME ./backups/
```
### 복원 명령어
```bash
docker cp ./backups/$BACKUP_NAME {프로젝트}-mongodb:/tmp/
docker exec {프로젝트}-mongodb mongorestore \
--uri="mongodb://{user}:{password}@localhost:27017" \
--authenticationDatabase=admin \
"/tmp/$BACKUP_NAME"
```
### 자동화 (cron)
```bash
# crontab -e
0 2 * * * /path/to/backup-script.sh # 매일 새벽 2시
```
### 주의사항
- 새 프로젝트 생성 시 반드시 백업 스크립트 설정
- 백업 디렉토리는 .gitignore에 추가
- 중요 데이터는 외부 스토리지에 추가 백업 권장
---
## 11. Gitea 리포지토리 관리
### 서버 정보
- **URL**: http://gitea.yakenator.io
- **사용자**: yakenator
- **비밀번호**: asdfg23we
### 새 리포지토리 생성 (API)
```bash
curl -X POST "http://gitea.yakenator.io/api/v1/user/repos" \
-H "Content-Type: application/json" \
-u "yakenator:asdfg23we" \
-d '{"name": "{repo-name}", "private": false}'
```
### 새 프로젝트 초기화 및 푸시
```bash
# 1. Git 초기화
git init
# 2. 첫 커밋
git add -A
git commit -m "Initial commit"
# 3. Gitea에 리포지토리 생성 (위 API 사용)
# 4. Remote 추가 및 푸시
git remote add origin http://yakenator:asdfg23we@gitea.yakenator.io/yakenator/{repo-name}.git
git branch -M main
git push -u origin main
```
---
이 문서를 Claude.ai Projects의 "Project Knowledge"에 추가하면 프로젝트 컨텍스트를 공유할 수 있습니다.

7
.claude/settings.json Normal file
View File

@ -0,0 +1,7 @@
{
"permissions": {
"allow": [
"Bash(git fetch:*)"
]
}
}

58
.claude/skills/README.md Normal file
View File

@ -0,0 +1,58 @@
# Project Skills
프로젝트 개발 표준 및 패턴 가이드입니다.
## 중요도 1 (핵심)
| Skill | 설명 | 파일 |
|-------|------|------|
| deployment-standards | Docker 배포 규칙 | [deployment-standards.md](deployment-standards.md) |
| project-stack | React+Next.js+FastAPI 기본 구조 | [project-stack.md](project-stack.md) |
| korean-dev-conventions | 한국어 주석, 에러 처리 등 | [korean-dev-conventions.md](korean-dev-conventions.md) |
| gpu-local-models | GPU 인프라 및 로컬 LLM 서빙 | [gpu-local-models.md](gpu-local-models.md) |
| flume-architecture | Flume 파이프라인 시스템 설계 | [flume-architecture.md](flume-architecture.md) |
## 중요도 2 (주요)
| Skill | 설명 | 파일 |
|-------|------|------|
| ai-api-integration | AI 모델 FastAPI 통합 패턴 | [ai-api-integration.md](ai-api-integration.md) |
| api-design-standards | RESTful API 설계 규칙 | [api-design-standards.md](api-design-standards.md) |
| frontend-component-patterns | React 컴포넌트 패턴 | [frontend-component-patterns.md](frontend-component-patterns.md) |
| database-patterns | MongoDB 설계 패턴 | [database-patterns.md](database-patterns.md) |
## 중요도 3 (참고)
| Skill | 설명 | 파일 |
|-------|------|------|
| infrastructure-setup | MAAS, K8s, Rancher 인프라 구축 | [infrastructure-setup.md](infrastructure-setup.md) |
| testing-standards | Jest/pytest 테스트 작성 | [testing-standards.md](testing-standards.md) |
| monitoring-logging | Prometheus/Grafana 모니터링 | [monitoring-logging.md](monitoring-logging.md) |
| gitea-workflow | Gitea 리포지토리 관리 | [gitea-workflow.md](gitea-workflow.md) |
## 사용법
Claude Code에서 작업 시 이 Skills를 참조하여 프로젝트 표준에 맞게 코드를 작성합니다.
### 기술 스택 요약
- **Frontend**: Next.js 16 + React 19 + Tailwind CSS 4 + shadcn/ui
- **Backend**: FastAPI + Python 3.11
- **Database**: MongoDB 7.0 + Redis 7
- **Containerization**: Docker + Kubernetes
- **AI**: Claude API + OpenAI API + 로컬 LLM (vLLM)
- **GPU**: V100 16GB×8, V100 32GB×4, RTX 3090 24GB×2 (총 304GB VRAM)
- **Repository**: Gitea (http://gitea.yakenator.io/yakenator/)
### 플레이스홀더
템플릿에서 다음 플레이스홀더를 실제 값으로 교체하세요:
| 플레이스홀더 | 설명 |
|-------------|------|
| `{프로젝트}` | 프로젝트명 |
| `{서비스명}` | 서비스명 |
| `{데이터베이스명}` | DB명 |
| `{user}` | DB 사용자 |
| `{password}` | DB 비밀번호 |
| `{호스트포트}` | 호스트 포트 |

View File

@ -0,0 +1,213 @@
# AI API 통합 패턴 (AI API Integration)
이 프로젝트의 AI 모델 API 통합 패턴입니다.
## Claude API 통합
### 클라이언트 초기화
```python
from anthropic import AsyncAnthropic
class AIArticleGeneratorWorker:
def __init__(self):
self.claude_api_key = os.getenv("CLAUDE_API_KEY")
self.claude_client = None
async def start(self):
if self.claude_api_key:
self.claude_client = AsyncAnthropic(api_key=self.claude_api_key)
else:
logger.error("Claude API key not configured")
return
```
### API 호출 패턴
```python
async def _call_claude_api(self, prompt: str) -> str:
"""Claude API 호출"""
try:
response = await self.claude_client.messages.create(
model="claude-sonnet-4-20250514", # 또는 claude-3-5-sonnet-latest
max_tokens=8192,
messages=[
{
"role": "user",
"content": prompt
}
]
)
return response.content[0].text
except Exception as e:
logger.error(f"Claude API error: {e}")
raise
```
### JSON 응답 파싱
```python
async def _generate_article(self, prompt: str) -> Dict[str, Any]:
"""기사 생성 및 JSON 파싱"""
response_text = await self._call_claude_api(prompt)
# JSON 블록 추출
json_match = re.search(r'```json\s*(.*?)\s*```', response_text, re.DOTALL)
if json_match:
json_str = json_match.group(1)
else:
json_str = response_text
return json.loads(json_str)
```
## 프롬프트 관리
### MongoDB 기반 동적 프롬프트
```python
class AIArticleGeneratorWorker:
def __init__(self):
self._cached_prompt = None
self._prompt_cache_time = None
self._prompt_cache_ttl = 300 # 5분 캐시
self._default_prompt = """..."""
async def _get_prompt_template(self) -> str:
"""MongoDB에서 프롬프트 템플릿을 가져옴 (캐시 적용)"""
import time
current_time = time.time()
# 캐시가 유효하면 캐시된 프롬프트 반환
if (self._cached_prompt and self._prompt_cache_time and
current_time - self._prompt_cache_time < self._prompt_cache_ttl):
return self._cached_prompt
try:
prompts_collection = self.db.prompts
custom_prompt = await prompts_collection.find_one({"service": "article_generator"})
if custom_prompt and custom_prompt.get("content"):
self._cached_prompt = custom_prompt["content"]
logger.info("Using custom prompt from database")
else:
self._cached_prompt = self._default_prompt
logger.info("Using default prompt")
self._prompt_cache_time = current_time
return self._cached_prompt
except Exception as e:
logger.warning(f"Error fetching prompt from database: {e}, using default")
return self._default_prompt
```
### 프롬프트 템플릿 형식
```python
prompt_template = """Write a comprehensive article based on the following news information.
Keyword: {keyword}
News Information:
Title: {title}
Summary: {summary}
Link: {link}
{search_text}
Please write in the following JSON format:
{{
"title": "Article title",
"summary": "One-line summary",
"subtopics": [
{{
"title": "Subtopic 1",
"content": ["Paragraph 1", "Paragraph 2", ...]
}}
],
"categories": ["Category1", "Category2"],
"entities": {{
"people": [{{"name": "Name", "context": ["role", "company"]}}],
"organizations": [{{"name": "Name", "context": ["industry", "type"]}}]
}}
}}
Requirements:
- Structure with 2-5 subtopics
- Professional and objective tone
- Write in English
"""
```
## OpenAI API 통합 (참고)
### 클라이언트 초기화
```python
from openai import AsyncOpenAI
class OpenAIService:
def __init__(self):
self.api_key = os.getenv("OPENAI_API_KEY")
self.client = AsyncOpenAI(api_key=self.api_key)
async def generate(self, prompt: str) -> str:
response = await self.client.chat.completions.create(
model="gpt-4o",
messages=[{"role": "user", "content": prompt}],
max_tokens=4096
)
return response.choices[0].message.content
```
## 에러 처리 및 재시도
### 재시도 패턴
```python
import asyncio
from typing import Optional
async def _call_with_retry(
self,
func,
max_retries: int = 3,
initial_delay: float = 1.0
) -> Optional[Any]:
"""지수 백오프 재시도"""
delay = initial_delay
for attempt in range(max_retries):
try:
return await func()
except Exception as e:
if attempt == max_retries - 1:
logger.error(f"All {max_retries} attempts failed: {e}")
raise
logger.warning(f"Attempt {attempt + 1} failed: {e}, retrying in {delay}s")
await asyncio.sleep(delay)
delay *= 2 # 지수 백오프
```
## 환경 변수
```bash
# .env 파일
CLAUDE_API_KEY=sk-ant-...
OPENAI_API_KEY=sk-...
# docker-compose.yml
environment:
- CLAUDE_API_KEY=${CLAUDE_API_KEY}
- OPENAI_API_KEY=${OPENAI_API_KEY}
```
## 비용 최적화
### 토큰 제한
```python
response = await self.claude_client.messages.create(
model="claude-sonnet-4-20250514",
max_tokens=8192, # 출력 토큰 제한
messages=[...]
)
```
### 캐싱 전략
- MongoDB에 응답 캐시 저장
- TTL 기반 캐시 만료
- 동일 입력에 대한 중복 호출 방지

View File

@ -0,0 +1,269 @@
# RESTful API 설계 규칙 (API Design Standards)
이 프로젝트의 RESTful API 설계 패턴입니다.
## FastAPI 기본 구조
### 앱 초기화
```python
from fastapi import FastAPI, HTTPException
from pydantic import BaseModel
from typing import Optional
app = FastAPI(
title="Biocode API",
description="바이오코드 성격 분석 및 유명인 등록 API",
version="1.0.0"
)
```
### Pydantic 모델 정의
```python
class AddFamousPersonRequest(BaseModel):
year: int
month: int
day: int
name: str
description: Optional[str] = ""
entity_type: Optional[str] = "person" # "person" or "organization"
class BiocodeResponse(BaseModel):
biocode: str
g_code: str
s_code: str
personality: dict
```
## 엔드포인트 패턴
### Health Check
```python
@app.get("/health")
async def health_check():
"""헬스체크 엔드포인트"""
return {"status": "healthy", "timestamp": datetime.now().isoformat()}
```
### GET - 조회
```python
@app.get("/biocode/{year}/{month}/{day}")
async def get_biocode(year: int, month: int, day: int):
"""생년월일로 바이오코드 조회"""
try:
biocode = calculate_biocode(year, month, day)
g_code = f"g{biocode[:2]}"
s_code = biocode[2:]
if g_code not in biocode_data:
raise HTTPException(status_code=404, detail=f"G code {g_code} not found")
personality = biocode_data[g_code].get("codes", {}).get(biocode, {})
return {
"biocode": biocode,
"g_code": g_code,
"s_code": s_code,
"personality": personality
}
except Exception as e:
raise HTTPException(status_code=500, detail=str(e))
```
### POST - 생성
```python
@app.post("/add_famous_person")
async def add_famous_person(request: AddFamousPersonRequest):
"""유명인/조직 등록"""
try:
biocode = calculate_biocode(request.year, request.month, request.day)
g_code = f"g{biocode[:2]}"
if g_code not in biocode_data:
raise HTTPException(status_code=404, detail=f"G code {g_code} not found")
# 엔티티 타입에 따라 저장 위치 결정
is_organization = request.entity_type == "organization"
target_field = "famousOrganizations" if is_organization else "famousPeople"
# 데이터 추가
new_entry = {
"name": request.name,
"code": biocode,
"description": request.description or ""
}
if target_field not in biocode_data[g_code]["codes"][biocode]:
biocode_data[g_code]["codes"][biocode][target_field] = []
biocode_data[g_code]["codes"][biocode][target_field].append(new_entry)
# JSON 파일 저장
save_biocode_data(g_code)
return {
"success": True,
"biocode": biocode,
"entity_type": request.entity_type,
"name": request.name
}
except Exception as e:
raise HTTPException(status_code=500, detail=str(e))
```
## 라우터 분리 패턴
### 메인 앱 (main.py)
```python
from fastapi import FastAPI
from app.routers import users, articles
app = FastAPI(title="News Engine API")
app.include_router(users.router, prefix="/api/users", tags=["users"])
app.include_router(articles.router, prefix="/api/articles", tags=["articles"])
@app.get("/health")
async def health():
return {"status": "healthy"}
```
### 라우터 (routers/users.py)
```python
from fastapi import APIRouter, HTTPException, Depends
from app.models.user import UserCreate, UserResponse
from app.database import get_database
router = APIRouter()
@router.post("/", response_model=UserResponse)
async def create_user(user: UserCreate, db = Depends(get_database)):
"""새 사용자 생성"""
existing = await db.users.find_one({"email": user.email})
if existing:
raise HTTPException(status_code=400, detail="Email already registered")
result = await db.users.insert_one(user.dict())
return UserResponse(id=str(result.inserted_id), **user.dict())
@router.get("/{user_id}", response_model=UserResponse)
async def get_user(user_id: str, db = Depends(get_database)):
"""사용자 조회"""
user = await db.users.find_one({"_id": ObjectId(user_id)})
if not user:
raise HTTPException(status_code=404, detail="User not found")
return UserResponse(**user)
```
## 에러 처리
### HTTPException 사용
```python
from fastapi import HTTPException
# 404 Not Found
raise HTTPException(status_code=404, detail="Resource not found")
# 400 Bad Request
raise HTTPException(status_code=400, detail="Invalid input data")
# 500 Internal Server Error
raise HTTPException(status_code=500, detail=str(e))
```
### 전역 예외 처리
```python
from fastapi import Request
from fastapi.responses import JSONResponse
@app.exception_handler(Exception)
async def global_exception_handler(request: Request, exc: Exception):
return JSONResponse(
status_code=500,
content={"detail": "Internal server error", "error": str(exc)}
)
```
## 응답 형식
### 성공 응답
```json
{
"success": true,
"data": { ... },
"message": "Operation completed successfully"
}
```
### 에러 응답
```json
{
"detail": "Error message here"
}
```
### 목록 응답
```json
{
"items": [...],
"total": 100,
"page": 1,
"page_size": 20
}
```
## CORS 설정
```python
from fastapi.middleware.cors import CORSMiddleware
app.add_middleware(
CORSMiddleware,
allow_origins=["http://localhost:3000", "https://yourdomain.com"],
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],
)
```
## API 버저닝
```python
# URL 기반 버저닝
app.include_router(v1_router, prefix="/api/v1")
app.include_router(v2_router, prefix="/api/v2")
# 또는 헤더 기반
@app.get("/api/resource")
async def get_resource(api_version: str = Header(default="v1")):
if api_version == "v2":
return v2_response()
return v1_response()
```
## 인증
### JWT 토큰 검증
```python
from fastapi import Depends, HTTPException
from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials
import jwt
security = HTTPBearer()
async def verify_token(credentials: HTTPAuthorizationCredentials = Depends(security)):
try:
payload = jwt.decode(
credentials.credentials,
os.getenv("JWT_SECRET_KEY"),
algorithms=["HS256"]
)
return payload
except jwt.ExpiredSignatureError:
raise HTTPException(status_code=401, detail="Token expired")
except jwt.InvalidTokenError:
raise HTTPException(status_code=401, detail="Invalid token")
@router.get("/protected")
async def protected_route(user = Depends(verify_token)):
return {"user": user}
```

View File

@ -0,0 +1,384 @@
# MongoDB 설계 패턴 (Database Patterns)
이 프로젝트의 MongoDB 설계 및 사용 패턴입니다.
## 연결 설정
### Motor (async driver)
```python
from motor.motor_asyncio import AsyncIOMotorClient
class WikipediaEnrichmentWorker:
def __init__(self):
self.mongodb_url = os.getenv("MONGODB_URL", "mongodb://mongodb:27017")
self.db_name = os.getenv("DB_NAME", "ai_writer_db")
self.db = None
async def start(self):
client = AsyncIOMotorClient(self.mongodb_url)
self.db = client[self.db_name]
```
### PyMongo (sync driver)
```python
from pymongo import MongoClient
from pymongo.database import Database
client: MongoClient = None
db: Database = None
def connect_to_mongo():
global client, db
try:
client = MongoClient(MONGODB_URL)
db = client[DATABASE_NAME]
client.admin.command('ping') # 연결 테스트
print(f"Successfully connected to MongoDB: {DATABASE_NAME}")
except Exception as e:
print(f"Error connecting to MongoDB: {e}")
raise e
```
## 컬렉션 설계
### 기사 컬렉션 (articles_en)
```json
{
"_id": "ObjectId",
"news_id": "unique_id",
"title": "Article Title",
"summary": "One-line summary",
"subtopics": [
{
"title": "Subtopic 1",
"content": ["Paragraph 1", "Paragraph 2"]
}
],
"categories": ["Category1", "Category2"],
"entities": {
"people": [
{
"name": "Person Name",
"context": ["role", "company"],
"birth_date": "1990-01-15",
"wikipedia_url": "https://...",
"image_urls": ["https://..."],
"verified": true
}
],
"organizations": [
{
"name": "Organization Name",
"context": ["industry", "type"],
"founding_date": "2004-02-04",
"wikipedia_url": "https://...",
"image_urls": ["https://..."],
"verified": true
}
]
},
"wikipedia_enriched": true,
"wikipedia_enriched_at": "2024-01-15T10:30:00",
"created_at": "2024-01-15T10:00:00",
"updated_at": "2024-01-15T10:30:00"
}
```
### 엔티티 캐시 컬렉션 (entity_people)
```json
{
"_id": "ObjectId",
"name": "Elon Musk",
"context": ["Tesla", "SpaceX", "CEO"],
"birth_date": "1971-06-28",
"wikipedia_url": "https://en.wikipedia.org/wiki/Elon_Musk",
"image_urls": ["https://..."],
"verified": true,
"created_at": "2024-01-10T00:00:00",
"updated_at": "2024-01-15T10:30:00"
}
```
## 인덱스 설계
### 인덱스 생성 패턴
```python
class EntityCache:
async def ensure_indexes(self):
"""인덱스 생성 (이미 존재하면 무시)"""
try:
# wikipedia_url이 unique key (동명이인 구분)
try:
await self.people_collection.create_index(
"wikipedia_url", unique=True, sparse=True
)
except Exception:
pass # 이미 존재
# 이름으로 검색용 (동명이인 가능)
try:
await self.people_collection.create_index("name")
except Exception:
pass
# context 검색용
try:
await self.people_collection.create_index("context")
except Exception:
pass
# TTL 정책용
try:
await self.people_collection.create_index("updated_at")
except Exception:
pass
logger.info("Entity cache indexes ensured")
except Exception as e:
logger.warning(f"Error ensuring indexes: {e}")
```
## CRUD 패턴
### Create (삽입)
```python
async def save_person(self, data: Dict[str, Any]) -> bool:
"""인물 정보 저장/갱신 (wikipedia_url 기준)"""
now = datetime.now()
update_doc = {
"name": data.get("name"),
"context": data.get("context", []),
"birth_date": data.get("birth_date"),
"wikipedia_url": data.get("wikipedia_url"),
"image_urls": data.get("image_urls", []),
"verified": data.get("verified", False),
"updated_at": now
}
if data.get("wikipedia_url"):
# upsert: 있으면 업데이트, 없으면 삽입
result = await self.people_collection.update_one(
{"wikipedia_url": data["wikipedia_url"]},
{
"$set": update_doc,
"$setOnInsert": {"created_at": now}
},
upsert=True
)
return result.modified_count > 0 or result.upserted_id is not None
```
### Read (조회)
```python
async def get_person(self, name: str, context: List[str] = None) -> Tuple[Optional[Dict], bool]:
"""
인물 정보 조회 (context 기반 최적 매칭)
Returns:
Tuple of (cached_data, needs_refresh)
"""
# 이름으로 모든 후보 검색
cursor = self.people_collection.find({"name": {"$regex": f"^{name}$", "$options": "i"}})
candidates = await cursor.to_list(length=10)
if not candidates:
return None, True
# context가 있으면 최적 후보 선택
if context:
best_match = None
best_score = -1
for candidate in candidates:
score = self._calculate_context_match_score(
candidate.get("context", []), context
)
if score > best_score:
best_score = score
best_match = candidate
if best_match and best_score >= MIN_CONTEXT_MATCH:
needs_refresh = not self._is_cache_fresh(best_match)
return best_match, needs_refresh
# context 없으면 첫 번째 후보 반환
candidate = candidates[0]
needs_refresh = not self._is_cache_fresh(candidate)
return candidate, needs_refresh
```
### Update (수정)
```python
async def update_article(self, mongodb_id: str, update_data: Dict[str, Any]):
"""기사 정보 업데이트"""
result = await self.collection.update_one(
{"_id": ObjectId(mongodb_id)},
{
"$set": {
"entities.people": update_data.get("people", []),
"entities.organizations": update_data.get("organizations", []),
"wikipedia_enriched": True,
"wikipedia_enriched_at": datetime.now().isoformat()
}
}
)
return result.modified_count > 0
```
### Delete (삭제)
```python
async def delete_old_cache(self, days: int = 30):
"""오래된 캐시 데이터 삭제"""
cutoff_date = datetime.now() - timedelta(days=days)
result = await self.people_collection.delete_many({
"updated_at": {"$lt": cutoff_date}
})
return result.deleted_count
```
## 캐싱 전략
### TTL 기반 캐시
```python
# 캐시 유효 기간 (7일)
CACHE_TTL_DAYS = 7
def _is_cache_fresh(self, cached_data: Dict[str, Any]) -> bool:
"""캐시 데이터가 신선한지 확인"""
if not cached_data:
return False
updated_at = cached_data.get("updated_at")
if not updated_at:
return False
if isinstance(updated_at, str):
updated_at = datetime.fromisoformat(updated_at)
expiry_date = updated_at + timedelta(days=CACHE_TTL_DAYS)
return datetime.now() < expiry_date
```
### 갱신 정책
```python
# 정책:
# - 7일이 지나면 갱신 시도 (삭제 아님)
# - API 호출 실패 시 기존 데이터 유지
# - 데이터 동일 시 확인 일자만 갱신
async def save_person(self, new_data: Dict, existing_data: Dict = None):
"""기존 데이터와 비교하여 적절히 처리"""
if existing_data and existing_data.get("verified"):
# 기존에 검증된 데이터가 있음
if not new_data.get("birth_date") and existing_data.get("birth_date"):
# 새 데이터가 덜 완전하면 기존 데이터 유지, 시간만 갱신
await self.people_collection.update_one(
{"wikipedia_url": existing_data["wikipedia_url"]},
{"$set": {"updated_at": datetime.now()}}
)
return
# 새 데이터로 갱신
await self._upsert_person(new_data)
```
## GridFS (대용량 파일)
### 오디오 파일 저장
```python
from motor.motor_asyncio import AsyncIOMotorGridFSBucket
class AudioStorage:
def __init__(self, db):
self.fs = AsyncIOMotorGridFSBucket(db, bucket_name="audio")
async def save_audio(self, audio_data: bytes, filename: str) -> str:
"""오디오 파일 저장"""
file_id = await self.fs.upload_from_stream(
filename,
audio_data,
metadata={"content_type": "audio/mpeg"}
)
return str(file_id)
async def get_audio(self, file_id: str) -> bytes:
"""오디오 파일 조회"""
grid_out = await self.fs.open_download_stream(ObjectId(file_id))
return await grid_out.read()
```
## 백업 정책
### 규칙
- **주기**: 하루에 한 번 (daily)
- **보관 기간**: 최소 7일
- **백업 위치**: 프로젝트 루트의 `./backups/` 디렉토리
### MongoDB 백업
```bash
# 백업 실행
BACKUP_NAME="mongodb_backup_$(date +%Y%m%d_%H%M%S)"
docker exec {프로젝트}-mongodb mongodump \
--uri="mongodb://{user}:{password}@localhost:27017" \
--authenticationDatabase=admin \
--out="/tmp/$BACKUP_NAME"
docker cp {프로젝트}-mongodb:/tmp/$BACKUP_NAME ./backups/
echo "백업 완료: ./backups/$BACKUP_NAME"
```
### MongoDB 복원
```bash
docker cp ./backups/$BACKUP_NAME {프로젝트}-mongodb:/tmp/
docker exec {프로젝트}-mongodb mongorestore \
--uri="mongodb://{user}:{password}@localhost:27017" \
--authenticationDatabase=admin \
"/tmp/$BACKUP_NAME"
```
### 자동화 스크립트 (backup-mongodb.sh)
```bash
#!/bin/bash
PROJECT_NAME="{프로젝트명}"
BACKUP_DIR="{프로젝트경로}/backups"
BACKUP_NAME="mongodb_backup_$(date +%Y%m%d_%H%M%S)"
# 백업 실행
docker exec ${PROJECT_NAME}-mongodb mongodump \
--uri="mongodb://{user}:{password}@localhost:27017" \
--authenticationDatabase=admin \
--out="/tmp/$BACKUP_NAME"
docker cp ${PROJECT_NAME}-mongodb:/tmp/$BACKUP_NAME $BACKUP_DIR/
# 7일 이상 된 백업 삭제
find $BACKUP_DIR -type d -name "mongodb_backup_*" -mtime +7 -exec rm -rf {} \;
echo "$(date): Backup completed - $BACKUP_NAME" >> $BACKUP_DIR/backup.log
```
### cron 설정
```bash
# crontab -e
0 2 * * * /path/to/backup-mongodb.sh # 매일 새벽 2시
```
### 주의사항
- 새 프로젝트 생성 시 반드시 백업 스크립트 설정
- 백업 디렉토리는 .gitignore에 추가하여 커밋 제외
- 중요 데이터는 외부 스토리지에 추가 백업 권장
---
## 환경 변수
```bash
# .env
MONGODB_URL=mongodb://admin:password123@mongodb:27017/
DB_NAME=ai_writer_db
TARGET_COLLECTION=articles_en
# docker-compose.yml
environment:
- MONGODB_URL=mongodb://${MONGO_USER}:${MONGO_PASSWORD}@mongodb:27017/
- DB_NAME=ai_writer_db
```

View File

@ -0,0 +1,220 @@
# Docker 배포 규칙 (Deployment Standards)
Docker 배포 표준입니다.
## Dockerfile 패턴
### Python 마이크로서비스 (Worker)
```dockerfile
FROM python:3.11-slim
WORKDIR /app
# 필요한 시스템 패키지 설치 (git은 공통 라이브러리 설치에 필요)
RUN apt-get update && apt-get install -y --no-install-recommends git && rm -rf /var/lib/apt/lists/*
# 의존성 설치 (공통 라이브러리 포함 시)
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt && \
pip install --no-cache-dir git+http://gitea.yakenator.io/yakenator/{공통라이브러리}.git
# 애플리케이션 코드 복사
COPY *.py .
# 워커 실행
CMD ["python", "worker.py"]
```
### Python API 서비스
```dockerfile
FROM python:3.11-slim
WORKDIR /app
# curl은 healthcheck에 필요
RUN apt-get update && apt-get install -y curl && rm -rf /var/lib/apt/lists/*
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
COPY app/ ./app/
EXPOSE 8000
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"]
```
## docker-compose.yml 패턴
### 서비스 구조
```yaml
services:
# ===================
# Infrastructure (독립 포트 사용)
# ===================
mongodb:
image: mongo:7.0
container_name: {프로젝트}-mongodb
restart: unless-stopped
environment:
MONGO_INITDB_ROOT_USERNAME: ${MONGO_USER:-admin}
MONGO_INITDB_ROOT_PASSWORD: ${MONGO_PASSWORD:-password123}
ports:
- "{호스트포트}:27017" # 호스트 포트는 충돌 방지를 위해 변경
volumes:
- {프로젝트}_mongodb_data:/data/db
networks:
- {프로젝트}-network
healthcheck:
test: ["CMD", "mongosh", "--eval", "db.adminCommand('ping')"]
interval: 30s
timeout: 10s
retries: 3
```
### Worker 서비스 패턴
```yaml
{서비스명}:
build:
context: ./repos/{서비스명}
dockerfile: Dockerfile
container_name: {프로젝트}-{서비스명}
restart: unless-stopped
environment:
- REDIS_URL=redis://redis:6379
- MONGODB_URL=mongodb://${MONGO_USER}:${MONGO_PASSWORD}@mongodb:27017/
- DB_NAME={데이터베이스명}
- TARGET_COLLECTION={컬렉션명}
depends_on:
redis:
condition: service_healthy
mongodb:
condition: service_healthy
networks:
- {프로젝트}-network
```
## 컨테이너 네이밍 규칙
- 컨테이너명: `{프로젝트}-{서비스명}`
- 볼륨명: `{프로젝트}_{데이터유형}_data`
- 네트워크: `{프로젝트}-network`
## 환경 변수 관리
- `.env` 파일로 민감 정보 관리
- docker-compose.yml에서 `${VAR:-default}` 형태로 기본값 제공
- 공통 변수: `MONGO_USER`, `MONGO_PASSWORD`, `REDIS_URL`, `JWT_SECRET_KEY`
## 헬스체크 패턴
### Redis
```yaml
healthcheck:
test: ["CMD", "redis-cli", "ping"]
interval: 30s
timeout: 10s
retries: 3
```
### MongoDB
```yaml
healthcheck:
test: ["CMD", "mongosh", "--eval", "db.adminCommand('ping')"]
interval: 30s
timeout: 10s
retries: 3
```
### FastAPI 서비스
```yaml
healthcheck:
test: ["CMD", "curl", "-f", "http://localhost:8000/health"]
interval: 30s
timeout: 10s
retries: 3
```
## 프라이빗 컨테이너 레지스트리
Docker Hub rate limit 우회 및 프라이빗 이미지 관리를 위해 자체 레지스트리를 사용합니다.
### 레지스트리 구성
| 서비스 | 주소 | 용도 |
|--------|------|------|
| **Private Registry** | `docker.yakenator.io` / `10.0.0.3:5000` | 프라이빗 이미지 Push/Pull |
| **Docker Hub Cache** | `10.0.0.3:5001` | Docker Hub 이미지 캐시 (Pull-through) |
| **Registry UI** | http://reg.yakenator.io | 웹 UI |
### 프라이빗 이미지 Push/Pull (5000)
```bash
# 빌드 후 태깅
docker build -t {이미지명}:{태그} .
docker tag {이미지명}:{태그} 10.0.0.3:5000/{프로젝트}/{이미지명}:{태그}
# Push
docker push 10.0.0.3:5000/{프로젝트}/{이미지명}:{태그}
# 예시
docker build -t drama-studio-api:latest ./audio-studio-api
docker tag drama-studio-api:latest 10.0.0.3:5000/drama-studio/api:latest
docker push 10.0.0.3:5000/drama-studio/api:latest
```
### Docker Hub 캐시 사용 (5001)
```bash
# Docker Hub 이미지를 캐시 경유로 pull (rate limit 우회)
docker pull 10.0.0.3:5001/library/mongo:7.0
docker pull 10.0.0.3:5001/library/redis:7-alpine
docker pull 10.0.0.3:5001/library/python:3.11-slim
# 공식 이미지는 library/ 접두사 사용
docker pull 10.0.0.3:5001/library/node:20-alpine
# 사용자 이미지는 {username}/ 접두사 사용
docker pull 10.0.0.3:5001/joxit/docker-registry-ui:latest
```
### docker-compose.yml에서 사용
```yaml
services:
# 프라이빗 이미지 사용
api:
image: 10.0.0.3:5000/{프로젝트}/api:latest
# Docker Hub 캐시 이미지 사용
mongodb:
image: 10.0.0.3:5001/library/mongo:7.0
redis:
image: 10.0.0.3:5001/library/redis:7-alpine
```
### Docker 데몬 설정 (Insecure Registry)
```bash
# /etc/docker/daemon.json
{
"insecure-registries": ["10.0.0.3:5000", "10.0.0.3:5001"]
}
# 설정 후 재시작
sudo systemctl restart docker
```
### CI/CD에서 사용
```bash
# 빌드 및 배포 스크립트 예시
VERSION=$(git rev-parse --short HEAD)
IMAGE="10.0.0.3:5000/${PROJECT}/${SERVICE}:${VERSION}"
docker build -t $IMAGE .
docker push $IMAGE
docker tag $IMAGE 10.0.0.3:5000/${PROJECT}/${SERVICE}:latest
docker push 10.0.0.3:5000/${PROJECT}/${SERVICE}:latest
```
## 참고 리포지토리
- Gitea: http://gitea.yakenator.io/yakenator/
- Container Registry: http://reg.yakenator.io
- Docker Registry API: http://docker.yakenator.io/v2/_catalog

View File

@ -0,0 +1,365 @@
# Flume - K8s 기반 범용 파이프라인 시스템
## 개요
Flume은 K8s 네이티브 범용 파이프라인/워크플로우 시스템입니다.
- **비주얼 에디터** + **YAML/JSON 정의** 지원
- **하이브리드 실행**: 경량 작업은 중앙 엔진, 무거운 작업은 K8s Job
- **로컬 LLM 통합**: vLLM 기반 GPU 노드 지원
## 아키텍처
```mermaid
flowchart TB
subgraph Frontend["Frontend (Next.js)"]
VE[Visual Node Editor<br/>ReactFlow]
PD[Pipeline Dashboard]
MC[Monitoring Console]
end
subgraph API["API Server (FastAPI)"]
CRUD[Pipeline CRUD]
TM[Trigger Manager]
NR[Node Registry]
end
subgraph Engine["Pipeline Engine"]
SCH[Scheduler<br/>APScheduler]
EXE[Executor<br/>Async]
SM[State Manager]
end
subgraph K8s["Kubernetes Cluster"]
OP[Flume Operator<br/>kopf]
subgraph Workers["Worker Pods"]
CPU[CPU Workers]
GPU[GPU Workers<br/>vLLM]
end
end
subgraph Storage["Storage"]
Redis[(Redis<br/>Queue/Cache)]
MongoDB[(MongoDB<br/>Definitions/History)]
end
Frontend --> API
API --> Engine
Engine --> Redis
Engine --> MongoDB
Engine --> OP
OP --> Workers
GPU -.->|OpenAI API| EXE
```
## 실행 모델 (하이브리드)
```mermaid
flowchart LR
subgraph Light["경량 작업 (중앙 실행)"]
L1[HTTP Request]
L2[JSON Transform]
L3[Condition/Switch]
L4[Variable Set]
end
subgraph Heavy["무거운 작업 (K8s Job)"]
H1[LLM Generate]
H2[Image Process]
H3[Data ETL]
H4[Custom Script]
end
ENG[Engine] --> Light
ENG --> |K8s Job 생성| Heavy
```
### 실행 기준
| 구분 | 실행 위치 | 예시 |
|------|----------|------|
| 경량 | 중앙 엔진 | HTTP 요청, 데이터 변환, 조건 분기 |
| GPU | GPU Pod | LLM 생성, 이미지 생성 |
| 무거운 CPU | K8s Job | 대용량 ETL, 장시간 스크립트 |
## 핵심 컴포넌트
### 1. Frontend (flume-ui)
```
flume-ui/
├── src/
│ ├── app/
│ │ ├── page.tsx # 대시보드
│ │ ├── pipelines/
│ │ │ ├── page.tsx # 파이프라인 목록
│ │ │ ├── [id]/
│ │ │ │ ├── page.tsx # 파이프라인 상세
│ │ │ │ └── edit/page.tsx # 비주얼 에디터
│ │ │ └── new/page.tsx # 새 파이프라인
│ │ └── runs/page.tsx # 실행 히스토리
│ ├── components/
│ │ ├── editor/ # ReactFlow 노드 에디터
│ │ ├── nodes/ # 커스텀 노드 컴포넌트
│ │ └── ui/ # shadcn/ui
│ └── lib/
│ └── api.ts # API 클라이언트
```
### 2. API Server (flume-api)
```
flume-api/
├── app/
│ ├── main.py
│ ├── database.py
│ ├── models/
│ │ ├── pipeline.py # 파이프라인 정의
│ │ ├── run.py # 실행 기록
│ │ └── node.py # 노드 정의
│ ├── routers/
│ │ ├── pipelines.py # CRUD
│ │ ├── runs.py # 실행 관리
│ │ ├── triggers.py # 트리거 관리
│ │ └── nodes.py # 노드 레지스트리
│ └── services/
│ └── engine_client.py # 엔진 통신
```
### 3. Pipeline Engine (flume-engine)
```
flume-engine/
├── engine/
│ ├── executor.py # DAG 실행기
│ ├── scheduler.py # 스케줄러 (cron, interval)
│ ├── state.py # 상태 관리
│ └── k8s_client.py # K8s Job 생성
├── nodes/
│ ├── base.py # 노드 베이스 클래스
│ ├── builtin/
│ │ ├── http.py # HTTP 요청
│ │ ├── transform.py # 데이터 변환
│ │ ├── condition.py # 조건 분기
│ │ ├── llm.py # LLM 호출
│ │ └── database.py # DB 작업
│ └── registry.py # 노드 레지스트리
└── worker.py # 메인 워커
```
### 4. K8s Operator (flume-operator)
```
flume-operator/
├── operator.py # kopf 기반 오퍼레이터
├── crds/
│ └── flumejob.yaml # CRD 정의
└── templates/
├── cpu_job.yaml
└── gpu_job.yaml
```
## 파이프라인 정의
### YAML 형식
```yaml
apiVersion: flume/v1
kind: Pipeline
metadata:
name: article-generator
description: 기사 생성 파이프라인
trigger:
type: webhook
# type: cron
# schedule: "0 9 * * *"
variables:
model: llama3.1-70b
nodes:
- id: fetch-topic
type: http-request
config:
method: GET
url: "https://api.example.com/topics"
- id: generate-article
type: llm-generate
runOn: gpu # GPU Pod에서 실행
config:
model: "{{variables.model}}"
maxTokens: 4096
inputs:
prompt: |
주제: {{fetch-topic.output.topic}}
위 주제로 뉴스 기사를 작성하세요.
- id: save-article
type: mongodb-insert
config:
database: flume
collection: articles
inputs:
document:
title: "{{fetch-topic.output.topic}}"
content: "{{generate-article.output}}"
createdAt: "{{$now}}"
edges:
- from: fetch-topic
to: generate-article
- from: generate-article
to: save-article
```
## 노드 SDK
### 커스텀 노드 작성
```python
from flume.nodes import Node, NodeInput, NodeOutput
class MyCustomNode(Node):
"""커스텀 노드 예시"""
name = "my-custom-node"
category = "custom"
run_on = "engine" # engine | cpu | gpu
class Input(NodeInput):
data: str
count: int = 1
class Output(NodeOutput):
result: list[str]
async def execute(self, input: Input) -> Output:
"""노드 실행 로직"""
results = [input.data] * input.count
return self.Output(result=results)
```
### 노드 등록
```python
from flume.nodes import registry
registry.register(MyCustomNode)
```
## 빌트인 노드
| 카테고리 | 노드 | 설명 | 실행 위치 |
|---------|------|------|----------|
| **Trigger** | webhook | 웹훅 수신 | - |
| | cron | 스케줄 실행 | - |
| | manual | 수동 실행 | - |
| **HTTP** | http-request | HTTP 요청 | engine |
| | http-response | 응답 반환 | engine |
| **Transform** | json-transform | JSON 변환 | engine |
| | template | 템플릿 렌더링 | engine |
| **Logic** | condition | 조건 분기 | engine |
| | switch | 다중 분기 | engine |
| | loop | 반복 실행 | engine |
| **AI** | llm-generate | LLM 텍스트 생성 | gpu |
| | llm-chat | LLM 대화 | gpu |
| | embedding | 임베딩 생성 | gpu |
| **Database** | mongodb-query | MongoDB 조회 | engine |
| | mongodb-insert | MongoDB 삽입 | engine |
| | redis-get/set | Redis 작업 | engine |
| **Utility** | delay | 지연 | engine |
| | log | 로깅 | engine |
| | error | 에러 발생 | engine |
## 데이터 모델 (MongoDB)
### pipelines 컬렉션
```javascript
{
_id: ObjectId,
name: "article-generator",
description: "기사 생성 파이프라인",
definition: { /* YAML을 파싱한 객체 */ },
trigger: { type: "webhook", config: {} },
status: "active", // active | paused | draft
createdAt: ISODate,
updatedAt: ISODate
}
```
### runs 컬렉션
```javascript
{
_id: ObjectId,
pipelineId: ObjectId,
status: "running", // pending | running | completed | failed
trigger: { type: "webhook", data: {} },
nodes: {
"fetch-topic": {
status: "completed",
output: { topic: "..." },
startedAt: ISODate,
completedAt: ISODate
},
"generate-article": {
status: "running",
startedAt: ISODate
}
},
startedAt: ISODate,
completedAt: ISODate,
error: null
}
```
## 환경 변수
```bash
# flume-api
MONGODB_URL=mongodb://admin:password@mongodb:27017/
DB_NAME=flume
REDIS_URL=redis://redis:6379
ENGINE_URL=http://flume-engine:8001
# flume-engine
MONGODB_URL=mongodb://admin:password@mongodb:27017/
DB_NAME=flume
REDIS_URL=redis://redis:6379
VLLM_URL=http://vllm:8000/v1
K8S_NAMESPACE=flume
# flume-ui
NEXT_PUBLIC_API_URL=http://localhost:8000
```
## 개발 로드맵
### Phase 1: 코어
- [ ] 파이프라인 정의 스키마
- [ ] API Server 기본 CRUD
- [ ] Engine 코어 (DAG 실행)
- [ ] 빌트인 노드 (HTTP, Transform, Logic)
### Phase 2: 실행
- [ ] 스케줄러 (cron, interval)
- [ ] K8s Job 실행
- [ ] 상태 관리 및 재시도
### Phase 3: AI
- [ ] LLM 노드 (vLLM 연동)
- [ ] 임베딩 노드
- [ ] GPU 스케줄링
### Phase 4: UI
- [ ] 비주얼 노드 에디터
- [ ] 실행 모니터링
- [ ] 로그 뷰어
### Phase 5: 확장
- [ ] 커스텀 노드 SDK
- [ ] 플러그인 시스템
- [ ] 멀티 테넌시

View File

@ -0,0 +1,325 @@
# React 컴포넌트 패턴 (Frontend Component Patterns)
이 프로젝트의 React/Next.js 컴포넌트 패턴입니다.
## shadcn/ui 기반 컴포넌트
### 설치 및 초기화
```bash
# shadcn/ui 초기화
npx shadcn@latest init
# 컴포넌트 추가
npx shadcn@latest add button card dialog tabs table form
```
### Button 컴포넌트 (CVA 패턴)
```tsx
import * as React from "react"
import { Slot } from "@radix-ui/react-slot"
import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "@/lib/utils"
const buttonVariants = cva(
"inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-all disabled:pointer-events-none disabled:opacity-50",
{
variants: {
variant: {
default: "bg-primary text-primary-foreground hover:bg-primary/90",
destructive: "bg-destructive text-white hover:bg-destructive/90",
outline: "border bg-background shadow-xs hover:bg-accent",
secondary: "bg-secondary text-secondary-foreground hover:bg-secondary/80",
ghost: "hover:bg-accent hover:text-accent-foreground",
link: "text-primary underline-offset-4 hover:underline",
},
size: {
default: "h-9 px-4 py-2",
sm: "h-8 rounded-md px-3",
lg: "h-10 rounded-md px-6",
icon: "size-9",
},
},
defaultVariants: {
variant: "default",
size: "default",
},
}
)
function Button({
className,
variant,
size,
asChild = false,
...props
}: React.ComponentProps<"button"> &
VariantProps<typeof buttonVariants> & {
asChild?: boolean
}) {
const Comp = asChild ? Slot : "button"
return (
<Comp
className={cn(buttonVariants({ variant, size, className }))}
{...props}
/>
)
}
export { Button, buttonVariants }
```
## Next.js App Router 구조
### 레이아웃 (layout.tsx)
```tsx
import type { Metadata } from "next";
import { Geist, Geist_Mono } from "next/font/google";
import "./globals.css";
import { Providers } from "@/components/providers";
import { Toaster } from "@/components/ui/sonner";
const geistSans = Geist({
variable: "--font-geist-sans",
subsets: ["latin"],
});
export const metadata: Metadata = {
title: "News Engine Admin",
description: "Admin dashboard for News Pipeline management",
};
export default function RootLayout({
children,
}: Readonly<{ children: React.ReactNode }>) {
return (
<html lang="en">
<body className={`${geistSans.variable} antialiased`}>
<Providers>
{children}
<Toaster />
</Providers>
</body>
</html>
);
}
```
### Provider 패턴
```tsx
// components/providers.tsx
"use client"
import { ThemeProvider } from "next-themes"
export function Providers({ children }: { children: React.ReactNode }) {
return (
<ThemeProvider
attribute="class"
defaultTheme="system"
enableSystem
disableTransitionOnChange
>
{children}
</ThemeProvider>
)
}
```
## 유틸리티 함수
### cn() 함수 (lib/utils.ts)
```tsx
import { clsx, type ClassValue } from "clsx"
import { twMerge } from "tailwind-merge"
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs))
}
```
## 커스텀 훅 패턴
### 데이터 페칭 훅
```tsx
// hooks/use-articles.ts
import { useState, useEffect } from "react"
interface Article {
id: string
title: string
summary: string
}
export function useArticles() {
const [articles, setArticles] = useState<Article[]>([])
const [isLoading, setIsLoading] = useState(true)
const [error, setError] = useState<Error | null>(null)
useEffect(() => {
async function fetchArticles() {
try {
const res = await fetch("/api/articles")
if (!res.ok) throw new Error("Failed to fetch")
const data = await res.json()
setArticles(data.items)
} catch (e) {
setError(e as Error)
} finally {
setIsLoading(false)
}
}
fetchArticles()
}, [])
return { articles, isLoading, error }
}
```
### 토글 훅
```tsx
// hooks/use-toggle.ts
import { useState, useCallback } from "react"
export function useToggle(initialState = false) {
const [state, setState] = useState(initialState)
const toggle = useCallback(() => setState((s) => !s), [])
const setTrue = useCallback(() => setState(true), [])
const setFalse = useCallback(() => setState(false), [])
return { state, toggle, setTrue, setFalse }
}
```
## 폼 패턴 (react-hook-form + zod)
```tsx
"use client"
import { useForm } from "react-hook-form"
import { zodResolver } from "@hookform/resolvers/zod"
import { z } from "zod"
import { Button } from "@/components/ui/button"
import { Input } from "@/components/ui/input"
import { Form, FormControl, FormField, FormItem, FormLabel, FormMessage } from "@/components/ui/form"
const formSchema = z.object({
title: z.string().min(1, "제목을 입력하세요"),
content: z.string().min(10, "내용은 10자 이상이어야 합니다"),
})
type FormValues = z.infer<typeof formSchema>
export function ArticleForm() {
const form = useForm<FormValues>({
resolver: zodResolver(formSchema),
defaultValues: {
title: "",
content: "",
},
})
async function onSubmit(values: FormValues) {
try {
const res = await fetch("/api/articles", {
method: "POST",
body: JSON.stringify(values),
})
if (!res.ok) throw new Error("Failed to create")
// 성공 처리
} catch (error) {
console.error(error)
}
}
return (
<Form {...form}>
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-4">
<FormField
control={form.control}
name="title"
render={({ field }) => (
<FormItem>
<FormLabel>제목</FormLabel>
<FormControl>
<Input {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<Button type="submit">저장</Button>
</form>
</Form>
)
}
```
## 다크모드 지원
### 테마 토글 버튼
```tsx
"use client"
import { useTheme } from "next-themes"
import { Moon, Sun } from "lucide-react"
import { Button } from "@/components/ui/button"
export function ThemeToggle() {
const { theme, setTheme } = useTheme()
return (
<Button
variant="ghost"
size="icon"
onClick={() => setTheme(theme === "dark" ? "light" : "dark")}
>
<Sun className="h-5 w-5 rotate-0 scale-100 transition-all dark:-rotate-90 dark:scale-0" />
<Moon className="absolute h-5 w-5 rotate-90 scale-0 transition-all dark:rotate-0 dark:scale-100" />
</Button>
)
}
```
## 토스트 알림 (sonner)
```tsx
import { toast } from "sonner"
// 성공 알림
toast.success("저장되었습니다")
// 에러 알림
toast.error("오류가 발생했습니다")
// 로딩 알림
const toastId = toast.loading("처리 중...")
// 완료 후
toast.success("완료!", { id: toastId })
```
## 파일 구조
```
src/
├── app/ # Next.js App Router
│ ├── layout.tsx # 루트 레이아웃
│ ├── page.tsx # 메인 페이지
│ └── dashboard/
│ └── page.tsx
├── components/
│ ├── ui/ # shadcn/ui 기본 컴포넌트
│ │ ├── button.tsx
│ │ ├── card.tsx
│ │ └── ...
│ ├── providers.tsx # Context Providers
│ └── app-sidebar.tsx # 앱 전용 컴포넌트
├── hooks/ # 커스텀 훅
│ └── use-articles.ts
├── lib/ # 유틸리티
│ └── utils.ts
└── types/ # TypeScript 타입
└── index.ts
```

View File

@ -0,0 +1,142 @@
# Gitea 워크플로우 (Gitea Workflow)
Gitea 리포지토리 관리 워크플로우입니다.
## Gitea 서버 정보
- **URL**: http://gitea.yakenator.io
- **사용자**: yakenator
- **비밀번호**: asdfg23we
## 새 리포지토리 생성
### 1. Gitea CLI (tea) 사용
```bash
# tea 설치 (macOS)
brew install tea
# 로그인
tea login add --url http://gitea.yakenator.io --user yakenator --password asdfg23we
# 리포지토리 생성
tea repo create --name {repo-name} --private=false
```
### 2. API 사용
```bash
# 리포지토리 생성
curl -X POST "http://gitea.yakenator.io/api/v1/user/repos" \
-H "Content-Type: application/json" \
-u "yakenator:asdfg23we" \
-d '{
"name": "{repo-name}",
"description": "서비스 설명",
"private": true,
"auto_init": true
}'
```
### 3. 웹 UI 사용
1. http://gitea.yakenator.io 접속
2. yakenator / asdfg23we 로 로그인
3. "New Repository" 클릭
4. 리포지토리 정보 입력 후 생성
## 새 프로젝트 초기화 및 푸시
### 전체 워크플로우
```bash
# 1. 프로젝트 디렉토리 생성
mkdir ./{new-project}
cd ./{new-project}
# 2. Git 초기화
git init
# 3. 파일 생성 (Dockerfile, requirements.txt 등)
# 4. 첫 커밋
git add -A
git commit -m "Initial commit: {project-name} 초기 구성
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>"
# 5. Gitea에 리포지토리 생성 (API)
curl -X POST "http://gitea.yakenator.io/api/v1/user/repos" \
-H "Content-Type: application/json" \
-u "yakenator:asdfg23we" \
-d '{"name": "{new-project}", "private": false}'
# 6. Remote 추가 및 푸시
git remote add origin http://yakenator:asdfg23we@gitea.yakenator.io/yakenator/{new-project}.git
git branch -M main
git push -u origin main
```
## 기존 리포지토리 클론
```bash
# HTTP 클론 (인증 포함)
git clone http://yakenator:asdfg23we@gitea.yakenator.io/yakenator/{repo-name}.git
# 또는 클론 후 credential 설정
git clone http://gitea.yakenator.io/yakenator/{repo-name}.git
cd {repo-name}
git config credential.helper store
```
## 리포지토리 URL 패턴
```
http://gitea.yakenator.io/yakenator/{repo-name}.git
```
## 커밋 및 푸시
```bash
# 변경사항 확인
git status
# 스테이징 및 커밋
git add -A
git commit -m "$(cat <<'EOF'
feat: 기능 설명
- 변경사항 1
- 변경사항 2
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
EOF
)"
# 푸시
git push
```
## 브랜치 전략
```bash
# 기본 브랜치: main
# 기능 브랜치: feature/{기능명}
# 버그 수정: fix/{버그설명}
# 브랜치 생성 및 전환
git checkout -b feature/new-feature
# 작업 후 푸시
git push -u origin feature/new-feature
# PR 생성 (웹 UI 또는 API)
```
## 주의사항
- 민감 정보(.env, credentials)는 절대 커밋하지 않음
- .gitignore에 다음 항목 포함:
```
.env
__pycache__/
*.pyc
node_modules/
.DS_Store
```

View File

@ -0,0 +1,213 @@
# GPU 로컬 모델 인프라 (GPU Local Models)
로컬 LLM/ML 모델 서빙을 위한 GPU 인프라 가이드입니다.
## GPU 인벤토리
| GPU | VRAM | 수량 | 용도 |
|-----|------|------|------|
| NVIDIA V100 | 16GB | 8 | 중형 모델, 병렬 추론 |
| NVIDIA V100 | 32GB | 4 | 대형 모델, 파인튜닝 |
| NVIDIA RTX 3090 | 24GB | 2 | 개발/테스트, 중형 모델 |
**총 VRAM**: 16×8 + 32×4 + 24×2 = 128 + 128 + 48 = **304GB**
## VRAM별 모델 가이드
### 16GB (V100 16GB, 단일)
- Llama 3.1 8B (Q4 양자화)
- Mistral 7B
- Phi-3 Medium
- Gemma 2 9B
### 24GB (RTX 3090)
- Llama 3.1 8B (FP16)
- Qwen2.5 14B (Q4)
- CodeLlama 13B
### 32GB (V100 32GB, 단일)
- Llama 3.1 70B (Q4 양자화)
- Qwen2.5 32B
- DeepSeek Coder 33B
### 멀티 GPU (텐서 병렬)
- V100 32GB × 2 (64GB): Llama 3.1 70B (FP16)
- V100 32GB × 4 (128GB): Llama 3.1 70B + 여유 / 대형 모델
- V100 16GB × 8 (128GB): 대규모 배치 추론
## 모델 서빙 프레임워크
### vLLM (권장)
```bash
# Docker 실행
docker run --gpus all -p 8000:8000 \
-v ~/.cache/huggingface:/root/.cache/huggingface \
vllm/vllm-openai:latest \
--model meta-llama/Llama-3.1-8B-Instruct \
--tensor-parallel-size 1
```
### Text Generation Inference (TGI)
```bash
docker run --gpus all -p 8080:80 \
-v ~/.cache/huggingface:/data \
ghcr.io/huggingface/text-generation-inference:latest \
--model-id meta-llama/Llama-3.1-8B-Instruct
```
### Ollama (개발/테스트용)
```bash
# 설치
curl -fsSL https://ollama.com/install.sh | sh
# 모델 실행
ollama run llama3.1:8b
```
## Docker Compose GPU 패턴
### 단일 GPU 서비스
```yaml
services:
llm-server:
image: vllm/vllm-openai:latest
container_name: {프로젝트}-llm
restart: unless-stopped
deploy:
resources:
reservations:
devices:
- driver: nvidia
count: 1
capabilities: [gpu]
environment:
- HUGGING_FACE_HUB_TOKEN=${HF_TOKEN}
volumes:
- ~/.cache/huggingface:/root/.cache/huggingface
ports:
- "8000:8000"
command: >
--model meta-llama/Llama-3.1-8B-Instruct
--max-model-len 8192
networks:
- {프로젝트}-network
```
### 멀티 GPU 텐서 병렬
```yaml
services:
llm-large:
image: vllm/vllm-openai:latest
container_name: {프로젝트}-llm-large
restart: unless-stopped
deploy:
resources:
reservations:
devices:
- driver: nvidia
count: 4
capabilities: [gpu]
environment:
- HUGGING_FACE_HUB_TOKEN=${HF_TOKEN}
- CUDA_VISIBLE_DEVICES=0,1,2,3
volumes:
- ~/.cache/huggingface:/root/.cache/huggingface
ports:
- "8001:8000"
command: >
--model meta-llama/Llama-3.1-70B-Instruct
--tensor-parallel-size 4
--max-model-len 4096
networks:
- {프로젝트}-network
```
### 특정 GPU 지정
```yaml
deploy:
resources:
reservations:
devices:
- driver: nvidia
device_ids: ['0', '1'] # GPU 0, 1 지정
capabilities: [gpu]
```
## API 통합 패턴
### OpenAI 호환 클라이언트 (vLLM)
```python
from openai import AsyncOpenAI
client = AsyncOpenAI(
base_url="http://llm-server:8000/v1",
api_key="not-needed" # 로컬은 API 키 불필요
)
response = await client.chat.completions.create(
model="meta-llama/Llama-3.1-8B-Instruct",
messages=[{"role": "user", "content": prompt}],
max_tokens=2048,
temperature=0.7
)
```
### 로컬/원격 전환 패턴
```python
import os
from openai import AsyncOpenAI
def get_llm_client():
"""환경에 따라 로컬 또는 원격 LLM 클라이언트 반환"""
if os.getenv("USE_LOCAL_LLM", "false").lower() == "true":
return AsyncOpenAI(
base_url=os.getenv("LOCAL_LLM_URL", "http://llm-server:8000/v1"),
api_key="not-needed"
)
else:
return AsyncOpenAI(api_key=os.getenv("OPENAI_API_KEY"))
```
## 환경 변수
```bash
# .env 추가 항목
HF_TOKEN=hf_... # Hugging Face 토큰
USE_LOCAL_LLM=true # 로컬 LLM 사용 여부
LOCAL_LLM_URL=http://llm-server:8000/v1 # 로컬 LLM 엔드포인트
LOCAL_MODEL_NAME=meta-llama/Llama-3.1-8B-Instruct
```
## GPU 모니터링
### nvidia-smi
```bash
# 실시간 모니터링
watch -n 1 nvidia-smi
# 특정 정보만
nvidia-smi --query-gpu=index,name,memory.used,memory.total,utilization.gpu --format=csv
```
### Docker 내부에서
```bash
docker exec {컨테이너명} nvidia-smi
```
## 헬스체크
```yaml
healthcheck:
test: ["CMD", "curl", "-f", "http://localhost:8000/health"]
interval: 30s
timeout: 30s
retries: 3
start_period: 120s # 모델 로딩 시간 고려
```
## 주의사항
- 모델 첫 로딩 시 시간 소요 (수 분)
- VRAM 부족 시 OOM 에러 → 양자화 또는 GPU 추가
- Hugging Face 게이트 모델은 토큰 및 동의 필요
- 멀티 GPU 사용 시 NVLink 유무에 따라 성능 차이

View File

@ -0,0 +1,268 @@
# 인프라 구축 가이드 (Infrastructure Setup)
인프라 설정 패턴입니다.
## MAAS (Metal as a Service)
### 개요
- 베어메탈 서버 프로비저닝 도구
- Ubuntu 기반 자동 배포
- 네트워크 자동 설정
### 기본 설정
```yaml
# MAAS 설정 예시
machines:
- hostname: k8s-master-01
architecture: amd64
cpu_count: 8
memory: 32768
storage: 500GB
tags:
- kubernetes
- master
- hostname: k8s-worker-01
architecture: amd64
cpu_count: 16
memory: 65536
storage: 1TB
tags:
- kubernetes
- worker
```
### 네트워크 설정
```yaml
# 서브넷 설정
subnets:
- cidr: 10.10.0.0/16
gateway_ip: 10.10.0.1
dns_servers:
- 8.8.8.8
- 8.8.4.4
```
## Kubernetes 클러스터
### 클러스터 구성
```yaml
# 마스터 노드: 3대 (HA 구성)
# 워커 노드: 3대 이상
# etcd: 마스터 노드에 내장
```
### kubeadm 초기화
```bash
# 마스터 노드 초기화
kubeadm init --pod-network-cidr=10.244.0.0/16 \
--control-plane-endpoint="k8s-api.example.com:6443" \
--upload-certs
# 워커 노드 조인
kubeadm join k8s-api.example.com:6443 \
--token <token> \
--discovery-token-ca-cert-hash sha256:<hash>
```
### CNI 설치 (Flannel)
```bash
kubectl apply -f https://raw.githubusercontent.com/coreos/flannel/master/Documentation/kube-flannel.yml
```
## Rancher 설치
### Docker 기반 설치
```bash
docker run -d --restart=unless-stopped \
-p 80:80 -p 443:443 \
--privileged \
-v /opt/rancher:/var/lib/rancher \
rancher/rancher:latest
```
### Helm 기반 설치
```bash
# Rancher Helm 레포 추가
helm repo add rancher-latest https://releases.rancher.com/server-charts/latest
helm repo update
# 네임스페이스 생성
kubectl create namespace cattle-system
# cert-manager 설치 (Let's Encrypt 사용 시)
kubectl apply -f https://github.com/cert-manager/cert-manager/releases/download/v1.13.0/cert-manager.yaml
# Rancher 설치
helm install rancher rancher-latest/rancher \
--namespace cattle-system \
--set hostname=rancher.example.com \
--set replicas=3 \
--set ingress.tls.source=letsEncrypt \
--set letsEncrypt.email=admin@example.com
```
## Docker Compose 배포
### docker-compose.yml 구조
```yaml
services:
# ===================
# Infrastructure
# ===================
mongodb:
image: mongo:7.0
container_name: {프로젝트}-mongodb
restart: unless-stopped
environment:
MONGO_INITDB_ROOT_USERNAME: ${MONGO_USER:-admin}
MONGO_INITDB_ROOT_PASSWORD: ${MONGO_PASSWORD:-password123}
ports:
- "{호스트포트}:27017"
volumes:
- {프로젝트}_mongodb_data:/data/db
networks:
- {프로젝트}-network
healthcheck:
test: ["CMD", "mongosh", "--eval", "db.adminCommand('ping')"]
interval: 30s
timeout: 10s
retries: 3
redis:
image: redis:7-alpine
container_name: {프로젝트}-redis
restart: unless-stopped
ports:
- "{호스트포트}:6379"
volumes:
- {프로젝트}_redis_data:/data
networks:
- {프로젝트}-network
volumes:
{프로젝트}_mongodb_data:
{프로젝트}_redis_data:
networks:
{프로젝트}-network:
driver: bridge
```
### 배포 명령어
```bash
# 전체 서비스 시작
docker-compose up -d
# 특정 서비스만 재빌드
docker-compose up -d --build {서비스명}
# 로그 확인
docker-compose logs -f {서비스명}
# 서비스 상태 확인
docker-compose ps
```
## 환경 변수 관리
### .env 파일
```bash
# Infrastructure
MONGO_USER=admin
MONGO_PASSWORD={비밀번호}
REDIS_URL=redis://redis:6379
# API Keys
CLAUDE_API_KEY=sk-ant-...
OPENAI_API_KEY=sk-...
JWT_SECRET_KEY={시크릿키}
# Database
DB_NAME={데이터베이스명}
TARGET_COLLECTION={컬렉션명}
```
### 환경별 설정
```bash
# 개발 환경
docker-compose -f docker-compose.yml -f docker-compose.dev.yml up -d
# 프로덕션 환경
docker-compose -f docker-compose.yml -f docker-compose.prod.yml up -d
```
## 백업 전략
### MongoDB 백업
```bash
# 백업
BACKUP_NAME="mongodb_backup_$(date +%Y%m%d_%H%M%S)"
docker exec {프로젝트}-mongodb mongodump \
--uri="mongodb://{user}:{password}@localhost:27017" \
--authenticationDatabase=admin \
--out="/tmp/$BACKUP_NAME"
docker cp {프로젝트}-mongodb:/tmp/$BACKUP_NAME ./backups/
# 복원
docker cp ./backups/$BACKUP_NAME {프로젝트}-mongodb:/tmp/
docker exec {프로젝트}-mongodb mongorestore \
--uri="mongodb://{user}:{password}@localhost:27017" \
--authenticationDatabase=admin \
"/tmp/$BACKUP_NAME"
```
### 볼륨 백업
```bash
# 볼륨 백업
docker run --rm \
-v {프로젝트}_mongodb_data:/data \
-v $(pwd)/backups:/backup \
alpine tar czf /backup/mongodb_volume.tar.gz -C /data .
```
## 네트워크 구성
### 포트 매핑 규칙
```yaml
# Infrastructure (기본 포트 + 오프셋)
MongoDB: {오프셋}+27017:27017
Redis: {오프셋}+6379:6379
# Application Services
api-service: 8000:8000
admin-frontend: 3000:3000
```
### 내부 통신
```yaml
# 컨테이너 간 통신은 서비스명 사용
mongodb: mongodb://{user}:{password}@mongodb:27017/
redis: redis://redis:6379
```
## 모니터링 접근점
### 헬스체크 엔드포인트
```bash
# MongoDB
docker exec {프로젝트}-mongodb mongosh --eval "db.adminCommand('ping')"
# Redis
docker exec {프로젝트}-redis redis-cli ping
# FastAPI 서비스
curl http://localhost:{포트}/health
```
### 로그 수집
```bash
# 전체 로그
docker-compose logs -f
# 특정 서비스 로그
docker-compose logs -f {서비스명}
# 최근 100줄만
docker-compose logs --tail=100 {서비스명}
```

View File

@ -0,0 +1,174 @@
# 한국어 개발 컨벤션 (Korean Dev Conventions)
이 프로젝트의 한국어 개발 규칙입니다.
## 주석 규칙
### Python Docstring
```python
async def get_organization_info(self, name: str, context: List[str] = None) -> OrganizationInfo:
"""
조직/단체 정보 조회 (context 기반 후보 선택)
Args:
name: 조직 이름
context: 기사에서 추출한 컨텍스트 키워드 (산업, 유형 등)
Returns:
OrganizationInfo with founding_year, wikipedia_url, and image_url if found
"""
```
### 섹션 구분 주석
```python
# ===================
# Infrastructure (독립 포트 사용)
# ===================
```
### 인라인 주석
```python
# 캐시에서 조회 (context 기반 매칭)
cached_data, needs_refresh = await self.entity_cache.get_person(name, context=context)
# P154 = logo image
"property": "P154",
```
## 로깅 메시지
### 한글 + 영문 혼용 패턴
```python
logger.info(f"Found {len(image_urls)} image(s) for '{name}' (logo preferred)")
logger.info(f"Article {news_id} enriched with Wikipedia data in {processing_time:.2f}s")
logger.warning(f"Biocode registration failed (non-critical): {e}")
```
### 워커 로그 패턴
```python
logger.info("Starting Wikipedia Enrichment Worker")
logger.info(f"Processing job {job.job_id} for Wikipedia enrichment")
logger.info(f"Job {job.job_id} forwarded to image_generation")
```
## 에러 처리
### Try-Except 패턴
```python
try:
info = await self.wikipedia_service.get_person_info(name, context=context)
except Exception as e:
logger.error(f"Error getting person info for '{name}': {e}")
```
### 비치명적 에러 처리
```python
try:
stats = await self.biocode_client.register_entities(people, organizations)
except Exception as e:
# Biocode 등록 실패는 전체 파이프라인을 중단시키지 않음
logger.warning(f"Biocode registration failed (non-critical): {e}")
```
## 변수/함수 네이밍
### Python (snake_case)
```python
# 변수
birth_date = "1990-01-15"
founding_year = 2004
image_urls = []
existing_names = set()
# 함수
def get_existing_names(biocode_data: dict) -> set:
async def _enrich_organizations(self, entities: List[Dict[str, Any]]):
```
### TypeScript (camelCase)
```typescript
// 변수
const articleCount = 100;
const isLoading = false;
// 함수
function getArticleById(id: string): Article
async function fetchDashboardStats(): Promise<Stats>
```
## 상수 정의
```python
# 영문 상수명 + 한글 주석
API_URL = "https://en.wikipedia.org/api/rest_v1/page/summary/{title}"
SEARCH_URL = "https://en.wikipedia.org/w/api.php"
# 기본값
DEFAULT_TIMEOUT = 10 # seconds
MAX_RETRIES = 3
```
## 타입 힌트
```python
from typing import Optional, Dict, Any, List
async def enrich_entities(
self,
people: List[Dict[str, Any]],
organizations: List[Dict[str, Any]]
) -> Dict[str, Any]:
"""엔티티 목록을 Wikipedia 정보로 보강 (context 지원)"""
```
## 커밋 메시지
### 형식
```
<type>: <description>
- <detail 1>
- <detail 2>
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
```
### 타입
- `feat`: 새로운 기능
- `fix`: 버그 수정
- `chore`: 설정, 문서 등 잡다한 작업
- `refactor`: 리팩토링
- `docs`: 문서 수정
### 예시
```
feat: Pass entity_type to biocode API
- biocode_worker: Forward entity_type (person/organization) to API
- Enables proper storage in famousPeople or famousOrganizations
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
```
## 파일 인코딩
- 모든 파일: UTF-8
- JSON 파일: `ensure_ascii=False` 사용
```python
with open(file_path, 'w', encoding='utf-8') as f:
json.dump(content, f, ensure_ascii=False, indent=2)
```
## 다이어그램
- **도구**: Mermaid 사용 (ASCII art 금지)
- **용도**: 아키텍처, 플로우차트, 시퀀스 다이어그램, ERD 등
```markdown
```mermaid
flowchart LR
A[입력] --> B{처리}
B --> C[출력]
```
```

View File

@ -0,0 +1,361 @@
# 모니터링 및 로깅 (Monitoring & Logging)
이 프로젝트의 모니터링 및 로깅 패턴입니다.
## Python 로깅
### 기본 설정
```python
import logging
logging.basicConfig(level=logging.INFO)
logger = logging.getLogger(__name__)
```
### 로깅 패턴
```python
# 정보성 로그
logger.info(f"Starting Wikipedia Enrichment Worker")
logger.info(f"Processing job {job.job_id} for Wikipedia enrichment")
logger.info(f"Found {len(image_urls)} image(s) for '{name}' (logo preferred)")
# 경고 로그 (비치명적 오류)
logger.warning(f"Biocode registration failed (non-critical): {e}")
logger.warning(f"Failed to get logo for '{title}': {e}")
# 에러 로그
logger.error(f"Error processing job {job.job_id}: {e}")
logger.error(f"Claude API key not configured")
# 디버그 로그
logger.debug(f"Selected candidate '{candidate.get('title')}' with score: {best_score}")
```
### 구조화된 로깅
```python
import json
def log_structured(level: str, message: str, **kwargs):
"""구조화된 JSON 로깅"""
log_entry = {
"timestamp": datetime.now().isoformat(),
"level": level,
"message": message,
**kwargs
}
print(json.dumps(log_entry))
# 사용 예시
log_structured("INFO", "Article processed",
job_id=job.job_id,
processing_time=processing_time,
people_count=len(enriched_people),
orgs_count=len(enriched_orgs)
)
```
## Docker 로그
### 로그 확인
```bash
# 전체 로그
docker-compose logs -f
# 특정 서비스 로그
docker-compose logs -f news-wikipedia-enrichment
# 최근 100줄만
docker-compose logs --tail=100 news-article-generator
# 시간 범위 지정
docker-compose logs --since 2024-01-15T10:00:00 news-wikipedia-enrichment
```
### 로그 드라이버 설정
```yaml
# docker-compose.yml
services:
news-wikipedia-enrichment:
logging:
driver: "json-file"
options:
max-size: "10m"
max-file: "3"
```
## Prometheus 설정
### docker-compose.yml
```yaml
services:
prometheus:
image: prom/prometheus:latest
container_name: {프로젝트}-prometheus
restart: unless-stopped
ports:
- "9090:9090"
volumes:
- ./prometheus/prometheus.yml:/etc/prometheus/prometheus.yml
- {프로젝트}_prometheus_data:/prometheus
networks:
- {프로젝트}-network
command:
- '--config.file=/etc/prometheus/prometheus.yml'
- '--storage.tsdb.path=/prometheus'
- '--web.enable-lifecycle'
```
### prometheus.yml
```yaml
global:
scrape_interval: 15s
evaluation_interval: 15s
scrape_configs:
- job_name: 'prometheus'
static_configs:
- targets: ['localhost:9090']
- job_name: 'fastapi-services'
static_configs:
- targets:
- 'base-auth:8000'
- 'base-image:8000'
- 'news-user-service:8000'
metrics_path: '/metrics'
- job_name: 'redis'
static_configs:
- targets: ['redis-exporter:9121']
- job_name: 'mongodb'
static_configs:
- targets: ['mongodb-exporter:9216']
```
### FastAPI 메트릭 노출
```python
from prometheus_client import Counter, Histogram, generate_latest
from fastapi import Response
# 메트릭 정의
REQUEST_COUNT = Counter(
'http_requests_total',
'Total HTTP requests',
['method', 'endpoint', 'status']
)
REQUEST_LATENCY = Histogram(
'http_request_duration_seconds',
'HTTP request latency',
['method', 'endpoint']
)
@app.get("/metrics")
async def metrics():
return Response(
content=generate_latest(),
media_type="text/plain"
)
@app.middleware("http")
async def track_metrics(request: Request, call_next):
start_time = time.time()
response = await call_next(request)
duration = time.time() - start_time
REQUEST_COUNT.labels(
method=request.method,
endpoint=request.url.path,
status=response.status_code
).inc()
REQUEST_LATENCY.labels(
method=request.method,
endpoint=request.url.path
).observe(duration)
return response
```
## Grafana 설정
### docker-compose.yml
```yaml
services:
grafana:
image: grafana/grafana:latest
container_name: {프로젝트}-grafana
restart: unless-stopped
ports:
- "3000:3000"
volumes:
- {프로젝트}_grafana_data:/var/lib/grafana
- ./grafana/provisioning:/etc/grafana/provisioning
environment:
- GF_SECURITY_ADMIN_USER=admin
- GF_SECURITY_ADMIN_PASSWORD=admin123
- GF_USERS_ALLOW_SIGN_UP=false
networks:
- {프로젝트}-network
```
### 데이터소스 프로비저닝
```yaml
# grafana/provisioning/datasources/datasources.yml
apiVersion: 1
datasources:
- name: Prometheus
type: prometheus
access: proxy
url: http://prometheus:9090
isDefault: true
editable: false
```
### 대시보드 예시 (JSON)
```json
{
"dashboard": {
"title": "News Pipeline Monitoring",
"panels": [
{
"title": "Request Rate",
"type": "graph",
"targets": [
{
"expr": "rate(http_requests_total[5m])",
"legendFormat": "{{method}} {{endpoint}}"
}
]
},
{
"title": "Request Latency (p95)",
"type": "graph",
"targets": [
{
"expr": "histogram_quantile(0.95, rate(http_request_duration_seconds_bucket[5m]))",
"legendFormat": "{{endpoint}}"
}
]
}
]
}
}
```
## 헬스체크
### FastAPI 헬스체크 엔드포인트
```python
@app.get("/health")
async def health_check():
"""헬스체크 엔드포인트"""
checks = {
"status": "healthy",
"timestamp": datetime.now().isoformat(),
"checks": {}
}
# MongoDB 체크
try:
await db.command("ping")
checks["checks"]["mongodb"] = "healthy"
except Exception as e:
checks["checks"]["mongodb"] = f"unhealthy: {e}"
checks["status"] = "unhealthy"
# Redis 체크
try:
await redis.ping()
checks["checks"]["redis"] = "healthy"
except Exception as e:
checks["checks"]["redis"] = f"unhealthy: {e}"
checks["status"] = "unhealthy"
status_code = 200 if checks["status"] == "healthy" else 503
return JSONResponse(content=checks, status_code=status_code)
```
### Docker 헬스체크
```yaml
healthcheck:
test: ["CMD", "curl", "-f", "http://localhost:8000/health"]
interval: 30s
timeout: 10s
retries: 3
start_period: 40s
```
## 워커 하트비트
### Redis 기반 하트비트
```python
class QueueManager:
async def start_heartbeat(self, worker_name: str):
"""워커 하트비트 시작"""
async def heartbeat_loop():
while True:
try:
await self.redis.setex(
f"worker:heartbeat:{worker_name}",
60, # 60초 TTL
datetime.now().isoformat()
)
await asyncio.sleep(30) # 30초마다 갱신
except Exception as e:
logger.error(f"Heartbeat error: {e}")
asyncio.create_task(heartbeat_loop())
async def get_active_workers(self) -> List[str]:
"""활성 워커 목록 조회"""
keys = await self.redis.keys("worker:heartbeat:*")
return [key.decode().split(":")[-1] for key in keys]
```
## 알림 설정 (Alertmanager)
### alertmanager.yml
```yaml
global:
slack_api_url: 'https://hooks.slack.com/services/xxx'
route:
receiver: 'slack-notifications'
group_wait: 30s
group_interval: 5m
repeat_interval: 4h
receivers:
- name: 'slack-notifications'
slack_configs:
- channel: '#alerts'
send_resolved: true
title: '{{ .GroupLabels.alertname }}'
text: '{{ range .Alerts }}{{ .Annotations.description }}{{ end }}'
```
### 알림 규칙
```yaml
# prometheus/rules/alerts.yml
groups:
- name: service-alerts
rules:
- alert: HighErrorRate
expr: rate(http_requests_total{status=~"5.."}[5m]) > 0.1
for: 5m
labels:
severity: critical
annotations:
description: "High error rate detected"
- alert: WorkerDown
expr: absent(up{job="fastapi-services"})
for: 1m
labels:
severity: warning
annotations:
description: "Worker service is down"
```

View File

@ -0,0 +1,117 @@
# 프로젝트 기술 스택 (Project Stack)
이 프로젝트의 기본 기술 스택과 구조입니다.
## Frontend (news-engine-admin)
### 기술 스택
- **Framework**: Next.js 16.x (App Router)
- **Language**: TypeScript 5.x
- **React**: 19.x
- **Styling**: Tailwind CSS 4.x
- **UI Components**: Radix UI + shadcn/ui
- **Form**: react-hook-form + zod
- **Icons**: lucide-react
- **Theme**: next-themes (다크모드 지원)
### 디렉토리 구조
```
src/
├── app/ # Next.js App Router 페이지
│ ├── layout.tsx # 루트 레이아웃
│ ├── page.tsx # 메인 페이지
│ └── dashboard/ # 대시보드 페이지들
├── components/ # React 컴포넌트
│ ├── ui/ # shadcn/ui 기본 컴포넌트
│ ├── dashboard/ # 대시보드 전용 컴포넌트
│ └── providers/ # Context Provider
├── hooks/ # 커스텀 훅
├── lib/ # 유틸리티 함수
│ └── utils.ts # cn() 등 공통 유틸
└── types/ # TypeScript 타입 정의
```
### 설치 명령어
```bash
# 프로젝트 생성
npx create-next-app@latest --typescript --tailwind --app
# shadcn/ui 초기화
npx shadcn@latest init
# 컴포넌트 추가
npx shadcn@latest add button card dialog tabs
```
## Backend (FastAPI 마이크로서비스)
### 기술 스택
- **Framework**: FastAPI
- **Language**: Python 3.11
- **Database**: MongoDB (motor - async driver)
- **Queue**: Redis (aioredis)
- **Validation**: Pydantic v2
- **HTTP Client**: aiohttp
### 마이크로서비스 구조
```
service/
├── Dockerfile
├── requirements.txt
├── worker.py # 메인 워커 (큐 처리)
├── *_service.py # 비즈니스 로직
└── *_client.py # 외부 서비스 클라이언트
```
### API 서비스 구조
```
service/
├── Dockerfile
├── requirements.txt
└── app/
├── __init__.py
├── main.py # FastAPI 앱 엔트리포인트
├── database.py # DB 연결 관리
├── models/ # Pydantic 모델
└── routers/ # API 라우터
```
## 공통 라이브러리 (news-commons)
### 제공 기능
- `QueueManager`: Redis 큐 관리 (enqueue, dequeue, heartbeat)
- `PipelineJob`: 파이프라인 작업 데이터 모델
- `PersonEntity`, `OrganizationEntity`: 엔티티 모델
- 로깅, 설정 유틸리티
### 사용 예시
```python
from news_commons import PipelineJob, QueueManager
queue_manager = QueueManager(redis_url="redis://redis:6379")
await queue_manager.connect()
job = await queue_manager.dequeue('wikipedia_enrichment', timeout=5)
```
## 인프라
### 컨테이너
- **MongoDB**: 7.0
- **Redis**: 7-alpine
- **Docker Compose**: 서비스 오케스트레이션
### 외부 서비스
- **Gitea**: 코드 저장소 (http://gitea.yakenator.io/)
- **OpenAI API**: GPT 모델 사용
- **Claude API**: Claude 모델 사용
## 참고 리포지토리
| 서비스 | 설명 | URL |
|--------|------|-----|
| news-commons | 공통 라이브러리 | gitea.yakenator.io/sapiens/news-commons |
| news-article-generator | 기사 생성 | gitea.yakenator.io/sapiens/news-article-generator |
| news-wikipedia-enrichment | 위키피디아 보강 | gitea.yakenator.io/sapiens/news-wikipedia-enrichment |
| news-image-generator | 이미지 생성 | gitea.yakenator.io/sapiens/news-image-generator |
| mcp_biocode | 바이오코드 API | gitea.yakenator.io/sapiens/mcp_biocode |

View File

@ -0,0 +1,360 @@
# 테스트 작성 표준 (Testing Standards)
이 프로젝트의 테스트 작성 패턴입니다.
## Python (pytest)
### 설치
```bash
pip install pytest pytest-asyncio pytest-cov
```
### 디렉토리 구조
```
service/
├── worker.py
├── service.py
├── tests/
│ ├── __init__.py
│ ├── conftest.py # 공통 fixture
│ ├── test_worker.py
│ └── test_service.py
└── pytest.ini
```
### pytest.ini 설정
```ini
[pytest]
asyncio_mode = auto
testpaths = tests
python_files = test_*.py
python_functions = test_*
```
### Fixture 패턴 (conftest.py)
```python
import pytest
import asyncio
from motor.motor_asyncio import AsyncIOMotorClient
@pytest.fixture(scope="session")
def event_loop():
"""세션 범위의 이벤트 루프"""
loop = asyncio.get_event_loop_policy().new_event_loop()
yield loop
loop.close()
@pytest.fixture
async def mongodb():
"""테스트용 MongoDB 연결"""
client = AsyncIOMotorClient("mongodb://localhost:27017")
db = client["test_db"]
yield db
# 테스트 후 정리
await client.drop_database("test_db")
client.close()
@pytest.fixture
def sample_article():
"""테스트용 기사 데이터"""
return {
"title": "Test Article",
"summary": "Test summary",
"entities": {
"people": [{"name": "Elon Musk", "context": ["Tesla", "CEO"]}],
"organizations": [{"name": "Tesla", "context": ["EV", "automotive"]}]
}
}
```
### 단위 테스트 예시
```python
import pytest
from service import calculate_biocode
def test_calculate_biocode_basic():
"""바이오코드 계산 기본 테스트"""
result = calculate_biocode(1990, 5, 15)
assert result is not None
assert len(result) == 4 # g코드 2자리 + s코드 2자리
def test_calculate_biocode_edge_cases():
"""경계값 테스트"""
# 연초
result = calculate_biocode(1990, 1, 1)
assert result.endswith("60") # 대설
# 연말
result = calculate_biocode(1990, 12, 31)
assert result is not None
```
### 비동기 테스트 예시
```python
import pytest
from wikipedia_service import WikipediaService
@pytest.mark.asyncio
async def test_get_person_info():
"""인물 정보 조회 테스트"""
service = WikipediaService()
try:
info = await service.get_person_info(
"Elon Musk",
context=["Tesla", "SpaceX"]
)
assert info is not None
assert info.name == "Elon Musk"
assert info.wikipedia_url is not None
finally:
await service.close()
@pytest.mark.asyncio
async def test_get_organization_info_with_logo():
"""조직 로고 우선 조회 테스트"""
service = WikipediaService()
try:
info = await service.get_organization_info(
"Apple Inc.",
context=["technology", "iPhone"]
)
assert info is not None
assert info.image_urls # 로고 이미지가 있어야 함
finally:
await service.close()
```
### Mock 사용 예시
```python
from unittest.mock import AsyncMock, patch
import pytest
@pytest.mark.asyncio
async def test_worker_process_job():
"""워커 작업 처리 테스트 (외부 API 모킹)"""
with patch('worker.WikipediaService') as mock_service:
mock_instance = AsyncMock()
mock_instance.get_person_info.return_value = PersonInfo(
name="Test Person",
birth_date="1990-01-15",
verified=True
)
mock_service.return_value = mock_instance
worker = WikipediaEnrichmentWorker()
# ... 테스트 수행
```
### 테스트 실행
```bash
# 전체 테스트
pytest
# 커버리지 포함
pytest --cov=. --cov-report=html
# 특정 테스트만
pytest tests/test_service.py -v
# 특정 함수만
pytest tests/test_service.py::test_calculate_biocode_basic -v
```
## JavaScript/TypeScript (Jest)
### 설치
```bash
npm install --save-dev jest @types/jest ts-jest
```
### jest.config.js
```javascript
module.exports = {
preset: 'ts-jest',
testEnvironment: 'node',
roots: ['<rootDir>/src'],
testMatch: ['**/*.test.ts', '**/*.spec.ts'],
collectCoverageFrom: [
'src/**/*.{ts,tsx}',
'!src/**/*.d.ts',
],
}
```
### 단위 테스트 예시
```typescript
// utils.test.ts
import { cn, formatDate } from './utils'
describe('cn utility', () => {
it('should merge class names', () => {
const result = cn('foo', 'bar')
expect(result).toBe('foo bar')
})
it('should handle conditional classes', () => {
const result = cn('foo', false && 'bar', 'baz')
expect(result).toBe('foo baz')
})
})
describe('formatDate', () => {
it('should format date correctly', () => {
const date = new Date('2024-01-15')
expect(formatDate(date)).toBe('2024-01-15')
})
})
```
### React 컴포넌트 테스트
```typescript
// Button.test.tsx
import { render, screen, fireEvent } from '@testing-library/react'
import { Button } from './Button'
describe('Button', () => {
it('renders correctly', () => {
render(<Button>Click me</Button>)
expect(screen.getByText('Click me')).toBeInTheDocument()
})
it('calls onClick handler', () => {
const handleClick = jest.fn()
render(<Button onClick={handleClick}>Click me</Button>)
fireEvent.click(screen.getByText('Click me'))
expect(handleClick).toHaveBeenCalledTimes(1)
})
it('applies variant styles', () => {
render(<Button variant="destructive">Delete</Button>)
const button = screen.getByText('Delete')
expect(button).toHaveClass('bg-destructive')
})
})
```
### API 테스트 (MSW Mock)
```typescript
// api.test.ts
import { rest } from 'msw'
import { setupServer } from 'msw/node'
import { fetchArticles } from './api'
const server = setupServer(
rest.get('/api/articles', (req, res, ctx) => {
return res(
ctx.json({
items: [{ id: '1', title: 'Test Article' }],
total: 1
})
)
})
)
beforeAll(() => server.listen())
afterEach(() => server.resetHandlers())
afterAll(() => server.close())
describe('fetchArticles', () => {
it('fetches articles successfully', async () => {
const articles = await fetchArticles()
expect(articles.items).toHaveLength(1)
expect(articles.items[0].title).toBe('Test Article')
})
})
```
### 테스트 실행
```bash
# 전체 테스트
npm test
# 감시 모드
npm test -- --watch
# 커버리지
npm test -- --coverage
# 특정 파일만
npm test -- Button.test.tsx
```
## E2E 테스트 (Playwright)
### 설치
```bash
npm install --save-dev @playwright/test
npx playwright install
```
### playwright.config.ts
```typescript
import { defineConfig } from '@playwright/test'
export default defineConfig({
testDir: './e2e',
fullyParallel: true,
forbidOnly: !!process.env.CI,
retries: process.env.CI ? 2 : 0,
use: {
baseURL: 'http://localhost:3000',
trace: 'on-first-retry',
},
})
```
### E2E 테스트 예시
```typescript
// e2e/dashboard.spec.ts
import { test, expect } from '@playwright/test'
test.describe('Dashboard', () => {
test('should display article list', async ({ page }) => {
await page.goto('/dashboard')
await expect(page.getByRole('heading', { name: 'Articles' })).toBeVisible()
await expect(page.getByRole('table')).toBeVisible()
})
test('should filter articles by keyword', async ({ page }) => {
await page.goto('/dashboard')
await page.fill('[placeholder="Search..."]', 'technology')
await page.click('button:has-text("Search")')
await expect(page.locator('table tbody tr')).toHaveCount(5)
})
})
```
## CI/CD 통합
### GitHub Actions
```yaml
# .github/workflows/test.yml
name: Tests
on: [push, pull_request]
jobs:
python-tests:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v3
- uses: actions/setup-python@v4
with:
python-version: '3.11'
- run: pip install -r requirements.txt
- run: pytest --cov
js-tests:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v3
- uses: actions/setup-node@v3
with:
node-version: '20'
- run: npm ci
- run: npm test -- --coverage
```

24
.env.example Normal file
View File

@ -0,0 +1,24 @@
# Audio Studio 환경 변수
# MongoDB
MONGO_USER=admin
MONGO_PASSWORD=your-mongo-password
# Redis
REDIS_URL=redis://redis:6379
# Hugging Face (모델 다운로드용)
HF_TOKEN=your-huggingface-token
# Freesound API (https://freesound.org/apiv2/apply 에서 발급)
FREESOUND_API_KEY=your-freesound-api-key
# 서비스 URL (내부 통신용)
TTS_ENGINE_URL=http://tts-engine:8001
MUSICGEN_URL=http://musicgen:8002
# 데이터베이스
DB_NAME=audio_studio
# JWT (추후 인증용)
JWT_SECRET_KEY=your-secret-key-change-in-production

40
.gitignore vendored Normal file
View File

@ -0,0 +1,40 @@
# Environment
.env
.env.*
!.env.example
# Dependencies
node_modules/
__pycache__/
*.pyc
.venv/
venv/
# Build outputs
.next/
dist/
build/
*.egg-info/
# IDE
.idea/
.vscode/
*.swp
*.swo
# OS
.DS_Store
Thumbs.db
# Logs
*.log
logs/
# Docker volumes
data/
# Models (large files)
models/
*.pt
*.bin
*.safetensors

58
CLAUDE.md Normal file
View File

@ -0,0 +1,58 @@
# CLAUDE.md
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
## 프로젝트 개요
**Flume** - K8s 기반 범용 파이프라인 시스템
**기술 스택:**
- Frontend: Next.js 16 + React 19 + TypeScript + Tailwind CSS 4 + shadcn/ui
- Backend: FastAPI + Python 3.11 + Pydantic v2
- Database: MongoDB 7.0 (motor async) + Redis 7
- AI: Claude API + OpenAI API + 로컬 LLM (vLLM)
- GPU: V100 16GB×8, V100 32GB×4, RTX 3090 24GB×2 (총 304GB VRAM)
- Container: Docker + Kubernetes
- Repository: Gitea (http://gitea.yakenator.io/yakenator/)
## 개발 컨벤션
### 언어
- Docstring, 주석, 로그: 한국어 + 영문 혼용
- 변수/함수: Python은 snake_case, TypeScript는 camelCase
- 파일 인코딩: UTF-8 (`ensure_ascii=False`)
### 커밋 메시지
```
<type>: <description>
- <detail>
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
```
타입: feat, fix, chore, refactor, docs
## 네이밍 규칙
- 컨테이너: `{프로젝트}-{서비스명}`
- 볼륨: `{프로젝트}_{데이터유형}_data`
- 네트워크: `{프로젝트}-network`
## 상세 가이드
프로젝트 표준 및 패턴은 `.claude/skills/` 디렉토리 참조:
| 우선순위 | 스킬 | 설명 |
|---------|------|------|
| 1 | deployment-standards | Docker 배포 규칙 |
| 1 | project-stack | 기술 스택 및 구조 |
| 1 | korean-dev-conventions | 한국어 컨벤션 |
| 1 | gpu-local-models | GPU 인프라 및 로컬 LLM |
| 1 | flume-architecture | Flume 파이프라인 시스템 |
| 2 | ai-api-integration | Claude/OpenAI 통합 |
| 2 | api-design-standards | RESTful API 설계 |
| 2 | frontend-component-patterns | React 컴포넌트 |
| 2 | database-patterns | MongoDB 패턴 |
| 3 | gitea-workflow | Git 워크플로우 |
| 3 | testing-standards | 테스트 작성 |
| 3 | monitoring-logging | 모니터링 |

View File

@ -0,0 +1,33 @@
# Audio Studio API Server - Dockerfile
FROM python:3.11-slim
# 환경 변수
ENV PYTHONUNBUFFERED=1
ENV PYTHONDONTWRITEBYTECODE=1
# 시스템 패키지 설치
RUN apt-get update && apt-get install -y --no-install-recommends \
curl \
libsndfile1 \
&& rm -rf /var/lib/apt/lists/*
# 작업 디렉토리
WORKDIR /app
# 의존성 설치 (캐시 활용)
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
# 소스 코드 복사
COPY app/ ./app/
# 포트 노출
EXPOSE 8000
# 헬스체크
HEALTHCHECK --interval=30s --timeout=10s --start-period=10s --retries=3 \
CMD curl -f http://localhost:8000/health || exit 1
# 서버 실행
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"]

View File

View File

@ -0,0 +1,169 @@
"""데이터베이스 연결 설정
MongoDB (motor async) + GridFS (오디오 저장)
"""
import os
import logging
from typing import Optional
from motor.motor_asyncio import AsyncIOMotorClient, AsyncIOMotorDatabase, AsyncIOMotorGridFSBucket
from redis.asyncio import Redis
logger = logging.getLogger(__name__)
class Database:
"""데이터베이스 연결 관리"""
def __init__(self):
self.client: Optional[AsyncIOMotorClient] = None
self.db: Optional[AsyncIOMotorDatabase] = None
self.gridfs: Optional[AsyncIOMotorGridFSBucket] = None
self.redis: Optional[Redis] = None
async def connect(self):
"""데이터베이스 연결"""
# MongoDB
mongodb_url = os.getenv("MONGODB_URL", "mongodb://localhost:27017/")
db_name = os.getenv("DB_NAME", "audio_studio")
logger.info(f"MongoDB 연결 중: {db_name}")
self.client = AsyncIOMotorClient(mongodb_url)
self.db = self.client[db_name]
# GridFS (오디오 파일 저장용)
self.gridfs = AsyncIOMotorGridFSBucket(self.db, bucket_name="audio_files")
# 연결 테스트
await self.client.admin.command("ping")
logger.info("MongoDB 연결 성공")
# Redis
redis_url = os.getenv("REDIS_URL", "redis://localhost:6379")
logger.info("Redis 연결 중...")
self.redis = Redis.from_url(redis_url, decode_responses=True)
# 연결 테스트
await self.redis.ping()
logger.info("Redis 연결 성공")
# 인덱스 생성
await self._create_indexes()
async def _create_indexes(self):
"""컬렉션 인덱스 생성"""
# voices 컬렉션
await self.db.voices.create_index("voice_id", unique=True)
await self.db.voices.create_index("owner_id")
await self.db.voices.create_index("type")
await self.db.voices.create_index("language")
await self.db.voices.create_index("is_public")
# tts_generations 컬렉션
await self.db.tts_generations.create_index("generation_id", unique=True)
await self.db.tts_generations.create_index("user_id")
await self.db.tts_generations.create_index("voice_id")
await self.db.tts_generations.create_index("created_at")
# sound_effects 컬렉션
await self.db.sound_effects.create_index("source_id")
await self.db.sound_effects.create_index("categories")
await self.db.sound_effects.create_index("tags")
# music_tracks 컬렉션
await self.db.music_tracks.create_index("source")
await self.db.music_tracks.create_index("genre")
await self.db.music_tracks.create_index("mood")
logger.info("인덱스 생성 완료")
async def disconnect(self):
"""데이터베이스 연결 해제"""
if self.client:
self.client.close()
logger.info("MongoDB 연결 해제")
if self.redis:
await self.redis.close()
logger.info("Redis 연결 해제")
# ========================================
# 컬렉션 접근자
# ========================================
@property
def voices(self):
"""voices 컬렉션"""
return self.db.voices
@property
def tts_generations(self):
"""tts_generations 컬렉션"""
return self.db.tts_generations
@property
def sound_effects(self):
"""sound_effects 컬렉션"""
return self.db.sound_effects
@property
def music_tracks(self):
"""music_tracks 컬렉션"""
return self.db.music_tracks
@property
def user_voice_library(self):
"""user_voice_library 컬렉션"""
return self.db.user_voice_library
# ========================================
# GridFS 오디오 저장
# ========================================
async def save_audio(
self,
audio_bytes: bytes,
filename: str,
content_type: str = "audio/wav",
metadata: dict = None,
) -> str:
"""오디오 파일을 GridFS에 저장
Returns:
file_id (str)
"""
file_id = await self.gridfs.upload_from_stream(
filename,
audio_bytes,
metadata={
"content_type": content_type,
**(metadata or {}),
}
)
return str(file_id)
async def get_audio(self, file_id: str) -> bytes:
"""GridFS에서 오디오 파일 읽기"""
from bson import ObjectId
from io import BytesIO
buffer = BytesIO()
await self.gridfs.download_to_stream(ObjectId(file_id), buffer)
buffer.seek(0)
return buffer.read()
async def delete_audio(self, file_id: str):
"""GridFS에서 오디오 파일 삭제"""
from bson import ObjectId
await self.gridfs.delete(ObjectId(file_id))
# 싱글톤 인스턴스
db = Database()
# FastAPI 의존성
async def get_db() -> Database:
"""데이터베이스 인스턴스 반환 (의존성 주입용)"""
return db

View File

@ -0,0 +1,163 @@
"""Drama Studio API Server
AI 라디오 드라마 제작 - TTS, 보이스, 효과음, 배경음악, 드라마 생성 API
"""
import logging
from contextlib import asynccontextmanager
from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware
from fastapi.responses import JSONResponse
from app.database import db
from app.routers import voices, tts, recordings, sound_effects, music, drama
# 로깅 설정
logging.basicConfig(
level=logging.INFO,
format="%(asctime)s - %(name)s - %(levelname)s - %(message)s"
)
logger = logging.getLogger(__name__)
# ========================================
# 앱 생명주기
# ========================================
@asynccontextmanager
async def lifespan(app: FastAPI):
"""앱 시작/종료 시 실행"""
# 시작 시 DB 연결
logger.info("Drama Studio API 서버 시작...")
try:
await db.connect()
logger.info("데이터베이스 연결 완료")
except Exception as e:
logger.error(f"데이터베이스 연결 실패: {e}")
raise
yield
# 종료 시 DB 연결 해제
await db.disconnect()
logger.info("Drama Studio API 서버 종료")
# ========================================
# FastAPI 앱
# ========================================
app = FastAPI(
title="Drama Studio API",
description="""
Drama Studio API - AI 라디오 드라마 제작 플랫폼
## 기능
### Voice (보이스 관리)
- 프리셋 보이스 목록 조회
- Voice Clone (목소리 복제)
- Voice Design (AI 음성 생성)
- 사용자 보이스 라이브러리
### TTS (음성 합성)
- 텍스트를 음성으로 변환
- 다양한 언어 지원 (한국어, 영어, 일본어 등)
### Recording (녹음)
- 녹음 업로드 및 품질 검증
- Voice Clone용 레퍼런스 관리
### Sound Effects (효과음)
- Freesound 검색 및 다운로드
- 로컬 효과음 라이브러리
### Drama (드라마 생성)
- 스크립트 기반 라디오 드라마 생성
- 자동 TTS/BGM/효과음 합성
- 타임라인 기반 오디오 믹싱
""",
version="0.1.0",
lifespan=lifespan,
)
# CORS 설정
app.add_middleware(
CORSMiddleware,
allow_origins=["*"], # 개발 환경용, 프로덕션에서는 제한 필요
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],
)
# ========================================
# 라우터 등록
# ========================================
app.include_router(voices.router)
app.include_router(tts.router)
app.include_router(recordings.router)
app.include_router(sound_effects.router)
app.include_router(music.router)
app.include_router(drama.router)
# ========================================
# 기본 엔드포인트
# ========================================
@app.get("/")
async def root():
"""API 루트"""
return {
"name": "Drama Studio API",
"version": "0.1.0",
"docs": "/docs",
}
@app.get("/health")
async def health_check():
"""헬스체크"""
try:
# MongoDB 연결 확인
await db.client.admin.command("ping")
mongo_status = "healthy"
except Exception as e:
mongo_status = f"unhealthy: {str(e)}"
try:
# Redis 연결 확인
await db.redis.ping()
redis_status = "healthy"
except Exception as e:
redis_status = f"unhealthy: {str(e)}"
status = "healthy" if mongo_status == "healthy" and redis_status == "healthy" else "degraded"
return JSONResponse(
status_code=200 if status == "healthy" else 503,
content={
"status": status,
"services": {
"mongodb": mongo_status,
"redis": redis_status,
},
}
)
# ========================================
# 에러 핸들러
# ========================================
@app.exception_handler(Exception)
async def global_exception_handler(request, exc):
"""전역 예외 핸들러"""
logger.error(f"Unhandled exception: {exc}", exc_info=True)
return JSONResponse(
status_code=500,
content={"detail": "Internal server error"},
)

View File

View File

@ -0,0 +1,193 @@
# 드라마 API 라우터
from fastapi import APIRouter, HTTPException, BackgroundTasks
from fastapi.responses import FileResponse
from typing import Optional
import os
from app.models.drama import (
DramaCreateRequest, DramaGenerateRequest, DramaResponse,
ParsedScript, Character
)
from app.services.script_parser import script_parser
from app.services.drama_orchestrator import drama_orchestrator
router = APIRouter(prefix="/api/v1/drama", tags=["drama"])
@router.post("/parse", response_model=ParsedScript)
async def parse_script(script: str):
"""
스크립트 파싱 (미리보기)
마크다운 형식의 스크립트를 구조화된 데이터로 변환합니다.
실제 프로젝트 생성 없이 파싱 결과만 확인할 수 있습니다.
"""
is_valid, errors = script_parser.validate_script(script)
if not is_valid:
raise HTTPException(status_code=400, detail={"errors": errors})
return script_parser.parse(script)
@router.post("/projects", response_model=DramaResponse)
async def create_project(request: DramaCreateRequest):
"""
새 드라마 프로젝트 생성
스크립트를 파싱하고 프로젝트를 생성합니다.
voice_mapping으로 캐릭터별 보이스를 지정할 수 있습니다.
"""
# 스크립트 유효성 검사
is_valid, errors = script_parser.validate_script(request.script)
if not is_valid:
raise HTTPException(status_code=400, detail={"errors": errors})
project = await drama_orchestrator.create_project(request)
return DramaResponse(
project_id=project.project_id,
title=project.title,
status=project.status,
characters=project.script_parsed.characters if project.script_parsed else [],
element_count=len(project.script_parsed.elements) if project.script_parsed else 0,
estimated_duration=drama_orchestrator.estimate_duration(project.script_parsed) if project.script_parsed else None
)
@router.get("/projects", response_model=list[DramaResponse])
async def list_projects(skip: int = 0, limit: int = 20):
"""프로젝트 목록 조회"""
projects = await drama_orchestrator.list_projects(skip=skip, limit=limit)
return [
DramaResponse(
project_id=p.project_id,
title=p.title,
status=p.status,
characters=p.script_parsed.characters if p.script_parsed else [],
element_count=len(p.script_parsed.elements) if p.script_parsed else 0,
output_file_id=p.output_file_id,
error_message=p.error_message
)
for p in projects
]
@router.get("/projects/{project_id}", response_model=DramaResponse)
async def get_project(project_id: str):
"""프로젝트 상세 조회"""
project = await drama_orchestrator.get_project(project_id)
if not project:
raise HTTPException(status_code=404, detail="프로젝트를 찾을 수 없습니다")
return DramaResponse(
project_id=project.project_id,
title=project.title,
status=project.status,
characters=project.script_parsed.characters if project.script_parsed else [],
element_count=len(project.script_parsed.elements) if project.script_parsed else 0,
estimated_duration=drama_orchestrator.estimate_duration(project.script_parsed) if project.script_parsed else None,
output_file_id=project.output_file_id,
error_message=project.error_message
)
@router.post("/projects/{project_id}/render")
async def render_project(
project_id: str,
background_tasks: BackgroundTasks,
output_format: str = "wav"
):
"""
드라마 렌더링 시작
백그라운드에서 TTS 생성, 효과음 검색, 믹싱을 수행합니다.
완료되면 status가 'completed'로 변경됩니다.
"""
project = await drama_orchestrator.get_project(project_id)
if not project:
raise HTTPException(status_code=404, detail="프로젝트를 찾을 수 없습니다")
if project.status == "processing":
raise HTTPException(status_code=400, detail="이미 렌더링 중입니다")
# 백그라운드 렌더링 시작
background_tasks.add_task(
drama_orchestrator.render,
project_id,
output_format
)
return {
"project_id": project_id,
"status": "processing",
"message": "렌더링이 시작되었습니다"
}
@router.get("/projects/{project_id}/download")
async def download_project(project_id: str):
"""렌더링된 드라마 다운로드"""
project = await drama_orchestrator.get_project(project_id)
if not project:
raise HTTPException(status_code=404, detail="프로젝트를 찾을 수 없습니다")
if project.status != "completed":
raise HTTPException(
status_code=400,
detail=f"렌더링이 완료되지 않았습니다 (현재 상태: {project.status})"
)
if not project.output_file_id or not os.path.exists(project.output_file_id):
raise HTTPException(status_code=404, detail="출력 파일을 찾을 수 없습니다")
return FileResponse(
project.output_file_id,
media_type="audio/wav",
filename=f"{project.title}.wav"
)
@router.put("/projects/{project_id}/voices")
async def update_voice_mapping(
project_id: str,
voice_mapping: dict[str, str]
):
"""캐릭터-보이스 매핑 업데이트"""
project = await drama_orchestrator.get_project(project_id)
if not project:
raise HTTPException(status_code=404, detail="프로젝트를 찾을 수 없습니다")
from app.database import db
from datetime import datetime
await db.dramas.update_one(
{"project_id": project_id},
{
"$set": {
"voice_mapping": voice_mapping,
"updated_at": datetime.utcnow()
}
}
)
return {"message": "보이스 매핑이 업데이트되었습니다"}
@router.delete("/projects/{project_id}")
async def delete_project(project_id: str):
"""프로젝트 삭제"""
project = await drama_orchestrator.get_project(project_id)
if not project:
raise HTTPException(status_code=404, detail="프로젝트를 찾을 수 없습니다")
from app.database import db
# 출력 파일 삭제
if project.output_file_id and os.path.exists(project.output_file_id):
os.remove(project.output_file_id)
# DB에서 삭제
await db.dramas.delete_one({"project_id": project_id})
return {"message": "프로젝트가 삭제되었습니다"}

View File

@ -0,0 +1,278 @@
"""배경음악 API 라우터
MusicGen 연동 및 외부 음악 소스
"""
import os
import uuid
from datetime import datetime
from typing import Optional, List
from fastapi import APIRouter, HTTPException, Depends, Query, UploadFile, File, Form
from fastapi.responses import Response
from pydantic import BaseModel, Field
import httpx
from app.database import Database, get_db
router = APIRouter(prefix="/api/v1/music", tags=["music"])
MUSICGEN_URL = os.getenv("MUSICGEN_URL", "http://localhost:8002")
# ========================================
# Pydantic 모델
# ========================================
class MusicGenerateRequest(BaseModel):
"""음악 생성 요청"""
prompt: str = Field(..., min_length=5, max_length=500, description="음악 설명")
duration: int = Field(default=30, ge=5, le=30, description="생성 길이 (초)")
save_to_library: bool = Field(default=True, description="라이브러리에 저장")
class MusicTrackResponse(BaseModel):
"""음악 트랙 응답"""
id: str
name: str
description: Optional[str] = None
source: str # musicgen | pixabay | uploaded
generation_prompt: Optional[str] = None
duration_seconds: float
genre: Optional[str] = None
mood: List[str] = []
license: str = ""
created_at: datetime
class MusicListResponse(BaseModel):
"""음악 목록 응답"""
tracks: List[MusicTrackResponse]
total: int
page: int
page_size: int
# ========================================
# API 엔드포인트
# ========================================
@router.post("/generate")
async def generate_music(
request: MusicGenerateRequest,
db: Database = Depends(get_db),
):
"""AI로 배경음악 생성
MusicGen을 사용하여 텍스트 프롬프트 기반 음악 생성
"""
try:
# MusicGen 서비스 호출
async with httpx.AsyncClient(timeout=120.0) as client:
response = await client.post(
f"{MUSICGEN_URL}/generate",
json={
"prompt": request.prompt,
"duration": request.duration,
},
)
response.raise_for_status()
audio_bytes = response.content
except httpx.TimeoutException:
raise HTTPException(status_code=504, detail="Music generation timed out")
except httpx.HTTPStatusError as e:
raise HTTPException(status_code=502, detail=f"MusicGen error: {e.response.text}")
except Exception as e:
raise HTTPException(status_code=500, detail=f"Music generation failed: {str(e)}")
# 라이브러리에 저장
if request.save_to_library:
track_id = f"music_{uuid.uuid4().hex[:12]}"
now = datetime.utcnow()
# GridFS에 오디오 저장
audio_file_id = await db.save_audio(
audio_bytes,
f"{track_id}.wav",
metadata={
"type": "generated_music",
"prompt": request.prompt,
},
)
# DB에 트랙 정보 저장
track_doc = {
"track_id": track_id,
"name": f"Generated: {request.prompt[:30]}...",
"description": request.prompt,
"source": "musicgen",
"generation_prompt": request.prompt,
"audio_file_id": audio_file_id,
"duration_seconds": request.duration,
"format": "wav",
"genre": None,
"mood": [],
"license": "CC-BY-NC", # MusicGen 모델 라이센스
"created_at": now,
}
await db.music_tracks.insert_one(track_doc)
return Response(
content=audio_bytes,
media_type="audio/wav",
headers={
"X-Duration": str(request.duration),
"Content-Disposition": 'attachment; filename="generated_music.wav"',
},
)
@router.get("/library", response_model=MusicListResponse)
async def list_music_library(
page: int = Query(1, ge=1),
page_size: int = Query(20, ge=1, le=100),
source: Optional[str] = Query(None, description="소스 필터 (musicgen, pixabay, uploaded)"),
genre: Optional[str] = Query(None, description="장르 필터"),
db: Database = Depends(get_db),
):
"""음악 라이브러리 목록 조회"""
query = {}
if source:
query["source"] = source
if genre:
query["genre"] = genre
total = await db.music_tracks.count_documents(query)
skip = (page - 1) * page_size
cursor = db.music_tracks.find(query).sort("created_at", -1).skip(skip).limit(page_size)
tracks = []
async for doc in cursor:
tracks.append(MusicTrackResponse(
id=doc.get("track_id", str(doc["_id"])),
name=doc["name"],
description=doc.get("description"),
source=doc.get("source", "unknown"),
generation_prompt=doc.get("generation_prompt"),
duration_seconds=doc.get("duration_seconds", 0),
genre=doc.get("genre"),
mood=doc.get("mood", []),
license=doc.get("license", ""),
created_at=doc.get("created_at", datetime.utcnow()),
))
return MusicListResponse(
tracks=tracks,
total=total,
page=page,
page_size=page_size,
)
@router.get("/{track_id}")
async def get_music_track(
track_id: str,
db: Database = Depends(get_db),
):
"""음악 트랙 상세 정보"""
doc = await db.music_tracks.find_one({"track_id": track_id})
if not doc:
raise HTTPException(status_code=404, detail="Track not found")
return MusicTrackResponse(
id=doc.get("track_id", str(doc["_id"])),
name=doc["name"],
description=doc.get("description"),
source=doc.get("source", "unknown"),
generation_prompt=doc.get("generation_prompt"),
duration_seconds=doc.get("duration_seconds", 0),
genre=doc.get("genre"),
mood=doc.get("mood", []),
license=doc.get("license", ""),
created_at=doc.get("created_at", datetime.utcnow()),
)
@router.get("/{track_id}/audio")
async def get_music_audio(
track_id: str,
db: Database = Depends(get_db),
):
"""음악 오디오 스트리밍"""
doc = await db.music_tracks.find_one({"track_id": track_id})
if not doc:
raise HTTPException(status_code=404, detail="Track not found")
audio_file_id = doc.get("audio_file_id")
if not audio_file_id:
raise HTTPException(status_code=404, detail="Audio file not found")
audio_bytes = await db.get_audio(audio_file_id)
return Response(
content=audio_bytes,
media_type="audio/wav",
headers={"Content-Disposition": f'inline; filename="{track_id}.wav"'},
)
@router.delete("/{track_id}")
async def delete_music_track(
track_id: str,
db: Database = Depends(get_db),
):
"""음악 트랙 삭제"""
doc = await db.music_tracks.find_one({"track_id": track_id})
if not doc:
raise HTTPException(status_code=404, detail="Track not found")
# 오디오 파일 삭제
if doc.get("audio_file_id"):
await db.delete_audio(doc["audio_file_id"])
# 문서 삭제
await db.music_tracks.delete_one({"track_id": track_id})
return {"status": "deleted", "track_id": track_id}
@router.get("/prompts/examples")
async def get_example_prompts():
"""예시 프롬프트 목록"""
try:
async with httpx.AsyncClient(timeout=10.0) as client:
response = await client.get(f"{MUSICGEN_URL}/prompts")
response.raise_for_status()
return response.json()
except Exception:
# MusicGen 서비스 연결 실패 시 기본 프롬프트 반환
return {
"examples": [
{
"category": "Ambient",
"prompts": [
"calm piano music, peaceful, ambient",
"lo-fi hip hop beats, relaxing, study music",
"meditation music, calm, zen",
],
},
{
"category": "Electronic",
"prompts": [
"upbeat electronic dance music",
"retro synthwave 80s style",
"chill electronic ambient",
],
},
{
"category": "Cinematic",
"prompts": [
"epic orchestral cinematic music",
"tense suspenseful thriller music",
"cheerful happy video game background",
],
},
]
}

View File

@ -0,0 +1,184 @@
"""녹음 관리 API 라우터"""
import uuid
import io
from typing import List
from fastapi import APIRouter, HTTPException, Depends, UploadFile, File, Form
from fastapi.responses import Response
import soundfile as sf
import numpy as np
from app.database import Database, get_db
from app.models.voice import RecordingValidateResponse, RecordingUploadResponse
router = APIRouter(prefix="/api/v1/recordings", tags=["recordings"])
def analyze_audio(audio_bytes: bytes) -> dict:
"""오디오 파일 분석
Returns:
duration, sample_rate, quality_score, issues
"""
try:
# 오디오 로드
audio_data, sample_rate = sf.read(io.BytesIO(audio_bytes))
# 모노로 변환
if len(audio_data.shape) > 1:
audio_data = audio_data.mean(axis=1)
duration = len(audio_data) / sample_rate
# 품질 분석
issues = []
quality_score = 1.0
# 길이 체크
if duration < 1.0:
issues.append("오디오가 너무 짧습니다 (최소 1초 이상)")
quality_score -= 0.3
elif duration < 3.0:
issues.append("Voice Clone에는 3초 이상의 오디오가 권장됩니다")
quality_score -= 0.1
# RMS 레벨 체크 (볼륨)
rms = np.sqrt(np.mean(audio_data ** 2))
if rms < 0.01:
issues.append("볼륨이 너무 낮습니다")
quality_score -= 0.2
elif rms > 0.5:
issues.append("볼륨이 너무 높습니다 (클리핑 가능성)")
quality_score -= 0.1
# 피크 체크
peak = np.max(np.abs(audio_data))
if peak > 0.99:
issues.append("오디오가 클리핑되었습니다")
quality_score -= 0.2
# 노이즈 체크 (간단한 휴리스틱)
# 실제로는 더 정교한 노이즈 감지 필요
silence_threshold = 0.01
silent_samples = np.sum(np.abs(audio_data) < silence_threshold)
silence_ratio = silent_samples / len(audio_data)
if silence_ratio > 0.7:
issues.append("대부분이 무음입니다")
quality_score -= 0.3
elif silence_ratio > 0.5:
issues.append("무음 구간이 많습니다")
quality_score -= 0.1
quality_score = max(0.0, min(1.0, quality_score))
return {
"duration": duration,
"sample_rate": sample_rate,
"quality_score": quality_score,
"issues": issues,
"rms": float(rms),
"peak": float(peak),
}
except Exception as e:
return {
"duration": 0,
"sample_rate": 0,
"quality_score": 0,
"issues": [f"오디오 분석 실패: {str(e)}"],
}
@router.post("/validate", response_model=RecordingValidateResponse)
async def validate_recording(
audio: UploadFile = File(..., description="검증할 오디오 파일"),
):
"""녹음 품질 검증
Voice Clone에 사용할 녹음의 품질을 검증합니다.
"""
audio_bytes = await audio.read()
if len(audio_bytes) < 1000:
raise HTTPException(status_code=400, detail="파일이 너무 작습니다")
analysis = analyze_audio(audio_bytes)
return RecordingValidateResponse(
valid=analysis["quality_score"] > 0.5 and analysis["duration"] > 1.0,
duration=analysis["duration"],
sample_rate=analysis["sample_rate"],
quality_score=analysis["quality_score"],
issues=analysis["issues"],
)
@router.post("/upload", response_model=RecordingUploadResponse)
async def upload_recording(
audio: UploadFile = File(..., description="업로드할 오디오 파일"),
transcript: str = Form(None, description="오디오의 텍스트 내용"),
db: Database = Depends(get_db),
):
"""녹음 파일 업로드
Voice Clone에 사용할 녹음을 업로드합니다.
"""
audio_bytes = await audio.read()
# 품질 분석
analysis = analyze_audio(audio_bytes)
if analysis["duration"] < 0.5:
raise HTTPException(status_code=400, detail="오디오가 너무 짧습니다")
# GridFS에 저장
file_id = await db.save_audio(
audio_bytes,
audio.filename or f"recording_{uuid.uuid4()}.wav",
metadata={
"type": "recording",
"transcript": transcript,
"duration": analysis["duration"],
"sample_rate": analysis["sample_rate"],
"quality_score": analysis["quality_score"],
},
)
return RecordingUploadResponse(
file_id=file_id,
filename=audio.filename or "recording.wav",
duration=analysis["duration"],
sample_rate=analysis["sample_rate"],
)
@router.get("/{file_id}")
async def get_recording(
file_id: str,
db: Database = Depends(get_db),
):
"""녹음 파일 다운로드"""
try:
audio_bytes = await db.get_audio(file_id)
return Response(
content=audio_bytes,
media_type="audio/wav",
headers={"Content-Disposition": f'attachment; filename="{file_id}.wav"'},
)
except Exception as e:
raise HTTPException(status_code=404, detail="Recording not found")
@router.delete("/{file_id}")
async def delete_recording(
file_id: str,
db: Database = Depends(get_db),
):
"""녹음 파일 삭제"""
try:
await db.delete_audio(file_id)
return {"status": "deleted", "file_id": file_id}
except Exception as e:
raise HTTPException(status_code=404, detail="Recording not found")

View File

@ -0,0 +1,340 @@
"""효과음 API 라우터
Freesound API 연동
"""
import uuid
from datetime import datetime
from typing import Optional, List
from fastapi import APIRouter, HTTPException, Depends, Query
from fastapi.responses import Response
from pydantic import BaseModel
from app.database import Database, get_db
from app.services.freesound_client import freesound_client
router = APIRouter(prefix="/api/v1/sound-effects", tags=["sound-effects"])
# ========================================
# Pydantic 모델
# ========================================
class SoundEffectResponse(BaseModel):
"""효과음 응답"""
id: str
freesound_id: Optional[int] = None
name: str
description: str
duration: float
tags: List[str] = []
preview_url: Optional[str] = None
license: str = ""
username: Optional[str] = None
source: str = "freesound" # freesound | local
class SoundEffectSearchResponse(BaseModel):
"""효과음 검색 응답"""
count: int
page: int
page_size: int
results: List[SoundEffectResponse]
class SoundEffectImportRequest(BaseModel):
"""효과음 가져오기 요청"""
freesound_id: int
# ========================================
# API 엔드포인트
# ========================================
@router.get("/search", response_model=SoundEffectSearchResponse)
async def search_sound_effects(
query: str = Query(..., min_length=1, description="검색어"),
page: int = Query(1, ge=1),
page_size: int = Query(20, ge=1, le=100),
min_duration: Optional[float] = Query(None, ge=0, description="최소 길이 (초)"),
max_duration: Optional[float] = Query(None, ge=0, description="최대 길이 (초)"),
sort: str = Query("score", description="정렬 (score, duration_asc, duration_desc)"),
):
"""Freesound에서 효과음 검색"""
try:
result = await freesound_client.search(
query=query,
page=page,
page_size=page_size,
min_duration=min_duration,
max_duration=max_duration,
sort=sort,
)
# 응답 형식 변환
sounds = []
for item in result["results"]:
sounds.append(SoundEffectResponse(
id=f"fs_{item['freesound_id']}",
freesound_id=item["freesound_id"],
name=item["name"],
description=item["description"],
duration=item["duration"],
tags=item["tags"],
preview_url=item["preview_url"],
license=item["license"],
username=item.get("username"),
source="freesound",
))
return SoundEffectSearchResponse(
count=result["count"],
page=page,
page_size=page_size,
results=sounds,
)
except Exception as e:
raise HTTPException(status_code=500, detail=f"Search failed: {str(e)}")
@router.get("/library", response_model=SoundEffectSearchResponse)
async def list_local_sound_effects(
page: int = Query(1, ge=1),
page_size: int = Query(20, ge=1, le=100),
category: Optional[str] = Query(None, description="카테고리 필터"),
db: Database = Depends(get_db),
):
"""로컬 효과음 라이브러리 조회"""
query = {}
if category:
query["categories"] = category
total = await db.sound_effects.count_documents(query)
skip = (page - 1) * page_size
cursor = db.sound_effects.find(query).sort("created_at", -1).skip(skip).limit(page_size)
sounds = []
async for doc in cursor:
sounds.append(SoundEffectResponse(
id=str(doc["_id"]),
freesound_id=doc.get("source_id"),
name=doc["name"],
description=doc.get("description", ""),
duration=doc.get("duration_seconds", 0),
tags=doc.get("tags", []),
preview_url=None, # 로컬 파일은 별도 엔드포인트로 제공
license=doc.get("license", ""),
source="local",
))
return SoundEffectSearchResponse(
count=total,
page=page,
page_size=page_size,
results=sounds,
)
@router.post("/import", response_model=SoundEffectResponse)
async def import_sound_effect(
request: SoundEffectImportRequest,
db: Database = Depends(get_db),
):
"""Freesound에서 효과음 가져오기 (로컬 캐시)"""
try:
# Freesound에서 상세 정보 조회
sound_info = await freesound_client.get_sound(request.freesound_id)
# 프리뷰 다운로드
preview_url = sound_info.get("previews", {}).get("preview-hq-mp3", "")
if not preview_url:
raise HTTPException(status_code=400, detail="Preview not available")
audio_bytes = await freesound_client.download_preview(preview_url)
# GridFS에 저장
file_id = await db.save_audio(
audio_bytes,
f"sfx_{request.freesound_id}.mp3",
content_type="audio/mpeg",
metadata={"freesound_id": request.freesound_id},
)
# DB에 메타데이터 저장
now = datetime.utcnow()
doc = {
"name": sound_info.get("name", ""),
"description": sound_info.get("description", ""),
"source": "freesound",
"source_id": request.freesound_id,
"source_url": f"https://freesound.org/s/{request.freesound_id}/",
"audio_file_id": file_id,
"duration_seconds": sound_info.get("duration", 0),
"format": "mp3",
"categories": [],
"tags": sound_info.get("tags", [])[:20], # 최대 20개
"license": sound_info.get("license", ""),
"attribution": sound_info.get("username", ""),
"created_at": now,
"updated_at": now,
}
result = await db.sound_effects.insert_one(doc)
return SoundEffectResponse(
id=str(result.inserted_id),
freesound_id=request.freesound_id,
name=doc["name"],
description=doc["description"],
duration=doc["duration_seconds"],
tags=doc["tags"],
license=doc["license"],
source="local",
)
except HTTPException:
raise
except Exception as e:
raise HTTPException(status_code=500, detail=f"Import failed: {str(e)}")
@router.get("/{sound_id}")
async def get_sound_effect_info(
sound_id: str,
db: Database = Depends(get_db),
):
"""효과음 상세 정보 조회"""
# Freesound ID인 경우
if sound_id.startswith("fs_"):
freesound_id = int(sound_id[3:])
try:
sound_info = await freesound_client.get_sound(freesound_id)
return SoundEffectResponse(
id=sound_id,
freesound_id=freesound_id,
name=sound_info.get("name", ""),
description=sound_info.get("description", ""),
duration=sound_info.get("duration", 0),
tags=sound_info.get("tags", []),
preview_url=sound_info.get("previews", {}).get("preview-hq-mp3", ""),
license=sound_info.get("license", ""),
source="freesound",
)
except Exception as e:
raise HTTPException(status_code=404, detail="Sound not found")
# 로컬 ID인 경우
from bson import ObjectId
try:
doc = await db.sound_effects.find_one({"_id": ObjectId(sound_id)})
except:
raise HTTPException(status_code=400, detail="Invalid sound ID")
if not doc:
raise HTTPException(status_code=404, detail="Sound not found")
return SoundEffectResponse(
id=str(doc["_id"]),
freesound_id=doc.get("source_id"),
name=doc["name"],
description=doc.get("description", ""),
duration=doc.get("duration_seconds", 0),
tags=doc.get("tags", []),
license=doc.get("license", ""),
source="local",
)
@router.get("/{sound_id}/audio")
async def get_sound_effect_audio(
sound_id: str,
db: Database = Depends(get_db),
):
"""효과음 오디오 스트리밍"""
# Freesound ID인 경우 프리뷰 리다이렉트
if sound_id.startswith("fs_"):
freesound_id = int(sound_id[3:])
try:
sound_info = await freesound_client.get_sound(freesound_id)
preview_url = sound_info.get("previews", {}).get("preview-hq-mp3", "")
if preview_url:
audio_bytes = await freesound_client.download_preview(preview_url)
return Response(
content=audio_bytes,
media_type="audio/mpeg",
headers={"Content-Disposition": f'inline; filename="{freesound_id}.mp3"'},
)
except Exception as e:
raise HTTPException(status_code=404, detail="Audio not found")
# 로컬 ID인 경우
from bson import ObjectId
try:
doc = await db.sound_effects.find_one({"_id": ObjectId(sound_id)})
except:
raise HTTPException(status_code=400, detail="Invalid sound ID")
if not doc or not doc.get("audio_file_id"):
raise HTTPException(status_code=404, detail="Audio not found")
audio_bytes = await db.get_audio(doc["audio_file_id"])
content_type = "audio/mpeg" if doc.get("format") == "mp3" else "audio/wav"
return Response(
content=audio_bytes,
media_type=content_type,
headers={"Content-Disposition": f'inline; filename="{sound_id}.{doc.get("format", "wav")}"'},
)
@router.get("/categories")
async def list_categories(
db: Database = Depends(get_db),
):
"""효과음 카테고리 목록"""
# 로컬 라이브러리의 카테고리 집계
pipeline = [
{"$unwind": "$categories"},
{"$group": {"_id": "$categories", "count": {"$sum": 1}}},
{"$sort": {"count": -1}},
]
categories = []
async for doc in db.sound_effects.aggregate(pipeline):
categories.append({
"name": doc["_id"],
"count": doc["count"],
})
return {"categories": categories}
@router.delete("/{sound_id}")
async def delete_sound_effect(
sound_id: str,
db: Database = Depends(get_db),
):
"""로컬 효과음 삭제"""
if sound_id.startswith("fs_"):
raise HTTPException(status_code=400, detail="Cannot delete Freesound reference")
from bson import ObjectId
try:
doc = await db.sound_effects.find_one({"_id": ObjectId(sound_id)})
except:
raise HTTPException(status_code=400, detail="Invalid sound ID")
if not doc:
raise HTTPException(status_code=404, detail="Sound not found")
# 오디오 파일 삭제
if doc.get("audio_file_id"):
await db.delete_audio(doc["audio_file_id"])
# 문서 삭제
await db.sound_effects.delete_one({"_id": ObjectId(sound_id)})
return {"status": "deleted", "sound_id": sound_id}

View File

@ -0,0 +1,227 @@
"""TTS API 라우터"""
import uuid
from datetime import datetime
from typing import Optional
from fastapi import APIRouter, HTTPException, Depends
from fastapi.responses import Response, StreamingResponse
from app.database import Database, get_db
from app.models.voice import TTSSynthesizeRequest, TTSGenerationResponse, VoiceType
from app.services.tts_client import tts_client
from app.routers.voices import PRESET_VOICES
router = APIRouter(prefix="/api/v1/tts", tags=["tts"])
@router.post("/synthesize")
async def synthesize(
request: TTSSynthesizeRequest,
db: Database = Depends(get_db),
):
"""TTS 음성 합성
지정된 보이스로 텍스트를 음성으로 변환합니다.
"""
voice_id = request.voice_id
# 프리셋 보이스 확인
preset_speaker = None
for preset in PRESET_VOICES:
if preset["voice_id"] == voice_id:
preset_speaker = preset["preset_voice_id"]
break
if preset_speaker:
# 프리셋 음성 합성
try:
audio_bytes, sr = await tts_client.synthesize(
text=request.text,
speaker=preset_speaker,
language="ko",
instruct=request.instruct,
)
except Exception as e:
raise HTTPException(status_code=500, detail=f"TTS synthesis failed: {str(e)}")
else:
# DB에서 보이스 정보 조회
voice_doc = await db.voices.find_one({"voice_id": voice_id})
if not voice_doc:
raise HTTPException(status_code=404, detail="Voice not found")
voice_type = voice_doc.get("type")
if voice_type == VoiceType.CLONED.value:
# Voice Clone 합성 (레퍼런스 오디오 필요)
ref_audio_id = voice_doc.get("reference_audio_id")
ref_transcript = voice_doc.get("reference_transcript", "")
if not ref_audio_id:
raise HTTPException(status_code=400, detail="Reference audio not found")
ref_audio = await db.get_audio(ref_audio_id)
try:
audio_bytes, sr = await tts_client.voice_clone(
text=request.text,
ref_audio=ref_audio,
ref_text=ref_transcript,
language=voice_doc.get("language", "ko"),
)
except Exception as e:
raise HTTPException(status_code=500, detail=f"Voice clone synthesis failed: {str(e)}")
elif voice_type == VoiceType.DESIGNED.value:
# Voice Design 합성
design_prompt = voice_doc.get("design_prompt", "")
try:
audio_bytes, sr = await tts_client.voice_design(
text=request.text,
instruct=design_prompt,
language=voice_doc.get("language", "ko"),
)
except Exception as e:
raise HTTPException(status_code=500, detail=f"Voice design synthesis failed: {str(e)}")
else:
raise HTTPException(status_code=400, detail=f"Unknown voice type: {voice_type}")
# 생성 기록 저장
generation_id = f"gen_{uuid.uuid4().hex[:12]}"
now = datetime.utcnow()
# 오디오 저장
audio_file_id = await db.save_audio(
audio_bytes,
f"{generation_id}.wav",
metadata={"voice_id": voice_id, "text": request.text[:100]},
)
# 생성 기록 저장
gen_doc = {
"generation_id": generation_id,
"voice_id": voice_id,
"text": request.text,
"audio_file_id": audio_file_id,
"status": "completed",
"created_at": now,
}
await db.tts_generations.insert_one(gen_doc)
return Response(
content=audio_bytes,
media_type="audio/wav",
headers={
"X-Sample-Rate": str(sr),
"X-Generation-ID": generation_id,
"Content-Disposition": f'attachment; filename="{generation_id}.wav"',
},
)
@router.post("/synthesize/async", response_model=TTSGenerationResponse)
async def synthesize_async(
request: TTSSynthesizeRequest,
db: Database = Depends(get_db),
):
"""비동기 TTS 음성 합성 (긴 텍스트용)
생성 작업을 큐에 등록하고 generation_id를 반환합니다.
완료 후 /generations/{generation_id}/audio로 다운로드 가능합니다.
"""
# 긴 텍스트 처리를 위한 비동기 방식
# 현재는 동기 방식과 동일하게 처리 (추후 Redis 큐 연동)
generation_id = f"gen_{uuid.uuid4().hex[:12]}"
now = datetime.utcnow()
gen_doc = {
"generation_id": generation_id,
"voice_id": request.voice_id,
"text": request.text,
"status": "pending",
"created_at": now,
}
await db.tts_generations.insert_one(gen_doc)
# 실제로는 백그라운드 워커에서 처리해야 함
# 여기서는 바로 처리
try:
# synthesize 로직과 동일...
# (간소화를 위해 생략, 실제 구현 시 비동기 워커 사용)
pass
except Exception as e:
await db.tts_generations.update_one(
{"generation_id": generation_id},
{"$set": {"status": "failed", "error_message": str(e)}},
)
return TTSGenerationResponse(
generation_id=generation_id,
voice_id=request.voice_id,
text=request.text,
status="pending",
created_at=now,
)
@router.get("/generations/{generation_id}", response_model=TTSGenerationResponse)
async def get_generation(
generation_id: str,
db: Database = Depends(get_db),
):
"""TTS 생성 상태 조회"""
doc = await db.tts_generations.find_one({"generation_id": generation_id})
if not doc:
raise HTTPException(status_code=404, detail="Generation not found")
return TTSGenerationResponse(
generation_id=doc["generation_id"],
voice_id=doc["voice_id"],
text=doc["text"],
status=doc["status"],
audio_file_id=str(doc.get("audio_file_id")) if doc.get("audio_file_id") else None,
duration_seconds=doc.get("duration_seconds"),
created_at=doc["created_at"],
)
@router.get("/generations/{generation_id}/audio")
async def get_generation_audio(
generation_id: str,
db: Database = Depends(get_db),
):
"""생성된 오디오 다운로드"""
doc = await db.tts_generations.find_one({"generation_id": generation_id})
if not doc:
raise HTTPException(status_code=404, detail="Generation not found")
if doc["status"] != "completed":
raise HTTPException(status_code=400, detail=f"Generation not completed: {doc['status']}")
audio_file_id = doc.get("audio_file_id")
if not audio_file_id:
raise HTTPException(status_code=404, detail="Audio file not found")
audio_bytes = await db.get_audio(audio_file_id)
return Response(
content=audio_bytes,
media_type="audio/wav",
headers={
"Content-Disposition": f'attachment; filename="{generation_id}.wav"',
},
)
@router.get("/health")
async def tts_health():
"""TTS 엔진 헬스체크"""
try:
health = await tts_client.health_check()
return {"status": "healthy", "tts_engine": health}
except Exception as e:
return {"status": "unhealthy", "error": str(e)}

View File

@ -0,0 +1,426 @@
"""Voice 관리 API 라우터"""
import uuid
from datetime import datetime
from typing import Optional, List
from fastapi import APIRouter, HTTPException, Depends, Query, UploadFile, File, Form
from fastapi.responses import Response
from app.database import Database, get_db
from app.models.voice import (
VoiceType,
LanguageCode,
VoiceResponse,
VoiceListResponse,
VoiceCloneRequest,
VoiceDesignRequest,
VoiceUpdateRequest,
)
from app.services.tts_client import tts_client
router = APIRouter(prefix="/api/v1/voices", tags=["voices"])
# ========================================
# 프리셋 보이스 목록 (시스템 기본)
# ========================================
PRESET_VOICES = [
{
"voice_id": "preset_chelsie",
"name": "Chelsie",
"description": "밝고 활기찬 여성 목소리",
"type": VoiceType.PRESET,
"preset_voice_id": "Chelsie",
"language": LanguageCode.EN,
"gender": "female",
"style_tags": ["bright", "energetic"],
},
{
"voice_id": "preset_ethan",
"name": "Ethan",
"description": "차분하고 신뢰감 있는 남성 목소리",
"type": VoiceType.PRESET,
"preset_voice_id": "Ethan",
"language": LanguageCode.EN,
"gender": "male",
"style_tags": ["calm", "trustworthy"],
},
{
"voice_id": "preset_vivian",
"name": "Vivian",
"description": "부드럽고 따뜻한 여성 목소리",
"type": VoiceType.PRESET,
"preset_voice_id": "Vivian",
"language": LanguageCode.EN,
"gender": "female",
"style_tags": ["soft", "warm"],
},
{
"voice_id": "preset_benjamin",
"name": "Benjamin",
"description": "깊고 전문적인 남성 목소리",
"type": VoiceType.PRESET,
"preset_voice_id": "Benjamin",
"language": LanguageCode.EN,
"gender": "male",
"style_tags": ["deep", "professional"],
},
{
"voice_id": "preset_aurora",
"name": "Aurora",
"description": "우아하고 세련된 여성 목소리",
"type": VoiceType.PRESET,
"preset_voice_id": "Aurora",
"language": LanguageCode.EN,
"gender": "female",
"style_tags": ["elegant", "refined"],
},
{
"voice_id": "preset_oliver",
"name": "Oliver",
"description": "친근하고 편안한 남성 목소리",
"type": VoiceType.PRESET,
"preset_voice_id": "Oliver",
"language": LanguageCode.EN,
"gender": "male",
"style_tags": ["friendly", "casual"],
},
{
"voice_id": "preset_luna",
"name": "Luna",
"description": "따뜻하고 감성적인 여성 목소리",
"type": VoiceType.PRESET,
"preset_voice_id": "Luna",
"language": LanguageCode.EN,
"gender": "female",
"style_tags": ["warm", "emotional"],
},
{
"voice_id": "preset_jasper",
"name": "Jasper",
"description": "전문적이고 명확한 남성 목소리",
"type": VoiceType.PRESET,
"preset_voice_id": "Jasper",
"language": LanguageCode.EN,
"gender": "male",
"style_tags": ["professional", "clear"],
},
{
"voice_id": "preset_aria",
"name": "Aria",
"description": "표현력 풍부한 여성 목소리",
"type": VoiceType.PRESET,
"preset_voice_id": "Aria",
"language": LanguageCode.EN,
"gender": "female",
"style_tags": ["expressive", "dynamic"],
},
]
def _voice_doc_to_response(doc: dict) -> VoiceResponse:
"""MongoDB 문서를 VoiceResponse로 변환"""
return VoiceResponse(
voice_id=doc["voice_id"],
name=doc["name"],
description=doc.get("description"),
type=doc["type"],
language=doc.get("language", LanguageCode.KO),
preset_voice_id=doc.get("preset_voice_id"),
design_prompt=doc.get("design_prompt"),
reference_transcript=doc.get("reference_transcript"),
gender=doc.get("gender"),
age_range=doc.get("age_range"),
style_tags=doc.get("style_tags", []),
owner_id=str(doc.get("owner_id")) if doc.get("owner_id") else None,
is_public=doc.get("is_public", True),
sample_audio_id=str(doc.get("sample_audio_id")) if doc.get("sample_audio_id") else None,
created_at=doc.get("created_at", datetime.utcnow()),
updated_at=doc.get("updated_at", datetime.utcnow()),
)
@router.get("", response_model=VoiceListResponse)
async def list_voices(
type: Optional[VoiceType] = Query(None, description="보이스 타입 필터"),
language: Optional[LanguageCode] = Query(None, description="언어 필터"),
is_public: bool = Query(True, description="공개 보이스만"),
include_presets: bool = Query(True, description="프리셋 포함"),
page: int = Query(1, ge=1),
page_size: int = Query(20, ge=1, le=100),
db: Database = Depends(get_db),
):
"""보이스 목록 조회"""
voices = []
# 프리셋 보이스 추가
if include_presets and (type is None or type == VoiceType.PRESET):
for preset in PRESET_VOICES:
if language and preset["language"] != language:
continue
voices.append(VoiceResponse(
**preset,
is_public=True,
created_at=datetime.utcnow(),
updated_at=datetime.utcnow(),
))
# DB에서 사용자 보이스 조회
query = {"is_public": True} if is_public else {}
if type and type != VoiceType.PRESET:
query["type"] = type.value
if language:
query["language"] = language.value
cursor = db.voices.find(query).sort("created_at", -1)
skip = (page - 1) * page_size
cursor = cursor.skip(skip).limit(page_size)
async for doc in cursor:
voices.append(_voice_doc_to_response(doc))
total = len(PRESET_VOICES) + await db.voices.count_documents(query)
return VoiceListResponse(
voices=voices,
total=total,
page=page,
page_size=page_size,
)
@router.get("/{voice_id}", response_model=VoiceResponse)
async def get_voice(
voice_id: str,
db: Database = Depends(get_db),
):
"""보이스 상세 조회"""
# 프리셋 체크
for preset in PRESET_VOICES:
if preset["voice_id"] == voice_id:
return VoiceResponse(
**preset,
is_public=True,
created_at=datetime.utcnow(),
updated_at=datetime.utcnow(),
)
# DB 조회
doc = await db.voices.find_one({"voice_id": voice_id})
if not doc:
raise HTTPException(status_code=404, detail="Voice not found")
return _voice_doc_to_response(doc)
@router.get("/{voice_id}/sample")
async def get_voice_sample(
voice_id: str,
db: Database = Depends(get_db),
):
"""보이스 샘플 오디오 스트리밍"""
# 프리셋인 경우 TTS로 샘플 생성
for preset in PRESET_VOICES:
if preset["voice_id"] == voice_id:
sample_text = "안녕하세요, 저는 AI 음성입니다."
audio_bytes, sr = await tts_client.synthesize(
text=sample_text,
speaker=preset["preset_voice_id"],
language="ko",
)
return Response(
content=audio_bytes,
media_type="audio/wav",
headers={"Content-Disposition": f'inline; filename="{voice_id}_sample.wav"'},
)
# DB에서 조회
doc = await db.voices.find_one({"voice_id": voice_id})
if not doc:
raise HTTPException(status_code=404, detail="Voice not found")
if not doc.get("sample_audio_id"):
raise HTTPException(status_code=404, detail="No sample audio available")
audio_bytes = await db.get_audio(doc["sample_audio_id"])
return Response(
content=audio_bytes,
media_type="audio/wav",
headers={"Content-Disposition": f'inline; filename="{voice_id}_sample.wav"'},
)
@router.post("/clone", response_model=VoiceResponse)
async def create_voice_clone(
name: str = Form(...),
description: Optional[str] = Form(None),
reference_transcript: str = Form(...),
language: LanguageCode = Form(LanguageCode.KO),
is_public: bool = Form(False),
reference_audio: UploadFile = File(...),
db: Database = Depends(get_db),
):
"""Voice Clone으로 새 보이스 생성
레퍼런스 오디오를 기반으로 목소리를 복제합니다.
3초 이상의 오디오가 권장됩니다.
"""
# 오디오 파일 읽기
audio_content = await reference_audio.read()
# Voice Clone으로 샘플 생성
sample_text = "안녕하세요, 저는 복제된 AI 음성입니다."
try:
sample_audio, sr = await tts_client.voice_clone(
text=sample_text,
ref_audio=audio_content,
ref_text=reference_transcript,
language=language.value,
)
except Exception as e:
raise HTTPException(status_code=500, detail=f"Voice clone failed: {str(e)}")
# GridFS에 오디오 저장
ref_audio_id = await db.save_audio(
audio_content,
f"ref_{uuid.uuid4()}.wav",
metadata={"type": "reference"},
)
sample_audio_id = await db.save_audio(
sample_audio,
f"sample_{uuid.uuid4()}.wav",
metadata={"type": "sample"},
)
# DB에 보이스 저장
voice_id = f"clone_{uuid.uuid4().hex[:12]}"
now = datetime.utcnow()
doc = {
"voice_id": voice_id,
"name": name,
"description": description,
"type": VoiceType.CLONED.value,
"language": language.value,
"reference_audio_id": ref_audio_id,
"reference_transcript": reference_transcript,
"sample_audio_id": sample_audio_id,
"is_public": is_public,
"created_at": now,
"updated_at": now,
}
await db.voices.insert_one(doc)
return _voice_doc_to_response(doc)
@router.post("/design", response_model=VoiceResponse)
async def create_voice_design(
request: VoiceDesignRequest,
db: Database = Depends(get_db),
):
"""Voice Design으로 새 보이스 생성
텍스트 프롬프트를 기반으로 새로운 음성을 생성합니다.
예: "30대 남성, 부드럽고 차분한 목소리"
"""
# Voice Design으로 샘플 생성
sample_text = "안녕하세요, 저는 AI로 생성된 음성입니다."
try:
sample_audio, sr = await tts_client.voice_design(
text=sample_text,
instruct=request.design_prompt,
language=request.language.value,
)
except Exception as e:
raise HTTPException(status_code=500, detail=f"Voice design failed: {str(e)}")
# GridFS에 샘플 저장
sample_audio_id = await db.save_audio(
sample_audio,
f"sample_{uuid.uuid4()}.wav",
metadata={"type": "sample"},
)
# DB에 보이스 저장
voice_id = f"design_{uuid.uuid4().hex[:12]}"
now = datetime.utcnow()
doc = {
"voice_id": voice_id,
"name": request.name,
"description": request.description,
"type": VoiceType.DESIGNED.value,
"language": request.language.value,
"design_prompt": request.design_prompt,
"sample_audio_id": sample_audio_id,
"is_public": request.is_public,
"created_at": now,
"updated_at": now,
}
await db.voices.insert_one(doc)
return _voice_doc_to_response(doc)
@router.patch("/{voice_id}", response_model=VoiceResponse)
async def update_voice(
voice_id: str,
request: VoiceUpdateRequest,
db: Database = Depends(get_db),
):
"""보이스 정보 수정"""
# 프리셋은 수정 불가
for preset in PRESET_VOICES:
if preset["voice_id"] == voice_id:
raise HTTPException(status_code=400, detail="Cannot modify preset voice")
# 업데이트할 필드만 추출
update_data = {k: v for k, v in request.model_dump().items() if v is not None}
if not update_data:
raise HTTPException(status_code=400, detail="No fields to update")
update_data["updated_at"] = datetime.utcnow()
result = await db.voices.update_one(
{"voice_id": voice_id},
{"$set": update_data},
)
if result.matched_count == 0:
raise HTTPException(status_code=404, detail="Voice not found")
doc = await db.voices.find_one({"voice_id": voice_id})
return _voice_doc_to_response(doc)
@router.delete("/{voice_id}")
async def delete_voice(
voice_id: str,
db: Database = Depends(get_db),
):
"""보이스 삭제"""
# 프리셋은 삭제 불가
for preset in PRESET_VOICES:
if preset["voice_id"] == voice_id:
raise HTTPException(status_code=400, detail="Cannot delete preset voice")
# 먼저 조회
doc = await db.voices.find_one({"voice_id": voice_id})
if not doc:
raise HTTPException(status_code=404, detail="Voice not found")
# 관련 오디오 파일 삭제
if doc.get("reference_audio_id"):
await db.delete_audio(doc["reference_audio_id"])
if doc.get("sample_audio_id"):
await db.delete_audio(doc["sample_audio_id"])
# 보이스 삭제
await db.voices.delete_one({"voice_id": voice_id})
return {"status": "deleted", "voice_id": voice_id}

View File

@ -0,0 +1,260 @@
# 오디오 믹서 서비스
# pydub를 사용한 오디오 합성/믹싱
import os
import tempfile
from typing import Optional
from pydub import AudioSegment
from pydub.effects import normalize
from app.models.drama import TimelineItem
class AudioMixer:
"""
오디오 믹서
기능:
- 여러 오디오 트랙 합성
- 볼륨 조절
- 페이드 인/아웃
- 타임라인 기반 믹싱
"""
def __init__(self, sample_rate: int = 44100):
self.sample_rate = sample_rate
def load_audio(self, file_path: str) -> AudioSegment:
"""오디오 파일 로드"""
return AudioSegment.from_file(file_path)
def adjust_volume(self, audio: AudioSegment, volume: float) -> AudioSegment:
"""볼륨 조절 (0.0 ~ 2.0, 1.0 = 원본)"""
if volume == 1.0:
return audio
# dB 변환: 0.5 = -6dB, 2.0 = +6dB
db_change = 20 * (volume ** 0.5 - 1) if volume > 0 else -120
return audio + db_change
def apply_fade(
self,
audio: AudioSegment,
fade_in_ms: int = 0,
fade_out_ms: int = 0
) -> AudioSegment:
"""페이드 인/아웃 적용"""
if fade_in_ms > 0:
audio = audio.fade_in(fade_in_ms)
if fade_out_ms > 0:
audio = audio.fade_out(fade_out_ms)
return audio
def concatenate(self, segments: list[AudioSegment]) -> AudioSegment:
"""오디오 세그먼트 연결"""
if not segments:
return AudioSegment.silent(duration=0)
result = segments[0]
for segment in segments[1:]:
result += segment
return result
def overlay(
self,
base: AudioSegment,
overlay_audio: AudioSegment,
position_ms: int = 0
) -> AudioSegment:
"""오디오 오버레이 (배경음악 위에 보이스 등)"""
return base.overlay(overlay_audio, position=position_ms)
def create_silence(self, duration_ms: int) -> AudioSegment:
"""무음 생성"""
return AudioSegment.silent(duration=duration_ms)
def mix_timeline(
self,
timeline: list[TimelineItem],
audio_files: dict[str, str] # audio_path -> 실제 파일 경로
) -> AudioSegment:
"""
타임라인 기반 믹싱
Args:
timeline: 타임라인 아이템 리스트
audio_files: 오디오 경로 매핑
Returns:
믹싱된 오디오
"""
if not timeline:
return AudioSegment.silent(duration=1000)
# 전체 길이 계산
total_duration_ms = max(
int((item.start_time + item.duration) * 1000)
for item in timeline
)
# 트랙별 분리 (voice, music, sfx)
voice_track = AudioSegment.silent(duration=total_duration_ms)
music_track = AudioSegment.silent(duration=total_duration_ms)
sfx_track = AudioSegment.silent(duration=total_duration_ms)
for item in timeline:
if not item.audio_path or item.audio_path not in audio_files:
continue
file_path = audio_files[item.audio_path]
if not os.path.exists(file_path):
continue
# 오디오 로드 및 처리
audio = self.load_audio(file_path)
# 볼륨 조절
audio = self.adjust_volume(audio, item.volume)
# 페이드 적용
fade_in_ms = int(item.fade_in * 1000)
fade_out_ms = int(item.fade_out * 1000)
audio = self.apply_fade(audio, fade_in_ms, fade_out_ms)
# 위치 계산
position_ms = int(item.start_time * 1000)
# 트랙에 오버레이
if item.type == "voice":
voice_track = voice_track.overlay(audio, position=position_ms)
elif item.type == "music":
music_track = music_track.overlay(audio, position=position_ms)
elif item.type == "sfx":
sfx_track = sfx_track.overlay(audio, position=position_ms)
# 트랙 믹싱 (music -> sfx -> voice 순서로 레이어링)
mixed = music_track.overlay(sfx_track).overlay(voice_track)
return mixed
def auto_duck(
self,
music: AudioSegment,
voice: AudioSegment,
duck_amount_db: float = -10,
attack_ms: int = 100,
release_ms: int = 300
) -> AudioSegment:
"""
Auto-ducking: 보이스가 나올 때 음악 볼륨 자동 감소
간단한 구현 - 보이스가 있는 구간에서 음악 볼륨 낮춤
"""
# 보이스 길이에 맞춰 음악 조절
if len(music) < len(voice):
music = music + AudioSegment.silent(duration=len(voice) - len(music))
# 보이스의 무음/유음 구간 감지 (간단한 RMS 기반)
chunk_ms = 50
ducked_music = AudioSegment.silent(duration=0)
for i in range(0, len(voice), chunk_ms):
voice_chunk = voice[i:i + chunk_ms]
music_chunk = music[i:i + chunk_ms]
# 보이스 RMS가 임계값 이상이면 ducking
if voice_chunk.rms > 100: # 임계값 조정 가능
music_chunk = music_chunk + duck_amount_db
ducked_music += music_chunk
return ducked_music
def export(
self,
audio: AudioSegment,
output_path: str,
format: str = "wav",
normalize_audio: bool = True
) -> str:
"""
오디오 내보내기
Args:
audio: 오디오 세그먼트
output_path: 출력 파일 경로
format: 출력 포맷 (wav, mp3)
normalize_audio: 노멀라이즈 여부
Returns:
저장된 파일 경로
"""
if normalize_audio:
audio = normalize(audio)
# 포맷별 설정
export_params = {}
if format == "mp3":
export_params = {"format": "mp3", "bitrate": "192k"}
else:
export_params = {"format": "wav"}
audio.export(output_path, **export_params)
return output_path
def create_with_background(
self,
voice_segments: list[tuple[AudioSegment, float]], # (audio, start_time)
background_music: Optional[AudioSegment] = None,
music_volume: float = 0.3,
gap_between_lines_ms: int = 500
) -> AudioSegment:
"""
보이스 + 배경음악 간단 합성
Args:
voice_segments: (오디오, 시작시간) 튜플 리스트
background_music: 배경음악 (없으면 무음)
music_volume: 배경음악 볼륨
gap_between_lines_ms: 대사 간 간격
Returns:
합성된 오디오
"""
if not voice_segments:
return AudioSegment.silent(duration=1000)
# 전체 보이스 트랙 생성
voice_track = AudioSegment.silent(duration=0)
for audio, start_time in voice_segments:
# 시작 위치까지 무음 추가
current_pos = len(voice_track)
target_pos = int(start_time * 1000)
if target_pos > current_pos:
voice_track += AudioSegment.silent(duration=target_pos - current_pos)
voice_track += audio
voice_track += AudioSegment.silent(duration=gap_between_lines_ms)
total_duration = len(voice_track)
# 배경음악 처리
if background_music:
# 음악 길이 조정
if len(background_music) < total_duration:
# 루프
loops_needed = (total_duration // len(background_music)) + 1
background_music = background_music * loops_needed
background_music = background_music[:total_duration]
# 볼륨 조절
background_music = self.adjust_volume(background_music, music_volume)
# Auto-ducking 적용
background_music = self.auto_duck(background_music, voice_track)
# 믹싱
return background_music.overlay(voice_track)
else:
return voice_track
# 싱글톤 인스턴스
audio_mixer = AudioMixer()

View File

@ -0,0 +1,362 @@
# 드라마 오케스트레이터
# 스크립트 파싱 → 에셋 생성 → 타임라인 구성 → 믹싱 조율
import os
import uuid
import asyncio
import tempfile
from datetime import datetime
from typing import Optional
from pydub import AudioSegment
from app.models.drama import (
ParsedScript, ScriptElement, ElementType, Character,
TimelineItem, DramaProject, DramaCreateRequest
)
from app.services.script_parser import script_parser
from app.services.audio_mixer import audio_mixer
from app.services.tts_client import tts_client
from app.services.freesound_client import freesound_client
from app.database import db
class DramaOrchestrator:
"""
드라마 생성 오케스트레이터
워크플로우:
1. 스크립트 파싱
2. 캐릭터-보이스 매핑
3. 에셋 생성 (TTS, 음악, 효과음)
4. 타임라인 구성
5. 오디오 믹싱
6. 최종 파일 출력
"""
# 기본 대사 간격 (초)
DEFAULT_DIALOGUE_GAP = 0.5
# 효과음 기본 길이 (초)
DEFAULT_SFX_DURATION = 2.0
# 예상 TTS 속도 (글자/초)
TTS_CHARS_PER_SECOND = 5
async def create_project(
self,
request: DramaCreateRequest
) -> DramaProject:
"""새 드라마 프로젝트 생성"""
project_id = str(uuid.uuid4())
# 스크립트 파싱
parsed = script_parser.parse(request.script)
# 보이스 매핑 적용
voice_mapping = request.voice_mapping or {}
for char in parsed.characters:
if char.name in voice_mapping:
char.voice_id = voice_mapping[char.name]
project = DramaProject(
project_id=project_id,
title=request.title or parsed.title or "Untitled Drama",
script_raw=request.script,
script_parsed=parsed,
voice_mapping=voice_mapping,
status="draft"
)
# DB 저장
await db.dramas.insert_one(project.model_dump())
return project
async def get_project(self, project_id: str) -> Optional[DramaProject]:
"""프로젝트 조회"""
doc = await db.dramas.find_one({"project_id": project_id})
if doc:
return DramaProject(**doc)
return None
async def update_project_status(
self,
project_id: str,
status: str,
error_message: Optional[str] = None
):
"""프로젝트 상태 업데이트"""
update = {
"status": status,
"updated_at": datetime.utcnow()
}
if error_message:
update["error_message"] = error_message
await db.dramas.update_one(
{"project_id": project_id},
{"$set": update}
)
def estimate_duration(self, parsed: ParsedScript) -> float:
"""예상 재생 시간 계산 (초)"""
total = 0.0
for element in parsed.elements:
if element.type == ElementType.DIALOGUE:
# 대사 길이 추정
text_len = len(element.text or "")
total += text_len / self.TTS_CHARS_PER_SECOND
total += self.DEFAULT_DIALOGUE_GAP
elif element.type == ElementType.PAUSE:
total += element.duration or 1.0
elif element.type == ElementType.SFX:
total += self.DEFAULT_SFX_DURATION
return total
async def generate_assets(
self,
project: DramaProject,
temp_dir: str
) -> dict[str, str]:
"""
에셋 생성 (TTS, SFX)
Returns:
audio_id -> 파일 경로 매핑
"""
assets: dict[str, str] = {}
parsed = project.script_parsed
if not parsed:
return assets
dialogue_index = 0
for element in parsed.elements:
if element.type == ElementType.DIALOGUE:
# TTS 생성
audio_id = f"dialogue_{dialogue_index}"
# 보이스 ID 결정
voice_id = project.voice_mapping.get(element.character)
if not voice_id:
# 기본 보이스 사용 (첫 번째 프리셋)
voice_id = "default"
try:
# TTS 엔진 호출
audio_data = await tts_client.synthesize(
text=element.text or "",
voice_id=voice_id,
instruct=element.emotion
)
# 파일 저장
file_path = os.path.join(temp_dir, f"{audio_id}.wav")
with open(file_path, "wb") as f:
f.write(audio_data)
assets[audio_id] = file_path
except Exception as e:
print(f"TTS 생성 실패 ({element.character}): {e}")
# 무음으로 대체
silence_duration = len(element.text or "") / self.TTS_CHARS_PER_SECOND
silence = AudioSegment.silent(duration=int(silence_duration * 1000))
file_path = os.path.join(temp_dir, f"{audio_id}.wav")
silence.export(file_path, format="wav")
assets[audio_id] = file_path
dialogue_index += 1
elif element.type == ElementType.SFX:
# Freesound에서 효과음 검색
audio_id = f"sfx_{element.description}"
try:
results = await freesound_client.search(
query=element.description,
page_size=1
)
if results and len(results) > 0:
sound = results[0]
# 프리뷰 다운로드
if sound.get("preview_url"):
audio_data = await freesound_client.download_preview(
sound["preview_url"]
)
file_path = os.path.join(temp_dir, f"sfx_{sound['id']}.mp3")
with open(file_path, "wb") as f:
f.write(audio_data)
assets[audio_id] = file_path
except Exception as e:
print(f"SFX 검색 실패 ({element.description}): {e}")
elif element.type == ElementType.MUSIC:
# MusicGen은 GPU 필요하므로 여기서는 placeholder
# 실제 구현 시 music_client 추가 필요
audio_id = f"music_{element.description}"
# TODO: MusicGen 연동
return assets
def build_timeline(
self,
parsed: ParsedScript,
assets: dict[str, str]
) -> list[TimelineItem]:
"""타임라인 구성"""
timeline: list[TimelineItem] = []
current_time = 0.0
dialogue_index = 0
current_music: Optional[dict] = None
for element in parsed.elements:
if element.type == ElementType.DIALOGUE:
audio_id = f"dialogue_{dialogue_index}"
if audio_id in assets:
# 오디오 길이 확인
try:
audio = AudioSegment.from_file(assets[audio_id])
duration = len(audio) / 1000.0
except:
duration = len(element.text or "") / self.TTS_CHARS_PER_SECOND
timeline.append(TimelineItem(
start_time=current_time,
duration=duration,
type="voice",
audio_path=audio_id,
volume=1.0
))
current_time += duration + self.DEFAULT_DIALOGUE_GAP
dialogue_index += 1
elif element.type == ElementType.PAUSE:
current_time += element.duration or 1.0
elif element.type == ElementType.SFX:
audio_id = f"sfx_{element.description}"
if audio_id in assets:
try:
audio = AudioSegment.from_file(assets[audio_id])
duration = len(audio) / 1000.0
except:
duration = self.DEFAULT_SFX_DURATION
timeline.append(TimelineItem(
start_time=current_time,
duration=duration,
type="sfx",
audio_path=audio_id,
volume=element.volume or 1.0
))
elif element.type == ElementType.MUSIC:
audio_id = f"music_{element.description}"
if element.action == "stop":
current_music = None
elif element.action in ("play", "change", "fade_in"):
if audio_id in assets:
# 음악은 현재 시점부터 끝까지 (나중에 조정)
current_music = {
"audio_id": audio_id,
"start_time": current_time,
"volume": element.volume or 0.3,
"fade_in": element.fade_duration if element.action == "fade_in" else 0
}
# 배경음악 아이템 추가 (전체 길이로)
if current_music:
timeline.append(TimelineItem(
start_time=current_music["start_time"],
duration=current_time - current_music["start_time"],
type="music",
audio_path=current_music["audio_id"],
volume=current_music["volume"],
fade_in=current_music.get("fade_in", 0)
))
return timeline
async def render(
self,
project_id: str,
output_format: str = "wav"
) -> Optional[str]:
"""
드라마 렌더링
Returns:
출력 파일 경로
"""
project = await self.get_project(project_id)
if not project or not project.script_parsed:
return None
await self.update_project_status(project_id, "processing")
try:
with tempfile.TemporaryDirectory() as temp_dir:
# 1. 에셋 생성
assets = await self.generate_assets(project, temp_dir)
# 2. 타임라인 구성
timeline = self.build_timeline(project.script_parsed, assets)
# 3. 믹싱
mixed_audio = audio_mixer.mix_timeline(timeline, assets)
# 4. 출력
output_path = os.path.join(temp_dir, f"drama_{project_id}.{output_format}")
audio_mixer.export(mixed_audio, output_path, format=output_format)
# 5. GridFS에 저장 (TODO: 실제 구현)
# file_id = await save_to_gridfs(output_path)
# 임시: 파일 복사
final_path = f"/tmp/drama_{project_id}.{output_format}"
import shutil
shutil.copy(output_path, final_path)
# 상태 업데이트
await db.dramas.update_one(
{"project_id": project_id},
{
"$set": {
"status": "completed",
"timeline": [t.model_dump() for t in timeline],
"output_file_id": final_path,
"updated_at": datetime.utcnow()
}
}
)
return final_path
except Exception as e:
await self.update_project_status(project_id, "error", str(e))
raise
async def list_projects(
self,
skip: int = 0,
limit: int = 20
) -> list[DramaProject]:
"""프로젝트 목록 조회"""
cursor = db.dramas.find().sort("created_at", -1).skip(skip).limit(limit)
projects = []
async for doc in cursor:
projects.append(DramaProject(**doc))
return projects
# 싱글톤 인스턴스
drama_orchestrator = DramaOrchestrator()

View File

@ -0,0 +1,165 @@
"""Freesound API 클라이언트
효과음 검색 및 다운로드
https://freesound.org/docs/api/
"""
import os
import logging
from typing import Optional, List, Dict
import httpx
logger = logging.getLogger(__name__)
class FreesoundClient:
"""Freesound API 클라이언트"""
BASE_URL = "https://freesound.org/apiv2"
def __init__(self):
self.api_key = os.getenv("FREESOUND_API_KEY", "")
self.timeout = httpx.Timeout(30.0, connect=10.0)
def _get_headers(self) -> dict:
"""인증 헤더 반환"""
return {"Authorization": f"Token {self.api_key}"}
async def search(
self,
query: str,
page: int = 1,
page_size: int = 20,
filter_fields: Optional[str] = None,
sort: str = "score",
min_duration: Optional[float] = None,
max_duration: Optional[float] = None,
) -> Dict:
"""효과음 검색
Args:
query: 검색어
page: 페이지 번호
page_size: 페이지당 결과 수
filter_fields: 필터 (예: "duration:[1 TO 5]")
sort: 정렬 (score, duration_asc, duration_desc, created_desc 등)
min_duration: 최소 길이 (초)
max_duration: 최대 길이 (초)
Returns:
검색 결과 딕셔너리
"""
if not self.api_key:
logger.warning("Freesound API 키가 설정되지 않음")
return {"count": 0, "results": []}
# 필터 구성
filters = []
if min_duration is not None or max_duration is not None:
min_d = min_duration if min_duration is not None else 0
max_d = max_duration if max_duration is not None else "*"
filters.append(f"duration:[{min_d} TO {max_d}]")
if filter_fields:
filters.append(filter_fields)
params = {
"query": query,
"page": page,
"page_size": min(page_size, 150), # Freesound 최대 150
"sort": sort,
"fields": "id,name,description,duration,tags,previews,license,username",
}
if filters:
params["filter"] = " ".join(filters)
async with httpx.AsyncClient(timeout=self.timeout) as client:
response = await client.get(
f"{self.BASE_URL}/search/text/",
params=params,
headers=self._get_headers(),
)
response.raise_for_status()
data = response.json()
# 결과 정리
results = []
for sound in data.get("results", []):
results.append({
"freesound_id": sound["id"],
"name": sound.get("name", ""),
"description": sound.get("description", ""),
"duration": sound.get("duration", 0),
"tags": sound.get("tags", []),
"preview_url": sound.get("previews", {}).get("preview-hq-mp3", ""),
"license": sound.get("license", ""),
"username": sound.get("username", ""),
})
return {
"count": data.get("count", 0),
"page": page,
"page_size": page_size,
"results": results,
}
async def get_sound(self, sound_id: int) -> Dict:
"""사운드 상세 정보 조회"""
if not self.api_key:
raise ValueError("Freesound API 키 필요")
async with httpx.AsyncClient(timeout=self.timeout) as client:
response = await client.get(
f"{self.BASE_URL}/sounds/{sound_id}/",
headers=self._get_headers(),
)
response.raise_for_status()
return response.json()
async def download_preview(self, preview_url: str) -> bytes:
"""프리뷰 오디오 다운로드 (인증 불필요)"""
async with httpx.AsyncClient(timeout=self.timeout) as client:
response = await client.get(preview_url)
response.raise_for_status()
return response.content
async def get_similar_sounds(
self,
sound_id: int,
page_size: int = 10,
) -> List[Dict]:
"""유사한 사운드 검색"""
if not self.api_key:
return []
async with httpx.AsyncClient(timeout=self.timeout) as client:
response = await client.get(
f"{self.BASE_URL}/sounds/{sound_id}/similar/",
params={
"page_size": page_size,
"fields": "id,name,description,duration,tags,previews,license",
},
headers=self._get_headers(),
)
response.raise_for_status()
data = response.json()
results = []
for sound in data.get("results", []):
results.append({
"freesound_id": sound["id"],
"name": sound.get("name", ""),
"description": sound.get("description", ""),
"duration": sound.get("duration", 0),
"tags": sound.get("tags", []),
"preview_url": sound.get("previews", {}).get("preview-hq-mp3", ""),
"license": sound.get("license", ""),
})
return results
# 싱글톤 인스턴스
freesound_client = FreesoundClient()

View File

@ -0,0 +1,174 @@
# 드라마 스크립트 파서
# 마크다운 형식의 대본을 구조화된 데이터로 변환
import re
from typing import Optional
from app.models.drama import (
ParsedScript, ScriptElement, Character, ElementType
)
class ScriptParser:
"""
드라마 스크립트 파서
지원 형식:
- # 제목
- [장소: 설명] 또는 [지문]
- [효과음: 설명]
- [음악: 설명] 또는 [음악 시작/중지/변경: 설명]
- [쉼: 2초]
- 캐릭터명(설명, 감정): 대사
- 캐릭터명: 대사
"""
# 정규식 패턴
TITLE_PATTERN = re.compile(r'^#\s+(.+)$')
DIRECTION_PATTERN = re.compile(r'^\[(?:장소|지문|장면):\s*(.+)\]$')
SFX_PATTERN = re.compile(r'^\[효과음:\s*(.+)\]$')
MUSIC_PATTERN = re.compile(r'^\[음악(?:\s+(시작|중지|변경|페이드인|페이드아웃))?:\s*(.+)\]$')
PAUSE_PATTERN = re.compile(r'^\[쉼:\s*(\d+(?:\.\d+)?)\s*초?\]$')
DIALOGUE_PATTERN = re.compile(r'^([^(\[:]+?)(?:\(([^)]*)\))?:\s*(.+)$')
# 음악 액션 매핑
MUSIC_ACTIONS = {
None: "play",
"시작": "play",
"중지": "stop",
"변경": "change",
"페이드인": "fade_in",
"페이드아웃": "fade_out",
}
def parse(self, script: str) -> ParsedScript:
"""스크립트 파싱"""
lines = script.strip().split('\n')
title: Optional[str] = None
characters: dict[str, Character] = {}
elements: list[ScriptElement] = []
for line in lines:
line = line.strip()
if not line:
continue
# 제목
if match := self.TITLE_PATTERN.match(line):
title = match.group(1)
continue
# 지문/장면
if match := self.DIRECTION_PATTERN.match(line):
elements.append(ScriptElement(
type=ElementType.DIRECTION,
text=match.group(1)
))
continue
# 효과음
if match := self.SFX_PATTERN.match(line):
elements.append(ScriptElement(
type=ElementType.SFX,
description=match.group(1),
volume=1.0
))
continue
# 음악
if match := self.MUSIC_PATTERN.match(line):
action_kr = match.group(1)
action = self.MUSIC_ACTIONS.get(action_kr, "play")
elements.append(ScriptElement(
type=ElementType.MUSIC,
description=match.group(2),
action=action,
volume=0.3,
fade_duration=2.0
))
continue
# 쉼
if match := self.PAUSE_PATTERN.match(line):
elements.append(ScriptElement(
type=ElementType.PAUSE,
duration=float(match.group(1))
))
continue
# 대사
if match := self.DIALOGUE_PATTERN.match(line):
char_name = match.group(1).strip()
char_info = match.group(2) # 괄호 안 내용 (설명, 감정)
dialogue_text = match.group(3).strip()
# 캐릭터 정보 파싱
emotion = None
description = None
if char_info:
parts = [p.strip() for p in char_info.split(',')]
if len(parts) >= 2:
description = parts[0]
emotion = parts[1]
else:
# 단일 값은 감정으로 처리
emotion = parts[0]
# 캐릭터 등록
if char_name not in characters:
characters[char_name] = Character(
name=char_name,
description=description
)
elif description and not characters[char_name].description:
characters[char_name].description = description
elements.append(ScriptElement(
type=ElementType.DIALOGUE,
character=char_name,
text=dialogue_text,
emotion=emotion
))
continue
# 매칭 안 되는 줄은 지문으로 처리 (대괄호 없는 일반 텍스트)
if not line.startswith('[') and not line.startswith('#'):
# 콜론이 없으면 지문으로 처리
if ':' not in line:
elements.append(ScriptElement(
type=ElementType.DIRECTION,
text=line
))
return ParsedScript(
title=title,
characters=list(characters.values()),
elements=elements
)
def validate_script(self, script: str) -> tuple[bool, list[str]]:
"""
스크립트 유효성 검사
Returns: (is_valid, error_messages)
"""
errors = []
if not script or not script.strip():
errors.append("스크립트가 비어있습니다")
return False, errors
parsed = self.parse(script)
if not parsed.elements:
errors.append("파싱된 요소가 없습니다")
# 대사가 있는지 확인
dialogue_count = sum(1 for e in parsed.elements if e.type == ElementType.DIALOGUE)
if dialogue_count == 0:
errors.append("대사가 없습니다")
return len(errors) == 0, errors
# 싱글톤 인스턴스
script_parser = ScriptParser()

View File

@ -0,0 +1,135 @@
"""TTS 엔진 클라이언트
audio-studio-tts 서비스와 통신
"""
import os
import logging
from typing import Optional, Tuple, List
import httpx
logger = logging.getLogger(__name__)
class TTSClient:
"""TTS 엔진 HTTP 클라이언트"""
def __init__(self):
self.base_url = os.getenv("TTS_ENGINE_URL", "http://localhost:8001")
self.timeout = httpx.Timeout(120.0, connect=10.0) # TTS는 시간이 걸릴 수 있음
async def health_check(self) -> dict:
"""TTS 엔진 헬스체크"""
async with httpx.AsyncClient(timeout=self.timeout) as client:
response = await client.get(f"{self.base_url}/health")
response.raise_for_status()
return response.json()
async def get_speakers(self) -> List[str]:
"""프리셋 스피커 목록 조회"""
async with httpx.AsyncClient(timeout=self.timeout) as client:
response = await client.get(f"{self.base_url}/speakers")
response.raise_for_status()
return response.json()["speakers"]
async def get_languages(self) -> dict:
"""지원 언어 목록 조회"""
async with httpx.AsyncClient(timeout=self.timeout) as client:
response = await client.get(f"{self.base_url}/languages")
response.raise_for_status()
return response.json()["languages"]
async def synthesize(
self,
text: str,
speaker: str = "Chelsie",
language: str = "ko",
instruct: Optional[str] = None,
) -> Tuple[bytes, int]:
"""프리셋 음성으로 TTS 합성
Returns:
(audio_bytes, sample_rate)
"""
async with httpx.AsyncClient(timeout=self.timeout) as client:
payload = {
"text": text,
"speaker": speaker,
"language": language,
}
if instruct:
payload["instruct"] = instruct
response = await client.post(
f"{self.base_url}/synthesize",
json=payload,
)
response.raise_for_status()
# 샘플레이트 추출
sample_rate = int(response.headers.get("X-Sample-Rate", "24000"))
return response.content, sample_rate
async def voice_clone(
self,
text: str,
ref_audio: bytes,
ref_text: str,
language: str = "ko",
) -> Tuple[bytes, int]:
"""Voice Clone으로 TTS 합성
Returns:
(audio_bytes, sample_rate)
"""
async with httpx.AsyncClient(timeout=self.timeout) as client:
# multipart/form-data로 전송
files = {"ref_audio": ("reference.wav", ref_audio, "audio/wav")}
data = {
"text": text,
"ref_text": ref_text,
"language": language,
}
response = await client.post(
f"{self.base_url}/voice-clone",
files=files,
data=data,
)
response.raise_for_status()
sample_rate = int(response.headers.get("X-Sample-Rate", "24000"))
return response.content, sample_rate
async def voice_design(
self,
text: str,
instruct: str,
language: str = "ko",
) -> Tuple[bytes, int]:
"""Voice Design으로 TTS 합성
Returns:
(audio_bytes, sample_rate)
"""
async with httpx.AsyncClient(timeout=self.timeout) as client:
payload = {
"text": text,
"instruct": instruct,
"language": language,
}
response = await client.post(
f"{self.base_url}/voice-design",
json=payload,
)
response.raise_for_status()
sample_rate = int(response.headers.get("X-Sample-Rate", "24000"))
return response.content, sample_rate
# 싱글톤 인스턴스
tts_client = TTSClient()

View File

@ -0,0 +1,31 @@
# Audio Studio API Server - Dependencies
# FastAPI
fastapi==0.115.6
uvicorn[standard]==0.34.0
python-multipart==0.0.20
# Database
motor==3.7.0
pymongo==4.10.1
redis==5.2.1
# Pydantic
pydantic==2.10.4
pydantic-settings==2.7.1
# HTTP Client
httpx==0.28.1
aiofiles==24.1.0
# Audio Processing
soundfile>=0.12.1
numpy>=1.26.0
pydub>=0.25.1
# Freesound API (httpx로 직접 구현)
# Utilities
python-dotenv==1.0.1
python-jose[cryptography]==3.3.0
passlib[bcrypt]==1.7.4

View File

@ -0,0 +1,56 @@
# Audio Studio MusicGen - GPU Dockerfile
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
# AudioCraft 설치
RUN pip install --no-cache-dir audiocraft
# 나머지 의존성 설치
RUN pip install --no-cache-dir -r requirements.txt
# 소스 코드 복사
COPY app/ ./app/
# 포트 노출
EXPOSE 8002
# 헬스체크
HEALTHCHECK --interval=30s --timeout=30s --start-period=120s --retries=3 \
CMD curl -f http://localhost:8002/health || exit 1
# 서버 실행
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8002"]

View File

View File

@ -0,0 +1,205 @@
"""Audio Studio MusicGen API
AI 음악 생성 API 서버
"""
import logging
from contextlib import asynccontextmanager
from typing import Optional
from fastapi import FastAPI, HTTPException, UploadFile, File, Form
from fastapi.responses import Response
from pydantic import BaseModel, Field
from app.services.musicgen_service import musicgen_service
# 로깅 설정
logging.basicConfig(
level=logging.INFO,
format="%(asctime)s - %(name)s - %(levelname)s - %(message)s"
)
logger = logging.getLogger(__name__)
# ========================================
# Pydantic 모델
# ========================================
class GenerateRequest(BaseModel):
"""음악 생성 요청"""
prompt: str = Field(..., min_length=5, max_length=500, description="음악 설명")
duration: int = Field(default=30, ge=5, le=30, description="생성 길이 (초)")
top_k: int = Field(default=250, ge=50, le=500, description="top-k 샘플링")
temperature: float = Field(default=1.0, ge=0.5, le=2.0, description="생성 다양성")
class HealthResponse(BaseModel):
"""헬스체크 응답"""
status: str
model_info: dict
# ========================================
# 앱 생명주기
# ========================================
@asynccontextmanager
async def lifespan(app: FastAPI):
"""앱 시작/종료 시 실행"""
logger.info("MusicGen 서비스 시작...")
try:
await musicgen_service.initialize()
logger.info("MusicGen 서비스 준비 완료")
except Exception as e:
logger.error(f"MusicGen 초기화 실패: {e}")
# 초기화 실패해도 서버는 시작 (lazy loading 시도)
yield
logger.info("MusicGen 서비스 종료")
# ========================================
# FastAPI 앱
# ========================================
app = FastAPI(
title="Audio Studio MusicGen",
description="AI 음악 생성 API (Meta AudioCraft)",
version="0.1.0",
lifespan=lifespan,
)
# ========================================
# API 엔드포인트
# ========================================
@app.get("/health", response_model=HealthResponse)
async def health_check():
"""헬스체크 엔드포인트"""
return HealthResponse(
status="healthy" if musicgen_service.is_initialized() else "initializing",
model_info=musicgen_service.get_model_info(),
)
@app.post("/generate")
async def generate_music(request: GenerateRequest):
"""텍스트 프롬프트로 음악 생성
예시 프롬프트:
- "upbeat electronic music for gaming"
- "calm piano music, peaceful, ambient"
- "energetic rock music with drums"
- "lo-fi hip hop beats, relaxing"
"""
try:
audio_bytes = await musicgen_service.generate(
prompt=request.prompt,
duration=request.duration,
top_k=request.top_k,
temperature=request.temperature,
)
return Response(
content=audio_bytes,
media_type="audio/wav",
headers={
"X-Sample-Rate": "32000",
"X-Duration": str(request.duration),
"Content-Disposition": 'attachment; filename="generated_music.wav"',
},
)
except Exception as e:
logger.error(f"음악 생성 실패: {e}")
raise HTTPException(status_code=500, detail=f"Music generation failed: {str(e)}")
@app.post("/generate-with-melody")
async def generate_with_melody(
prompt: str = Form(..., min_length=5, description="음악 설명"),
duration: int = Form(default=30, ge=5, le=30, description="생성 길이"),
melody_audio: UploadFile = File(..., description="참조 멜로디 오디오"),
):
"""멜로디 조건부 음악 생성
참조 멜로디의 멜로디/하모니를 유지하면서 새로운 음악 생성
"""
try:
melody_bytes = await melody_audio.read()
if len(melody_bytes) < 1000:
raise HTTPException(status_code=400, detail="Melody audio is too small")
audio_bytes = await musicgen_service.generate_with_melody(
prompt=prompt,
melody_audio=melody_bytes,
duration=duration,
)
return Response(
content=audio_bytes,
media_type="audio/wav",
headers={
"X-Sample-Rate": "32000",
"X-Duration": str(duration),
"Content-Disposition": 'attachment; filename="melody_based_music.wav"',
},
)
except HTTPException:
raise
except Exception as e:
logger.error(f"멜로디 기반 생성 실패: {e}")
raise HTTPException(status_code=500, detail=f"Generation failed: {str(e)}")
@app.get("/prompts")
async def get_example_prompts():
"""예시 프롬프트 목록"""
return {
"examples": [
{
"category": "Electronic",
"prompts": [
"upbeat electronic dance music with synthesizers",
"chill electronic ambient music",
"retro synthwave 80s style music",
],
},
{
"category": "Classical",
"prompts": [
"calm piano solo, classical style",
"orchestral epic cinematic music",
"gentle string quartet, romantic",
],
},
{
"category": "Pop/Rock",
"prompts": [
"energetic rock music with electric guitar",
"upbeat pop song with catchy melody",
"acoustic guitar folk music",
],
},
{
"category": "Ambient/Lo-fi",
"prompts": [
"lo-fi hip hop beats, relaxing, study music",
"peaceful ambient nature sounds music",
"meditation music, calm, zen",
],
},
{
"category": "Game/Film",
"prompts": [
"epic adventure game soundtrack",
"tense suspenseful thriller music",
"cheerful happy video game background",
],
},
]
}

View File

@ -0,0 +1,199 @@
"""MusicGen 서비스
Meta AudioCraft MusicGen을 사용한 AI 음악 생성
"""
import os
import io
import logging
from typing import Optional
import torch
import soundfile as sf
import numpy as np
logger = logging.getLogger(__name__)
class MusicGenService:
"""MusicGen 음악 생성 서비스"""
def __init__(self):
self.device = "cuda:0" if torch.cuda.is_available() else "cpu"
self.model_name = os.getenv("MODEL_NAME", "facebook/musicgen-medium")
self.model = None
self._initialized = False
async def initialize(self):
"""모델 초기화 (서버 시작 시 호출)"""
if self._initialized:
return
logger.info(f"MusicGen 모델 로딩 중: {self.model_name}")
try:
from audiocraft.models import MusicGen
self.model = MusicGen.get_pretrained(self.model_name)
self.model.set_generation_params(
use_sampling=True,
top_k=250,
duration=30, # 기본 30초
)
self._initialized = True
logger.info(f"MusicGen 모델 로드 완료 (device: {self.device})")
except Exception as e:
logger.error(f"MusicGen 모델 로드 실패: {e}")
raise
async def generate(
self,
prompt: str,
duration: int = 30,
top_k: int = 250,
temperature: float = 1.0,
) -> bytes:
"""텍스트 프롬프트로 음악 생성
Args:
prompt: 음악 설명 (예: "upbeat electronic music for gaming")
duration: 생성 길이 (초, 최대 30초)
top_k: top-k 샘플링 파라미터
temperature: 생성 다양성 (높을수록 다양)
Returns:
WAV 바이트
"""
if not self._initialized:
await self.initialize()
# 파라미터 제한
duration = min(max(duration, 5), 30)
logger.info(f"음악 생성 시작: prompt='{prompt[:50]}...', duration={duration}s")
try:
# 생성 파라미터 설정
self.model.set_generation_params(
use_sampling=True,
top_k=top_k,
top_p=0.0,
temperature=temperature,
duration=duration,
)
# 생성
wav = self.model.generate([prompt])
# 결과 처리 (첫 번째 결과만)
audio_data = wav[0].cpu().numpy()
# 스테레오인 경우 모노로 변환
if len(audio_data.shape) > 1:
audio_data = audio_data.mean(axis=0)
# WAV 바이트로 변환
buffer = io.BytesIO()
sf.write(buffer, audio_data, 32000, format='WAV') # MusicGen은 32kHz
buffer.seek(0)
logger.info(f"음악 생성 완료: {duration}")
return buffer.read()
except Exception as e:
logger.error(f"음악 생성 실패: {e}")
raise
async def generate_with_melody(
self,
prompt: str,
melody_audio: bytes,
duration: int = 30,
) -> bytes:
"""멜로디 조건부 음악 생성
Args:
prompt: 음악 설명
melody_audio: 참조 멜로디 오디오 (WAV)
duration: 생성 길이
Returns:
WAV 바이트
"""
if not self._initialized:
await self.initialize()
duration = min(max(duration, 5), 30)
logger.info(f"멜로디 기반 음악 생성: prompt='{prompt[:50]}...', duration={duration}s")
try:
# 멜로디 로드
import torchaudio
buffer = io.BytesIO(melody_audio)
melody, sr = torchaudio.load(buffer)
# 리샘플링 (32kHz로)
if sr != 32000:
melody = torchaudio.functional.resample(melody, sr, 32000)
# 모노로 변환
if melody.shape[0] > 1:
melody = melody.mean(dim=0, keepdim=True)
# 길이 제한 (30초)
max_samples = 32000 * 30
if melody.shape[1] > max_samples:
melody = melody[:, :max_samples]
# 생성 파라미터 설정
self.model.set_generation_params(
use_sampling=True,
top_k=250,
duration=duration,
)
# 멜로디 조건부 생성
wav = self.model.generate_with_chroma(
descriptions=[prompt],
melody_wavs=melody.unsqueeze(0).to(self.device),
melody_sample_rate=32000,
progress=True,
)
# 결과 처리
audio_data = wav[0].cpu().numpy()
if len(audio_data.shape) > 1:
audio_data = audio_data.mean(axis=0)
buffer = io.BytesIO()
sf.write(buffer, audio_data, 32000, format='WAV')
buffer.seek(0)
logger.info(f"멜로디 기반 음악 생성 완료")
return buffer.read()
except Exception as e:
logger.error(f"멜로디 기반 생성 실패: {e}")
raise
def is_initialized(self) -> bool:
"""초기화 상태 확인"""
return self._initialized
def get_model_info(self) -> dict:
"""모델 정보 반환"""
return {
"model_name": self.model_name,
"device": self.device,
"initialized": self._initialized,
"max_duration": 30,
"sample_rate": 32000,
}
# 싱글톤 인스턴스
musicgen_service = MusicGenService()

View File

@ -0,0 +1,24 @@
# Audio Studio MusicGen - Dependencies
# FastAPI
fastapi==0.115.6
uvicorn[standard]==0.34.0
# PyTorch (CUDA 12.x)
--extra-index-url https://download.pytorch.org/whl/cu124
torch>=2.5.0
torchaudio>=2.5.0
# AudioCraft (MusicGen)
audiocraft>=1.3.0
# Audio Processing
soundfile>=0.12.1
numpy>=1.26.0
scipy>=1.14.0
# Utilities
httpx>=0.28.0
pydantic>=2.10.0
pydantic-settings>=2.7.0
python-dotenv>=1.0.1

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

View File

@ -0,0 +1,43 @@
# Audio Studio UI - Dockerfile
FROM node:22-alpine AS base
# 의존성 설치
FROM base AS deps
WORKDIR /app
COPY package.json package-lock.json* ./
RUN npm ci
# 빌드
FROM base AS builder
WORKDIR /app
COPY --from=deps /app/node_modules ./node_modules
COPY . .
RUN npm run build
# 프로덕션
FROM base AS runner
WORKDIR /app
ENV NODE_ENV=production
RUN addgroup --system --gid 1001 nodejs
RUN adduser --system --uid 1001 nextjs
COPY --from=builder /app/public ./public
# standalone 모드 사용
COPY --from=builder --chown=nextjs:nodejs /app/.next/standalone ./
COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static
USER nextjs
EXPOSE 3000
ENV PORT=3000
ENV HOSTNAME="0.0.0.0"
CMD ["node", "server.js"]

6
audio-studio-ui/next-env.d.ts vendored Normal file
View File

@ -0,0 +1,6 @@
/// <reference types="next" />
/// <reference types="next/image-types/global" />
/// <reference path="./.next/types/routes.d.ts" />
// NOTE: This file should not be edited
// see https://nextjs.org/docs/app/api-reference/config/typescript for more information.

View File

@ -0,0 +1,10 @@
import type { NextConfig } from "next";
const nextConfig: NextConfig = {
output: "standalone",
env: {
NEXT_PUBLIC_API_URL: process.env.NEXT_PUBLIC_API_URL || "http://localhost:8000",
},
};
export default nextConfig;

7238
audio-studio-ui/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,42 @@
{
"name": "drama-studio-ui",
"version": "0.1.0",
"private": true,
"scripts": {
"dev": "next dev",
"build": "next build",
"start": "next start",
"lint": "next lint"
},
"dependencies": {
"next": "^15.1.0",
"react": "^19.0.0",
"react-dom": "^19.0.0",
"@radix-ui/react-dialog": "^1.1.4",
"@radix-ui/react-dropdown-menu": "^2.1.4",
"@radix-ui/react-icons": "^1.3.2",
"@radix-ui/react-label": "^2.1.1",
"@radix-ui/react-select": "^2.1.4",
"@radix-ui/react-slider": "^1.2.2",
"@radix-ui/react-slot": "^1.1.1",
"@radix-ui/react-tabs": "^1.1.2",
"@radix-ui/react-toast": "^1.2.4",
"@radix-ui/react-tooltip": "^1.1.6",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"lucide-react": "^0.469.0",
"tailwind-merge": "^2.6.0",
"tailwindcss-animate": "^1.0.7"
},
"devDependencies": {
"@tailwindcss/postcss": "^4.0.0",
"@types/node": "^22.10.0",
"@types/react": "^19.0.0",
"@types/react-dom": "^19.0.0",
"typescript": "^5.7.0",
"tailwindcss": "^4.0.0",
"postcss": "^8.4.49",
"eslint": "^9.17.0",
"eslint-config-next": "^15.1.0"
}
}

View File

@ -0,0 +1,5 @@
export default {
plugins: {
"@tailwindcss/postcss": {},
},
};

View File

View File

@ -0,0 +1,432 @@
"use client";
import { useState, useEffect } from "react";
import {
parseScript,
createDramaProject,
getDramaProjects,
getDramaProject,
renderDrama,
getDramaDownloadUrl,
getVoices,
updateVoiceMapping,
DramaProject,
ParsedScript,
Voice,
} from "@/lib/api";
import { formatDuration } from "@/lib/utils";
// 예시 스크립트
const EXAMPLE_SCRIPT = `# 아침의 카페
[장소: 조용한 카페, 아침 햇살이 들어오는 창가]
[음악: 잔잔한 재즈 피아노]
민수(30대 남성, 차분): 오랜만이야. 잘 지냈어?
수진(20대 여성, 밝음): 어! 민수 오빠! 완전 오랜만이다!
[효과음: 커피잔 내려놓는 소리]
민수: 커피 시켰어. 아메리카노 맞지?
수진(감사하며): 고마워. 오빠는 여전하네.
[쉼: 2초]
민수(조용히): 그동안... 많이 보고 싶었어.
[음악 페이드아웃: 여운]`;
export default function DramaPage() {
// 상태
const [script, setScript] = useState(EXAMPLE_SCRIPT);
const [title, setTitle] = useState("새 드라마");
const [parsedScript, setParsedScript] = useState<ParsedScript | null>(null);
const [parseError, setParseError] = useState<string | null>(null);
const [projects, setProjects] = useState<DramaProject[]>([]);
const [selectedProject, setSelectedProject] = useState<DramaProject | null>(null);
const [voices, setVoices] = useState<Voice[]>([]);
const [voiceMapping, setVoiceMapping] = useState<Record<string, string>>({});
const [loading, setLoading] = useState(false);
const [renderStatus, setRenderStatus] = useState<string | null>(null);
// 초기 로드
useEffect(() => {
loadProjects();
loadVoices();
}, []);
async function loadProjects() {
try {
const data = await getDramaProjects();
setProjects(data);
} catch (e) {
console.error("프로젝트 로드 실패:", e);
}
}
async function loadVoices() {
try {
const response = await getVoices({ page_size: 100 });
setVoices(response.voices);
} catch (e) {
console.error("보이스 로드 실패:", e);
}
}
// 스크립트 미리보기 파싱
async function handleParsePreview() {
setParseError(null);
try {
const parsed = await parseScript(script);
setParsedScript(parsed);
// 캐릭터별 기본 보이스 매핑
const mapping: Record<string, string> = {};
parsed.characters.forEach((char) => {
if (!voiceMapping[char.name] && voices.length > 0) {
mapping[char.name] = voices[0].voice_id;
} else {
mapping[char.name] = voiceMapping[char.name] || "";
}
});
setVoiceMapping(mapping);
} catch (e) {
setParseError(e instanceof Error ? e.message : "파싱 실패");
}
}
// 프로젝트 생성
async function handleCreateProject() {
setLoading(true);
try {
const project = await createDramaProject({
title,
script,
voice_mapping: voiceMapping,
auto_generate_assets: true,
});
setSelectedProject(project);
await loadProjects();
} catch (e) {
alert(e instanceof Error ? e.message : "프로젝트 생성 실패");
} finally {
setLoading(false);
}
}
// 렌더링 시작
async function handleRender() {
if (!selectedProject) return;
setLoading(true);
setRenderStatus("렌더링 시작 중...");
try {
// 보이스 매핑 업데이트
await updateVoiceMapping(selectedProject.project_id, voiceMapping);
// 렌더링 시작
await renderDrama(selectedProject.project_id);
setRenderStatus("렌더링 중... (TTS, 효과음 생성 및 믹싱)");
// 상태 폴링
const pollStatus = async () => {
const project = await getDramaProject(selectedProject.project_id);
setSelectedProject(project);
if (project.status === "completed") {
setRenderStatus("완료!");
setLoading(false);
} else if (project.status === "error") {
setRenderStatus(`오류: ${project.error_message}`);
setLoading(false);
} else {
setTimeout(pollStatus, 2000);
}
};
setTimeout(pollStatus, 2000);
} catch (e) {
setRenderStatus(`오류: ${e instanceof Error ? e.message : "렌더링 실패"}`);
setLoading(false);
}
}
// 프로젝트 선택
async function handleSelectProject(project: DramaProject) {
setSelectedProject(project);
try {
const fullProject = await getDramaProject(project.project_id);
setSelectedProject(fullProject);
// 캐릭터 보이스 매핑
const mapping: Record<string, string> = {};
fullProject.characters.forEach((char) => {
mapping[char.name] = char.voice_id || "";
});
setVoiceMapping(mapping);
} catch (e) {
console.error("프로젝트 상세 로드 실패:", e);
}
}
return (
<div className="space-y-6">
{/* 헤더 */}
<div>
<h1 className="text-2xl font-bold"> </h1>
<p className="text-muted-foreground">
AI가
</p>
</div>
<div className="grid gap-6 lg:grid-cols-3">
{/* 스크립트 에디터 */}
<div className="lg:col-span-2 space-y-4">
<div className="flex items-center gap-3">
<input
type="text"
value={title}
onChange={(e) => setTitle(e.target.value)}
placeholder="드라마 제목"
className="flex-1 rounded-lg border bg-background px-3 py-2 text-sm font-medium focus:outline-none focus:ring-2 focus:ring-ring"
/>
<button
onClick={handleParsePreview}
className="rounded-lg border px-4 py-2 text-sm font-medium hover:bg-accent"
>
</button>
</div>
<div>
<label className="text-sm font-medium"></label>
<textarea
value={script}
onChange={(e) => setScript(e.target.value)}
placeholder="스크립트를 입력하세요..."
className="mt-1 w-full h-80 rounded-lg border bg-background px-3 py-2 text-sm font-mono resize-none focus:outline-none focus:ring-2 focus:ring-ring"
/>
</div>
{/* 문법 가이드 */}
<div className="rounded-lg bg-secondary p-4 text-sm">
<h3 className="font-medium mb-2"> </h3>
<ul className="space-y-1 text-muted-foreground">
<li><code className="bg-background px-1 rounded"># </code> - </li>
<li><code className="bg-background px-1 rounded">[장소: 설명]</code> - </li>
<li><code className="bg-background px-1 rounded">[효과음: 설명]</code> - </li>
<li><code className="bg-background px-1 rounded">[음악: 설명]</code> - </li>
<li><code className="bg-background px-1 rounded">[: 2초]</code> - </li>
<li><code className="bg-background px-1 rounded">(): </code> - </li>
</ul>
</div>
{/* 파싱 에러 */}
{parseError && (
<div className="rounded-lg border border-destructive/50 bg-destructive/10 p-3 text-sm text-destructive">
{parseError}
</div>
)}
{/* 파싱 결과 */}
{parsedScript && (
<div className="rounded-lg border p-4 space-y-4">
<h3 className="font-medium"> </h3>
{/* 캐릭터 목록 & 보이스 매핑 */}
<div>
<h4 className="text-sm font-medium mb-2"> ({parsedScript.characters.length})</h4>
<div className="space-y-2">
{parsedScript.characters.map((char) => (
<div key={char.name} className="flex items-center gap-3">
<span className="w-24 text-sm truncate">{char.name}</span>
<span className="text-xs text-muted-foreground w-32 truncate">
{char.description || "-"}
</span>
<select
value={voiceMapping[char.name] || ""}
onChange={(e) =>
setVoiceMapping({ ...voiceMapping, [char.name]: e.target.value })
}
className="flex-1 rounded-lg border bg-background px-2 py-1 text-sm"
>
<option value=""> ...</option>
{voices.map((voice) => (
<option key={voice.voice_id} value={voice.voice_id}>
{voice.name} ({voice.type})
</option>
))}
</select>
</div>
))}
</div>
</div>
{/* 요소 수 */}
<div className="text-sm text-muted-foreground">
{parsedScript.elements.length}
( {parsedScript.elements.filter((e) => e.type === "dialogue").length},
{parsedScript.elements.filter((e) => e.type === "sfx").length},
{parsedScript.elements.filter((e) => e.type === "music").length})
</div>
{/* 프로젝트 생성 버튼 */}
<button
onClick={handleCreateProject}
disabled={loading}
className="w-full inline-flex items-center justify-center gap-2 rounded-lg bg-primary px-4 py-2 text-sm font-medium text-primary-foreground hover:bg-primary/90 disabled:opacity-50"
>
{loading ? (
<>
<div className="h-4 w-4 animate-spin rounded-full border-2 border-current border-t-transparent" />
...
</>
) : (
"프로젝트 생성"
)}
</button>
</div>
)}
</div>
{/* 프로젝트 목록 & 상세 */}
<div className="space-y-4">
<h2 className="font-semibold"></h2>
{/* 프로젝트 목록 */}
<div className="space-y-2 max-h-60 overflow-y-auto">
{projects.map((project) => (
<button
key={project.project_id}
onClick={() => handleSelectProject(project)}
className={`w-full text-left rounded-lg border p-3 transition-colors ${
selectedProject?.project_id === project.project_id
? "border-primary bg-primary/5"
: "hover:bg-accent"
}`}
>
<div className="flex items-center justify-between">
<span className="font-medium text-sm truncate">{project.title}</span>
<span
className={`text-xs px-1.5 py-0.5 rounded ${
project.status === "completed"
? "bg-green-100 text-green-700"
: project.status === "processing"
? "bg-yellow-100 text-yellow-700"
: project.status === "error"
? "bg-red-100 text-red-700"
: "bg-secondary"
}`}
>
{project.status === "completed"
? "완료"
: project.status === "processing"
? "처리 중"
: project.status === "error"
? "오류"
: "초안"}
</span>
</div>
<div className="text-xs text-muted-foreground mt-1">
{project.characters.length} · {project.element_count}
{project.estimated_duration && (
<> · {formatDuration(project.estimated_duration)}</>
)}
</div>
</button>
))}
{projects.length === 0 && (
<p className="text-sm text-muted-foreground text-center py-4">
</p>
)}
</div>
{/* 선택된 프로젝트 상세 */}
{selectedProject && (
<div className="rounded-lg border p-4 space-y-4">
<h3 className="font-medium">{selectedProject.title}</h3>
<div className="text-sm space-y-1">
<p>: {selectedProject.status}</p>
<p>: {selectedProject.characters.length}</p>
<p>: {selectedProject.element_count}</p>
{selectedProject.estimated_duration && (
<p> : {formatDuration(selectedProject.estimated_duration)}</p>
)}
</div>
{/* 렌더링 상태 */}
{renderStatus && (
<div className="text-sm text-muted-foreground">{renderStatus}</div>
)}
{/* 액션 버튼 */}
<div className="flex gap-2">
{selectedProject.status === "draft" && (
<button
onClick={handleRender}
disabled={loading}
className="flex-1 inline-flex items-center justify-center gap-2 rounded-lg bg-primary px-3 py-2 text-sm font-medium text-primary-foreground hover:bg-primary/90 disabled:opacity-50"
>
{loading ? (
<>
<div className="h-4 w-4 animate-spin rounded-full border-2 border-current border-t-transparent" />
...
</>
) : (
<>
<svg
xmlns="http://www.w3.org/2000/svg"
style={{ width: 16, height: 16 }}
viewBox="0 0 24 24"
fill="currentColor"
>
<polygon points="5 3 19 12 5 21 5 3" />
</svg>
</>
)}
</button>
)}
{selectedProject.status === "completed" && (
<a
href={getDramaDownloadUrl(selectedProject.project_id)}
download
className="flex-1 inline-flex items-center justify-center gap-2 rounded-lg bg-primary px-3 py-2 text-sm font-medium text-primary-foreground hover:bg-primary/90"
>
<svg
xmlns="http://www.w3.org/2000/svg"
style={{ width: 16, height: 16 }}
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
>
<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4" />
<polyline points="7 10 12 15 17 10" />
<line x1="12" y1="15" x2="12" y2="3" />
</svg>
</a>
)}
</div>
{selectedProject.error_message && (
<div className="rounded-lg border border-destructive/50 bg-destructive/10 p-3 text-sm text-destructive">
{selectedProject.error_message}
</div>
)}
</div>
)}
</div>
</div>
</div>
);
}

View File

@ -0,0 +1,59 @@
@import "tailwindcss";
@theme {
--color-background: oklch(1 0 0);
--color-foreground: oklch(0.145 0 0);
--color-card: oklch(1 0 0);
--color-card-foreground: oklch(0.145 0 0);
--color-popover: oklch(1 0 0);
--color-popover-foreground: oklch(0.145 0 0);
--color-primary: oklch(0.205 0 0);
--color-primary-foreground: oklch(0.985 0 0);
--color-secondary: oklch(0.97 0 0);
--color-secondary-foreground: oklch(0.205 0 0);
--color-muted: oklch(0.97 0 0);
--color-muted-foreground: oklch(0.556 0 0);
--color-accent: oklch(0.97 0 0);
--color-accent-foreground: oklch(0.205 0 0);
--color-destructive: oklch(0.577 0.245 27.325);
--color-destructive-foreground: oklch(0.577 0.245 27.325);
--color-border: oklch(0.922 0 0);
--color-input: oklch(0.922 0 0);
--color-ring: oklch(0.708 0.165 254.624);
--radius: 0.625rem;
}
@layer base {
* {
@apply border-border;
}
body {
@apply bg-background text-foreground;
}
}
/* 오디오 파형 스타일 */
.waveform-container {
@apply w-full h-16 bg-muted rounded-lg overflow-hidden;
}
/* 오디오 플레이어 스타일 */
.audio-player {
@apply flex items-center gap-4 p-4 bg-card rounded-lg border;
}
/* 녹음 버튼 애니메이션 */
@keyframes pulse-recording {
0%, 100% {
opacity: 1;
transform: scale(1);
}
50% {
opacity: 0.8;
transform: scale(1.05);
}
}
.recording-pulse {
animation: pulse-recording 1.5s ease-in-out infinite;
}

View File

@ -0,0 +1,85 @@
import type { Metadata } from "next";
import "./globals.css";
export const metadata: Metadata = {
title: "Drama Studio",
description: "AI 라디오 드라마 제작 시스템",
};
export default function RootLayout({
children,
}: Readonly<{
children: React.ReactNode;
}>) {
return (
<html lang="ko">
<body className="min-h-screen bg-background antialiased">
<div className="flex min-h-screen">
{/* 사이드바 */}
<aside className="w-48 border-r bg-card px-3 py-4">
<div className="mb-5 px-2">
<h1 className="text-base font-bold">Drama Studio</h1>
<p className="text-xs text-muted-foreground">AI </p>
</div>
<nav className="space-y-0.5">
<a href="/" className="flex items-center gap-2 rounded-md px-2 py-1.5 text-sm hover:bg-accent">
<svg xmlns="http://www.w3.org/2000/svg" style={{width: 16, height: 16, flexShrink: 0}} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<path d="M3 9l9-7 9 7v11a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z" />
<polyline points="9,22 9,12 15,12 15,22" />
</svg>
</a>
<a href="/drama" className="flex items-center gap-2 rounded-md px-2 py-1.5 text-sm hover:bg-accent">
<svg xmlns="http://www.w3.org/2000/svg" style={{width: 16, height: 16, flexShrink: 0}} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<path d="M4 19.5A2.5 2.5 0 0 1 6.5 17H20" />
<path d="M6.5 2H20v20H6.5A2.5 2.5 0 0 1 4 19.5v-15A2.5 2.5 0 0 1 6.5 2z" />
</svg>
</a>
<a href="/voices" className="flex items-center gap-2 rounded-md px-2 py-1.5 text-sm hover:bg-accent">
<svg xmlns="http://www.w3.org/2000/svg" style={{width: 16, height: 16, flexShrink: 0}} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<path d="M12 1a3 3 0 0 0-3 3v8a3 3 0 0 0 6 0V4a3 3 0 0 0-3-3z" />
<path d="M19 10v2a7 7 0 0 1-14 0v-2" />
<line x1="12" y1="19" x2="12" y2="23" />
<line x1="8" y1="23" x2="16" y2="23" />
</svg>
</a>
<a href="/tts" className="flex items-center gap-2 rounded-md px-2 py-1.5 text-sm hover:bg-accent">
<svg xmlns="http://www.w3.org/2000/svg" style={{width: 16, height: 16, flexShrink: 0}} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<polygon points="11 5 6 9 2 9 2 15 6 15 11 19 11 5" />
<path d="M19.07 4.93a10 10 0 0 1 0 14.14M15.54 8.46a5 5 0 0 1 0 7.07" />
</svg>
TTS
</a>
<a href="/recordings" className="flex items-center gap-2 rounded-md px-2 py-1.5 text-sm hover:bg-accent">
<svg xmlns="http://www.w3.org/2000/svg" style={{width: 16, height: 16, flexShrink: 0}} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<circle cx="12" cy="12" r="10" />
<circle cx="12" cy="12" r="3" fill="currentColor" />
</svg>
</a>
<a href="/sound-effects" className="flex items-center gap-2 rounded-md px-2 py-1.5 text-sm hover:bg-accent">
<svg xmlns="http://www.w3.org/2000/svg" style={{width: 16, height: 16, flexShrink: 0}} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<polygon points="12 2 15.09 8.26 22 9.27 17 14.14 18.18 21.02 12 17.77 5.82 21.02 7 14.14 2 9.27 8.91 8.26 12 2" />
</svg>
</a>
<a href="/music" className="flex items-center gap-2 rounded-md px-2 py-1.5 text-sm hover:bg-accent">
<svg xmlns="http://www.w3.org/2000/svg" style={{width: 16, height: 16, flexShrink: 0}} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<path d="M9 18V5l12-2v13" />
<circle cx="6" cy="18" r="3" />
<circle cx="18" cy="16" r="3" />
</svg>
</a>
</nav>
</aside>
{/* 메인 콘텐츠 */}
<main className="flex-1 p-6">{children}</main>
</div>
</body>
</html>
);
}

View File

@ -0,0 +1,251 @@
"use client";
import { useState, useRef } from "react";
import { formatDuration } from "@/lib/utils";
const API_BASE_URL = process.env.NEXT_PUBLIC_API_URL || "http://localhost:8000";
// 예시 프롬프트
const EXAMPLE_PROMPTS = [
{ category: "Ambient", prompts: ["calm piano music, peaceful, ambient", "lo-fi hip hop beats, relaxing, study music", "meditation music, calm, zen"] },
{ category: "Electronic", prompts: ["upbeat electronic dance music", "retro synthwave 80s style", "chill electronic ambient"] },
{ category: "Cinematic", prompts: ["epic orchestral cinematic music", "tense suspenseful thriller music", "cheerful happy video game background"] },
];
export default function MusicPage() {
const [prompt, setPrompt] = useState("");
const [duration, setDuration] = useState(30);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const [audioUrl, setAudioUrl] = useState<string | null>(null);
const audioRef = useRef<HTMLAudioElement>(null);
async function handleGenerate() {
if (!prompt.trim()) {
setError("프롬프트를 입력하세요");
return;
}
setLoading(true);
setError(null);
try {
const response = await fetch(`${API_BASE_URL}/api/v1/music/generate`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
prompt: prompt.trim(),
duration,
save_to_library: true,
}),
});
if (!response.ok) {
const errorData = await response.json().catch(() => ({ detail: "Unknown error" }));
throw new Error(errorData.detail || `HTTP ${response.status}`);
}
const blob = await response.blob();
// 기존 URL 해제
if (audioUrl) {
URL.revokeObjectURL(audioUrl);
}
const url = URL.createObjectURL(blob);
setAudioUrl(url);
// 자동 재생
setTimeout(() => {
audioRef.current?.play();
}, 100);
} catch (e) {
setError(e instanceof Error ? e.message : "음악 생성 실패");
} finally {
setLoading(false);
}
}
function handleDownload() {
if (!audioUrl) return;
const a = document.createElement("a");
a.href = audioUrl;
a.download = `music_${Date.now()}.wav`;
a.click();
}
function selectPrompt(p: string) {
setPrompt(p);
}
return (
<div className="space-y-6">
{/* 헤더 */}
<div>
<h1 className="text-2xl font-bold"> </h1>
<p className="text-muted-foreground">
AI로 (MusicGen)
</p>
</div>
<div className="grid gap-6 lg:grid-cols-3">
{/* 프롬프트 예시 */}
<div className="space-y-4">
<h2 className="font-semibold"> </h2>
<div className="space-y-4">
{EXAMPLE_PROMPTS.map((category) => (
<div key={category.category}>
<h3 className="text-sm font-medium text-muted-foreground mb-2">
{category.category}
</h3>
<div className="space-y-1">
{category.prompts.map((p) => (
<button
key={p}
onClick={() => selectPrompt(p)}
className="w-full text-left rounded-lg border px-3 py-2 text-sm hover:bg-accent transition-colors"
>
{p}
</button>
))}
</div>
</div>
))}
</div>
</div>
{/* 생성 영역 */}
<div className="lg:col-span-2 space-y-4">
<div>
<label className="text-sm font-medium"> ( )</label>
<textarea
value={prompt}
onChange={(e) => setPrompt(e.target.value)}
placeholder="원하는 음악을 설명하세요... (예: calm piano music, peaceful, ambient)"
className="mt-1 w-full h-32 rounded-lg border bg-background px-3 py-2 text-sm resize-none focus:outline-none focus:ring-2 focus:ring-ring"
maxLength={500}
/>
<p className="text-xs text-muted-foreground text-right">
{prompt.length}/500
</p>
</div>
<div>
<label className="text-sm font-medium">: {duration}</label>
<input
type="range"
min={5}
max={30}
value={duration}
onChange={(e) => setDuration(Number(e.target.value))}
className="mt-1 w-full"
/>
<div className="flex justify-between text-xs text-muted-foreground">
<span>5</span>
<span>30</span>
</div>
</div>
{/* 에러 */}
{error && (
<div className="rounded-lg border border-destructive/50 bg-destructive/10 p-3 text-sm text-destructive">
{error}
</div>
)}
{/* 버튼 */}
<button
onClick={handleGenerate}
disabled={loading || !prompt.trim()}
className="w-full inline-flex items-center justify-center gap-2 rounded-lg bg-primary px-4 py-3 text-sm font-medium text-primary-foreground hover:bg-primary/90 disabled:opacity-50"
>
{loading ? (
<>
<div className="h-4 w-4 animate-spin rounded-full border-2 border-current border-t-transparent" />
... ( 2 )
</>
) : (
<>
<svg
xmlns="http://www.w3.org/2000/svg"
className="h-4 w-4"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
>
<path d="M9 18V5l12-2v13" />
<circle cx="6" cy="18" r="3" />
<circle cx="18" cy="16" r="3" />
</svg>
</>
)}
</button>
{/* 오디오 플레이어 */}
{audioUrl && (
<div className="rounded-lg border bg-card p-4 space-y-3">
<h3 className="font-medium"> </h3>
<audio
ref={audioRef}
src={audioUrl}
controls
className="w-full"
/>
<div className="flex gap-2">
<button
onClick={() => audioRef.current?.play()}
className="inline-flex items-center gap-2 rounded-lg border px-3 py-2 text-sm font-medium hover:bg-accent"
>
<svg
xmlns="http://www.w3.org/2000/svg"
className="h-4 w-4"
viewBox="0 0 24 24"
fill="currentColor"
>
<polygon points="5 3 19 12 5 21 5 3" />
</svg>
</button>
<button
onClick={handleDownload}
className="inline-flex items-center gap-2 rounded-lg border px-3 py-2 text-sm font-medium hover:bg-accent"
>
<svg
xmlns="http://www.w3.org/2000/svg"
className="h-4 w-4"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
>
<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4" />
<polyline points="7 10 12 15 17 10" />
<line x1="12" y1="15" x2="12" y2="3" />
</svg>
</button>
</div>
<p className="text-xs text-muted-foreground">
* MusicGen으로 CC-BY-NC . .
</p>
</div>
)}
{/* 정보 */}
<div className="rounded-lg bg-secondary p-4">
<h3 className="font-medium mb-2">MusicGen </h3>
<ul className="text-sm text-muted-foreground space-y-1">
<li> Meta AI의 AI </li>
<li> </li>
<li> 30 </li>
<li> </li>
</ul>
</div>
</div>
</div>
</div>
);
}

View File

@ -0,0 +1,235 @@
"use client";
import { useEffect, useState } from "react";
import { healthCheck, getDramaProjects, DramaProject } from "@/lib/api";
export default function DashboardPage() {
const [health, setHealth] = useState<{
status: string;
services: { mongodb: string; redis: string };
} | null>(null);
const [error, setError] = useState<string | null>(null);
const [projects, setProjects] = useState<DramaProject[]>([]);
useEffect(() => {
healthCheck()
.then(setHealth)
.catch((e) => setError(e.message));
getDramaProjects()
.then(setProjects)
.catch(() => {});
}, []);
// 프로젝트 통계
const stats = {
total: projects.length,
draft: projects.filter(p => p.status === "draft").length,
processing: projects.filter(p => p.status === "processing").length,
completed: projects.filter(p => p.status === "completed").length,
error: projects.filter(p => p.status === "error").length,
};
const statusLabel: Record<string, { text: string; color: string }> = {
draft: { text: "편집 중", color: "text-gray-600 bg-gray-100" },
processing: { text: "렌더링", color: "text-blue-600 bg-blue-100" },
completed: { text: "완료", color: "text-green-600 bg-green-100" },
error: { text: "오류", color: "text-red-600 bg-red-100" },
};
return (
<div className="space-y-6 max-w-5xl">
{/* 헤더 */}
<div className="flex items-center justify-between">
<div>
<h1 className="text-2xl font-bold">Drama Studio</h1>
<p className="text-sm text-muted-foreground">
AI
</p>
</div>
<a
href="/drama"
className="inline-flex items-center gap-2 rounded-md bg-primary px-4 py-2 text-sm font-medium text-primary-foreground hover:bg-primary/90"
>
<svg xmlns="http://www.w3.org/2000/svg" style={{width: 16, height: 16}} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<line x1="12" y1="5" x2="12" y2="19" />
<line x1="5" y1="12" x2="19" y2="12" />
</svg>
</a>
</div>
{/* 프로젝트 현황 */}
<div>
<h2 className="text-lg font-semibold mb-3"> </h2>
<div className="grid gap-3 grid-cols-2 lg:grid-cols-5">
<div className="rounded-lg border bg-card p-4">
<h3 className="text-xs font-medium text-muted-foreground"></h3>
<p className="mt-1 text-2xl font-bold">{stats.total}</p>
</div>
<div className="rounded-lg border bg-card p-4">
<h3 className="text-xs font-medium text-muted-foreground"> </h3>
<p className="mt-1 text-2xl font-bold text-gray-600">{stats.draft}</p>
</div>
<div className="rounded-lg border bg-card p-4">
<h3 className="text-xs font-medium text-muted-foreground"></h3>
<p className="mt-1 text-2xl font-bold text-blue-600">{stats.processing}</p>
</div>
<div className="rounded-lg border bg-card p-4">
<h3 className="text-xs font-medium text-muted-foreground"></h3>
<p className="mt-1 text-2xl font-bold text-green-600">{stats.completed}</p>
</div>
<div className="rounded-lg border bg-card p-4">
<h3 className="text-xs font-medium text-muted-foreground"></h3>
<p className="mt-1 text-2xl font-bold text-red-600">{stats.error}</p>
</div>
</div>
</div>
{/* 최근 프로젝트 */}
<div>
<div className="flex items-center justify-between mb-3">
<h2 className="text-lg font-semibold"> </h2>
{projects.length > 0 && (
<a href="/drama" className="text-sm text-primary hover:underline">
</a>
)}
</div>
{projects.length === 0 ? (
<div className="rounded-lg border border-dashed p-8 text-center">
<svg xmlns="http://www.w3.org/2000/svg" style={{width: 40, height: 40, margin: "0 auto"}} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" className="text-muted-foreground">
<path d="M4 19.5A2.5 2.5 0 0 1 6.5 17H20" />
<path d="M6.5 2H20v20H6.5A2.5 2.5 0 0 1 4 19.5v-15A2.5 2.5 0 0 1 6.5 2z" />
</svg>
<p className="mt-3 text-sm text-muted-foreground">
</p>
<a
href="/drama"
className="mt-3 inline-flex items-center gap-1 text-sm text-primary hover:underline"
>
<svg xmlns="http://www.w3.org/2000/svg" style={{width: 14, height: 14}} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<line x1="5" y1="12" x2="19" y2="12" />
<polyline points="12 5 19 12 12 19" />
</svg>
</a>
</div>
) : (
<div className="space-y-2">
{projects.slice(0, 5).map((project) => (
<a
key={project.project_id}
href={`/drama?id=${project.project_id}`}
className="flex items-center justify-between rounded-lg border bg-card p-4 hover:bg-accent transition-colors"
>
<div className="flex items-center gap-3">
<svg xmlns="http://www.w3.org/2000/svg" style={{width: 20, height: 20, flexShrink: 0}} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" className="text-muted-foreground">
<path d="M4 19.5A2.5 2.5 0 0 1 6.5 17H20" />
<path d="M6.5 2H20v20H6.5A2.5 2.5 0 0 1 4 19.5v-15A2.5 2.5 0 0 1 6.5 2z" />
</svg>
<div>
<h3 className="text-sm font-medium">{project.title}</h3>
<p className="text-xs text-muted-foreground">
{project.characters.length} · {project.element_count}
{project.estimated_duration && ` · ${Math.round(project.estimated_duration / 60)}`}
</p>
</div>
</div>
<span className={`rounded-full px-2 py-0.5 text-xs font-medium ${statusLabel[project.status]?.color || ""}`}>
{statusLabel[project.status]?.text || project.status}
</span>
</a>
))}
</div>
)}
</div>
{/* 시스템 상태 */}
<div>
<h2 className="text-lg font-semibold mb-3"> </h2>
<div className="grid gap-3 grid-cols-2 lg:grid-cols-4">
<div className="rounded-lg border bg-card p-4">
<h3 className="text-xs font-medium text-muted-foreground">API</h3>
{error ? (
<p className="mt-1 text-lg font-semibold text-destructive"></p>
) : health ? (
<p className={`mt-1 text-lg font-semibold ${health.status === "healthy" ? "text-green-600" : "text-yellow-600"}`}>
{health.status === "healthy" ? "정상" : "점검 중"}
</p>
) : (
<p className="mt-1 text-lg font-semibold text-muted-foreground">...</p>
)}
</div>
<div className="rounded-lg border bg-card p-4">
<h3 className="text-xs font-medium text-muted-foreground">MongoDB</h3>
<p className={`mt-1 text-lg font-semibold ${health?.services.mongodb === "healthy" ? "text-green-600" : "text-muted-foreground"}`}>
{health?.services.mongodb === "healthy" ? "연결됨" : "-"}
</p>
</div>
<div className="rounded-lg border bg-card p-4">
<h3 className="text-xs font-medium text-muted-foreground">Redis</h3>
<p className={`mt-1 text-lg font-semibold ${health?.services.redis === "healthy" ? "text-green-600" : "text-muted-foreground"}`}>
{health?.services.redis === "healthy" ? "연결됨" : "-"}
</p>
</div>
<div className="rounded-lg border bg-card p-4">
<h3 className="text-xs font-medium text-muted-foreground">TTS </h3>
<p className="mt-1 text-lg font-semibold text-muted-foreground">-</p>
</div>
</div>
</div>
{/* 빠른 시작 */}
<div>
<h2 className="text-lg font-semibold mb-3"> </h2>
<div className="grid gap-3 grid-cols-1 md:grid-cols-3">
<a href="/drama" className="flex items-center gap-3 rounded-lg border bg-card p-4 hover:bg-accent transition-colors">
<div className="rounded-lg bg-orange-500/10 p-2">
<svg xmlns="http://www.w3.org/2000/svg" style={{width: 16, height: 16}} viewBox="0 0 24 24" fill="none" stroke="#ea580c" strokeWidth="2">
<path d="M4 19.5A2.5 2.5 0 0 1 6.5 17H20" />
<path d="M6.5 2H20v20H6.5A2.5 2.5 0 0 1 4 19.5v-15A2.5 2.5 0 0 1 6.5 2z" />
</svg>
</div>
<div>
<h3 className="text-sm font-medium"> </h3>
<p className="text-xs text-muted-foreground"> </p>
</div>
</a>
<a href="/tts" className="flex items-center gap-3 rounded-lg border bg-card p-4 hover:bg-accent transition-colors">
<div className="rounded-lg bg-blue-500/10 p-2">
<svg xmlns="http://www.w3.org/2000/svg" style={{width: 16, height: 16}} viewBox="0 0 24 24" fill="none" stroke="#2563eb" strokeWidth="2">
<polygon points="11 5 6 9 2 9 2 15 6 15 11 19 11 5" />
<path d="M19.07 4.93a10 10 0 0 1 0 14.14M15.54 8.46a5 5 0 0 1 0 7.07" />
</svg>
</div>
<div>
<h3 className="text-sm font-medium">TTS </h3>
<p className="text-xs text-muted-foreground"> </p>
</div>
</a>
<a href="/voices" className="flex items-center gap-3 rounded-lg border bg-card p-4 hover:bg-accent transition-colors">
<div className="rounded-lg bg-green-500/10 p-2">
<svg xmlns="http://www.w3.org/2000/svg" style={{width: 16, height: 16}} viewBox="0 0 24 24" fill="none" stroke="#16a34a" strokeWidth="2">
<path d="M12 1a3 3 0 0 0-3 3v8a3 3 0 0 0 6 0V4a3 3 0 0 0-3-3z" />
<path d="M19 10v2a7 7 0 0 1-14 0v-2" />
<line x1="12" y1="19" x2="12" y2="23" />
<line x1="8" y1="23" x2="16" y2="23" />
</svg>
</div>
<div>
<h3 className="text-sm font-medium"> </h3>
<p className="text-xs text-muted-foreground">3 </p>
</div>
</a>
</div>
</div>
</div>
);
}

View File

@ -0,0 +1,323 @@
"use client";
import { useState, useRef, useEffect } from "react";
import { validateRecording, uploadRecording } from "@/lib/api";
import { formatDuration } from "@/lib/utils";
export default function RecordingsPage() {
const [isRecording, setIsRecording] = useState(false);
const [audioUrl, setAudioUrl] = useState<string | null>(null);
const [audioBlob, setAudioBlob] = useState<Blob | null>(null);
const [duration, setDuration] = useState(0);
const [validation, setValidation] = useState<{
valid: boolean;
quality_score: number;
issues: string[];
} | null>(null);
const [transcript, setTranscript] = useState("");
const [uploading, setUploading] = useState(false);
const [error, setError] = useState<string | null>(null);
const [uploadResult, setUploadResult] = useState<{ file_id: string } | null>(null);
const mediaRecorderRef = useRef<MediaRecorder | null>(null);
const chunksRef = useRef<Blob[]>([]);
const timerRef = useRef<NodeJS.Timeout | null>(null);
const audioRef = useRef<HTMLAudioElement>(null);
// 녹음 시간 업데이트
useEffect(() => {
if (isRecording) {
timerRef.current = setInterval(() => {
setDuration((d) => d + 1);
}, 1000);
} else {
if (timerRef.current) {
clearInterval(timerRef.current);
}
}
return () => {
if (timerRef.current) {
clearInterval(timerRef.current);
}
};
}, [isRecording]);
async function startRecording() {
try {
const stream = await navigator.mediaDevices.getUserMedia({ audio: true });
const mediaRecorder = new MediaRecorder(stream, {
mimeType: "audio/webm;codecs=opus",
});
mediaRecorderRef.current = mediaRecorder;
chunksRef.current = [];
mediaRecorder.ondataavailable = (e) => {
if (e.data.size > 0) {
chunksRef.current.push(e.data);
}
};
mediaRecorder.onstop = async () => {
const blob = new Blob(chunksRef.current, { type: "audio/webm" });
const url = URL.createObjectURL(blob);
setAudioBlob(blob);
setAudioUrl(url);
// 스트림 정리
stream.getTracks().forEach((track) => track.stop());
// 품질 검증
await validateAudio(blob);
};
mediaRecorder.start();
setIsRecording(true);
setDuration(0);
setAudioUrl(null);
setValidation(null);
setUploadResult(null);
setError(null);
} catch (e) {
setError("마이크 접근 권한이 필요합니다");
}
}
function stopRecording() {
if (mediaRecorderRef.current && isRecording) {
mediaRecorderRef.current.stop();
setIsRecording(false);
}
}
async function validateAudio(blob: Blob) {
try {
const file = new File([blob], "recording.webm", { type: "audio/webm" });
const result = await validateRecording(file);
setValidation({
valid: result.valid,
quality_score: result.quality_score,
issues: result.issues,
});
} catch (e) {
console.error("검증 실패:", e);
}
}
async function handleUpload() {
if (!audioBlob) return;
setUploading(true);
setError(null);
try {
const file = new File([audioBlob], "recording.webm", { type: "audio/webm" });
const result = await uploadRecording({
audio: file,
transcript: transcript.trim() || undefined,
});
setUploadResult(result);
} catch (e) {
setError(e instanceof Error ? e.message : "업로드 실패");
} finally {
setUploading(false);
}
}
function handleReset() {
if (audioUrl) {
URL.revokeObjectURL(audioUrl);
}
setAudioUrl(null);
setAudioBlob(null);
setValidation(null);
setUploadResult(null);
setDuration(0);
setTranscript("");
setError(null);
}
return (
<div className="space-y-6">
{/* 헤더 */}
<div>
<h1 className="text-2xl font-bold"></h1>
<p className="text-muted-foreground">
Voice Clone에 (3 )
</p>
</div>
{/* 녹음 카드 */}
<div className="rounded-lg border bg-card p-6 max-w-xl mx-auto">
{/* 녹음 버튼 */}
<div className="flex flex-col items-center">
<button
onClick={isRecording ? stopRecording : startRecording}
className={`relative w-24 h-24 rounded-full flex items-center justify-center transition-colors ${
isRecording
? "bg-destructive text-destructive-foreground recording-pulse"
: "bg-primary text-primary-foreground hover:bg-primary/90"
}`}
>
{isRecording ? (
<svg
xmlns="http://www.w3.org/2000/svg"
className="h-10 w-10"
viewBox="0 0 24 24"
fill="currentColor"
>
<rect x="6" y="6" width="12" height="12" rx="2" />
</svg>
) : (
<svg
xmlns="http://www.w3.org/2000/svg"
className="h-10 w-10"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
>
<path d="M12 1a3 3 0 0 0-3 3v8a3 3 0 0 0 6 0V4a3 3 0 0 0-3-3z" />
<path d="M19 10v2a7 7 0 0 1-14 0v-2" />
<line x1="12" y1="19" x2="12" y2="23" />
<line x1="8" y1="23" x2="16" y2="23" />
</svg>
)}
</button>
<p className="mt-4 text-lg font-mono">
{formatDuration(duration)}
</p>
<p className="text-sm text-muted-foreground">
{isRecording ? "녹음 중... 클릭하여 중지" : "클릭하여 녹음 시작"}
</p>
</div>
{/* 스크립트 가이드 */}
{!audioUrl && (
<div className="mt-6 rounded-lg bg-secondary p-4">
<h3 className="font-medium mb-2"> </h3>
<p className="text-sm text-muted-foreground">
:
</p>
<p className="mt-2 text-sm font-medium">
&quot;,
. .&quot;
</p>
</div>
)}
{/* 에러 */}
{error && (
<div className="mt-4 rounded-lg border border-destructive/50 bg-destructive/10 p-3 text-sm text-destructive">
{error}
</div>
)}
{/* 녹음 결과 */}
{audioUrl && (
<div className="mt-6 space-y-4">
<audio
ref={audioRef}
src={audioUrl}
controls
className="w-full"
/>
{/* 검증 결과 */}
{validation && (
<div
className={`rounded-lg p-4 ${
validation.valid
? "bg-green-50 border border-green-200"
: "bg-yellow-50 border border-yellow-200"
}`}
>
<div className="flex items-center justify-between mb-2">
<span className="font-medium">
{validation.valid ? "녹음 품질 양호" : "품질 개선 필요"}
</span>
<span className="text-sm">
: {Math.round(validation.quality_score * 100)}%
</span>
</div>
{validation.issues.length > 0 && (
<ul className="text-sm space-y-1">
{validation.issues.map((issue, i) => (
<li key={i} className="text-muted-foreground">
{issue}
</li>
))}
</ul>
)}
</div>
)}
{/* 트랜스크립트 입력 */}
<div>
<label className="text-sm font-medium">
()
</label>
<textarea
value={transcript}
onChange={(e) => setTranscript(e.target.value)}
placeholder="녹음에서 말한 내용을 입력하세요..."
className="mt-1 w-full h-20 rounded-lg border bg-background px-3 py-2 text-sm resize-none focus:outline-none focus:ring-2 focus:ring-ring"
/>
</div>
{/* 업로드 결과 */}
{uploadResult && (
<div className="rounded-lg bg-green-50 border border-green-200 p-4">
<p className="font-medium text-green-700"> !</p>
<p className="text-sm text-green-600">
File ID: {uploadResult.file_id}
</p>
<p className="text-sm text-muted-foreground mt-2">
.
</p>
</div>
)}
{/* 버튼 */}
<div className="flex gap-3">
<button
onClick={handleReset}
className="flex-1 inline-flex items-center justify-center rounded-lg border px-4 py-2 text-sm font-medium hover:bg-accent"
>
</button>
{!uploadResult && (
<button
onClick={handleUpload}
disabled={uploading}
className="flex-1 inline-flex items-center justify-center gap-2 rounded-lg bg-primary px-4 py-2 text-sm font-medium text-primary-foreground hover:bg-primary/90 disabled:opacity-50"
>
{uploading ? (
<>
<div className="h-4 w-4 animate-spin rounded-full border-2 border-current border-t-transparent" />
...
</>
) : (
"업로드"
)}
</button>
)}
{uploadResult && (
<a
href="/voices"
className="flex-1 inline-flex items-center justify-center rounded-lg bg-primary px-4 py-2 text-sm font-medium text-primary-foreground hover:bg-primary/90"
>
</a>
)}
</div>
</div>
)}
</div>
</div>
);
}

View File

@ -0,0 +1,255 @@
"use client";
import { useState } from "react";
import { searchSoundEffects, getSoundEffectAudioUrl, SoundEffect } from "@/lib/api";
import { formatDuration } from "@/lib/utils";
export default function SoundEffectsPage() {
const [query, setQuery] = useState("");
const [results, setResults] = useState<SoundEffect[]>([]);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const [playingId, setPlayingId] = useState<string | null>(null);
const [totalCount, setTotalCount] = useState(0);
async function handleSearch(e?: React.FormEvent) {
e?.preventDefault();
if (!query.trim()) return;
setLoading(true);
setError(null);
try {
const response = await searchSoundEffects({
query: query.trim(),
page_size: 20,
});
setResults(response.results);
setTotalCount(response.count);
} catch (e) {
setError(e instanceof Error ? e.message : "검색 실패");
} finally {
setLoading(false);
}
}
function playSound(soundId: string, previewUrl?: string) {
const url = previewUrl || getSoundEffectAudioUrl(soundId);
const audio = new Audio(url);
audio.onplay = () => setPlayingId(soundId);
audio.onended = () => setPlayingId(null);
audio.onerror = () => {
setPlayingId(null);
alert("재생 실패");
};
audio.play();
}
return (
<div className="space-y-6">
{/* 헤더 */}
<div>
<h1 className="text-2xl font-bold"> </h1>
<p className="text-muted-foreground">
Freesound에서
</p>
</div>
{/* 검색 */}
<form onSubmit={handleSearch} className="flex gap-3">
<input
type="text"
value={query}
onChange={(e) => setQuery(e.target.value)}
placeholder="효과음 검색 (예: door, explosion, rain...)"
className="flex-1 rounded-lg border bg-background px-4 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-ring"
/>
<button
type="submit"
disabled={loading || !query.trim()}
className="inline-flex items-center gap-2 rounded-lg bg-primary px-4 py-2 text-sm font-medium text-primary-foreground hover:bg-primary/90 disabled:opacity-50"
>
{loading ? (
<div className="h-4 w-4 animate-spin rounded-full border-2 border-current border-t-transparent" />
) : (
<svg
xmlns="http://www.w3.org/2000/svg"
className="h-4 w-4"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
>
<circle cx="11" cy="11" r="8" />
<line x1="21" y1="21" x2="16.65" y2="16.65" />
</svg>
)}
</button>
</form>
{/* 에러 */}
{error && (
<div className="rounded-lg border border-destructive/50 bg-destructive/10 p-4 text-destructive">
{error}
</div>
)}
{/* 결과 수 */}
{totalCount > 0 && (
<p className="text-sm text-muted-foreground">
{totalCount.toLocaleString()}
</p>
)}
{/* 결과 그리드 */}
{results.length > 0 && (
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
{results.map((sound) => (
<div
key={sound.id}
className="rounded-lg border bg-card p-4 space-y-3"
>
<div>
<h3 className="font-medium truncate" title={sound.name}>
{sound.name}
</h3>
<p className="text-sm text-muted-foreground line-clamp-2">
{sound.description || "설명 없음"}
</p>
</div>
<div className="flex items-center gap-2 text-sm text-muted-foreground">
<span>{formatDuration(sound.duration)}</span>
<span>·</span>
<span className="truncate">{sound.license || "Unknown"}</span>
</div>
{/* 태그 */}
{sound.tags && sound.tags.length > 0 && (
<div className="flex flex-wrap gap-1">
{sound.tags.slice(0, 5).map((tag) => (
<span
key={tag}
className="rounded bg-secondary px-1.5 py-0.5 text-xs"
>
{tag}
</span>
))}
</div>
)}
{/* 버튼 */}
<div className="flex gap-2">
<button
onClick={() => playSound(sound.id, sound.preview_url)}
disabled={playingId === sound.id}
className="flex-1 inline-flex items-center justify-center gap-2 rounded-lg border px-3 py-2 text-sm font-medium hover:bg-accent disabled:opacity-50"
>
{playingId === sound.id ? (
<>
<div className="h-4 w-4 animate-spin rounded-full border-2 border-current border-t-transparent" />
</>
) : (
<>
<svg
xmlns="http://www.w3.org/2000/svg"
className="h-4 w-4"
viewBox="0 0 24 24"
fill="currentColor"
>
<polygon points="5 3 19 12 5 21 5 3" />
</svg>
</>
)}
</button>
{sound.preview_url && (
<a
href={sound.preview_url}
download
className="inline-flex items-center justify-center rounded-lg border px-3 py-2 text-sm font-medium hover:bg-accent"
title="다운로드"
>
<svg
xmlns="http://www.w3.org/2000/svg"
className="h-4 w-4"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
>
<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4" />
<polyline points="7 10 12 15 17 10" />
<line x1="12" y1="15" x2="12" y2="3" />
</svg>
</a>
)}
</div>
</div>
))}
</div>
)}
{/* 빈 상태 */}
{!loading && results.length === 0 && query && (
<div className="flex flex-col items-center justify-center py-12 text-center">
<svg
xmlns="http://www.w3.org/2000/svg"
className="h-12 w-12 text-muted-foreground"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
>
<circle cx="11" cy="11" r="8" />
<line x1="21" y1="21" x2="16.65" y2="16.65" />
</svg>
<h3 className="mt-4 font-semibold"> </h3>
<p className="text-sm text-muted-foreground">
</p>
</div>
)}
{/* 초기 상태 */}
{!loading && results.length === 0 && !query && (
<div className="flex flex-col items-center justify-center py-12 text-center">
<svg
xmlns="http://www.w3.org/2000/svg"
className="h-12 w-12 text-muted-foreground"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
>
<path d="M9 18V5l12-2v13" />
<circle cx="6" cy="18" r="3" />
<circle cx="18" cy="16" r="3" />
</svg>
<h3 className="mt-4 font-semibold"> </h3>
<p className="text-sm text-muted-foreground">
Freesound에서
</p>
<div className="mt-4 flex flex-wrap justify-center gap-2">
{["door", "footsteps", "rain", "thunder", "explosion", "whoosh"].map(
(term) => (
<button
key={term}
onClick={() => {
setQuery(term);
setTimeout(() => handleSearch(), 0);
}}
className="rounded-full border px-3 py-1 text-sm hover:bg-accent"
>
{term}
</button>
)
)}
</div>
</div>
)}
</div>
);
}

View File

@ -0,0 +1,298 @@
"use client";
import { Suspense, useEffect, useState, useRef } from "react";
import { useSearchParams } from "next/navigation";
import { getVoices, synthesize, Voice } from "@/lib/api";
function TTSContent() {
const searchParams = useSearchParams();
const initialVoiceId = searchParams.get("voice");
const [voices, setVoices] = useState<Voice[]>([]);
const [selectedVoiceId, setSelectedVoiceId] = useState<string>(
initialVoiceId || ""
);
const [text, setText] = useState("");
const [instruct, setInstruct] = useState("");
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const [audioUrl, setAudioUrl] = useState<string | null>(null);
const audioRef = useRef<HTMLAudioElement>(null);
useEffect(() => {
loadVoices();
}, []);
useEffect(() => {
if (initialVoiceId && !selectedVoiceId) {
setSelectedVoiceId(initialVoiceId);
}
}, [initialVoiceId, selectedVoiceId]);
async function loadVoices() {
try {
const response = await getVoices({ page_size: 100 });
setVoices(response.voices);
// 기본 선택
if (!selectedVoiceId && response.voices.length > 0) {
setSelectedVoiceId(response.voices[0].voice_id);
}
} catch (e) {
console.error("보이스 로드 실패:", e);
}
}
async function handleSynthesize() {
if (!selectedVoiceId || !text.trim()) {
setError("보이스와 텍스트를 입력하세요");
return;
}
setLoading(true);
setError(null);
try {
const blob = await synthesize({
voice_id: selectedVoiceId,
text: text.trim(),
instruct: instruct.trim() || undefined,
});
// 기존 URL 해제
if (audioUrl) {
URL.revokeObjectURL(audioUrl);
}
const url = URL.createObjectURL(blob);
setAudioUrl(url);
// 자동 재생
setTimeout(() => {
audioRef.current?.play();
}, 100);
} catch (e) {
setError(e instanceof Error ? e.message : "TTS 합성 실패");
} finally {
setLoading(false);
}
}
function handleDownload() {
if (!audioUrl) return;
const a = document.createElement("a");
a.href = audioUrl;
a.download = `tts_${Date.now()}.wav`;
a.click();
}
const selectedVoice = voices.find((v) => v.voice_id === selectedVoiceId);
return (
<div className="space-y-6">
{/* 헤더 */}
<div>
<h1 className="text-2xl font-bold">TTS </h1>
<p className="text-muted-foreground">
</p>
</div>
<div className="grid gap-6 lg:grid-cols-3">
{/* 보이스 선택 */}
<div className="space-y-4">
<h2 className="font-semibold"> </h2>
<div className="space-y-2 max-h-96 overflow-y-auto">
{voices.map((voice) => (
<button
key={voice.voice_id}
onClick={() => setSelectedVoiceId(voice.voice_id)}
className={`w-full text-left rounded-lg border p-3 transition-colors ${
selectedVoiceId === voice.voice_id
? "border-primary bg-primary/5"
: "hover:bg-accent"
}`}
>
<div className="flex items-center justify-between">
<span className="font-medium">{voice.name}</span>
<span
className={`text-xs px-1.5 py-0.5 rounded ${
voice.type === "preset"
? "bg-blue-100 text-blue-700"
: voice.type === "cloned"
? "bg-green-100 text-green-700"
: "bg-purple-100 text-purple-700"
}`}
>
{voice.type === "preset"
? "프리셋"
: voice.type === "cloned"
? "클론"
: "디자인"}
</span>
</div>
{voice.description && (
<p className="text-sm text-muted-foreground truncate">
{voice.description}
</p>
)}
</button>
))}
</div>
{selectedVoice && (
<div className="rounded-lg border bg-card p-4">
<h3 className="font-medium mb-2"> </h3>
<p className="text-sm">{selectedVoice.name}</p>
{selectedVoice.style_tags && selectedVoice.style_tags.length > 0 && (
<div className="mt-2 flex flex-wrap gap-1">
{selectedVoice.style_tags.map((tag) => (
<span
key={tag}
className="rounded bg-secondary px-1.5 py-0.5 text-xs"
>
{tag}
</span>
))}
</div>
)}
</div>
)}
</div>
{/* 입력 영역 */}
<div className="lg:col-span-2 space-y-4">
<div>
<label className="text-sm font-medium"> </label>
<textarea
value={text}
onChange={(e) => setText(e.target.value)}
placeholder="변환할 텍스트를 입력하세요..."
className="mt-1 w-full h-40 rounded-lg border bg-background px-3 py-2 text-sm resize-none focus:outline-none focus:ring-2 focus:ring-ring"
maxLength={5000}
/>
<p className="text-xs text-muted-foreground text-right">
{text.length}/5000
</p>
</div>
<div>
<label className="text-sm font-medium">
/ ()
</label>
<input
type="text"
value={instruct}
onChange={(e) => setInstruct(e.target.value)}
placeholder="예: 밝고 활기차게, 슬프게, 화난 목소리로..."
className="mt-1 w-full rounded-lg border bg-background px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-ring"
/>
</div>
{/* 에러 */}
{error && (
<div className="rounded-lg border border-destructive/50 bg-destructive/10 p-3 text-sm text-destructive">
{error}
</div>
)}
{/* 버튼 */}
<div className="flex gap-3">
<button
onClick={handleSynthesize}
disabled={loading || !text.trim() || !selectedVoiceId}
className="flex-1 inline-flex items-center justify-center gap-2 rounded-lg bg-primary px-4 py-3 text-sm font-medium text-primary-foreground hover:bg-primary/90 disabled:opacity-50"
>
{loading ? (
<>
<div className="h-4 w-4 animate-spin rounded-full border-2 border-current border-t-transparent" />
...
</>
) : (
<>
<svg
xmlns="http://www.w3.org/2000/svg"
className="h-4 w-4"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
>
<polygon points="11 5 6 9 2 9 2 15 6 15 11 19 11 5" />
<path d="M19.07 4.93a10 10 0 0 1 0 14.14M15.54 8.46a5 5 0 0 1 0 7.07" />
</svg>
</>
)}
</button>
</div>
{/* 오디오 플레이어 */}
{audioUrl && (
<div className="rounded-lg border bg-card p-4 space-y-3">
<h3 className="font-medium"> </h3>
<audio
ref={audioRef}
src={audioUrl}
controls
className="w-full"
/>
<div className="flex gap-2">
<button
onClick={() => audioRef.current?.play()}
className="inline-flex items-center gap-2 rounded-lg border px-3 py-2 text-sm font-medium hover:bg-accent"
>
<svg
xmlns="http://www.w3.org/2000/svg"
className="h-4 w-4"
viewBox="0 0 24 24"
fill="currentColor"
>
<polygon points="5 3 19 12 5 21 5 3" />
</svg>
</button>
<button
onClick={handleDownload}
className="inline-flex items-center gap-2 rounded-lg border px-3 py-2 text-sm font-medium hover:bg-accent"
>
<svg
xmlns="http://www.w3.org/2000/svg"
className="h-4 w-4"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
>
<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4" />
<polyline points="7 10 12 15 17 10" />
<line x1="12" y1="15" x2="12" y2="3" />
</svg>
</button>
</div>
</div>
)}
</div>
</div>
</div>
);
}
function LoadingFallback() {
return (
<div className="flex items-center justify-center min-h-[400px]">
<div className="h-8 w-8 animate-spin rounded-full border-4 border-primary border-t-transparent" />
</div>
);
}
export default function TTSPage() {
return (
<Suspense fallback={<LoadingFallback />}>
<TTSContent />
</Suspense>
);
}

View File

@ -0,0 +1,235 @@
"use client";
import { useEffect, useState } from "react";
import { getVoices, getVoiceSampleUrl, Voice, VoiceListResponse } from "@/lib/api";
export default function VoicesPage() {
const [voices, setVoices] = useState<Voice[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [filter, setFilter] = useState<"all" | "preset" | "cloned" | "designed">("all");
const [playingId, setPlayingId] = useState<string | null>(null);
useEffect(() => {
loadVoices();
}, [filter]);
async function loadVoices() {
setLoading(true);
setError(null);
try {
const params: { type?: string } = {};
if (filter !== "all") {
params.type = filter;
}
const response = await getVoices(params);
setVoices(response.voices);
} catch (e) {
setError(e instanceof Error ? e.message : "보이스 로드 실패");
} finally {
setLoading(false);
}
}
function playVoiceSample(voiceId: string) {
const audio = new Audio(getVoiceSampleUrl(voiceId));
audio.onplay = () => setPlayingId(voiceId);
audio.onended = () => setPlayingId(null);
audio.onerror = () => {
setPlayingId(null);
alert("샘플 재생 실패");
};
audio.play();
}
return (
<div className="space-y-6">
{/* 헤더 */}
<div className="flex items-center justify-between">
<div>
<h1 className="text-2xl font-bold"> </h1>
<p className="text-muted-foreground">
AI
</p>
</div>
<div className="flex gap-2">
<a
href="/voices/clone"
className="inline-flex items-center gap-2 rounded-lg bg-primary px-4 py-2 text-sm font-medium text-primary-foreground hover:bg-primary/90"
>
<svg
xmlns="http://www.w3.org/2000/svg"
className="h-4 w-4"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
>
<path d="M12 1a3 3 0 0 0-3 3v8a3 3 0 0 0 6 0V4a3 3 0 0 0-3-3z" />
<path d="M19 10v2a7 7 0 0 1-14 0v-2" />
</svg>
</a>
<a
href="/voices/design"
className="inline-flex items-center gap-2 rounded-lg border px-4 py-2 text-sm font-medium hover:bg-accent"
>
<svg
xmlns="http://www.w3.org/2000/svg"
className="h-4 w-4"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
>
<path d="M12 20h9" />
<path d="M16.5 3.5a2.121 2.121 0 0 1 3 3L7 19l-4 1 1-4L16.5 3.5z" />
</svg>
</a>
</div>
</div>
{/* 필터 */}
<div className="flex gap-2">
{[
{ value: "all", label: "전체" },
{ value: "preset", label: "프리셋" },
{ value: "cloned", label: "클론" },
{ value: "designed", label: "디자인" },
].map((item) => (
<button
key={item.value}
onClick={() => setFilter(item.value as typeof filter)}
className={`rounded-full px-4 py-1.5 text-sm font-medium transition-colors ${
filter === item.value
? "bg-primary text-primary-foreground"
: "bg-secondary hover:bg-secondary/80"
}`}
>
{item.label}
</button>
))}
</div>
{/* 에러 */}
{error && (
<div className="rounded-lg border border-destructive/50 bg-destructive/10 p-4 text-destructive">
{error}
</div>
)}
{/* 로딩 */}
{loading && (
<div className="flex items-center justify-center py-12">
<div className="h-8 w-8 animate-spin rounded-full border-4 border-primary border-t-transparent" />
</div>
)}
{/* 보이스 그리드 */}
{!loading && voices.length > 0 && (
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
{voices.map((voice) => (
<div
key={voice.voice_id}
className="flex flex-col rounded-lg border bg-card p-4 hover:shadow-md transition-shadow"
>
<div className="flex items-start justify-between">
<div>
<h3 className="font-semibold">{voice.name}</h3>
<p className="text-sm text-muted-foreground">
{voice.description || "설명 없음"}
</p>
</div>
<span
className={`rounded-full px-2 py-0.5 text-xs font-medium ${
voice.type === "preset"
? "bg-blue-100 text-blue-700"
: voice.type === "cloned"
? "bg-green-100 text-green-700"
: "bg-purple-100 text-purple-700"
}`}
>
{voice.type === "preset"
? "프리셋"
: voice.type === "cloned"
? "클론"
: "디자인"}
</span>
</div>
{/* 스타일 태그 */}
{voice.style_tags && voice.style_tags.length > 0 && (
<div className="mt-2 flex flex-wrap gap-1">
{voice.style_tags.slice(0, 3).map((tag) => (
<span
key={tag}
className="rounded bg-secondary px-1.5 py-0.5 text-xs"
>
{tag}
</span>
))}
</div>
)}
{/* 액션 버튼 */}
<div className="mt-4 flex gap-2">
<button
onClick={() => playVoiceSample(voice.voice_id)}
disabled={playingId === voice.voice_id}
className="flex-1 inline-flex items-center justify-center gap-2 rounded-lg border px-3 py-2 text-sm font-medium hover:bg-accent disabled:opacity-50"
>
{playingId === voice.voice_id ? (
<>
<div className="h-4 w-4 animate-spin rounded-full border-2 border-current border-t-transparent" />
</>
) : (
<>
<svg
xmlns="http://www.w3.org/2000/svg"
className="h-4 w-4"
viewBox="0 0 24 24"
fill="currentColor"
>
<polygon points="5 3 19 12 5 21 5 3" />
</svg>
</>
)}
</button>
<a
href={`/tts?voice=${voice.voice_id}`}
className="inline-flex items-center justify-center rounded-lg bg-primary px-3 py-2 text-sm font-medium text-primary-foreground hover:bg-primary/90"
>
</a>
</div>
</div>
))}
</div>
)}
{/* 빈 상태 */}
{!loading && voices.length === 0 && (
<div className="flex flex-col items-center justify-center py-12 text-center">
<svg
xmlns="http://www.w3.org/2000/svg"
className="h-12 w-12 text-muted-foreground"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
>
<path d="M12 1a3 3 0 0 0-3 3v8a3 3 0 0 0 6 0V4a3 3 0 0 0-3-3z" />
<path d="M19 10v2a7 7 0 0 1-14 0v-2" />
</svg>
<h3 className="mt-4 font-semibold"> </h3>
<p className="text-sm text-muted-foreground">
</p>
</div>
)}
</div>
);
}

View File

@ -0,0 +1,371 @@
/**
* Audio Studio API 클라이언트
*/
const API_BASE_URL = process.env.NEXT_PUBLIC_API_URL || "http://localhost:8010";
// ========================================
// 타입 정의
// ========================================
export interface Voice {
voice_id: string;
name: string;
description?: string;
type: "preset" | "cloned" | "designed";
language: string;
preset_voice_id?: string;
design_prompt?: string;
reference_transcript?: string;
gender?: string;
style_tags?: string[];
is_public: boolean;
sample_audio_id?: string;
created_at: string;
updated_at: string;
}
export interface VoiceListResponse {
voices: Voice[];
total: number;
page: number;
page_size: number;
}
export interface TTSGenerationResponse {
generation_id: string;
voice_id: string;
text: string;
status: string;
audio_file_id?: string;
duration_seconds?: number;
created_at: string;
}
export interface RecordingValidateResponse {
valid: boolean;
duration: number;
sample_rate: number;
quality_score: number;
issues: string[];
}
export interface SoundEffect {
id: string;
freesound_id?: number;
name: string;
description: string;
duration: number;
tags: string[];
preview_url?: string;
license: string;
source: string;
}
export interface SoundEffectSearchResponse {
count: number;
page: number;
page_size: number;
results: SoundEffect[];
}
// ========================================
// API 함수
// ========================================
async function fetchAPI<T>(
endpoint: string,
options?: RequestInit
): Promise<T> {
const response = await fetch(`${API_BASE_URL}${endpoint}`, {
...options,
headers: {
"Content-Type": "application/json",
...options?.headers,
},
});
if (!response.ok) {
const error = await response.json().catch(() => ({ detail: "Unknown error" }));
throw new Error(error.detail || `HTTP ${response.status}`);
}
return response.json();
}
// ========================================
// Voice API
// ========================================
export async function getVoices(params?: {
type?: string;
language?: string;
page?: number;
page_size?: number;
}): Promise<VoiceListResponse> {
const searchParams = new URLSearchParams();
if (params?.type) searchParams.set("type", params.type);
if (params?.language) searchParams.set("language", params.language);
if (params?.page) searchParams.set("page", String(params.page));
if (params?.page_size) searchParams.set("page_size", String(params.page_size));
return fetchAPI(`/api/v1/voices?${searchParams}`);
}
export async function getVoice(voiceId: string): Promise<Voice> {
return fetchAPI(`/api/v1/voices/${voiceId}`);
}
export function getVoiceSampleUrl(voiceId: string): string {
return `${API_BASE_URL}/api/v1/voices/${voiceId}/sample`;
}
export async function createVoiceClone(data: {
name: string;
description?: string;
reference_transcript: string;
language?: string;
is_public?: boolean;
reference_audio: File;
}): Promise<Voice> {
const formData = new FormData();
formData.append("name", data.name);
if (data.description) formData.append("description", data.description);
formData.append("reference_transcript", data.reference_transcript);
formData.append("language", data.language || "ko");
formData.append("is_public", String(data.is_public || false));
formData.append("reference_audio", data.reference_audio);
const response = await fetch(`${API_BASE_URL}/api/v1/voices/clone`, {
method: "POST",
body: formData,
});
if (!response.ok) {
const error = await response.json().catch(() => ({ detail: "Unknown error" }));
throw new Error(error.detail || `HTTP ${response.status}`);
}
return response.json();
}
export async function createVoiceDesign(data: {
name: string;
description?: string;
design_prompt: string;
language?: string;
is_public?: boolean;
}): Promise<Voice> {
return fetchAPI("/api/v1/voices/design", {
method: "POST",
body: JSON.stringify(data),
});
}
export async function deleteVoice(voiceId: string): Promise<void> {
await fetchAPI(`/api/v1/voices/${voiceId}`, { method: "DELETE" });
}
// ========================================
// TTS API
// ========================================
export async function synthesize(data: {
voice_id: string;
text: string;
instruct?: string;
}): Promise<Blob> {
const response = await fetch(`${API_BASE_URL}/api/v1/tts/synthesize`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(data),
});
if (!response.ok) {
const error = await response.json().catch(() => ({ detail: "Unknown error" }));
throw new Error(error.detail || `HTTP ${response.status}`);
}
return response.blob();
}
export async function getGeneration(generationId: string): Promise<TTSGenerationResponse> {
return fetchAPI(`/api/v1/tts/generations/${generationId}`);
}
export function getGenerationAudioUrl(generationId: string): string {
return `${API_BASE_URL}/api/v1/tts/generations/${generationId}/audio`;
}
// ========================================
// Recording API
// ========================================
export async function validateRecording(audio: File): Promise<RecordingValidateResponse> {
const formData = new FormData();
formData.append("audio", audio);
const response = await fetch(`${API_BASE_URL}/api/v1/recordings/validate`, {
method: "POST",
body: formData,
});
if (!response.ok) {
const error = await response.json().catch(() => ({ detail: "Unknown error" }));
throw new Error(error.detail || `HTTP ${response.status}`);
}
return response.json();
}
export async function uploadRecording(data: {
audio: File;
transcript?: string;
}): Promise<{ file_id: string; filename: string; duration: number }> {
const formData = new FormData();
formData.append("audio", data.audio);
if (data.transcript) formData.append("transcript", data.transcript);
const response = await fetch(`${API_BASE_URL}/api/v1/recordings/upload`, {
method: "POST",
body: formData,
});
if (!response.ok) {
const error = await response.json().catch(() => ({ detail: "Unknown error" }));
throw new Error(error.detail || `HTTP ${response.status}`);
}
return response.json();
}
// ========================================
// Sound Effects API
// ========================================
export async function searchSoundEffects(params: {
query: string;
page?: number;
page_size?: number;
min_duration?: number;
max_duration?: number;
}): Promise<SoundEffectSearchResponse> {
const searchParams = new URLSearchParams();
searchParams.set("query", params.query);
if (params.page) searchParams.set("page", String(params.page));
if (params.page_size) searchParams.set("page_size", String(params.page_size));
if (params.min_duration) searchParams.set("min_duration", String(params.min_duration));
if (params.max_duration) searchParams.set("max_duration", String(params.max_duration));
return fetchAPI(`/api/v1/sound-effects/search?${searchParams}`);
}
export function getSoundEffectAudioUrl(soundId: string): string {
return `${API_BASE_URL}/api/v1/sound-effects/${soundId}/audio`;
}
// ========================================
// Drama API
// ========================================
export interface DramaCharacter {
name: string;
description?: string;
voice_id?: string;
}
export interface DramaProject {
project_id: string;
title: string;
status: "draft" | "processing" | "completed" | "error";
characters: DramaCharacter[];
element_count: number;
estimated_duration?: number;
output_file_id?: string;
error_message?: string;
}
export interface ParsedScript {
title?: string;
characters: DramaCharacter[];
elements: Array<{
type: "dialogue" | "direction" | "sfx" | "music" | "pause";
character?: string;
text?: string;
description?: string;
emotion?: string;
}>;
}
export async function parseScript(script: string): Promise<ParsedScript> {
const response = await fetch(`${API_BASE_URL}/api/v1/drama/parse`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(script),
});
if (!response.ok) {
const error = await response.json().catch(() => ({ detail: "Unknown error" }));
throw new Error(error.detail || `HTTP ${response.status}`);
}
return response.json();
}
export async function createDramaProject(data: {
title: string;
script: string;
voice_mapping?: Record<string, string>;
auto_generate_assets?: boolean;
}): Promise<DramaProject> {
return fetchAPI("/api/v1/drama/projects", {
method: "POST",
body: JSON.stringify(data),
});
}
export async function getDramaProjects(): Promise<DramaProject[]> {
return fetchAPI("/api/v1/drama/projects");
}
export async function getDramaProject(projectId: string): Promise<DramaProject> {
return fetchAPI(`/api/v1/drama/projects/${projectId}`);
}
export async function renderDrama(
projectId: string,
outputFormat: string = "wav"
): Promise<{ project_id: string; status: string; message: string }> {
return fetchAPI(`/api/v1/drama/projects/${projectId}/render?output_format=${outputFormat}`, {
method: "POST",
});
}
export async function updateVoiceMapping(
projectId: string,
voiceMapping: Record<string, string>
): Promise<void> {
await fetchAPI(`/api/v1/drama/projects/${projectId}/voices`, {
method: "PUT",
body: JSON.stringify(voiceMapping),
});
}
export function getDramaDownloadUrl(projectId: string): string {
return `${API_BASE_URL}/api/v1/drama/projects/${projectId}/download`;
}
export async function deleteDramaProject(projectId: string): Promise<void> {
await fetchAPI(`/api/v1/drama/projects/${projectId}`, { method: "DELETE" });
}
// ========================================
// Health Check
// ========================================
export async function healthCheck(): Promise<{
status: string;
services: { mongodb: string; redis: string };
}> {
return fetchAPI("/health");
}

View File

@ -0,0 +1,18 @@
import { type ClassValue, clsx } from "clsx";
import { twMerge } from "tailwind-merge";
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs));
}
export function formatDuration(seconds: number): string {
const mins = Math.floor(seconds / 60);
const secs = Math.floor(seconds % 60);
return `${mins}:${secs.toString().padStart(2, "0")}`;
}
export function formatFileSize(bytes: number): string {
if (bytes < 1024) return `${bytes} B`;
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
}

View File

@ -0,0 +1,27 @@
{
"compilerOptions": {
"target": "ES2017",
"lib": ["dom", "dom.iterable", "esnext"],
"allowJs": true,
"skipLibCheck": true,
"strict": true,
"noEmit": true,
"esModuleInterop": true,
"module": "esnext",
"moduleResolution": "bundler",
"resolveJsonModule": true,
"isolatedModules": true,
"jsx": "preserve",
"incremental": true,
"plugins": [
{
"name": "next"
}
],
"paths": {
"@/*": ["./src/*"]
}
},
"include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"],
"exclude": ["node_modules"]
}

89
docker-compose.dev.yml Normal file
View File

@ -0,0 +1,89 @@
# Drama Studio - Development Mode
# UI 핫리로드 지원
services:
mongodb:
image: mongo:7.0
container_name: drama-studio-mongodb
restart: unless-stopped
environment:
MONGO_INITDB_ROOT_USERNAME: ${MONGO_USER:-admin}
MONGO_INITDB_ROOT_PASSWORD: ${MONGO_PASSWORD:-password123}
ports:
- "27021:27017"
volumes:
- drama_studio_mongodb_data:/data/db
networks:
- drama-studio-network
healthcheck:
test: ["CMD", "mongosh", "--eval", "db.adminCommand('ping')"]
interval: 30s
timeout: 10s
retries: 3
redis:
image: redis:7-alpine
container_name: drama-studio-redis
restart: unless-stopped
ports:
- "6383:6379"
volumes:
- drama_studio_redis_data:/data
networks:
- drama-studio-network
healthcheck:
test: ["CMD", "redis-cli", "ping"]
interval: 30s
timeout: 10s
retries: 3
api:
build:
context: ./audio-studio-api
dockerfile: Dockerfile
container_name: drama-studio-api
restart: unless-stopped
environment:
- MONGODB_URL=mongodb://${MONGO_USER}:${MONGO_PASSWORD}@mongodb:27017/
- DB_NAME=${DB_NAME:-drama_studio}
- REDIS_URL=redis://redis:6379
- TTS_ENGINE_URL=http://tts-engine:8001
- MUSICGEN_URL=http://musicgen:8002
- FREESOUND_API_KEY=${FREESOUND_API_KEY}
ports:
- "8010:8000"
depends_on:
mongodb:
condition: service_healthy
redis:
condition: service_healthy
networks:
- drama-studio-network
# UI 개발 모드 - 핫리로드
ui:
image: node:20-alpine
container_name: drama-studio-ui-dev
working_dir: /app
command: sh -c "npm install && npm run dev"
environment:
- NEXT_PUBLIC_API_URL=http://localhost:8010
- WATCHPACK_POLLING=true
ports:
- "3010:3000"
volumes:
- ./audio-studio-ui:/app
- /app/node_modules
- /app/.next
depends_on:
- api
networks:
- drama-studio-network
networks:
drama-studio-network:
driver: bridge
volumes:
drama_studio_mongodb_data:
drama_studio_redis_data:

165
docker-compose.yml Normal file
View File

@ -0,0 +1,165 @@
# Drama Studio - Docker Compose
# AI 라디오 드라마 제작 시스템
services:
# ===================
# Infrastructure
# ===================
mongodb:
image: mongo:7.0
container_name: drama-studio-mongodb
restart: unless-stopped
environment:
MONGO_INITDB_ROOT_USERNAME: ${MONGO_USER:-admin}
MONGO_INITDB_ROOT_PASSWORD: ${MONGO_PASSWORD:-password123}
ports:
- "27021:27017"
volumes:
- drama_studio_mongodb_data:/data/db
networks:
- drama-studio-network
healthcheck:
test: ["CMD", "mongosh", "--eval", "db.adminCommand('ping')"]
interval: 30s
timeout: 10s
retries: 3
redis:
image: redis:7-alpine
container_name: drama-studio-redis
restart: unless-stopped
ports:
- "6383:6379"
volumes:
- drama_studio_redis_data:/data
networks:
- drama-studio-network
healthcheck:
test: ["CMD", "redis-cli", "ping"]
interval: 30s
timeout: 10s
retries: 3
# ===================
# API Server
# ===================
api:
build:
context: ./audio-studio-api
dockerfile: Dockerfile
container_name: drama-studio-api
restart: unless-stopped
environment:
- MONGODB_URL=mongodb://${MONGO_USER}:${MONGO_PASSWORD}@mongodb:27017/
- DB_NAME=${DB_NAME:-drama_studio}
- REDIS_URL=redis://redis:6379
- TTS_ENGINE_URL=http://tts-engine:8001
- MUSICGEN_URL=http://musicgen:8002
- FREESOUND_API_KEY=${FREESOUND_API_KEY}
ports:
- "8010:8000"
depends_on:
mongodb:
condition: service_healthy
redis:
condition: service_healthy
networks:
- drama-studio-network
healthcheck:
test: ["CMD", "curl", "-f", "http://localhost:8000/health"]
interval: 30s
timeout: 10s
retries: 3
# ===================
# GPU Services
# ===================
tts-engine:
build:
context: ./audio-studio-tts
dockerfile: Dockerfile.gpu
container_name: drama-studio-tts
restart: unless-stopped
deploy:
resources:
reservations:
devices:
- driver: nvidia
device_ids: ['0']
capabilities: [gpu]
environment:
- HF_TOKEN=${HF_TOKEN}
- CUDA_VISIBLE_DEVICES=0
- MODEL_ID=Qwen/Qwen3-TTS-12Hz-1.7B-Base
- CLONE_MODEL_ID=Qwen/Qwen3-TTS-12Hz-1.7B-CustomVoice
- DESIGN_MODEL_ID=Qwen/Qwen3-TTS-12Hz-1.7B-VoiceDesign
volumes:
- ~/.cache/huggingface:/root/.cache/huggingface
- drama_studio_models:/app/models
ports:
- "8001:8001"
networks:
- drama-studio-network
healthcheck:
test: ["CMD", "curl", "-f", "http://localhost:8001/health"]
interval: 30s
timeout: 30s
retries: 3
start_period: 180s # 모델 로딩 대기
musicgen:
build:
context: ./audio-studio-musicgen
dockerfile: Dockerfile.gpu
container_name: drama-studio-musicgen
restart: unless-stopped
deploy:
resources:
reservations:
devices:
- driver: nvidia
device_ids: ['1']
capabilities: [gpu]
environment:
- HF_TOKEN=${HF_TOKEN}
- CUDA_VISIBLE_DEVICES=0
- MODEL_NAME=facebook/musicgen-medium
volumes:
- ~/.cache/huggingface:/root/.cache/huggingface
ports:
- "8002:8002"
networks:
- drama-studio-network
healthcheck:
test: ["CMD", "curl", "-f", "http://localhost:8002/health"]
interval: 30s
timeout: 30s
retries: 3
start_period: 120s
# ===================
# Frontend
# ===================
ui:
build:
context: ./audio-studio-ui
dockerfile: Dockerfile
container_name: drama-studio-ui
restart: unless-stopped
environment:
- NEXT_PUBLIC_API_URL=http://localhost:8010
ports:
- "3010:3000"
depends_on:
- api
networks:
- drama-studio-network
networks:
drama-studio-network:
driver: bridge
volumes:
drama_studio_mongodb_data:
drama_studio_redis_data:
drama_studio_models: