From f5c58a5aa5f9ef18c6491e9976993f6295610eb0 Mon Sep 17 00:00:00 2001 From: gahusb Date: Fri, 3 Apr 2026 08:23:29 +0900 Subject: [PATCH] =?UTF-8?q?music-lab:=20Suno=20API=20+=20MusicGen=20?= =?UTF-8?q?=EB=93=80=EC=96=BC=20=ED=94=84=EB=A1=9C=EB=B0=94=EC=9D=B4?= =?UTF-8?q?=EB=8D=94=20=EA=B5=AC=EC=A1=B0=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- .env.example | 7 + CLAUDE.md | 30 +++- docker-compose.yml | 1 + music-lab/app/db.py | 40 ++++- music-lab/app/local_provider.py | 122 +++++++++++++++ music-lab/app/main.py | 179 ++++++++------------- music-lab/app/suno_provider.py | 268 ++++++++++++++++++++++++++++++++ 7 files changed, 522 insertions(+), 125 deletions(-) create mode 100644 music-lab/app/local_provider.py create mode 100644 music-lab/app/suno_provider.py diff --git a/.env.example b/.env.example index c9a95f8..28d0517 100644 --- a/.env.example +++ b/.env.example @@ -57,5 +57,12 @@ ADMIN_API_KEY= # Anthropic API Key (AI Coach 프록시, 미설정 시 AI Coach 비활성화) 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_ALLOW_ORIGINS=https://gahusb.synology.me,http://localhost:3007,http://localhost:8080 diff --git a/CLAUDE.md b/CLAUDE.md index f342601..1d57ea3 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -225,30 +225,44 @@ docker compose up -d - 15:40 평일 — 총 자산 스냅샷 저장 (`save_daily_snapshot`) ### music-lab (music-lab/) -- AI 음악 생성 서비스. Windows AI 서버(`MUSIC_AI_SERVER_URL`)에 생성 요청 프록시 +- 듀얼 프로바이더 음악 생성 서비스 (Suno API + 로컬 MusicGen) - 생성된 오디오 파일: `/app/data/music/` (Nginx가 `/media/music/`로 직접 서빙) - DB: `/app/data/music.db` (music_tasks, music_library 테이블) -- 파일 구조: `main.py`, `db.py` -- 생성 흐름: POST generate → task_id 반환 → BackgroundTask가 AI 서버 호출 → 파일 저장 → 라이브러리 자동 등록 +- 파일 구조: `main.py`, `db.py`, `suno_provider.py`, `local_provider.py` +- 생성 흐름: POST generate (provider 지정) → task_id 반환 → BackgroundTask → 파일 저장 → 라이브러리 자동 등록 + +**Provider 구조** +- `suno`: Suno REST API (`apicast.suno.ai/v1`) — 보컬·가사·인스트루멘탈 지원 +- `local`: Windows AI 서버 (MusicGen) — 인스트루멘탈 전용 **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) | +| POST | `/api/music/lyrics` | Suno AI 가사 생성 (곡 생성 전 미리보기용) | | GET | `/api/music/library` | 라이브러리 전체 조회 | | POST | `/api/music/library` | 트랙 수동 추가 (201) | | 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_DATA_PATH`: NAS 오디오 파일 저장 경로 (기본 `./data/music`) -**AI 서버 응답 형식 (2가지 모두 지원)** -- binary audio (Content-Type: audio/*) → 직접 저장 -- JSON `{"audio_url": "..."}` → 해당 URL에서 다운로드 후 저장 +**music_library 테이블 (확장 컬럼)** +- `provider`: `suno` | `local` — 생성에 사용된 프로바이더 +- `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/) - 원본 사진: `/data/travel/` (RO) diff --git a/docker-compose.yml b/docker-compose.yml index 0b6a4ac..6c999ce 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -57,6 +57,7 @@ services: environment: - TZ=${TZ:-Asia/Seoul} - MUSIC_AI_SERVER_URL=${MUSIC_AI_SERVER_URL:-} + - SUNO_API_KEY=${SUNO_API_KEY:-} - MUSIC_MEDIA_BASE=${MUSIC_MEDIA_BASE:-/media/music} - CORS_ALLOW_ORIGINS=${CORS_ALLOW_ORIGINS:-http://localhost:3007,http://localhost:8080} volumes: diff --git a/music-lab/app/db.py b/music-lab/app/db.py index f04dc6c..20e076a 100644 --- a/music-lab/app/db.py +++ b/music-lab/app/db.py @@ -24,6 +24,7 @@ def init_db() -> None: audio_url TEXT, error TEXT, 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')), 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 '', task_id TEXT, 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')) ) """) 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 ────────────────────────────────────────────────────────── @@ -63,16 +82,17 @@ def _task_row_to_dict(r) -> Dict[str, Any]: "audio_url": r["audio_url"], "error": r["error"], "params": json.loads(r["params"]), + "provider": r["provider"] if "provider" in r.keys() else "local", "created_at": r["created_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: conn.execute( - "INSERT INTO music_tasks (id, params) VALUES (?, ?)", - (task_id, json.dumps(params)), + "INSERT INTO music_tasks (id, params, provider) VALUES (?, ?, ?)", + (task_id, json.dumps(params), provider), ) row = conn.execute("SELECT * FROM music_tasks WHERE id = ?", (task_id,)).fetchone() return _task_row_to_dict(row) @@ -107,6 +127,7 @@ def get_task(task_id: str) -> Optional[Dict[str, Any]]: # ── music_library CRUD ──────────────────────────────────────────────────────── def _track_row_to_dict(r) -> Dict[str, Any]: + keys = r.keys() return { "id": r["id"], "title": r["title"], @@ -122,6 +143,10 @@ def _track_row_to_dict(r) -> Dict[str, Any]: "file_path": r["file_path"], "task_id": r["task_id"], "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"], } @@ -138,8 +163,9 @@ def add_track(data: Dict[str, Any]) -> Dict[str, Any]: """ INSERT INTO music_library (title, genre, moods, instruments, duration_sec, bpm, key, scale, - prompt, audio_url, file_path, task_id, tags) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + prompt, audio_url, file_path, task_id, tags, + provider, lyrics, image_url, suno_id) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) """, ( data.get("title", ""), @@ -155,6 +181,10 @@ def add_track(data: Dict[str, Any]) -> Dict[str, Any]: data.get("file_path", ""), data.get("task_id"), 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() diff --git a/music-lab/app/local_provider.py b/music-lab/app/local_provider.py new file mode 100644 index 0000000..6d493f3 --- /dev/null +++ b/music-lab/app/local_provider.py @@ -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)) diff --git a/music-lab/app/main.py b/music-lab/app/main.py index aa933ed..2789c85 100644 --- a/music-lab/app/main.py +++ b/music-lab/app/main.py @@ -1,18 +1,17 @@ import os -import time import uuid -import requests from typing import List, Optional from fastapi import FastAPI, HTTPException, BackgroundTasks from fastapi.middleware.cors import CORSMiddleware -from fastapi.responses import JSONResponse from pydantic import BaseModel from .db import ( 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, ) +from .local_provider import run_local_generation +from .suno_provider import run_suno_generation, generate_lyrics, SUNO_API_KEY app = FastAPI() @@ -25,9 +24,7 @@ app.add_middleware( allow_headers=["Content-Type"], ) -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") @app.on_event("startup") @@ -41,106 +38,31 @@ def health(): return {"ok": True} -# ── 음악 생성 워커 ──────────────────────────────────────────────────────────── - -def _run_generation(task_id: str, params: dict) -> None: - """BackgroundTask: AI 서버에 생성 요청 → 파일 저장 → 라이브러리 등록""" - 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초 간격) — 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, +@app.get("/api/music/providers") +def get_providers(): + """사용 가능한 음악 생성 프로바이더 목록 반환.""" + providers = [] + if os.getenv("MUSIC_AI_SERVER_URL"): + providers.append({ + "id": "local", + "name": "MusicGen", + "description": "로컬 AI 서버 (인스트루멘탈 전용)", + "features": ["instrumental"], }) - - 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: - update_task(task_id, "failed", 0, "", error=str(e)) + if SUNO_API_KEY: + providers.append({ + "id": "suno", + "name": "Suno", + "description": "Suno AI (보컬·가사·인스트루멘탈)", + "features": ["vocals", "lyrics", "instrumental"], + }) + return {"providers": providers} # ── 음악 생성 API ───────────────────────────────────────────────────────────── class GenerateRequest(BaseModel): + provider: str = "suno" # "suno" | "local" title: str = "" genre: str = "" moods: List[str] = [] @@ -150,19 +72,35 @@ class GenerateRequest(BaseModel): key: str = "" scale: str = "" prompt: str = "" + # Suno 전용 + lyrics: str = "" # 커스텀 가사 ([Verse], [Chorus] 등) + instrumental: bool = False # True면 보컬 없이 인스트루멘탈만 @app.post("/api/music/generate") def generate_music(req: GenerateRequest, background_tasks: BackgroundTasks): """ 음악 생성 작업 시작. 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()) params = req.model_dump() - create_task(task_id, params) - background_tasks.add_task(_run_generation, task_id, params) - return {"task_id": task_id} + create_task(task_id, params, provider=provider) + + 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}") @@ -170,7 +108,7 @@ def get_status(task_id: str): """ 생성 작업 상태 조회. 프론트는 succeeded 또는 failed가 될 때까지 폴링. status: queued | processing | succeeded | failed - succeeded 시 track 메타데이터 포함 (라이브러리 별도 저장 불필요). + succeeded 시 track 메타데이터 포함. """ task = get_task(task_id) if not task: @@ -182,10 +120,9 @@ def get_status(task_id: str): "message": task["message"], "audio_url": task["audio_url"], "error": task["error"], + "provider": task["provider"], } - # succeeded 시 라이브러리에 저장된 트랙 메타데이터 포함 - # 프론트는 이 track 객체로 UI를 바로 업데이트하면 됨 (Save 버튼 불필요) if task["status"] == "succeeded": track = get_track_by_task_id(task_id) resp["track"] = track @@ -193,6 +130,23 @@ def get_status(task_id: str): 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 ──────────────────────────────────────────────────────────── class TrackCreate(BaseModel): @@ -209,6 +163,10 @@ class TrackCreate(BaseModel): file_path: str = "" task_id: Optional[str] = None tags: List[str] = [] + provider: str = "local" + lyrics: str = "" + image_url: str = "" + suno_id: str = "" @app.get("/api/music/library") @@ -226,19 +184,16 @@ def save_to_library(req: TrackCreate): @app.delete("/api/music/library/{track_id}") def remove_from_library(track_id: int): - """ - 라이브러리에서 트랙 삭제. 로컬 파일도 함께 삭제. - """ + """라이브러리에서 트랙 삭제. 로컬 파일도 함께 삭제.""" file_path = get_track_file_path(track_id) ok = delete_track(track_id) if not ok: raise HTTPException(status_code=404, detail="Track not found") - # 생성된 파일이 있으면 함께 삭제 if file_path and os.path.isfile(file_path): try: os.remove(file_path) except OSError: - pass # 파일 삭제 실패해도 DB에서는 이미 삭제됨 + pass return {"ok": True} diff --git a/music-lab/app/suno_provider.py b/music-lab/app/suno_provider.py new file mode 100644 index 0000000..073557a --- /dev/null +++ b/music-lab/app/suno_provider.py @@ -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)