10개 Task, 3 Phase 구조의 상세 구현 계획. Phase 1(생성 강화), Phase 2(후처리), Phase 3(리믹스). Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
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_extend와 run_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.py의 init_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.py의 GenerateRequest 클래스(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 신규 엔드포인트 반영"