- 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>
279 lines
8.6 KiB
Python
279 lines
8.6 KiB
Python
"""배경음악 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",
|
|
],
|
|
},
|
|
]
|
|
}
|