lyric, song finished

insta
bluebamus 2025-12-22 18:05:55 +09:00
parent 4ad6be7504
commit dd855ff11a
11 changed files with 701 additions and 10 deletions

8
.gitignore vendored
View File

@ -14,5 +14,11 @@ __pycache__/
# VSCode settings # VSCode settings
.vscode/ .vscode/
# Python package metadata # Build artifacts
*.egg-info/ *.egg-info/
*.egg
dist/
build/
*.pyc
*.pyo
.eggs/

View File

@ -26,6 +26,10 @@ class GenerateRequestInfo(BaseModel):
region: str = Field(..., description="지역명") region: str = Field(..., description="지역명")
detail_region_info: Optional[str] = Field(None, description="상세 지역 정보") detail_region_info: Optional[str] = Field(None, description="상세 지역 정보")
attribute: AttributeInfo = Field(..., description="음악 속성 정보") attribute: AttributeInfo = Field(..., description="음악 속성 정보")
language: str = Field(
default="Korean",
description="가사 출력 언어 (Korean, English, Chinese, Japanese, Thai, Vietnamese)",
)
class GenerateRequest(GenerateRequestInfo): class GenerateRequest(GenerateRequestInfo):
@ -46,6 +50,7 @@ class GenerateRequest(GenerateRequestInfo):
"tempo": "110 BPM", "tempo": "110 BPM",
"mood": "happy", "mood": "happy",
}, },
"language": "Korean",
} }
} }
) )
@ -69,6 +74,7 @@ class GenerateUrlsRequest(GenerateRequestInfo):
"tempo": "110 BPM", "tempo": "110 BPM",
"mood": "happy", "mood": "happy",
}, },
"language": "Korean",
"images": [ "images": [
{"url": "https://example.com/images/image_001.jpg"}, {"url": "https://example.com/images/image_001.jpg"},
{"url": "https://example.com/images/image_002.jpg", "name": "외관"}, {"url": "https://example.com/images/image_002.jpg", "name": "외관"},

View File

@ -44,12 +44,14 @@ async def lyric_task(
customer_name: str, customer_name: str,
region: str, region: str,
detail_region_info: str, detail_region_info: str,
language: str = "Korean",
) -> None: ) -> None:
"""가사 생성 작업: ChatGPT로 가사 생성 및 Lyric 테이블 저장/업데이트""" """가사 생성 작업: ChatGPT로 가사 생성 및 Lyric 테이블 저장/업데이트"""
service = ChatgptService( service = ChatgptService(
customer_name=customer_name, customer_name=customer_name,
region=region, region=region,
detail_region_info=detail_region_info, detail_region_info=detail_region_info,
language=language,
) )
# Lyric 레코드 저장 (status=processing, lyric_result=null) # Lyric 레코드 저장 (status=processing, lyric_result=null)
@ -73,13 +75,15 @@ async def _task_process_async(request_body: GenerateRequest, task_id: str, proje
customer_name = request_body.customer_name customer_name = request_body.customer_name
region = request_body.region region = request_body.region
detail_region_info = request_body.detail_region_info or "" detail_region_info = request_body.detail_region_info or ""
language = request_body.language
print(f"customer_name: {customer_name}") print(f"customer_name: {customer_name}")
print(f"region: {region}") print(f"region: {region}")
print(f"detail_region_info: {detail_region_info}") print(f"detail_region_info: {detail_region_info}")
print(f"language: {language}")
# 가사 생성 작업 # 가사 생성 작업
await lyric_task(task_id, project_id, customer_name, region, detail_region_info) await lyric_task(task_id, project_id, customer_name, region, detail_region_info, language)
def task_process(request_body: GenerateRequest, task_id: str, project_id: int) -> None: def task_process(request_body: GenerateRequest, task_id: str, project_id: int) -> None:

View File

@ -32,11 +32,14 @@ from sqlalchemy.ext.asyncio import AsyncSession
from app.database.session import get_session from app.database.session import get_session
from app.lyric.models import Lyric from app.lyric.models import Lyric
from app.lyric.schemas.lyric import ( from app.lyric.schemas.lyric import (
GenerateLyricRequest,
GenerateLyricResponse,
LyricDetailResponse, LyricDetailResponse,
LyricListItem, LyricListItem,
LyricStatusResponse, LyricStatusResponse,
PaginatedResponse, PaginatedResponse,
) )
from app.utils.chatgpt_prompt import ChatgptService
router = APIRouter(prefix="/lyric", tags=["lyric"]) router = APIRouter(prefix="/lyric", tags=["lyric"])
@ -214,6 +217,84 @@ async def get_lyrics_paginated(
# ============================================================================= # =============================================================================
@router.post(
"/generate",
summary="가사 생성",
description="""
고객 정보를 기반으로 ChatGPT를 이용하여 가사를 생성합니다.
## 요청 필드
- **customer_name**: 고객명/가게명 (필수)
- **region**: 지역명 (필수)
- **detail_region_info**: 상세 지역 정보 (선택)
- **language**: 가사 출력 언어 (Korean, English, Chinese, Japanese, Thai, Vietnamese)
## 반환 정보
- **success**: 생성 성공 여부
- **lyric**: 생성된 가사
- **language**: 가사 언어
- **prompt_used**: 사용된 프롬프트
- **error_message**: 에러 메시지 (실패 )
## 사용 예시
```
POST /lyric/generate
{
"customer_name": "스테이 머뭄",
"region": "군산",
"detail_region_info": "군산 신흥동 말랭이 마을",
"language": "English"
}
```
""",
response_model=GenerateLyricResponse,
responses={
200: {"description": "가사 생성 성공"},
500: {"description": "가사 생성 실패"},
},
)
async def generate_lyric(
request_body: GenerateLyricRequest,
) -> GenerateLyricResponse:
"""고객 정보를 기반으로 가사를 생성합니다."""
try:
service = ChatgptService(
customer_name=request_body.customer_name,
region=request_body.region,
detail_region_info=request_body.detail_region_info or "",
language=request_body.language,
)
prompt = service.build_lyrics_prompt()
result = await service.generate(prompt=prompt)
# ERROR가 포함되어 있으면 실패 처리
if "ERROR:" in result:
return GenerateLyricResponse(
success=False,
lyric=None,
language=request_body.language,
prompt_used=prompt,
error_message=result,
)
return GenerateLyricResponse(
success=True,
lyric=result,
language=request_body.language,
prompt_used=prompt,
error_message=None,
)
except Exception as e:
return GenerateLyricResponse(
success=False,
lyric=None,
language=request_body.language,
prompt_used=None,
error_message=str(e),
)
@router.get( @router.get(
"/status/{task_id}", "/status/{task_id}",
summary="가사 생성 상태 조회", summary="가사 생성 상태 조회",

View File

@ -29,6 +29,65 @@ from typing import Generic, List, Optional, TypeVar
from pydantic import BaseModel, Field from pydantic import BaseModel, Field
class GenerateLyricRequest(BaseModel):
"""가사 생성 요청 스키마
Usage:
POST /lyric/generate
Request body for generating lyrics.
Example Request:
{
"customer_name": "스테이 머뭄",
"region": "군산",
"detail_region_info": "군산 신흥동 말랭이 마을",
"language": "Korean"
}
"""
model_config = {
"json_schema_extra": {
"example": {
"customer_name": "스테이 머뭄",
"region": "군산",
"detail_region_info": "군산 신흥동 말랭이 마을",
"language": "Korean",
}
}
}
customer_name: str = Field(..., description="고객명/가게명")
region: str = Field(..., description="지역명")
detail_region_info: Optional[str] = Field(None, description="상세 지역 정보")
language: str = Field(
default="Korean",
description="가사 출력 언어 (Korean, English, Chinese, Japanese, Thai, Vietnamese)",
)
class GenerateLyricResponse(BaseModel):
"""가사 생성 응답 스키마
Usage:
POST /lyric/generate
Returns the generated lyrics.
Example Response:
{
"success": true,
"lyric": "생성된 가사...",
"language": "Korean",
"prompt_used": "..."
}
"""
success: bool = Field(..., description="생성 성공 여부")
lyric: Optional[str] = Field(None, description="생성된 가사")
language: str = Field(..., description="가사 언어")
prompt_used: Optional[str] = Field(None, description="사용된 프롬프트")
error_message: Optional[str] = Field(None, description="에러 메시지 (실패 시)")
class LyricStatusResponse(BaseModel): class LyricStatusResponse(BaseModel):
"""가사 상태 조회 응답 스키마 """가사 상태 조회 응답 스키마

View File

@ -0,0 +1,196 @@
"""
Song API Router
모듈은 Suno API를 통한 노래 생성 관련 API 엔드포인트를 정의합니다.
엔드포인트 목록:
- POST /song/generate: 노래 생성 요청
- GET /song/status/{task_id}: 노래 생성 상태 조회
사용 예시:
from app.song.api.routers.v1.song import router
app.include_router(router, prefix="/api/v1")
"""
from fastapi import APIRouter
from app.song.schemas.song_schema import (
GenerateSongRequest,
GenerateSongResponse,
PollingSongResponse,
SongClipData,
)
from app.utils.suno import SunoService
def _parse_suno_status_response(result: dict) -> PollingSongResponse:
"""Suno API 상태 응답을 파싱하여 PollingSongResponse로 변환합니다."""
code = result.get("code", 0)
data = result.get("data", {})
if code != 200:
return PollingSongResponse(
success=False,
status="failed",
message="Suno API 응답 오류",
clips=None,
raw_response=result,
error_message=result.get("msg", "Unknown error"),
)
status = data.get("status", "unknown")
clips_data = data.get("data", [])
# 상태별 메시지
status_messages = {
"pending": "노래 생성 대기 중입니다.",
"processing": "노래를 생성하고 있습니다.",
"complete": "노래 생성이 완료되었습니다.",
"failed": "노래 생성에 실패했습니다.",
}
# 클립 데이터 파싱
clips = None
if clips_data:
clips = [
SongClipData(
id=clip.get("id"),
audio_url=clip.get("audio_url"),
stream_audio_url=clip.get("stream_audio_url"),
image_url=clip.get("image_url"),
title=clip.get("title"),
status=clip.get("status"),
duration=clip.get("duration"),
)
for clip in clips_data
]
return PollingSongResponse(
success=True,
status=status,
message=status_messages.get(status, f"상태: {status}"),
clips=clips,
raw_response=result,
error_message=None,
)
router = APIRouter(prefix="/song", tags=["song"])
@router.post(
"/generate",
summary="노래 생성 요청",
description="""
Suno API를 통해 노래 생성을 요청합니다.
## 요청 필드
- **lyrics**: 노래에 사용할 가사 (필수)
- **genre**: 음악 장르 (필수) - K-Pop, Pop, R&B, Hip-Hop, Ballad, EDM, Rock, Jazz
- **language**: 노래 언어 (선택, 기본값: Korean)
## 반환 정보
- **success**: 요청 성공 여부
- **task_id**: Suno 작업 ID (폴링에 사용)
- **message**: 응답 메시지
## 사용 예시
```
POST /song/generate
{
"lyrics": "여기 군산에서 만나요\\n아름다운 하루를 함께",
"genre": "K-Pop",
"language": "Korean"
}
```
## 참고
- 생성되는 노래는 1 이내 길이입니다.
- task_id를 사용하여 /status/{task_id} 엔드포인트에서 생성 상태를 확인할 있습니다.
""",
response_model=GenerateSongResponse,
responses={
200: {"description": "노래 생성 요청 성공"},
500: {"description": "노래 생성 요청 실패"},
},
)
async def generate_song(
request_body: GenerateSongRequest,
) -> GenerateSongResponse:
"""가사와 장르를 기반으로 Suno API를 통해 노래를 생성합니다."""
try:
suno_service = SunoService()
task_id = await suno_service.generate(
prompt=request_body.lyrics,
genre=request_body.genre,
)
return GenerateSongResponse(
success=True,
task_id=task_id,
message="노래 생성 요청이 접수되었습니다. task_id로 상태를 조회하세요.",
error_message=None,
)
except Exception as e:
return GenerateSongResponse(
success=False,
task_id=None,
message="노래 생성 요청에 실패했습니다.",
error_message=str(e),
)
@router.get(
"/status/{task_id}",
summary="노래 생성 상태 조회",
description="""
Suno API를 통해 노래 생성 작업의 상태를 조회합니다.
## 경로 파라미터
- **task_id**: 노래 생성 반환된 작업 ID (필수)
## 반환 정보
- **success**: 조회 성공 여부
- **status**: 작업 상태 (pending, processing, complete, failed)
- **message**: 상태 메시지
- **clips**: 생성된 노래 클립 목록 (완료 )
- **raw_response**: Suno API 원본 응답
## 사용 예시
```
GET /song/status/abc123...
```
## 상태 값
- **pending**: 대기
- **processing**: 생성
- **complete**: 생성 완료
- **failed**: 생성 실패
## 참고
- 스트림 URL: 30-40 생성
- 다운로드 URL: 2-3 생성
""",
response_model=PollingSongResponse,
responses={
200: {"description": "상태 조회 성공"},
500: {"description": "상태 조회 실패"},
},
)
async def get_song_status(
task_id: str,
) -> PollingSongResponse:
"""task_id로 노래 생성 작업의 상태를 조회합니다."""
try:
suno_service = SunoService()
result = await suno_service.get_task_status(task_id)
return _parse_suno_status_response(result)
except Exception as e:
return PollingSongResponse(
success=False,
status="error",
message="상태 조회에 실패했습니다.",
clips=None,
raw_response=None,
error_message=str(e),
)

View File

@ -1,8 +1,130 @@
from dataclasses import dataclass, field from dataclasses import dataclass, field
from datetime import datetime from datetime import datetime
from typing import Dict, List from typing import Any, Dict, List, Optional
from fastapi import Request from fastapi import Request
from pydantic import BaseModel, Field
# =============================================================================
# Pydantic Schemas for Song Generation API
# =============================================================================
class GenerateSongRequest(BaseModel):
"""노래 생성 요청 스키마
Usage:
POST /song/generate
Request body for generating a song via Suno API.
Example Request:
{
"lyrics": "인스타 감성의 스테이 머뭄...",
"genre": "k-pop",
"language": "Korean"
}
"""
model_config = {
"json_schema_extra": {
"example": {
"lyrics": "인스타 감성의 스테이 머뭄, 머물러봐요 \n군산 신흥동 말랭이 마을의 마음 힐링 \n사진같은 하루, 여행의 시작 \n보석 같은 이곳은 감성 숙소의 느낌 \n\n인근 명소와 아름다움이 가득한 거리 \n힐링의 바람과 여행의 추억 \n글로벌 감성의 스테이 머뭄, 인스타 감성 \n사진으로 남기고 싶은 그 순간들이 되어줘요",
"genre": "k-pop",
"language": "Korean",
}
}
}
lyrics: str = Field(..., description="노래에 사용할 가사")
genre: str = Field(
...,
description="음악 장르 (K-Pop, Pop, R&B, Hip-Hop, Ballad, EDM, Rock, Jazz 등)",
)
language: str = Field(
default="Korean",
description="노래 언어 (Korean, English, Chinese, Japanese, Thai, Vietnamese)",
)
class GenerateSongResponse(BaseModel):
"""노래 생성 응답 스키마
Usage:
POST /song/generate
Returns the task ID for tracking song generation.
Example Response:
{
"success": true,
"task_id": "abc123...",
"message": "노래 생성 요청이 접수되었습니다."
}
"""
success: bool = Field(..., description="요청 성공 여부")
task_id: Optional[str] = Field(None, description="Suno 작업 ID")
message: str = Field(..., description="응답 메시지")
error_message: Optional[str] = Field(None, description="에러 메시지 (실패 시)")
class PollingSongRequest(BaseModel):
"""노래 생성 상태 조회 요청 스키마
Usage:
POST /song/polling
Request body for checking song generation status.
Example Request:
{
"task_id": "abc123..."
}
"""
task_id: str = Field(..., description="Suno 작업 ID")
class SongClipData(BaseModel):
"""생성된 노래 클립 정보"""
id: Optional[str] = Field(None, description="클립 ID")
audio_url: Optional[str] = Field(None, description="오디오 URL")
stream_audio_url: Optional[str] = Field(None, description="스트리밍 오디오 URL")
image_url: Optional[str] = Field(None, description="이미지 URL")
title: Optional[str] = Field(None, description="곡 제목")
status: Optional[str] = Field(None, description="클립 상태")
duration: Optional[float] = Field(None, description="노래 길이 (초)")
class PollingSongResponse(BaseModel):
"""노래 생성 상태 조회 응답 스키마
Usage:
POST /song/polling 또는 GET /song/status/{task_id}
Returns the current status of song generation.
Example Response:
{
"success": true,
"status": "complete",
"message": "노래 생성이 완료되었습니다.",
"clips": [...]
}
"""
success: bool = Field(..., description="조회 성공 여부")
status: Optional[str] = Field(
None, description="작업 상태 (pending, processing, complete, failed)"
)
message: str = Field(..., description="상태 메시지")
clips: Optional[List[SongClipData]] = Field(None, description="생성된 노래 클립 목록")
raw_response: Optional[Dict[str, Any]] = Field(None, description="Suno API 원본 응답")
error_message: Optional[str] = Field(None, description="에러 메시지 (실패 시)")
# =============================================================================
# Dataclass Schemas (Legacy)
# =============================================================================
@dataclass @dataclass

View File

@ -40,12 +40,13 @@ Deliver outputs optimized for three formats:1 minute. Ensure that each version a
LYRICS_PROMPT_TEMPLATE = """ LYRICS_PROMPT_TEMPLATE = """
[ROLE] [ROLE]
Content marketing expert specializing in pension/accommodation services in Korea Content marketing expert and creative songwriter specializing in pension/accommodation services
[INPUT] [INPUT]
- Business Name: {customer_name} - Business Name: {customer_name}
- Region: {region} - Region: {region}
- Region Details: {detail_region_info} - Region Details: {detail_region_info}
- Output Language: {language}
[INTERNAL ANALYSIS - DO NOT OUTPUT] [INTERNAL ANALYSIS - DO NOT OUTPUT]
Analyze the following internally to inform lyrics creation: Analyze the following internally to inform lyrics creation:
@ -53,24 +54,59 @@ Analyze the following internally to inform lyrics creation:
- Unique Selling Propositions (USPs) - Unique Selling Propositions (USPs)
- Regional characteristics and nearby attractions (within 10 min access) - Regional characteristics and nearby attractions (within 10 min access)
- Seasonal appeal points - Seasonal appeal points
- Emotional triggers for the target audience
[LYRICS REQUIREMENTS] [LYRICS REQUIREMENTS]
- Must include: business name, region name, main target audience, nearby famous places 1. Must Include Elements:
- Keywords to incorporate: 인스타 감성, 사진같은 하루, 힐링, 여행 - Business name (TRANSLATED or TRANSLITERATED to {language})
- Length: For 1-minute video (approximately 8-12 lines) - Region name (TRANSLATED or TRANSLITERATED to {language})
- Tone: Emotional, trendy, viral-friendly - Main target audience appeal
- Nearby famous places or regional characteristics
2. Keywords to Incorporate (use language-appropriate trendy expressions):
- Korean: 인스타 감성, 사진같은 하루, 힐링, 여행, 감성 숙소
- English: Instagram vibes, picture-perfect day, healing, travel, getaway
- Chinese: 网红打卡, 治愈系, 旅行, 度假, 拍照圣地
- Japanese: インスタ映え, 写真のような一日, 癒し, 旅行, 絶景
- Thai: กสวย, ลใจ, เทยว, ายร, วสวย
- Vietnamese: check-in đẹp, healing, du lịch, nghỉ dưỡng, view đẹp
3. Structure:
- Length: For 1-minute video (approximately 8-12 lines)
- Flow: Verse structure suitable for music
- Rhythm: Natural speech rhythm in the specified language
4. Tone:
- Emotional and heartfelt
- Trendy and viral-friendly
- Relatable to target audience
[CRITICAL LANGUAGE REQUIREMENT - ABSOLUTE RULE]
ALL OUTPUT MUST BE 100% WRITTEN IN {language} - NO EXCEPTIONS
- ALL lyrics content: {language} ONLY
- ALL proper nouns (business names, region names, place names): MUST be translated or transliterated to {language}
- Korean input like "군산" must become "Gunsan" in English, "群山" in Chinese, "グンサン" in Japanese, etc.
- Korean input like "스테이 머뭄" must become "Stay Meoum" in English, "住留" in Chinese, "ステイモーム" in Japanese, etc.
- ZERO Korean characters (한글) allowed when output language is NOT Korean
- ZERO mixing of languages - the entire output must be monolingual in {language}
- This is a NON-NEGOTIABLE requirement
- Any output containing characters from other languages is considered a COMPLETE FAILURE
- Violation of this rule invalidates the entire response
[OUTPUT RULES - STRICTLY ENFORCED] [OUTPUT RULES - STRICTLY ENFORCED]
- Output lyrics ONLY - Output lyrics ONLY
- Lyrics MUST be written in Korean (한국어) - Lyrics MUST be written ENTIRELY in {language} - NO EXCEPTIONS
- ALL names and places MUST be in {language} script/alphabet
- NO Korean (한글), Chinese (漢字), Japanese (仮名), Thai (ไทย), or Vietnamese (Tiếng Việt) characters unless that is the selected output language
- NO titles, descriptions, analysis, or explanations - NO titles, descriptions, analysis, or explanations
- NO greetings or closing remarks - NO greetings or closing remarks
- NO additional commentary before or after lyrics - NO additional commentary before or after lyrics
- NO line numbers or labels
- Follow the exact format below - Follow the exact format below
[OUTPUT FORMAT - SUCCESS] [OUTPUT FORMAT - SUCCESS]
--- ---
[Lyrics in Korean here] [Lyrics ENTIRELY in {language} here - no other language characters allowed]
--- ---
[OUTPUT FORMAT - FAILURE] [OUTPUT FORMAT - FAILURE]
@ -181,6 +217,7 @@ class ChatgptService:
customer_name: str, customer_name: str,
region: str, region: str,
detail_region_info: str = "", detail_region_info: str = "",
language: str = "Korean",
): ):
# 최신 모델: GPT-5, GPT-5 mini, GPT-5 nano, GPT-4.1, GPT-4.1 mini, GPT-4.1 nano # 최신 모델: GPT-5, GPT-5 mini, GPT-5 nano, GPT-4.1, GPT-4.1 mini, GPT-4.1 nano
# 이전 세대: GPT-4o, GPT-4o mini, GPT-4 Turbo, GPT-3.5 Turbo # 이전 세대: GPT-4o, GPT-4o mini, GPT-4 Turbo, GPT-3.5 Turbo
@ -189,6 +226,7 @@ class ChatgptService:
self.customer_name = customer_name self.customer_name = customer_name
self.region = region self.region = region
self.detail_region_info = detail_region_info self.detail_region_info = detail_region_info
self.language = language
def build_lyrics_prompt(self) -> str: def build_lyrics_prompt(self) -> str:
"""LYRICS_PROMPT_TEMPLATE에 고객 정보를 대입하여 완성된 프롬프트 반환""" """LYRICS_PROMPT_TEMPLATE에 고객 정보를 대입하여 완성된 프롬프트 반환"""
@ -196,6 +234,7 @@ class ChatgptService:
customer_name=self.customer_name, customer_name=self.customer_name,
region=self.region, region=self.region,
detail_region_info=self.detail_region_info, detail_region_info=self.detail_region_info,
language=self.language,
) )
def build_market_analysis_prompt(self) -> str: def build_market_analysis_prompt(self) -> str:

172
app/utils/suno.py Normal file
View File

@ -0,0 +1,172 @@
"""
Suno API 클라이언트 모듈
API 문서: https://docs.sunoapi.org
## 사용법
```python
from app.utils.suno import SunoService
# config에서 자동으로 API 키를 가져옴
suno = SunoService()
# 또는 명시적으로 API 키 전달
suno = SunoService(api_key="your_api_key")
# 음악 생성 요청
task_id = await suno.generate(
prompt="[Verse]\\n오늘도 좋은 하루...",
style="K-Pop, Happy, 110 BPM",
title="좋은 하루"
)
# 상태 확인 (폴링 방식)
result = await suno.get_task_status(task_id)
```
## 콜백 URL 사용법
generate() 호출 callback_url 파라미터를 전달하면 생성 완료 해당 URL로 POST 요청이 전송됩니다.
콜백 요청 형식:
```json
{
"code": 200,
"msg": "All generated successfully.",
"data": {
"callbackType": "complete",
"task_id": "작업ID",
"data": [
{
"id": "clip_id",
"audio_url": "https://...",
"image_url": "https://...",
"title": "곡 제목",
"status": "complete"
}
]
}
}
```
콜백 주의사항:
- HTTPS 프로토콜 권장
- 15 응답 필수
- 동일 task_id에 대해 여러 콜백 수신 가능 (멱등성 처리 필요)
"""
from typing import Any
import httpx
from config import apikey_settings
class SunoService:
"""Suno API를 통한 AI 음악 생성 서비스"""
BASE_URL = "https://api.sunoapi.org/api/v1"
def __init__(self, api_key: str | None = None):
"""
Args:
api_key: Suno API (Bearer token으로 사용)
None일 경우 config에서 자동으로 가져옴
"""
self.api_key = api_key or apikey_settings.SUNO_API_KEY
self.headers = {
"Authorization": f"Bearer {self.api_key}",
"Content-Type": "application/json",
}
async def generate(
self,
prompt: str,
genre: str | None = None,
callback_url: str | None = None,
) -> str:
"""
음악 생성 요청
Args:
prompt: 가사 (customMode=true일 가사로 사용)
1 이내 길이의 노래에 적합한 가사여야
genre: 음악 장르 (: "K-Pop", "Pop", "R&B", "Hip-Hop", "Ballad", "EDM")
None일 경우 style 파라미터를 전송하지 않음
callback_url: 생성 완료 알림 받을 URL (None일 경우 config에서 기본값 사용)
Returns:
task_id: 작업 추적용 ID
Note:
- 스트림 URL: 30-40 생성
- 다운로드 URL: 2-3 생성
- 생성되는 노래는 1 이내의 길이
"""
# 1분 이내 노래 생성을 위한 프롬프트 조건 추가
formatted_prompt = f"[Short Song - Under 1 minute]\n{prompt}"
# callback_url이 없으면 config에서 기본값 사용 (Suno API 필수 파라미터)
actual_callback_url = callback_url or apikey_settings.SUNO_CALLBACK_URL
payload: dict[str, Any] = {
"model": "V5",
"customMode": True,
"instrumental": False,
"prompt": formatted_prompt,
"callBackUrl": actual_callback_url,
}
# genre가 있을 때만 style 추가
if genre:
payload["style"] = genre
async with httpx.AsyncClient() as client:
response = await client.post(
f"{self.BASE_URL}/generate",
headers=self.headers,
json=payload,
timeout=30.0,
)
response.raise_for_status()
data = response.json()
# 응답: {"code": 200, "msg": "success", "data": {"taskId": "..."}}
# API 응답 검증
if data is None:
raise ValueError("Suno API returned empty response")
if data.get("code") != 200:
error_msg = data.get("msg", "Unknown error")
raise ValueError(f"Suno API error: {error_msg}")
response_data = data.get("data")
if response_data is None:
raise ValueError(f"Suno API response missing 'data' field: {data}")
task_id = response_data.get("taskId")
if task_id is None:
raise ValueError(f"Suno API response missing 'taskId': {response_data}")
return task_id
async def get_task_status(self, task_id: str) -> dict[str, Any]:
"""
음악 생성 작업 상태 확인
Args:
task_id: generate()에서 반환된 작업 ID
Returns:
작업 상태 정보 (status, audio_url, image_url 포함)
Note:
폴링 방식으로 상태 확인 사용.
콜백 URL을 사용하면 폴링 없이 결과를 받을 있음.
"""
async with httpx.AsyncClient() as client:
response = await client.get(
f"{self.BASE_URL}/generate/record-info",
headers=self.headers,
params={"taskId": task_id},
timeout=30.0,
)
response.raise_for_status()
return response.json()

View File

@ -25,6 +25,10 @@ class ProjectSettings(BaseSettings):
class APIKeySettings(BaseSettings): class APIKeySettings(BaseSettings):
CHATGPT_API_KEY: str = Field(default="your-chatgpt-api-key") # 기본값 추가 CHATGPT_API_KEY: str = Field(default="your-chatgpt-api-key") # 기본값 추가
SUNO_API_KEY: str = Field(default="your-suno-api-key") # Suno API 키
SUNO_CALLBACK_URL: str = Field(
default="https://example.com/api/suno/callback"
) # Suno 콜백 URL (필수)
model_config = _base_config model_config = _base_config

View File

@ -8,6 +8,7 @@ from app.core.common import lifespan
from app.database.session import engine from app.database.session import engine
from app.home.api.routers.v1.home import router as home_router from app.home.api.routers.v1.home import router as home_router
from app.lyric.api.routers.v1.lyric import router as lyric_router from app.lyric.api.routers.v1.lyric import router as lyric_router
from app.song.api.routers.v1.song import router as song_router
from app.utils.cors import CustomCORSMiddleware from app.utils.cors import CustomCORSMiddleware
from config import prj_settings from config import prj_settings
@ -48,3 +49,4 @@ def get_scalar_docs():
app.include_router(home_router) app.include_router(home_router)
app.include_router(lyric_router) # Lyric API 라우터 추가 app.include_router(lyric_router) # Lyric API 라우터 추가
app.include_router(song_router) # Song API 라우터 추가