From 94969f97a86a2b4360767a3f54cac92366f46b82 Mon Sep 17 00:00:00 2001 From: gahusb Date: Wed, 8 Apr 2026 08:58:37 +0900 Subject: [PATCH] =?UTF-8?q?feat(music-lab):=20Phase=202=20=EB=B0=B1?= =?UTF-8?q?=EC=97=94=EB=93=9C=20=E2=80=94=20WAV=20=EB=B3=80=ED=99=98,=2012?= =?UTF-8?q?=EC=8A=A4=ED=85=9C=20=EB=B6=84=EB=A6=AC,=20=ED=83=80=EC=9E=84?= =?UTF-8?q?=EC=8A=A4=ED=83=AC=ED=94=84=20=EA=B0=80=EC=82=AC,=20=EC=8A=A4?= =?UTF-8?q?=ED=83=80=EC=9D=BC=20=EB=B6=80=EC=8A=A4=ED=8A=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.6 --- music-lab/app/main.py | 74 +++++++++++++- music-lab/app/suno_provider.py | 173 +++++++++++++++++++++++++++++++++ 2 files changed, 245 insertions(+), 2 deletions(-) diff --git a/music-lab/app/main.py b/music-lab/app/main.py index 47de044..438d8a9 100644 --- a/music-lab/app/main.py +++ b/music-lab/app/main.py @@ -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): diff --git a/music-lab/app/suno_provider.py b/music-lab/app/suno_provider.py index a3abb57..f3e3d99 100644 --- a/music-lab/app/suno_provider.py +++ b/music-lab/app/suno_provider.py @@ -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