Files
web-page-backend/docs/superpowers/plans/2026-04-08-music-lab-suno-enhancement.md
gahusb c8ee3bb95b docs: music-lab Suno API 전체 기능 확장 구현 계획
10개 Task, 3 Phase 구조의 상세 구현 계획.
Phase 1(생성 강화), Phase 2(후처리), Phase 3(리믹스).

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-08 03:26:40 +09:00

103 KiB

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를 범용 헬퍼로 리팩토링:

# SUNO_MODELS 리스트 끝에 추가 (line 31 뒤)
    {"id": "V5_5", "name": "V5.5", "max_duration": "8분", "description": "커스텀 모델, 최신 음악성"},

_poll_until_complete 함수를 범용화하여 다른 Suno 작업(WAV, 스템, 커버이미지 등)에도 재사용:

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 호출로 교체:

# 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_extendrun_vocal_removal도 동일 패턴으로 교체.

  • Step 2: _build_suno_payload에 신규 파라미터 매핑 추가
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 하단에 추가:

# ── 커버 이미지 생성 ─────────────────────────────────────────────────────────

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: 크레딧 조회 폴백 로직
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: 커밋
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.pyinit_db() 함수, 기존 마이그레이션 루프(line 72~) 뒤에 추가:

        # 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 뒤):

        "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 {},

파일 하단에 업데이트 함수 추가:

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.pyGenerateRequest 클래스(line 90~)에 필드 추가:

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 뒤에 엔드포인트 추가:

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: 커밋
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 생성:

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 (
        <div className="ms-audio-player" style={{ '--player-accent': accentColor }}>
            {!isFake && (
                <audio
                    ref={audioRef}
                    src={audioUrl}
                    onLoadedMetadata={(e) => setDuration(e.target.duration)}
                    onTimeUpdate={(e) => setElapsed(e.target.currentTime)}
                    onPlay={() => setPlaying(true)}
                    onPause={() => setPlaying(false)}
                    onEnded={() => { setPlaying(false); setElapsed(0); }}
                />
            )}
            <button
                type="button"
                className={`ms-player__play ${playing ? 'is-playing' : ''}`}
                onClick={togglePlay}
                aria-label={playing ? '일시정지' : '재생'}
            >
                {playing ? (
                    <svg width="16" height="16" viewBox="0 0 16 16" fill="currentColor">
                        <rect x="3" y="2" width="4" height="12" rx="1" />
                        <rect x="9" y="2" width="4" height="12" rx="1" />
                    </svg>
                ) : (
                    <svg width="16" height="16" viewBox="0 0 16 16" fill="currentColor">
                        <path d="M4 2l10 6-10 6V2z" />
                    </svg>
                )}
            </button>

            <div className="ms-player__timeline">
                <div className="ms-player__bar" onClick={handleSeek} role="slider"
                     aria-label="재생 위치" aria-valuenow={Math.round(elapsed)} aria-valuemin={0} aria-valuemax={Math.round(total)}>
                    <div className="ms-player__fill" style={{ width: `${progress}%` }} />
                    <div className="ms-player__thumb" style={{ left: `${progress}%` }} />
                </div>
                <div className="ms-player__times">
                    <span>{fmtTime(elapsed)}</span>
                    <span>{fmtTime(total)}</span>
                </div>
            </div>

            <div className="ms-volume">
                <svg width="14" height="14" viewBox="0 0 14 14" fill="currentColor" aria-hidden>
                    <path d="M2 5h2.5l3-3v10l-3-3H2V5zm8.5-1.5a4.5 4.5 0 010 7" stroke="currentColor" strokeWidth="1.2" fill="none" strokeLinecap="round"/>
                </svg>
                <input
                    type="range" min={0} max={1} step={0.02} value={volume}
                    onChange={handleVolumeChange}
                    className="ms-volume__slider"
                    aria-label="볼륨"
                />
            </div>
        </div>
    );
};

export default AudioPlayer;
  • Step 2: CreditsBadge 컴포넌트 생성

web-ui/src/pages/music/components/CreditsBadge.jsx 생성:

import React, { useEffect, useState, useCallback } from 'react';
import { getMusicCredits } from '../../../api';

const CreditsBadge = () => {
    const [credits, setCredits] = useState(null);

    const fetchCredits = useCallback(async () => {
        try {
            const data = await getMusicCredits();
            setCredits(data);
        } catch {}
    }, []);

    useEffect(() => {
        fetchCredits();
        const interval = setInterval(fetchCredits, 30000);
        return () => clearInterval(interval);
    }, [fetchCredits]);

    if (!credits) return null;

    const remaining = credits.credits_left ?? credits.remaining ?? credits.data ?? null;
    if (remaining == null) return null;

    const isLow = remaining <= 10;

    return (
        <div className={`ms-credits-badge ${isLow ? 'is-low' : ''}`}>
            <span className="ms-credits-badge__icon"></span>
            <span className="ms-credits-badge__value">{remaining}</span>
            <span className="ms-credits-badge__label">credits</span>
        </div>
    );
};

export default CreditsBadge;
  • Step 3: LyricsTab 추출

web-ui/src/pages/music/components/LyricsTab.jsx 생성 — 기존 MusicStudio.jsx의 LyricsTab 컴포넌트(line 617~847)를 그대로 이동. import 경로를 상대경로로 변경:

import React, { useEffect, useState } from 'react';
import {
    generateMusicLyrics,
    getSavedLyrics,
    saveLyrics,
    updateLyrics,
    deleteLyrics,
} from '../../../api';

const LyricsTab = ({ onUseInCreate }) => {
    // ... 기존 LyricsTab 코드 전체 (line 618~847) 그대로 이동 ...
};

export default LyricsTab;
  • Step 4: MusicStudio.jsx에서 추출된 컴포넌트 import로 교체
// MusicStudio.jsx 상단 import 변경
import AudioPlayer from './components/AudioPlayer';
import { fmtTime } from './components/AudioPlayer';
import CreditsBadge from './components/CreditsBadge';
import LyricsTab from './components/LyricsTab';

기존 인라인 AudioPlayer, LyricsTab, fmtTime, pad 정의를 삭제.

헤더 영역의 크레딧 표시(line 1191~1198)를 <CreditsBadge /> 로 교체:

<div className="ms-header__right">
    <CreditsBadge />
    <SonicRadar isGenerating={isGenerating} accentColor={accentColor} />
</div>

기존 credits 상태 변수와 관련 로직 제거:

  • const [credits, setCredits] = useState(null); 삭제

  • getMusicCredits().then(...) 호출 삭제

  • Step 5: 커밋

git add web-ui/src/pages/music/
git commit -m "refactor(music-lab): 컴포넌트 분할 — AudioPlayer, LyricsTab, CreditsBadge 추출"

Task 4: 프론트엔드 — Create 탭 Phase 1 확장 (보컬 성별, negativeTags, weight 슬라이더)

Files:

  • Modify: web-ui/src/pages/music/MusicStudio.jsx

  • Modify: web-ui/src/pages/music/MusicStudio.css

  • Step 1: 상태 변수 추가

MusicStudio.jsx의 상태 선언 영역(line 870~) 뒤에 추가:

    /* ── Phase 1: 신규 파라미터 ── */
    const [vocalGender, setVocalGender]     = useState(null);       // "m" | "f" | null
    const [negativeTags, setNegativeTags]   = useState('');
    const [styleWeight, setStyleWeight]     = useState(50);         // UI: 0~100, API: 0~1
    const [audioWeight, setAudioWeight]     = useState(50);
  • Step 2: handleGenerate 페이로드에 신규 파라미터 포함

기존 payload 조립(line 1063~) 수정:

        const payload = {
            provider,
            model,
            title,
            genre,
            moods,
            instruments: instList,
            duration_sec: durSec,
            bpm,
            key:   musicalKey,
            scale,
            prompt: prompt || undefined,
            ...(provider === 'suno' ? {
                lyrics: lyrics || undefined,
                instrumental,
                vocal_gender: vocalGender || undefined,
                negative_tags: negativeTags || undefined,
                style_weight: styleWeight !== 50 ? styleWeight / 100 : undefined,
                audio_weight: audioWeight !== 50 ? audioWeight / 100 : undefined,
            } : {}),
        };
  • Step 3: Step 4 (Parameters) 섹션에 UI 추가

Key+Scale 그리드(line 1489~1531) 뒤, </section> 닫기 전에 추가:

                            {/* Vocal Gender (Suno only) */}
                            {provider === 'suno' && (
                                <div className="ms-param-group">
                                    <label className="ms-param-label">Vocal Gender</label>
                                    <div className="ms-gender-toggle">
                                        {[
                                            { value: null, label: 'Auto', icon: '🎵' },
                                            { value: 'm', label: 'Male', icon: '♂' },
                                            { value: 'f', label: 'Female', icon: '♀' },
                                        ].map((opt) => (
                                            <button
                                                key={opt.label}
                                                type="button"
                                                className={`ms-gender-btn ${vocalGender === opt.value ? 'is-active' : ''} ${opt.value === 'm' ? 'is-male' : opt.value === 'f' ? 'is-female' : ''}`}
                                                onClick={() => setVocalGender(opt.value)}
                                            >
                                                <span className="ms-gender-btn__icon">{opt.icon}</span>
                                                {opt.label}
                                            </button>
                                        ))}
                                    </div>
                                </div>
                            )}

                            {/* Negative Tags (Suno only) */}
                            {provider === 'suno' && (
                                <div className="ms-param-group">
                                    <label className="ms-param-label">Exclude Styles</label>
                                    <div className="ms-negative-tags">
                                        <div className="ms-negative-tags__presets">
                                            {['screaming', 'autotune', 'distortion', 'whisper', 'falsetto', 'rap'].map((tag) => (
                                                <button
                                                    key={tag}
                                                    type="button"
                                                    className={`ms-neg-chip ${negativeTags.includes(tag) ? 'is-active' : ''}`}
                                                    onClick={() => {
                                                        setNegativeTags((prev) => {
                                                            const tags = prev.split(',').map(t => t.trim()).filter(Boolean);
                                                            if (tags.includes(tag)) return tags.filter(t => t !== tag).join(', ');
                                                            return [...tags, tag].join(', ');
                                                        });
                                                    }}
                                                >
                                                    {tag}
                                                </button>
                                            ))}
                                        </div>
                                        <input
                                            type="text"
                                            className="ms-negative-tags__input"
                                            placeholder="추가로 제외할 스타일을 입력..."
                                            value={negativeTags}
                                            onChange={(e) => setNegativeTags(e.target.value)}
                                        />
                                    </div>
                                </div>
                            )}

                            {/* Style Weight / Audio Weight (Suno only) */}
                            {provider === 'suno' && (
                                <div className="ms-param-grid">
                                    <div className="ms-param-group">
                                        <div className="ms-param-row">
                                            <label className="ms-param-label">Style Weight</label>
                                            <span className="ms-param-value">{styleWeight}%</span>
                                        </div>
                                        <p className="ms-param-hint ms-param-hint--inline">Prompt  Style 밸런스</p>
                                        <input
                                            type="range" min={0} max={100} value={styleWeight}
                                            onChange={(e) => setStyleWeight(Number(e.target.value))}
                                            className="ms-bpm-slider"
                                            aria-label="Style Weight"
                                        />
                                    </div>
                                    <div className="ms-param-group">
                                        <div className="ms-param-row">
                                            <label className="ms-param-label">Audio Weight</label>
                                            <span className="ms-param-value">{audioWeight}%</span>
                                        </div>
                                        <p className="ms-param-hint ms-param-hint--inline">Original  AI 밸런스</p>
                                        <input
                                            type="range" min={0} max={100} value={audioWeight}
                                            onChange={(e) => setAudioWeight(Number(e.target.value))}
                                            className="ms-bpm-slider"
                                            aria-label="Audio Weight"
                                        />
                                    </div>
                                </div>
                            )}
  • Step 4: CSS 스타일 추가

web-ui/src/pages/music/MusicStudio.css 하단에 추가:

/* ── Phase 1: Credits Badge ─────────────────────────────── */
.ms-credits-badge {
    display: inline-flex; align-items: center; gap: 6px;
    padding: 6px 14px; border-radius: 20px;
    background: rgba(245, 166, 35, 0.1);
    border: 1px solid rgba(245, 166, 35, 0.25);
    font-family: 'Courier Prime', monospace;
    font-size: 0.85rem; color: var(--ms-accent);
}
.ms-credits-badge__icon { font-size: 1rem; }
.ms-credits-badge__value { font-weight: 700; font-size: 1.1rem; }
.ms-credits-badge__label { color: var(--ms-muted); font-size: 0.75rem; text-transform: uppercase; }
.ms-credits-badge.is-low {
    background: rgba(231, 76, 60, 0.15);
    border-color: rgba(231, 76, 60, 0.4);
    color: #e74c3c;
    animation: pulse-badge 1.5s ease-in-out infinite;
}
@keyframes pulse-badge {
    0%, 100% { opacity: 1; }
    50% { opacity: 0.6; }
}

/* ── Phase 1: Vocal Gender Toggle ───────────────────────── */
.ms-gender-toggle {
    display: flex; gap: 6px;
}
.ms-gender-btn {
    flex: 1; padding: 8px 12px; border-radius: 8px;
    background: var(--ms-surface); border: 1px solid var(--ms-line);
    color: var(--ms-muted); font-family: 'Syne', sans-serif;
    font-size: 0.82rem; cursor: pointer; transition: all 0.2s;
    display: flex; align-items: center; gap: 6px; justify-content: center;
}
.ms-gender-btn:hover { border-color: var(--ms-accent); color: var(--ms-text); }
.ms-gender-btn.is-active { background: rgba(245, 166, 35, 0.12); border-color: var(--ms-accent); color: var(--ms-text); }
.ms-gender-btn.is-active.is-male { background: rgba(74, 158, 255, 0.12); border-color: #4a9eff; color: #4a9eff; }
.ms-gender-btn.is-active.is-female { background: rgba(255, 107, 157, 0.12); border-color: #ff6b9d; color: #ff6b9d; }
.ms-gender-btn__icon { font-size: 1.1rem; }

/* ── Phase 1: Negative Tags ─────────────────────────────── */
.ms-negative-tags { display: flex; flex-direction: column; gap: 8px; }
.ms-negative-tags__presets { display: flex; flex-wrap: wrap; gap: 6px; }
.ms-neg-chip {
    padding: 4px 12px; border-radius: 14px;
    background: var(--ms-surface); border: 1px solid var(--ms-line);
    color: var(--ms-muted); font-size: 0.78rem; cursor: pointer;
    font-family: 'Syne', sans-serif; transition: all 0.2s;
}
.ms-neg-chip:hover { border-color: #e74c3c; color: var(--ms-text); }
.ms-neg-chip.is-active {
    background: rgba(231, 76, 60, 0.12); border-color: #e74c3c; color: #e74c3c;
    text-decoration: line-through;
}
.ms-negative-tags__input {
    padding: 8px 12px; border-radius: 8px;
    background: var(--ms-surface); border: 1px solid var(--ms-line);
    color: var(--ms-text); font-family: 'Syne', sans-serif; font-size: 0.82rem;
}
.ms-negative-tags__input::placeholder { color: var(--ms-dim); }

/* ── Phase 1: Param hint inline ─────────────────────────── */
.ms-param-hint--inline {
    font-size: 0.72rem; color: var(--ms-dim); margin: 0 0 4px;
    font-family: 'Courier Prime', monospace;
}
  • Step 5: 커밋
git add web-ui/src/pages/music/
git commit -m "feat(music-lab): Phase 1 UI — 보컬 성별, 제외 스타일, weight 슬라이더, 크레딧 배지"

Task 5: 프론트엔드 — LibraryCard 더보기 메뉴 + CoverArtModal

Files:

  • Modify: web-ui/src/pages/music/MusicStudio.jsx (LibraryCard 수정)

  • Create: web-ui/src/pages/music/components/CoverArtModal.jsx

  • Modify: web-ui/src/pages/music/MusicStudio.css

  • Modify: web-ui/src/api.js

  • Step 1: api.js에 커버 이미지 API 함수 추가

web-ui/src/api.js의 음악 API 섹션(line 313 뒤)에 추가:

// POST /api/music/cover-image  body: { suno_task_id, track_id }
// → { task_id, provider }
export function generateCoverImage(payload) {
  return apiPost('/api/music/cover-image', payload);
}
  • Step 2: CoverArtModal 컴포넌트 생성

web-ui/src/pages/music/components/CoverArtModal.jsx 생성:

import React, { useState } from 'react';

const CoverArtModal = ({ images, onSelect, onClose }) => {
    const [selected, setSelected] = useState(null);

    if (!images || images.length === 0) return null;

    return (
        <div className="ms-modal-overlay" onClick={onClose}>
            <div className="ms-modal" onClick={(e) => e.stopPropagation()}>
                <div className="ms-modal__header">
                    <h3 className="ms-modal__title">Cover Art 선택</h3>
                    <button type="button" className="ms-modal__close" onClick={onClose}></button>
                </div>
                <div className="ms-cover-grid">
                    {images.map((url, idx) => (
                        <button
                            key={idx}
                            type="button"
                            className={`ms-cover-option ${selected === idx ? 'is-selected' : ''}`}
                            onClick={() => setSelected(idx)}
                        >
                            <img src={url} alt={`Cover option ${idx + 1}`} className="ms-cover-option__img" />
                            <span className="ms-cover-option__label">Option {idx + 1}</span>
                        </button>
                    ))}
                </div>
                <div className="ms-modal__actions">
                    <button
                        type="button"
                        className="ms-btn ms-btn--accent"
                        disabled={selected === null}
                        onClick={() => { if (selected !== null) onSelect(images[selected]); }}
                    >
                         이미지 사용
                    </button>
                    <button type="button" className="ms-btn ms-btn--ghost" onClick={onClose}>
                        취소
                    </button>
                </div>
            </div>
        </div>
    );
};

export default CoverArtModal;
  • Step 3: LibraryCard에 더보기 메뉴 + 커버아트 핸들러 추가

MusicStudio.jsx의 LibraryCard 컴포넌트를 수정. props에 onCoverArt 추가:

const LibraryCard = ({ track, onDelete, onPlay, isPlaying, onExtend, onVocalRemoval, onCoverArt, isGenerating }) => {
    const [menuOpen, setMenuOpen] = useState(false);
    const genre    = GENRES.find((g) => g.id === track.genre);
    const totalSec = track.duration_sec ?? null;
    const filename = track.audio_url ? track.audio_url.split('/').pop() : '';
    const hasSunoId = !!track.suno_id;

    return (
        <div
            className={`ms-lib-card ${isPlaying ? 'is-playing' : ''}`}
            style={{ '--lib-accent': genre?.color ?? '#f5a623' }}
        >
            <div className="ms-lib-card__header">
                <span className="ms-lib-card__icon">{genre?.icon ?? '🎵'}</span>
                {track.cover_images?.[0] && (
                    <img src={track.cover_images[0]} alt="" className="ms-lib-card__thumb" />
                )}
                <p className="ms-lib-card__title">{track.title}</p>
                <div className="ms-lib-card__controls">
                    <button
                        type="button"
                        className={`ms-btn--icon ${isPlaying ? 'is-active' : ''}`}
                        onClick={() => onPlay(track)}
                        aria-label={isPlaying ? '정지' : '재생'}
                    >
                        {isPlaying ? '■' : '▶'}
                    </button>
                    {track.audio_url && (
                        <a href={track.audio_url} download className="ms-btn--icon" aria-label="다운로드"></a>
                    )}
                    <button
                        type="button"
                        className="ms-btn--icon ms-btn--danger"
                        onClick={() => onDelete(track.id)}
                        aria-label="삭제"
                    >
                        
                    </button>
                </div>
            </div>
            <div className="ms-lib-card__sub">
                <p className="ms-lib-card__filename">{filename}</p>
                <p className="ms-lib-card__meta">
                    {totalSec != null ? fmtTime(totalSec) : '--:--'} · {track.bpm ? `${track.bpm} BPM` : ''} {track.key} {track.scale}
                </p>
            </div>
            {isPlaying && (
                <AudioPlayer
                    audioUrl={track.audio_url}
                    totalSec={totalSec}
                    accentColor={genre?.color ?? '#f5a623'}
                />
            )}
            <div className="ms-lib-card__tags">
                {track.provider && (
                    <span className={`ms-result__tag ms-result__tag--provider ${track.provider === 'suno' ? 'is-suno' : 'is-local'}`}>
                        {track.provider === 'suno' ? '🎙️ Suno' : '🤖 MusicGen'}
                    </span>
                )}
                {(track.instruments ?? []).slice(0, 3).map((i) => (
                    <span key={i} className="ms-result__tag">{i}</span>
                ))}
                {(track.moods ?? []).slice(0, 2).map((m) => (
                    <span key={m} className="ms-result__tag">{m}</span>
                ))}
            </div>
            {hasSunoId && (
                <div className="ms-lib-card__actions">
                    <button type="button" className="ms-btn ms-btn--ghost ms-btn--sm"
                        onClick={() => onExtend(track)} disabled={isGenerating} title="이 곡을 이어서 연장합니다">
                         Extend
                    </button>
                    <button type="button" className="ms-btn ms-btn--ghost ms-btn--sm"
                        onClick={() => onVocalRemoval(track)} disabled={isGenerating} title="보컬과 인스트루멘탈을 분리합니다">
                        🎤 Vocal Split
                    </button>
                    {/* 더보기 메뉴 */}
                    <div className="ms-more-menu">
                        <button type="button" className="ms-btn ms-btn--ghost ms-btn--sm"
                            onClick={() => setMenuOpen(!menuOpen)}>
                            •••
                        </button>
                        {menuOpen && (
                            <div className="ms-more-menu__dropdown">
                                <button type="button" onClick={() => { onCoverArt(track); setMenuOpen(false); }}
                                    disabled={isGenerating}>
                                    🖼 Cover Art
                                </button>
                            </div>
                        )}
                    </div>
                </div>
            )}
            <p className="ms-lib-card__date">
                {track.created_at ? new Date(track.created_at).toLocaleDateString('ko-KR') : ''}
            </p>
        </div>
    );
};
  • Step 4: MusicStudio 메인에 커버아트 핸들러 + 모달 추가
import CoverArtModal from './components/CoverArtModal';
import { generateCoverImage } from '../../api';

// 상태 추가
const [coverArtModal, setCoverArtModal] = useState(null); // { trackId, images }

// 핸들러 추가
const handleCoverArt = async (track) => {
    if (!track.task_id || isGenerating) return;
    setTab('create');
    setIsGenerating(true);
    setTrack(null);
    setGenProgress(0);
    setGenStep('커버 이미지 생성 요청 중…');
    setGenError(null);
    try {
        const res = await generateCoverImage({
            suno_task_id: track.task_id,
            track_id: track.id,
        });
        if (res?.task_id) {
            taskIdRef.current = res.task_id;
            setGenStep('AI가 커버 이미지를 생성하고 있습니다…');
            setGenProgress(5);
            // 커버 이미지용 폴링 — 완료 시 이미지 URL 배열이 audio_url에 JSON으로 들어옴
            clearInterval(pollRef.current);
            pollRef.current = setInterval(async () => {
                try {
                    const status = await getMusicStatus(res.task_id);
                    setGenProgress(status.progress ?? 0);
                    setGenStep(status.message ?? '처리 중…');
                    if (status.status === 'succeeded') {
                        clearInterval(pollRef.current);
                        setIsGenerating(false);
                        const images = JSON.parse(status.audio_url || '[]');
                        setCoverArtModal({ trackId: track.id, images });
                    } else if (status.status === 'failed') {
                        clearInterval(pollRef.current);
                        setIsGenerating(false);
                        setGenError(`커버 이미지 생성 실패: ${status.error ?? '알 수 없는 오류'}`);
                    }
                } catch {
                    clearInterval(pollRef.current);
                    setIsGenerating(false);
                    setGenError('커버 이미지 상태 조회 실패');
                }
            }, 3000);
        }
    } catch {
        setIsGenerating(false);
        setGenError('커버 이미지 생성에 실패했습니다');
    }
};

const handleCoverSelect = (imageUrl) => {
    if (coverArtModal?.trackId) {
        setLibrary((prev) => prev.map((t) =>
            t.id === coverArtModal.trackId
                ? { ...t, cover_images: [imageUrl, ...(coverArtModal.images || []).filter(u => u !== imageUrl)] }
                : t
        ));
    }
    setCoverArtModal(null);
};

// JSX에 모달 추가 (최하단 닫기 div 전)
{coverArtModal && (
    <CoverArtModal
        images={coverArtModal.images}
        onSelect={handleCoverSelect}
        onClose={() => setCoverArtModal(null)}
    />
)}

// Library 컴포넌트에 onCoverArt prop 전달
<Library
    tracks={library}
    loading={libLoading}
    onDelete={handleDeleteFromLibrary}
    onRefresh={loadLibrary}
    onExtend={handleExtend}
    onVocalRemoval={handleVocalRemoval}
    onCoverArt={handleCoverArt}
    isGenerating={isGenerating}
/>

Library 컴포넌트에도 onCoverArt prop을 받아서 LibraryCard에 전달하도록 수정.

  • Step 5: 더보기 메뉴 + 모달 CSS
/* ── More Menu ──────────────────────────────────────────── */
.ms-more-menu { position: relative; }
.ms-more-menu__dropdown {
    position: absolute; bottom: 100%; right: 0;
    background: var(--ms-surface2); border: 1px solid var(--ms-line);
    border-radius: 8px; padding: 4px; min-width: 160px; z-index: 20;
    box-shadow: 0 8px 24px rgba(0,0,0,0.4);
}
.ms-more-menu__dropdown button {
    display: block; width: 100%; padding: 8px 12px; border: none;
    background: none; color: var(--ms-text); font-size: 0.82rem;
    font-family: 'Syne', sans-serif; cursor: pointer; text-align: left;
    border-radius: 6px;
}
.ms-more-menu__dropdown button:hover { background: rgba(245,166,35,0.1); }
.ms-more-menu__dropdown button:disabled { opacity: 0.4; cursor: not-allowed; }

/* ── Modal Overlay ──────────────────────────────────────── */
.ms-modal-overlay {
    position: fixed; inset: 0; background: rgba(0,0,0,0.7);
    display: flex; align-items: center; justify-content: center; z-index: 100;
}
.ms-modal {
    background: var(--ms-surface); border: 1px solid var(--ms-line);
    border-radius: 16px; padding: 24px; max-width: 520px; width: 90%;
    max-height: 90vh; overflow-y: auto;
}
.ms-modal__header {
    display: flex; justify-content: space-between; align-items: center; margin-bottom: 16px;
}
.ms-modal__title { font-family: 'Bebas Neue', sans-serif; font-size: 1.3rem; color: var(--ms-text); }
.ms-modal__close { background: none; border: none; color: var(--ms-muted); font-size: 1.2rem; cursor: pointer; }
.ms-modal__actions { display: flex; gap: 8px; margin-top: 16px; justify-content: flex-end; }

/* ── Cover Art Grid ─────────────────────────────────────── */
.ms-cover-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 12px; }
.ms-cover-option {
    border: 2px solid var(--ms-line); border-radius: 12px; overflow: hidden;
    cursor: pointer; background: none; padding: 0; transition: border-color 0.2s;
}
.ms-cover-option:hover { border-color: var(--ms-accent); }
.ms-cover-option.is-selected { border-color: var(--ms-accent); box-shadow: 0 0 12px rgba(245,166,35,0.3); }
.ms-cover-option__img { width: 100%; aspect-ratio: 1; object-fit: cover; display: block; }
.ms-cover-option__label {
    display: block; padding: 8px; text-align: center;
    font-family: 'Courier Prime', monospace; font-size: 0.78rem; color: var(--ms-muted);
}

/* ── Library Card Thumb ─────────────────────────────────── */
.ms-lib-card__thumb {
    width: 28px; height: 28px; border-radius: 6px; object-fit: cover;
    margin-right: 4px; flex-shrink: 0;
}
  • Step 6: 커밋
git add web-ui/src/pages/music/ web-ui/src/api.js
git commit -m "feat(music-lab): Phase 1 UI — LibraryCard 더보기 메뉴 + CoverArtModal"

Phase 2: 후처리 파워업

Task 6: 백엔드 — WAV 변환 + 12스템 분리 + 타임스탬프 가사 + 스타일 부스트

Files:

  • Modify: music-lab/app/suno_provider.py

  • Modify: music-lab/app/main.py

  • Step 1: suno_provider.py에 Phase 2 함수 추가

# ── WAV 변환 ─────────────────────────────────────────────────────────────────

def run_wav_convert(task_id: str, params: dict) -> None:
    """곡을 WAV 포맷으로 변환."""
    try:
        if not SUNO_API_KEY:
            update_task(task_id, "failed", 0, "", error="SUNO_API_KEY가 설정되지 않았습니다.")
            return

        update_task(task_id, "processing", 5, "WAV 변환 요청 중...")

        payload = {
            "taskId": params["suno_task_id"],
            "audioId": params["suno_id"],
            "callBackUrl": "https://example.com/noop",
        }

        resp = requests.post(
            f"{SUNO_BASE_URL}/wav/generate",
            headers=_headers(),
            json=payload,
            timeout=30,
        )

        if resp.status_code == 409:
            # 이미 WAV 변환됨 — 기존 결과 조회
            body = resp.json()
            wav_url = body.get("data", {}).get("audioWavUrl", "")
            if wav_url:
                update_task(task_id, "succeeded", 100, "WAV 변환 완료 (캐시)", audio_url=wav_url)
                return

        if resp.status_code != 200:
            update_task(task_id, "failed", 0, "", error=f"WAV API 오류: {resp.text[:300]}")
            return

        body = resp.json()
        if body.get("code") != 200:
            update_task(task_id, "failed", 0, "", error=f"WAV 변환 거부: {body.get('msg', 'unknown')}")
            return

        wav_task_id = body.get("data", {}).get("taskId", params["suno_task_id"])
        update_task(task_id, "processing", 15, "WAV 변환 처리 중...")

        response = _poll_suno_record(
            "/wav/record-info", wav_task_id, task_id,
            max_attempts=30, interval=5,
            progress_msg_map={"PENDING": "WAV 변환 대기 중...", "GENERATING": "WAV 변환 중..."},
        )
        if not response:
            return

        wav_url = ""
        suno_data = response.get("sunoData") or []
        if suno_data and isinstance(suno_data, list):
            wav_url = suno_data[0].get("audioWavUrl", "") if isinstance(suno_data[0], dict) else ""
        if not wav_url:
            wav_url = response.get("audioWavUrl", "")

        update_task(task_id, "succeeded", 100, "WAV 변환 완료", audio_url=wav_url)

    except Exception as e:
        logger.exception("WAV convert error for task %s", task_id)
        update_task(task_id, "failed", 0, "", error=str(e))


# ── 12스템 분리 ──────────────────────────────────────────────────────────────

def run_stem_split(task_id: str, params: dict) -> None:
    """곡을 12개 스템으로 분리 (50 크레딧 소모)."""
    try:
        if not SUNO_API_KEY:
            update_task(task_id, "failed", 0, "", error="SUNO_API_KEY가 설정되지 않았습니다.")
            return

        update_task(task_id, "processing", 5, "12스템 분리 요청 중...")

        payload = {
            "taskId": params["suno_task_id"],
            "audioId": params["suno_id"],
            "type": "split_stem",
            "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"스템 분리 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

        stem_task_id = body.get("data", {}).get("taskId", "")
        if not stem_task_id:
            update_task(task_id, "failed", 0, "", error="스템 분리 응답에 taskId 없음")
            return

        update_task(task_id, "processing", 15, "12스템 분리 처리 중 (약 2~3분)...")

        response = _poll_suno_record(
            "/vocal-removal/record-info", stem_task_id, task_id,
            max_attempts=40, interval=8,
            progress_msg_map={"PENDING": "스템 분리 대기 중...", "GENERATING": "스템 분리 중..."},
        )
        if not response:
            return

        suno_data = response.get("sunoData") or []
        stems = {}
        stem_names = ["vocal", "backing_vocals", "drums", "bass", "guitar", "keyboard",
                       "strings", "brass", "woodwinds", "percussion", "synth", "fx"]
        for i, item in enumerate(suno_data):
            if isinstance(item, dict):
                name = stem_names[i] if i < len(stem_names) else f"stem_{i}"
                stems[name] = item.get("audioUrl") or item.get("audio_url", "")

        update_task(task_id, "succeeded", 100, "12스템 분리 완료",
                    audio_url=json.dumps(stems))

    except Exception as e:
        logger.exception("Stem split error for task %s", task_id)
        update_task(task_id, "failed", 0, "", error=str(e))


# ── 타임스탬프 가사 ──────────────────────────────────────────────────────────

def get_timestamped_lyrics(suno_task_id: str, suno_id: str) -> Optional[dict]:
    """타임스탬프가 포함된 가사 데이터 조회 (동기)."""
    if not SUNO_API_KEY:
        return None
    try:
        resp = requests.post(
            f"{SUNO_BASE_URL}/generate/get-timestamped-lyrics",
            headers=_headers(),
            json={"taskId": suno_task_id, "audioId": suno_id},
            timeout=30,
        )
        if resp.status_code == 200:
            body = resp.json()
            return body.get("data", body)
    except Exception as e:
        logger.warning("Timestamped lyrics error: %s", e)
    return None


# ── 스타일 부스트 ────────────────────────────────────────────────────────────

def generate_style_boost(content: str) -> Optional[dict]:
    """AI로 최적 스타일 텍스트 생성 (동기)."""
    if not SUNO_API_KEY:
        return None
    try:
        resp = requests.post(
            f"{SUNO_BASE_URL}/style/generate",
            headers=_headers(),
            json={"content": content},
            timeout=30,
        )
        if resp.status_code == 200:
            body = resp.json()
            return body.get("data", body)
    except Exception as e:
        logger.warning("Style boost error: %s", e)
    return None
  • Step 2: main.py에 Phase 2 엔드포인트 추가

import 업데이트:

from .suno_provider import (
    run_suno_generation, run_suno_extend, run_vocal_removal,
    run_cover_image, run_wav_convert, run_stem_split,
    generate_lyrics, get_credits, get_timestamped_lyrics, generate_style_boost,
    SUNO_API_KEY, SUNO_MODELS,
)

엔드포인트 추가:

# ── WAV 변환 API ────────────────────────────────────────────────────────────

class WavRequest(BaseModel):
    suno_task_id: str
    suno_id: str
    track_id: Optional[int] = None


@app.post("/api/music/wav")
def wav_convert(req: WavRequest, background_tasks: BackgroundTasks):
    """곡을 WAV 포맷으로 변환."""
    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_wav_convert, task_id, params)
    return {"task_id": task_id, "provider": "suno"}


# ── 12스템 분리 API ─────────────────────────────────────────────────────────

class StemSplitRequest(BaseModel):
    suno_task_id: str
    suno_id: str
    track_id: Optional[int] = None


@app.post("/api/music/stem-split")
def stem_split(req: StemSplitRequest, background_tasks: BackgroundTasks):
    """곡을 12개 스템으로 분리 (50 크레딧). 보컬, 드럼, 베이스, 기타 등."""
    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_stem_split, task_id, params)
    return {"task_id": task_id, "provider": "suno"}


# ── 타임스탬프 가사 API ─────────────────────────────────────────────────────

@app.get("/api/music/timestamped-lyrics")
def timestamped_lyrics(task_id: str, suno_id: str):
    """타임스탬프 가사 조회 (가라오케 스타일 싱크용)."""
    if not SUNO_API_KEY:
        raise HTTPException(status_code=400, detail="Suno API 키가 설정되지 않았습니다")
    result = get_timestamped_lyrics(task_id, suno_id)
    if not result:
        raise HTTPException(status_code=502, detail="타임스탬프 가사 조회 실패")
    return result


# ── 스타일 부스트 API ───────────────────────────────────────────────────────

class StyleBoostRequest(BaseModel):
    content: str


@app.post("/api/music/style-boost")
def style_boost(req: StyleBoostRequest):
    """AI로 최적 스타일 프롬프트 생성."""
    if not SUNO_API_KEY:
        raise HTTPException(status_code=400, detail="Suno API 키가 설정되지 않았습니다")
    result = generate_style_boost(req.content)
    if not result:
        raise HTTPException(status_code=502, detail="스타일 부스트 생성 실패")
    return result
  • Step 3: 커밋
git add music-lab/app/suno_provider.py music-lab/app/main.py
git commit -m "feat(music-lab): Phase 2 백엔드 — WAV 변환, 12스템 분리, 타임스탬프 가사, 스타일 부스트"

Task 7: 프론트엔드 — Phase 2 UI (StemModal, 타임스탬프 가사, 스타일 부스트)

Files:

  • Modify: web-ui/src/api.js

  • Create: web-ui/src/pages/music/components/StemModal.jsx

  • Create: web-ui/src/pages/music/components/SyncedLyricsPlayer.jsx

  • Modify: web-ui/src/pages/music/MusicStudio.jsx

  • Modify: web-ui/src/pages/music/MusicStudio.css

  • Step 1: api.js Phase 2 함수 추가

// ── Phase 2 API ─────────────────────────────────────────────────────────────

// POST /api/music/wav  body: { suno_task_id, suno_id, track_id }
export function convertToWav(payload) {
  return apiPost('/api/music/wav', payload);
}

// POST /api/music/stem-split  body: { suno_task_id, suno_id, track_id }
export function splitStems(payload) {
  return apiPost('/api/music/stem-split', payload);
}

// GET /api/music/timestamped-lyrics?task_id=...&suno_id=...
export function getTimestampedLyrics(taskId, sunoId) {
  return apiGet(`/api/music/timestamped-lyrics?task_id=${encodeURIComponent(taskId)}&suno_id=${encodeURIComponent(sunoId)}`);
}

// POST /api/music/style-boost  body: { content }
export function generateStyleBoost(content) {
  return apiPost('/api/music/style-boost', { content });
}
  • Step 2: StemModal 컴포넌트

web-ui/src/pages/music/components/StemModal.jsx:

import React, { useState } from 'react';

const STEM_ICONS = {
    vocal: '🎤', backing_vocals: '🎶', drums: '🥁', bass: '🎸',
    guitar: '🎸', keyboard: '🎹', strings: '🎻', brass: '🎺',
    woodwinds: '🪈', percussion: '🪘', synth: '🎛', fx: '✨',
};

const StemModal = ({ stems, onClose }) => {
    const [playingStem, setPlayingStem] = useState(null);

    if (!stems || Object.keys(stems).length === 0) return null;

    return (
        <div className="ms-modal-overlay" onClick={onClose}>
            <div className="ms-modal ms-modal--wide" onClick={(e) => e.stopPropagation()}>
                <div className="ms-modal__header">
                    <h3 className="ms-modal__title">12 Stems</h3>
                    <span className="ms-modal__subtitle"> 스템을 개별 재생  다운로드할  있습니다</span>
                    <button type="button" className="ms-modal__close" onClick={onClose}></button>
                </div>
                <div className="ms-stem-grid">
                    {Object.entries(stems).map(([name, url]) => {
                        if (!url) return null;
                        const isPlaying = playingStem === name;
                        return (
                            <div key={name} className={`ms-stem-card ${isPlaying ? 'is-playing' : ''}`}>
                                <span className="ms-stem-card__icon">{STEM_ICONS[name] || '🎵'}</span>
                                <span className="ms-stem-card__name">{name.replace(/_/g, ' ')}</span>
                                <div className="ms-stem-card__actions">
                                    <button
                                        type="button"
                                        className="ms-btn--icon"
                                        onClick={() => setPlayingStem(isPlaying ? null : name)}
                                    >
                                        {isPlaying ? '■' : '▶'}
                                    </button>
                                    <a href={url} download className="ms-btn--icon" aria-label="다운로드"></a>
                                </div>
                                {isPlaying && (
                                    <audio src={url} autoPlay onEnded={() => setPlayingStem(null)} />
                                )}
                            </div>
                        );
                    })}
                </div>
                <div className="ms-modal__actions">
                    <button type="button" className="ms-btn ms-btn--ghost" onClick={onClose}>닫기</button>
                </div>
            </div>
        </div>
    );
};

export default StemModal;
  • Step 3: SyncedLyricsPlayer 컴포넌트

web-ui/src/pages/music/components/SyncedLyricsPlayer.jsx:

import React, { useEffect, useRef, useState } from 'react';

const SyncedLyricsPlayer = ({ audioUrl, alignedWords, onClose, accentColor }) => {
    const audioRef = useRef(null);
    const [playing, setPlaying] = useState(false);
    const [currentTime, setCurrentTime] = useState(0);

    useEffect(() => {
        const el = audioRef.current;
        if (!el) return;
        const handler = () => setCurrentTime(el.currentTime);
        el.addEventListener('timeupdate', handler);
        return () => el.removeEventListener('timeupdate', handler);
    }, []);

    if (!alignedWords || alignedWords.length === 0) return null;

    return (
        <div className="ms-synced-player" style={{ '--synced-accent': accentColor }}>
            <div className="ms-synced-player__header">
                <h4 className="ms-synced-player__title">Synced Lyrics</h4>
                <button type="button" className="ms-modal__close" onClick={onClose}></button>
            </div>
            <audio
                ref={audioRef}
                src={audioUrl}
                onPlay={() => setPlaying(true)}
                onPause={() => setPlaying(false)}
                onEnded={() => setPlaying(false)}
                controls
                className="ms-synced-player__audio"
            />
            <div className="ms-synced-player__lyrics">
                {alignedWords.map((word, idx) => {
                    const isActive = currentTime >= word.startS && currentTime < word.endS;
                    const isPast = currentTime >= word.endS;
                    return (
                        <span
                            key={idx}
                            className={`ms-synced-word ${isActive ? 'is-active' : ''} ${isPast ? 'is-past' : ''}`}
                        >
                            {word.word}{' '}
                        </span>
                    );
                })}
            </div>
        </div>
    );
};

export default SyncedLyricsPlayer;
  • Step 4: LibraryCard 더보기 메뉴에 Phase 2 액션 추가 + 스타일 부스트 버튼

MusicStudio.jsx 더보기 메뉴 dropdown에 추가:

<button type="button" onClick={() => { onWavConvert(track); setMenuOpen(false); }}
    disabled={isGenerating}>
    📀 WAV Download
</button>
<button type="button" onClick={() => { onStemSplit(track); setMenuOpen(false); }}
    disabled={isGenerating}>
    🎛 12 Stems (50cr)
</button>
<button type="button" onClick={() => { onSyncedLyrics(track); setMenuOpen(false); }}
    disabled={isGenerating || !track.lyrics}>
    📝 Synced Lyrics
</button>

Create 탭 Step 1 (Genre) 제목 옆에 Style Boost 버튼:

<div className="ms-section__head">
    <span className="ms-section__step">01</span>
    <h2 className="ms-section__title">Genre</h2>
    <span className="ms-section__hint">장르를 선택하세요</span>
    {provider === 'suno' && (
        <button
            type="button"
            className={`ms-btn ms-btn--ghost ms-btn--sm ms-style-boost-btn ${styleBoostLoading ? 'is-loading' : ''}`}
            onClick={handleStyleBoost}
            disabled={styleBoostLoading || !genre}
            title="현재 설정으로 최적 스타일 프롬프트 생성"
        >
            {styleBoostLoading ? '생성 중...' : '✨ Style Boost'}
        </button>
    )}
</div>

핸들러:

const [styleBoostLoading, setStyleBoostLoading] = useState(false);

const handleStyleBoost = async () => {
    if (!genre || styleBoostLoading) return;
    setStyleBoostLoading(true);
    try {
        const content = [
            GENRES.find(g => g.id === genre)?.label,
            ...moods.map(id => MOODS.find(m => m.id === id)?.label).filter(Boolean),
        ].join(', ');
        const result = await generateStyleBoost(content);
        if (result?.result) {
            setPrompt(result.result);
        }
    } catch {}
    finally { setStyleBoostLoading(false); }
};
  • Step 5: Phase 2 CSS
/* ── Stem Modal ─────────────────────────────────────────── */
.ms-modal--wide { max-width: 680px; }
.ms-modal__subtitle { font-size: 0.78rem; color: var(--ms-muted); font-family: 'Courier Prime', monospace; }
.ms-stem-grid { display: grid; grid-template-columns: repeat(3, 1fr); gap: 8px; }
.ms-stem-card {
    display: flex; flex-direction: column; align-items: center; gap: 6px;
    padding: 12px 8px; border-radius: 10px;
    background: var(--ms-surface2); border: 1px solid var(--ms-line);
    transition: border-color 0.2s;
}
.ms-stem-card.is-playing { border-color: var(--ms-accent); background: rgba(245,166,35,0.08); }
.ms-stem-card__icon { font-size: 1.4rem; }
.ms-stem-card__name {
    font-family: 'Courier Prime', monospace; font-size: 0.72rem;
    color: var(--ms-muted); text-transform: capitalize;
}
.ms-stem-card__actions { display: flex; gap: 6px; }

/* ── Synced Lyrics Player ───────────────────────────────── */
.ms-synced-player {
    background: var(--ms-surface); border: 1px solid var(--ms-line);
    border-radius: 12px; padding: 16px; margin-top: 12px;
}
.ms-synced-player__header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 12px; }
.ms-synced-player__title { font-family: 'Bebas Neue', sans-serif; font-size: 1.1rem; color: var(--ms-text); }
.ms-synced-player__audio { width: 100%; margin-bottom: 12px; }
.ms-synced-player__lyrics { line-height: 1.8; font-family: 'Syne', sans-serif; font-size: 0.95rem; }
.ms-synced-word { color: var(--ms-dim); transition: color 0.15s; }
.ms-synced-word.is-active { color: var(--synced-accent, var(--ms-accent)); font-weight: 600; }
.ms-synced-word.is-past { color: var(--ms-muted); }

/* ── Style Boost Button ─────────────────────────────────── */
.ms-style-boost-btn { margin-left: auto; }
.ms-style-boost-btn.is-loading { opacity: 0.6; }
  • Step 6: 커밋
git add web-ui/src/pages/music/ web-ui/src/api.js
git commit -m "feat(music-lab): Phase 2 UI — StemModal, SyncedLyricsPlayer, Style Boost, WAV 변환"

Phase 3: 고급 크리에이티브

Task 8: 백엔드 — Phase 3 엔드포인트 (업로드, 보컬/인스트 추가, 뮤직비디오)

Files:

  • Modify: music-lab/app/suno_provider.py

  • Modify: music-lab/app/main.py

  • Step 1: suno_provider.py Phase 3 함수 추가

# ── 오디오 업로드 + 커버 ─────────────────────────────────────────────────────

def run_upload_cover(task_id: str, params: dict) -> None:
    """외부 오디오를 Suno 스타일로 리메이크."""
    try:
        if not SUNO_API_KEY:
            update_task(task_id, "failed", 0, "", error="SUNO_API_KEY가 설정되지 않았습니다.")
            return

        update_task(task_id, "processing", 5, "AI Cover 요청 중...")

        payload = {
            "uploadUrl": params["upload_url"],
            "customMode": params.get("custom_mode", True),
            "instrumental": params.get("instrumental", False),
            "model": params.get("model", "V4"),
            "callBackUrl": "https://example.com/noop",
        }
        for key, api_key in [("prompt", "prompt"), ("style", "style"), ("title", "title"),
                              ("vocal_gender", "vocalGender"), ("negative_tags", "negativeTags"),
                              ("style_weight", "styleWeight"), ("audio_weight", "audioWeight")]:
            if params.get(key):
                payload[api_key] = params[key]

        resp = requests.post(f"{SUNO_BASE_URL}/generate/upload-cover", headers=_headers(), json=payload, timeout=30)
        if resp.status_code != 200:
            update_task(task_id, "failed", 0, "", error=f"Upload Cover API 오류: {resp.text[:300]}")
            return
        body = resp.json()
        if body.get("code") != 200:
            update_task(task_id, "failed", 0, "", error=f"Upload Cover 거부: {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="Upload Cover 응답에 taskId 없음")
            return
        update_task(task_id, "processing", 15, "AI Cover 생성 중...")

        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="AI Cover 생성 완료했으나 트랙 없음")
            return

        track = _download_and_register(task_id=task_id, song=completed_tracks[0], params=params, filename_suffix="")
        if track:
            update_task(task_id, "succeeded", 100, "AI Cover 완료", audio_url=track["audio_url"])

    except Exception as e:
        logger.exception("Upload cover error for task %s", task_id)
        update_task(task_id, "failed", 0, "", error=str(e))


# ── 오디오 업로드 + 확장 ─────────────────────────────────────────────────────

def run_upload_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, "Upload Extend 요청 중...")

        payload = {
            "uploadUrl": params["upload_url"],
            "defaultParamFlag": params.get("default_param_flag", True),
            "model": params.get("model", "V4"),
            "callBackUrl": "https://example.com/noop",
        }
        for key, api_key in [("prompt", "prompt"), ("style", "style"), ("title", "title"),
                              ("continue_at", "continueAt"), ("instrumental", "instrumental"),
                              ("vocal_gender", "vocalGender"), ("negative_tags", "negativeTags")]:
            if params.get(key) is not None:
                payload[api_key] = params[key]

        resp = requests.post(f"{SUNO_BASE_URL}/generate/upload-extend", headers=_headers(), json=payload, timeout=30)
        if resp.status_code != 200:
            update_task(task_id, "failed", 0, "", error=f"Upload Extend API 오류: {resp.text[:300]}")
            return
        body = resp.json()
        if body.get("code") != 200:
            update_task(task_id, "failed", 0, "", error=f"Upload 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="Upload Extend 응답에 taskId 없음")
            return
        update_task(task_id, "processing", 15, "Upload Extend 생성 중...")

        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="Upload Extend 완료했으나 트랙 없음")
            return

        track = _download_and_register(task_id=task_id, song=completed_tracks[0], params=params, filename_suffix="")
        if track:
            update_task(task_id, "succeeded", 100, "Upload Extend 완료", audio_url=track["audio_url"])

    except Exception as e:
        logger.exception("Upload extend error for task %s", task_id)
        update_task(task_id, "failed", 0, "", error=str(e))


# ── 보컬 추가 ────────────────────────────────────────────────────────────────

def run_add_vocals(task_id: str, params: dict) -> None:
    """인스트루멘탈에 AI 보컬을 추가."""
    try:
        if not SUNO_API_KEY:
            update_task(task_id, "failed", 0, "", error="SUNO_API_KEY가 설정되지 않았습니다.")
            return

        update_task(task_id, "processing", 5, "보컬 추가 요청 중...")

        payload = {
            "uploadUrl": params["upload_url"],
            "prompt": params.get("prompt", ""),
            "title": params.get("title", ""),
            "style": params.get("style", ""),
            "negativeTags": params.get("negative_tags", ""),
            "callBackUrl": "https://example.com/noop",
        }
        for key, api_key in [("vocal_gender", "vocalGender"), ("model", "model"),
                              ("style_weight", "styleWeight"), ("audio_weight", "audioWeight")]:
            if params.get(key) is not None:
                payload[api_key] = params[key]

        resp = requests.post(f"{SUNO_BASE_URL}/generate/add-vocals", headers=_headers(), json=payload, timeout=30)
        if resp.status_code != 200:
            update_task(task_id, "failed", 0, "", error=f"Add Vocals API 오류: {resp.text[:300]}")
            return
        body = resp.json()
        if body.get("code") != 200:
            update_task(task_id, "failed", 0, "", error=f"Add Vocals 거부: {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="Add Vocals 응답에 taskId 없음")
            return
        update_task(task_id, "processing", 15, "AI 보컬 생성 중...")

        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="보컬 추가 완료했으나 트랙 없음")
            return

        track = _download_and_register(task_id=task_id, song=completed_tracks[0], params=params, filename_suffix="")
        if track:
            update_task(task_id, "succeeded", 100, "보컬 추가 완료", audio_url=track["audio_url"])

    except Exception as e:
        logger.exception("Add vocals error for task %s", task_id)
        update_task(task_id, "failed", 0, "", error=str(e))


# ── 인스트루멘탈 추가 ────────────────────────────────────────────────────────

def run_add_instrumental(task_id: str, params: dict) -> None:
    """보컬에 AI 반주를 추가."""
    try:
        if not SUNO_API_KEY:
            update_task(task_id, "failed", 0, "", error="SUNO_API_KEY가 설정되지 않았습니다.")
            return

        update_task(task_id, "processing", 5, "인스트루멘탈 추가 요청 중...")

        payload = {
            "uploadUrl": params["upload_url"],
            "title": params.get("title", ""),
            "tags": params.get("tags", ""),
            "negativeTags": params.get("negative_tags", ""),
            "callBackUrl": "https://example.com/noop",
        }
        for key, api_key in [("vocal_gender", "vocalGender"), ("model", "model"),
                              ("style_weight", "styleWeight"), ("audio_weight", "audioWeight")]:
            if params.get(key) is not None:
                payload[api_key] = params[key]

        resp = requests.post(f"{SUNO_BASE_URL}/generate/add-instrumental", headers=_headers(), json=payload, timeout=30)
        if resp.status_code != 200:
            update_task(task_id, "failed", 0, "", error=f"Add Instrumental API 오류: {resp.text[:300]}")
            return
        body = resp.json()
        if body.get("code") != 200:
            update_task(task_id, "failed", 0, "", error=f"Add Instrumental 거부: {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="Add Instrumental 응답에 taskId 없음")
            return
        update_task(task_id, "processing", 15, "AI 반주 생성 중...")

        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="인스트루멘탈 추가 완료했으나 트랙 없음")
            return

        track = _download_and_register(task_id=task_id, song=completed_tracks[0], params=params, filename_suffix="")
        if track:
            update_task(task_id, "succeeded", 100, "인스트루멘탈 추가 완료", audio_url=track["audio_url"])

    except Exception as e:
        logger.exception("Add instrumental error for task %s", task_id)
        update_task(task_id, "failed", 0, "", error=str(e))


# ── 뮤직비디오 생성 ──────────────────────────────────────────────────────────

def run_video_generate(task_id: str, params: dict) -> None:
    """곡의 뮤직비디오(MP4) 생성."""
    try:
        if not SUNO_API_KEY:
            update_task(task_id, "failed", 0, "", error="SUNO_API_KEY가 설정되지 않았습니다.")
            return

        update_task(task_id, "processing", 5, "뮤직비디오 생성 요청 중...")

        payload = {
            "taskId": params["suno_task_id"],
            "audioId": params["suno_id"],
            "callBackUrl": "https://example.com/noop",
        }
        if params.get("author"):
            payload["author"] = params["author"][:50]
        if params.get("domain_name"):
            payload["domainName"] = params["domain_name"][:50]

        resp = requests.post(f"{SUNO_BASE_URL}/mp4/generate", headers=_headers(), json=payload, timeout=30)
        if resp.status_code != 200:
            update_task(task_id, "failed", 0, "", error=f"Video API 오류: {resp.text[:300]}")
            return
        body = resp.json()
        if body.get("code") != 200:
            update_task(task_id, "failed", 0, "", error=f"Video 생성 거부: {body.get('msg', 'unknown')}")
            return

        video_task_id = body.get("data", {}).get("taskId", params.get("suno_task_id", ""))
        update_task(task_id, "processing", 15, "뮤직비디오 렌더링 중...")

        response = _poll_suno_record(
            "/mp4/record-info", video_task_id, task_id,
            max_attempts=60, interval=10,
            progress_msg_map={"PENDING": "비디오 렌더링 대기 중...", "GENERATING": "비디오 렌더링 중..."},
        )
        if not response:
            return

        video_url = ""
        suno_data = response.get("sunoData") or []
        if suno_data and isinstance(suno_data, list) and isinstance(suno_data[0], dict):
            video_url = suno_data[0].get("videoUrl") or suno_data[0].get("video_url", "")
        if not video_url:
            video_url = response.get("video_url") or response.get("videoUrl", "")

        update_task(task_id, "succeeded", 100, "뮤직비디오 생성 완료", audio_url=video_url)

    except Exception as e:
        logger.exception("Video generate error for task %s", task_id)
        update_task(task_id, "failed", 0, "", error=str(e))
  • Step 2: main.py Phase 3 엔드포인트 추가

import 업데이트:

from .suno_provider import (
    run_suno_generation, run_suno_extend, run_vocal_removal,
    run_cover_image, run_wav_convert, run_stem_split,
    run_upload_cover, run_upload_extend, run_add_vocals, run_add_instrumental, run_video_generate,
    generate_lyrics, get_credits, get_timestamped_lyrics, generate_style_boost,
    SUNO_API_KEY, SUNO_MODELS,
)

엔드포인트:

# ── Phase 3: 업로드 + 커버 ──────────────────────────────────────────────────

class UploadCoverRequest(BaseModel):
    upload_url: str
    model: str = "V4"
    custom_mode: bool = True
    instrumental: bool = False
    prompt: str = ""
    style: str = ""
    title: str = ""
    vocal_gender: Optional[str] = None
    negative_tags: Optional[str] = None
    style_weight: Optional[float] = None
    audio_weight: Optional[float] = None


@app.post("/api/music/upload-cover")
def upload_cover(req: UploadCoverRequest, background_tasks: BackgroundTasks):
    """외부 오디오를 Suno 스타일로 리메이크."""
    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_upload_cover, task_id, params)
    return {"task_id": task_id, "provider": "suno"}


# ── Phase 3: 업로드 + 확장 ──────────────────────────────────────────────────

class UploadExtendRequest(BaseModel):
    upload_url: str
    model: str = "V4"
    default_param_flag: bool = True
    continue_at: Optional[float] = None
    prompt: str = ""
    style: str = ""
    title: str = ""
    instrumental: bool = False
    vocal_gender: Optional[str] = None
    negative_tags: Optional[str] = None


@app.post("/api/music/upload-extend")
def upload_extend(req: UploadExtendRequest, background_tasks: BackgroundTasks):
    """외부 오디오를 이어서 확장."""
    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_upload_extend, task_id, params)
    return {"task_id": task_id, "provider": "suno"}


# ── Phase 3: 보컬 추가 ──────────────────────────────────────────────────────

class AddVocalsRequest(BaseModel):
    upload_url: str
    prompt: str
    title: str
    style: str
    negative_tags: str = ""
    vocal_gender: Optional[str] = None
    model: str = "V4_5PLUS"
    style_weight: Optional[float] = None
    audio_weight: Optional[float] = None


@app.post("/api/music/add-vocals")
def add_vocals(req: AddVocalsRequest, background_tasks: BackgroundTasks):
    """인스트루멘탈에 AI 보컬 추가."""
    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_add_vocals, task_id, params)
    return {"task_id": task_id, "provider": "suno"}


# ── Phase 3: 인스트루멘탈 추가 ──────────────────────────────────────────────

class AddInstrumentalRequest(BaseModel):
    upload_url: str
    title: str
    tags: str
    negative_tags: str = ""
    vocal_gender: Optional[str] = None
    model: str = "V4_5PLUS"
    style_weight: Optional[float] = None
    audio_weight: Optional[float] = None


@app.post("/api/music/add-instrumental")
def add_instrumental(req: AddInstrumentalRequest, background_tasks: BackgroundTasks):
    """보컬에 AI 반주 추가."""
    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_add_instrumental, task_id, params)
    return {"task_id": task_id, "provider": "suno"}


# ── Phase 3: 뮤직비디오 생성 ────────────────────────────────────────────────

class VideoRequest(BaseModel):
    suno_task_id: str
    suno_id: str
    author: str = ""
    domain_name: str = ""
    track_id: Optional[int] = None


@app.post("/api/music/video")
def video_generate(req: VideoRequest, background_tasks: BackgroundTasks):
    """뮤직비디오(MP4) 생성."""
    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_video_generate, task_id, params)
    return {"task_id": task_id, "provider": "suno"}
  • Step 3: 커밋
git add music-lab/app/suno_provider.py music-lab/app/main.py
git commit -m "feat(music-lab): Phase 3 백엔드 — 업로드커버, 업로드확장, 보컬추가, 인스트추가, 뮤직비디오"

Task 9: 프론트엔드 — Phase 3 UI (RemixTab + 뮤직비디오)

Files:

  • Modify: web-ui/src/api.js

  • Create: web-ui/src/pages/music/components/RemixTab.jsx

  • Modify: web-ui/src/pages/music/MusicStudio.jsx

  • Modify: web-ui/src/pages/music/MusicStudio.css

  • Step 1: api.js Phase 3 함수 추가

// ── Phase 3 API ─────────────────────────────────────────────────────────────

// POST /api/music/upload-cover
export function uploadAndCover(payload) {
  return apiPost('/api/music/upload-cover', payload);
}

// POST /api/music/upload-extend
export function uploadAndExtend(payload) {
  return apiPost('/api/music/upload-extend', payload);
}

// POST /api/music/add-vocals
export function addVocals(payload) {
  return apiPost('/api/music/add-vocals', payload);
}

// POST /api/music/add-instrumental
export function addInstrumental(payload) {
  return apiPost('/api/music/add-instrumental', payload);
}

// POST /api/music/video
export function generateVideo(payload) {
  return apiPost('/api/music/video', payload);
}
  • Step 2: RemixTab 컴포넌트

web-ui/src/pages/music/components/RemixTab.jsx:

import React, { useState } from 'react';
import { uploadAndCover, uploadAndExtend, addVocals, addInstrumental, getMusicStatus } from '../../../api';

const REMIX_ACTIONS = [
    { id: 'cover', label: 'AI Cover', icon: '🎨', desc: '외부 음원을 Suno AI 스타일로 리메이크' },
    { id: 'extend', label: 'Extend', icon: '⏩', desc: '외부 음원을 이어서 확장' },
    { id: 'add-vocals', label: 'Add Vocals', icon: '🎤', desc: '인스트루멘탈에 AI 보컬 입히기' },
    { id: 'add-instrumental', label: 'Add Instrumental', icon: '🎹', desc: '보컬에 AI 반주 입히기' },
];

const RemixTab = ({ onTaskStarted, model, isGenerating }) => {
    const [uploadUrl, setUploadUrl] = useState('');
    const [activeAction, setActiveAction] = useState(null);

    // 각 액션별 파라미터
    const [title, setTitle] = useState('');
    const [style, setStyle] = useState('');
    const [prompt, setPrompt] = useState('');
    const [tags, setTags] = useState('');
    const [negativeTags, setNegativeTags] = useState('');
    const [vocalGender, setVocalGender] = useState(null);
    const [continueAt, setContinueAt] = useState(0);
    const [instrumental, setInstrumental] = useState(false);

    const handleSubmit = async () => {
        if (!uploadUrl || !activeAction || isGenerating) return;

        let apiCall;
        let payload = {};

        switch (activeAction) {
            case 'cover':
                apiCall = uploadAndCover;
                payload = {
                    upload_url: uploadUrl, model, custom_mode: true,
                    instrumental, prompt, style, title,
                    vocal_gender: vocalGender || undefined,
                    negative_tags: negativeTags || undefined,
                };
                break;
            case 'extend':
                apiCall = uploadAndExtend;
                payload = {
                    upload_url: uploadUrl, model,
                    default_param_flag: !!prompt,
                    continue_at: continueAt || undefined,
                    prompt, style, title, instrumental,
                    vocal_gender: vocalGender || undefined,
                    negative_tags: negativeTags || undefined,
                };
                break;
            case 'add-vocals':
                apiCall = addVocals;
                payload = {
                    upload_url: uploadUrl, prompt, title, style,
                    negative_tags: negativeTags,
                    vocal_gender: vocalGender || undefined,
                    model: 'V4_5PLUS',
                };
                break;
            case 'add-instrumental':
                apiCall = addInstrumental;
                payload = {
                    upload_url: uploadUrl, title, tags,
                    negative_tags: negativeTags,
                    vocal_gender: vocalGender || undefined,
                    model: 'V4_5PLUS',
                };
                break;
            default:
                return;
        }

        try {
            const res = await apiCall(payload);
            if (res?.task_id) {
                onTaskStarted(res.task_id, `Remix: ${REMIX_ACTIONS.find(a => a.id === activeAction)?.label}`);
            }
        } catch (e) {
            // 에러는 부모 컴포넌트에서 처리
        }
    };

    return (
        <div className="ms-remix-tab">
            <div className="ms-remix-tab__header">
                <h2 className="ms-remix-tab__title">Remix Studio</h2>
                <p className="ms-remix-tab__desc">외부 음원을 AI로 리메이크, 확장, 보컬/반주 추가</p>
            </div>

            <div className="ms-param-group">
                <label className="ms-param-label">Audio URL</label>
                <input
                    type="url"
                    className="ms-negative-tags__input"
                    placeholder="리믹스할 오디오 파일 URL (예: /media/music/track.mp3)"
                    value={uploadUrl}
                    onChange={(e) => setUploadUrl(e.target.value)}
                    style={{ width: '100%' }}
                />
            </div>

            <div className="ms-remix-actions">
                {REMIX_ACTIONS.map((action) => (
                    <button
                        key={action.id}
                        type="button"
                        className={`ms-remix-card ${activeAction === action.id ? 'is-active' : ''}`}
                        onClick={() => setActiveAction(activeAction === action.id ? null : action.id)}
                    >
                        <span className="ms-remix-card__icon">{action.icon}</span>
                        <span className="ms-remix-card__label">{action.label}</span>
                        <span className="ms-remix-card__desc">{action.desc}</span>
                    </button>
                ))}
            </div>

            {activeAction && (
                <div className="ms-remix-params">
                    {/* 공통 파라미터 */}
                    <div className="ms-param-group">
                        <label className="ms-param-label">Title</label>
                        <input type="text" className="ms-negative-tags__input" value={title}
                            onChange={(e) => setTitle(e.target.value)} placeholder="곡 제목" style={{ width: '100%' }} />
                    </div>

                    {(activeAction === 'cover' || activeAction === 'extend' || activeAction === 'add-vocals') && (
                        <div className="ms-param-group">
                            <label className="ms-param-label">Prompt / Lyrics</label>
                            <textarea className="ms-prompt" value={prompt}
                                onChange={(e) => setPrompt(e.target.value)} rows={3}
                                placeholder="가사 또는 스타일 설명" />
                        </div>
                    )}

                    {(activeAction === 'cover' || activeAction === 'extend' || activeAction === 'add-vocals') && (
                        <div className="ms-param-group">
                            <label className="ms-param-label">Style</label>
                            <input type="text" className="ms-negative-tags__input" value={style}
                                onChange={(e) => setStyle(e.target.value)} placeholder="예: Pop, Energetic, Piano" style={{ width: '100%' }} />
                        </div>
                    )}

                    {activeAction === 'add-instrumental' && (
                        <div className="ms-param-group">
                            <label className="ms-param-label">Tags (스타일/특성)</label>
                            <input type="text" className="ms-negative-tags__input" value={tags}
                                onChange={(e) => setTags(e.target.value)} placeholder="예: acoustic, warm, dreamy" style={{ width: '100%' }} />
                        </div>
                    )}

                    {activeAction === 'extend' && (
                        <div className="ms-param-group">
                            <label className="ms-param-label">Continue At ()</label>
                            <input type="number" className="ms-negative-tags__input" value={continueAt}
                                onChange={(e) => setContinueAt(Number(e.target.value))} min={0} style={{ width: '120px' }} />
                        </div>
                    )}

                    <div className="ms-param-group">
                        <label className="ms-param-label">Exclude Styles</label>
                        <input type="text" className="ms-negative-tags__input" value={negativeTags}
                            onChange={(e) => setNegativeTags(e.target.value)} placeholder="제외할 스타일" style={{ width: '100%' }} />
                    </div>

                    <div className="ms-param-group">
                        <label className="ms-param-label">Vocal Gender</label>
                        <div className="ms-gender-toggle">
                            {[{ value: null, label: 'Auto' }, { value: 'm', label: 'Male' }, { value: 'f', label: 'Female' }].map((opt) => (
                                <button key={opt.label} type="button"
                                    className={`ms-gender-btn ${vocalGender === opt.value ? 'is-active' : ''}`}
                                    onClick={() => setVocalGender(opt.value)}>
                                    {opt.label}
                                </button>
                            ))}
                        </div>
                    </div>

                    <button
                        type="button"
                        className="ms-btn ms-btn--accent ms-remix-submit"
                        disabled={!uploadUrl || isGenerating}
                        onClick={handleSubmit}
                    >
                        {isGenerating ? 'Processing...' : `Start ${REMIX_ACTIONS.find(a => a.id === activeAction)?.label}`}
                    </button>
                </div>
            )}
        </div>
    );
};

export default RemixTab;
  • Step 3: MusicStudio.jsx에 Remix 탭 + Video 액션 통합
import RemixTab from './components/RemixTab';
import { generateVideo } from '../../api';

// 탭 네비게이션에 Remix 추가
<button
    type="button"
    className={`ms-tab ${tab === 'remix' ? 'is-active' : ''}`}
    onClick={() => setTab('remix')}
>
    <span className="ms-tab__icon">🔄</span> Remix
</button>

// 탭 내용에 Remix 추가
{tab === 'remix' && (
    <RemixTab
        onTaskStarted={(taskId, title) => {
            setTab('create');
            setIsGenerating(true);
            setTrack(null);
            setGenProgress(0);
            setGenStep(`${title} 처리 중…`);
            setGenError(null);
            taskIdRef.current = taskId;
            startPolling(taskId, title);
        }}
        model={model}
        isGenerating={isGenerating}
    />
)}

// LibraryCard 더보기 메뉴에 Video 추가
<button type="button" onClick={() => { onVideoGenerate(track); setMenuOpen(false); }}
    disabled={isGenerating}>
    🎬 Music Video
</button>

Video 핸들러:

const handleVideoGenerate = async (track) => {
    if (!track.task_id || !track.suno_id || isGenerating) return;
    setTab('create');
    setIsGenerating(true);
    setTrack(null);
    setGenProgress(0);
    setGenStep('뮤직비디오 생성 요청 중…');
    setGenError(null);
    try {
        const res = await generateVideo({
            suno_task_id: track.task_id,
            suno_id: track.suno_id,
            track_id: track.id,
        });
        if (res?.task_id) {
            taskIdRef.current = res.task_id;
            startPolling(res.task_id, `${track.title} (Video)`);
        }
    } catch {
        setIsGenerating(false);
        setGenError('뮤직비디오 생성에 실패했습니다');
    }
};
  • Step 4: Phase 3 CSS
/* ── Remix Tab ──────────────────────────────────────────── */
.ms-remix-tab { display: flex; flex-direction: column; gap: 20px; }
.ms-remix-tab__header { }
.ms-remix-tab__title { font-family: 'Bebas Neue', sans-serif; font-size: 1.8rem; color: var(--ms-text); }
.ms-remix-tab__desc { font-size: 0.85rem; color: var(--ms-muted); }

.ms-remix-actions { display: grid; grid-template-columns: 1fr 1fr; gap: 10px; }
.ms-remix-card {
    display: flex; flex-direction: column; align-items: center; gap: 6px;
    padding: 20px 12px; border-radius: 12px; cursor: pointer;
    background: var(--ms-surface); border: 1px solid var(--ms-line);
    transition: all 0.2s; text-align: center;
}
.ms-remix-card:hover { border-color: var(--ms-accent); background: var(--ms-surface2); }
.ms-remix-card.is-active { border-color: var(--ms-accent); background: rgba(245,166,35,0.08); }
.ms-remix-card__icon { font-size: 2rem; }
.ms-remix-card__label { font-family: 'Bebas Neue', sans-serif; font-size: 1.1rem; color: var(--ms-text); }
.ms-remix-card__desc { font-size: 0.72rem; color: var(--ms-muted); font-family: 'Courier Prime', monospace; }

.ms-remix-params {
    display: flex; flex-direction: column; gap: 12px;
    padding: 16px; border-radius: 12px;
    background: var(--ms-surface); border: 1px solid var(--ms-line);
}
.ms-remix-submit { align-self: flex-start; margin-top: 8px; }
  • Step 5: 커밋
git add web-ui/src/pages/music/ web-ui/src/api.js
git commit -m "feat(music-lab): Phase 3 UI — RemixTab + 뮤직비디오 생성"

Task 10: 최종 통합 검증 + CLAUDE.md 업데이트

Files:

  • Modify: web-backend/CLAUDE.md

  • Step 1: CLAUDE.md music-lab API 목록 업데이트

web-backend/CLAUDE.md의 music-lab API 목록 테이블을 업데이트:

**music-lab API 목록**

| 메서드 | 경로 | 설명 |
|--------|------|------|
| GET | `/api/music/providers` | 사용 가능한 프로바이더 목록 |
| GET | `/api/music/models` | Suno 모델 목록 (V4~V5.5) |
| GET | `/api/music/credits` | Suno 크레딧 조회 |
| POST | `/api/music/generate` | 음악 생성 (provider, model, vocal_gender, negative_tags, style_weight, audio_weight) |
| GET | `/api/music/status/{task_id}` | 생성 상태 폴링 |
| POST | `/api/music/lyrics` | Suno AI 가사 생성 |
| GET | `/api/music/library` | 라이브러리 전체 조회 |
| POST | `/api/music/library` | 트랙 수동 추가 |
| DELETE | `/api/music/library/{id}` | 트랙 삭제 |
| POST | `/api/music/extend` | 곡 연장 |
| POST | `/api/music/vocal-removal` | 보컬/인스트 분리 (2트랙) |
| POST | `/api/music/cover-image` | 커버 이미지 2장 생성 |
| POST | `/api/music/wav` | WAV 고음질 변환 |
| POST | `/api/music/stem-split` | 12스템 분리 (50cr) |
| GET | `/api/music/timestamped-lyrics` | 타임스탬프 가사 (가라오케) |
| POST | `/api/music/style-boost` | AI 스타일 프롬프트 생성 |
| POST | `/api/music/upload-cover` | 외부 음원 AI Cover |
| POST | `/api/music/upload-extend` | 외부 음원 확장 |
| POST | `/api/music/add-vocals` | 인스트에 AI 보컬 추가 |
| POST | `/api/music/add-instrumental` | 보컬에 AI 반주 추가 |
| POST | `/api/music/video` | 뮤직비디오 MP4 생성 |
| GET | `/api/music/lyrics/library` | 저장된 가사 목록 |
| POST | `/api/music/lyrics/library` | 가사 저장 |
| PUT | `/api/music/lyrics/library/{id}` | 가사 수정 |
| DELETE | `/api/music/lyrics/library/{id}` | 가사 삭제 |
  • Step 2: 전체 빌드 확인
cd web-ui && npm run build

빌드 에러가 있으면 수정.

  • Step 3: 커밋
git add web-backend/CLAUDE.md
git commit -m "docs: music-lab API 목록 업데이트 — Phase 1~3 신규 엔드포인트 반영"