""" Suno API Provider — sunoapi.org 래퍼를 통한 음악 생성 https://docs.sunoapi.org/suno-api/quickstart """ import json import os import time import logging import requests from typing import Optional from .db import update_task, add_track logger = logging.getLogger(__name__) SUNO_BASE_URL = "https://api.sunoapi.org/api/v1" SUNO_API_KEY = os.getenv("SUNO_API_KEY", "") MUSIC_DATA_DIR = "/app/data" 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": "최신, 빠른 생성 + 뛰어난 음악성"}, {"id": "V5_5", "name": "V5.5", "max_duration": "8분", "description": "커스텀 모델, 최신 음악성"}, ] def _headers() -> dict: return { "Authorization": f"Bearer {SUNO_API_KEY}", "Content-Type": "application/json", } def generate_lyrics(prompt: str) -> Optional[dict]: """Suno 가사 생성 API 호출. 곡 생성 전 가사 미리보기용.""" if not SUNO_API_KEY: return None try: resp = requests.post( f"{SUNO_BASE_URL}/lyrics", headers=_headers(), json={"prompt": prompt[:200]}, # max 200 chars timeout=30, ) if resp.status_code == 200: body = resp.json() # sunoapi.org 래퍼 응답: {code, msg, data: {taskId}} if body.get("code") == 200: task_id = body.get("data", {}).get("taskId", "") if task_id: # 가사 생성도 비동기 — 폴링으로 결과 대기 return _poll_lyrics(task_id) return body except Exception as e: logger.warning("Suno lyrics API error: %s", e) return None def _poll_lyrics(lyrics_task_id: str) -> Optional[dict]: """가사 생성 결과를 폴링하여 반환.""" for _ in range(15): # 최대 ~45초 time.sleep(3) try: resp = requests.get( f"{SUNO_BASE_URL}/lyrics/record-info", headers=_headers(), params={"taskId": lyrics_task_id}, timeout=15, ) if resp.status_code != 200: continue body = resp.json() data = body.get("data", {}) status = data.get("status", "") if status == "complete": items = data.get("data") or data.get("sunoData") or [] if items and isinstance(items, list): return { "id": lyrics_task_id, "status": "complete", "text": items[0].get("text", ""), "title": items[0].get("title", ""), } return {"id": lyrics_task_id, "status": "complete", "text": ""} except Exception: continue return None def run_suno_generation(task_id: str, params: dict) -> None: """ BackgroundTask: Suno API로 곡 생성 → MP3 다운로드 → 라이브러리 등록. Suno는 1회 생성 시 2개 변형을 반환하므로, 첫 번째를 메인으로 저장하고 두 번째는 별도 트랙으로 추가 등록한다. """ try: # ── 사전 검증 ── if not SUNO_API_KEY: update_task(task_id, "failed", 0, "", error="SUNO_API_KEY가 설정되지 않았습니다. .env 파일을 확인하세요.") return update_task(task_id, "processing", 5, "Suno API에 연결 중...") # ── 1단계: 곡 생성 요청 ── payload = _build_suno_payload(params) resp = requests.post( f"{SUNO_BASE_URL}/generate", headers=_headers(), json=payload, timeout=30, ) if resp.status_code != 200: error_detail = resp.text[:300] if resp.text else f"HTTP {resp.status_code}" update_task(task_id, "failed", 0, "", error=f"Suno API 오류: {error_detail}") return body = resp.json() if body.get("code") != 200: update_task(task_id, "failed", 0, "", error=f"Suno API 거부: {body.get('msg', 'unknown error')}") 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, "곡 생성 대기열에 등록됨...") logger.info("Suno task created: %s (internal: %s)", suno_task_id, task_id) # ── 2단계: 상태 폴링 ── response = _poll_suno_record("/generate/record-info", suno_task_id, task_id) if not response: return completed_tracks = response.get("sunoData") or [] if not completed_tracks: update_task(task_id, "failed", 0, "", error="Suno 생성 완료했으나 트랙 데이터 없음") return update_task(task_id, "processing", 80, "오디오 파일 다운로드 중...") # ── 3단계: 메인 트랙 다운로드 + 등록 ── track = _download_and_register( task_id=task_id, song=completed_tracks[0], params=params, filename_suffix="", ) if not track: return audio_url = track["audio_url"] update_task(task_id, "succeeded", 100, "생성 완료", audio_url=audio_url) # ── 4단계: 두 번째 변형이 있으면 추가 등록 ── if len(completed_tracks) > 1: try: _download_and_register( task_id=f"{task_id}_v2", song=completed_tracks[1], params=params, filename_suffix="", ) except Exception: pass # 보조 변형 실패는 무시 except requests.Timeout: update_task(task_id, "failed", 0, "", error="Suno API 타임아웃") except Exception as e: logger.exception("Suno generation error for task %s", task_id) update_task(task_id, "failed", 0, "", error=str(e)) def _build_suno_payload(params: dict) -> dict: """프론트엔드 params → sunoapi.org 요청 형식으로 변환.""" instrumental = params.get("instrumental", False) has_lyrics = bool(params.get("lyrics")) # customMode: 가사 또는 스타일 지정 시 활성화 custom_mode = has_lyrics or bool(params.get("genre")) or bool(params.get("moods")) payload = { "customMode": custom_mode, "instrumental": instrumental, "model": params.get("model", "V4"), "callBackUrl": "https://example.com/noop", # 필수 파라미터, 폴링 방식이므로 더미 URL } if custom_mode: # ── Custom Mode ── # prompt: 가사 (instrumental이면 빈 문자열) if instrumental: payload["prompt"] = "" elif has_lyrics: payload["prompt"] = params["lyrics"][:3000] else: # 가사 없이 커스텀 모드 — 프롬프트를 가사 위치에 prompt_text = params.get("prompt", "") payload["prompt"] = prompt_text[:3000] if prompt_text else "" # style: 장르 + 분위기 + 악기 조합 style_parts = [] if params.get("genre"): style_parts.append(params["genre"]) if params.get("moods"): style_parts.extend(params["moods"]) if params.get("instruments"): style_parts.extend(params["instruments"][:3]) if style_parts: payload["style"] = ", ".join(style_parts)[:200] # title if params.get("title"): payload["title"] = params["title"][:80] else: # ── Simple Mode ── # prompt: 자연어 설명 parts = [] if params.get("prompt"): parts.append(params["prompt"]) if params.get("genre"): parts.append(params["genre"]) if params.get("moods"): parts.append(", ".join(params["moods"])) payload["prompt"] = " ".join(parts)[:500] if parts else "instrumental music" if params.get("vocal_gender"): payload["vocalGender"] = params["vocal_gender"] if params.get("negative_tags"): payload["negativeTags"] = params["negative_tags"] if params.get("style_weight") is not None: payload["styleWeight"] = params["style_weight"] if params.get("audio_weight") is not None: payload["audioWeight"] = params["audio_weight"] return payload def _poll_suno_record( record_info_path: str, suno_task_id: str, task_id: str, max_attempts: int = POLL_MAX_ATTEMPTS, interval: int = POLL_INTERVAL, progress_msg_map: dict = None, ) -> Optional[dict]: """범용 Suno 작업 폴링. SUCCESS 시 response 객체 반환.""" error_statuses = { "CREATE_TASK_FAILED", "GENERATE_AUDIO_FAILED", "CALLBACK_EXCEPTION", "SENSITIVE_WORD_ERROR", } default_msgs = { "PENDING": "대기열에서 대기 중...", "TEXT_SUCCESS": "가사 생성 완료, 음악 생성 중...", "FIRST_SUCCESS": "첫 번째 트랙 완료, 두 번째 생성 중...", "GENERATING": "생성 중...", } msgs = {**default_msgs, **(progress_msg_map or {})} for attempt in range(max_attempts): time.sleep(interval) try: resp = requests.get( f"{SUNO_BASE_URL}{record_info_path}", headers=_headers(), params={"taskId": suno_task_id}, timeout=15, ) if resp.status_code != 200: continue body = resp.json() if body.get("code") != 200: continue data = body.get("data", {}) status = data.get("status", "") progress = min(15 + int((attempt / max_attempts) * 65), 79) if status == "SUCCESS": return data.get("response", data) elif status in error_statuses: error_msg = data.get("errorMessage") or data.get("msg") or f"Suno 작업 실패 ({status})" update_task(task_id, "failed", 0, "", error=error_msg) return None else: msg = msgs.get(status, f"처리 중... ({status})") if status == "FIRST_SUCCESS": progress = max(progress, 60) update_task(task_id, "processing", progress, msg) except Exception as e: logger.warning("Suno poll error (attempt %d): %s", attempt, e) continue update_task(task_id, "failed", 0, "", error="Suno 작업 타임아웃") return None def _download_and_register( task_id: str, song: dict, params: dict, filename_suffix: str = "", ) -> Optional[dict]: """Suno CDN에서 MP3 다운로드 → 로컬 저장 → 라이브러리 등록.""" # sunoapi.org 응답 필드: audioUrl (camelCase) audio_url_remote = song.get("audioUrl") or song.get("audio_url", "") if not audio_url_remote: update_task(task_id, "failed", 0, "", error="Suno 응답에 audioUrl이 없습니다") return None filename = f"{task_id}{filename_suffix}.mp3" file_path = os.path.join(MUSIC_DATA_DIR, filename) try: dl = requests.get(audio_url_remote, timeout=120, stream=True) dl.raise_for_status() with open(file_path, "wb") as f: for chunk in dl.iter_content(chunk_size=8192): f.write(chunk) except Exception as e: update_task(task_id, "failed", 0, "", error=f"오디오 다운로드 실패: {e}") return None local_audio_url = f"{MUSIC_MEDIA_BASE}/{filename}" # 메타데이터 조합 (sunoapi.org 필드는 camelCase) genre = params.get("genre", song.get("tags", "")) moods = params.get("moods", []) mood_str = moods[0] if moods else "Original" title = ( song.get("title") or params.get("title") or (f"{genre} — {mood_str} Mix" if genre else f"{mood_str} Mix") ) track_data = { "title": title, "genre": genre, "moods": moods, "instruments": params.get("instruments", []), "duration_sec": int(song["duration"]) if song.get("duration") else params.get("duration_sec"), "bpm": params.get("bpm"), "key": params.get("key", ""), "scale": params.get("scale", ""), "prompt": song.get("prompt", params.get("prompt", "")), "audio_url": local_audio_url, "file_path": file_path, "task_id": task_id, "provider": "suno", "lyrics": song.get("prompt", params.get("lyrics", "")), "image_url": song.get("imageUrl") or song.get("image_url", ""), "suno_id": song.get("id", ""), } return add_track(track_data) # ── 크레딧 조회 ────────────────────────────────────────────────────────────── def get_credits() -> Optional[dict]: """Suno API 잔여 크레딧 조회. 두 엔드포인트 폴백.""" if not SUNO_API_KEY: return None for path in ["/generate/credit", "/get-credits"]: try: resp = requests.get(f"{SUNO_BASE_URL}{path}", headers=_headers(), timeout=15) if resp.status_code == 200: body = resp.json() data = body.get("data", body) if isinstance(data, (int, float)): return {"credits_left": int(data)} return data except Exception as e: logger.warning("Suno credits API error (%s): %s", path, 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, "곡 연장 대기열에 등록됨...") response = _poll_suno_record("/generate/record-info", suno_task_id, task_id) if not response: return completed_tracks = response.get("sunoData") or [] if not completed_tracks: update_task(task_id, "failed", 0, "", error="Suno 연장 완료했으나 트랙 데이터 없음") 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, "보컬 분리 처리 중...") # 보컬 분리 결과 폴링 response = _poll_suno_record("/vocal-removal/record-info", suno_task_id, task_id) if not response: return completed_tracks = response.get("sunoData") or [] if not completed_tracks: update_task(task_id, "failed", 0, "", error="보컬 분리 완료했으나 트랙 데이터 없음") 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)) # ── 커버 이미지 생성 ──────────────────────────────────────────────────────── def run_cover_image(task_id: str, params: dict) -> None: """Suno 곡의 커버 이미지 2장을 생성.""" try: if not SUNO_API_KEY: update_task(task_id, "failed", 0, "", error="SUNO_API_KEY가 설정되지 않았습니다.") return update_task(task_id, "processing", 5, "커버 이미지 생성 요청 중...") suno_task_id = params.get("suno_task_id", "") if not suno_task_id: update_task(task_id, "failed", 0, "", error="suno_task_id가 필요합니다") return payload = { "taskId": suno_task_id, "callBackUrl": "https://example.com/noop", } resp = requests.post(f"{SUNO_BASE_URL}/suno/cover/generate", headers=_headers(), json=payload, timeout=30) if resp.status_code != 200: update_task(task_id, "failed", 0, "", error=f"커버 이미지 API 오류: {resp.text[:300]}") return body = resp.json() if body.get("code") != 200: update_task(task_id, "failed", 0, "", error=f"커버 이미지 거부: {body.get('msg', 'unknown')}") return cover_task_id = body.get("data", {}).get("taskId", suno_task_id) update_task(task_id, "processing", 15, "커버 이미지 생성 중...") response = _poll_suno_record( "/suno/cover/record-info", cover_task_id, task_id, max_attempts=30, interval=5, progress_msg_map={"PENDING": "이미지 생성 대기 중...", "GENERATING": "이미지 생성 중..."}, ) if not response: return images = response.get("images") or response.get("sunoData") or [] image_urls = [] if isinstance(images, list): for img in images: if isinstance(img, str): image_urls.append(img) elif isinstance(img, dict): image_urls.append(img.get("imageUrl") or img.get("image_url", "")) update_task(task_id, "succeeded", 100, "커버 이미지 생성 완료", audio_url=json.dumps(image_urls)) except Exception as e: logger.exception("Cover image generation error for task %s", task_id) update_task(task_id, "failed", 0, "", error=str(e))