playreel/backend/services/bgm.py

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)