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 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)
@@ -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,5 @@
1
+ import time
2
+
3
+
4
+ def wait(seconds: float = 1):
5
+ time.sleep(seconds)
@@ -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,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (82.0.1)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -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