openvoiceui 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.env.example +104 -0
- package/Dockerfile +30 -0
- package/LICENSE +21 -0
- package/README.md +638 -0
- package/SETUP.md +360 -0
- package/app.py +232 -0
- package/auto-approve-devices.js +111 -0
- package/cli/index.js +372 -0
- package/config/__init__.py +4 -0
- package/config/default.yaml +43 -0
- package/config/flags.yaml +67 -0
- package/config/loader.py +203 -0
- package/config/providers.yaml +71 -0
- package/config/speech_normalization.yaml +182 -0
- package/config/theme.json +4 -0
- package/data/greetings.json +25 -0
- package/default-pages/ai-image-creator.html +915 -0
- package/default-pages/bulk-image-uploader.html +492 -0
- package/default-pages/desktop.html +2865 -0
- package/default-pages/file-explorer.html +854 -0
- package/default-pages/interactive-map.html +655 -0
- package/default-pages/style-guide.html +1005 -0
- package/default-pages/website-setup.html +1623 -0
- package/deploy/openclaw/Dockerfile +46 -0
- package/deploy/openvoiceui.service +30 -0
- package/deploy/setup-nginx.sh +50 -0
- package/deploy/setup-sudo.sh +306 -0
- package/deploy/skill-runner/Dockerfile +19 -0
- package/deploy/skill-runner/requirements.txt +14 -0
- package/deploy/skill-runner/server.py +269 -0
- package/deploy/supertonic/Dockerfile +22 -0
- package/deploy/supertonic/server.py +79 -0
- package/docker-compose.pinokio.yml +11 -0
- package/docker-compose.yml +59 -0
- package/greetings.json +25 -0
- package/index.html +65 -0
- package/inject-device-identity.js +142 -0
- package/package.json +82 -0
- package/profiles/default.json +114 -0
- package/profiles/manager.py +354 -0
- package/profiles/schema.json +337 -0
- package/prompts/voice-system-prompt.md +149 -0
- package/providers/__init__.py +39 -0
- package/providers/base.py +63 -0
- package/providers/llm/__init__.py +12 -0
- package/providers/llm/base.py +71 -0
- package/providers/llm/clawdbot_provider.py +112 -0
- package/providers/llm/zai_provider.py +115 -0
- package/providers/registry.py +320 -0
- package/providers/stt/__init__.py +12 -0
- package/providers/stt/base.py +58 -0
- package/providers/stt/webspeech_provider.py +49 -0
- package/providers/stt/whisper_provider.py +100 -0
- package/providers/tts/__init__.py +20 -0
- package/providers/tts/base.py +91 -0
- package/providers/tts/groq_provider.py +74 -0
- package/providers/tts/supertonic_provider.py +72 -0
- package/requirements.txt +38 -0
- package/routes/__init__.py +10 -0
- package/routes/admin.py +515 -0
- package/routes/canvas.py +1315 -0
- package/routes/chat.py +51 -0
- package/routes/conversation.py +2158 -0
- package/routes/elevenlabs_hybrid.py +306 -0
- package/routes/greetings.py +98 -0
- package/routes/icons.py +279 -0
- package/routes/image_gen.py +364 -0
- package/routes/instructions.py +190 -0
- package/routes/music.py +838 -0
- package/routes/onboarding.py +43 -0
- package/routes/pi.py +62 -0
- package/routes/profiles.py +215 -0
- package/routes/report_issue.py +68 -0
- package/routes/static_files.py +533 -0
- package/routes/suno.py +664 -0
- package/routes/theme.py +81 -0
- package/routes/transcripts.py +199 -0
- package/routes/vision.py +348 -0
- package/routes/workspace.py +288 -0
- package/server.py +1510 -0
- package/services/__init__.py +1 -0
- package/services/auth.py +143 -0
- package/services/canvas_versioning.py +239 -0
- package/services/db_pool.py +107 -0
- package/services/gateway.py +16 -0
- package/services/gateway_manager.py +333 -0
- package/services/gateways/__init__.py +12 -0
- package/services/gateways/base.py +110 -0
- package/services/gateways/compat.py +264 -0
- package/services/gateways/openclaw.py +1134 -0
- package/services/health.py +100 -0
- package/services/memory_client.py +455 -0
- package/services/paths.py +26 -0
- package/services/speech_normalizer.py +285 -0
- package/services/tts.py +270 -0
- package/setup-config.js +262 -0
- package/sounds/air_horn.mp3 +0 -0
- package/sounds/bruh.mp3 +0 -0
- package/sounds/crowd_cheer.mp3 +0 -0
- package/sounds/gunshot.mp3 +0 -0
- package/sounds/impact.mp3 +0 -0
- package/sounds/lets_go.mp3 +0 -0
- package/sounds/record_stop.mp3 +0 -0
- package/sounds/rewind.mp3 +0 -0
- package/sounds/sad_trombone.mp3 +0 -0
- package/sounds/scratch_long.mp3 +0 -0
- package/sounds/yeah.mp3 +0 -0
- package/src/adapters/ClawdBotAdapter.js +264 -0
- package/src/adapters/_template.js +133 -0
- package/src/adapters/elevenlabs-classic.js +841 -0
- package/src/adapters/elevenlabs-hybrid.js +812 -0
- package/src/adapters/hume-evi.js +676 -0
- package/src/admin.html +1339 -0
- package/src/app.js +8802 -0
- package/src/core/Config.js +173 -0
- package/src/core/EmotionEngine.js +307 -0
- package/src/core/EventBridge.js +180 -0
- package/src/core/EventBus.js +117 -0
- package/src/core/VoiceSession.js +607 -0
- package/src/face/BaseFace.js +259 -0
- package/src/face/EyeFace.js +208 -0
- package/src/face/HaloSmokeFace.js +509 -0
- package/src/face/manifest.json +27 -0
- package/src/face/previews/eyes.svg +16 -0
- package/src/face/previews/orb.svg +29 -0
- package/src/features/MusicPlayer.js +620 -0
- package/src/features/Soundboard.js +128 -0
- package/src/providers/DeepgramSTT.js +472 -0
- package/src/providers/DeepgramStreamingSTT.js +766 -0
- package/src/providers/GroqSTT.js +559 -0
- package/src/providers/TTSPlayer.js +323 -0
- package/src/providers/WebSpeechSTT.js +479 -0
- package/src/providers/tts/BaseTTSProvider.js +81 -0
- package/src/providers/tts/HumeProvider.js +77 -0
- package/src/providers/tts/SupertonicProvider.js +174 -0
- package/src/providers/tts/index.js +140 -0
- package/src/shell/adapter-registry.js +154 -0
- package/src/shell/caller-bridge.js +35 -0
- package/src/shell/camera-bridge.js +28 -0
- package/src/shell/canvas-bridge.js +32 -0
- package/src/shell/commercial-bridge.js +44 -0
- package/src/shell/face-bridge.js +44 -0
- package/src/shell/music-bridge.js +60 -0
- package/src/shell/orchestrator.js +233 -0
- package/src/shell/profile-discovery.js +303 -0
- package/src/shell/sounds-bridge.js +28 -0
- package/src/shell/transcript-bridge.js +61 -0
- package/src/shell/waveform-bridge.js +33 -0
- package/src/styles/base.css +2862 -0
- package/src/styles/face.css +417 -0
- package/src/styles/pi-overrides.css +89 -0
- package/src/styles/theme-dark.css +67 -0
- package/src/test-tts.html +175 -0
- package/src/ui/AppShell.js +544 -0
- package/src/ui/ProfileSwitcher.js +228 -0
- package/src/ui/SessionControl.js +240 -0
- package/src/ui/face/FacePicker.js +195 -0
- package/src/ui/face/FaceRenderer.js +309 -0
- package/src/ui/settings/PlaylistEditor.js +366 -0
- package/src/ui/settings/SettingsPanel.css +684 -0
- package/src/ui/settings/SettingsPanel.js +419 -0
- package/src/ui/settings/TTSVoicePreview.js +210 -0
- package/src/ui/themes/ThemeManager.js +213 -0
- package/src/ui/visualizers/BaseVisualizer.js +29 -0
- package/src/ui/visualizers/PartyFXVisualizer.css +291 -0
- package/src/ui/visualizers/PartyFXVisualizer.js +637 -0
- package/static/emulators/jsdos/js-dos.css +1 -0
- package/static/emulators/jsdos/js-dos.js +22 -0
- package/static/favicon.svg +55 -0
- package/static/icons/apple-touch-icon.png +0 -0
- package/static/icons/favicon-32.png +0 -0
- package/static/icons/icon-192.png +0 -0
- package/static/icons/icon-512.png +0 -0
- package/static/install.html +449 -0
- package/static/manifest.json +26 -0
- package/static/sw.js +21 -0
- package/tts_providers/__init__.py +136 -0
- package/tts_providers/base_provider.py +319 -0
- package/tts_providers/groq_provider.py +155 -0
- package/tts_providers/hume_provider.py +226 -0
- package/tts_providers/providers_config.json +119 -0
- package/tts_providers/qwen3_provider.py +371 -0
- package/tts_providers/resemble_provider.py +315 -0
- package/tts_providers/supertonic_provider.py +557 -0
- package/tts_providers/supertonic_tts.py +399 -0
package/routes/suno.py
ADDED
|
@@ -0,0 +1,664 @@
|
|
|
1
|
+
"""
|
|
2
|
+
routes/suno.py — Suno AI Song Generation Blueprint
|
|
3
|
+
|
|
4
|
+
Provides endpoints for generating songs via Suno API (sunoapi.org).
|
|
5
|
+
Generated songs land in generated_music/ and show up in the music player.
|
|
6
|
+
|
|
7
|
+
Endpoints:
|
|
8
|
+
GET/POST /api/suno (action: generate|status|list|credits)
|
|
9
|
+
POST /api/suno/callback (webhook from sunoapi.org)
|
|
10
|
+
GET/POST /api/suno/completed (frontend polls for completed songs)
|
|
11
|
+
|
|
12
|
+
Agent trigger:
|
|
13
|
+
Include [SUNO_GENERATE:prompt text here] in a response to kick off generation.
|
|
14
|
+
The frontend detects the tag, calls /api/suno?action=generate, and polls for completion.
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
import hashlib
|
|
18
|
+
import hmac
|
|
19
|
+
import ipaddress
|
|
20
|
+
import json
|
|
21
|
+
import logging
|
|
22
|
+
import os
|
|
23
|
+
import socket
|
|
24
|
+
import threading
|
|
25
|
+
import time
|
|
26
|
+
import uuid
|
|
27
|
+
from datetime import datetime
|
|
28
|
+
from pathlib import Path
|
|
29
|
+
from urllib.parse import urlparse
|
|
30
|
+
|
|
31
|
+
import requests as http_requests
|
|
32
|
+
from flask import Blueprint, jsonify, request
|
|
33
|
+
|
|
34
|
+
# ---------------------------------------------------------------------------
|
|
35
|
+
# Paths & config
|
|
36
|
+
# ---------------------------------------------------------------------------
|
|
37
|
+
|
|
38
|
+
from services.paths import GENERATED_MUSIC_DIR
|
|
39
|
+
|
|
40
|
+
GENERATED_MUSIC_DIR.mkdir(parents=True, exist_ok=True)
|
|
41
|
+
GENERATED_METADATA_FILE = GENERATED_MUSIC_DIR / 'generated_metadata.json'
|
|
42
|
+
|
|
43
|
+
SUNO_API_KEY = os.environ.get('SUNO_API_KEY', '')
|
|
44
|
+
SUNO_API_BASE = 'https://api.sunoapi.org'
|
|
45
|
+
SUNO_WEBHOOK_SECRET = os.environ.get('SUNO_WEBHOOK_SECRET', '')
|
|
46
|
+
SUNO_MAX_DOWNLOAD_BYTES = 50 * 1024 * 1024 # 50 MB cap on audio downloads
|
|
47
|
+
|
|
48
|
+
# Callback URL: explicit > auto-derived from DOMAIN > empty
|
|
49
|
+
# sunoapi.org requires callBackUrl; auto-derive from DOMAIN if not set explicitly.
|
|
50
|
+
_domain = os.environ.get('DOMAIN', '')
|
|
51
|
+
SUNO_CALLBACK_URL = (
|
|
52
|
+
os.environ.get('SUNO_CALLBACK_URL')
|
|
53
|
+
or (f'https://{_domain}/api/suno/callback' if _domain else '')
|
|
54
|
+
)
|
|
55
|
+
|
|
56
|
+
# ---------------------------------------------------------------------------
|
|
57
|
+
# In-memory job tracking (single-worker deployment)
|
|
58
|
+
# ---------------------------------------------------------------------------
|
|
59
|
+
|
|
60
|
+
suno_jobs: dict = {} # job_id -> {status, prompt, title, style, created_at, task_id, ...}
|
|
61
|
+
completed_songs_queue: list = [] # [{song_id, title, job_id, completed_at, url}, ...]
|
|
62
|
+
_suno_lock = threading.Lock()
|
|
63
|
+
|
|
64
|
+
logger = logging.getLogger(__name__)
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def _is_safe_download_url(url: str) -> bool:
|
|
68
|
+
"""Reject URLs that point to private/reserved IP ranges (SSRF protection)."""
|
|
69
|
+
try:
|
|
70
|
+
parsed = urlparse(url)
|
|
71
|
+
hostname = parsed.hostname
|
|
72
|
+
if not hostname or parsed.scheme not in ('http', 'https'):
|
|
73
|
+
return False
|
|
74
|
+
# Resolve hostname to IP and check if private/reserved
|
|
75
|
+
for info in socket.getaddrinfo(hostname, parsed.port or 443, proto=socket.IPPROTO_TCP):
|
|
76
|
+
addr = info[4][0]
|
|
77
|
+
ip = ipaddress.ip_address(addr)
|
|
78
|
+
if ip.is_private or ip.is_reserved or ip.is_loopback or ip.is_link_local:
|
|
79
|
+
logger.warning(f'SSRF blocked: {url} resolves to private IP {addr}')
|
|
80
|
+
return False
|
|
81
|
+
return True
|
|
82
|
+
except (ValueError, socket.gaierror, OSError) as exc:
|
|
83
|
+
logger.warning(f'SSRF check failed for {url}: {exc}')
|
|
84
|
+
return False
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
# ---------------------------------------------------------------------------
|
|
88
|
+
# Blueprint
|
|
89
|
+
# ---------------------------------------------------------------------------
|
|
90
|
+
|
|
91
|
+
suno_bp = Blueprint('suno', __name__)
|
|
92
|
+
|
|
93
|
+
# ---------------------------------------------------------------------------
|
|
94
|
+
# Metadata helpers
|
|
95
|
+
# ---------------------------------------------------------------------------
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def _load_generated_metadata() -> dict:
|
|
99
|
+
if GENERATED_METADATA_FILE.exists():
|
|
100
|
+
try:
|
|
101
|
+
with open(GENERATED_METADATA_FILE) as f:
|
|
102
|
+
return json.load(f)
|
|
103
|
+
except Exception:
|
|
104
|
+
return {}
|
|
105
|
+
return {}
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def _save_generated_metadata(metadata: dict) -> None:
|
|
109
|
+
"""Persist generated music metadata (atomic write)."""
|
|
110
|
+
tmp = GENERATED_METADATA_FILE.with_suffix('.tmp')
|
|
111
|
+
tmp.write_text(json.dumps(metadata, indent=2))
|
|
112
|
+
tmp.replace(GENERATED_METADATA_FILE)
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
def _add_song_to_metadata(filename: str, title: str, prompt: str, style: str,
|
|
116
|
+
duration: float = 0, song_id: str = '') -> None:
|
|
117
|
+
"""Write a new song entry to generated_metadata.json in the format music.py expects."""
|
|
118
|
+
metadata = _load_generated_metadata()
|
|
119
|
+
metadata[filename] = {
|
|
120
|
+
'title': title,
|
|
121
|
+
'artist': 'Clawdbot AI',
|
|
122
|
+
'description': prompt[:200] if prompt else 'AI-generated track',
|
|
123
|
+
'genre': _guess_genre(style or prompt),
|
|
124
|
+
'energy': 'high',
|
|
125
|
+
'duration_seconds': round(duration, 1) if duration else 0,
|
|
126
|
+
'fun_facts': [],
|
|
127
|
+
'dj_intro_hints': [],
|
|
128
|
+
'dj_backstory': f'Generated by Clawdbot from prompt: {prompt[:100]}' if prompt else '',
|
|
129
|
+
'made_by': 'Clawdbot',
|
|
130
|
+
'created_date': datetime.now().strftime('%Y-%m-%d'),
|
|
131
|
+
'suno_id': song_id,
|
|
132
|
+
}
|
|
133
|
+
_save_generated_metadata(metadata)
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
def _is_uuid(s: str) -> bool:
|
|
137
|
+
"""Check if string looks like a UUID (hex-hex-hex-hex-hex pattern)."""
|
|
138
|
+
import re
|
|
139
|
+
return bool(re.match(r'^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$', s.strip(), re.IGNORECASE))
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
def _slugify_title(title: str) -> str:
|
|
143
|
+
"""Convert a song title to a safe filename slug (no extension)."""
|
|
144
|
+
import re
|
|
145
|
+
import unicodedata
|
|
146
|
+
# If the title is a UUID (Suno sometimes returns song ID as title), reject it
|
|
147
|
+
if _is_uuid(title):
|
|
148
|
+
return 'generated-track'
|
|
149
|
+
# Normalize unicode (e.g., smart quotes → ascii)
|
|
150
|
+
s = unicodedata.normalize('NFKD', title).encode('ascii', 'ignore').decode('ascii')
|
|
151
|
+
# Lowercase, replace non-alnum with hyphens, collapse multiples, strip edges
|
|
152
|
+
s = re.sub(r'[^a-z0-9]+', '-', s.lower()).strip('-')
|
|
153
|
+
return s[:80] or 'generated-track'
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
def _unique_filename(directory: Path, base: str, ext: str = '.mp3') -> str:
|
|
157
|
+
"""Return a unique filename in directory, appending -2, -3, etc. if needed."""
|
|
158
|
+
candidate = f'{base}{ext}'
|
|
159
|
+
if not (directory / candidate).exists():
|
|
160
|
+
return candidate
|
|
161
|
+
counter = 2
|
|
162
|
+
while (directory / f'{base}-{counter}{ext}').exists():
|
|
163
|
+
counter += 1
|
|
164
|
+
return f'{base}-{counter}{ext}'
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
def _guess_genre(text: str) -> str:
|
|
168
|
+
"""Rough genre guess from prompt keywords."""
|
|
169
|
+
if not text:
|
|
170
|
+
return 'Unknown'
|
|
171
|
+
t = text.lower()
|
|
172
|
+
for genre, keywords in [
|
|
173
|
+
('Hip-Hop', ['hip hop', 'hiphop', 'rap', 'trap', 'beats']),
|
|
174
|
+
('Electronic', ['electronic', 'edm', 'techno', 'house', 'synth', 'dance']),
|
|
175
|
+
('Rock', ['rock', 'metal', 'guitar', 'punk', 'grunge']),
|
|
176
|
+
('Pop', ['pop', 'catchy', 'radio', 'chorus']),
|
|
177
|
+
('Country', ['country', 'western', 'cowboy', 'twang']),
|
|
178
|
+
('Reggae', ['reggae', 'ska', 'dub', 'jamaican']),
|
|
179
|
+
('Jazz', ['jazz', 'blues', 'soul', 'funk', 'groove']),
|
|
180
|
+
]:
|
|
181
|
+
if any(kw in t for kw in keywords):
|
|
182
|
+
return genre
|
|
183
|
+
return 'Unknown'
|
|
184
|
+
|
|
185
|
+
|
|
186
|
+
# ---------------------------------------------------------------------------
|
|
187
|
+
# Main endpoint
|
|
188
|
+
# ---------------------------------------------------------------------------
|
|
189
|
+
|
|
190
|
+
|
|
191
|
+
@suno_bp.route('/api/suno', methods=['GET', 'POST'])
|
|
192
|
+
def handle_suno():
|
|
193
|
+
"""
|
|
194
|
+
Unified Suno endpoint.
|
|
195
|
+
action=generate — Submit a generation job
|
|
196
|
+
action=status — Poll job status (downloads when ready)
|
|
197
|
+
action=list — List generated songs
|
|
198
|
+
action=credits — Check API credits
|
|
199
|
+
"""
|
|
200
|
+
if request.method == 'POST':
|
|
201
|
+
body = request.get_json(silent=True) or {}
|
|
202
|
+
action = body.get('action') or request.args.get('action', 'list')
|
|
203
|
+
# Allow POST body params to override query params
|
|
204
|
+
_q = lambda k, default='': body.get(k) or request.args.get(k, default)
|
|
205
|
+
else:
|
|
206
|
+
body = {}
|
|
207
|
+
action = request.args.get('action', 'list')
|
|
208
|
+
_q = lambda k, default='': request.args.get(k, default)
|
|
209
|
+
|
|
210
|
+
if not SUNO_API_KEY:
|
|
211
|
+
return jsonify({'action': 'error', 'response': 'SUNO_API_KEY not configured — add it to .env'})
|
|
212
|
+
|
|
213
|
+
try:
|
|
214
|
+
# ------------------------------------------------------------------
|
|
215
|
+
if action == 'list':
|
|
216
|
+
return _action_list()
|
|
217
|
+
|
|
218
|
+
elif action == 'generate':
|
|
219
|
+
return _action_generate(_q, body)
|
|
220
|
+
|
|
221
|
+
elif action == 'status':
|
|
222
|
+
return _action_status(_q('job_id') or _q('song_id'))
|
|
223
|
+
|
|
224
|
+
elif action == 'credits':
|
|
225
|
+
return _action_credits()
|
|
226
|
+
|
|
227
|
+
else:
|
|
228
|
+
return jsonify({'action': 'error', 'response': f"Unknown action '{action}'. Use: generate, status, list, credits"})
|
|
229
|
+
|
|
230
|
+
except Exception as exc:
|
|
231
|
+
logger.exception('Suno endpoint error')
|
|
232
|
+
return jsonify({'action': 'error', 'response': f'Suno error: {exc}'}), 500
|
|
233
|
+
|
|
234
|
+
|
|
235
|
+
# ---------------------------------------------------------------------------
|
|
236
|
+
# Action handlers
|
|
237
|
+
# ---------------------------------------------------------------------------
|
|
238
|
+
|
|
239
|
+
|
|
240
|
+
def _action_list():
|
|
241
|
+
"""Return all generated songs with metadata."""
|
|
242
|
+
metadata = _load_generated_metadata()
|
|
243
|
+
songs = []
|
|
244
|
+
for f in sorted(GENERATED_MUSIC_DIR.iterdir(), key=lambda p: p.stat().st_mtime, reverse=True):
|
|
245
|
+
if f.suffix.lower() in {'.mp3', '.wav', '.ogg', '.m4a'}:
|
|
246
|
+
meta = metadata.get(f.name, {})
|
|
247
|
+
songs.append({
|
|
248
|
+
'filename': f.name,
|
|
249
|
+
'title': meta.get('title', f.stem),
|
|
250
|
+
'genre': meta.get('genre', 'Unknown'),
|
|
251
|
+
'description': meta.get('description', ''),
|
|
252
|
+
'duration_seconds': meta.get('duration_seconds', 0),
|
|
253
|
+
'made_by': meta.get('made_by', 'Clawdbot'),
|
|
254
|
+
'created_date': meta.get('created_date', ''),
|
|
255
|
+
'url': f'/generated_music/{f.name}',
|
|
256
|
+
'size_bytes': f.stat().st_size,
|
|
257
|
+
})
|
|
258
|
+
return jsonify({
|
|
259
|
+
'action': 'list',
|
|
260
|
+
'count': len(songs),
|
|
261
|
+
'songs': songs,
|
|
262
|
+
'response': f'Got {len(songs)} AI-generated tracks in the vault!',
|
|
263
|
+
})
|
|
264
|
+
|
|
265
|
+
|
|
266
|
+
def _action_generate(_q, body: dict):
|
|
267
|
+
"""Submit a song generation job to Suno API."""
|
|
268
|
+
prompt = _q('prompt') or body.get('prompt', '')
|
|
269
|
+
style = _q('style') or body.get('style', '')
|
|
270
|
+
title = _q('title') or body.get('title', '')
|
|
271
|
+
lyrics = _q('lyrics') or body.get('lyrics', '')
|
|
272
|
+
instrumental = (_q('instrumental') or str(body.get('instrumental', 'false'))).lower() == 'true'
|
|
273
|
+
vocal_gender = _q('vocal_gender') or body.get('vocal_gender', 'm')
|
|
274
|
+
|
|
275
|
+
if not prompt and not lyrics and not style:
|
|
276
|
+
return jsonify({'action': 'error', 'response': 'Need a prompt, lyrics, or style — tell me what kind of song to make.'})
|
|
277
|
+
|
|
278
|
+
# Determine mode: custom (explicit lyrics) vs description (Suno writes lyrics)
|
|
279
|
+
if lyrics:
|
|
280
|
+
song_prompt = lyrics
|
|
281
|
+
has_lyrics = True
|
|
282
|
+
elif '[Verse' in prompt or '[Chorus' in prompt or '[Hook' in prompt or '[Bridge' in prompt:
|
|
283
|
+
song_prompt = prompt
|
|
284
|
+
has_lyrics = True
|
|
285
|
+
else:
|
|
286
|
+
# Description mode — Suno auto-generates lyrics from the description
|
|
287
|
+
combined = f'{style}. {prompt}' if style and prompt else (style or prompt)
|
|
288
|
+
song_prompt = combined[:500]
|
|
289
|
+
has_lyrics = False
|
|
290
|
+
|
|
291
|
+
# Build Suno API request
|
|
292
|
+
if has_lyrics:
|
|
293
|
+
request_body = {
|
|
294
|
+
'prompt': song_prompt,
|
|
295
|
+
'customMode': True,
|
|
296
|
+
'instrumental': instrumental,
|
|
297
|
+
'model': 'V5',
|
|
298
|
+
'vocalGender': vocal_gender,
|
|
299
|
+
'negativeTags': 'low quality, mumbling, distorted, off-key',
|
|
300
|
+
'style': style or 'Catchy, Radio-friendly, Professional',
|
|
301
|
+
}
|
|
302
|
+
if title:
|
|
303
|
+
request_body['title'] = title
|
|
304
|
+
else:
|
|
305
|
+
request_body = {
|
|
306
|
+
'prompt': song_prompt,
|
|
307
|
+
'customMode': False,
|
|
308
|
+
'instrumental': instrumental,
|
|
309
|
+
'model': 'V5',
|
|
310
|
+
'vocalGender': vocal_gender,
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
if SUNO_CALLBACK_URL:
|
|
314
|
+
request_body['callBackUrl'] = SUNO_CALLBACK_URL
|
|
315
|
+
|
|
316
|
+
logger.info(f'Suno generate: mode={"custom" if has_lyrics else "auto"} prompt={song_prompt[:80]}')
|
|
317
|
+
|
|
318
|
+
try:
|
|
319
|
+
resp = http_requests.post(
|
|
320
|
+
f'{SUNO_API_BASE}/api/v1/generate',
|
|
321
|
+
headers={'Authorization': f'Bearer {SUNO_API_KEY}', 'Content-Type': 'application/json'},
|
|
322
|
+
json=request_body,
|
|
323
|
+
timeout=30,
|
|
324
|
+
)
|
|
325
|
+
logger.info(f'Suno API response: {resp.status_code} {resp.text[:300]}')
|
|
326
|
+
|
|
327
|
+
if resp.status_code == 200:
|
|
328
|
+
data = resp.json()
|
|
329
|
+
if data.get('code') == 200 and data.get('data', {}).get('taskId'):
|
|
330
|
+
task_id = data['data']['taskId']
|
|
331
|
+
job_id = str(uuid.uuid4())
|
|
332
|
+
suno_jobs[job_id] = {
|
|
333
|
+
'status': 'generating',
|
|
334
|
+
'prompt': prompt,
|
|
335
|
+
'title': title,
|
|
336
|
+
'style': style,
|
|
337
|
+
'task_id': task_id,
|
|
338
|
+
'created_at': time.time(),
|
|
339
|
+
}
|
|
340
|
+
return jsonify({
|
|
341
|
+
'action': 'generating',
|
|
342
|
+
'job_id': job_id,
|
|
343
|
+
'task_id': task_id,
|
|
344
|
+
'response': f"Cooking! '{title or 'your track'}' is being generated — check back in 30-60 seconds.",
|
|
345
|
+
'estimated_seconds': 45,
|
|
346
|
+
})
|
|
347
|
+
else:
|
|
348
|
+
return jsonify({'action': 'error', 'response': f"Suno API error: {data.get('msg', 'Unknown error')}"})
|
|
349
|
+
else:
|
|
350
|
+
return jsonify({'action': 'error', 'response': f'Suno API HTTP {resp.status_code}: {resp.text[:200]}'})
|
|
351
|
+
|
|
352
|
+
except http_requests.RequestException as exc:
|
|
353
|
+
return jsonify({'action': 'error', 'response': f"Couldn't reach Suno API: {exc}"})
|
|
354
|
+
|
|
355
|
+
|
|
356
|
+
def _action_status(job_id: str):
|
|
357
|
+
"""Poll generation status; downloads song when Suno reports SUCCESS."""
|
|
358
|
+
if not job_id:
|
|
359
|
+
# Return status of most recent job
|
|
360
|
+
if suno_jobs:
|
|
361
|
+
job_id = max(suno_jobs.keys())
|
|
362
|
+
else:
|
|
363
|
+
return jsonify({'action': 'status', 'status': 'no_jobs', 'response': 'No songs cooking right now.'})
|
|
364
|
+
|
|
365
|
+
if job_id not in suno_jobs:
|
|
366
|
+
return jsonify({'action': 'status', 'status': 'not_found', 'response': "Can't find that job."})
|
|
367
|
+
|
|
368
|
+
job = suno_jobs[job_id]
|
|
369
|
+
|
|
370
|
+
# If already complete, just report
|
|
371
|
+
if job.get('status') == 'complete':
|
|
372
|
+
return jsonify({
|
|
373
|
+
'action': 'complete',
|
|
374
|
+
'status': 'complete',
|
|
375
|
+
'job_id': job_id,
|
|
376
|
+
'song_id': job.get('song_id', ''),
|
|
377
|
+
'title': job.get('title', 'Generated Track'),
|
|
378
|
+
'url': job.get('url', ''),
|
|
379
|
+
'response': f"Done! '{job.get('title', 'your track')}' is ready to spin!",
|
|
380
|
+
})
|
|
381
|
+
|
|
382
|
+
elapsed = time.time() - job['created_at']
|
|
383
|
+
|
|
384
|
+
# Don't bother polling Suno for the first 20 seconds
|
|
385
|
+
if elapsed < 20:
|
|
386
|
+
return jsonify({
|
|
387
|
+
'action': 'status',
|
|
388
|
+
'status': 'generating',
|
|
389
|
+
'elapsed_seconds': int(elapsed),
|
|
390
|
+
'response': f'Still cooking — about {max(0, 30 - int(elapsed))} more seconds...',
|
|
391
|
+
})
|
|
392
|
+
|
|
393
|
+
task_id = job.get('task_id')
|
|
394
|
+
if not task_id:
|
|
395
|
+
return jsonify({'action': 'status', 'status': 'generating', 'elapsed_seconds': int(elapsed), 'response': 'Generating...'})
|
|
396
|
+
|
|
397
|
+
try:
|
|
398
|
+
check = http_requests.get(
|
|
399
|
+
f'{SUNO_API_BASE}/api/v1/generate/record-info',
|
|
400
|
+
headers={'Authorization': f'Bearer {SUNO_API_KEY}'},
|
|
401
|
+
params={'taskId': task_id},
|
|
402
|
+
timeout=15,
|
|
403
|
+
)
|
|
404
|
+
logger.debug(f'Suno status check: {check.status_code} {check.text[:300]}')
|
|
405
|
+
|
|
406
|
+
if check.status_code == 200:
|
|
407
|
+
cdata = check.json()
|
|
408
|
+
if cdata.get('code') == 200:
|
|
409
|
+
status_data = cdata.get('data', {})
|
|
410
|
+
gen_status = status_data.get('status', '')
|
|
411
|
+
|
|
412
|
+
if gen_status == 'SUCCESS':
|
|
413
|
+
songs = status_data.get('response', {}).get('sunoData', [])
|
|
414
|
+
# Suno returns 2 clips per generation — only take the first one
|
|
415
|
+
songs = songs[:1] if songs else []
|
|
416
|
+
for song in songs:
|
|
417
|
+
audio_url = song.get('audioUrl') or song.get('audio_url')
|
|
418
|
+
if not audio_url:
|
|
419
|
+
continue
|
|
420
|
+
song_id = song.get('id', task_id)
|
|
421
|
+
_raw_title = song.get('title') or ''
|
|
422
|
+
# Suno API sometimes returns song ID as title — reject UUIDs
|
|
423
|
+
if _raw_title and not _is_uuid(_raw_title):
|
|
424
|
+
song_title = _raw_title
|
|
425
|
+
else:
|
|
426
|
+
song_title = job.get('title') or job.get('prompt', '')[:60] or 'Generated Track'
|
|
427
|
+
duration = song.get('duration', 0)
|
|
428
|
+
slug = _slugify_title(song_title)
|
|
429
|
+
filename = _unique_filename(GENERATED_MUSIC_DIR, slug)
|
|
430
|
+
save_path = GENERATED_MUSIC_DIR / filename
|
|
431
|
+
|
|
432
|
+
if not save_path.exists():
|
|
433
|
+
if not _is_safe_download_url(audio_url):
|
|
434
|
+
continue
|
|
435
|
+
audio_resp = http_requests.get(audio_url, timeout=60, stream=True)
|
|
436
|
+
if audio_resp.status_code == 200:
|
|
437
|
+
content_length = int(audio_resp.headers.get('Content-Length', 0))
|
|
438
|
+
if content_length > SUNO_MAX_DOWNLOAD_BYTES:
|
|
439
|
+
logger.warning(f'Suno download rejected: Content-Length {content_length} exceeds limit')
|
|
440
|
+
continue
|
|
441
|
+
chunks = []
|
|
442
|
+
total = 0
|
|
443
|
+
for chunk in audio_resp.iter_content(chunk_size=65536):
|
|
444
|
+
total += len(chunk)
|
|
445
|
+
if total > SUNO_MAX_DOWNLOAD_BYTES:
|
|
446
|
+
logger.warning(f'Suno download aborted: exceeded {SUNO_MAX_DOWNLOAD_BYTES} bytes')
|
|
447
|
+
break
|
|
448
|
+
chunks.append(chunk)
|
|
449
|
+
else:
|
|
450
|
+
save_path.write_bytes(b''.join(chunks))
|
|
451
|
+
logger.info(f'Suno downloaded: {song_title} → {filename}')
|
|
452
|
+
else:
|
|
453
|
+
logger.warning(f'Suno download failed: {audio_resp.status_code}')
|
|
454
|
+
continue
|
|
455
|
+
|
|
456
|
+
# Save metadata
|
|
457
|
+
_add_song_to_metadata(
|
|
458
|
+
filename=filename,
|
|
459
|
+
title=song_title,
|
|
460
|
+
prompt=job.get('prompt', ''),
|
|
461
|
+
style=job.get('style', ''),
|
|
462
|
+
duration=duration,
|
|
463
|
+
song_id=song_id,
|
|
464
|
+
)
|
|
465
|
+
|
|
466
|
+
# Update job
|
|
467
|
+
job['status'] = 'complete'
|
|
468
|
+
job['song_id'] = song_id
|
|
469
|
+
job['title'] = song_title
|
|
470
|
+
job['url'] = f'/generated_music/{filename}'
|
|
471
|
+
|
|
472
|
+
# Notify frontend poller
|
|
473
|
+
completed_songs_queue.append({
|
|
474
|
+
'song_id': song_id,
|
|
475
|
+
'filename': filename,
|
|
476
|
+
'title': song_title,
|
|
477
|
+
'job_id': job_id,
|
|
478
|
+
'url': f'/generated_music/{filename}',
|
|
479
|
+
'completed_at': datetime.now().isoformat(),
|
|
480
|
+
'prompt': job.get('prompt', ''),
|
|
481
|
+
})
|
|
482
|
+
|
|
483
|
+
return jsonify({
|
|
484
|
+
'action': 'complete',
|
|
485
|
+
'status': 'complete',
|
|
486
|
+
'job_id': job_id,
|
|
487
|
+
'song_id': song_id,
|
|
488
|
+
'title': song_title,
|
|
489
|
+
'url': f'/generated_music/{filename}',
|
|
490
|
+
'response': f"Done! '{song_title}' is ready to spin!",
|
|
491
|
+
})
|
|
492
|
+
|
|
493
|
+
return jsonify({'action': 'status', 'status': 'complete_no_audio', 'response': 'Song generated but audio unavailable.'})
|
|
494
|
+
|
|
495
|
+
elif gen_status in ('PENDING', 'TEXT_SUCCESS', 'FIRST_SUCCESS'):
|
|
496
|
+
return jsonify({
|
|
497
|
+
'action': 'status',
|
|
498
|
+
'status': 'generating',
|
|
499
|
+
'elapsed_seconds': int(elapsed),
|
|
500
|
+
'response': f'Still cooking ({gen_status})...',
|
|
501
|
+
})
|
|
502
|
+
else:
|
|
503
|
+
return jsonify({'action': 'status', 'status': gen_status.lower(), 'response': f'Status: {gen_status}'})
|
|
504
|
+
|
|
505
|
+
except Exception as exc:
|
|
506
|
+
logger.warning(f'Suno status poll error: {exc}')
|
|
507
|
+
|
|
508
|
+
return jsonify({
|
|
509
|
+
'action': 'status',
|
|
510
|
+
'status': 'generating',
|
|
511
|
+
'elapsed_seconds': int(elapsed),
|
|
512
|
+
'response': f'Still working... ({int(elapsed)}s elapsed)',
|
|
513
|
+
})
|
|
514
|
+
|
|
515
|
+
|
|
516
|
+
def _action_credits():
|
|
517
|
+
"""Check remaining Suno API credits."""
|
|
518
|
+
try:
|
|
519
|
+
resp = http_requests.get(
|
|
520
|
+
f'{SUNO_API_BASE}/api/v1/account/credits',
|
|
521
|
+
headers={'Authorization': f'Bearer {SUNO_API_KEY}'},
|
|
522
|
+
timeout=10,
|
|
523
|
+
)
|
|
524
|
+
if resp.status_code == 200:
|
|
525
|
+
data = resp.json()
|
|
526
|
+
credits = data.get('data', {}).get('credits', data.get('credits', '?'))
|
|
527
|
+
return jsonify({'action': 'credits', 'credits': credits, 'response': f'Suno credits remaining: {credits}'})
|
|
528
|
+
return jsonify({'action': 'error', 'response': f'Credits check failed: HTTP {resp.status_code}'})
|
|
529
|
+
except Exception as exc:
|
|
530
|
+
return jsonify({'action': 'error', 'response': f'Credits check error: {exc}'})
|
|
531
|
+
|
|
532
|
+
|
|
533
|
+
# ---------------------------------------------------------------------------
|
|
534
|
+
# Webhook callback (sunoapi.org POSTs here when song is done)
|
|
535
|
+
# ---------------------------------------------------------------------------
|
|
536
|
+
|
|
537
|
+
|
|
538
|
+
@suno_bp.route('/api/suno/callback', methods=['POST'])
|
|
539
|
+
def suno_callback():
|
|
540
|
+
"""Webhook from sunoapi.org — downloads song and queues frontend notification."""
|
|
541
|
+
try:
|
|
542
|
+
# Verify HMAC signature when a webhook secret is configured
|
|
543
|
+
if SUNO_WEBHOOK_SECRET:
|
|
544
|
+
sig_header = request.headers.get('X-Suno-Signature', '')
|
|
545
|
+
payload = request.get_data()
|
|
546
|
+
expected = hmac.new(SUNO_WEBHOOK_SECRET.encode(), payload, hashlib.sha256).hexdigest()
|
|
547
|
+
if not hmac.compare_digest(sig_header, expected):
|
|
548
|
+
logger.warning('Suno callback rejected: invalid signature')
|
|
549
|
+
return jsonify({'status': 'forbidden'}), 403
|
|
550
|
+
|
|
551
|
+
data = request.json or {}
|
|
552
|
+
logger.info(f'Suno callback: {json.dumps(data, indent=2)[:500]}')
|
|
553
|
+
|
|
554
|
+
if data.get('code') == 200:
|
|
555
|
+
callback_type = data.get('data', {}).get('callbackType', '')
|
|
556
|
+
task_id = data.get('data', {}).get('taskId', '')
|
|
557
|
+
|
|
558
|
+
# sunoapi.org sends: "text" (lyrics ready), "first"/"second" (audio ready), "complete"
|
|
559
|
+
# Only process 'complete' to avoid duplicates (first/second are partial deliveries
|
|
560
|
+
# of the same songs that appear again in complete).
|
|
561
|
+
if callback_type == 'complete' or (
|
|
562
|
+
callback_type not in ('text', 'first', 'second') and data.get('data', {}).get('data')
|
|
563
|
+
):
|
|
564
|
+
songs = data.get('data', {}).get('data', [])
|
|
565
|
+
# Suno returns 2 clips per generation — only take the first one
|
|
566
|
+
# (user asked for 1 song, not 2 variations)
|
|
567
|
+
songs = songs[:1] if songs else []
|
|
568
|
+
for song in songs:
|
|
569
|
+
audio_url = song.get('audioUrl') or song.get('audio_url')
|
|
570
|
+
if not audio_url:
|
|
571
|
+
continue # "text" callback — lyrics only, no audio yet
|
|
572
|
+
song_id = song.get('id', task_id)
|
|
573
|
+
_raw_cb_title = song.get('title', '')
|
|
574
|
+
if _raw_cb_title and not _is_uuid(_raw_cb_title):
|
|
575
|
+
song_title = _raw_cb_title
|
|
576
|
+
else:
|
|
577
|
+
song_title = 'Generated Track'
|
|
578
|
+
duration = song.get('duration', 0)
|
|
579
|
+
slug = _slugify_title(song_title)
|
|
580
|
+
filename = _unique_filename(GENERATED_MUSIC_DIR, slug)
|
|
581
|
+
save_path = GENERATED_MUSIC_DIR / filename
|
|
582
|
+
|
|
583
|
+
if audio_url and not save_path.exists():
|
|
584
|
+
if not _is_safe_download_url(audio_url):
|
|
585
|
+
continue
|
|
586
|
+
try:
|
|
587
|
+
audio_resp = http_requests.get(audio_url, timeout=60, stream=True)
|
|
588
|
+
if audio_resp.status_code == 200:
|
|
589
|
+
content_length = int(audio_resp.headers.get('Content-Length', 0))
|
|
590
|
+
if content_length > SUNO_MAX_DOWNLOAD_BYTES:
|
|
591
|
+
logger.warning(f'Callback download rejected: size {content_length} exceeds limit')
|
|
592
|
+
continue
|
|
593
|
+
chunks = []
|
|
594
|
+
total = 0
|
|
595
|
+
for chunk in audio_resp.iter_content(chunk_size=65536):
|
|
596
|
+
total += len(chunk)
|
|
597
|
+
if total > SUNO_MAX_DOWNLOAD_BYTES:
|
|
598
|
+
logger.warning(f'Callback download aborted: exceeded {SUNO_MAX_DOWNLOAD_BYTES} bytes')
|
|
599
|
+
break
|
|
600
|
+
chunks.append(chunk)
|
|
601
|
+
else:
|
|
602
|
+
save_path.write_bytes(b''.join(chunks))
|
|
603
|
+
logger.info(f'Callback downloaded: {song_title} → {filename}')
|
|
604
|
+
|
|
605
|
+
# Find matching job
|
|
606
|
+
prompt = ''
|
|
607
|
+
style = ''
|
|
608
|
+
job_id = None
|
|
609
|
+
for jid, job in suno_jobs.items():
|
|
610
|
+
if job.get('task_id') == task_id:
|
|
611
|
+
job['status'] = 'complete'
|
|
612
|
+
job['song_id'] = song_id
|
|
613
|
+
job['url'] = f'/generated_music/{filename}'
|
|
614
|
+
prompt = job.get('prompt', '')
|
|
615
|
+
style = job.get('style', '')
|
|
616
|
+
job_id = jid
|
|
617
|
+
break
|
|
618
|
+
|
|
619
|
+
_add_song_to_metadata(filename, song_title, prompt, style, duration, song_id)
|
|
620
|
+
|
|
621
|
+
completed_songs_queue.append({
|
|
622
|
+
'song_id': song_id,
|
|
623
|
+
'filename': filename,
|
|
624
|
+
'title': song_title,
|
|
625
|
+
'job_id': job_id or task_id,
|
|
626
|
+
'url': f'/generated_music/{filename}',
|
|
627
|
+
'completed_at': datetime.now().isoformat(),
|
|
628
|
+
'prompt': prompt,
|
|
629
|
+
})
|
|
630
|
+
except Exception as exc:
|
|
631
|
+
logger.warning(f'Callback download error: {exc}')
|
|
632
|
+
|
|
633
|
+
return jsonify({'status': 'ok'})
|
|
634
|
+
|
|
635
|
+
except Exception as exc:
|
|
636
|
+
logger.error(f'Suno callback error: {exc}')
|
|
637
|
+
logger.error('Suno callback error: %s', exc)
|
|
638
|
+
return jsonify({'status': 'error', 'message': 'Internal server error'})
|
|
639
|
+
|
|
640
|
+
|
|
641
|
+
# ---------------------------------------------------------------------------
|
|
642
|
+
# Completed songs queue (frontend polls this)
|
|
643
|
+
# ---------------------------------------------------------------------------
|
|
644
|
+
|
|
645
|
+
|
|
646
|
+
@suno_bp.route('/api/suno/completed', methods=['GET', 'POST'])
|
|
647
|
+
def suno_completed():
|
|
648
|
+
"""
|
|
649
|
+
GET — Returns completed songs waiting for notification.
|
|
650
|
+
POST — Clears specific song (or all) from queue after UI has shown it.
|
|
651
|
+
"""
|
|
652
|
+
global completed_songs_queue
|
|
653
|
+
|
|
654
|
+
if request.method == 'POST':
|
|
655
|
+
song_id = request.args.get('song_id') or (request.get_json(silent=True) or {}).get('song_id')
|
|
656
|
+
if song_id:
|
|
657
|
+
completed_songs_queue = [s for s in completed_songs_queue if s['song_id'] != song_id]
|
|
658
|
+
else:
|
|
659
|
+
completed_songs_queue = []
|
|
660
|
+
return jsonify({'status': 'ok', 'cleared': True})
|
|
661
|
+
|
|
662
|
+
if completed_songs_queue:
|
|
663
|
+
return jsonify({'has_completed': True, 'songs': completed_songs_queue, 'count': len(completed_songs_queue)})
|
|
664
|
+
return jsonify({'has_completed': False, 'songs': [], 'count': 0})
|