"""⑤ bgm — Suno V5 인스트루멘털 BGM. 보컬이 있으면 나레이션과 주파수·의미가 겹쳐 둘 다 안 들리므로 반드시 instrumental로 뽑고, 목표 길이는 나레이션 실측 길이에서 역산한다. """ import asyncio import httpx from answers.narration_answer import BgmStyle from models.bgm import BgmAudio from settings import settings SUNO_API = "https://api.sunoapi.org/api/v1" SUNO_MODEL = "V5_5" # duration 파라미터가 V5_5 + customMode에서만 먹는다 POLL_INTERVAL = 15 POLL_ATTEMPTS = 20 MIN_SECONDS, MAX_SECONDS = 10, 30 # duration 허용 범위 하한이 10초다 SECONDS_MARGIN = 2 def target_seconds(narration_total: float) -> int: return int(min(MAX_SECONDS, max(MIN_SECONDS, round(narration_total) + SECONDS_MARGIN))) async def request_generation(client: httpx.AsyncClient, style: BgmStyle, seconds: int, title: str) -> str: response = await client.post( f"{SUNO_API}/generate", json={ "model": SUNO_MODEL, "customMode": True, "instrumental": True, "duration": seconds, "prompt": style.prompt, "style": style.style, "title": title, "callBackUrl": settings.suno_callback_url, }) response.raise_for_status() task_id = (response.json().get("data") or {}).get("taskId", "") if not task_id: raise RuntimeError(f"Suno가 taskId를 안 줬다: {response.text[:300]}") return task_id async def wait_for_tracks(client: httpx.AsyncClient, task_id: str) -> list[dict]: for _ in range(POLL_ATTEMPTS): await asyncio.sleep(POLL_INTERVAL) response = await client.get(f"{SUNO_API}/generate/record-info", params={"taskId": task_id}) response.raise_for_status() data = response.json().get("data") or {} status = data.get("status", "") if status == "SUCCESS": tracks = (data.get("response") or {}).get("sunoData", []) if not tracks: raise RuntimeError(f"트랙 없음 (taskId={task_id})") return tracks if "FAIL" in status or "ERROR" in status: raise RuntimeError(f"Suno 생성 실패: {status} (taskId={task_id})") raise RuntimeError(f"Suno 타임아웃 {POLL_INTERVAL * POLL_ATTEMPTS}초 (taskId={task_id})") async def generate_bgm(style: BgmStyle, narration_total: float, title: str = "festival bgm") -> BgmAudio: if not settings.suno_api_key: raise RuntimeError("SUNO_API_KEY 없음") seconds = target_seconds(narration_total) async with httpx.AsyncClient( timeout=180, headers={"Authorization": f"Bearer {settings.suno_api_key}"}) as client: task_id = await request_generation(client, style, seconds, title) tracks = await wait_for_tracks(client, task_id) # 목표 길이에 가장 가까운 트랙 — 가장 긴 것이 아니다 track = min(tracks, key=lambda t: abs((t.get("duration") or 0) - seconds)) download = await client.get(track["audioUrl"], follow_redirects=True) download.raise_for_status() return BgmAudio(mp3=download.content, seconds=seconds, duration=float(track.get("duration") or 0), task_id=task_id)