-
Notifications
You must be signed in to change notification settings - Fork 5
Expand file tree
/
Copy pathsuno_client.py
More file actions
395 lines (326 loc) · 15.7 KB
/
Copy pathsuno_client.py
File metadata and controls
395 lines (326 loc) · 15.7 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
"""Suno API Client for music generation."""
import httpx
from typing import Optional, Dict, Any, List
import os
import re
from urllib.parse import urlparse
class SunoAPIError(Exception):
"""Base exception for Suno API errors."""
pass
class SunoClient:
"""Client for interacting with the Suno API."""
def __init__(self, api_key: Optional[str] = None, base_url: Optional[str] = None):
"""
Initialize the Suno API client.
Args:
api_key: Suno API key. If not provided, will read from SUNO_API_KEY env var.
base_url: Base URL for the Suno API. Defaults to https://api.sunoapi.org
"""
self.api_key = api_key or os.getenv("SUNO_API_KEY")
if not self.api_key:
raise ValueError("SUNO_API_KEY must be provided or set in environment")
self.base_url = base_url or os.getenv("SUNO_API_BASE_URL", "https://api.sunoapi.org")
self.client = httpx.AsyncClient(
base_url=self.base_url,
headers={
"Authorization": f"Bearer {self.api_key}",
"Content-Type": "application/json"
},
timeout=300.0 # 5 minute timeout for music generation
)
async def close(self):
"""Close the HTTP client."""
await self.client.aclose()
async def generate_music(
self,
prompt: Optional[str] = None,
make_instrumental: bool = False,
model_version: str = "V3_5",
wait_audio: bool = True,
custom_mode: bool = False,
style: Optional[str] = None,
title: Optional[str] = None,
callback_url: Optional[str] = None,
persona_id: Optional[str] = None,
negative_tags: Optional[str] = None,
vocal_gender: Optional[str] = None,
style_weight: Optional[float] = None,
weirdness_constraint: Optional[float] = None,
audio_weight: Optional[float] = None
) -> Dict[str, Any]:
"""
Generate music from a text prompt.
Args:
prompt: Text description/lyrics for the music. In Custom Mode with instrumental=False,
this is used as exact lyrics (max 3000-5000 chars depending on model).
In Non-custom Mode, used as core idea for auto-generated lyrics (max 500 chars).
Not required if custom_mode=True and make_instrumental=True.
make_instrumental: If True, generate instrumental only (no vocals)
model_version: AI model version (V3_5, V4, V4_5, V4_5PLUS, or V5). Defaults to V3_5.
wait_audio: If True, wait for audio generation to complete
custom_mode: If True, use custom mode (requires style and title; prompt required if not instrumental)
style: Music style/genre (required in Custom Mode). Max 200-1000 chars depending on model.
title: Song title (required in Custom Mode). Max 80 characters.
callback_url: Webhook URL for completion notification (required by API)
persona_id: Persona identifier for stylistic influence (Custom Mode only)
negative_tags: Styles/traits to exclude from generation
vocal_gender: Preferred vocal gender ('m' or 'f')
style_weight: Weight of style guidance (0.00-1.00)
weirdness_constraint: Creative deviation tolerance (0.00-1.00)
audio_weight: Input audio influence weighting (0.00-1.00)
Returns:
Dictionary containing generation task info and results
Raises:
SunoAPIError: If the API request fails
ValueError: If required parameters are missing or invalid
"""
# Validate custom mode requirements
if custom_mode:
if not style:
raise ValueError("style must be provided when custom_mode is True")
if not title:
raise ValueError("title must be provided when custom_mode is True")
if not make_instrumental and not prompt:
raise ValueError("prompt must be provided when custom_mode is True and instrumental is False")
# Validate non-custom mode requirements
if not custom_mode and not prompt:
raise ValueError("prompt is required in non-custom mode")
# Build payload with required parameters
payload: Dict[str, Any] = {
"instrumental": make_instrumental,
"model": model_version,
"wait_audio": wait_audio,
"customMode": custom_mode
}
# Add prompt if provided
if prompt:
payload["prompt"] = prompt
# Add custom mode parameters
if custom_mode:
payload["style"] = style
payload["title"] = title
# Add callback URL (required by API)
if callback_url:
payload["callBackUrl"] = callback_url
# Add optional parameters if provided
if persona_id:
payload["personaId"] = persona_id
if negative_tags:
payload["negativeTags"] = negative_tags
if vocal_gender and vocal_gender in ['m', 'f']:
payload["vocalGender"] = vocal_gender
if style_weight is not None and 0.0 <= style_weight <= 1.0:
payload["styleWeight"] = style_weight
if weirdness_constraint is not None and 0.0 <= weirdness_constraint <= 1.0:
payload["weirdnessConstraint"] = weirdness_constraint
if audio_weight is not None and 0.0 <= audio_weight <= 1.0:
payload["audioWeight"] = audio_weight
try:
response = await self.client.post("/api/v1/generate", json=payload)
response.raise_for_status()
result = response.json()
# Check for API-level errors (Suno returns HTTP 200 with error codes in JSON)
if isinstance(result, dict) and result.get("code") != 200:
error_msg = result.get("msg", "Unknown error")
raise SunoAPIError(f"API Error (code {result.get('code')}): {error_msg}")
return result
except httpx.HTTPError as e:
raise SunoAPIError(f"Failed to generate music: {str(e)}")
async def get_task_status(self, task_id: str) -> Dict[str, Any]:
"""
Get status of a music generation task using taskId.
Args:
task_id: Task ID returned from generate_music
Returns:
Dictionary containing task status and track information
Raises:
SunoAPIError: If the API request fails
"""
try:
response = await self.client.get(
"/api/v1/generate/record-info",
params={"taskId": task_id}
)
response.raise_for_status()
result = response.json()
# Check for API-level errors
if isinstance(result, dict) and result.get("code") != 200:
error_msg = result.get("msg", "Unknown error")
raise SunoAPIError(f"API Error (code {result.get('code')}): {error_msg}")
return result
except httpx.HTTPError as e:
raise SunoAPIError(f"Failed to get task status: {str(e)}")
async def get_music_info(self, ids: List[str]) -> Dict[str, Any]:
"""
Get information about generated music tracks using track IDs.
Args:
ids: List of track IDs to retrieve information for
Returns:
Dictionary containing track information
Raises:
SunoAPIError: If the API request fails
"""
try:
# Join IDs with commas for query parameter
ids_param = ",".join(ids)
response = await self.client.get(
"/api/v1/generate/record-info",
params={"ids": ids_param}
)
response.raise_for_status()
result = response.json()
# Check for API-level errors
if isinstance(result, dict) and result.get("code") != 200:
error_msg = result.get("msg", "Unknown error")
raise SunoAPIError(f"API Error (code {result.get('code')}): {error_msg}")
return result
except httpx.HTTPError as e:
raise SunoAPIError(f"Failed to get music info: {str(e)}")
async def get_credits(self) -> Dict[str, Any]:
"""
Get remaining API credits.
Returns:
Dictionary containing credit balance and usage statistics
Raises:
SunoAPIError: If the API request fails
"""
try:
response = await self.client.get("/api/v1/generate/credit")
response.raise_for_status()
result = response.json()
# Check for API-level errors
if isinstance(result, dict) and result.get("code") != 200:
error_msg = result.get("msg", "Unknown error")
raise SunoAPIError(f"API Error (code {result.get('code')}): {error_msg}")
return result
except httpx.HTTPError as e:
raise SunoAPIError(f"Failed to get credits: {str(e)}")
async def convert_to_wav(
self,
callback_url: str,
task_id: str,
audio_id: str
) -> Dict[str, Any]:
"""
Convert a generated audio track to WAV format.
Per Suno API docs: BOTH taskId AND audioId are REQUIRED parameters.
The audioId specifies which exact track to convert (tasks can have multiple tracks).
Args:
callback_url: Webhook URL for conversion completion notification (required)
task_id: Original generation task ID (taskId) from generate_music (required)
audio_id: Track ID (audioId) of the specific track to convert (required)
Returns:
Dictionary containing WAV conversion task information with taskId
Raises:
SunoAPIError: If the API request fails
ValueError: If required parameters are missing or invalid
Example:
# Generate music and wait for completion
music = await client.generate_music(prompt="Epic soundtrack", wait_audio=True)
# Extract BOTH IDs (both required for WAV conversion)
gen_task_id = music['data']['taskId'] # Generation job ID
track_id = music['data']['sunoData'][0]['id'] # Specific track ID
# Convert to WAV (requires both IDs)
conversion = await client.convert_to_wav(
callback_url="https://example.com/webhook",
task_id=gen_task_id,
audio_id=track_id
)
"""
if not callback_url:
raise ValueError("callback_url is required for WAV conversion")
if not task_id:
raise ValueError("task_id is required for WAV conversion (generation job ID)")
if not audio_id:
raise ValueError("audio_id is required for WAV conversion (specific track ID)")
# Validate callback_url format
if callback_url:
parsed = urlparse(callback_url)
if not parsed.scheme or not parsed.netloc:
raise ValueError(
f"Invalid callback_url format: '{callback_url}'. "
"Must be a valid URL like 'https://example.com/webhook'"
)
if parsed.scheme not in ['http', 'https']:
raise ValueError(
f"Invalid callback_url scheme: '{parsed.scheme}'. "
"Must use http:// or https://"
)
# Validate audio_id format (should be UUID)
if audio_id:
# UUID format: 8-4-4-4-12 hex characters
uuid_pattern = re.compile(r'^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$', re.IGNORECASE)
if not uuid_pattern.match(audio_id):
raise ValueError(
f"Invalid audio_id format: '{audio_id}'. "
f"Expected UUID format like '7752c889-3601-4e55-b805-54a28a53de85'. "
f"This is the track's 'id' field from sunoData array, NOT the generation taskId. "
f"If you have a taskId (hex string without dashes), use the task_id parameter instead."
)
# Validate task_id format (should be hex string without dashes, or could have dashes)
# Task IDs are typically hex strings (with or without dashes)
# If user accidentally passes a UUID here, warn them
uuid_pattern = re.compile(r'^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$', re.IGNORECASE)
if uuid_pattern.match(task_id):
raise ValueError(
f"Possible parameter error: task_id '{task_id}' looks like a UUID (track ID). "
f"task_id should be the generation job ID (hex string), not a track UUID. "
f"Check that you're using task_id from music['data']['taskId'], not from sunoData[]['id']"
)
# Build payload - BOTH IDs are required per API documentation
payload: Dict[str, Any] = {
"callBackUrl": callback_url,
"taskId": task_id,
"audioId": audio_id
}
try:
response = await self.client.post("/api/v1/wav/generate", json=payload)
response.raise_for_status()
result = response.json()
# Check for API-level errors (Suno returns HTTP 200 with error codes in JSON)
if isinstance(result, dict):
code = result.get("code")
# 409 = WAV already exists - this is informational, not an error
if code == 409:
# WAV already exists - provide helpful message with the original conversion task ID
# Note: We don't have the original conversion task ID, so user needs to retrieve it
raise SunoAPIError(
f"WAV conversion already exists for this track (code 409). "
f"The track audio_id '{audio_id}' has already been converted to WAV. "
f"To retrieve the WAV download URL, you need the original conversion task_id. "
f"If you don't have it, you may need to track conversion task IDs when creating conversions. "
f"Note: The Suno API does not provide a way to query WAV status by audio_id alone."
)
elif code != 200:
error_msg = result.get("msg", "Unknown error")
raise SunoAPIError(f"API Error (code {code}): {error_msg}")
return result
except httpx.HTTPError as e:
raise SunoAPIError(f"Failed to convert to WAV: {str(e)}")
async def get_wav_conversion_status(self, task_id: str) -> Dict[str, Any]:
"""
Get status of a WAV conversion task using taskId.
Args:
task_id: Task ID returned from convert_to_wav
Returns:
Dictionary containing WAV conversion status and download information
Raises:
SunoAPIError: If the API request fails
ValueError: If task_id is missing
"""
if not task_id:
raise ValueError("task_id is required to check WAV conversion status")
try:
response = await self.client.get(
"/api/v1/wav/record-info",
params={"taskId": task_id}
)
response.raise_for_status()
result = response.json()
# Check for API-level errors
if isinstance(result, dict) and result.get("code") != 200:
error_msg = result.get("msg", "Unknown error")
raise SunoAPIError(f"API Error (code {result.get('code')}): {error_msg}")
return result
except httpx.HTTPError as e:
raise SunoAPIError(f"Failed to get WAV conversion status: {str(e)}")