# Music Lab Suno API 전체 기능 확장 구현 계획 > **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. **Goal:** Suno API의 미사용 기능을 전부 활용하여 music-lab을 완전한 AI 음악 프로덕션 스튜디오로 업그레이드한다. **Architecture:** 백엔드(music-lab)에 Suno API 신규 엔드포인트를 추가하고, 공통 폴링 헬퍼를 추출하여 중복을 제거한다. 프론트엔드(web-ui)는 1,725줄 단일 파일을 컴포넌트별로 분할하고 Phase별 UI를 추가한다. **Tech Stack:** Python 3.12, FastAPI, SQLite, React 18, Vanilla CSS, Vite **Spec:** `docs/superpowers/specs/2026-04-08-music-lab-suno-enhancement-design.md` --- ## 파일 구조 ### 백엔드 (web-backend/music-lab/) | 파일 | 작업 | |------|------| | `app/suno_provider.py` | 수정: V5_5 모델 추가, 공통 폴링 헬퍼 추출, 신규 파라미터 매핑, Phase 1~3 함수 추가 | | `app/main.py` | 수정: GenerateRequest 확장, Phase 1~3 엔드포인트 추가 | | `app/db.py` | 수정: 마이그레이션 컬럼 추가, 트랙 업데이트 함수 추가 | ### 프론트엔드 (web-ui/src/) | 파일 | 작업 | |------|------| | `api.js` | 수정: Phase 1~3 API 함수 추가 | | `pages/music/MusicStudio.jsx` | 수정: 컴포넌트 분할 후 메인 셸 역할 | | `pages/music/MusicStudio.css` | 수정: 신규 컴포넌트 스타일 추가 | | `pages/music/components/CreditsBadge.jsx` | 생성: 크레딧 잔액 배지 | | `pages/music/components/CreateTab.jsx` | 생성: 생성 폼 (기존 코드 이동 + Phase 1 확장) | | `pages/music/components/LyricsTab.jsx` | 생성: 가사 관리 (기존 코드 이동) | | `pages/music/components/LibraryTab.jsx` | 생성: 라이브러리 (기존 코드 이동 + 더보기 메뉴) | | `pages/music/components/AudioPlayer.jsx` | 생성: 오디오 플레이어 (기존 코드 이동) | | `pages/music/components/CoverArtModal.jsx` | 생성: 커버 이미지 선택 모달 | | `pages/music/components/StemModal.jsx` | 생성: 12스템 결과 모달 | | `pages/music/components/SyncedLyricsPlayer.jsx` | 생성: 타임스탬프 가사 오버레이 | | `pages/music/components/RemixTab.jsx` | 생성: Phase 3 업로드/리믹스 탭 | --- ## Phase 1: 핵심 생성 강화 ### Task 1: 백엔드 — suno_provider 리팩토링 + V5_5 + 신규 파라미터 **Files:** - Modify: `music-lab/app/suno_provider.py` - [ ] **Step 1: V5_5 모델 추가 + 공통 폴링 헬퍼 추출** `music-lab/app/suno_provider.py` 상단의 SUNO_MODELS에 V5_5를 추가하고, 기존 `_poll_until_complete`를 범용 헬퍼로 리팩토링: ```python # SUNO_MODELS 리스트 끝에 추가 (line 31 뒤) {"id": "V5_5", "name": "V5.5", "max_duration": "8분", "description": "커스텀 모델, 최신 음악성"}, ``` `_poll_until_complete` 함수를 범용화하여 다른 Suno 작업(WAV, 스템, 커버이미지 등)에도 재사용: ```python 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 객체 반환. record_info_path: 예) "/generate/record-info", "/wav/record-info" progress_msg_map: 상태별 메시지 오버라이드 (예: {"PENDING": "WAV 변환 대기 중..."}) """ 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 ``` 기존 `_poll_until_complete`를 호출하는 곳(`run_suno_generation`, `run_suno_extend`, `run_vocal_removal`)을 `_poll_suno_record` 호출로 교체: ```python # run_suno_generation 내부 (기존: completed_tracks = _poll_until_complete(task_id, suno_task_id)) 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 ``` `run_suno_extend`와 `run_vocal_removal`도 동일 패턴으로 교체. - [ ] **Step 2: _build_suno_payload에 신규 파라미터 매핑 추가** ```python def _build_suno_payload(params: dict) -> dict: """프론트엔드 params → sunoapi.org 요청 형식으로 변환.""" # ... 기존 로직 유지 ... # 신규 파라미터 매핑 (None이 아닌 경우에만 포함) 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 ``` 이 4줄을 `_build_suno_payload` 함수의 `return payload` 직전에 추가. - [ ] **Step 3: 커버 이미지 생성 함수 추가** `suno_provider.py` 하단에 추가: ```python # ── 커버 이미지 생성 ───────────────────────────────────────────────────────── 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)) ``` suno_provider.py 상단에 `import json` 추가 필요. - [ ] **Step 4: 크레딧 조회 폴백 로직** ```python 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) # /generate/credit은 정수 반환 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 ``` - [ ] **Step 5: 커밋** ```bash git add music-lab/app/suno_provider.py git commit -m "refactor(music-lab): 공통 폴링 헬퍼 추출 + V5_5 모델 + 신규 파라미터 + 커버이미지" ``` --- ### Task 2: 백엔드 — DB 마이그레이션 + main.py 엔드포인트 **Files:** - Modify: `music-lab/app/db.py` - Modify: `music-lab/app/main.py` - [ ] **Step 1: db.py 마이그레이션 + 업데이트 함수 추가** `db.py`의 `init_db()` 함수, 기존 마이그레이션 루프(line 72~) 뒤에 추가: ```python # Phase 1~3 신규 컬럼 마이그레이션 for col, default in [ ("cover_images", "'[]'"), ("wav_url", "''"), ("video_url", "''"), ("stem_urls", "'{}'"), ]: try: conn.execute(f"ALTER TABLE music_library ADD COLUMN {col} TEXT NOT NULL DEFAULT {default}") except sqlite3.OperationalError: pass ``` `_track_row_to_dict` 함수에 신규 컬럼 매핑 추가 (line 164 뒤): ```python "cover_images": json.loads(r["cover_images"]) if "cover_images" in keys and r["cover_images"] else [], "wav_url": r["wav_url"] if "wav_url" in keys else "", "video_url": r["video_url"] if "video_url" in keys else "", "stem_urls": json.loads(r["stem_urls"]) if "stem_urls" in keys and r["stem_urls"] else {}, ``` 파일 하단에 업데이트 함수 추가: ```python def update_track_cover_images(track_id: int, images: list) -> None: with _conn() as conn: conn.execute( "UPDATE music_library SET cover_images=? WHERE id=?", (json.dumps(images), track_id), ) def update_track_wav_url(track_id: int, wav_url: str) -> None: with _conn() as conn: conn.execute( "UPDATE music_library SET wav_url=? WHERE id=?", (wav_url, track_id), ) def update_track_video_url(track_id: int, video_url: str) -> None: with _conn() as conn: conn.execute( "UPDATE music_library SET video_url=? WHERE id=?", (video_url, track_id), ) def update_track_stem_urls(track_id: int, stems: dict) -> None: with _conn() as conn: conn.execute( "UPDATE music_library SET stem_urls=? WHERE id=?", (json.dumps(stems), track_id), ) ``` - [ ] **Step 2: main.py — GenerateRequest 스키마 확장** `main.py`의 `GenerateRequest` 클래스(line 90~)에 필드 추가: ```python class GenerateRequest(BaseModel): provider: str = "suno" model: str = "V4" title: str = "" genre: str = "" moods: List[str] = [] instruments: List[str] = [] duration_sec: Optional[int] = None bpm: Optional[int] = None key: str = "" scale: str = "" prompt: str = "" # Suno 전용 lyrics: str = "" instrumental: bool = False # Phase 1 신규 vocal_gender: Optional[str] = None # "m" | "f" negative_tags: Optional[str] = None # 제외 스타일 style_weight: Optional[float] = None # 0.0~1.0 audio_weight: Optional[float] = None # 0.0~1.0 ``` - [ ] **Step 3: main.py — 커버 이미지 엔드포인트 추가** `main.py` import에 `run_cover_image` 추가, 보컬분리 API 뒤에 엔드포인트 추가: ```python from .suno_provider import ( run_suno_generation, run_suno_extend, run_vocal_removal, run_cover_image, generate_lyrics, get_credits, SUNO_API_KEY, SUNO_MODELS, ) # ── 커버 이미지 생성 API ──────────────────────────────────────────────────── class CoverImageRequest(BaseModel): suno_task_id: str # Suno 생성 task ID track_id: Optional[int] = None # 라이브러리 트랙 ID (결과 저장용) @app.post("/api/music/cover-image") def cover_image(req: CoverImageRequest, background_tasks: BackgroundTasks): """Suno 곡의 커버 이미지 2장 생성.""" if not SUNO_API_KEY: raise HTTPException(status_code=400, detail="Suno API 키가 설정되지 않았습니다") task_id = str(uuid.uuid4()) params = req.model_dump() create_task(task_id, params, provider="suno") background_tasks.add_task(run_cover_image, task_id, params) return {"task_id": task_id, "provider": "suno"} ``` - [ ] **Step 4: 커밋** ```bash git add music-lab/app/db.py music-lab/app/main.py git commit -m "feat(music-lab): Phase 1 DB 마이그레이션 + GenerateRequest 확장 + 커버이미지 엔드포인트" ``` --- ### Task 3: 프론트엔드 — 컴포넌트 분할 **Files:** - Modify: `web-ui/src/pages/music/MusicStudio.jsx` - Create: `web-ui/src/pages/music/components/AudioPlayer.jsx` - Create: `web-ui/src/pages/music/components/LyricsTab.jsx` - Create: `web-ui/src/pages/music/components/CreditsBadge.jsx` 이 태스크는 기존 MusicStudio.jsx에서 독립적인 컴포넌트를 별도 파일로 추출한다. 기능 변경 없이 구조만 변경. - [ ] **Step 1: AudioPlayer 컴포넌트 추출** `web-ui/src/pages/music/components/AudioPlayer.jsx` 생성: ```jsx import React, { useEffect, useRef, useState } from 'react'; const pad = (n) => String(Math.floor(n)).padStart(2, '0'); export const fmtTime = (s) => `${pad(s / 60)}:${pad(s % 60)}`; const AudioPlayer = ({ audioUrl, totalSec, accentColor }) => { const audioRef = useRef(null); const [playing, setPlaying] = useState(false); const [elapsed, setElapsed] = useState(0); const [duration, setDuration] = useState(totalSec ?? 0); const [volume, setVolume] = useState(1); const isFake = !audioUrl; const timerRef = useRef(null); const total = duration || totalSec || 60; const togglePlay = () => { if (isFake) { if (playing) { clearInterval(timerRef.current); setPlaying(false); } else { setPlaying(true); timerRef.current = setInterval(() => { setElapsed((e) => { if (e >= total - 1) { clearInterval(timerRef.current); setPlaying(false); return 0; } return e + 1; }); }, 1000); } return; } const el = audioRef.current; if (!el) return; playing ? el.pause() : el.play(); }; const handleSeek = (e) => { const rect = e.currentTarget.getBoundingClientRect(); const ratio = (e.clientX - rect.left) / rect.width; const newTime = ratio * total; if (!isFake && audioRef.current) { audioRef.current.currentTime = newTime; } setElapsed(newTime); }; const handleVolumeChange = (e) => { const v = Number(e.target.value); setVolume(v); if (!isFake && audioRef.current) audioRef.current.volume = v; }; useEffect(() => () => clearInterval(timerRef.current), []); const progress = (elapsed / total) * 100; return (
{!isFake && (