music-lab: Suno API + MusicGen 듀얼 프로바이더 구조 구현

- suno_provider.py: Suno REST API 클라이언트 (곡 생성, 가사, 2변형 저장)
- local_provider.py: 기존 MusicGen 로직 분리
- main.py: provider 라우팅, /providers·/lyrics 엔드포인트 추가
- db.py: provider, lyrics, image_url, suno_id 컬럼 마이그레이션
- docker-compose.yml: SUNO_API_KEY 환경변수 추가

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-04-03 08:23:29 +09:00
parent 9ac142e1de
commit f5c58a5aa5
7 changed files with 522 additions and 125 deletions

View File

@@ -57,5 +57,12 @@ ADMIN_API_KEY=
# Anthropic API Key (AI Coach 프록시, 미설정 시 AI Coach 비활성화) # Anthropic API Key (AI Coach 프록시, 미설정 시 AI Coach 비활성화)
ANTHROPIC_API_KEY= ANTHROPIC_API_KEY=
# [MUSIC LAB]
# Suno API Key (https://suno.com 에서 발급, 미설정 시 Suno provider 비활성화)
SUNO_API_KEY=
# 로컬 MusicGen AI Server URL (미설정 시 Local provider 비활성화)
# MUSIC_AI_SERVER_URL=http://192.168.45.59:8765
# CORS 허용 도메인 (콤마 구분) # CORS 허용 도메인 (콤마 구분)
CORS_ALLOW_ORIGINS=https://gahusb.synology.me,http://localhost:3007,http://localhost:8080 CORS_ALLOW_ORIGINS=https://gahusb.synology.me,http://localhost:3007,http://localhost:8080

View File

@@ -225,30 +225,44 @@ docker compose up -d
- 15:40 평일 — 총 자산 스냅샷 저장 (`save_daily_snapshot`) - 15:40 평일 — 총 자산 스냅샷 저장 (`save_daily_snapshot`)
### music-lab (music-lab/) ### music-lab (music-lab/)
- AI 음악 생성 서비스. Windows AI 서버(`MUSIC_AI_SERVER_URL`)에 생성 요청 프록시 - 듀얼 프로바이더 음악 생성 서비스 (Suno API + 로컬 MusicGen)
- 생성된 오디오 파일: `/app/data/music/` (Nginx가 `/media/music/`로 직접 서빙) - 생성된 오디오 파일: `/app/data/music/` (Nginx가 `/media/music/`로 직접 서빙)
- DB: `/app/data/music.db` (music_tasks, music_library 테이블) - DB: `/app/data/music.db` (music_tasks, music_library 테이블)
- 파일 구조: `main.py`, `db.py` - 파일 구조: `main.py`, `db.py`, `suno_provider.py`, `local_provider.py`
- 생성 흐름: POST generate → task_id 반환 → BackgroundTask가 AI 서버 호출 → 파일 저장 → 라이브러리 자동 등록 - 생성 흐름: POST generate (provider 지정) → task_id 반환 → BackgroundTask → 파일 저장 → 라이브러리 자동 등록
**Provider 구조**
- `suno`: Suno REST API (`apicast.suno.ai/v1`) — 보컬·가사·인스트루멘탈 지원
- `local`: Windows AI 서버 (MusicGen) — 인스트루멘탈 전용
**music-lab API 목록** **music-lab API 목록**
| 메서드 | 경로 | 설명 | | 메서드 | 경로 | 설명 |
|--------|------|------| |--------|------|------|
| POST | `/api/music/generate` | 음악 생성 시작 (task_id 반환, 비동기) | | GET | `/api/music/providers` | 사용 가능한 프로바이더 목록 |
| POST | `/api/music/generate` | 음악 생성 시작 (provider, lyrics, instrumental 지원) |
| GET | `/api/music/status/{task_id}` | 생성 상태 폴링 (queued→processing→succeeded/failed) | | GET | `/api/music/status/{task_id}` | 생성 상태 폴링 (queued→processing→succeeded/failed) |
| POST | `/api/music/lyrics` | Suno AI 가사 생성 (곡 생성 전 미리보기용) |
| GET | `/api/music/library` | 라이브러리 전체 조회 | | GET | `/api/music/library` | 라이브러리 전체 조회 |
| POST | `/api/music/library` | 트랙 수동 추가 (201) | | POST | `/api/music/library` | 트랙 수동 추가 (201) |
| DELETE | `/api/music/library/{id}` | 트랙 삭제 (로컬 파일 포함) | | DELETE | `/api/music/library/{id}` | 트랙 삭제 (로컬 파일 포함) |
**환경변수** **환경변수**
- `MUSIC_AI_SERVER_URL`: AI 음악 생성 서버 URL (미설정 시 생성 요청 실패) - `SUNO_API_KEY`: Suno API 키 (미설정 시 Suno provider 비활성화)
- `MUSIC_AI_SERVER_URL`: 로컬 MusicGen 서버 URL (미설정 시 local provider 비활성화)
- `MUSIC_MEDIA_BASE`: 오디오 파일 공개 URL prefix (기본 `/media/music`) - `MUSIC_MEDIA_BASE`: 오디오 파일 공개 URL prefix (기본 `/media/music`)
- `MUSIC_DATA_PATH`: NAS 오디오 파일 저장 경로 (기본 `./data/music`) - `MUSIC_DATA_PATH`: NAS 오디오 파일 저장 경로 (기본 `./data/music`)
**AI 서버 응답 형식 (2가지 모두 지원)** **music_library 테이블 (확장 컬럼)**
- binary audio (Content-Type: audio/*) → 직접 저장 - `provider`: `suno` | `local` — 생성에 사용된 프로바이더
- JSON `{"audio_url": "..."}` → 해당 URL에서 다운로드 후 저장 - `lyrics`: Suno 생성 가사 텍스트
- `image_url`: Suno 생성 커버 이미지 URL
- `suno_id`: Suno 곡 ID (CDN 참조용)
**Suno 생성 특이사항**
- 1회 생성 시 2개 변형(variation) 반환 → 둘 다 라이브러리에 저장
- CDN URL(`cdn1.suno.ai`)은 임시 → 반드시 로컬 다운로드 필요
- 가사 섹션 태그: `[Verse]`, `[Chorus]`, `[Bridge]`, `[Instrumental]`
### travel-proxy (travel-proxy/) ### travel-proxy (travel-proxy/)
- 원본 사진: `/data/travel/` (RO) - 원본 사진: `/data/travel/` (RO)

View File

@@ -57,6 +57,7 @@ services:
environment: environment:
- TZ=${TZ:-Asia/Seoul} - TZ=${TZ:-Asia/Seoul}
- MUSIC_AI_SERVER_URL=${MUSIC_AI_SERVER_URL:-} - MUSIC_AI_SERVER_URL=${MUSIC_AI_SERVER_URL:-}
- SUNO_API_KEY=${SUNO_API_KEY:-}
- MUSIC_MEDIA_BASE=${MUSIC_MEDIA_BASE:-/media/music} - MUSIC_MEDIA_BASE=${MUSIC_MEDIA_BASE:-/media/music}
- CORS_ALLOW_ORIGINS=${CORS_ALLOW_ORIGINS:-http://localhost:3007,http://localhost:8080} - CORS_ALLOW_ORIGINS=${CORS_ALLOW_ORIGINS:-http://localhost:3007,http://localhost:8080}
volumes: volumes:

View File

@@ -24,6 +24,7 @@ def init_db() -> None:
audio_url TEXT, audio_url TEXT,
error TEXT, error TEXT,
params TEXT NOT NULL DEFAULT '{}', params TEXT NOT NULL DEFAULT '{}',
provider TEXT NOT NULL DEFAULT 'local',
created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now')), created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now')),
updated_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now')) updated_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now'))
) )
@@ -46,11 +47,29 @@ def init_db() -> None:
file_path TEXT NOT NULL DEFAULT '', file_path TEXT NOT NULL DEFAULT '',
task_id TEXT, task_id TEXT,
tags TEXT NOT NULL DEFAULT '[]', tags TEXT NOT NULL DEFAULT '[]',
provider TEXT NOT NULL DEFAULT 'local',
lyrics TEXT NOT NULL DEFAULT '',
image_url TEXT NOT NULL DEFAULT '',
suno_id TEXT NOT NULL DEFAULT '',
created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now')) created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now'))
) )
""") """)
conn.execute("CREATE INDEX IF NOT EXISTS idx_library_created ON music_library(created_at DESC)") conn.execute("CREATE INDEX IF NOT EXISTS idx_library_created ON music_library(created_at DESC)")
# 기존 테이블 마이그레이션 (컬럼 없으면 추가)
for col, default in [
("provider", "'local'"), ("lyrics", "''"),
("image_url", "''"), ("suno_id", "''"),
]:
try:
conn.execute(f"ALTER TABLE music_library ADD COLUMN {col} TEXT NOT NULL DEFAULT {default}")
except sqlite3.OperationalError:
pass # 이미 존재
try:
conn.execute("ALTER TABLE music_tasks ADD COLUMN provider TEXT NOT NULL DEFAULT 'local'")
except sqlite3.OperationalError:
pass
# ── music_tasks CRUD ────────────────────────────────────────────────────────── # ── music_tasks CRUD ──────────────────────────────────────────────────────────
@@ -63,16 +82,17 @@ def _task_row_to_dict(r) -> Dict[str, Any]:
"audio_url": r["audio_url"], "audio_url": r["audio_url"],
"error": r["error"], "error": r["error"],
"params": json.loads(r["params"]), "params": json.loads(r["params"]),
"provider": r["provider"] if "provider" in r.keys() else "local",
"created_at": r["created_at"], "created_at": r["created_at"],
"updated_at": r["updated_at"], "updated_at": r["updated_at"],
} }
def create_task(task_id: str, params: Dict[str, Any]) -> Dict[str, Any]: def create_task(task_id: str, params: Dict[str, Any], provider: str = "local") -> Dict[str, Any]:
with _conn() as conn: with _conn() as conn:
conn.execute( conn.execute(
"INSERT INTO music_tasks (id, params) VALUES (?, ?)", "INSERT INTO music_tasks (id, params, provider) VALUES (?, ?, ?)",
(task_id, json.dumps(params)), (task_id, json.dumps(params), provider),
) )
row = conn.execute("SELECT * FROM music_tasks WHERE id = ?", (task_id,)).fetchone() row = conn.execute("SELECT * FROM music_tasks WHERE id = ?", (task_id,)).fetchone()
return _task_row_to_dict(row) return _task_row_to_dict(row)
@@ -107,6 +127,7 @@ def get_task(task_id: str) -> Optional[Dict[str, Any]]:
# ── music_library CRUD ──────────────────────────────────────────────────────── # ── music_library CRUD ────────────────────────────────────────────────────────
def _track_row_to_dict(r) -> Dict[str, Any]: def _track_row_to_dict(r) -> Dict[str, Any]:
keys = r.keys()
return { return {
"id": r["id"], "id": r["id"],
"title": r["title"], "title": r["title"],
@@ -122,6 +143,10 @@ def _track_row_to_dict(r) -> Dict[str, Any]:
"file_path": r["file_path"], "file_path": r["file_path"],
"task_id": r["task_id"], "task_id": r["task_id"],
"tags": json.loads(r["tags"]) if r["tags"] else [], "tags": json.loads(r["tags"]) if r["tags"] else [],
"provider": r["provider"] if "provider" in keys else "local",
"lyrics": r["lyrics"] if "lyrics" in keys else "",
"image_url": r["image_url"] if "image_url" in keys else "",
"suno_id": r["suno_id"] if "suno_id" in keys else "",
"created_at": r["created_at"], "created_at": r["created_at"],
} }
@@ -138,8 +163,9 @@ def add_track(data: Dict[str, Any]) -> Dict[str, Any]:
""" """
INSERT INTO music_library INSERT INTO music_library
(title, genre, moods, instruments, duration_sec, bpm, key, scale, (title, genre, moods, instruments, duration_sec, bpm, key, scale,
prompt, audio_url, file_path, task_id, tags) prompt, audio_url, file_path, task_id, tags,
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) provider, lyrics, image_url, suno_id)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
""", """,
( (
data.get("title", ""), data.get("title", ""),
@@ -155,6 +181,10 @@ def add_track(data: Dict[str, Any]) -> Dict[str, Any]:
data.get("file_path", ""), data.get("file_path", ""),
data.get("task_id"), data.get("task_id"),
json.dumps(data.get("tags", [])), json.dumps(data.get("tags", [])),
data.get("provider", "local"),
data.get("lyrics", ""),
data.get("image_url", ""),
data.get("suno_id", ""),
), ),
) )
row = conn.execute("SELECT * FROM music_library WHERE rowid = last_insert_rowid()").fetchone() row = conn.execute("SELECT * FROM music_library WHERE rowid = last_insert_rowid()").fetchone()

View File

@@ -0,0 +1,122 @@
"""
Local MusicGen Provider — Windows AI 서버(MusicGen)를 통한 음악 생성
기존 _run_generation 로직을 그대로 분리.
"""
import os
import time
import logging
import requests
from .db import update_task, add_track
logger = logging.getLogger(__name__)
MUSIC_AI_SERVER_URL = os.getenv("MUSIC_AI_SERVER_URL", "")
MUSIC_DATA_DIR = "/app/data/music"
MUSIC_MEDIA_BASE = os.getenv("MUSIC_MEDIA_BASE", "/media/music")
def run_local_generation(task_id: str, params: dict) -> None:
"""BackgroundTask: Windows AI 서버(MusicGen)에 생성 요청 → 파일 저장 → 라이브러리 등록"""
try:
update_task(task_id, "processing", 10, "AI 서버에 연결 중...")
if not MUSIC_AI_SERVER_URL:
update_task(task_id, "failed", 0, "",
error="MUSIC_AI_SERVER_URL이 설정되지 않았습니다")
return
update_task(task_id, "processing", 30, "음악 생성 중... (수 분 소요될 수 있습니다)")
# 1단계: 생성 요청 → ai_task_id 반환
resp = requests.post(
f"{MUSIC_AI_SERVER_URL}/generate",
json=params,
timeout=30,
)
if resp.status_code != 200:
update_task(task_id, "failed", 0, "",
error=f"AI 서버 오류: {resp.status_code} {resp.text[:200]}")
return
ai_task_id = resp.json().get("task_id")
if not ai_task_id:
update_task(task_id, "failed", 0, "",
error="AI 서버 응답에 task_id가 없습니다")
return
# 2단계: 상태 폴링 (최대 10분, 5초 간격)
remote_url = None
for _ in range(120):
time.sleep(5)
status_resp = requests.get(
f"{MUSIC_AI_SERVER_URL}/status/{ai_task_id}", timeout=10,
)
status_data = status_resp.json()
ai_status = status_data.get("status")
ai_progress = status_data.get("progress", 0)
ai_message = status_data.get("message", "음악 생성 중...")
scaled = 30 + int(ai_progress * 0.49) # 30% ~ 79%
update_task(task_id, "processing", scaled, ai_message)
if ai_status == "succeeded":
remote_url = status_data.get("audio_url")
break
elif ai_status == "failed":
update_task(task_id, "failed", 0, "",
error=status_data.get("error", "AI 서버 생성 실패"))
return
if not remote_url:
update_task(task_id, "failed", 0, "",
error="AI 서버 타임아웃 (10분 초과)")
return
update_task(task_id, "processing", 80, "파일 저장 중...")
filename = f"{task_id}.mp3"
file_path = os.path.join(MUSIC_DATA_DIR, filename)
# 3단계: 오디오 파일 다운로드
dl = requests.get(remote_url, timeout=120, stream=True)
with open(file_path, "wb") as f:
for chunk in dl.iter_content(chunk_size=8192):
f.write(chunk)
audio_url = f"{MUSIC_MEDIA_BASE}/{filename}"
# 라이브러리 자동 등록
genre = params.get("genre", "")
moods = params.get("moods", [])
mood_str = moods[0] if moods else "Original"
title = params.get("title") or (
f"{genre}{mood_str} Mix" if genre else f"{mood_str} Mix"
)
add_track({
"title": title,
"genre": genre,
"moods": params.get("moods", []),
"instruments": params.get("instruments", []),
"duration_sec": params.get("duration_sec"),
"bpm": params.get("bpm"),
"key": params.get("key", ""),
"scale": params.get("scale", ""),
"prompt": params.get("prompt", ""),
"audio_url": audio_url,
"file_path": file_path,
"task_id": task_id,
"provider": "local",
})
update_task(task_id, "succeeded", 100, "생성 완료", audio_url=audio_url)
except requests.Timeout:
update_task(task_id, "failed", 0, "",
error="AI 서버 타임아웃 (10분 초과)")
except Exception as e:
logger.exception("Local generation error for task %s", task_id)
update_task(task_id, "failed", 0, "", error=str(e))

View File

@@ -1,18 +1,17 @@
import os import os
import time
import uuid import uuid
import requests
from typing import List, Optional from typing import List, Optional
from fastapi import FastAPI, HTTPException, BackgroundTasks from fastapi import FastAPI, HTTPException, BackgroundTasks
from fastapi.middleware.cors import CORSMiddleware from fastapi.middleware.cors import CORSMiddleware
from fastapi.responses import JSONResponse
from pydantic import BaseModel from pydantic import BaseModel
from .db import ( from .db import (
init_db, init_db,
create_task, update_task, get_task, create_task, get_task,
get_all_tracks, add_track, delete_track, get_track_file_path, get_track_by_task_id, get_all_tracks, add_track, delete_track, get_track_file_path, get_track_by_task_id,
) )
from .local_provider import run_local_generation
from .suno_provider import run_suno_generation, generate_lyrics, SUNO_API_KEY
app = FastAPI() app = FastAPI()
@@ -25,9 +24,7 @@ app.add_middleware(
allow_headers=["Content-Type"], allow_headers=["Content-Type"],
) )
MUSIC_AI_SERVER_URL = os.getenv("MUSIC_AI_SERVER_URL", "")
MUSIC_DATA_DIR = "/app/data/music" MUSIC_DATA_DIR = "/app/data/music"
MUSIC_MEDIA_BASE = os.getenv("MUSIC_MEDIA_BASE", "/media/music")
@app.on_event("startup") @app.on_event("startup")
@@ -41,106 +38,31 @@ def health():
return {"ok": True} return {"ok": True}
# ── 음악 생성 워커 ──────────────────────────────────────────────────────────── @app.get("/api/music/providers")
def get_providers():
def _run_generation(task_id: str, params: dict) -> None: """사용 가능한 음악 생성 프로바이더 목록 반환."""
"""BackgroundTask: AI 서버에 생성 요청 → 파일 저장 → 라이브러리 등록""" providers = []
try: if os.getenv("MUSIC_AI_SERVER_URL"):
update_task(task_id, "processing", 10, "AI 서버에 연결 중...") providers.append({
"id": "local",
if not MUSIC_AI_SERVER_URL: "name": "MusicGen",
update_task(task_id, "failed", 0, "", error="MUSIC_AI_SERVER_URL이 설정되지 않았습니다") "description": "로컬 AI 서버 (인스트루멘탈 전용)",
return "features": ["instrumental"],
update_task(task_id, "processing", 30, "음악 생성 중... (수 분 소요될 수 있습니다)")
# 1단계: 생성 요청 → ai_task_id 반환
resp = requests.post(
f"{MUSIC_AI_SERVER_URL}/generate",
json=params,
timeout=30,
)
if resp.status_code != 200:
update_task(task_id, "failed", 0, "", error=f"AI 서버 오류: {resp.status_code} {resp.text[:200]}")
return
ai_task_id = resp.json().get("task_id")
if not ai_task_id:
update_task(task_id, "failed", 0, "", error="AI 서버 응답에 task_id가 없습니다")
return
# 2단계: 상태 폴링 (최대 10분, 5초 간격) — AI 서버 progress/message 그대로 반영
remote_url = None
for _ in range(120):
time.sleep(5)
status_resp = requests.get(f"{MUSIC_AI_SERVER_URL}/status/{ai_task_id}", timeout=10)
status_data = status_resp.json()
ai_status = status_data.get("status")
# AI 서버의 progress/message를 로컬 task에 전달 (30~79 범위로 스케일)
ai_progress = status_data.get("progress", 0)
ai_message = status_data.get("message", "음악 생성 중...")
scaled = 30 + int(ai_progress * 0.49) # 30% ~ 79%
update_task(task_id, "processing", scaled, ai_message)
if ai_status == "succeeded":
remote_url = status_data.get("audio_url")
break
elif ai_status == "failed":
update_task(task_id, "failed", 0, "", error=status_data.get("error", "AI 서버 생성 실패"))
return
if not remote_url:
update_task(task_id, "failed", 0, "", error="AI 서버 타임아웃 (10분 초과)")
return
update_task(task_id, "processing", 80, "파일 저장 중...")
filename = f"{task_id}.mp3"
file_path = os.path.join(MUSIC_DATA_DIR, filename)
# 3단계: 오디오 파일 다운로드
dl = requests.get(remote_url, timeout=120, stream=True)
with open(file_path, "wb") as f:
for chunk in dl.iter_content(chunk_size=8192):
f.write(chunk)
# audio_url은 항상 Nginx 상대경로 (Mixed Content 방지)
audio_url = f"/media/music/{filename}"
# 라이브러리 자동 등록 — payload title 우선, 없으면 자동 생성
genre = params.get("genre", "")
moods = params.get("moods", [])
mood_str = moods[0] if moods else "Original"
title = params.get("title") or (f"{genre}{mood_str} Mix" if genre else f"{mood_str} Mix")
add_track({
"title": title,
"genre": genre,
"moods": params.get("moods", []),
"instruments": params.get("instruments", []),
"duration_sec": params.get("duration_sec"),
"bpm": params.get("bpm"),
"key": params.get("key", ""),
"scale": params.get("scale", ""),
"prompt": params.get("prompt", ""),
"audio_url": audio_url,
"file_path": file_path,
"task_id": task_id,
}) })
if SUNO_API_KEY:
update_task(task_id, "succeeded", 100, "생성 완료", audio_url=audio_url) providers.append({
"id": "suno",
except requests.Timeout: "name": "Suno",
update_task(task_id, "failed", 0, "", error="AI 서버 타임아웃 (10분 초과)") "description": "Suno AI (보컬·가사·인스트루멘탈)",
except Exception as e: "features": ["vocals", "lyrics", "instrumental"],
update_task(task_id, "failed", 0, "", error=str(e)) })
return {"providers": providers}
# ── 음악 생성 API ───────────────────────────────────────────────────────────── # ── 음악 생성 API ─────────────────────────────────────────────────────────────
class GenerateRequest(BaseModel): class GenerateRequest(BaseModel):
provider: str = "suno" # "suno" | "local"
title: str = "" title: str = ""
genre: str = "" genre: str = ""
moods: List[str] = [] moods: List[str] = []
@@ -150,19 +72,35 @@ class GenerateRequest(BaseModel):
key: str = "" key: str = ""
scale: str = "" scale: str = ""
prompt: str = "" prompt: str = ""
# Suno 전용
lyrics: str = "" # 커스텀 가사 ([Verse], [Chorus] 등)
instrumental: bool = False # True면 보컬 없이 인스트루멘탈만
@app.post("/api/music/generate") @app.post("/api/music/generate")
def generate_music(req: GenerateRequest, background_tasks: BackgroundTasks): def generate_music(req: GenerateRequest, background_tasks: BackgroundTasks):
""" """
음악 생성 작업 시작. task_id 즉시 반환 후 백그라운드에서 AI 서버 호출. 음악 생성 작업 시작. task_id 즉시 반환 후 백그라운드에서 AI 서버 호출.
생성 완료 시 music_library에 자동 등록됨. provider: "suno" (Suno API) 또는 "local" (MusicGen)
""" """
provider = req.provider
if provider == "suno" and not SUNO_API_KEY:
raise HTTPException(status_code=400, detail="Suno API 키가 설정되지 않았습니다")
if provider == "local" and not os.getenv("MUSIC_AI_SERVER_URL"):
raise HTTPException(status_code=400, detail="로컬 AI 서버 URL이 설정되지 않았습니다")
if provider not in ("suno", "local"):
raise HTTPException(status_code=400, detail=f"지원하지 않는 provider: {provider}")
task_id = str(uuid.uuid4()) task_id = str(uuid.uuid4())
params = req.model_dump() params = req.model_dump()
create_task(task_id, params) create_task(task_id, params, provider=provider)
background_tasks.add_task(_run_generation, task_id, params)
return {"task_id": task_id} if provider == "suno":
background_tasks.add_task(run_suno_generation, task_id, params)
else:
background_tasks.add_task(run_local_generation, task_id, params)
return {"task_id": task_id, "provider": provider}
@app.get("/api/music/status/{task_id}") @app.get("/api/music/status/{task_id}")
@@ -170,7 +108,7 @@ def get_status(task_id: str):
""" """
생성 작업 상태 조회. 프론트는 succeeded 또는 failed가 될 때까지 폴링. 생성 작업 상태 조회. 프론트는 succeeded 또는 failed가 될 때까지 폴링.
status: queued | processing | succeeded | failed status: queued | processing | succeeded | failed
succeeded 시 track 메타데이터 포함 (라이브러리 별도 저장 불필요). succeeded 시 track 메타데이터 포함.
""" """
task = get_task(task_id) task = get_task(task_id)
if not task: if not task:
@@ -182,10 +120,9 @@ def get_status(task_id: str):
"message": task["message"], "message": task["message"],
"audio_url": task["audio_url"], "audio_url": task["audio_url"],
"error": task["error"], "error": task["error"],
"provider": task["provider"],
} }
# succeeded 시 라이브러리에 저장된 트랙 메타데이터 포함
# 프론트는 이 track 객체로 UI를 바로 업데이트하면 됨 (Save 버튼 불필요)
if task["status"] == "succeeded": if task["status"] == "succeeded":
track = get_track_by_task_id(task_id) track = get_track_by_task_id(task_id)
resp["track"] = track resp["track"] = track
@@ -193,6 +130,23 @@ def get_status(task_id: str):
return resp return resp
# ── 가사 생성 API (Suno 전용) ────────────────────────────────────────────────
class LyricsRequest(BaseModel):
prompt: str
@app.post("/api/music/lyrics")
def gen_lyrics(req: LyricsRequest):
"""Suno AI로 가사를 생성합니다. 곡 생성 전 가사 미리보기용."""
if not SUNO_API_KEY:
raise HTTPException(status_code=400, detail="Suno API 키가 설정되지 않았습니다")
result = generate_lyrics(req.prompt)
if not result:
raise HTTPException(status_code=502, detail="가사 생성에 실패했습니다")
return result
# ── 라이브러리 API ──────────────────────────────────────────────────────────── # ── 라이브러리 API ────────────────────────────────────────────────────────────
class TrackCreate(BaseModel): class TrackCreate(BaseModel):
@@ -209,6 +163,10 @@ class TrackCreate(BaseModel):
file_path: str = "" file_path: str = ""
task_id: Optional[str] = None task_id: Optional[str] = None
tags: List[str] = [] tags: List[str] = []
provider: str = "local"
lyrics: str = ""
image_url: str = ""
suno_id: str = ""
@app.get("/api/music/library") @app.get("/api/music/library")
@@ -226,19 +184,16 @@ def save_to_library(req: TrackCreate):
@app.delete("/api/music/library/{track_id}") @app.delete("/api/music/library/{track_id}")
def remove_from_library(track_id: int): def remove_from_library(track_id: int):
""" """라이브러리에서 트랙 삭제. 로컬 파일도 함께 삭제."""
라이브러리에서 트랙 삭제. 로컬 파일도 함께 삭제.
"""
file_path = get_track_file_path(track_id) file_path = get_track_file_path(track_id)
ok = delete_track(track_id) ok = delete_track(track_id)
if not ok: if not ok:
raise HTTPException(status_code=404, detail="Track not found") raise HTTPException(status_code=404, detail="Track not found")
# 생성된 파일이 있으면 함께 삭제
if file_path and os.path.isfile(file_path): if file_path and os.path.isfile(file_path):
try: try:
os.remove(file_path) os.remove(file_path)
except OSError: except OSError:
pass # 파일 삭제 실패해도 DB에서는 이미 삭제됨 pass
return {"ok": True} return {"ok": True}

View File

@@ -0,0 +1,268 @@
"""
Suno API Provider — Suno REST API를 통한 음악 생성
https://apicast.suno.ai/v1
"""
import os
import time
import logging
import requests
from typing import Optional
from .db import update_task, add_track
logger = logging.getLogger(__name__)
SUNO_BASE_URL = "https://apicast.suno.ai/v1"
SUNO_API_KEY = os.getenv("SUNO_API_KEY", "")
MUSIC_DATA_DIR = "/app/data/music"
MUSIC_MEDIA_BASE = os.getenv("MUSIC_MEDIA_BASE", "/media/music")
# 폴링 설정
POLL_INTERVAL = 6 # 초
POLL_MAX_ATTEMPTS = 50 # 최대 5분 (6초 × 50)
def _headers() -> dict:
return {
"Authorization": f"Bearer {SUNO_API_KEY}",
"Content-Type": "application/json",
}
def generate_lyrics(prompt: str) -> Optional[dict]:
"""Suno 가사 생성 API 호출. 곡 생성 전 가사 미리보기용."""
if not SUNO_API_KEY:
return None
try:
resp = requests.post(
f"{SUNO_BASE_URL}/lyrics",
headers=_headers(),
json={"prompt": prompt},
timeout=30,
)
if resp.status_code == 200:
return resp.json()
except Exception as e:
logger.warning("Suno lyrics API error: %s", e)
return None
def run_suno_generation(task_id: str, params: dict) -> None:
"""
BackgroundTask: Suno API로 곡 생성 → MP3 다운로드 → 라이브러리 등록.
Suno는 1회 생성 시 2개 변형을 반환하므로, 첫 번째를 메인으로 저장하고
두 번째는 별도 트랙으로 추가 등록한다.
"""
try:
# ── 사전 검증 ──
if not SUNO_API_KEY:
update_task(task_id, "failed", 0, "",
error="SUNO_API_KEY가 설정되지 않았습니다. .env 파일을 확인하세요.")
return
update_task(task_id, "processing", 5, "Suno API에 연결 중...")
# ── 1단계: 곡 생성 요청 ──
payload = _build_suno_payload(params)
resp = requests.post(
f"{SUNO_BASE_URL}/songs",
headers=_headers(),
json=payload,
timeout=30,
)
if resp.status_code not in (200, 201):
error_detail = resp.text[:300] if resp.text else f"HTTP {resp.status_code}"
update_task(task_id, "failed", 0, "", error=f"Suno API 오류: {error_detail}")
return
song_data = resp.json()
# Suno 응답 형태: 단일 객체 또는 리스트
songs = song_data if isinstance(song_data, list) else [song_data]
if not songs:
update_task(task_id, "failed", 0, "", error="Suno API 응답이 비어있습니다")
return
primary_song = songs[0]
suno_song_id = primary_song.get("id", "")
update_task(task_id, "processing", 15, "곡 생성 대기열에 등록됨...")
# ── 2단계: 상태 폴링 ──
completed_song = _poll_until_complete(task_id, suno_song_id)
if not completed_song:
return # 에러는 _poll_until_complete 내부에서 처리
update_task(task_id, "processing", 80, "오디오 파일 다운로드 중...")
# ── 3단계: 메인 트랙 다운로드 + 등록 ──
track = _download_and_register(
task_id=task_id,
song=completed_song,
params=params,
filename_suffix="",
)
if not track:
return
audio_url = track["audio_url"]
update_task(task_id, "succeeded", 100, "생성 완료", audio_url=audio_url)
# ── 4단계: 두 번째 변형이 있으면 추가 등록 ──
if len(songs) > 1:
second_id = songs[1].get("id", "")
if second_id:
try:
second_song = _fetch_song(second_id)
if second_song and second_song.get("status") == "complete":
_download_and_register(
task_id=f"{task_id}_v2",
song=second_song,
params=params,
filename_suffix="_v2",
)
except Exception:
pass # 보조 변형 실패는 무시
except requests.Timeout:
update_task(task_id, "failed", 0, "", error="Suno API 타임아웃")
except Exception as e:
logger.exception("Suno generation error for task %s", task_id)
update_task(task_id, "failed", 0, "", error=str(e))
def _build_suno_payload(params: dict) -> dict:
"""프론트엔드 params → Suno API 요청 형식으로 변환."""
payload = {}
# 프롬프트 조합: prompt + genre + moods
parts = []
if params.get("prompt"):
parts.append(params["prompt"])
if params.get("genre"):
parts.append(params["genre"])
if params.get("moods"):
parts.append(", ".join(params["moods"]))
payload["prompt"] = " ".join(parts) if parts else "instrumental music"
# 스타일 태그
style_parts = []
if params.get("genre"):
style_parts.append(params["genre"])
if params.get("moods"):
style_parts.extend(params["moods"])
if params.get("instruments"):
style_parts.extend(params["instruments"][:3]) # 너무 많으면 잘림
if style_parts:
payload["style"] = " ".join(style_parts)
# 제목
if params.get("title"):
payload["title"] = params["title"]
# 가사 / 인스트루멘탈
if params.get("instrumental", False):
payload["instrumental"] = True
elif params.get("lyrics"):
payload["lyrics"] = params["lyrics"]
return payload
def _fetch_song(song_id: str) -> Optional[dict]:
"""Suno에서 단일 곡 상태 조회."""
try:
resp = requests.get(
f"{SUNO_BASE_URL}/songs/{song_id}",
headers=_headers(),
timeout=15,
)
if resp.status_code == 200:
return resp.json()
except Exception as e:
logger.warning("Suno fetch song error: %s", e)
return None
def _poll_until_complete(task_id: str, suno_song_id: str) -> Optional[dict]:
"""Suno 곡 상태를 폴링하여 complete가 될 때까지 대기."""
for attempt in range(POLL_MAX_ATTEMPTS):
time.sleep(POLL_INTERVAL)
song = _fetch_song(suno_song_id)
if not song:
continue
status = song.get("status", "")
progress = min(15 + int((attempt / POLL_MAX_ATTEMPTS) * 65), 79)
if status == "streaming":
update_task(task_id, "processing", progress, "AI가 음악을 작곡 중...")
elif status == "complete":
return song
elif status == "error":
error_msg = song.get("error_message", "Suno 생성 실패")
update_task(task_id, "failed", 0, "", error=error_msg)
return None
else:
update_task(task_id, "processing", progress, f"대기 중... ({status})")
update_task(task_id, "failed", 0, "", error="Suno 생성 타임아웃 (5분 초과)")
return None
def _download_and_register(
task_id: str, song: dict, params: dict, filename_suffix: str = "",
) -> Optional[dict]:
"""Suno CDN에서 MP3 다운로드 → 로컬 저장 → 라이브러리 등록."""
audio_url_remote = song.get("audio_url", "")
if not audio_url_remote:
update_task(task_id, "failed", 0, "", error="Suno 응답에 audio_url이 없습니다")
return None
filename = f"{task_id}{filename_suffix}.mp3"
file_path = os.path.join(MUSIC_DATA_DIR, filename)
try:
dl = requests.get(audio_url_remote, timeout=120, stream=True)
dl.raise_for_status()
with open(file_path, "wb") as f:
for chunk in dl.iter_content(chunk_size=8192):
f.write(chunk)
except Exception as e:
update_task(task_id, "failed", 0, "", error=f"오디오 다운로드 실패: {e}")
return None
local_audio_url = f"{MUSIC_MEDIA_BASE}/{filename}"
# 메타데이터 조합
genre = params.get("genre", song.get("style", ""))
moods = params.get("moods", [])
mood_str = moods[0] if moods else "Original"
title = (
song.get("title")
or params.get("title")
or (f"{genre}{mood_str} Mix" if genre else f"{mood_str} Mix")
)
track_data = {
"title": title,
"genre": genre,
"moods": moods,
"instruments": params.get("instruments", []),
"duration_sec": int(song["duration"]) if song.get("duration") else params.get("duration_sec"),
"bpm": params.get("bpm"),
"key": params.get("key", ""),
"scale": params.get("scale", ""),
"prompt": params.get("prompt", ""),
"audio_url": local_audio_url,
"file_path": file_path,
"task_id": task_id,
"provider": "suno",
"lyrics": song.get("lyrics", params.get("lyrics", "")),
"image_url": song.get("image_url", ""),
"suno_id": song.get("id", ""),
}
return add_track(track_data)