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.
Files changed (185) hide show
  1. package/.env.example +104 -0
  2. package/Dockerfile +30 -0
  3. package/LICENSE +21 -0
  4. package/README.md +638 -0
  5. package/SETUP.md +360 -0
  6. package/app.py +232 -0
  7. package/auto-approve-devices.js +111 -0
  8. package/cli/index.js +372 -0
  9. package/config/__init__.py +4 -0
  10. package/config/default.yaml +43 -0
  11. package/config/flags.yaml +67 -0
  12. package/config/loader.py +203 -0
  13. package/config/providers.yaml +71 -0
  14. package/config/speech_normalization.yaml +182 -0
  15. package/config/theme.json +4 -0
  16. package/data/greetings.json +25 -0
  17. package/default-pages/ai-image-creator.html +915 -0
  18. package/default-pages/bulk-image-uploader.html +492 -0
  19. package/default-pages/desktop.html +2865 -0
  20. package/default-pages/file-explorer.html +854 -0
  21. package/default-pages/interactive-map.html +655 -0
  22. package/default-pages/style-guide.html +1005 -0
  23. package/default-pages/website-setup.html +1623 -0
  24. package/deploy/openclaw/Dockerfile +46 -0
  25. package/deploy/openvoiceui.service +30 -0
  26. package/deploy/setup-nginx.sh +50 -0
  27. package/deploy/setup-sudo.sh +306 -0
  28. package/deploy/skill-runner/Dockerfile +19 -0
  29. package/deploy/skill-runner/requirements.txt +14 -0
  30. package/deploy/skill-runner/server.py +269 -0
  31. package/deploy/supertonic/Dockerfile +22 -0
  32. package/deploy/supertonic/server.py +79 -0
  33. package/docker-compose.pinokio.yml +11 -0
  34. package/docker-compose.yml +59 -0
  35. package/greetings.json +25 -0
  36. package/index.html +65 -0
  37. package/inject-device-identity.js +142 -0
  38. package/package.json +82 -0
  39. package/profiles/default.json +114 -0
  40. package/profiles/manager.py +354 -0
  41. package/profiles/schema.json +337 -0
  42. package/prompts/voice-system-prompt.md +149 -0
  43. package/providers/__init__.py +39 -0
  44. package/providers/base.py +63 -0
  45. package/providers/llm/__init__.py +12 -0
  46. package/providers/llm/base.py +71 -0
  47. package/providers/llm/clawdbot_provider.py +112 -0
  48. package/providers/llm/zai_provider.py +115 -0
  49. package/providers/registry.py +320 -0
  50. package/providers/stt/__init__.py +12 -0
  51. package/providers/stt/base.py +58 -0
  52. package/providers/stt/webspeech_provider.py +49 -0
  53. package/providers/stt/whisper_provider.py +100 -0
  54. package/providers/tts/__init__.py +20 -0
  55. package/providers/tts/base.py +91 -0
  56. package/providers/tts/groq_provider.py +74 -0
  57. package/providers/tts/supertonic_provider.py +72 -0
  58. package/requirements.txt +38 -0
  59. package/routes/__init__.py +10 -0
  60. package/routes/admin.py +515 -0
  61. package/routes/canvas.py +1315 -0
  62. package/routes/chat.py +51 -0
  63. package/routes/conversation.py +2158 -0
  64. package/routes/elevenlabs_hybrid.py +306 -0
  65. package/routes/greetings.py +98 -0
  66. package/routes/icons.py +279 -0
  67. package/routes/image_gen.py +364 -0
  68. package/routes/instructions.py +190 -0
  69. package/routes/music.py +838 -0
  70. package/routes/onboarding.py +43 -0
  71. package/routes/pi.py +62 -0
  72. package/routes/profiles.py +215 -0
  73. package/routes/report_issue.py +68 -0
  74. package/routes/static_files.py +533 -0
  75. package/routes/suno.py +664 -0
  76. package/routes/theme.py +81 -0
  77. package/routes/transcripts.py +199 -0
  78. package/routes/vision.py +348 -0
  79. package/routes/workspace.py +288 -0
  80. package/server.py +1510 -0
  81. package/services/__init__.py +1 -0
  82. package/services/auth.py +143 -0
  83. package/services/canvas_versioning.py +239 -0
  84. package/services/db_pool.py +107 -0
  85. package/services/gateway.py +16 -0
  86. package/services/gateway_manager.py +333 -0
  87. package/services/gateways/__init__.py +12 -0
  88. package/services/gateways/base.py +110 -0
  89. package/services/gateways/compat.py +264 -0
  90. package/services/gateways/openclaw.py +1134 -0
  91. package/services/health.py +100 -0
  92. package/services/memory_client.py +455 -0
  93. package/services/paths.py +26 -0
  94. package/services/speech_normalizer.py +285 -0
  95. package/services/tts.py +270 -0
  96. package/setup-config.js +262 -0
  97. package/sounds/air_horn.mp3 +0 -0
  98. package/sounds/bruh.mp3 +0 -0
  99. package/sounds/crowd_cheer.mp3 +0 -0
  100. package/sounds/gunshot.mp3 +0 -0
  101. package/sounds/impact.mp3 +0 -0
  102. package/sounds/lets_go.mp3 +0 -0
  103. package/sounds/record_stop.mp3 +0 -0
  104. package/sounds/rewind.mp3 +0 -0
  105. package/sounds/sad_trombone.mp3 +0 -0
  106. package/sounds/scratch_long.mp3 +0 -0
  107. package/sounds/yeah.mp3 +0 -0
  108. package/src/adapters/ClawdBotAdapter.js +264 -0
  109. package/src/adapters/_template.js +133 -0
  110. package/src/adapters/elevenlabs-classic.js +841 -0
  111. package/src/adapters/elevenlabs-hybrid.js +812 -0
  112. package/src/adapters/hume-evi.js +676 -0
  113. package/src/admin.html +1339 -0
  114. package/src/app.js +8802 -0
  115. package/src/core/Config.js +173 -0
  116. package/src/core/EmotionEngine.js +307 -0
  117. package/src/core/EventBridge.js +180 -0
  118. package/src/core/EventBus.js +117 -0
  119. package/src/core/VoiceSession.js +607 -0
  120. package/src/face/BaseFace.js +259 -0
  121. package/src/face/EyeFace.js +208 -0
  122. package/src/face/HaloSmokeFace.js +509 -0
  123. package/src/face/manifest.json +27 -0
  124. package/src/face/previews/eyes.svg +16 -0
  125. package/src/face/previews/orb.svg +29 -0
  126. package/src/features/MusicPlayer.js +620 -0
  127. package/src/features/Soundboard.js +128 -0
  128. package/src/providers/DeepgramSTT.js +472 -0
  129. package/src/providers/DeepgramStreamingSTT.js +766 -0
  130. package/src/providers/GroqSTT.js +559 -0
  131. package/src/providers/TTSPlayer.js +323 -0
  132. package/src/providers/WebSpeechSTT.js +479 -0
  133. package/src/providers/tts/BaseTTSProvider.js +81 -0
  134. package/src/providers/tts/HumeProvider.js +77 -0
  135. package/src/providers/tts/SupertonicProvider.js +174 -0
  136. package/src/providers/tts/index.js +140 -0
  137. package/src/shell/adapter-registry.js +154 -0
  138. package/src/shell/caller-bridge.js +35 -0
  139. package/src/shell/camera-bridge.js +28 -0
  140. package/src/shell/canvas-bridge.js +32 -0
  141. package/src/shell/commercial-bridge.js +44 -0
  142. package/src/shell/face-bridge.js +44 -0
  143. package/src/shell/music-bridge.js +60 -0
  144. package/src/shell/orchestrator.js +233 -0
  145. package/src/shell/profile-discovery.js +303 -0
  146. package/src/shell/sounds-bridge.js +28 -0
  147. package/src/shell/transcript-bridge.js +61 -0
  148. package/src/shell/waveform-bridge.js +33 -0
  149. package/src/styles/base.css +2862 -0
  150. package/src/styles/face.css +417 -0
  151. package/src/styles/pi-overrides.css +89 -0
  152. package/src/styles/theme-dark.css +67 -0
  153. package/src/test-tts.html +175 -0
  154. package/src/ui/AppShell.js +544 -0
  155. package/src/ui/ProfileSwitcher.js +228 -0
  156. package/src/ui/SessionControl.js +240 -0
  157. package/src/ui/face/FacePicker.js +195 -0
  158. package/src/ui/face/FaceRenderer.js +309 -0
  159. package/src/ui/settings/PlaylistEditor.js +366 -0
  160. package/src/ui/settings/SettingsPanel.css +684 -0
  161. package/src/ui/settings/SettingsPanel.js +419 -0
  162. package/src/ui/settings/TTSVoicePreview.js +210 -0
  163. package/src/ui/themes/ThemeManager.js +213 -0
  164. package/src/ui/visualizers/BaseVisualizer.js +29 -0
  165. package/src/ui/visualizers/PartyFXVisualizer.css +291 -0
  166. package/src/ui/visualizers/PartyFXVisualizer.js +637 -0
  167. package/static/emulators/jsdos/js-dos.css +1 -0
  168. package/static/emulators/jsdos/js-dos.js +22 -0
  169. package/static/favicon.svg +55 -0
  170. package/static/icons/apple-touch-icon.png +0 -0
  171. package/static/icons/favicon-32.png +0 -0
  172. package/static/icons/icon-192.png +0 -0
  173. package/static/icons/icon-512.png +0 -0
  174. package/static/install.html +449 -0
  175. package/static/manifest.json +26 -0
  176. package/static/sw.js +21 -0
  177. package/tts_providers/__init__.py +136 -0
  178. package/tts_providers/base_provider.py +319 -0
  179. package/tts_providers/groq_provider.py +155 -0
  180. package/tts_providers/hume_provider.py +226 -0
  181. package/tts_providers/providers_config.json +119 -0
  182. package/tts_providers/qwen3_provider.py +371 -0
  183. package/tts_providers/resemble_provider.py +315 -0
  184. package/tts_providers/supertonic_provider.py +557 -0
  185. 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})