9 Commits

Author SHA1 Message Date
eb9bd65033 feat(music-lab): Suno API 전체 기능 확장 — Phase 1~3 (생성 강화, 후처리, 고급 크리에이티브) 2026-04-09 07:34:20 +09:00
a6fd44c697 fix(music-lab): DB 업데이트 함수 연결 — 커버이미지/WAV/스템/비디오 결과 영구 저장
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-08 09:19:47 +09:00
ad939dde40 docs: music-lab API 목록 업데이트 — Phase 1~3 신규 엔드포인트 반영
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-08 09:15:19 +09:00
26997a7dc7 feat(music-lab): Phase 3 백엔드 — 업로드커버, 업로드확장, 보컬추가, 인스트추가, 뮤직비디오
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-08 09:10:07 +09:00
94969f97a8 feat(music-lab): Phase 2 백엔드 — WAV 변환, 12스템 분리, 타임스탬프 가사, 스타일 부스트
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-08 08:58:37 +09:00
3e46cc41ca refactor(music-lab): 공통 폴링 헬퍼 추출 + V5_5 모델 + 신규 파라미터 + 커버이미지 2026-04-08 08:42:36 +09:00
214eb320fa feat(music-lab): Phase 1 DB 마이그레이션 + GenerateRequest 확장 + 커버이미지 엔드포인트 2026-04-08 08:42:14 +09:00
c8ee3bb95b docs: music-lab Suno API 전체 기능 확장 구현 계획
10개 Task, 3 Phase 구조의 상세 구현 계획.
Phase 1(생성 강화), Phase 2(후처리), Phase 3(리믹스).

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-08 03:26:40 +09:00
6ffa04f847 docs: music-lab Suno API 전체 기능 확장 설계 스펙
Suno API 미사용 기능 분석 후 3단계 점진 확장 설계.
Phase 1(생성 강화), Phase 2(후처리), Phase 3(리믹스) 구조.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-08 03:13:41 +09:00
6 changed files with 3862 additions and 50 deletions

View File

@@ -259,12 +259,30 @@ docker compose up -d
| 메서드 | 경로 | 설명 | | 메서드 | 경로 | 설명 |
|--------|------|------| |--------|------|------|
| GET | `/api/music/providers` | 사용 가능한 프로바이더 목록 | | GET | `/api/music/providers` | 사용 가능한 프로바이더 목록 |
| POST | `/api/music/generate` | 음악 생성 시작 (provider, lyrics, instrumental 지원) | | GET | `/api/music/models` | Suno 모델 목록 (V4~V5.5) |
| GET | `/api/music/status/{task_id}` | 생성 상태 폴링 (queued→processing→succeeded/failed) | | GET | `/api/music/credits` | Suno 크레딧 조회 |
| POST | `/api/music/lyrics` | Suno AI 가사 생성 (곡 생성 전 미리보기용) | | POST | `/api/music/generate` | 음악 생성 (provider, model, vocal_gender, negative_tags, style_weight, audio_weight) |
| GET | `/api/music/status/{task_id}` | 생성 상태 폴링 |
| POST | `/api/music/lyrics` | Suno AI 가사 생성 |
| GET | `/api/music/library` | 라이브러리 전체 조회 | | GET | `/api/music/library` | 라이브러리 전체 조회 |
| POST | `/api/music/library` | 트랙 수동 추가 (201) | | POST | `/api/music/library` | 트랙 수동 추가 |
| DELETE | `/api/music/library/{id}` | 트랙 삭제 (로컬 파일 포함) | | DELETE | `/api/music/library/{id}` | 트랙 삭제 |
| POST | `/api/music/extend` | 곡 연장 |
| POST | `/api/music/vocal-removal` | 보컬/인스트 분리 (2트랙) |
| POST | `/api/music/cover-image` | 커버 이미지 2장 생성 |
| POST | `/api/music/wav` | WAV 고음질 변환 |
| POST | `/api/music/stem-split` | 12스템 분리 (50cr) |
| GET | `/api/music/timestamped-lyrics` | 타임스탬프 가사 (가라오케) |
| POST | `/api/music/style-boost` | AI 스타일 프롬프트 생성 |
| POST | `/api/music/upload-cover` | 외부 음원 AI Cover |
| POST | `/api/music/upload-extend` | 외부 음원 확장 |
| POST | `/api/music/add-vocals` | 인스트에 AI 보컬 추가 |
| POST | `/api/music/add-instrumental` | 보컬에 AI 반주 추가 |
| POST | `/api/music/video` | 뮤직비디오 MP4 생성 |
| GET | `/api/music/lyrics/library` | 저장된 가사 목록 |
| POST | `/api/music/lyrics/library` | 가사 저장 |
| PUT | `/api/music/lyrics/library/{id}` | 가사 수정 |
| DELETE | `/api/music/lyrics/library/{id}` | 가사 삭제 |
**환경변수** **환경변수**
- `SUNO_API_KEY`: Suno API 키 (미설정 시 Suno provider 비활성화) - `SUNO_API_KEY`: Suno API 키 (미설정 시 Suno provider 비활성화)
@@ -277,6 +295,11 @@ docker compose up -d
- `lyrics`: Suno 생성 가사 텍스트 - `lyrics`: Suno 생성 가사 텍스트
- `image_url`: Suno 생성 커버 이미지 URL - `image_url`: Suno 생성 커버 이미지 URL
- `suno_id`: Suno 곡 ID (CDN 참조용) - `suno_id`: Suno 곡 ID (CDN 참조용)
- `file_hash`: MD5 해시 (rename 감지용)
- `cover_images`: JSON 배열 — 커버 이미지 URL 목록
- `wav_url`: WAV 변환 URL
- `video_url`: 뮤직비디오 URL
- `stem_urls`: JSON 객체 — 12스템 URL 맵
**Suno 생성 특이사항** **Suno 생성 특이사항**
- 1회 생성 시 2개 변형(variation) 반환 → 둘 다 라이브러리에 저장 - 1회 생성 시 2개 변형(variation) 반환 → 둘 다 라이브러리에 저장

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,398 @@
# Music Lab Suno API 전체 기능 확장 설계
> 작성일: 2026-04-08
> 범위: 백엔드 (web-backend/music-lab) + 프론트엔드 (web-ui/src/pages/music)
---
## 1. 목표
Suno API의 미사용 기능을 전부 활용하여 music-lab을 완전한 AI 음악 프로덕션 스튜디오로 업그레이드한다. 실용도 기준 3단계로 점진 확장.
## 2. 단계별 기능 목록
### Phase 1: 핵심 생성 강화
| # | 기능 | 설명 |
|---|------|------|
| 1-1 | 크레딧 잔액 상시 표시 | 헤더에 실시간 크레딧 배지, 10 이하 경고 |
| 1-2 | 보컬 성별 선택 | Male / Female / Auto 3버튼 |
| 1-3 | negativeTags | 제외 스타일 텍스트 + 프리셋 칩 |
| 1-4 | V5_5 모델 추가 | SUNO_MODELS 딕셔너리에 추가 |
| 1-5 | styleWeight / audioWeight | 0~1.0 슬라이더 2개 |
| 1-6 | 커버 이미지 생성 | 라이브러리 카드에서 앨범아트 2장 생성 |
### Phase 2: 후처리 파워업
| # | 기능 | 설명 |
|---|------|------|
| 2-1 | WAV 고음질 변환 | MP3→WAV 변환 다운로드 |
| 2-2 | 12스템 분리 | 드럼/베이스/기타 등 12개 개별 추출 |
| 2-3 | 타임스탬프 가사 | 가라오케 스타일 싱크 재생 |
| 2-4 | 스타일 부스트 | AI로 최적 스타일 프롬프트 자동 생성 |
### Phase 3: 고급 크리에이티브
| # | 기능 | 설명 |
|---|------|------|
| 3-1 | 오디오 업로드 + 커버 | 외부 음원을 Suno 스타일로 리메이크 |
| 3-2 | 오디오 업로드 + 확장 | 외부 음원 이어서 만들기 |
| 3-3 | 보컬 추가 | 인스트루멘탈에 AI 보컬 입히기 |
| 3-4 | 인스트루멘탈 추가 | 보컬에 AI 반주 입히기 |
| 3-5 | 뮤직비디오 생성 | MP4 자동 생성 |
---
## 3. 백엔드 API 설계
### 3.1 기존 엔드포인트 수정
#### GenerateRequest 스키마 확장 (main.py)
```python
class GenerateRequest(BaseModel):
# 기존 필드 유지
provider: str = "suno"
model: str = "V4"
title: str = ""
genre: str = ""
moods: list[str] = []
instruments: list[str] = []
duration_sec: int | None = None
bpm: int | None = None
key: str = ""
scale: str = ""
prompt: str = ""
lyrics: str = ""
instrumental: bool = False
# Phase 1 추가
vocal_gender: str | None = None # "m" | "f" | None(auto)
negative_tags: str | None = None # 제외 스타일
style_weight: float | None = None # 0.0~1.0
audio_weight: float | None = None # 0.0~1.0
```
#### SUNO_MODELS 확장 (suno_provider.py)
```python
SUNO_MODELS = {
"V4": {"name": "V4", "max_duration": 240},
"V4_5": {"name": "V4.5", "max_duration": 480},
"V4_5PLUS": {"name": "V4.5+", "max_duration": 480},
"V4_5ALL": {"name": "V4.5 All","max_duration": 480},
"V5": {"name": "V5", "max_duration": 480},
"V5_5": {"name": "V5.5", "max_duration": 480}, # 추가
}
```
#### _build_suno_payload 확장
새 파라미터를 Suno API 페이로드에 매핑:
- `vocal_gender``vocalGender`
- `negative_tags``negativeTags`
- `style_weight``styleWeight`
- `audio_weight``audioWeight`
None이 아닌 경우에만 페이로드에 포함.
### 3.2 신규 엔드포인트
#### Phase 1
```
POST /api/music/cover-image
Request: { "task_id": str, "suno_id": str }
Response: { "task_id": str } → 폴링 → { "images": [url1, url2] }
```
#### Phase 2
```
POST /api/music/wav
Request: { "task_id": str, "suno_id": str }
Response: { "task_id": str } → 폴링 → { "wav_url": str }
POST /api/music/stem-split
Request: { "task_id": str, "suno_id": str }
Response: { "task_id": str } → 폴링 → { "stems": { vocal: url, drums: url, ... } }
GET /api/music/timestamped-lyrics?task_id=...&suno_id=...
Response: { "aligned_words": [...], "waveform_data": [...] }
POST /api/music/style-boost
Request: { "content": str }
Response: { "result": str, "credits_consumed": float }
```
#### Phase 3
```
POST /api/music/upload-cover
Request: { "upload_url": str, "model": str, "custom_mode": bool,
"instrumental": bool, "prompt"?: str, "style"?: str, "title"?: str,
"vocal_gender"?: str, "negative_tags"?: str,
"style_weight"?: float, "audio_weight"?: float }
Response: { "task_id": str }
POST /api/music/upload-extend
Request: { "upload_url": str, "model": str, "continue_at"?: float,
"default_param_flag": bool, "prompt"?: str, "style"?: str, "title"?: str,
"vocal_gender"?: str, "negative_tags"?: str }
Response: { "task_id": str }
POST /api/music/add-vocals
Request: { "upload_url": str, "prompt": str, "title": str, "style": str,
"negative_tags": str, "vocal_gender"?: str, "model"?: str,
"style_weight"?: float, "audio_weight"?: float }
Response: { "task_id": str }
POST /api/music/add-instrumental
Request: { "upload_url": str, "title": str, "tags": str, "negative_tags": str,
"vocal_gender"?: str, "model"?: str,
"style_weight"?: float, "audio_weight"?: float }
Response: { "task_id": str }
POST /api/music/video
Request: { "task_id": str, "suno_id": str, "author"?: str, "domain_name"?: str }
Response: { "task_id": str } → 폴링 → { "video_url": str }
```
### 3.3 suno_provider.py 리팩토링
**공통 폴링 헬퍼 추출:**
```python
def _poll_suno_task(
record_info_url: str,
task_id: str,
max_attempts: int = 40,
interval: int = 8,
success_extractor: Callable[[dict], Any] = None
) -> dict:
"""
범용 Suno 작업 폴링.
record_info_url: 예) "/api/v1/generate/record-info"
success_extractor: SUCCESS 상태일 때 결과 추출 함수
"""
```
기존 `run_suno_generation`, `run_suno_extend`, `run_vocal_removal`도 이 헬퍼를 사용하도록 리팩토링.
**신규 함수 목록:**
| 함수 | Phase | Suno 엔드포인트 | 비동기 |
|------|-------|----------------|--------|
| `run_cover_image` | 1 | `POST /api/v1/suno/cover/generate` | 폴링 |
| `run_wav_convert` | 2 | `POST /api/v1/wav/generate` | 폴링 |
| `run_stem_split` | 2 | `POST /api/v1/vocal-removal/generate` (type=split_stem) | 폴링 |
| `get_timestamped_lyrics` | 2 | `POST /api/v1/generate/get-timestamped-lyrics` | 동기 |
| `generate_style_boost` | 2 | `POST /api/v1/style/generate` | 동기 |
| `run_upload_cover` | 3 | `POST /api/v1/generate/upload-cover` | 폴링 |
| `run_upload_extend` | 3 | `POST /api/v1/generate/upload-extend` | 폴링 |
| `run_add_vocals` | 3 | `POST /api/v1/generate/add-vocals` | 폴링 |
| `run_add_instrumental` | 3 | `POST /api/v1/generate/add-instrumental` | 폴링 |
| `run_video_generate` | 3 | `POST /api/v1/mp4/generate` | 폴링 |
### 3.4 DB 스키마 변경
**music_library 테이블 컬럼 추가 (ALTER TABLE 마이그레이션):**
```sql
ALTER TABLE music_library ADD COLUMN cover_images TEXT NOT NULL DEFAULT '[]';
ALTER TABLE music_library ADD COLUMN wav_url TEXT NOT NULL DEFAULT '';
ALTER TABLE music_library ADD COLUMN video_url TEXT NOT NULL DEFAULT '';
ALTER TABLE music_library ADD COLUMN stem_urls TEXT NOT NULL DEFAULT '{}';
```
**db.py 함수 추가:**
```python
def update_track_cover_images(track_id: int, images: list[str])
def update_track_wav_url(track_id: int, wav_url: str)
def update_track_video_url(track_id: int, video_url: str)
def update_track_stem_urls(track_id: int, stems: dict)
```
---
## 4. 프론트엔드 UI/UX 설계
### 4.1 파일 구조 (컴포넌트 분할)
```
web-ui/src/pages/music/
├── MusicStudio.jsx -- 메인 (탭 라우팅 + 공유 상태)
├── MusicStudio.css -- 전체 스타일
├── components/
│ ├── CreateTab.jsx -- 생성 폼 (6단계 + Phase 1 확장)
│ ├── LyricsTab.jsx -- 가사 관리
│ ├── LibraryTab.jsx -- 라이브러리 + 카드
│ ├── RemixTab.jsx -- Phase 3: 업로드/리믹스
│ ├── AudioPlayer.jsx -- 오디오 플레이어
│ ├── LibraryCard.jsx -- 트랙 카드 + 액션 메뉴
│ ├── StemModal.jsx -- 12스템 결과 모달
│ ├── CoverArtModal.jsx -- 커버 이미지 선택 모달
│ ├── SyncedLyricsPlayer.jsx -- 타임스탬프 가사 오버레이
│ └── CreditsBadge.jsx -- 크레딧 잔액 배지
```
### 4.2 Phase 1 UI 변경
#### 크레딧 배지 (CreditsBadge)
- 위치: 헤더 우측 상단, 탭 옆
- 표시: `⚡ 127 credits`
- 10 이하: 빨간색 + pulse 애니메이션
- 갱신 시점: 페이지 로드, 생성 완료 후, 30초 자동 갱신
#### Create 탭 Step 4 확장
**Vocal Gender (Suno 전용):**
- 3버튼 토글: `♂ Male` | `♀ Female` | `Auto`
- 기본값: Auto
- 스타일: `.ms-gender-toggle` (기존 duration-rail과 유사)
**Negative Tags:**
- 텍스트 입력 필드 + 프리셋 칩
- 프리셋: screaming, autotune, distortion, whisper, falsetto, rap
- 칩 클릭 시 텍스트에 추가/제거
- 스타일: `.ms-negative-tags` (기존 mood-rack과 유사)
**Style Weight / Audio Weight:**
- range 슬라이더 2개 (기존 BPM 슬라이더와 동일 스타일)
- 레이블: "Prompt ↔ Style Balance" / "Original ↔ AI Balance"
- 0~100 표시, API 전송 시 0.0~1.0 변환
- 기본값: 미설정 (슬라이더 중앙, 값 전송 안 함)
#### Library 카드 액션 메뉴 확장
기존 5개 버튼 → 6개 (Cover Art 추가)
4개 초과 시 `•••` 더보기 드롭다운 메뉴로 분기:
- 기본 노출: Play, Download, Delete
- 더보기: Extend, Vocal Split, Cover Art (+ Phase 2/3 추가분)
#### CoverArtModal
- 2장 이미지 좌우 비교 표시
- 각 이미지 아래 "이 이미지 사용" 버튼
- 선택 시 라이브러리 카드 썸네일 업데이트
### 4.3 Phase 2 UI 변경
#### Library 카드 더보기 메뉴 추가
- WAV 다운로드
- Stem Split (12스템)
- Synced Lyrics
- Style Boost (Create 탭 프롬프트로 전달)
#### StemModal
- 3×4 그리드 카드 레이아웃
- 각 스템: 이름 아이콘 + 미니 재생 버튼 + 다운로드 버튼
- 12개 스템: vocal, backing_vocals, drums, bass, guitar, keyboard, strings, brass, woodwinds, percussion, synth, fx
- 스타일: 기존 라이브러리 카드의 축소 버전
#### SyncedLyricsPlayer
- AudioPlayer 교체/오버레이 모드
- 재생 중 현재 단어를 accent 컬러로 하이라이트
- 하단에 waveformData 기반 파형 바
- 닫기 버튼으로 일반 플레이어 복귀
#### Style Boost 버튼
- Create 탭 장르 선택 영역에 `✨ Style Boost` 버튼
- 클릭 시: 현재 genre + moods 조합 → API 호출 → 결과를 프롬프트에 삽입
- 로딩 중 버튼 스피너
### 4.4 Phase 3 UI 변경
#### Remix 탭 (신규 4번째 탭)
- 탭 레이블: `REMIX`
- 상단: 오디오 URL 입력 필드 (또는 라이브러리에서 선택)
- 4개 액션 카드 그리드 (2×2):
- **AI Cover**: 아이콘 + 설명 + 파라미터 폼 (펼침)
- **Extend**: 아이콘 + 설명 + continue_at 입력
- **Add Vocals**: 아이콘 + 설명 + prompt/style 입력
- **Add Instrumental**: 아이콘 + 설명 + tags 입력
- 선택한 카드만 펼쳐서 세부 옵션 표시
- 하단: 뮤직비디오 생성 버튼 (라이브러리에서 선택한 곡 대상)
### 4.5 디자인 토큰 추가
```css
/* Phase 1 추가 토큰 */
--ms-danger: #e74c3c; /* 크레딧 경고 빨간색 */
--ms-male: #4a9eff; /* 남성 보컬 파란색 */
--ms-female: #ff6b9d; /* 여성 보컬 분홍색 */
```
---
## 5. api.js 추가 함수
```javascript
// Phase 1
export const generateCoverImage = (payload) => apiPost('/api/music/cover-image', payload);
// Phase 2
export const convertToWav = (payload) => apiPost('/api/music/wav', payload);
export const splitStems = (payload) => apiPost('/api/music/stem-split', payload);
export const getTimestampedLyrics = (taskId, sunoId) =>
apiGet(`/api/music/timestamped-lyrics?task_id=${taskId}&suno_id=${sunoId}`);
export const generateStyleBoost = (content) => apiPost('/api/music/style-boost', { content });
// Phase 3
export const uploadAndCover = (payload) => apiPost('/api/music/upload-cover', payload);
export const uploadAndExtend = (payload) => apiPost('/api/music/upload-extend', payload);
export const addVocals = (payload) => apiPost('/api/music/add-vocals', payload);
export const addInstrumental = (payload) => apiPost('/api/music/add-instrumental', payload);
export const generateVideo = (payload) => apiPost('/api/music/video', payload);
```
---
## 6. 폴링 패턴
모든 비동기 작업은 기존 음악 생성과 동일한 폴링 패턴:
1. POST 요청 → `{ task_id }` 반환
2. 프론트: 3초 간격 `GET /api/music/status/{task_id}` 폴링
3. 백엔드: BackgroundTask에서 Suno 폴링 → music_tasks 상태 업데이트
4. `status: succeeded` → 결과 반환 + 라이브러리 자동 갱신
동기 API (타임스탬프 가사, 스타일 부스트)는 즉시 응답.
---
## 7. 구현 순서
### Phase 1 (핵심 생성 강화)
1. 백엔드: SUNO_MODELS에 V5_5 추가 + 공통 폴링 헬퍼 추출
2. 백엔드: GenerateRequest 스키마 확장 + _build_suno_payload 매핑
3. 백엔드: 커버 이미지 생성 엔드포인트 + suno_provider 함수
4. 백엔드: DB 마이그레이션 (cover_images, wav_url, video_url, stem_urls)
5. 프론트: 컴포넌트 분할 (MusicStudio → CreateTab, LyricsTab, LibraryTab, etc.)
6. 프론트: CreditsBadge 구현
7. 프론트: Create 탭 Step 4 확장 (vocal gender, negative tags, weight 슬라이더)
8. 프론트: LibraryCard 더보기 메뉴 + CoverArtModal
9. 프론트: api.js 함수 추가
### Phase 2 (후처리 파워업)
10. 백엔드: WAV/스템/타임스탬프/스타일부스트 엔드포인트
11. 프론트: StemModal + SyncedLyricsPlayer + Style Boost 버튼
12. 프론트: Library 카드 Phase 2 액션 추가
### Phase 3 (고급 크리에이티브)
13. 백엔드: upload-cover/upload-extend/add-vocals/add-instrumental/video 엔드포인트
14. 프론트: RemixTab 구현
15. 프론트: Library 카드 Phase 3 액션 (Video)
---
## 8. 제약사항 및 주의점
- **Suno 파일 보관**: 14~15일 후 자동 삭제 → 로컬 다운로드 필수 (기존 패턴 유지)
- **동시 요청 제한**: 10초당 20건 → 배치 작업 시 rate limiting 고려
- **12스템 분리 비용**: 50크레딧 → UI에 비용 경고 표시
- **WAV 중복 변환**: 409 에러 → 이미 변환된 경우 기존 URL 반환
- **뮤직비디오**: taskId + audioId 필요 → 라이브러리에 task_id 저장 필수
- **V5_5 모델**: 커스텀 모델 → 문서상 제한사항 추가 확인 필요
- **크레딧 조회 엔드포인트**: 기존 `/api/v1/get-credits` vs 문서 `/api/v1/generate/credit` → 두 엔드포인트 폴백 시도
- **upload 계열 API**: upload_url은 외부 접근 가능한 URL이어야 함 → 로컬 파일은 NAS nginx URL로 변환

View File

@@ -83,6 +83,18 @@ def init_db() -> None:
except sqlite3.OperationalError: except sqlite3.OperationalError:
pass pass
# Phase 1~3 신규 컬럼 마이그레이션
for col, default in [
("cover_images", "'[]'"),
("wav_url", "''"),
("video_url", "''"),
("stem_urls", "'{}'"),
]:
try:
conn.execute(f"ALTER TABLE music_library ADD COLUMN {col} TEXT NOT NULL DEFAULT {default}")
except sqlite3.OperationalError:
pass
# ── music_tasks CRUD ────────────────────────────────────────────────────────── # ── music_tasks CRUD ──────────────────────────────────────────────────────────
@@ -161,6 +173,10 @@ def _track_row_to_dict(r) -> Dict[str, Any]:
"image_url": r["image_url"] if "image_url" 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 "", "suno_id": r["suno_id"] if "suno_id" in keys else "",
"file_hash": r["file_hash"] if "file_hash" in keys else "", "file_hash": r["file_hash"] if "file_hash" in keys else "",
"cover_images": json.loads(r["cover_images"]) if "cover_images" in keys and r["cover_images"] else [],
"wav_url": r["wav_url"] if "wav_url" in keys else "",
"video_url": r["video_url"] if "video_url" in keys else "",
"stem_urls": json.loads(r["stem_urls"]) if "stem_urls" in keys and r["stem_urls"] else {},
"created_at": r["created_at"], "created_at": r["created_at"],
} }
@@ -300,6 +316,26 @@ def update_lyrics(lyrics_id: int, data: Dict[str, Any]) -> Optional[Dict[str, An
return _lyrics_row_to_dict(row) if row else None return _lyrics_row_to_dict(row) if row else None
def update_track_cover_images(track_id: int, images: list) -> None:
with _conn() as conn:
conn.execute("UPDATE music_library SET cover_images=? WHERE id=?", (json.dumps(images), track_id))
def update_track_wav_url(track_id: int, wav_url: str) -> None:
with _conn() as conn:
conn.execute("UPDATE music_library SET wav_url=? WHERE id=?", (wav_url, track_id))
def update_track_video_url(track_id: int, video_url: str) -> None:
with _conn() as conn:
conn.execute("UPDATE music_library SET video_url=? WHERE id=?", (video_url, track_id))
def update_track_stem_urls(track_id: int, stems: dict) -> None:
with _conn() as conn:
conn.execute("UPDATE music_library SET stem_urls=? WHERE id=?", (json.dumps(stems), track_id))
def delete_lyrics(lyrics_id: int) -> bool: def delete_lyrics(lyrics_id: int) -> bool:
with _conn() as conn: with _conn() as conn:
row = conn.execute("SELECT id FROM saved_lyrics WHERE id = ?", (lyrics_id,)).fetchone() row = conn.execute("SELECT id FROM saved_lyrics WHERE id = ?", (lyrics_id,)).fetchone()

View File

@@ -15,7 +15,9 @@ from .db import (
from .local_provider import run_local_generation from .local_provider import run_local_generation
from .suno_provider import ( from .suno_provider import (
run_suno_generation, run_suno_extend, run_vocal_removal, run_suno_generation, run_suno_extend, run_vocal_removal,
generate_lyrics, get_credits, 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, SUNO_API_KEY, SUNO_MODELS,
) )
@@ -102,6 +104,11 @@ class GenerateRequest(BaseModel):
# Suno 전용 # Suno 전용
lyrics: str = "" # 커스텀 가사 ([Verse], [Chorus] 등) lyrics: str = "" # 커스텀 가사 ([Verse], [Chorus] 등)
instrumental: bool = False # True면 보컬 없이 인스트루멘탈만 instrumental: bool = False # True면 보컬 없이 인스트루멘탈만
# Phase 1 신규
vocal_gender: Optional[str] = None # "m" | "f"
negative_tags: Optional[str] = None # 제외 스타일
style_weight: Optional[float] = None # 0.0~1.0
audio_weight: Optional[float] = None # 0.0~1.0
@app.post("/api/music/generate") @app.post("/api/music/generate")
@@ -402,6 +409,224 @@ def vocal_removal(req: VocalRemovalRequest, background_tasks: BackgroundTasks):
return {"task_id": task_id, "provider": "suno"} return {"task_id": task_id, "provider": "suno"}
# ── 커버 이미지 생성 API ────────────────────────────────────────────────────
class CoverImageRequest(BaseModel):
suno_task_id: str # Suno 생성 task ID
track_id: Optional[int] = None # 라이브러리 트랙 ID (결과 저장용)
@app.post("/api/music/cover-image")
def cover_image(req: CoverImageRequest, background_tasks: BackgroundTasks):
"""Suno 곡의 커버 이미지 2장 생성."""
if not SUNO_API_KEY:
raise HTTPException(status_code=400, detail="Suno API 키가 설정되지 않았습니다")
task_id = str(uuid.uuid4())
params = req.model_dump()
create_task(task_id, params, provider="suno")
background_tasks.add_task(run_cover_image, task_id, params)
return {"task_id": task_id, "provider": "suno"}
# ── WAV 변환 API ────────────────────────────────────────────────────────────
class WavRequest(BaseModel):
suno_task_id: str
suno_id: str
track_id: Optional[int] = None
@app.post("/api/music/wav")
def wav_convert(req: WavRequest, background_tasks: BackgroundTasks):
"""곡을 WAV 포맷으로 변환."""
if not SUNO_API_KEY:
raise HTTPException(status_code=400, detail="Suno API 키가 설정되지 않았습니다")
task_id = str(uuid.uuid4())
params = req.model_dump()
create_task(task_id, params, provider="suno")
background_tasks.add_task(run_wav_convert, task_id, params)
return {"task_id": task_id, "provider": "suno"}
# ── 12스템 분리 API ─────────────────────────────────────────────────────────
class StemSplitRequest(BaseModel):
suno_task_id: str
suno_id: str
track_id: Optional[int] = None
@app.post("/api/music/stem-split")
def stem_split(req: StemSplitRequest, background_tasks: BackgroundTasks):
"""곡을 12개 스템으로 분리 (50 크레딧). 보컬, 드럼, 베이스, 기타 등."""
if not SUNO_API_KEY:
raise HTTPException(status_code=400, detail="Suno API 키가 설정되지 않았습니다")
task_id = str(uuid.uuid4())
params = req.model_dump()
create_task(task_id, params, provider="suno")
background_tasks.add_task(run_stem_split, task_id, params)
return {"task_id": task_id, "provider": "suno"}
# ── 타임스탬프 가사 API ─────────────────────────────────────────────────────
@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)
if not result:
raise HTTPException(status_code=502, detail="타임스탬프 가사 조회 실패")
return result
# ── 스타일 부스트 API ───────────────────────────────────────────────────────
class StyleBoostRequest(BaseModel):
content: str
@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)
if not result:
raise HTTPException(status_code=502, detail="스타일 부스트 생성 실패")
return result
# ── Phase 3: 업로드 + 커버 ──────────────────────────────────────────────────
class UploadCoverRequest(BaseModel):
upload_url: str
model: str = "V4"
custom_mode: bool = True
instrumental: bool = False
prompt: str = ""
style: str = ""
title: str = ""
vocal_gender: Optional[str] = None
negative_tags: Optional[str] = None
style_weight: Optional[float] = None
audio_weight: Optional[float] = None
@app.post("/api/music/upload-cover")
def upload_cover(req: UploadCoverRequest, background_tasks: BackgroundTasks):
"""외부 오디오를 Suno 스타일로 리메이크."""
if not SUNO_API_KEY:
raise HTTPException(status_code=400, detail="Suno API 키가 설정되지 않았습니다")
task_id = str(uuid.uuid4())
params = req.model_dump()
create_task(task_id, params, provider="suno")
background_tasks.add_task(run_upload_cover, task_id, params)
return {"task_id": task_id, "provider": "suno"}
# ── Phase 3: 업로드 + 확장 ──────────────────────────────────────────────────
class UploadExtendRequest(BaseModel):
upload_url: str
model: str = "V4"
default_param_flag: bool = True
continue_at: Optional[float] = None
prompt: str = ""
style: str = ""
title: str = ""
instrumental: bool = False
vocal_gender: Optional[str] = None
negative_tags: Optional[str] = None
@app.post("/api/music/upload-extend")
def upload_extend(req: UploadExtendRequest, background_tasks: BackgroundTasks):
"""외부 오디오를 이어서 확장."""
if not SUNO_API_KEY:
raise HTTPException(status_code=400, detail="Suno API 키가 설정되지 않았습니다")
task_id = str(uuid.uuid4())
params = req.model_dump()
create_task(task_id, params, provider="suno")
background_tasks.add_task(run_upload_extend, task_id, params)
return {"task_id": task_id, "provider": "suno"}
# ── Phase 3: 보컬 추가 ──────────────────────────────────────────────────────
class AddVocalsRequest(BaseModel):
upload_url: str
prompt: str
title: str
style: str
negative_tags: str = ""
vocal_gender: Optional[str] = None
model: str = "V4_5PLUS"
style_weight: Optional[float] = None
audio_weight: Optional[float] = None
@app.post("/api/music/add-vocals")
def add_vocals(req: AddVocalsRequest, background_tasks: BackgroundTasks):
"""인스트루멘탈에 AI 보컬 추가."""
if not SUNO_API_KEY:
raise HTTPException(status_code=400, detail="Suno API 키가 설정되지 않았습니다")
task_id = str(uuid.uuid4())
params = req.model_dump()
create_task(task_id, params, provider="suno")
background_tasks.add_task(run_add_vocals, task_id, params)
return {"task_id": task_id, "provider": "suno"}
# ── Phase 3: 인스트루멘탈 추가 ──────────────────────────────────────────────
class AddInstrumentalRequest(BaseModel):
upload_url: str
title: str
tags: str
negative_tags: str = ""
vocal_gender: Optional[str] = None
model: str = "V4_5PLUS"
style_weight: Optional[float] = None
audio_weight: Optional[float] = None
@app.post("/api/music/add-instrumental")
def add_instrumental(req: AddInstrumentalRequest, background_tasks: BackgroundTasks):
"""보컬에 AI 반주 추가."""
if not SUNO_API_KEY:
raise HTTPException(status_code=400, detail="Suno API 키가 설정되지 않았습니다")
task_id = str(uuid.uuid4())
params = req.model_dump()
create_task(task_id, params, provider="suno")
background_tasks.add_task(run_add_instrumental, task_id, params)
return {"task_id": task_id, "provider": "suno"}
# ── Phase 3: 뮤직비디오 생성 ────────────────────────────────────────────────
class VideoRequest(BaseModel):
suno_task_id: str
suno_id: str
author: str = ""
domain_name: str = ""
track_id: Optional[int] = None
@app.post("/api/music/video")
def video_generate(req: VideoRequest, background_tasks: BackgroundTasks):
"""뮤직비디오(MP4) 생성."""
if not SUNO_API_KEY:
raise HTTPException(status_code=400, detail="Suno API 키가 설정되지 않았습니다")
task_id = str(uuid.uuid4())
params = req.model_dump()
create_task(task_id, params, provider="suno")
background_tasks.add_task(run_video_generate, task_id, params)
return {"task_id": task_id, "provider": "suno"}
# ── 저장된 가사 CRUD API ──────────────────────────────────────────────────── # ── 저장된 가사 CRUD API ────────────────────────────────────────────────────
class LyricsSave(BaseModel): class LyricsSave(BaseModel):

View File

@@ -3,13 +3,18 @@ Suno API Provider — sunoapi.org 래퍼를 통한 음악 생성
https://docs.sunoapi.org/suno-api/quickstart https://docs.sunoapi.org/suno-api/quickstart
""" """
import json
import os import os
import time import time
import logging import logging
import requests import requests
from typing import Optional from typing import Optional
from .db import update_task, add_track from .db import (
update_task, add_track,
update_track_cover_images, update_track_wav_url,
update_track_video_url, update_track_stem_urls,
)
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@@ -29,6 +34,7 @@ SUNO_MODELS = [
{"id": "V4_5PLUS", "name": "V4.5+", "max_duration": "8분", "description": "강화된 음악성"}, {"id": "V4_5PLUS", "name": "V4.5+", "max_duration": "8분", "description": "강화된 음악성"},
{"id": "V4_5ALL", "name": "V4.5 All", "max_duration": "8분", "description": "더 나은 곡 구조"}, {"id": "V4_5ALL", "name": "V4.5 All", "max_duration": "8분", "description": "더 나은 곡 구조"},
{"id": "V5", "name": "V5", "max_duration": "8분", "description": "최신, 빠른 생성 + 뛰어난 음악성"}, {"id": "V5", "name": "V5", "max_duration": "8분", "description": "최신, 빠른 생성 + 뛰어난 음악성"},
{"id": "V5_5", "name": "V5.5", "max_duration": "8분", "description": "커스텀 모델, 최신 음악성"},
] ]
@@ -140,9 +146,13 @@ def run_suno_generation(task_id: str, params: dict) -> None:
logger.info("Suno task created: %s (internal: %s)", suno_task_id, task_id) logger.info("Suno task created: %s (internal: %s)", suno_task_id, task_id)
# ── 2단계: 상태 폴링 ── # ── 2단계: 상태 폴링 ──
completed_tracks = _poll_until_complete(task_id, suno_task_id) response = _poll_suno_record("/generate/record-info", suno_task_id, task_id)
if not response:
return
completed_tracks = response.get("sunoData") or []
if not completed_tracks: if not completed_tracks:
return # 에러는 _poll_until_complete 내부에서 처리 update_task(task_id, "failed", 0, "", error="Suno 생성 완료했으나 트랙 데이터 없음")
return
update_task(task_id, "processing", 80, "오디오 파일 다운로드 중...") update_task(task_id, "processing", 80, "오디오 파일 다운로드 중...")
@@ -231,63 +241,73 @@ def _build_suno_payload(params: dict) -> dict:
parts.append(", ".join(params["moods"])) parts.append(", ".join(params["moods"]))
payload["prompt"] = " ".join(parts)[:500] if parts else "instrumental music" payload["prompt"] = " ".join(parts)[:500] if parts else "instrumental music"
if params.get("vocal_gender"):
payload["vocalGender"] = params["vocal_gender"]
if params.get("negative_tags"):
payload["negativeTags"] = params["negative_tags"]
if params.get("style_weight") is not None:
payload["styleWeight"] = params["style_weight"]
if params.get("audio_weight") is not None:
payload["audioWeight"] = params["audio_weight"]
return payload return payload
def _poll_until_complete(task_id: str, suno_task_id: str) -> Optional[list]: def _poll_suno_record(
"""sunoapi.org record-info를 폴링하여 SUCCESS가 될 때까지 대기.""" record_info_path: str,
suno_task_id: str,
task_id: str,
max_attempts: int = POLL_MAX_ATTEMPTS,
interval: int = POLL_INTERVAL,
progress_msg_map: dict = None,
) -> Optional[dict]:
"""범용 Suno 작업 폴링. SUCCESS 시 response 객체 반환."""
error_statuses = { error_statuses = {
"CREATE_TASK_FAILED", "GENERATE_AUDIO_FAILED", "CREATE_TASK_FAILED", "GENERATE_AUDIO_FAILED",
"CALLBACK_EXCEPTION", "SENSITIVE_WORD_ERROR", "CALLBACK_EXCEPTION", "SENSITIVE_WORD_ERROR",
} }
default_msgs = {
"PENDING": "대기열에서 대기 중...",
"TEXT_SUCCESS": "가사 생성 완료, 음악 생성 중...",
"FIRST_SUCCESS": "첫 번째 트랙 완료, 두 번째 생성 중...",
"GENERATING": "생성 중...",
}
msgs = {**default_msgs, **(progress_msg_map or {})}
for attempt in range(POLL_MAX_ATTEMPTS): for attempt in range(max_attempts):
time.sleep(POLL_INTERVAL) time.sleep(interval)
try: try:
resp = requests.get( resp = requests.get(
f"{SUNO_BASE_URL}/generate/record-info", f"{SUNO_BASE_URL}{record_info_path}",
headers=_headers(), headers=_headers(),
params={"taskId": suno_task_id}, params={"taskId": suno_task_id},
timeout=15, timeout=15,
) )
if resp.status_code != 200: if resp.status_code != 200:
continue continue
body = resp.json() body = resp.json()
if body.get("code") != 200: if body.get("code") != 200:
continue continue
data = body.get("data", {}) data = body.get("data", {})
status = data.get("status", "") status = data.get("status", "")
progress = min(15 + int((attempt / POLL_MAX_ATTEMPTS) * 65), 79) progress = min(15 + int((attempt / max_attempts) * 65), 79)
if status == "PENDING": if status == "SUCCESS":
update_task(task_id, "processing", progress, "대기열에서 대기 중...") return data.get("response", data)
elif status == "TEXT_SUCCESS":
update_task(task_id, "processing", progress, "가사 생성 완료, 음악 생성 중...")
elif status == "FIRST_SUCCESS":
update_task(task_id, "processing", max(progress, 60), "첫 번째 트랙 완료, 두 번째 생성 중...")
elif status == "SUCCESS":
# data.response.sunoData 에 트랙 배열이 들어있음
response_obj = data.get("response", {})
tracks = response_obj.get("sunoData") or []
if tracks:
return tracks
update_task(task_id, "failed", 0, "", error="Suno 생성 완료했으나 트랙 데이터 없음")
return None
elif status in error_statuses: elif status in error_statuses:
error_msg = data.get("errorMessage") or data.get("msg") or f"Suno 생성 실패 ({status})" error_msg = data.get("errorMessage") or data.get("msg") or f"Suno 작업 실패 ({status})"
update_task(task_id, "failed", 0, "", error=error_msg) update_task(task_id, "failed", 0, "", error=error_msg)
return None return None
else: else:
update_task(task_id, "processing", progress, f"처리 중... ({status})") msg = msgs.get(status, f"처리 중... ({status})")
if status == "FIRST_SUCCESS":
progress = max(progress, 60)
update_task(task_id, "processing", progress, msg)
except Exception as e: except Exception as e:
logger.warning("Suno poll error (attempt %d): %s", attempt, e) logger.warning("Suno poll error (attempt %d): %s", attempt, e)
continue continue
update_task(task_id, "failed", 0, "", error="Suno 생성 타임아웃 (5분 초과)") update_task(task_id, "failed", 0, "", error="Suno 작업 타임아웃")
return None return None
@@ -351,20 +371,20 @@ def _download_and_register(
# ── 크레딧 조회 ────────────────────────────────────────────────────────────── # ── 크레딧 조회 ──────────────────────────────────────────────────────────────
def get_credits() -> Optional[dict]: def get_credits() -> Optional[dict]:
"""Suno API 잔여 크레딧 조회.""" """Suno API 잔여 크레딧 조회. 두 엔드포인트 폴백."""
if not SUNO_API_KEY: if not SUNO_API_KEY:
return None return None
try: for path in ["/generate/credit", "/get-credits"]:
resp = requests.get( try:
f"{SUNO_BASE_URL}/get-credits", resp = requests.get(f"{SUNO_BASE_URL}{path}", headers=_headers(), timeout=15)
headers=_headers(), if resp.status_code == 200:
timeout=15, body = resp.json()
) data = body.get("data", body)
if resp.status_code == 200: if isinstance(data, (int, float)):
body = resp.json() return {"credits_left": int(data)}
return body.get("data", body) return data
except Exception as e: except Exception as e:
logger.warning("Suno credits API error: %s", e) logger.warning("Suno credits API error (%s): %s", path, e)
return None return None
@@ -418,8 +438,12 @@ def run_suno_extend(task_id: str, params: dict) -> None:
update_task(task_id, "processing", 15, "곡 연장 대기열에 등록됨...") update_task(task_id, "processing", 15, "곡 연장 대기열에 등록됨...")
completed_tracks = _poll_until_complete(task_id, suno_task_id) response = _poll_suno_record("/generate/record-info", suno_task_id, task_id)
if not response:
return
completed_tracks = response.get("sunoData") or []
if not completed_tracks: if not completed_tracks:
update_task(task_id, "failed", 0, "", error="Suno 연장 완료했으나 트랙 데이터 없음")
return return
update_task(task_id, "processing", 80, "연장된 오디오 다운로드 중...") update_task(task_id, "processing", 80, "연장된 오디오 다운로드 중...")
@@ -483,8 +507,12 @@ def run_vocal_removal(task_id: str, params: dict) -> None:
update_task(task_id, "processing", 15, "보컬 분리 처리 중...") update_task(task_id, "processing", 15, "보컬 분리 처리 중...")
# 보컬 분리 결과 폴링 # 보컬 분리 결과 폴링
completed_tracks = _poll_until_complete(task_id, suno_task_id) response = _poll_suno_record("/vocal-removal/record-info", suno_task_id, task_id)
if not response:
return
completed_tracks = response.get("sunoData") or []
if not completed_tracks: if not completed_tracks:
update_task(task_id, "failed", 0, "", error="보컬 분리 완료했으나 트랙 데이터 없음")
return return
update_task(task_id, "processing", 80, "분리된 오디오 다운로드 중...") update_task(task_id, "processing", 80, "분리된 오디오 다운로드 중...")
@@ -512,3 +540,511 @@ def run_vocal_removal(task_id: str, params: dict) -> None:
except Exception as e: except Exception as e:
logger.exception("Suno vocal removal error for task %s", task_id) logger.exception("Suno vocal removal error for task %s", task_id)
update_task(task_id, "failed", 0, "", error=str(e)) update_task(task_id, "failed", 0, "", error=str(e))
# ── 커버 이미지 생성 ────────────────────────────────────────────────────────
def run_cover_image(task_id: str, params: dict) -> None:
"""Suno 곡의 커버 이미지 2장을 생성."""
try:
if not SUNO_API_KEY:
update_task(task_id, "failed", 0, "", error="SUNO_API_KEY가 설정되지 않았습니다.")
return
update_task(task_id, "processing", 5, "커버 이미지 생성 요청 중...")
suno_task_id = params.get("suno_task_id", "")
if not suno_task_id:
update_task(task_id, "failed", 0, "", error="suno_task_id가 필요합니다")
return
payload = {
"taskId": suno_task_id,
"callBackUrl": "https://example.com/noop",
}
resp = requests.post(f"{SUNO_BASE_URL}/suno/cover/generate", headers=_headers(), json=payload, timeout=30)
if resp.status_code != 200:
update_task(task_id, "failed", 0, "", error=f"커버 이미지 API 오류: {resp.text[:300]}")
return
body = resp.json()
if body.get("code") != 200:
update_task(task_id, "failed", 0, "", error=f"커버 이미지 거부: {body.get('msg', 'unknown')}")
return
cover_task_id = body.get("data", {}).get("taskId", suno_task_id)
update_task(task_id, "processing", 15, "커버 이미지 생성 중...")
response = _poll_suno_record(
"/suno/cover/record-info", cover_task_id, task_id,
max_attempts=30, interval=5,
progress_msg_map={"PENDING": "이미지 생성 대기 중...", "GENERATING": "이미지 생성 중..."},
)
if not response:
return
images = response.get("images") or response.get("sunoData") or []
image_urls = []
if isinstance(images, list):
for img in images:
if isinstance(img, str):
image_urls.append(img)
elif isinstance(img, dict):
image_urls.append(img.get("imageUrl") or img.get("image_url", ""))
update_task(task_id, "succeeded", 100, "커버 이미지 생성 완료", audio_url=json.dumps(image_urls))
if params.get("track_id") and image_urls:
update_track_cover_images(params["track_id"], image_urls)
except Exception as e:
logger.exception("Cover image generation error for task %s", task_id)
update_task(task_id, "failed", 0, "", error=str(e))
# ── WAV 변환 ─────────────────────────────────────────────────────────────────
def run_wav_convert(task_id: str, params: dict) -> None:
"""곡을 WAV 포맷으로 변환."""
try:
if not SUNO_API_KEY:
update_task(task_id, "failed", 0, "", error="SUNO_API_KEY가 설정되지 않았습니다.")
return
update_task(task_id, "processing", 5, "WAV 변환 요청 중...")
payload = {
"taskId": params["suno_task_id"],
"audioId": params["suno_id"],
"callBackUrl": "https://example.com/noop",
}
resp = requests.post(
f"{SUNO_BASE_URL}/wav/generate",
headers=_headers(),
json=payload,
timeout=30,
)
if resp.status_code == 409:
body = resp.json()
wav_url = body.get("data", {}).get("audioWavUrl", "")
if wav_url:
update_task(task_id, "succeeded", 100, "WAV 변환 완료 (캐시)", audio_url=wav_url)
if params.get("track_id") and wav_url:
update_track_wav_url(params["track_id"], wav_url)
return
if resp.status_code != 200:
update_task(task_id, "failed", 0, "", error=f"WAV API 오류: {resp.text[:300]}")
return
body = resp.json()
if body.get("code") != 200:
update_task(task_id, "failed", 0, "", error=f"WAV 변환 거부: {body.get('msg', 'unknown')}")
return
wav_task_id = body.get("data", {}).get("taskId", params["suno_task_id"])
update_task(task_id, "processing", 15, "WAV 변환 처리 중...")
response = _poll_suno_record(
"/wav/record-info", wav_task_id, task_id,
max_attempts=30, interval=5,
progress_msg_map={"PENDING": "WAV 변환 대기 중...", "GENERATING": "WAV 변환 중..."},
)
if not response:
return
wav_url = ""
suno_data = response.get("sunoData") or []
if suno_data and isinstance(suno_data, list):
wav_url = suno_data[0].get("audioWavUrl", "") if isinstance(suno_data[0], dict) else ""
if not wav_url:
wav_url = response.get("audioWavUrl", "")
update_task(task_id, "succeeded", 100, "WAV 변환 완료", audio_url=wav_url)
if params.get("track_id") and wav_url:
update_track_wav_url(params["track_id"], wav_url)
except Exception as e:
logger.exception("WAV convert error for task %s", task_id)
update_task(task_id, "failed", 0, "", error=str(e))
# ── 12스템 분리 ──────────────────────────────────────────────────────────────
def run_stem_split(task_id: str, params: dict) -> None:
"""곡을 12개 스템으로 분리 (50 크레딧 소모)."""
try:
if not SUNO_API_KEY:
update_task(task_id, "failed", 0, "", error="SUNO_API_KEY가 설정되지 않았습니다.")
return
update_task(task_id, "processing", 5, "12스템 분리 요청 중...")
payload = {
"taskId": params["suno_task_id"],
"audioId": params["suno_id"],
"type": "split_stem",
"callBackUrl": "https://example.com/noop",
}
resp = requests.post(
f"{SUNO_BASE_URL}/vocal-removal/generate",
headers=_headers(),
json=payload,
timeout=30,
)
if resp.status_code != 200:
update_task(task_id, "failed", 0, "", error=f"스템 분리 API 오류: {resp.text[:300]}")
return
body = resp.json()
if body.get("code") != 200:
update_task(task_id, "failed", 0, "", error=f"스템 분리 거부: {body.get('msg', 'unknown')}")
return
stem_task_id = body.get("data", {}).get("taskId", "")
if not stem_task_id:
update_task(task_id, "failed", 0, "", error="스템 분리 응답에 taskId 없음")
return
update_task(task_id, "processing", 15, "12스템 분리 처리 중 (약 2~3분)...")
response = _poll_suno_record(
"/vocal-removal/record-info", stem_task_id, task_id,
max_attempts=40, interval=8,
progress_msg_map={"PENDING": "스템 분리 대기 중...", "GENERATING": "스템 분리 중..."},
)
if not response:
return
suno_data = response.get("sunoData") or []
stems = {}
stem_names = ["vocal", "backing_vocals", "drums", "bass", "guitar", "keyboard",
"strings", "brass", "woodwinds", "percussion", "synth", "fx"]
for i, item in enumerate(suno_data):
if isinstance(item, dict):
name = stem_names[i] if i < len(stem_names) else f"stem_{i}"
stems[name] = item.get("audioUrl") or item.get("audio_url", "")
update_task(task_id, "succeeded", 100, "12스템 분리 완료",
audio_url=json.dumps(stems))
if params.get("track_id") and stems:
update_track_stem_urls(params["track_id"], stems)
except Exception as e:
logger.exception("Stem split error for task %s", task_id)
update_task(task_id, "failed", 0, "", error=str(e))
# ── 타임스탬프 가사 ──────────────────────────────────────────────────────────
def get_timestamped_lyrics(suno_task_id: str, suno_id: str) -> Optional[dict]:
"""타임스탬프가 포함된 가사 데이터 조회 (동기)."""
if not SUNO_API_KEY:
return None
try:
resp = requests.post(
f"{SUNO_BASE_URL}/generate/get-timestamped-lyrics",
headers=_headers(),
json={"taskId": suno_task_id, "audioId": suno_id},
timeout=30,
)
if resp.status_code == 200:
body = resp.json()
return body.get("data", body)
except Exception as e:
logger.warning("Timestamped lyrics error: %s", e)
return None
# ── 스타일 부스트 ────────────────────────────────────────────────────────────
def generate_style_boost(content: str) -> Optional[dict]:
"""AI로 최적 스타일 텍스트 생성 (동기)."""
if not SUNO_API_KEY:
return None
try:
resp = requests.post(
f"{SUNO_BASE_URL}/style/generate",
headers=_headers(),
json={"content": content},
timeout=30,
)
if resp.status_code == 200:
body = resp.json()
return body.get("data", body)
except Exception as e:
logger.warning("Style boost error: %s", e)
return None
# ── 오디오 업로드 + 커버 ─────────────────────────────────────────────────────
def run_upload_cover(task_id: str, params: dict) -> None:
"""외부 오디오를 Suno 스타일로 리메이크."""
try:
if not SUNO_API_KEY:
update_task(task_id, "failed", 0, "", error="SUNO_API_KEY가 설정되지 않았습니다.")
return
update_task(task_id, "processing", 5, "AI Cover 요청 중...")
payload = {
"uploadUrl": params["upload_url"],
"customMode": params.get("custom_mode", True),
"instrumental": params.get("instrumental", False),
"model": params.get("model", "V4"),
"callBackUrl": "https://example.com/noop",
}
for key, api_key in [("prompt", "prompt"), ("style", "style"), ("title", "title"),
("vocal_gender", "vocalGender"), ("negative_tags", "negativeTags"),
("style_weight", "styleWeight"), ("audio_weight", "audioWeight")]:
if params.get(key):
payload[api_key] = params[key]
resp = requests.post(f"{SUNO_BASE_URL}/generate/upload-cover", headers=_headers(), json=payload, timeout=30)
if resp.status_code != 200:
update_task(task_id, "failed", 0, "", error=f"Upload Cover API 오류: {resp.text[:300]}")
return
body = resp.json()
if body.get("code") != 200:
update_task(task_id, "failed", 0, "", error=f"Upload Cover 거부: {body.get('msg', 'unknown')}")
return
suno_task_id = body.get("data", {}).get("taskId", "")
if not suno_task_id:
update_task(task_id, "failed", 0, "", error="Upload Cover 응답에 taskId 없음")
return
update_task(task_id, "processing", 15, "AI Cover 생성 중...")
response = _poll_suno_record("/generate/record-info", suno_task_id, task_id)
if not response:
return
completed_tracks = response.get("sunoData") or []
if not completed_tracks:
update_task(task_id, "failed", 0, "", error="AI Cover 생성 완료했으나 트랙 없음")
return
track = _download_and_register(task_id=task_id, song=completed_tracks[0], params=params, filename_suffix="")
if track:
update_task(task_id, "succeeded", 100, "AI Cover 완료", audio_url=track["audio_url"])
except Exception as e:
logger.exception("Upload cover error for task %s", task_id)
update_task(task_id, "failed", 0, "", error=str(e))
# ── 오디오 업로드 + 확장 ─────────────────────────────────────────────────────
def run_upload_extend(task_id: str, params: dict) -> None:
"""외부 오디오를 이어서 확장."""
try:
if not SUNO_API_KEY:
update_task(task_id, "failed", 0, "", error="SUNO_API_KEY가 설정되지 않았습니다.")
return
update_task(task_id, "processing", 5, "Upload Extend 요청 중...")
payload = {
"uploadUrl": params["upload_url"],
"defaultParamFlag": params.get("default_param_flag", True),
"model": params.get("model", "V4"),
"callBackUrl": "https://example.com/noop",
}
for key, api_key in [("prompt", "prompt"), ("style", "style"), ("title", "title"),
("continue_at", "continueAt"), ("instrumental", "instrumental"),
("vocal_gender", "vocalGender"), ("negative_tags", "negativeTags")]:
if params.get(key) is not None:
payload[api_key] = params[key]
resp = requests.post(f"{SUNO_BASE_URL}/generate/upload-extend", headers=_headers(), json=payload, timeout=30)
if resp.status_code != 200:
update_task(task_id, "failed", 0, "", error=f"Upload Extend API 오류: {resp.text[:300]}")
return
body = resp.json()
if body.get("code") != 200:
update_task(task_id, "failed", 0, "", error=f"Upload Extend 거부: {body.get('msg', 'unknown')}")
return
suno_task_id = body.get("data", {}).get("taskId", "")
if not suno_task_id:
update_task(task_id, "failed", 0, "", error="Upload Extend 응답에 taskId 없음")
return
update_task(task_id, "processing", 15, "Upload Extend 생성 중...")
response = _poll_suno_record("/generate/record-info", suno_task_id, task_id)
if not response:
return
completed_tracks = response.get("sunoData") or []
if not completed_tracks:
update_task(task_id, "failed", 0, "", error="Upload Extend 완료했으나 트랙 없음")
return
track = _download_and_register(task_id=task_id, song=completed_tracks[0], params=params, filename_suffix="")
if track:
update_task(task_id, "succeeded", 100, "Upload Extend 완료", audio_url=track["audio_url"])
except Exception as e:
logger.exception("Upload extend error for task %s", task_id)
update_task(task_id, "failed", 0, "", error=str(e))
# ── 보컬 추가 ────────────────────────────────────────────────────────────────
def run_add_vocals(task_id: str, params: dict) -> None:
"""인스트루멘탈에 AI 보컬을 추가."""
try:
if not SUNO_API_KEY:
update_task(task_id, "failed", 0, "", error="SUNO_API_KEY가 설정되지 않았습니다.")
return
update_task(task_id, "processing", 5, "보컬 추가 요청 중...")
payload = {
"uploadUrl": params["upload_url"],
"prompt": params.get("prompt", ""),
"title": params.get("title", ""),
"style": params.get("style", ""),
"negativeTags": params.get("negative_tags", ""),
"callBackUrl": "https://example.com/noop",
}
for key, api_key in [("vocal_gender", "vocalGender"), ("model", "model"),
("style_weight", "styleWeight"), ("audio_weight", "audioWeight")]:
if params.get(key) is not None:
payload[api_key] = params[key]
resp = requests.post(f"{SUNO_BASE_URL}/generate/add-vocals", headers=_headers(), json=payload, timeout=30)
if resp.status_code != 200:
update_task(task_id, "failed", 0, "", error=f"Add Vocals API 오류: {resp.text[:300]}")
return
body = resp.json()
if body.get("code") != 200:
update_task(task_id, "failed", 0, "", error=f"Add Vocals 거부: {body.get('msg', 'unknown')}")
return
suno_task_id = body.get("data", {}).get("taskId", "")
if not suno_task_id:
update_task(task_id, "failed", 0, "", error="Add Vocals 응답에 taskId 없음")
return
update_task(task_id, "processing", 15, "AI 보컬 생성 중...")
response = _poll_suno_record("/generate/record-info", suno_task_id, task_id)
if not response:
return
completed_tracks = response.get("sunoData") or []
if not completed_tracks:
update_task(task_id, "failed", 0, "", error="보컬 추가 완료했으나 트랙 없음")
return
track = _download_and_register(task_id=task_id, song=completed_tracks[0], params=params, filename_suffix="")
if track:
update_task(task_id, "succeeded", 100, "보컬 추가 완료", audio_url=track["audio_url"])
except Exception as e:
logger.exception("Add vocals error for task %s", task_id)
update_task(task_id, "failed", 0, "", error=str(e))
# ── 인스트루멘탈 추가 ────────────────────────────────────────────────────────
def run_add_instrumental(task_id: str, params: dict) -> None:
"""보컬에 AI 반주를 추가."""
try:
if not SUNO_API_KEY:
update_task(task_id, "failed", 0, "", error="SUNO_API_KEY가 설정되지 않았습니다.")
return
update_task(task_id, "processing", 5, "인스트루멘탈 추가 요청 중...")
payload = {
"uploadUrl": params["upload_url"],
"title": params.get("title", ""),
"tags": params.get("tags", ""),
"negativeTags": params.get("negative_tags", ""),
"callBackUrl": "https://example.com/noop",
}
for key, api_key in [("vocal_gender", "vocalGender"), ("model", "model"),
("style_weight", "styleWeight"), ("audio_weight", "audioWeight")]:
if params.get(key) is not None:
payload[api_key] = params[key]
resp = requests.post(f"{SUNO_BASE_URL}/generate/add-instrumental", headers=_headers(), json=payload, timeout=30)
if resp.status_code != 200:
update_task(task_id, "failed", 0, "", error=f"Add Instrumental API 오류: {resp.text[:300]}")
return
body = resp.json()
if body.get("code") != 200:
update_task(task_id, "failed", 0, "", error=f"Add Instrumental 거부: {body.get('msg', 'unknown')}")
return
suno_task_id = body.get("data", {}).get("taskId", "")
if not suno_task_id:
update_task(task_id, "failed", 0, "", error="Add Instrumental 응답에 taskId 없음")
return
update_task(task_id, "processing", 15, "AI 반주 생성 중...")
response = _poll_suno_record("/generate/record-info", suno_task_id, task_id)
if not response:
return
completed_tracks = response.get("sunoData") or []
if not completed_tracks:
update_task(task_id, "failed", 0, "", error="인스트루멘탈 추가 완료했으나 트랙 없음")
return
track = _download_and_register(task_id=task_id, song=completed_tracks[0], params=params, filename_suffix="")
if track:
update_task(task_id, "succeeded", 100, "인스트루멘탈 추가 완료", audio_url=track["audio_url"])
except Exception as e:
logger.exception("Add instrumental error for task %s", task_id)
update_task(task_id, "failed", 0, "", error=str(e))
# ── 뮤직비디오 생성 ──────────────────────────────────────────────────────────
def run_video_generate(task_id: str, params: dict) -> None:
"""곡의 뮤직비디오(MP4) 생성."""
try:
if not SUNO_API_KEY:
update_task(task_id, "failed", 0, "", error="SUNO_API_KEY가 설정되지 않았습니다.")
return
update_task(task_id, "processing", 5, "뮤직비디오 생성 요청 중...")
payload = {
"taskId": params["suno_task_id"],
"audioId": params["suno_id"],
"callBackUrl": "https://example.com/noop",
}
if params.get("author"):
payload["author"] = params["author"][:50]
if params.get("domain_name"):
payload["domainName"] = params["domain_name"][:50]
resp = requests.post(f"{SUNO_BASE_URL}/mp4/generate", headers=_headers(), json=payload, timeout=30)
if resp.status_code != 200:
update_task(task_id, "failed", 0, "", error=f"Video API 오류: {resp.text[:300]}")
return
body = resp.json()
if body.get("code") != 200:
update_task(task_id, "failed", 0, "", error=f"Video 생성 거부: {body.get('msg', 'unknown')}")
return
video_task_id = body.get("data", {}).get("taskId", params.get("suno_task_id", ""))
update_task(task_id, "processing", 15, "뮤직비디오 렌더링 중...")
response = _poll_suno_record(
"/mp4/record-info", video_task_id, task_id,
max_attempts=60, interval=10,
progress_msg_map={"PENDING": "비디오 렌더링 대기 중...", "GENERATING": "비디오 렌더링 중..."},
)
if not response:
return
video_url = ""
suno_data = response.get("sunoData") or []
if suno_data and isinstance(suno_data, list) and isinstance(suno_data[0], dict):
video_url = suno_data[0].get("videoUrl") or suno_data[0].get("video_url", "")
if not video_url:
video_url = response.get("video_url") or response.get("videoUrl", "")
update_task(task_id, "succeeded", 100, "뮤직비디오 생성 완료", audio_url=video_url)
if params.get("track_id") and video_url:
update_track_video_url(params["track_id"], video_url)
except Exception as e:
logger.exception("Video generate error for task %s", task_id)
update_task(task_id, "failed", 0, "", error=str(e))