diff --git a/music-lab/app/main.py b/music-lab/app/main.py index 3f60ba6..b228ad4 100644 --- a/music-lab/app/main.py +++ b/music-lab/app/main.py @@ -2,6 +2,7 @@ import json import os import shutil import uuid +from datetime import datetime, timezone, timedelta from typing import Any, Dict, List, Optional from fastapi import FastAPI, HTTPException, BackgroundTasks, Query from fastapi.middleware.cors import CORSMiddleware @@ -25,16 +26,9 @@ from .db import ( from . import db as _db_module from .compiler import run_compile from .market import ingest_trends, get_suggestions -from .local_provider import run_local_generation from .pipeline import orchestrator from .pipeline import youtube as yt_module -from .suno_provider import ( - run_suno_generation, run_suno_extend, run_vocal_removal, - run_cover_image, run_wav_convert, run_stem_split, - run_upload_cover, run_upload_extend, run_add_vocals, run_add_instrumental, run_video_generate, - generate_lyrics, get_credits, get_timestamped_lyrics, generate_style_boost, - SUNO_API_KEY, SUNO_MODELS, -) +from .suno_provider import SUNO_MODELS from .batch_generator import run_batch as _run_batch import redis.asyncio as aioredis from .internal_router import router as internal_router @@ -49,7 +43,6 @@ app.include_router(internal_router) async def _push_render_job(task_id: str, job_type: str, params: dict) -> None: """Redis queue:music-render에 push. Windows worker가 BLPOP 후 처리.""" - from datetime import datetime, timezone, timedelta kst = timezone(timedelta(hours=9)) payload = { "task_id": task_id, @@ -116,13 +109,13 @@ def get_providers(): "description": "로컬 AI 서버 (인스트루멘탈 전용)", "features": ["instrumental"], }) - if SUNO_API_KEY: - providers.append({ - "id": "suno", - "name": "Suno", - "description": "Suno AI (보컬·가사·인스트루멘탈)", - "features": ["vocals", "lyrics", "instrumental"], - }) + # SUNO는 Windows music-render에서 처리 — 항상 가용 (Suno 키 누락 시 worker가 failed 보고) + providers.append({ + "id": "suno", + "name": "Suno", + "description": "Suno AI (보컬·가사·인스트루멘탈)", + "features": ["vocals", "lyrics", "instrumental"], + }) return {"providers": providers} @@ -151,7 +144,7 @@ class GenerateRequest(BaseModel): @app.post("/api/music/generate") -async def generate_music(req: GenerateRequest, background_tasks: BackgroundTasks): +async def generate_music(req: GenerateRequest): """음악 생성 작업 — Redis 큐로 Windows music-render에 위임.""" provider = req.provider # SUNO_API_KEY 검증은 Windows로 위임 (NAS에서 키 보유 X). @@ -205,12 +198,11 @@ class LyricsRequest(BaseModel): @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) + """Suno AI 가사 생성 — Windows music-render로 forward.""" + from .sync_forward import forward_lyrics + result = forward_lyrics(req.prompt) if not result: - raise HTTPException(status_code=502, detail="가사 생성에 실패했습니다") + raise HTTPException(status_code=502, detail="가사 생성 실패 (Windows worker 응답 없음)") return result @@ -389,10 +381,9 @@ def get_models(): @app.get("/api/music/credits") def check_credits(): - """Suno 잔여 크레딧 조회.""" - if not SUNO_API_KEY: - raise HTTPException(status_code=400, detail="Suno API 키가 설정되지 않았습니다") - result = get_credits() + """Suno 잔여 크레딧 조회 — Windows music-render로 forward.""" + from .sync_forward import forward_credits + result = forward_credits() if result is None: raise HTTPException(status_code=502, detail="크레딧 조회 실패") return result @@ -410,7 +401,7 @@ class ExtendRequest(BaseModel): @app.post("/api/music/extend") -async def extend_music(req: ExtendRequest, background_tasks: BackgroundTasks): +async def extend_music(req: ExtendRequest): """기존 곡을 특정 지점부터 연장 (Suno Extend API).""" task_id = str(uuid.uuid4()) params = req.model_dump() @@ -427,7 +418,7 @@ class VocalRemovalRequest(BaseModel): @app.post("/api/music/vocal-removal") -async def vocal_removal(req: VocalRemovalRequest, background_tasks: BackgroundTasks): +async def vocal_removal(req: VocalRemovalRequest): """트랙에서 보컬과 인스트루멘탈을 분리 (Suno Vocal Removal API).""" task_id = str(uuid.uuid4()) params = req.model_dump() @@ -444,7 +435,7 @@ class CoverImageRequest(BaseModel): @app.post("/api/music/cover-image") -async def cover_image(req: CoverImageRequest, background_tasks: BackgroundTasks): +async def cover_image(req: CoverImageRequest): """Suno 곡의 커버 이미지 2장 생성.""" task_id = str(uuid.uuid4()) params = req.model_dump() @@ -462,7 +453,7 @@ class WavRequest(BaseModel): @app.post("/api/music/wav") -async def wav_convert(req: WavRequest, background_tasks: BackgroundTasks): +async def wav_convert(req: WavRequest): """곡을 WAV 포맷으로 변환.""" task_id = str(uuid.uuid4()) params = req.model_dump() @@ -480,7 +471,7 @@ class StemSplitRequest(BaseModel): @app.post("/api/music/stem-split") -async def stem_split(req: StemSplitRequest, background_tasks: BackgroundTasks): +async def stem_split(req: StemSplitRequest): """곡을 12개 스템으로 분리 (50 크레딧). 보컬, 드럼, 베이스, 기타 등.""" task_id = str(uuid.uuid4()) params = req.model_dump() @@ -493,10 +484,9 @@ async def stem_split(req: StemSplitRequest, background_tasks: BackgroundTasks): @app.get("/api/music/timestamped-lyrics") def timestamped_lyrics(task_id: str, suno_id: str): - """타임스탬프 가사 조회 (가라오케 스타일 싱크용).""" - if not SUNO_API_KEY: - raise HTTPException(status_code=400, detail="Suno API 키가 설정되지 않았습니다") - result = get_timestamped_lyrics(task_id, suno_id) + """타임스탬프 가사 — Windows music-render로 forward.""" + from .sync_forward import forward_timestamped_lyrics + result = forward_timestamped_lyrics(task_id, suno_id) if not result: raise HTTPException(status_code=502, detail="타임스탬프 가사 조회 실패") return result @@ -510,10 +500,9 @@ class StyleBoostRequest(BaseModel): @app.post("/api/music/style-boost") def style_boost(req: StyleBoostRequest): - """AI로 최적 스타일 프롬프트 생성.""" - if not SUNO_API_KEY: - raise HTTPException(status_code=400, detail="Suno API 키가 설정되지 않았습니다") - result = generate_style_boost(req.content) + """스타일 부스트 — Windows music-render로 forward.""" + from .sync_forward import forward_style_boost + result = forward_style_boost(req.content) if not result: raise HTTPException(status_code=502, detail="스타일 부스트 생성 실패") return result @@ -536,7 +525,7 @@ class UploadCoverRequest(BaseModel): @app.post("/api/music/upload-cover") -async def upload_cover(req: UploadCoverRequest, background_tasks: BackgroundTasks): +async def upload_cover(req: UploadCoverRequest): """외부 오디오를 Suno 스타일로 리메이크.""" task_id = str(uuid.uuid4()) params = req.model_dump() @@ -561,7 +550,7 @@ class UploadExtendRequest(BaseModel): @app.post("/api/music/upload-extend") -async def upload_extend(req: UploadExtendRequest, background_tasks: BackgroundTasks): +async def upload_extend(req: UploadExtendRequest): """외부 오디오를 이어서 확장.""" task_id = str(uuid.uuid4()) params = req.model_dump() @@ -585,7 +574,7 @@ class AddVocalsRequest(BaseModel): @app.post("/api/music/add-vocals") -async def add_vocals(req: AddVocalsRequest, background_tasks: BackgroundTasks): +async def add_vocals(req: AddVocalsRequest): """인스트루멘탈에 AI 보컬 추가.""" task_id = str(uuid.uuid4()) params = req.model_dump() @@ -608,7 +597,7 @@ class AddInstrumentalRequest(BaseModel): @app.post("/api/music/add-instrumental") -async def add_instrumental(req: AddInstrumentalRequest, background_tasks: BackgroundTasks): +async def add_instrumental(req: AddInstrumentalRequest): """보컬에 AI 반주 추가.""" task_id = str(uuid.uuid4()) params = req.model_dump() @@ -628,7 +617,7 @@ class VideoRequest(BaseModel): @app.post("/api/music/video") -async def video_generate(req: VideoRequest, background_tasks: BackgroundTasks): +async def video_generate(req: VideoRequest): """뮤직비디오(MP4) 생성.""" task_id = str(uuid.uuid4()) params = req.model_dump() @@ -860,7 +849,7 @@ async def generate_batch(req: BatchGenerateRequest, bg: BackgroundTasks): raise HTTPException(status_code=400, detail="target_duration_sec는 60-300 사이") if not req.genre: raise HTTPException(status_code=400, detail="genre 필수") - if not SUNO_API_KEY: + if not os.getenv("SUNO_API_KEY"): raise HTTPException(status_code=400, detail="SUNO_API_KEY 미설정") batch_id = _db_module.create_batch_job( diff --git a/music-lab/app/sync_forward.py b/music-lab/app/sync_forward.py new file mode 100644 index 0000000..28b28d6 --- /dev/null +++ b/music-lab/app/sync_forward.py @@ -0,0 +1,76 @@ +"""SP-6 sync helpers forward — NAS → Windows music-render. + +NAS music-lab의 /api/music/lyrics, /api/music/credits, +/api/music/timestamped-lyrics, /api/music/style-boost 호출을 +Windows music-render의 /api/music-render/sync/* 로 forward. + +SUNO_API_KEY는 NAS에 없으므로 NAS에서 직접 호출 불가. +""" +from __future__ import annotations + +import logging +import os +from typing import Optional + +import httpx + +logger = logging.getLogger(__name__) + +MUSIC_RENDER_URL = os.getenv("MUSIC_RENDER_URL", "http://192.168.45.59:18711") +_TIMEOUT = 60.0 # 가사 생성은 폴링 포함 ~45초 + + +def forward_lyrics(prompt: str) -> Optional[dict]: + try: + r = httpx.post( + f"{MUSIC_RENDER_URL}/api/music-render/sync/lyrics", + json={"prompt": prompt}, + timeout=_TIMEOUT, + ) + if r.status_code == 200: + return r.json() + logger.warning("forward_lyrics returned %d", r.status_code) + except Exception: + logger.exception("forward_lyrics 실패") + return None + + +def forward_credits() -> Optional[dict]: + try: + r = httpx.get( + f"{MUSIC_RENDER_URL}/api/music-render/sync/credits", + timeout=30.0, + ) + if r.status_code == 200: + return r.json() + except Exception: + logger.exception("forward_credits 실패") + return None + + +def forward_timestamped_lyrics(task_id: str, suno_id: str) -> Optional[dict]: + try: + r = httpx.get( + f"{MUSIC_RENDER_URL}/api/music-render/sync/timestamped-lyrics", + params={"task_id": task_id, "suno_id": suno_id}, + timeout=30.0, + ) + if r.status_code == 200: + return r.json() + except Exception: + logger.exception("forward_timestamped_lyrics 실패") + return None + + +def forward_style_boost(content: str) -> Optional[dict]: + try: + r = httpx.post( + f"{MUSIC_RENDER_URL}/api/music-render/sync/style-boost", + json={"content": content}, + timeout=30.0, + ) + if r.status_code == 200: + return r.json() + except Exception: + logger.exception("forward_style_boost 실패") + return None diff --git a/music-lab/tests/test_batch_endpoints.py b/music-lab/tests/test_batch_endpoints.py index d769109..ffd6568 100644 --- a/music-lab/tests/test_batch_endpoints.py +++ b/music-lab/tests/test_batch_endpoints.py @@ -50,7 +50,7 @@ def test_create_batch_rejects_invalid_duration(client): def test_create_batch_rejects_no_suno_key(client, monkeypatch): - monkeypatch.setattr(main_module, "SUNO_API_KEY", "", raising=False) + monkeypatch.delenv("SUNO_API_KEY", raising=False) r = client.post("/api/music/generate-batch", json={"genre": "lo-fi", "count": 3}) assert r.status_code == 400