- 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>
341 lines
11 KiB
Python
341 lines
11 KiB
Python
"""효과음 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}
|