suno-easy 0.1.0__py3-none-any.whl
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.
- suno_easy/__init__.py +25 -0
- suno_easy/audio.py +380 -0
- suno_easy/client.py +147 -0
- suno_easy/exceptions.py +19 -0
- suno_easy/lyrics.py +56 -0
- suno_easy/models.py +209 -0
- suno_easy/music.py +182 -0
- suno_easy/persona.py +30 -0
- suno_easy/utils.py +5 -0
- suno_easy-0.1.0.dist-info/METADATA +216 -0
- suno_easy-0.1.0.dist-info/RECORD +14 -0
- suno_easy-0.1.0.dist-info/WHEEL +5 -0
- suno_easy-0.1.0.dist-info/licenses/LICENSE +21 -0
- suno_easy-0.1.0.dist-info/top_level.txt +1 -0
suno_easy/__init__.py
ADDED
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
from .client import SunoClient
|
|
2
|
+
from .models import (
|
|
3
|
+
Song,
|
|
4
|
+
Lyrics,
|
|
5
|
+
CoverImage,
|
|
6
|
+
SeparatedStems,
|
|
7
|
+
MIDIData,
|
|
8
|
+
MIDINote,
|
|
9
|
+
MIDIInstrument,
|
|
10
|
+
)
|
|
11
|
+
from .exceptions import SunoError, TaskFailed, SunoAPIError
|
|
12
|
+
|
|
13
|
+
__all__ = [
|
|
14
|
+
"SunoClient",
|
|
15
|
+
"Song",
|
|
16
|
+
"Lyrics",
|
|
17
|
+
"CoverImage",
|
|
18
|
+
"SeparatedStems",
|
|
19
|
+
"MIDIData",
|
|
20
|
+
"MIDINote",
|
|
21
|
+
"MIDIInstrument",
|
|
22
|
+
"SunoError",
|
|
23
|
+
"TaskFailed",
|
|
24
|
+
"SunoAPIError",
|
|
25
|
+
]
|
suno_easy/audio.py
ADDED
|
@@ -0,0 +1,380 @@
|
|
|
1
|
+
from .models import Song, CoverImage, SeparatedStems, MIDIData
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
class AudioResource:
|
|
5
|
+
"""Resource manager for audio processing (vocal separation, MIDI, covers, uploading)."""
|
|
6
|
+
|
|
7
|
+
def __init__(self, client):
|
|
8
|
+
"""Initializes the AudioResource with a client reference.
|
|
9
|
+
|
|
10
|
+
Args:
|
|
11
|
+
client (SunoClient): The parent client instance.
|
|
12
|
+
"""
|
|
13
|
+
self.client = client
|
|
14
|
+
|
|
15
|
+
def cover(
|
|
16
|
+
self,
|
|
17
|
+
upload_url: str,
|
|
18
|
+
style: str,
|
|
19
|
+
title: str,
|
|
20
|
+
prompt: str = "",
|
|
21
|
+
model: str = "V4_5ALL",
|
|
22
|
+
custom_mode: bool = True,
|
|
23
|
+
callback_url: str | None = None,
|
|
24
|
+
wait: bool = True,
|
|
25
|
+
timeout: int = 300
|
|
26
|
+
) -> str | list[Song]:
|
|
27
|
+
"""Uploads an audio track and transforms it into a new style (Cover).
|
|
28
|
+
|
|
29
|
+
Args:
|
|
30
|
+
upload_url (str): Public URL of the audio file to cover.
|
|
31
|
+
style (str): Musical style for the cover. Required if custom_mode is True. Defaults to "".
|
|
32
|
+
title (str): Title for the cover track. Required if custom_mode is True. Defaults to "".
|
|
33
|
+
prompt (str): Optional prompt describing changes/vocals. Defaults to "".
|
|
34
|
+
model (str): Model version. Defaults to "V4_5ALL".
|
|
35
|
+
custom_mode (bool): Use custom mode. Defaults to True.
|
|
36
|
+
callback_url (str, optional): Webhook URL for completion notification.
|
|
37
|
+
wait (bool): If True, poll and wait for task completion. Defaults to True.
|
|
38
|
+
timeout (int): Max time in seconds to wait for task completion. Defaults to 300.
|
|
39
|
+
|
|
40
|
+
Returns:
|
|
41
|
+
str | list[Song]: A list of covered Song objects if wait is True, otherwise the taskId as a string.
|
|
42
|
+
"""
|
|
43
|
+
payload = {
|
|
44
|
+
"uploadUrl": upload_url,
|
|
45
|
+
"style": style,
|
|
46
|
+
"title": title,
|
|
47
|
+
"prompt": prompt,
|
|
48
|
+
"model": model,
|
|
49
|
+
"customMode": custom_mode
|
|
50
|
+
}
|
|
51
|
+
if callback_url:
|
|
52
|
+
payload["callBackUrl"] = callback_url
|
|
53
|
+
|
|
54
|
+
res = self.client.post("/api/v1/generate/upload-cover", payload)
|
|
55
|
+
task_id = res["data"]["taskId"]
|
|
56
|
+
|
|
57
|
+
if not wait:
|
|
58
|
+
return task_id
|
|
59
|
+
|
|
60
|
+
task_data = self.client.wait_task(task_id, endpoint="/api/v1/generate/record-info", timeout=timeout)
|
|
61
|
+
return Song.from_task_data(task_data)
|
|
62
|
+
|
|
63
|
+
def extend(
|
|
64
|
+
self,
|
|
65
|
+
upload_url: str,
|
|
66
|
+
continue_at: int,
|
|
67
|
+
prompt: str,
|
|
68
|
+
style: str = "",
|
|
69
|
+
title: str = "",
|
|
70
|
+
model: str = "V4_5ALL",
|
|
71
|
+
default_param_flag: bool = True,
|
|
72
|
+
persona_id: str | None = None,
|
|
73
|
+
persona_model: str | None = None,
|
|
74
|
+
callback_url: str | None = None,
|
|
75
|
+
wait: bool = True,
|
|
76
|
+
timeout: int = 300
|
|
77
|
+
) -> str | list[Song]:
|
|
78
|
+
"""Uploads an audio file and extends it.
|
|
79
|
+
|
|
80
|
+
Args:
|
|
81
|
+
upload_url (str): Public URL of the audio file to extend.
|
|
82
|
+
continue_at (int): Time in seconds in the original track where extension starts.
|
|
83
|
+
prompt (str): Text prompt describing the continuation.
|
|
84
|
+
style (str): Musical style for the continuation. Defaults to "".
|
|
85
|
+
title (str): Title for the extended track. Defaults to "".
|
|
86
|
+
model (str): Model version. Defaults to "V4_5ALL".
|
|
87
|
+
default_param_flag (bool): If True, uses custom parameters. Defaults to True.
|
|
88
|
+
persona_id (str, optional): Optional Persona ID.
|
|
89
|
+
persona_model (str, optional): Persona model type ("style_persona" or "voice_persona").
|
|
90
|
+
callback_url (str, optional): Webhook URL for callback notification.
|
|
91
|
+
wait (bool): If True, poll and wait for completion. Defaults to True.
|
|
92
|
+
timeout (int): Max wait time in seconds. Defaults to 300.
|
|
93
|
+
|
|
94
|
+
Returns:
|
|
95
|
+
str | list[Song]: A list of extended Song objects if wait is True, otherwise the taskId as a string.
|
|
96
|
+
"""
|
|
97
|
+
payload = {
|
|
98
|
+
"uploadUrl": upload_url,
|
|
99
|
+
"continueAt": continue_at,
|
|
100
|
+
"prompt": prompt,
|
|
101
|
+
"style": style,
|
|
102
|
+
"title": title,
|
|
103
|
+
"model": model,
|
|
104
|
+
"defaultParamFlag": default_param_flag
|
|
105
|
+
}
|
|
106
|
+
if persona_id:
|
|
107
|
+
payload["personaId"] = persona_id
|
|
108
|
+
if persona_model:
|
|
109
|
+
payload["personaModel"] = persona_model
|
|
110
|
+
if callback_url:
|
|
111
|
+
payload["callBackUrl"] = callback_url
|
|
112
|
+
|
|
113
|
+
res = self.client.post("/api/v1/generate/upload-extend", payload)
|
|
114
|
+
task_id = res["data"]["taskId"]
|
|
115
|
+
|
|
116
|
+
if not wait:
|
|
117
|
+
return task_id
|
|
118
|
+
|
|
119
|
+
task_data = self.client.wait_task(task_id, endpoint="/api/v1/generate/record-info", timeout=timeout)
|
|
120
|
+
return Song.from_task_data(task_data)
|
|
121
|
+
|
|
122
|
+
def generate_cover_image(
|
|
123
|
+
self,
|
|
124
|
+
task_id: str,
|
|
125
|
+
callback_url: str | None = None,
|
|
126
|
+
wait: bool = True,
|
|
127
|
+
timeout: int = 300
|
|
128
|
+
) -> str | CoverImage:
|
|
129
|
+
"""Generates a personalized cover image for a music task.
|
|
130
|
+
|
|
131
|
+
Args:
|
|
132
|
+
task_id (str): Task ID of the original music generation.
|
|
133
|
+
callback_url (str, optional): Webhook URL.
|
|
134
|
+
wait (bool): If True, poll and wait for completion. Defaults to True.
|
|
135
|
+
timeout (int): Max wait time in seconds. Defaults to 300.
|
|
136
|
+
|
|
137
|
+
Returns:
|
|
138
|
+
str | CoverImage: A CoverImage object if wait is True, otherwise the taskId as a string.
|
|
139
|
+
"""
|
|
140
|
+
payload = {"taskId": task_id}
|
|
141
|
+
if callback_url:
|
|
142
|
+
payload["callBackUrl"] = callback_url
|
|
143
|
+
|
|
144
|
+
res = self.client.post("/api/v1/suno/cover/generate", payload)
|
|
145
|
+
new_task_id = res["data"]["taskId"]
|
|
146
|
+
|
|
147
|
+
if not wait:
|
|
148
|
+
return new_task_id
|
|
149
|
+
|
|
150
|
+
task_data = self.client.wait_task(new_task_id, endpoint="/api/v1/suno/cover/record-info", timeout=timeout)
|
|
151
|
+
return CoverImage.from_task_data(task_data)
|
|
152
|
+
|
|
153
|
+
def get_cover_image(self, task_id: str) -> CoverImage:
|
|
154
|
+
"""Retrieves the cover image details for a cover task.
|
|
155
|
+
|
|
156
|
+
Args:
|
|
157
|
+
task_id (str): The ID of the cover task.
|
|
158
|
+
|
|
159
|
+
Returns:
|
|
160
|
+
CoverImage: The CoverImage object containing generated image URLs.
|
|
161
|
+
"""
|
|
162
|
+
task_data = self.client.get_task_info(task_id, endpoint="/api/v1/suno/cover/record-info")
|
|
163
|
+
return CoverImage.from_task_data(task_data)
|
|
164
|
+
|
|
165
|
+
def separate_vocals(
|
|
166
|
+
self,
|
|
167
|
+
task_id: str,
|
|
168
|
+
mode: str = "separate_vocal",
|
|
169
|
+
audio_id: str | None = None,
|
|
170
|
+
callback_url: str | None = None,
|
|
171
|
+
wait: bool = True,
|
|
172
|
+
timeout: int = 300
|
|
173
|
+
) -> str | SeparatedStems:
|
|
174
|
+
"""Separates vocals from instruments.
|
|
175
|
+
|
|
176
|
+
Args:
|
|
177
|
+
task_id (str): Task ID of the original music generation.
|
|
178
|
+
mode (str): Separation mode: "separate_vocal" (2 stems) or "split_stem" (up to 12 stems).
|
|
179
|
+
Defaults to "separate_vocal".
|
|
180
|
+
audio_id (str, optional): Optional specific audio variation ID to process.
|
|
181
|
+
callback_url (str, optional): Webhook URL.
|
|
182
|
+
wait (bool): If True, poll and wait for completion. Defaults to True.
|
|
183
|
+
timeout (int): Max wait time in seconds. Defaults to 300.
|
|
184
|
+
|
|
185
|
+
Returns:
|
|
186
|
+
str | SeparatedStems: A SeparatedStems object if wait is True, otherwise the taskId as a string.
|
|
187
|
+
"""
|
|
188
|
+
payload = {
|
|
189
|
+
"taskId": task_id,
|
|
190
|
+
"type": mode
|
|
191
|
+
}
|
|
192
|
+
if audio_id:
|
|
193
|
+
payload["audioId"] = audio_id
|
|
194
|
+
if callback_url:
|
|
195
|
+
payload["callBackUrl"] = callback_url
|
|
196
|
+
|
|
197
|
+
res = self.client.post("/api/v1/vocal-removal/generate", payload)
|
|
198
|
+
new_task_id = res["data"]["taskId"]
|
|
199
|
+
|
|
200
|
+
if not wait:
|
|
201
|
+
return new_task_id
|
|
202
|
+
|
|
203
|
+
task_data = self.client.wait_task(new_task_id, endpoint="/api/v1/vocal-removal/record-info", timeout=timeout)
|
|
204
|
+
return SeparatedStems.from_task_data(task_data)
|
|
205
|
+
|
|
206
|
+
def get_separated_stems(self, task_id: str) -> SeparatedStems:
|
|
207
|
+
"""Retrieves stem separation results.
|
|
208
|
+
|
|
209
|
+
Args:
|
|
210
|
+
task_id (str): The ID of the vocal separation task.
|
|
211
|
+
|
|
212
|
+
Returns:
|
|
213
|
+
SeparatedStems: The SeparatedStems object containing urls of processed stems.
|
|
214
|
+
"""
|
|
215
|
+
task_data = self.client.get_task_info(task_id, endpoint="/api/v1/vocal-removal/record-info")
|
|
216
|
+
return SeparatedStems.from_task_data(task_data)
|
|
217
|
+
|
|
218
|
+
def generate_midi(
|
|
219
|
+
self,
|
|
220
|
+
task_id: str,
|
|
221
|
+
callback_url: str | None = None,
|
|
222
|
+
wait: bool = True,
|
|
223
|
+
timeout: int = 300
|
|
224
|
+
) -> str | MIDIData:
|
|
225
|
+
"""Converts separated audio tracks into MIDI format.
|
|
226
|
+
|
|
227
|
+
Args:
|
|
228
|
+
task_id (str): Task ID of the vocal removal task.
|
|
229
|
+
callback_url (str, optional): Webhook URL.
|
|
230
|
+
wait (bool): If True, poll and wait for completion. Defaults to True.
|
|
231
|
+
timeout (int): Max wait time in seconds. Defaults to 300.
|
|
232
|
+
|
|
233
|
+
Returns:
|
|
234
|
+
str | MIDIData: A MIDIData object if wait is True, otherwise the taskId as a string.
|
|
235
|
+
"""
|
|
236
|
+
payload = {"taskId": task_id}
|
|
237
|
+
if callback_url:
|
|
238
|
+
payload["callBackUrl"] = callback_url
|
|
239
|
+
|
|
240
|
+
res = self.client.post("/api/v1/midi/generate", payload)
|
|
241
|
+
new_task_id = res["data"]["taskId"]
|
|
242
|
+
|
|
243
|
+
if not wait:
|
|
244
|
+
return new_task_id
|
|
245
|
+
|
|
246
|
+
task_data = self.client.wait_task(new_task_id, endpoint="/api/v1/midi/record-info", timeout=timeout)
|
|
247
|
+
return MIDIData.from_task_data(task_data)
|
|
248
|
+
|
|
249
|
+
def get_midi(self, task_id: str) -> MIDIData:
|
|
250
|
+
"""Retrieves generated MIDI note/instrument data.
|
|
251
|
+
|
|
252
|
+
Args:
|
|
253
|
+
task_id (str): The ID of the MIDI generation task.
|
|
254
|
+
|
|
255
|
+
Returns:
|
|
256
|
+
MIDIData: The MIDIData object containing instrument and note details.
|
|
257
|
+
"""
|
|
258
|
+
task_data = self.client.get_task_info(task_id, endpoint="/api/v1/midi/record-info")
|
|
259
|
+
return MIDIData.from_task_data(task_data)
|
|
260
|
+
|
|
261
|
+
def add_vocals(
|
|
262
|
+
self,
|
|
263
|
+
upload_url: str,
|
|
264
|
+
prompt: str,
|
|
265
|
+
style: str = "",
|
|
266
|
+
title: str = "",
|
|
267
|
+
negative_tags: str | None = None,
|
|
268
|
+
vocal_gender: str | None = None,
|
|
269
|
+
style_weight: float | None = None,
|
|
270
|
+
weirdness_constraint: float | None = None,
|
|
271
|
+
audio_weight: float | None = None,
|
|
272
|
+
model: str | None = None,
|
|
273
|
+
callback_url: str | None = None,
|
|
274
|
+
wait: bool = True,
|
|
275
|
+
timeout: int = 300
|
|
276
|
+
) -> str | list[Song]:
|
|
277
|
+
"""Adds vocals to an instrumental track.
|
|
278
|
+
|
|
279
|
+
Args:
|
|
280
|
+
upload_url (str): Public URL of the instrumental audio track.
|
|
281
|
+
prompt (str): Text prompt describing the vocal lyrics/style.
|
|
282
|
+
style (str): Musical style. Defaults to "".
|
|
283
|
+
title (str): Title for the track. Defaults to "".
|
|
284
|
+
negative_tags (str, optional): Characteristics to exclude.
|
|
285
|
+
vocal_gender (str, optional): Preferred vocal gender.
|
|
286
|
+
style_weight (float, optional): Weight parameter.
|
|
287
|
+
weirdness_constraint (float, optional): Tuning parameter.
|
|
288
|
+
audio_weight (float, optional): Tuning parameter.
|
|
289
|
+
model (str, optional): Model version.
|
|
290
|
+
callback_url (str, optional): Webhook URL.
|
|
291
|
+
wait (bool): If True, poll and wait for completion. Defaults to True.
|
|
292
|
+
timeout (int): Max wait time in seconds. Defaults to 300.
|
|
293
|
+
|
|
294
|
+
Returns:
|
|
295
|
+
str | list[Song]: A list of Song objects if wait is True, otherwise the taskId as a string.
|
|
296
|
+
"""
|
|
297
|
+
payload = {
|
|
298
|
+
"uploadUrl": upload_url,
|
|
299
|
+
"prompt": prompt,
|
|
300
|
+
"style": style,
|
|
301
|
+
"title": title
|
|
302
|
+
}
|
|
303
|
+
if negative_tags is not None:
|
|
304
|
+
payload["negativeTags"] = negative_tags
|
|
305
|
+
if vocal_gender is not None:
|
|
306
|
+
payload["vocalGender"] = vocal_gender
|
|
307
|
+
if style_weight is not None:
|
|
308
|
+
payload["styleWeight"] = style_weight
|
|
309
|
+
if weirdness_constraint is not None:
|
|
310
|
+
payload["weirdnessConstraint"] = weirdness_constraint
|
|
311
|
+
if audio_weight is not None:
|
|
312
|
+
payload["audioWeight"] = audio_weight
|
|
313
|
+
if model is not None:
|
|
314
|
+
payload["model"] = model
|
|
315
|
+
if callback_url:
|
|
316
|
+
payload["callBackUrl"] = callback_url
|
|
317
|
+
|
|
318
|
+
res = self.client.post("/api/v1/generate/add-vocals", payload)
|
|
319
|
+
task_id = res["data"]["taskId"]
|
|
320
|
+
|
|
321
|
+
if not wait:
|
|
322
|
+
return task_id
|
|
323
|
+
|
|
324
|
+
task_data = self.client.wait_task(task_id, endpoint="/api/v1/generate/record-info", timeout=timeout)
|
|
325
|
+
return Song.from_task_data(task_data)
|
|
326
|
+
|
|
327
|
+
def add_instrumental(
|
|
328
|
+
self,
|
|
329
|
+
upload_url: str,
|
|
330
|
+
title: str,
|
|
331
|
+
tags: str = "",
|
|
332
|
+
negative_tags: str | None = None,
|
|
333
|
+
weirdness_constraint: float | None = None,
|
|
334
|
+
audio_weight: float | None = None,
|
|
335
|
+
model: str | None = None,
|
|
336
|
+
callback_url: str | None = None,
|
|
337
|
+
wait: bool = True,
|
|
338
|
+
timeout: int = 300
|
|
339
|
+
) -> str | list[Song]:
|
|
340
|
+
"""Adds instrumental backing to an audio track (e.g. vocals).
|
|
341
|
+
|
|
342
|
+
Args:
|
|
343
|
+
upload_url (str): Public URL of the vocal/melody track.
|
|
344
|
+
title (str): Title for the track.
|
|
345
|
+
tags (str): Desired style and characteristics for the accompaniment. Defaults to "".
|
|
346
|
+
negative_tags (str, optional): Styles or instruments to exclude.
|
|
347
|
+
weirdness_constraint (float, optional): Tuning parameter.
|
|
348
|
+
audio_weight (float, optional): Tuning parameter.
|
|
349
|
+
model (str, optional): Model version.
|
|
350
|
+
callback_url (str, optional): Webhook URL.
|
|
351
|
+
wait (bool): If True, poll and wait for completion. Defaults to True.
|
|
352
|
+
timeout (int): Max wait time in seconds. Defaults to 300.
|
|
353
|
+
|
|
354
|
+
Returns:
|
|
355
|
+
str | list[Song]: A list of Song objects if wait is True, otherwise the taskId as a string.
|
|
356
|
+
"""
|
|
357
|
+
payload = {
|
|
358
|
+
"uploadUrl": upload_url,
|
|
359
|
+
"title": title,
|
|
360
|
+
"tags": tags
|
|
361
|
+
}
|
|
362
|
+
if negative_tags is not None:
|
|
363
|
+
payload["negativeTags"] = negative_tags
|
|
364
|
+
if weirdness_constraint is not None:
|
|
365
|
+
payload["weirdnessConstraint"] = weirdness_constraint
|
|
366
|
+
if audio_weight is not None:
|
|
367
|
+
payload["audioWeight"] = audio_weight
|
|
368
|
+
if model is not None:
|
|
369
|
+
payload["model"] = model
|
|
370
|
+
if callback_url:
|
|
371
|
+
payload["callBackUrl"] = callback_url
|
|
372
|
+
|
|
373
|
+
res = self.client.post("/api/v1/generate/add-instrumental", payload)
|
|
374
|
+
task_id = res["data"]["taskId"]
|
|
375
|
+
|
|
376
|
+
if not wait:
|
|
377
|
+
return task_id
|
|
378
|
+
|
|
379
|
+
task_data = self.client.wait_task(task_id, endpoint="/api/v1/generate/record-info", timeout=timeout)
|
|
380
|
+
return Song.from_task_data(task_data)
|
suno_easy/client.py
ADDED
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
import time
|
|
2
|
+
import requests
|
|
3
|
+
|
|
4
|
+
from .exceptions import TaskFailed, SunoAPIError
|
|
5
|
+
from .music import MusicResource
|
|
6
|
+
from .lyrics import LyricsResource
|
|
7
|
+
from .persona import PersonaResource
|
|
8
|
+
from .audio import AudioResource
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class SunoClient:
|
|
12
|
+
"""Main client to interact with the Suno API.
|
|
13
|
+
|
|
14
|
+
This client acts as the central orchestrator and HTTP session manager,
|
|
15
|
+
exposing sub-resources to interact with different domain endpoints.
|
|
16
|
+
|
|
17
|
+
Attributes:
|
|
18
|
+
music (MusicResource): Endpoints related to music generation and extension.
|
|
19
|
+
lyrics (LyricsResource): Endpoints related to lyrics generation.
|
|
20
|
+
persona (PersonaResource): Endpoints related to persona creation.
|
|
21
|
+
audio (AudioResource): Endpoints related to audio processing (separations, MIDI, covers).
|
|
22
|
+
"""
|
|
23
|
+
|
|
24
|
+
BASE_URL = "https://api.sunoapi.org"
|
|
25
|
+
|
|
26
|
+
def __init__(self, api_key: str):
|
|
27
|
+
"""Initializes the SunoClient with a bearer token.
|
|
28
|
+
|
|
29
|
+
Args:
|
|
30
|
+
api_key (str): The API key for authorization.
|
|
31
|
+
"""
|
|
32
|
+
self.session = requests.Session()
|
|
33
|
+
self.session.headers.update({
|
|
34
|
+
"Authorization": f"Bearer {api_key}",
|
|
35
|
+
"Content-Type": "application/json"
|
|
36
|
+
})
|
|
37
|
+
|
|
38
|
+
# Composition: Initialize sub-resources
|
|
39
|
+
self.music = MusicResource(self)
|
|
40
|
+
self.lyrics = LyricsResource(self)
|
|
41
|
+
self.persona = PersonaResource(self)
|
|
42
|
+
self.audio = AudioResource(self)
|
|
43
|
+
|
|
44
|
+
def post(self, url: str, json: dict) -> dict:
|
|
45
|
+
"""Sends a POST request to the API.
|
|
46
|
+
|
|
47
|
+
Args:
|
|
48
|
+
url (str): The endpoint path.
|
|
49
|
+
json (dict): The JSON payload.
|
|
50
|
+
|
|
51
|
+
Returns:
|
|
52
|
+
dict: The JSON response.
|
|
53
|
+
|
|
54
|
+
Raises:
|
|
55
|
+
SunoAPIError: If the API returns an HTTP error.
|
|
56
|
+
"""
|
|
57
|
+
r = self.session.post(self.BASE_URL + url, json=json)
|
|
58
|
+
if not r.ok:
|
|
59
|
+
raise SunoAPIError(r.text, status_code=r.status_code, response_text=r.text)
|
|
60
|
+
return r.json()
|
|
61
|
+
|
|
62
|
+
def get(self, url: str, params: dict | None = None) -> dict:
|
|
63
|
+
"""Sends a GET request to the API.
|
|
64
|
+
|
|
65
|
+
Args:
|
|
66
|
+
url (str): The endpoint path.
|
|
67
|
+
params (dict, optional): The query parameters.
|
|
68
|
+
|
|
69
|
+
Returns:
|
|
70
|
+
dict: The JSON response.
|
|
71
|
+
|
|
72
|
+
Raises:
|
|
73
|
+
SunoAPIError: If the API returns an HTTP error.
|
|
74
|
+
"""
|
|
75
|
+
r = self.session.get(self.BASE_URL + url, params=params)
|
|
76
|
+
if not r.ok:
|
|
77
|
+
raise SunoAPIError(r.text, status_code=r.status_code, response_text=r.text)
|
|
78
|
+
return r.json()
|
|
79
|
+
|
|
80
|
+
def get_task_info(self, task_id: str, endpoint: str = "/api/v1/generate/record-info") -> dict:
|
|
81
|
+
"""Fetches the raw response data of a task.
|
|
82
|
+
|
|
83
|
+
Args:
|
|
84
|
+
task_id (str): The ID of the task to retrieve.
|
|
85
|
+
endpoint (str): The API endpoint path. Defaults to "/api/v1/generate/record-info".
|
|
86
|
+
|
|
87
|
+
Returns:
|
|
88
|
+
dict: The raw data dictionary returned by the API.
|
|
89
|
+
"""
|
|
90
|
+
return self.get(endpoint, params={"taskId": task_id})["data"]
|
|
91
|
+
|
|
92
|
+
def wait_task(
|
|
93
|
+
self,
|
|
94
|
+
task_id: str,
|
|
95
|
+
endpoint: str = "/api/v1/generate/record-info",
|
|
96
|
+
timeout: int = 300,
|
|
97
|
+
poll_interval: int = 3
|
|
98
|
+
) -> dict:
|
|
99
|
+
"""Polls the API and waits for a task to complete.
|
|
100
|
+
|
|
101
|
+
Args:
|
|
102
|
+
task_id (str): The ID of the task to poll.
|
|
103
|
+
endpoint (str): The API endpoint path. Defaults to "/api/v1/generate/record-info".
|
|
104
|
+
timeout (int): Maximum wait time in seconds. Defaults to 300.
|
|
105
|
+
poll_interval (int): Time in seconds between polling requests. Defaults to 3.
|
|
106
|
+
|
|
107
|
+
Returns:
|
|
108
|
+
dict: The completed task raw response.
|
|
109
|
+
|
|
110
|
+
Raises:
|
|
111
|
+
TimeoutError: If the task does not finish within the timeout period.
|
|
112
|
+
TaskFailed: If the task fails or encounters an error.
|
|
113
|
+
"""
|
|
114
|
+
start = time.time()
|
|
115
|
+
|
|
116
|
+
while True:
|
|
117
|
+
if time.time() - start > timeout:
|
|
118
|
+
raise TimeoutError(f"Task {task_id} timed out after {timeout} seconds")
|
|
119
|
+
|
|
120
|
+
res = self.get_task_info(task_id, endpoint)
|
|
121
|
+
status = res.get("status")
|
|
122
|
+
success_flag = res.get("successFlag")
|
|
123
|
+
error_msg = res.get("errorMessage") or res.get("errorMsg")
|
|
124
|
+
|
|
125
|
+
if (
|
|
126
|
+
status == "FAILED"
|
|
127
|
+
or success_flag == "FAILED"
|
|
128
|
+
or (isinstance(success_flag, int) and success_flag < 0)
|
|
129
|
+
or (status is not None and "FAIL" in str(status).upper())
|
|
130
|
+
or (success_flag is not None and "FAIL" in str(success_flag).upper())
|
|
131
|
+
or error_msg
|
|
132
|
+
):
|
|
133
|
+
if error_msg or status == "FAILED" or success_flag == "FAILED" or (isinstance(success_flag, int) and success_flag < 0):
|
|
134
|
+
raise TaskFailed(res)
|
|
135
|
+
|
|
136
|
+
is_complete = False
|
|
137
|
+
if status == "SUCCESS" or success_flag == "SUCCESS":
|
|
138
|
+
is_complete = True
|
|
139
|
+
elif success_flag == 1 or success_flag == "1":
|
|
140
|
+
is_complete = True
|
|
141
|
+
elif "midiData" in res and isinstance(res["midiData"], dict) and res["midiData"].get("state") == "complete":
|
|
142
|
+
is_complete = True
|
|
143
|
+
|
|
144
|
+
if is_complete:
|
|
145
|
+
return res
|
|
146
|
+
|
|
147
|
+
time.sleep(poll_interval)
|
suno_easy/exceptions.py
ADDED
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
class SunoError(Exception):
|
|
2
|
+
"""Base exception for all Suno Easy library errors."""
|
|
3
|
+
pass
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
class TaskFailed(SunoError):
|
|
7
|
+
"""Raised when a task status is FAILED or has error messages."""
|
|
8
|
+
def __init__(self, task_info: dict):
|
|
9
|
+
self.task_info = task_info
|
|
10
|
+
super().__init__(f"Task failed: {task_info.get('errorMessage') or task_info.get('errorMsg') or task_info}")
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class SunoAPIError(SunoError):
|
|
14
|
+
"""Raised when the Suno API returns an HTTP error code."""
|
|
15
|
+
def __init__(self, message: str, status_code: int = None, response_text: str = None):
|
|
16
|
+
self.message = message
|
|
17
|
+
self.status_code = status_code
|
|
18
|
+
self.response_text = response_text
|
|
19
|
+
super().__init__(f"Suno API error ({status_code}): {message}")
|
suno_easy/lyrics.py
ADDED
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
from .models import Lyrics
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
class LyricsResource:
|
|
5
|
+
"""Resource manager for lyrics generation."""
|
|
6
|
+
|
|
7
|
+
def __init__(self, client):
|
|
8
|
+
"""Initializes the LyricsResource with a client reference.
|
|
9
|
+
|
|
10
|
+
Args:
|
|
11
|
+
client (SunoClient): The parent client instance.
|
|
12
|
+
"""
|
|
13
|
+
self.client = client
|
|
14
|
+
|
|
15
|
+
def generate(
|
|
16
|
+
self,
|
|
17
|
+
prompt: str,
|
|
18
|
+
callback_url: str | None = None,
|
|
19
|
+
wait: bool = True,
|
|
20
|
+
timeout: int = 120
|
|
21
|
+
) -> str | list[Lyrics]:
|
|
22
|
+
"""Generates lyrics based on a text prompt.
|
|
23
|
+
|
|
24
|
+
Args:
|
|
25
|
+
prompt (str): Text prompt describing the desired lyrics.
|
|
26
|
+
callback_url (str, optional): Webhook URL for callback notification.
|
|
27
|
+
wait (bool): If True, poll and wait for task completion. Defaults to True.
|
|
28
|
+
timeout (int): Max time in seconds to wait for task completion. Defaults to 120.
|
|
29
|
+
|
|
30
|
+
Returns:
|
|
31
|
+
str | list[Lyrics]: A list of generated Lyrics objects if wait is True, otherwise the taskId as a string.
|
|
32
|
+
"""
|
|
33
|
+
payload = {"prompt": prompt}
|
|
34
|
+
if callback_url:
|
|
35
|
+
payload["callBackUrl"] = callback_url
|
|
36
|
+
|
|
37
|
+
res = self.client.post("/api/v1/lyrics", payload)
|
|
38
|
+
task_id = res["data"]["taskId"]
|
|
39
|
+
|
|
40
|
+
if not wait:
|
|
41
|
+
return task_id
|
|
42
|
+
|
|
43
|
+
task_data = self.client.wait_task(task_id, endpoint="/api/v1/lyrics/record-info", timeout=timeout)
|
|
44
|
+
return Lyrics.from_task_data(task_data)
|
|
45
|
+
|
|
46
|
+
def get(self, task_id: str) -> list[Lyrics]:
|
|
47
|
+
"""Retrieves the lyrics generated by a task.
|
|
48
|
+
|
|
49
|
+
Args:
|
|
50
|
+
task_id (str): The ID of the lyrics task.
|
|
51
|
+
|
|
52
|
+
Returns:
|
|
53
|
+
list[Lyrics]: A list of generated Lyrics objects.
|
|
54
|
+
"""
|
|
55
|
+
task_data = self.client.get_task_info(task_id, endpoint="/api/v1/lyrics/record-info")
|
|
56
|
+
return Lyrics.from_task_data(task_data)
|
suno_easy/models.py
ADDED
|
@@ -0,0 +1,209 @@
|
|
|
1
|
+
from dataclasses import dataclass
|
|
2
|
+
import requests
|
|
3
|
+
|
|
4
|
+
|
|
5
|
+
@dataclass
|
|
6
|
+
class Song:
|
|
7
|
+
id: str
|
|
8
|
+
title: str
|
|
9
|
+
audio_url: str | None = None
|
|
10
|
+
stream_url: str | None = None
|
|
11
|
+
image_url: str | None = None
|
|
12
|
+
prompt: str | None = None
|
|
13
|
+
tags: str | None = None
|
|
14
|
+
duration: float | None = None
|
|
15
|
+
model_name: str | None = None
|
|
16
|
+
create_time: str | None = None
|
|
17
|
+
|
|
18
|
+
def download(self, path: str):
|
|
19
|
+
if not self.audio_url:
|
|
20
|
+
raise ValueError("No audio_url available")
|
|
21
|
+
|
|
22
|
+
r = requests.get(self.audio_url, stream=True)
|
|
23
|
+
r.raise_for_status()
|
|
24
|
+
|
|
25
|
+
with open(path, "wb") as f:
|
|
26
|
+
for chunk in r.iter_content(1024 * 1024):
|
|
27
|
+
f.write(chunk)
|
|
28
|
+
|
|
29
|
+
def download_image(self, path: str):
|
|
30
|
+
if not self.image_url:
|
|
31
|
+
raise ValueError("No image_url available")
|
|
32
|
+
|
|
33
|
+
r = requests.get(self.image_url)
|
|
34
|
+
r.raise_for_status()
|
|
35
|
+
|
|
36
|
+
with open(path, "wb") as f:
|
|
37
|
+
f.write(r.content)
|
|
38
|
+
|
|
39
|
+
@classmethod
|
|
40
|
+
def from_task_data(cls, task_data: dict) -> list["Song"]:
|
|
41
|
+
response_data = task_data.get("response", {})
|
|
42
|
+
songs_list = []
|
|
43
|
+
if isinstance(response_data, dict):
|
|
44
|
+
songs_list = response_data.get("sunoData") or response_data.get("songs") or []
|
|
45
|
+
if not songs_list:
|
|
46
|
+
songs_list = task_data.get("songs") or task_data.get("sunoData") or []
|
|
47
|
+
|
|
48
|
+
return [
|
|
49
|
+
cls(
|
|
50
|
+
id=song["id"],
|
|
51
|
+
title=song.get("title", ""),
|
|
52
|
+
audio_url=song.get("audioUrl"),
|
|
53
|
+
stream_url=song.get("streamAudioUrl"),
|
|
54
|
+
image_url=song.get("imageUrl"),
|
|
55
|
+
prompt=song.get("prompt"),
|
|
56
|
+
tags=song.get("tags"),
|
|
57
|
+
duration=song.get("duration"),
|
|
58
|
+
model_name=song.get("modelName"),
|
|
59
|
+
create_time=song.get("createTime")
|
|
60
|
+
)
|
|
61
|
+
for song in songs_list
|
|
62
|
+
]
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
@dataclass
|
|
66
|
+
class Lyrics:
|
|
67
|
+
text: str
|
|
68
|
+
title: str
|
|
69
|
+
status: str | None = None
|
|
70
|
+
error_message: str | None = None
|
|
71
|
+
|
|
72
|
+
@classmethod
|
|
73
|
+
def from_task_data(cls, task_data: dict) -> list["Lyrics"]:
|
|
74
|
+
response_data = task_data.get("response", {})
|
|
75
|
+
lyrics_list = []
|
|
76
|
+
if isinstance(response_data, dict):
|
|
77
|
+
lyrics_list = response_data.get("data") or []
|
|
78
|
+
if not lyrics_list:
|
|
79
|
+
lyrics_list = task_data.get("data") or []
|
|
80
|
+
|
|
81
|
+
return [
|
|
82
|
+
cls(
|
|
83
|
+
text=lyric.get("text", ""),
|
|
84
|
+
title=lyric.get("title", ""),
|
|
85
|
+
status=lyric.get("status"),
|
|
86
|
+
error_message=lyric.get("errorMessage")
|
|
87
|
+
)
|
|
88
|
+
for lyric in lyrics_list
|
|
89
|
+
]
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
@dataclass
|
|
93
|
+
class CoverImage:
|
|
94
|
+
images: list[str]
|
|
95
|
+
|
|
96
|
+
@classmethod
|
|
97
|
+
def from_task_data(cls, task_data: dict) -> "CoverImage":
|
|
98
|
+
response_data = task_data.get("response", {})
|
|
99
|
+
images = []
|
|
100
|
+
if isinstance(response_data, dict):
|
|
101
|
+
images = response_data.get("images") or []
|
|
102
|
+
if not images:
|
|
103
|
+
images = task_data.get("images") or []
|
|
104
|
+
return cls(images=images)
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
@dataclass
|
|
108
|
+
class SeparatedStems:
|
|
109
|
+
vocal_url: str | None = None
|
|
110
|
+
instrumental_url: str | None = None
|
|
111
|
+
backing_vocals_url: str | None = None
|
|
112
|
+
drums_url: str | None = None
|
|
113
|
+
bass_url: str | None = None
|
|
114
|
+
guitar_url: str | None = None
|
|
115
|
+
keyboard_url: str | None = None
|
|
116
|
+
percussion_url: str | None = None
|
|
117
|
+
strings_url: str | None = None
|
|
118
|
+
synth_url: str | None = None
|
|
119
|
+
fx_url: str | None = None
|
|
120
|
+
brass_url: str | None = None
|
|
121
|
+
woodwinds_url: str | None = None
|
|
122
|
+
|
|
123
|
+
def download_vocal(self, path: str):
|
|
124
|
+
if not self.vocal_url:
|
|
125
|
+
raise ValueError("No vocal_url available")
|
|
126
|
+
self._download_file(self.vocal_url, path)
|
|
127
|
+
|
|
128
|
+
def download_instrumental(self, path: str):
|
|
129
|
+
if not self.instrumental_url:
|
|
130
|
+
raise ValueError("No instrumental_url available")
|
|
131
|
+
self._download_file(self.instrumental_url, path)
|
|
132
|
+
|
|
133
|
+
def _download_file(self, url: str, path: str):
|
|
134
|
+
r = requests.get(url, stream=True)
|
|
135
|
+
r.raise_for_status()
|
|
136
|
+
with open(path, "wb") as f:
|
|
137
|
+
for chunk in r.iter_content(1024 * 1024):
|
|
138
|
+
f.write(chunk)
|
|
139
|
+
|
|
140
|
+
@classmethod
|
|
141
|
+
def from_task_data(cls, task_data: dict) -> "SeparatedStems":
|
|
142
|
+
response_data = task_data.get("response", {})
|
|
143
|
+
if not isinstance(response_data, dict):
|
|
144
|
+
response_data = {}
|
|
145
|
+
src = response_data if response_data else task_data
|
|
146
|
+
return cls(
|
|
147
|
+
vocal_url=src.get("vocalUrl"),
|
|
148
|
+
instrumental_url=src.get("instrumentalUrl"),
|
|
149
|
+
backing_vocals_url=src.get("backingVocalsUrl"),
|
|
150
|
+
drums_url=src.get("drumsUrl"),
|
|
151
|
+
bass_url=src.get("bassUrl"),
|
|
152
|
+
guitar_url=src.get("guitarUrl"),
|
|
153
|
+
keyboard_url=src.get("keyboardUrl"),
|
|
154
|
+
percussion_url=src.get("percussionUrl"),
|
|
155
|
+
strings_url=src.get("stringsUrl"),
|
|
156
|
+
synth_url=src.get("synthUrl"),
|
|
157
|
+
fx_url=src.get("fxUrl"),
|
|
158
|
+
brass_url=src.get("brassUrl"),
|
|
159
|
+
woodwinds_url=src.get("woodwindsUrl")
|
|
160
|
+
)
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
@dataclass
|
|
164
|
+
class MIDINote:
|
|
165
|
+
pitch: int
|
|
166
|
+
start: float
|
|
167
|
+
end: float
|
|
168
|
+
velocity: float
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
@dataclass
|
|
172
|
+
class MIDIInstrument:
|
|
173
|
+
name: str
|
|
174
|
+
notes: list[MIDINote]
|
|
175
|
+
|
|
176
|
+
|
|
177
|
+
@dataclass
|
|
178
|
+
class MIDIData:
|
|
179
|
+
state: str
|
|
180
|
+
instruments: list[MIDIInstrument]
|
|
181
|
+
|
|
182
|
+
@classmethod
|
|
183
|
+
def from_task_data(cls, task_data: dict) -> "MIDIData":
|
|
184
|
+
midi_data = task_data.get("midiData", {})
|
|
185
|
+
if not isinstance(midi_data, dict):
|
|
186
|
+
midi_data = {}
|
|
187
|
+
|
|
188
|
+
instruments_list = []
|
|
189
|
+
for inst in midi_data.get("instruments", []):
|
|
190
|
+
notes = [
|
|
191
|
+
MIDINote(
|
|
192
|
+
pitch=note.get("pitch", 0),
|
|
193
|
+
start=note.get("start", 0.0),
|
|
194
|
+
end=note.get("end", 0.0),
|
|
195
|
+
velocity=note.get("velocity", 0.0)
|
|
196
|
+
)
|
|
197
|
+
for note in inst.get("notes", [])
|
|
198
|
+
]
|
|
199
|
+
instruments_list.append(
|
|
200
|
+
MIDIInstrument(
|
|
201
|
+
name=inst.get("name", ""),
|
|
202
|
+
notes=notes
|
|
203
|
+
)
|
|
204
|
+
)
|
|
205
|
+
|
|
206
|
+
return cls(
|
|
207
|
+
state=midi_data.get("state", ""),
|
|
208
|
+
instruments=instruments_list
|
|
209
|
+
)
|
suno_easy/music.py
ADDED
|
@@ -0,0 +1,182 @@
|
|
|
1
|
+
from .models import Song
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
class MusicResource:
|
|
5
|
+
"""Resource manager for music generation, extension, and remastering."""
|
|
6
|
+
|
|
7
|
+
def __init__(self, client):
|
|
8
|
+
"""Initializes the MusicResource with a client reference.
|
|
9
|
+
|
|
10
|
+
Args:
|
|
11
|
+
client (SunoClient): The parent client instance.
|
|
12
|
+
"""
|
|
13
|
+
self.client = client
|
|
14
|
+
|
|
15
|
+
def generate(
|
|
16
|
+
self,
|
|
17
|
+
prompt: str,
|
|
18
|
+
style: str = "",
|
|
19
|
+
title: str = "",
|
|
20
|
+
model: str = "V4_5ALL",
|
|
21
|
+
instrumental: bool = False,
|
|
22
|
+
custom_mode: bool = True,
|
|
23
|
+
callback_url: str | None = None,
|
|
24
|
+
wait: bool = True,
|
|
25
|
+
timeout: int = 300
|
|
26
|
+
) -> str | list[Song]:
|
|
27
|
+
"""Generates music based on a prompt.
|
|
28
|
+
|
|
29
|
+
Args:
|
|
30
|
+
prompt (str): Text prompt describing the music.
|
|
31
|
+
style (str): Musical style. Required if custom_mode is True. Defaults to "".
|
|
32
|
+
title (str): Title of the generated music. Required if custom_mode is True. Defaults to "".
|
|
33
|
+
model (str): Model version to use. Defaults to "V4_5ALL".
|
|
34
|
+
instrumental (bool): Generate instrumental music without vocals. Defaults to False.
|
|
35
|
+
custom_mode (bool): Use custom mode. Defaults to True.
|
|
36
|
+
callback_url (str, optional): Webhook URL for callback notification.
|
|
37
|
+
wait (bool): If True, poll and wait for task completion. Defaults to True.
|
|
38
|
+
timeout (int): Max time in seconds to wait for task completion. Defaults to 300.
|
|
39
|
+
|
|
40
|
+
Returns:
|
|
41
|
+
str | list[Song]: A list of generated Song objects if wait is True, otherwise the taskId as a string.
|
|
42
|
+
"""
|
|
43
|
+
payload = {
|
|
44
|
+
"customMode": custom_mode,
|
|
45
|
+
"prompt": prompt,
|
|
46
|
+
"style": style,
|
|
47
|
+
"title": title,
|
|
48
|
+
"instrumental": instrumental,
|
|
49
|
+
"model": model
|
|
50
|
+
}
|
|
51
|
+
if callback_url:
|
|
52
|
+
payload["callBackUrl"] = callback_url
|
|
53
|
+
|
|
54
|
+
res = self.client.post("/api/v1/generate", payload)
|
|
55
|
+
task_id = res["data"]["taskId"]
|
|
56
|
+
|
|
57
|
+
if not wait:
|
|
58
|
+
return task_id
|
|
59
|
+
|
|
60
|
+
task_data = self.client.wait_task(task_id, endpoint="/api/v1/generate/record-info", timeout=timeout)
|
|
61
|
+
return Song.from_task_data(task_data)
|
|
62
|
+
|
|
63
|
+
def extend(
|
|
64
|
+
self,
|
|
65
|
+
audio_id: str,
|
|
66
|
+
continue_at: int,
|
|
67
|
+
prompt: str,
|
|
68
|
+
style: str = "",
|
|
69
|
+
title: str = "",
|
|
70
|
+
model: str = "V4_5ALL",
|
|
71
|
+
default_param_flag: bool = True,
|
|
72
|
+
persona_id: str | None = None,
|
|
73
|
+
persona_model: str | None = None,
|
|
74
|
+
callback_url: str | None = None,
|
|
75
|
+
wait: bool = True,
|
|
76
|
+
timeout: int = 300
|
|
77
|
+
) -> str | list[Song]:
|
|
78
|
+
"""Extends an existing audio track.
|
|
79
|
+
|
|
80
|
+
Args:
|
|
81
|
+
audio_id (str): The ID of the original music track to extend.
|
|
82
|
+
continue_at (int): Time in seconds in the original track where extension starts.
|
|
83
|
+
prompt (str): Text prompt describing the continuation of the music.
|
|
84
|
+
style (str): Musical style for the continuation. Defaults to "".
|
|
85
|
+
title (str): Title for the extended track. Defaults to "".
|
|
86
|
+
model (str): Model version (should match source track). Defaults to "V4_5ALL".
|
|
87
|
+
default_param_flag (bool): If True, uses custom parameters. Defaults to True.
|
|
88
|
+
persona_id (str, optional): Optional Persona ID.
|
|
89
|
+
persona_model (str, optional): Persona model type ("style_persona" or "voice_persona").
|
|
90
|
+
callback_url (str, optional): Webhook URL for callback notification.
|
|
91
|
+
wait (bool): If True, poll and wait for task completion. Defaults to True.
|
|
92
|
+
timeout (int): Max time in seconds to wait for task completion. Defaults to 300.
|
|
93
|
+
|
|
94
|
+
Returns:
|
|
95
|
+
str | list[Song]: A list of extended Song objects if wait is True, otherwise the taskId as a string.
|
|
96
|
+
"""
|
|
97
|
+
payload = {
|
|
98
|
+
"audioId": audio_id,
|
|
99
|
+
"continueAt": continue_at,
|
|
100
|
+
"prompt": prompt,
|
|
101
|
+
"style": style,
|
|
102
|
+
"title": title,
|
|
103
|
+
"model": model,
|
|
104
|
+
"defaultParamFlag": default_param_flag
|
|
105
|
+
}
|
|
106
|
+
if persona_id:
|
|
107
|
+
payload["personaId"] = persona_id
|
|
108
|
+
if persona_model:
|
|
109
|
+
payload["personaModel"] = persona_model
|
|
110
|
+
if callback_url:
|
|
111
|
+
payload["callBackUrl"] = callback_url
|
|
112
|
+
|
|
113
|
+
res = self.client.post("/api/v1/generate/extend", payload)
|
|
114
|
+
task_id = res["data"]["taskId"]
|
|
115
|
+
|
|
116
|
+
if not wait:
|
|
117
|
+
return task_id
|
|
118
|
+
|
|
119
|
+
task_data = self.client.wait_task(task_id, endpoint="/api/v1/generate/record-info", timeout=timeout)
|
|
120
|
+
return Song.from_task_data(task_data)
|
|
121
|
+
|
|
122
|
+
def generate_instrumental(
|
|
123
|
+
self,
|
|
124
|
+
style: str,
|
|
125
|
+
title: str,
|
|
126
|
+
model: str = "V4_5ALL",
|
|
127
|
+
callback_url: str | None = None,
|
|
128
|
+
wait: bool = True,
|
|
129
|
+
timeout: int = 300
|
|
130
|
+
) -> str | list[Song]:
|
|
131
|
+
"""Helper to generate instrumental music.
|
|
132
|
+
|
|
133
|
+
Args:
|
|
134
|
+
style (str): Musical style.
|
|
135
|
+
title (str): Title of the music.
|
|
136
|
+
model (str): Model version. Defaults to "V4_5ALL".
|
|
137
|
+
callback_url (str, optional): Webhook URL.
|
|
138
|
+
wait (bool): If True, poll and wait for completion. Defaults to True.
|
|
139
|
+
timeout (int): Max wait time in seconds. Defaults to 300.
|
|
140
|
+
|
|
141
|
+
Returns:
|
|
142
|
+
str | list[Song]: A list of generated Song objects if wait is True, otherwise the taskId as a string.
|
|
143
|
+
"""
|
|
144
|
+
return self.generate(
|
|
145
|
+
prompt="",
|
|
146
|
+
style=style,
|
|
147
|
+
title=title,
|
|
148
|
+
model=model,
|
|
149
|
+
instrumental=True,
|
|
150
|
+
callback_url=callback_url,
|
|
151
|
+
wait=wait,
|
|
152
|
+
timeout=timeout
|
|
153
|
+
)
|
|
154
|
+
|
|
155
|
+
def remaster(
|
|
156
|
+
self,
|
|
157
|
+
music_id: str,
|
|
158
|
+
wait: bool = True,
|
|
159
|
+
timeout: int = 300
|
|
160
|
+
) -> str | list[Song]:
|
|
161
|
+
"""Remasters an existing track to improve production quality/mix.
|
|
162
|
+
|
|
163
|
+
Args:
|
|
164
|
+
music_id (str): ID of the generated track to remaster.
|
|
165
|
+
wait (bool): If True, poll and wait for completion. Defaults to True.
|
|
166
|
+
timeout (int): Max wait time in seconds. Defaults to 300.
|
|
167
|
+
|
|
168
|
+
Returns:
|
|
169
|
+
str | list[Song]: A list of remastered Song objects if wait is True, otherwise the taskId as a string.
|
|
170
|
+
"""
|
|
171
|
+
payload = {
|
|
172
|
+
"musicId": music_id
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
res = self.client.post("/api/v1/generate/remaster", payload)
|
|
176
|
+
task_id = res["data"]["taskId"]
|
|
177
|
+
|
|
178
|
+
if not wait:
|
|
179
|
+
return task_id
|
|
180
|
+
|
|
181
|
+
task_data = self.client.wait_task(task_id, endpoint="/api/v1/generate/record-info", timeout=timeout)
|
|
182
|
+
return Song.from_task_data(task_data)
|
suno_easy/persona.py
ADDED
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
class PersonaResource:
|
|
2
|
+
"""Resource manager for persona creation."""
|
|
3
|
+
|
|
4
|
+
def __init__(self, client):
|
|
5
|
+
"""Initializes the PersonaResource with a client reference.
|
|
6
|
+
|
|
7
|
+
Args:
|
|
8
|
+
client (SunoClient): The parent client instance.
|
|
9
|
+
"""
|
|
10
|
+
self.client = client
|
|
11
|
+
|
|
12
|
+
def create(self, music_id: str, name: str) -> dict:
|
|
13
|
+
"""Creates a personalized music persona based on a generated music ID.
|
|
14
|
+
|
|
15
|
+
Args:
|
|
16
|
+
music_id (str): The ID of the generated music track to create the persona from.
|
|
17
|
+
name (str): The name of the persona.
|
|
18
|
+
|
|
19
|
+
Returns:
|
|
20
|
+
dict: A dictionary containing the generated persona's details (such as personaId).
|
|
21
|
+
"""
|
|
22
|
+
res = self.client.post(
|
|
23
|
+
"/api/v1/generate/persona",
|
|
24
|
+
{
|
|
25
|
+
"musicId": music_id,
|
|
26
|
+
"name": name
|
|
27
|
+
}
|
|
28
|
+
)
|
|
29
|
+
|
|
30
|
+
return res["data"]
|
suno_easy/utils.py
ADDED
|
@@ -0,0 +1,216 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: suno-easy
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: A lightweight, modern, and fully-typed Python SDK for the Suno AI API (sunoapi.org)
|
|
5
|
+
Author-email: Fred <frederic.dymko@gmail.com>
|
|
6
|
+
License: MIT
|
|
7
|
+
Project-URL: Homepage, https://github.com/Onnonoka/suno-easy
|
|
8
|
+
Project-URL: Documentation, https://github.com/Onnonoka/suno-easy#readme
|
|
9
|
+
Project-URL: Repository, https://github.com/Onnonoka/suno-easy.git
|
|
10
|
+
Project-URL: Issues, https://github.com/Onnonoka/suno-easy/issues
|
|
11
|
+
Classifier: Programming Language :: Python :: 3
|
|
12
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
13
|
+
Classifier: Operating System :: OS Independent
|
|
14
|
+
Classifier: Intended Audience :: Developers
|
|
15
|
+
Classifier: Topic :: Software Development :: Libraries :: Python Modules
|
|
16
|
+
Requires-Python: >=3.8
|
|
17
|
+
Description-Content-Type: text/markdown
|
|
18
|
+
License-File: LICENSE
|
|
19
|
+
Requires-Dist: requests>=2.25.0
|
|
20
|
+
Dynamic: license-file
|
|
21
|
+
|
|
22
|
+
# suno-easy 🎵
|
|
23
|
+
|
|
24
|
+
> [!IMPORTANT]
|
|
25
|
+
> **Disclaimer**: This is an unofficial, community-driven Python SDK wrapper for the Suno API (`sunoapi.org`). It is not affiliated with, endorsed, sponsored, or supported by Suno, Inc. or the official Suno AI platform.
|
|
26
|
+
|
|
27
|
+
`suno-easy` is a lightweight, modern, and fully-typed Python SDK for the Suno AI API ([sunoapi.org](https://docs.sunoapi.org/)).
|
|
28
|
+
|
|
29
|
+
It provides an intuitive, object-oriented interface to generate music, write lyrics, create voice personas, separate audio stems, and generate MIDI notes from audio files.
|
|
30
|
+
|
|
31
|
+
---
|
|
32
|
+
|
|
33
|
+
## Features
|
|
34
|
+
|
|
35
|
+
- **Clean Namespace Organization**: Resources are grouped logically (`client.music`, `client.lyrics`, `client.audio`, `client.persona`).
|
|
36
|
+
- **Fully Typed**: Rich python dataclasses for responses (`Song`, `Lyrics`, `SeparatedStems`, `MIDIData`, `CoverImage`).
|
|
37
|
+
- **Smart Polling**: Methods can either block and return the processed result (`wait=True`) or instantly return a `taskId` for asynchronous workflows (`wait=False`).
|
|
38
|
+
- **Built-in Downloads**: Download audio tracks, cover images, and isolated stems with built-in streaming helpers.
|
|
39
|
+
- **Robust Error Handling**: Distinct exceptions for HTTP failures (`SunoAPIError`) and generation failures (`TaskFailed`).
|
|
40
|
+
|
|
41
|
+
---
|
|
42
|
+
|
|
43
|
+
## Repository Structure
|
|
44
|
+
|
|
45
|
+
```text
|
|
46
|
+
suno-easy/
|
|
47
|
+
├── suno_easy/ # Core SDK library source code
|
|
48
|
+
│ ├── __init__.py # Exposed client, models, and exceptions
|
|
49
|
+
│ ├── client.py # Main SunoClient orchestrator
|
|
50
|
+
│ ├── models.py # Strongly typed dataclasses representing API payloads
|
|
51
|
+
│ ├── audio.py # Audio processing sub-resource (stems, MIDI, covers)
|
|
52
|
+
│ ├── music.py # Music generation and extension sub-resource
|
|
53
|
+
│ ├── lyrics.py # Lyrics generation sub-resource
|
|
54
|
+
│ ├── persona.py # Voice/style persona sub-resource
|
|
55
|
+
│ ├── exceptions.py # SDK custom exception classes
|
|
56
|
+
│ └── utils.py # Internal utility and polling helpers
|
|
57
|
+
├── tests/ # Test suite
|
|
58
|
+
│ ├── __init__.py
|
|
59
|
+
│ └── test_client.py # Mocked HTTP interface unit tests
|
|
60
|
+
├── examples/ # Basic usage examples
|
|
61
|
+
│ └── quickstart.py # Quickstart example script
|
|
62
|
+
├── pyproject.toml # PEP 621 compliant project packaging configuration
|
|
63
|
+
├── requirements.txt # Runtime dependencies
|
|
64
|
+
├── requirements-dev.txt# Development and testing requirements
|
|
65
|
+
├── LICENSE # MIT License file
|
|
66
|
+
└── README.md # Project documentation
|
|
67
|
+
```
|
|
68
|
+
|
|
69
|
+
---
|
|
70
|
+
|
|
71
|
+
## Installation
|
|
72
|
+
|
|
73
|
+
This SDK requires `requests`. You can install the package directly from source:
|
|
74
|
+
|
|
75
|
+
```bash
|
|
76
|
+
pip install .
|
|
77
|
+
```
|
|
78
|
+
|
|
79
|
+
Or for development (editable mode):
|
|
80
|
+
|
|
81
|
+
```bash
|
|
82
|
+
pip install -e .
|
|
83
|
+
```
|
|
84
|
+
|
|
85
|
+
---
|
|
86
|
+
|
|
87
|
+
## Quickstart
|
|
88
|
+
|
|
89
|
+
### 1. Initialize the Client
|
|
90
|
+
|
|
91
|
+
```python
|
|
92
|
+
from suno_easy import SunoClient
|
|
93
|
+
|
|
94
|
+
client = SunoClient(api_key="your_suno_api_key_here")
|
|
95
|
+
```
|
|
96
|
+
|
|
97
|
+
### 2. Generate Music
|
|
98
|
+
|
|
99
|
+
Generate a track in custom mode (requires prompt, style, and title). By default, this blocks until the songs are generated (usually 2-3 minutes) and returns a list containing two song variations.
|
|
100
|
+
|
|
101
|
+
```python
|
|
102
|
+
songs = client.music.generate(
|
|
103
|
+
prompt="A peaceful acoustic guitar melody with soft strings",
|
|
104
|
+
style="Folk, Acoustic",
|
|
105
|
+
title="Morning Breeze",
|
|
106
|
+
instrumental=True
|
|
107
|
+
)
|
|
108
|
+
|
|
109
|
+
for song in songs:
|
|
110
|
+
print(f"Song generated: {song.title} (ID: {song.id})")
|
|
111
|
+
print(f"Audio URL: {song.audio_url}")
|
|
112
|
+
|
|
113
|
+
# Download the track and its cover image
|
|
114
|
+
song.download(f"{song.title}.mp3")
|
|
115
|
+
song.download_image(f"{song.title}.jpg")
|
|
116
|
+
```
|
|
117
|
+
|
|
118
|
+
### 3. Generate Lyrics
|
|
119
|
+
|
|
120
|
+
Create AI-generated lyrics structure markers like `[Verse]` or `[Chorus]`.
|
|
121
|
+
|
|
122
|
+
```python
|
|
123
|
+
lyrics_list = client.lyrics.generate(prompt="a song about embarking on a journey to Mars")
|
|
124
|
+
|
|
125
|
+
for lyrics in lyrics_list:
|
|
126
|
+
print(f"Title Idea: {lyrics.title}")
|
|
127
|
+
print(lyrics.text)
|
|
128
|
+
```
|
|
129
|
+
|
|
130
|
+
### 4. Separate Vocals (Stem Separation)
|
|
131
|
+
|
|
132
|
+
Separate an existing song task into vocals and instrumental tracks. Supports 2-stem (`separate_vocal`) and up to 12-stem (`split_stem`) separation.
|
|
133
|
+
|
|
134
|
+
```python
|
|
135
|
+
stems = client.audio.separate_vocals(
|
|
136
|
+
task_id="original_music_task_id",
|
|
137
|
+
mode="separate_vocal" # or "split_stem"
|
|
138
|
+
)
|
|
139
|
+
|
|
140
|
+
print(f"Vocal URL: {stems.vocal_url}")
|
|
141
|
+
print(f"Instrumental URL: {stems.instrumental_url}")
|
|
142
|
+
|
|
143
|
+
# Download isolated files
|
|
144
|
+
stems.download_vocal("vocals.mp3")
|
|
145
|
+
stems.download_instrumental("instrumental.mp3")
|
|
146
|
+
```
|
|
147
|
+
|
|
148
|
+
### 5. Convert Audio to MIDI
|
|
149
|
+
|
|
150
|
+
Convert separated audio tracks into MIDI note structures.
|
|
151
|
+
|
|
152
|
+
```python
|
|
153
|
+
midi_data = client.audio.generate_midi(task_id="vocal_removal_task_id")
|
|
154
|
+
|
|
155
|
+
print(f"MIDI Generation State: {midi_data.state}")
|
|
156
|
+
for instrument in midi_data.instruments:
|
|
157
|
+
print(f"Instrument: {instrument.name}")
|
|
158
|
+
for note in instrument.notes[:5]: # Print first 5 notes
|
|
159
|
+
print(f" Note pitch: {note.pitch}, start: {note.start}s, end: {note.end}s")
|
|
160
|
+
```
|
|
161
|
+
|
|
162
|
+
---
|
|
163
|
+
|
|
164
|
+
## Asynchronous Workflows (Webhooks & Background Tasks)
|
|
165
|
+
|
|
166
|
+
If you don't want the methods to block your program execution, set `wait=False`. The client will instantly return the `taskId` string. You can then poll later or receive webhook callbacks on your server.
|
|
167
|
+
|
|
168
|
+
```python
|
|
169
|
+
# Starts music generation and returns instantly
|
|
170
|
+
task_id = client.music.generate(
|
|
171
|
+
prompt="Lo-fi hip hop beat for studying",
|
|
172
|
+
style="Lo-Fi",
|
|
173
|
+
title="Study Session",
|
|
174
|
+
wait=False,
|
|
175
|
+
callback_url="https://yourdomain.com/webhook"
|
|
176
|
+
)
|
|
177
|
+
|
|
178
|
+
print(f"Music generation started. Task ID: {task_id}")
|
|
179
|
+
|
|
180
|
+
# Manually retrieve info later
|
|
181
|
+
task_info = client.music.get_task_info(task_id)
|
|
182
|
+
print(f"Status: {task_info.get('status')}")
|
|
183
|
+
```
|
|
184
|
+
|
|
185
|
+
---
|
|
186
|
+
|
|
187
|
+
## API Reference
|
|
188
|
+
|
|
189
|
+
### `client.music`
|
|
190
|
+
* `generate(...) -> list[Song] | str`: Generates songs from prompts.
|
|
191
|
+
* `extend(...) -> list[Song] | str`: Extends an existing song from a timestamp.
|
|
192
|
+
* `generate_instrumental(...) -> list[Song] | str`: Generates instrumentals.
|
|
193
|
+
* `remaster(music_id, ...) -> list[Song] | str`: Improves the quality of a song.
|
|
194
|
+
|
|
195
|
+
### `client.lyrics`
|
|
196
|
+
* `generate(prompt, ...) -> list[Lyrics] | str`: Generates lyrics.
|
|
197
|
+
* `get(task_id) -> list[Lyrics]`: Retrieves lyrics from a completed task.
|
|
198
|
+
|
|
199
|
+
### `client.audio`
|
|
200
|
+
* `cover(upload_url, style, title, ...) -> list[Song] | str`: Applies a style cover to an uploaded audio.
|
|
201
|
+
* `extend(upload_url, continue_at, prompt, ...) -> list[Song] | str`: Extends an uploaded audio track.
|
|
202
|
+
* `separate_vocals(task_id, mode, ...) -> SeparatedStems | str`: Split vocals and instrumentation.
|
|
203
|
+
* `get_separated_stems(task_id) -> SeparatedStems`: Retrieves separated stems.
|
|
204
|
+
* `generate_midi(task_id, ...) -> MIDIData | str`: Converts audio stems to MIDI notes.
|
|
205
|
+
* `get_midi(task_id) -> MIDIData`: Retrieves MIDI notes.
|
|
206
|
+
* `add_vocals(upload_url, prompt, ...) -> list[Song] | str`: Adds vocals to an instrumental track.
|
|
207
|
+
* `add_instrumental(upload_url, title, tags, ...) -> list[Song] | str`: Adds backing instruments to vocals.
|
|
208
|
+
|
|
209
|
+
### `client.persona`
|
|
210
|
+
* `create(music_id, name) -> dict`: Creates a voice/style persona from a track.
|
|
211
|
+
|
|
212
|
+
---
|
|
213
|
+
|
|
214
|
+
## License
|
|
215
|
+
|
|
216
|
+
This project is licensed under the MIT License.
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
suno_easy/__init__.py,sha256=Oq20glb9dN1WmOI8gdJCOuxMCRDo1ElU9hcs4D_gjs4,428
|
|
2
|
+
suno_easy/audio.py,sha256=q5XzqdKlBAau1HiP9V1ZePWLpDAIX2PpYOWNv6vYoZQ,14600
|
|
3
|
+
suno_easy/client.py,sha256=gbGWLxmdfseEK5aAOydY1SvbdAxQtaaacuxRJ2CCZn8,5252
|
|
4
|
+
suno_easy/exceptions.py,sha256=1CJ_f7Xqu8_iFoUEKQGza6xPlauXhE7FHgKLgDg9QW4,757
|
|
5
|
+
suno_easy/lyrics.py,sha256=MxhdEIvW837YSChfnW6Xmg3i7jxdbGBjUCNPIc1ICLM,1862
|
|
6
|
+
suno_easy/models.py,sha256=heV06GYOPXbWfjE78NiPSbUigrrnPGuUQw6cHLQKAE8,6313
|
|
7
|
+
suno_easy/music.py,sha256=f1SKHMNdAKpNaJ_vLh1FGhx8IHdtIDUBJwsSdeoeYEY,6685
|
|
8
|
+
suno_easy/persona.py,sha256=_mOzAAEgfk3MbTLSnen6uyJ72PkhMWU9vHbc-M-m93g,910
|
|
9
|
+
suno_easy/utils.py,sha256=Hd_teNSLHyfDbuPXq8lMn8kV4ZcdQA9e_nNW5oVHYII,67
|
|
10
|
+
suno_easy-0.1.0.dist-info/licenses/LICENSE,sha256=NQOBbjLLnmdABdu7wu3NSyDpTBIhsPDmrf8KROa8GS0,1061
|
|
11
|
+
suno_easy-0.1.0.dist-info/METADATA,sha256=HXI18kUdZd6kH8ttLTaaRrZ0JSC9D8PE5FUYyG1g1UM,7915
|
|
12
|
+
suno_easy-0.1.0.dist-info/WHEEL,sha256=aeYiig01lYGDzBgS8HxWXOg3uV61G9ijOsup-k9o1sk,91
|
|
13
|
+
suno_easy-0.1.0.dist-info/top_level.txt,sha256=DkNsMy-TPRtD96oVhlhTqWdRGa_4-WUG_-_nKPFgFgE,10
|
|
14
|
+
suno_easy-0.1.0.dist-info/RECORD,,
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Fred
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
suno_easy
|