From c8ee3bb95bc1bbb836edb975a011912b54e2fea4 Mon Sep 17 00:00:00 2001 From: gahusb Date: Wed, 8 Apr 2026 03:26:40 +0900 Subject: [PATCH] =?UTF-8?q?docs:=20music-lab=20Suno=20API=20=EC=A0=84?= =?UTF-8?q?=EC=B2=B4=20=EA=B8=B0=EB=8A=A5=20=ED=99=95=EC=9E=A5=20=EA=B5=AC?= =?UTF-8?q?=ED=98=84=20=EA=B3=84=ED=9A=8D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 10개 Task, 3 Phase 구조의 상세 구현 계획. Phase 1(생성 강화), Phase 2(후처리), Phase 3(리믹스). Co-Authored-By: Claude Opus 4.6 --- .../2026-04-08-music-lab-suno-enhancement.md | 2594 +++++++++++++++++ 1 file changed, 2594 insertions(+) create mode 100644 docs/superpowers/plans/2026-04-08-music-lab-suno-enhancement.md diff --git a/docs/superpowers/plans/2026-04-08-music-lab-suno-enhancement.md b/docs/superpowers/plans/2026-04-08-music-lab-suno-enhancement.md new file mode 100644 index 0000000..ac952bf --- /dev/null +++ b/docs/superpowers/plans/2026-04-08-music-lab-suno-enhancement.md @@ -0,0 +1,2594 @@ +# 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 && ( +