feat(music-lab): Phase 2 백엔드 — WAV 변환, 12스템 분리, 타임스탬프 가사, 스타일 부스트
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -15,8 +15,8 @@ from .db import (
|
|||||||
from .local_provider import run_local_generation
|
from .local_provider import run_local_generation
|
||||||
from .suno_provider import (
|
from .suno_provider import (
|
||||||
run_suno_generation, run_suno_extend, run_vocal_removal,
|
run_suno_generation, run_suno_extend, run_vocal_removal,
|
||||||
run_cover_image,
|
run_cover_image, run_wav_convert, run_stem_split,
|
||||||
generate_lyrics, get_credits,
|
generate_lyrics, get_credits, get_timestamped_lyrics, generate_style_boost,
|
||||||
SUNO_API_KEY, SUNO_MODELS,
|
SUNO_API_KEY, SUNO_MODELS,
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -428,6 +428,76 @@ def cover_image(req: CoverImageRequest, background_tasks: BackgroundTasks):
|
|||||||
return {"task_id": task_id, "provider": "suno"}
|
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 ────────────────────────────────────────────────────
|
# ── 저장된 가사 CRUD API ────────────────────────────────────────────────────
|
||||||
|
|
||||||
class LyricsSave(BaseModel):
|
class LyricsSave(BaseModel):
|
||||||
|
|||||||
@@ -584,3 +584,176 @@ def run_cover_image(task_id: str, params: dict) -> None:
|
|||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.exception("Cover image generation error for task %s", task_id)
|
logger.exception("Cover image generation error for task %s", task_id)
|
||||||
update_task(task_id, "failed", 0, "", error=str(e))
|
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