music-lab: 서비스 고도화 — duration 수정 + 모델/크레딧/연장/분리 API 추가

Phase 1A:
- mutagen으로 MP3 실제 재생시간 추출 (sync + startup backfill)
- update_track_duration() DB 헬퍼 추가

Phase 2:
- GET /api/music/models — Suno 모델 목록 (V4~V5)
- GET /api/music/credits — 잔여 크레딧 조회
- POST /api/music/extend — 곡 연장 (continueAt 지점부터)
- POST /api/music/vocal-removal — 보컬/인스트루멘탈 분리
- GenerateRequest에 model 필드 추가 (하드코딩 V4 제거)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-04-04 14:36:52 +09:00
parent 4b339d9d4f
commit 649b99d143
4 changed files with 281 additions and 4 deletions

View File

@@ -22,6 +22,15 @@ MUSIC_MEDIA_BASE = os.getenv("MUSIC_MEDIA_BASE", "/media/music")
POLL_INTERVAL = 8 # 초 (Suno 생성은 30초~3분 소요)
POLL_MAX_ATTEMPTS = 40 # 최대 ~5분 20초 (8초 × 40)
# 사용 가능한 모델 목록
SUNO_MODELS = [
{"id": "V4", "name": "V4", "max_duration": "4분", "description": "안정적 품질, 빠른 생성"},
{"id": "V4_5", "name": "V4.5", "max_duration": "8분", "description": "향상된 장르 블렌딩"},
{"id": "V4_5PLUS", "name": "V4.5+", "max_duration": "8분", "description": "강화된 음악성"},
{"id": "V4_5ALL", "name": "V4.5 All", "max_duration": "8분", "description": "더 나은 곡 구조"},
{"id": "V5", "name": "V5", "max_duration": "8분", "description": "최신, 빠른 생성 + 뛰어난 음악성"},
]
def _headers() -> dict:
return {
@@ -180,7 +189,7 @@ def _build_suno_payload(params: dict) -> dict:
payload = {
"customMode": custom_mode,
"instrumental": instrumental,
"model": "V4", # 안정적인 기본 모델
"model": params.get("model", "V4"),
"callBackUrl": "https://example.com/noop", # 필수 파라미터, 폴링 방식이므로 더미 URL
}
@@ -337,3 +346,169 @@ def _download_and_register(
}
return add_track(track_data)
# ── 크레딧 조회 ──────────────────────────────────────────────────────────────
def get_credits() -> Optional[dict]:
"""Suno API 잔여 크레딧 조회."""
if not SUNO_API_KEY:
return None
try:
resp = requests.get(
f"{SUNO_BASE_URL}/get-credits",
headers=_headers(),
timeout=15,
)
if resp.status_code == 200:
body = resp.json()
return body.get("data", body)
except Exception as e:
logger.warning("Suno credits API error: %s", e)
return None
# ── 곡 연장 (Extend) ────────────────────────────────────────────────────────
def run_suno_extend(task_id: str, params: dict) -> None:
"""기존 곡을 특정 지점부터 연장."""
try:
if not SUNO_API_KEY:
update_task(task_id, "failed", 0, "",
error="SUNO_API_KEY가 설정되지 않았습니다.")
return
update_task(task_id, "processing", 5, "곡 연장 요청 중...")
payload = {
"audioId": params["suno_id"],
"defaultParamFlag": not bool(params.get("prompt")),
"prompt": params.get("prompt", ""),
"continueAt": params.get("continue_at", 0),
"model": params.get("model", "V4"),
"callBackUrl": "https://example.com/noop",
}
if params.get("style"):
payload["style"] = params["style"]
if params.get("title"):
payload["title"] = params["title"]
resp = requests.post(
f"{SUNO_BASE_URL}/generate/extend",
headers=_headers(),
json=payload,
timeout=30,
)
if resp.status_code != 200:
update_task(task_id, "failed", 0, "",
error=f"Suno Extend API 오류: {resp.text[:300]}")
return
body = resp.json()
if body.get("code") != 200:
update_task(task_id, "failed", 0, "",
error=f"Suno Extend 거부: {body.get('msg', 'unknown')}")
return
suno_task_id = body.get("data", {}).get("taskId", "")
if not suno_task_id:
update_task(task_id, "failed", 0, "", error="Suno 응답에 taskId 없음")
return
update_task(task_id, "processing", 15, "곡 연장 대기열에 등록됨...")
completed_tracks = _poll_until_complete(task_id, suno_task_id)
if not completed_tracks:
return
update_task(task_id, "processing", 80, "연장된 오디오 다운로드 중...")
track = _download_and_register(
task_id=task_id, song=completed_tracks[0],
params=params, filename_suffix="",
)
if not track:
return
update_task(task_id, "succeeded", 100, "곡 연장 완료",
audio_url=track["audio_url"])
except Exception as e:
logger.exception("Suno extend error for task %s", task_id)
update_task(task_id, "failed", 0, "", error=str(e))
# ── 보컬 분리 ────────────────────────────────────────────────────────────────
def run_vocal_removal(task_id: str, params: dict) -> None:
"""트랙에서 보컬과 인스트루멘탈을 분리."""
try:
if not SUNO_API_KEY:
update_task(task_id, "failed", 0, "",
error="SUNO_API_KEY가 설정되지 않았습니다.")
return
update_task(task_id, "processing", 5, "보컬 분리 요청 중...")
# suno_id로 원본 Suno 곡 참조
payload = {
"audioId": params["suno_id"],
"callBackUrl": "https://example.com/noop",
}
resp = requests.post(
f"{SUNO_BASE_URL}/vocal-removal/generate",
headers=_headers(),
json=payload,
timeout=30,
)
if resp.status_code != 200:
update_task(task_id, "failed", 0, "",
error=f"Suno Vocal Removal 오류: {resp.text[:300]}")
return
body = resp.json()
if body.get("code") != 200:
update_task(task_id, "failed", 0, "",
error=f"Suno Vocal Removal 거부: {body.get('msg', 'unknown')}")
return
suno_task_id = body.get("data", {}).get("taskId", "")
if not suno_task_id:
update_task(task_id, "failed", 0, "", error="Suno 응답에 taskId 없음")
return
update_task(task_id, "processing", 15, "보컬 분리 처리 중...")
# 보컬 분리 결과 폴링
completed_tracks = _poll_until_complete(task_id, suno_task_id)
if not completed_tracks:
return
update_task(task_id, "processing", 80, "분리된 오디오 다운로드 중...")
# 첫 번째: 보컬 트랙, 두 번째: 인스트루멘탈 트랙
vocal_params = {**params, "title": f"{params.get('title', 'Track')} (Vocals)"}
track = _download_and_register(
task_id=task_id, song=completed_tracks[0],
params=vocal_params, filename_suffix="",
)
if len(completed_tracks) > 1:
inst_params = {**params, "title": f"{params.get('title', 'Track')} (Instrumental)"}
_download_and_register(
task_id=f"{task_id}_inst", song=completed_tracks[1],
params=inst_params, filename_suffix="",
)
if track:
update_task(task_id, "succeeded", 100, "보컬 분리 완료",
audio_url=track["audio_url"])
else:
update_task(task_id, "failed", 0, "", error="분리 결과 저장 실패")
except Exception as e:
logger.exception("Suno vocal removal error for task %s", task_id)
update_task(task_id, "failed", 0, "", error=str(e))