93 lines
3.8 KiB
Python
93 lines
3.8 KiB
Python
"""⑤ 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
|
|
|
|
|
|
# 나레이션을 VLM이 쓰지 않는 흐름(Playreel)은 스타일도 따로 안 나오므로 이 값을 쓴다.
|
|
# 잔잔한 앰비언트는 광고에서 늘어진다.
|
|
DEFAULT_BGM_STYLE = BgmStyle(
|
|
style=("modern korean fusion gugak, gayageum and haegeum hooks over driving "
|
|
"janggu percussion and punchy modern beat, bright festival energy, "
|
|
"128 bpm, instrumental"),
|
|
prompt=("Starts hard on beat one - no intro ramp. Bright festival energy, "
|
|
"driving groove throughout, brief lift at two thirds, clean ending."),
|
|
)
|
|
|
|
|
|
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)
|