feat(music-lab): Phase 2 백엔드 — WAV 변환, 12스템 분리, 타임스탬프 가사, 스타일 부스트

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-04-08 08:58:37 +09:00
parent 3e46cc41ca
commit 94969f97a8
2 changed files with 245 additions and 2 deletions

View File

@@ -15,8 +15,8 @@ from .db import (
from .local_provider import run_local_generation
from .suno_provider import (
run_suno_generation, run_suno_extend, run_vocal_removal,
run_cover_image,
generate_lyrics, get_credits,
run_cover_image, run_wav_convert, run_stem_split,
generate_lyrics, get_credits, get_timestamped_lyrics, generate_style_boost,
SUNO_API_KEY, SUNO_MODELS,
)
@@ -428,6 +428,76 @@ def cover_image(req: CoverImageRequest, background_tasks: BackgroundTasks):
return {"task_id": task_id, "provider": "suno"}
# ── 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
# ── 저장된 가사 CRUD API ────────────────────────────────────────────────────
class LyricsSave(BaseModel):

View File

@@ -584,3 +584,176 @@ def run_cover_image(task_id: str, params: dict) -> None:
except Exception as e:
logger.exception("Cover image generation error for task %s", task_id)
update_task(task_id, "failed", 0, "", error=str(e))
# ── 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:
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