
please note that you can't increase the timeout in any way, even by clicking,so the only option we have is to just reinstate the solving process
811 lines
29 KiB
TypeScript
811 lines
29 KiB
TypeScript
import axios, { AxiosInstance } from 'axios';
|
|
import UserAgent from 'user-agents';
|
|
import pino from 'pino';
|
|
import yn from 'yn';
|
|
import { sleep, isPage } from '@/lib/utils';
|
|
import * as cookie from 'cookie';
|
|
import { randomUUID } from 'node:crypto';
|
|
import { Solver } from '@2captcha/captcha-solver';
|
|
import { BrowserContext, Page, Locator, chromium, firefox } from 'rebrowser-playwright-core';
|
|
import { createCursor, Cursor } from 'ghost-cursor-playwright';
|
|
import { paramsCoordinates } from '@2captcha/captcha-solver/dist/structs/2captcha';
|
|
import { promises as fs } from 'fs';
|
|
import path from 'node:path';
|
|
|
|
// sunoApi instance caching
|
|
const globalForSunoApi = global as unknown as { sunoApiCache?: Map<string, SunoApi> };
|
|
const cache = globalForSunoApi.sunoApiCache || new Map<string, SunoApi>();
|
|
globalForSunoApi.sunoApiCache = cache;
|
|
|
|
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.
|
|
negative_tags?: string; // Negative tags 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.prod.suno.com';
|
|
private static CLERK_BASE_URL: string = 'https://clerk.suno.com';
|
|
private static CLERK_VERSION = '5.15.0';
|
|
|
|
private readonly client: AxiosInstance;
|
|
private sid?: string;
|
|
private currentToken?: string;
|
|
private deviceId?: string;
|
|
private userAgent?: string;
|
|
private cookies: Record<string, string | undefined>;
|
|
private solver = new Solver(process.env.TWOCAPTCHA_KEY + '');
|
|
private ghostCursorEnabled = yn(process.env.BROWSER_GHOST_CURSOR, { default: false });
|
|
private cursor?: Cursor;
|
|
|
|
constructor(cookies: string) {
|
|
this.userAgent = new UserAgent(/Macintosh/).random().toString(); // Usually Mac systems get less amount of CAPTCHAs
|
|
this.cookies = cookie.parse(cookies);
|
|
this.deviceId = this.cookies.ajs_anonymous_id || randomUUID();
|
|
this.client = axios.create({
|
|
withCredentials: true,
|
|
headers: {
|
|
'Affiliate-Id': 'undefined',
|
|
'Device-Id': `"${this.deviceId}"`,
|
|
'x-suno-client': 'Android prerelease-4nt180t 1.0.42',
|
|
'X-Requested-With': 'com.suno.android',
|
|
'sec-ch-ua': '"Chromium";v="130", "Android WebView";v="130", "Not?A_Brand";v="99"',
|
|
'sec-ch-ua-mobile': '?1',
|
|
'sec-ch-ua-platform': '"Android"',
|
|
'User-Agent': this.userAgent
|
|
}
|
|
});
|
|
this.client.interceptors.request.use(config => {
|
|
if (this.currentToken && !config.headers.Authorization)
|
|
config.headers.Authorization = `Bearer ${this.currentToken}`;
|
|
const cookiesArray = Object.entries(this.cookies).map(([key, value]) =>
|
|
cookie.serialize(key, value as string)
|
|
);
|
|
config.headers.Cookie = cookiesArray.join('; ');
|
|
return config;
|
|
});
|
|
this.client.interceptors.response.use(resp => {
|
|
const setCookieHeader = resp.headers['set-cookie'];
|
|
if (Array.isArray(setCookieHeader)) {
|
|
const newCookies = cookie.parse(setCookieHeader.join('; '));
|
|
for (const [key, value] of Object.entries(newCookies)) {
|
|
this.cookies[key] = value;
|
|
}
|
|
}
|
|
return resp;
|
|
})
|
|
}
|
|
|
|
public async init(): Promise<SunoApi> {
|
|
//await this.getClerkLatestVersion();
|
|
await this.getAuthToken();
|
|
await this.keepAlive();
|
|
return this;
|
|
}
|
|
|
|
/**
|
|
* Get the clerk package latest version id.
|
|
* This method is commented because we are now using a hard-coded Clerk version, hence this method is not needed.
|
|
|
|
private async getClerkLatestVersion() {
|
|
// URL to get clerk version ID
|
|
const getClerkVersionUrl = `${SunoApi.JSDELIVR_BASE_URL}/v1/package/npm/@clerk/clerk-js`;
|
|
// Get clerk version ID
|
|
const versionListResponse = await this.client.get(getClerkVersionUrl);
|
|
if (!versionListResponse?.data?.['tags']['latest']) {
|
|
throw new Error(
|
|
'Failed to get clerk version info, Please try again later'
|
|
);
|
|
}
|
|
// Save clerk version ID for auth
|
|
SunoApi.clerkVersion = versionListResponse?.data?.['tags']['latest'];
|
|
}
|
|
*/
|
|
|
|
/**
|
|
* Get the session ID and save it for later use.
|
|
*/
|
|
private async getAuthToken() {
|
|
logger.info('Getting the session ID');
|
|
// URL to get session ID
|
|
const getSessionUrl = `${SunoApi.CLERK_BASE_URL}/v1/client?_is_native=true&_clerk_js_version=${SunoApi.CLERK_VERSION}`;
|
|
// Get session ID
|
|
const sessionResponse = await this.client.get(getSessionUrl, {
|
|
headers: { Authorization: this.cookies.__client }
|
|
});
|
|
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?_is_native=true&_clerk_js_version=${SunoApi.CLERK_VERSION}`;
|
|
// Renew session token
|
|
logger.info('KeepAlive...\n');
|
|
const renewResponse = await this.client.post(renewUrl, {}, {
|
|
headers: { Authorization: this.cookies.__client }
|
|
});
|
|
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;
|
|
}
|
|
|
|
/**
|
|
* Get the session token (not to be confused with session ID) and save it for later use.
|
|
*/
|
|
private async getSessionToken() {
|
|
const tokenResponse = await this.client.post(
|
|
`${SunoApi.BASE_URL}/api/user/create_session_id/`,
|
|
{
|
|
session_properties: JSON.stringify({ deviceId: this.deviceId }),
|
|
session_type: 1
|
|
}
|
|
);
|
|
return tokenResponse.data.session_id;
|
|
}
|
|
|
|
private async captchaRequired(): Promise<boolean> {
|
|
const resp = await this.client.post(`${SunoApi.BASE_URL}/api/c/check`, {
|
|
ctype: 'generation'
|
|
});
|
|
logger.info(resp.data);
|
|
// await sleep(10);
|
|
return resp.data.required;
|
|
}
|
|
|
|
/**
|
|
* Clicks on a locator or XY vector. This method is made because of the difference between ghost-cursor-playwright and Playwright methods
|
|
*/
|
|
private async click(target: Locator|Page, position?: { x: number, y: number }): Promise<void> {
|
|
if (this.ghostCursorEnabled) {
|
|
let pos: any = isPage(target) ? { x: 0, y: 0 } : await target.boundingBox();
|
|
if (position)
|
|
pos = {
|
|
...pos,
|
|
x: pos.x + position.x,
|
|
y: pos.y + position.y,
|
|
width: null,
|
|
height: null,
|
|
};
|
|
return this.cursor?.actions.click({
|
|
target: pos
|
|
});
|
|
} else {
|
|
if (isPage(target))
|
|
return target.mouse.click(position?.x ?? 0, position?.y ?? 0);
|
|
else
|
|
return target.click({ force: true, position });
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Get the BrowserType from the `BROWSER` environment variable.
|
|
* @returns {BrowserType} chromium, firefox or webkit. Default is chromium
|
|
*/
|
|
private getBrowserType() {
|
|
const browser = process.env.BROWSER?.toLowerCase();
|
|
switch (browser) {
|
|
case 'firefox':
|
|
return firefox;
|
|
/*case 'webkit': ** doesn't work with rebrowser-patches
|
|
case 'safari':
|
|
return webkit;*/
|
|
default:
|
|
return chromium;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Launches a browser with the necessary cookies
|
|
* @returns {BrowserContext}
|
|
*/
|
|
private async launchBrowser(): Promise<BrowserContext> {
|
|
const browser = await this.getBrowserType().launch({
|
|
args: [
|
|
'--disable-blink-features=AutomationControlled',
|
|
'--disable-web-security',
|
|
'--no-sandbox',
|
|
'--disable-dev-shm-usage',
|
|
'--disable-features=site-per-process',
|
|
'--disable-features=IsolateOrigins',
|
|
'--disable-extensions',
|
|
'--disable-infobars'
|
|
],
|
|
headless: yn(process.env.BROWSER_HEADLESS, { default: true })
|
|
});
|
|
const context = await browser.newContext({ userAgent: this.userAgent, locale: process.env.BROWSER_LOCALE, viewport: null });
|
|
const cookies = [];
|
|
const lax: 'Lax' | 'Strict' | 'None' = 'Lax';
|
|
cookies.push({
|
|
name: '__session',
|
|
value: this.currentToken+'',
|
|
domain: '.suno.com',
|
|
path: '/',
|
|
sameSite: lax
|
|
});
|
|
for (const key in this.cookies) {
|
|
cookies.push({
|
|
name: key,
|
|
value: this.cookies[key]+'',
|
|
domain: '.suno.com',
|
|
path: '/',
|
|
sameSite: lax
|
|
})
|
|
}
|
|
await context.addCookies(cookies);
|
|
return context;
|
|
}
|
|
|
|
/**
|
|
* Checks for CAPTCHA verification and solves the CAPTCHA if needed
|
|
* @returns {string|null} hCaptcha token. If no verification is required, returns null
|
|
*/
|
|
public async getCaptcha(): Promise<string|null> {
|
|
if (!await this.captchaRequired())
|
|
return null;
|
|
|
|
logger.info('CAPTCHA required. Launching browser...')
|
|
const browser = await this.launchBrowser();
|
|
const page = await browser.newPage();
|
|
await page.goto('https://suno.com/create', { referer: 'https://www.google.com/', waitUntil: 'domcontentloaded', timeout: 0 });
|
|
|
|
logger.info('Waiting for Suno interface to load');
|
|
//await page.locator('.react-aria-GridList').waitFor({ timeout: 60000 });
|
|
await page.waitForResponse('**/api/feed/v2**', { timeout: 60000 }); // wait for song list API call
|
|
|
|
if (this.ghostCursorEnabled)
|
|
this.cursor = await createCursor(page);
|
|
|
|
logger.info('Triggering the CAPTCHA');
|
|
await this.click(page, { x: 318, y: 13 }); // close all popups
|
|
|
|
const textarea = page.locator('.custom-textarea');
|
|
await this.click(textarea);
|
|
await textarea.pressSequentially('Lorem ipsum', { delay: 80 });
|
|
|
|
const button = page.locator('button[aria-label="Create"]').locator('div.flex');
|
|
await this.click(button);
|
|
|
|
new Promise<void>(async (resolve, reject) => {
|
|
const frame = page.frameLocator('iframe[title*="hCaptcha"]');
|
|
const challenge = frame.locator('.challenge-container');
|
|
try {
|
|
while (true) {
|
|
await page.waitForResponse('https://img**.hcaptcha.com/**', { timeout: 60000 }); // wait for hCaptcha image to load
|
|
while (true) { // wait for all requests to finish
|
|
try {
|
|
await page.waitForResponse('https://img**.hcaptcha.com/**', { timeout: 1000 });
|
|
} catch(e) {
|
|
break
|
|
}
|
|
}
|
|
const drag = (await challenge.locator('.prompt-text').first().innerText()).toLowerCase().includes('drag');
|
|
let captcha: any;
|
|
for (let j = 0; j < 3; j++) { // try several times because sometimes 2Captcha could send an error
|
|
try {
|
|
logger.info('Sending the CAPTCHA to 2Captcha');
|
|
const payload: paramsCoordinates = {
|
|
body: (await challenge.screenshot()).toString('base64'),
|
|
lang: process.env.BROWSER_LOCALE
|
|
};
|
|
if (drag) {
|
|
// Say to the worker that he needs to click
|
|
payload.textinstructions = '! Instead of dragging, CLICK on the shapes as shown in the image above !';
|
|
payload.imginstructions = (await fs.readFile(path.join(process.cwd(), 'public', 'drag-instructions.jpg'))).toString('base64');
|
|
}
|
|
captcha = await this.solver.coordinates(payload);
|
|
break;
|
|
} catch(err: any) {
|
|
logger.info(err.message);
|
|
if (j != 2)
|
|
logger.info('Retrying...');
|
|
else
|
|
throw err;
|
|
}
|
|
}
|
|
if (drag) {
|
|
const challengeBox = await challenge.boundingBox();
|
|
if (challengeBox == null)
|
|
throw new Error('.challenge-container boundingBox is null!');
|
|
for (let i = 0; i < captcha.data.length; i += 2) {
|
|
const data1 = captcha.data[i];
|
|
const data2 = captcha.data[i+1];
|
|
logger.info(JSON.stringify(data1) + JSON.stringify(data2));
|
|
await page.mouse.move(challengeBox.x + +data1.x, challengeBox.y + +data1.y);
|
|
await page.mouse.down();
|
|
await sleep(1.1); // wait for the piece to be 'unlocked'
|
|
await page.mouse.move(challengeBox.x + +data2.x, challengeBox.y + +data2.y, { steps: 30 });
|
|
await page.mouse.up();
|
|
}
|
|
} else {
|
|
for (const data of captcha.data) {
|
|
logger.info(data);
|
|
await this.click(challenge, { x: +data.x, y: +data.y });
|
|
};
|
|
}
|
|
this.click(frame.locator('.button-submit')).catch(e => {
|
|
if (e.message.includes('viewport')) // when hCaptcha window has been closed due to inactivity,
|
|
this.click(button); // click the Create button again to trigger the CAPTCHA
|
|
else
|
|
throw e;
|
|
});
|
|
}
|
|
} catch(e: any) {
|
|
if (e.message.includes('been closed')) // catch error when closing the browser
|
|
resolve();
|
|
else
|
|
reject(e);
|
|
}
|
|
}).catch(e => {
|
|
browser.browser()?.close();
|
|
throw e;
|
|
});
|
|
return (new Promise((resolve, reject) => {
|
|
page.route('**/api/generate/v2/**', async (route: any) => {
|
|
try {
|
|
logger.info('hCaptcha token received. Closing browser');
|
|
route.abort();
|
|
browser.browser()?.close();
|
|
const request = route.request();
|
|
this.currentToken = request.headers().authorization.split('Bearer ').pop();
|
|
resolve(request.postDataJSON().token);
|
|
} catch(err) {
|
|
reject(err);
|
|
}
|
|
});
|
|
}));
|
|
}
|
|
|
|
/**
|
|
* Imitates Cloudflare Turnstile loading error. Unused right now, left for future
|
|
*/
|
|
private async getTurnstile() {
|
|
return this.client.post(
|
|
`https://clerk.suno.com/v1/client?__clerk_api_version=2021-02-05&_clerk_js_version=${SunoApi.CLERK_VERSION}&_method=PATCH`,
|
|
{ captcha_error: '300030,300030,300030' },
|
|
{ headers: { 'content-type': 'application/x-www-form-urlencoded' } });
|
|
}
|
|
|
|
/**
|
|
* 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 = await 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.
|
|
* @param negative_tags Negative tags that should not be included in the generated audio.
|
|
* @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,
|
|
negative_tags?: string
|
|
): Promise<AudioInfo[]> {
|
|
const startTime = Date.now();
|
|
const audios = await this.generateSongs(
|
|
prompt,
|
|
true,
|
|
tags,
|
|
title,
|
|
make_instrumental,
|
|
model,
|
|
wait_audio,
|
|
negative_tags
|
|
);
|
|
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.
|
|
* @param negative_tags Negative tags that should not be included in the generated audio.
|
|
* @param task Optional indication of what to do. Enter 'extend' if extending an audio, otherwise specify null.
|
|
* @param continue_clip_id
|
|
* @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,
|
|
negative_tags?: string,
|
|
task?: string,
|
|
continue_clip_id?: string,
|
|
continue_at?: number
|
|
): Promise<AudioInfo[]> {
|
|
await this.keepAlive();
|
|
const payload: any = {
|
|
make_instrumental: make_instrumental,
|
|
mv: model || DEFAULT_MODEL,
|
|
prompt: '',
|
|
generation_type: 'TEXT',
|
|
continue_at: continue_at,
|
|
continue_clip_id: continue_clip_id,
|
|
task: task,
|
|
token: await this.getCaptcha()
|
|
};
|
|
if (isCustom) {
|
|
payload.tags = tags;
|
|
payload.title = title;
|
|
payload.negative_tags = negative_tags;
|
|
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,
|
|
negative_tags: negative_tags,
|
|
payload: payload
|
|
},
|
|
null,
|
|
2
|
|
)
|
|
);
|
|
const response = await this.client.post(
|
|
`${SunoApi.BASE_URL}/api/generate/v2/`,
|
|
payload,
|
|
{
|
|
timeout: 10000 // 10 seconds timeout
|
|
}
|
|
);
|
|
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 {
|
|
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,
|
|
negative_tags: audio.metadata.negative_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: number,
|
|
tags: string = '',
|
|
negative_tags: string = '',
|
|
title: string = '',
|
|
model?: string,
|
|
wait_audio?: boolean
|
|
): Promise<AudioInfo[]> {
|
|
return this.generateSongs(prompt, true, tags, title, false, model, wait_audio, negative_tags, 'extend', audioId, continueAt);
|
|
}
|
|
|
|
/**
|
|
* Generate stems for a song.
|
|
* @param song_id The ID of the song to generate stems for.
|
|
* @returns A promise that resolves to an AudioInfo object representing the generated stems.
|
|
*/
|
|
public async generateStems(song_id: string): Promise<AudioInfo[]> {
|
|
await this.keepAlive(false);
|
|
const response = await this.client.post(
|
|
`${SunoApi.BASE_URL}/api/edit/stems/${song_id}`, {}
|
|
);
|
|
|
|
console.log('generateStems response:\n', response?.data);
|
|
return response.data.clips.map((clip: any) => ({
|
|
id: clip.id,
|
|
status: clip.status,
|
|
created_at: clip.created_at,
|
|
title: clip.title,
|
|
stem_from_id: clip.metadata.stem_from_id,
|
|
duration: clip.metadata.duration
|
|
}));
|
|
}
|
|
|
|
|
|
/**
|
|
* Get the lyric alignment for a song.
|
|
* @param song_id The ID of the song to get the lyric alignment for.
|
|
* @returns A promise that resolves to an object containing the lyric alignment.
|
|
*/
|
|
public async getLyricAlignment(song_id: string): Promise<object> {
|
|
await this.keepAlive(false);
|
|
const response = await this.client.get(`${SunoApi.BASE_URL}/api/gen/${song_id}/aligned_lyrics/v2/`);
|
|
|
|
console.log(`getLyricAlignment ~ response:`, response.data);
|
|
return response.data?.aligned_words.map((transcribedWord: any) => ({
|
|
word: transcribedWord.word,
|
|
start_s: transcribedWord.start_s,
|
|
end_s: transcribedWord.end_s,
|
|
success: transcribedWord.success,
|
|
p_align: transcribedWord.p_align
|
|
}));
|
|
}
|
|
|
|
/**
|
|
* 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.
|
|
* @param page An optional page number to retrieve audio information from.
|
|
* @returns A promise that resolves to an array of AudioInfo objects.
|
|
*/
|
|
public async get(
|
|
songIds?: string[],
|
|
page?: string | null
|
|
): Promise<AudioInfo[]> {
|
|
await this.keepAlive(false);
|
|
let url = new URL(`${SunoApi.BASE_URL}/api/feed/v2`);
|
|
if (songIds) {
|
|
url.searchParams.append('ids', songIds.join(','));
|
|
}
|
|
if (page) {
|
|
url.searchParams.append('page', page);
|
|
}
|
|
logger.info('Get audio status: ' + url.href);
|
|
const response = await this.client.get(url.href, {
|
|
// 10 seconds timeout
|
|
timeout: 10000
|
|
});
|
|
|
|
const audios = response.data.clips;
|
|
|
|
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
|
|
};
|
|
}
|
|
}
|
|
|
|
export const sunoApi = async (cookie?: string) => {
|
|
const resolvedCookie = cookie || process.env.SUNO_COOKIE;
|
|
if (!resolvedCookie) {
|
|
logger.info('No cookie provided! Aborting...\nPlease provide a cookie either in the .env file or in the Cookie header of your request.')
|
|
throw new Error('Please provide a cookie either in the .env file or in the Cookie header of your request.');
|
|
}
|
|
|
|
// Check if the instance for this cookie already exists in the cache
|
|
const cachedInstance = cache.get(resolvedCookie);
|
|
if (cachedInstance)
|
|
return cachedInstance;
|
|
|
|
// If not, create a new instance and initialize it
|
|
const instance = await new SunoApi(resolvedCookie).init();
|
|
// Cache the initialized instance
|
|
cache.set(resolvedCookie, instance);
|
|
|
|
return instance;
|
|
}; |