File size: 14,914 Bytes
dfe72e0 |
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 296 297 298 299 300 301 302 303 304 305 306 307 308 309 310 311 312 313 314 315 316 317 318 319 320 321 322 323 324 325 326 327 328 329 330 331 332 333 334 335 336 337 338 339 340 341 342 343 344 345 346 347 348 349 350 351 352 353 354 355 356 357 358 359 360 361 362 363 364 365 366 367 368 369 370 371 372 373 374 375 376 377 378 379 380 381 382 383 384 385 386 387 388 389 390 391 392 393 394 395 396 397 398 399 400 401 402 403 404 405 406 407 408 |
import axios, { AxiosInstance } from 'axios';
import UserAgent from 'user-agents';
import pino from 'pino';
import { wrapper } from "axios-cookiejar-support";
import { CookieJar } from "tough-cookie";
import { sleep } from "@/lib/utils";
const logger = pino();
export const DEFAULT_MODEL = "chirp-v3-5";
export interface AudioInfo {
id: string; // Unique identifier for the audio
title?: string; // Title of the audio
image_url?: string; // URL of the image associated with the audio
lyric?: string; // Lyrics of the audio
audio_url?: string; // URL of the audio file
video_url?: string; // URL of the video associated with the audio
created_at: string; // Date and time when the audio was created
model_name: string; // Name of the model used for audio generation
gpt_description_prompt?: string; // Prompt for GPT description
prompt?: string; // Prompt for audio generation
status: string; // Status
type?: string;
tags?: string; // Genre of music.
duration?: string; // Duration of the audio
error_message?: string; // Error message if any
}
class SunoApi {
private static BASE_URL: string = 'https://studio-api.suno.ai';
private static CLERK_BASE_URL: string = 'https://clerk.suno.com';
private readonly client: AxiosInstance;
private sid?: string;
private currentToken?: string;
constructor(cookie: string) {
const cookieJar = new CookieJar();
const randomUserAgent = new UserAgent(/Chrome/).random().toString();
this.client = wrapper(axios.create({
jar: cookieJar,
withCredentials: true,
headers: {
'User-Agent': randomUserAgent,
'Cookie': cookie
}
}))
this.client.interceptors.request.use((config) => {
if (this.currentToken) { // Use the current token status
config.headers['Authorization'] = `Bearer ${this.currentToken}`;
}
return config;
});
}
public async init(): Promise<SunoApi> {
await this.getAuthToken();
await this.keepAlive();
return this;
}
/**
* Get the session ID and save it for later use.
*/
private async getAuthToken() {
// URL to get session ID
const getSessionUrl = `${SunoApi.CLERK_BASE_URL}/v1/client?_clerk_js_version=4.73.4`;
// Get session ID
const sessionResponse = await this.client.get(getSessionUrl);
if (!sessionResponse?.data?.response?.['last_active_session_id']) {
throw new Error("Failed to get session id, you may need to update the SUNO_COOKIE");
}
// Save session ID for later use
this.sid = sessionResponse.data.response['last_active_session_id'];
}
/**
* Keep the session alive.
* @param isWait Indicates if the method should wait for the session to be fully renewed before returning.
*/
public async keepAlive(isWait?: boolean): Promise<void> {
if (!this.sid) {
throw new Error("Session ID is not set. Cannot renew token.");
}
// URL to renew session token
const renewUrl = `${SunoApi.CLERK_BASE_URL}/v1/client/sessions/${this.sid}/tokens?_clerk_js_version==4.73.4`;
// Renew session token
const renewResponse = await this.client.post(renewUrl);
logger.info("KeepAlive...\n");
if (isWait) {
await sleep(1, 2);
}
const newToken = renewResponse.data['jwt'];
// Update Authorization field in request header with the new JWT token
this.currentToken = newToken;
}
/**
* Generate a song based on the prompt.
* @param prompt The text prompt to generate audio from.
* @param make_instrumental Indicates if the generated audio should be instrumental.
* @param wait_audio Indicates if the method should wait for the audio file to be fully generated before returning.
* @returns
*/
public async generate(
prompt: string,
make_instrumental: boolean = false,
model?: string,
wait_audio: boolean = false,
): Promise<AudioInfo[]> {
await this.keepAlive(false);
const startTime = Date.now();
const audios = this.generateSongs(prompt, false, undefined, undefined, make_instrumental, model, wait_audio);
const costTime = Date.now() - startTime;
logger.info("Generate Response:\n" + JSON.stringify(audios, null, 2));
logger.info("Cost time: " + costTime);
return audios;
}
/**
* Calls the concatenate endpoint for a clip to generate the whole song.
* @param clip_id The ID of the audio clip to concatenate.
* @returns A promise that resolves to an AudioInfo object representing the concatenated audio.
* @throws Error if the response status is not 200.
*/
public async concatenate(clip_id: string): Promise<AudioInfo> {
await this.keepAlive(false);
const payload: any = { clip_id: clip_id };
const response = await this.client.post(
`${SunoApi.BASE_URL}/api/generate/concat/v2/`,
payload,
{
timeout: 10000, // 10 seconds timeout
},
);
if (response.status !== 200) {
throw new Error("Error response:" + response.statusText);
}
return response.data;
}
/**
* Generates custom audio based on provided parameters.
*
* @param prompt The text prompt to generate audio from.
* @param tags Tags to categorize the generated audio.
* @param title The title for the generated audio.
* @param make_instrumental Indicates if the generated audio should be instrumental.
* @param wait_audio Indicates if the method should wait for the audio file to be fully generated before returning.
* @returns A promise that resolves to an array of AudioInfo objects representing the generated audios.
*/
public async custom_generate(
prompt: string,
tags: string,
title: string,
make_instrumental: boolean = false,
model?: string,
wait_audio: boolean = false,
): Promise<AudioInfo[]> {
const startTime = Date.now();
const audios = await this.generateSongs(prompt, true, tags, title, make_instrumental, model, wait_audio);
const costTime = Date.now() - startTime;
logger.info("Custom Generate Response:\n" + JSON.stringify(audios, null, 2));
logger.info("Cost time: " + costTime);
return audios;
}
/**
* Generates songs based on the provided parameters.
*
* @param prompt The text prompt to generate songs from.
* @param isCustom Indicates if the generation should consider custom parameters like tags and title.
* @param tags Optional tags to categorize the song, used only if isCustom is true.
* @param title Optional title for the song, used only if isCustom is true.
* @param make_instrumental Indicates if the generated song should be instrumental.
* @param wait_audio Indicates if the method should wait for the audio file to be fully generated before returning.
* @returns A promise that resolves to an array of AudioInfo objects representing the generated songs.
*/
private async generateSongs(
prompt: string,
isCustom: boolean,
tags?: string,
title?: string,
make_instrumental?: boolean,
model?: string,
wait_audio: boolean = false
): Promise<AudioInfo[]> {
await this.keepAlive(false);
const payload: any = {
make_instrumental: make_instrumental == true,
mv: model || DEFAULT_MODEL,
prompt: "",
};
if (isCustom) {
payload.tags = tags;
payload.title = title;
payload.prompt = prompt;
} else {
payload.gpt_description_prompt = prompt;
}
logger.info("generateSongs payload:\n" + JSON.stringify({
prompt: prompt,
isCustom: isCustom,
tags: tags,
title: title,
make_instrumental: make_instrumental,
wait_audio: wait_audio,
payload: payload,
}, null, 2));
const response = await this.client.post(
`${SunoApi.BASE_URL}/api/generate/v2/`,
payload,
{
timeout: 10000, // 10 seconds timeout
},
);
logger.info("generateSongs Response:\n" + JSON.stringify(response.data, null, 2));
if (response.status !== 200) {
throw new Error("Error response:" + response.statusText);
}
const songIds = response.data['clips'].map((audio: any) => audio.id);
//Want to wait for music file generation
if (wait_audio) {
const startTime = Date.now();
let lastResponse: AudioInfo[] = [];
await sleep(5, 5);
while (Date.now() - startTime < 100000) {
const response = await this.get(songIds);
const allCompleted = response.every(
audio => audio.status === 'streaming' || audio.status === 'complete'
);
const allError = response.every(
audio => audio.status === 'error'
);
if (allCompleted || allError) {
return response;
}
lastResponse = response;
await sleep(3, 6);
await this.keepAlive(true);
}
return lastResponse;
} else {
await this.keepAlive(true);
return response.data['clips'].map((audio: any) => ({
id: audio.id,
title: audio.title,
image_url: audio.image_url,
lyric: audio.metadata.prompt,
audio_url: audio.audio_url,
video_url: audio.video_url,
created_at: audio.created_at,
model_name: audio.model_name,
status: audio.status,
gpt_description_prompt: audio.metadata.gpt_description_prompt,
prompt: audio.metadata.prompt,
type: audio.metadata.type,
tags: audio.metadata.tags,
duration: audio.metadata.duration,
}));
}
}
/**
* Generates lyrics based on a given prompt.
* @param prompt The prompt for generating lyrics.
* @returns The generated lyrics text.
*/
public async generateLyrics(prompt: string): Promise<string> {
await this.keepAlive(false);
// Initiate lyrics generation
const generateResponse = await this.client.post(`${SunoApi.BASE_URL}/api/generate/lyrics/`, { prompt });
const generateId = generateResponse.data.id;
// Poll for lyrics completion
let lyricsResponse = await this.client.get(`${SunoApi.BASE_URL}/api/generate/lyrics/${generateId}`);
while (lyricsResponse?.data?.status !== 'complete') {
await sleep(2); // Wait for 2 seconds before polling again
lyricsResponse = await this.client.get(`${SunoApi.BASE_URL}/api/generate/lyrics/${generateId}`);
}
// Return the generated lyrics text
return lyricsResponse.data;
}
/**
* Extends an existing audio clip by generating additional content based on the provided prompt.
*
* @param audioId The ID of the audio clip to extend.
* @param prompt The prompt for generating additional content.
* @param continueAt Extend a new clip from a song at mm:ss(e.g. 00:30). Default extends from the end of the song.
* @param tags Style of Music.
* @param title Title of the song.
* @returns A promise that resolves to an AudioInfo object representing the extended audio clip.
*/
public async extendAudio(
audioId: string,
prompt: string = "",
continueAt: string = "0",
tags: string = "",
title: string = "",
model?: string,
): Promise<AudioInfo> {
const response = await this.client.post(`${SunoApi.BASE_URL}/api/generate/v2/`, {
continue_clip_id: audioId,
continue_at: continueAt,
mv: model || DEFAULT_MODEL,
prompt: prompt,
tags: tags,
title: title
});
console.log("response:\n", response);
return response.data;
}
/**
* Processes the lyrics (prompt) from the audio metadata into a more readable format.
* @param prompt The original lyrics text.
* @returns The processed lyrics text.
*/
private parseLyrics(prompt: string): string {
// Assuming the original lyrics are separated by a specific delimiter (e.g., newline), we can convert it into a more readable format.
// The implementation here can be adjusted according to the actual lyrics format.
// For example, if the lyrics exist as continuous text, it might be necessary to split them based on specific markers (such as periods, commas, etc.).
// The following implementation assumes that the lyrics are already separated by newlines.
// Split the lyrics using newline and ensure to remove empty lines.
const lines = prompt.split('\n').filter(line => line.trim() !== '');
// Reassemble the processed lyrics lines into a single string, separated by newlines between each line.
// Additional formatting logic can be added here, such as adding specific markers or handling special lines.
return lines.join('\n');
}
/**
* Retrieves audio information for the given song IDs.
* @param songIds An optional array of song IDs to retrieve information for.
* @returns A promise that resolves to an array of AudioInfo objects.
*/
public async get(songIds?: string[]): Promise<AudioInfo[]> {
await this.keepAlive(false);
let url = `${SunoApi.BASE_URL}/api/feed/`;
if (songIds) {
url = `${url}?ids=${songIds.join(',')}`;
}
logger.info("Get audio status: " + url);
const response = await this.client.get(url, {
// 3 seconds timeout
timeout: 3000
});
const audios = response.data;
return audios.map((audio: any) => ({
id: audio.id,
title: audio.title,
image_url: audio.image_url,
lyric: audio.metadata.prompt ? this.parseLyrics(audio.metadata.prompt) : "",
audio_url: audio.audio_url,
video_url: audio.video_url,
created_at: audio.created_at,
model_name: audio.model_name,
status: audio.status,
gpt_description_prompt: audio.metadata.gpt_description_prompt,
prompt: audio.metadata.prompt,
type: audio.metadata.type,
tags: audio.metadata.tags,
duration: audio.metadata.duration,
error_message: audio.metadata.error_message,
}));
}
/**
* Retrieves information for a specific audio clip.
* @param clipId The ID of the audio clip to retrieve information for.
* @returns A promise that resolves to an object containing the audio clip information.
*/
public async getClip(clipId: string): Promise<object> {
await this.keepAlive(false);
const response = await this.client.get(`${SunoApi.BASE_URL}/api/clip/${clipId}`);
return response.data;
}
public async get_credits(): Promise<object> {
await this.keepAlive(false);
const response = await this.client.get(`${SunoApi.BASE_URL}/api/billing/info/`);
return {
credits_left: response.data.total_credits_left,
period: response.data.period,
monthly_limit: response.data.monthly_limit,
monthly_usage: response.data.monthly_usage,
};
}
}
const newSunoApi = async (cookie: string) => {
const sunoApi = new SunoApi(cookie);
return await sunoApi.init();
}
if (!process.env.SUNO_COOKIE) {
console.log("Environment does not contain SUNO_COOKIE.", process.env)
}
export const sunoApi = newSunoApi(process.env.SUNO_COOKIE || '');
|