feat(music-lab): Phase 2 백엔드 — WAV 변환, 12스템 분리, 타임스탬프 가사, 스타일 부스트
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user