From 4569eff9c00a12d8fb276bf549af6560661e6b3d Mon Sep 17 00:00:00 2001 From: francialisomlimoeiro <315097200+francialisomlimoeiro@users.noreply.github.com> Date: Wed, 12 Aug 2026 01:10:02 +0300 Subject: [PATCH] =?UTF-8?q?feat:=20advanced=20split=20=E2=80=94=20per-inst?= =?UTF-8?q?rument=20stems=20+=20MIDI=20via=20/api/advanced=5Fsplit?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README.md | 60 ++++++ src/app/api/advanced_split/route.ts | 86 ++++++++ src/app/docs/swagger-suno-api.json | 56 ++++++ src/lib/SunoApi.ts | 295 ++++++++++++++++++++++++++++ 4 files changed, 497 insertions(+) create mode 100644 src/app/api/advanced_split/route.ts diff --git a/README.md b/README.md index 6e11dab..6aa427a 100644 --- a/README.md +++ b/README.md @@ -4,6 +4,8 @@ Enhanced fork of [gcui-art/suno-api](https://github.com/gcui-art/suno-api). Adds an **admin dashboard**, **multi-account pool**, **global generation concurrency control**, **OpenAI-compatible endpoints with API key auth**, **credit and multiplier billing**, **YesCaptcha / 2Captcha**, and **cookie extraction tools**. +This fork adds a **local audio API** (upload your own audio and use it as a cover source), **Advanced Split** (per-instrument stems + MIDI export), and **download / export routes**. + [English](./README.md) · [简体中文](./README_CN.md) --- @@ -22,6 +24,11 @@ Adds an **admin dashboard**, **multi-account pool**, **global generation concurr - Credit and multiplier billing based on package cost, upstream credits, request usage, and group multiplier - API key auth: `Authorization: Bearer ` (manage in admin UI) - Captcha providers: YesCaptcha and 2Captcha (token-first) +- Local audio API: + - `POST /api/uploads/audio` — upload an audio file, get a `clip_id` + - Cover generation: pass `task: 'cover'` + `cover_clip_id` to `custom_generate` + - `GET /api/download` — quota-aware download/export (normal, Studio, sample-pack stems) +- Advanced Split: `POST /api/advanced_split` — per-instrument stems and MIDI for any song - Cookie tools: - Playwright extractor: `npm run get-cookie` - Browser extension: `suno-cookie-extension` @@ -371,6 +378,9 @@ Swagger UI: `/docs` - `POST /api/generate` - `POST /api/custom_generate` +- `POST /api/uploads/audio` (multipart: `file`, optional `mime_type`) +- `POST /api/advanced_split` +- `GET /api/download?id=&path=auto|normal|studio|sample-pack&format=mp3|wav&job=` - `POST /api/extend_audio` - `POST /api/generate_lyrics` - `GET /api/get?ids=...` @@ -381,6 +391,56 @@ Swagger UI: `/docs` --- +## Fork Additions + +### Local audio upload + cover generation + +Upload a local audio file through the native Suno upload chain and get a `clip_id`: + +```bash +curl -X POST http://localhost:3000/api/uploads/audio \ + -F "file=@voice.mp3" +# -> { "clip_id": "...", ... } +``` + +Then generate a cover of it via `custom_generate`: + +```json +{ + "task": "cover", + "cover_clip_id": "", + "cover_start_s": 0, + "cover_end_s": 30, + "prompt": "..." +} +``` + +### Advanced Split (stems + MIDI) + +Split any song into per-instrument stems, optionally exporting MIDI: + +```json +POST /api/advanced_split +{ + "song_id": "", + "instruments": ["vocals", "drums", "bass"], + "include_midi": true +} +``` + +The route proxies Suno's `v2-web` generation contract and polls the feed +until the split job finishes (requires a plan that has Advanced Split). + +### Download / export routes + +`GET /api/download?id=` picks the best quota path automatically: +Studio per-clip download and sample-pack (stems) exports do not count +against the monthly download quota on Premier plans, while the normal path +does. Use `path=` to force a specific path and `job=` to poll a running +sample-pack export. + +--- + ## Environment Variables | Variable | Required | Description | diff --git a/src/app/api/advanced_split/route.ts b/src/app/api/advanced_split/route.ts new file mode 100644 index 0000000..1541b53 --- /dev/null +++ b/src/app/api/advanced_split/route.ts @@ -0,0 +1,86 @@ +import { NextResponse, NextRequest } from "next/server"; +import { cookies } from 'next/headers'; +import { runSunoRequest } from "@/lib/SunoApi"; +import { accountTier } from '@/lib/account-pool'; +import { withGenerationConcurrency } from '@/lib/concurrency-settings'; +import { concurrencyLimitResponse } from '@/lib/concurrency-response'; +import { corsHeaders } from "@/lib/utils"; + +export const maxDuration = 60; // same as custom_generate; client method has its own deadlines +export const dynamic = "force-dynamic"; + +export async function POST(req: NextRequest) { + if (req.method === 'POST') { + try { + const body = await req.json().catch(() => ({})); + const { song_id, songId, audio_id, instruments, include_midi, mode } = body; + const sourceId = song_id || songId || audio_id; + + if (!sourceId || typeof sourceId !== 'string') { + return new NextResponse(JSON.stringify({ error: 'song_id is required' }), { + status: 400, + headers: { + 'Content-Type': 'application/json', + ...corsHeaders + } + }); + } + if ( + !Array.isArray(instruments) + || instruments.length === 0 + || !instruments.every((i: unknown) => typeof i === 'string' && i.trim().length > 0) + ) { + return new NextResponse(JSON.stringify({ error: 'instruments must be a non-empty array of strings' }), { + status: 400, + headers: { + 'Content-Type': 'application/json', + ...corsHeaders + } + }); + } + + const result = await withGenerationConcurrency(async () => runSunoRequest( + (await cookies()).toString(), + accountTier(body.pool || req.headers.get('x-suno-pool')), + (api) => api.advancedSplit(sourceId, { + instruments, + include_midi: Boolean(include_midi), + mode + }), + )); + return new NextResponse(JSON.stringify(result), { + status: 200, + headers: { + 'Content-Type': 'application/json', + ...corsHeaders + } + }); + } catch (error: any) { + console.error('Error running advanced split:', error); + const limited = concurrencyLimitResponse(error, corsHeaders); + if (limited) return limited; + return new NextResponse(JSON.stringify({ error: error.response?.data?.detail || error.toString() }), { + status: error.response?.status || 500, + headers: { + 'Content-Type': 'application/json', + ...corsHeaders + } + }); + } + } else { + return new NextResponse('Method Not Allowed', { + headers: { + Allow: 'POST', + ...corsHeaders + }, + status: 405 + }); + } +} + +export async function OPTIONS(request: Request) { + return new Response(null, { + status: 200, + headers: corsHeaders + }); +} diff --git a/src/app/docs/swagger-suno-api.json b/src/app/docs/swagger-suno-api.json index d4c28a2..328930c 100644 --- a/src/app/docs/swagger-suno-api.json +++ b/src/app/docs/swagger-suno-api.json @@ -582,6 +582,62 @@ "429": { "$ref": "#/components/responses/ConcurrencyLimitExceeded" } } } + }, + "/api/advanced_split": { + "post": { + "summary": "Advanced Split: extract stems for chosen instruments (+ optional MIDI note-events).", + "description": "Wraps the Suno Studio \"Extract Stems and MIDI\" → Advanced split flow. One generation per instrument; each returns 4 clips (audio extract, audio remove, MIDI extract, MIDI remove). Audio stems are returned as cdn1 mp3 links once complete; MIDI note-events JSON is attached when include_midi is true. Files download via /api/download?path=studio.", + "tags": ["default"], + "requestBody": { + "content": { + "application/json": { + "schema": { + "type": "object", + "required": ["song_id", "instruments"], + "properties": { + "song_id": { "type": "string", "description": "The clip to split.", "example": "7871d753-06ba-4110-b028-ccc1bb1e1ef9" }, + "instruments": { "type": "array", "items": { "type": "string" }, "description": "Instrument names from the Suno dropdown (Lead Vocal, Drums, Bass, ...).", "example": ["Lead Vocal", "Drums"] }, + "include_midi": { "type": "boolean", "description": "Also poll and attach MIDI note-events per instrument." }, + "mode": { "type": "string", "description": "Echoed back in the response (defaults to advanced)." } + } + } + } + } + }, + "responses": { + "200": { + "description": "Per-instrument split result", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "song_id": { "type": "string" }, + "mode": { "type": "string" }, + "instruments": { + "type": "array", + "items": { + "type": "object", + "properties": { + "instrument": { "type": "string" }, + "extract": { "type": "object", "properties": { "clip_id": { "type": "string" }, "title": { "type": "string" }, "status": { "type": "string" }, "audio_url": { "type": "string" } } }, + "remove": { "type": "object", "properties": { "clip_id": { "type": "string" }, "title": { "type": "string" }, "status": { "type": "string" }, "audio_url": { "type": "string" } } }, + "midi_extract": { "type": "object", "properties": { "clip_id": { "type": "string" }, "midi": { "type": "object" } } }, + "midi_remove": { "type": "object", "properties": { "clip_id": { "type": "string" }, "midi": { "type": "object" } } } + } + } + } + } + } + } + } + }, + "400": { "description": "Validation error (missing song_id / empty instruments)" }, + "429": { + "$ref": "#/components/responses/ConcurrencyLimitExceeded" + } + } + } }, "/api/generate_lyrics": { "post": { diff --git a/src/lib/SunoApi.ts b/src/lib/SunoApi.ts index 00f8ab2..8a9b4c0 100644 --- a/src/lib/SunoApi.ts +++ b/src/lib/SunoApi.ts @@ -76,8 +76,58 @@ interface PersonaResponse { is_following: boolean; } +/** One stem produced by Advanced Split (audio extract / audio remove). */ +export interface AdvancedSplitStem { + clip_id?: string; + title?: string; + status?: string; + audio_url?: string; +} + +/** MIDI half of an Advanced Split batch (note-events JSON, no .mid on CDN). */ +export interface AdvancedSplitMidi { + clip_id?: string; + /** { state, instruments: [{ name, notes: [{pitch,start,end,velocity}] }] } once complete, else null. */ + midi?: unknown; +} + +export interface AdvancedSplitInstrument { + instrument: string; + extract: AdvancedSplitStem; + remove: AdvancedSplitStem; + midi_extract: AdvancedSplitMidi; + midi_remove: AdvancedSplitMidi; +} + +export interface AdvancedSplitResult { + song_id: string; + mode: string; + instruments: AdvancedSplitInstrument[]; +} + export class SunoApi { private static BASE_URL: string = 'https://studio-api.prod.suno.com'; + // Advanced Split traffic (captured 2026-08-11) went exclusively to the + // web-client host for generate/v2-web, feed polling and MIDI — the tier + // gate on gen_stem rejects the generic BASE_URL host (permission_denied). + private static WEB_API_URL: string = 'https://studio-api-prod.suno.com'; + // Client fingerprint of the successful captured v2-web/MIDI requests + // (desktop Chrome 146 / Linux). Overrides the class's Android-WebView + // axios defaults per-request — a self-contradictory fingerprint + // (Macintosh UA + Android sec-ch-ua) is suspected of tripping the + // client-profiling tier gate. Do NOT touch the global defaults. + private static WEB_CLIENT_HEADERS = { + 'User-Agent': 'Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/146.0.0.0 Safari/537.36', + 'sec-ch-ua': '"Chromium";v="146", "Not-A.Brand";v="24", "Google Chrome";v="146"', + 'sec-ch-ua-mobile': '?0', + 'sec-ch-ua-platform': '"Linux"', + // Strip the Android-client instance defaults from v2-web calls: the server + // classifies the request as an Android client and 403s web-only features + // (gen_stem). axios drops undefined-valued headers on the wire (verified). + 'x-suno-client': undefined, + 'X-Requested-With': undefined, + 'Affiliate-Id': undefined + }; private static CLERK_BASE_URL: string = 'https://auth.suno.com'; private static CLERK_VERSION = '5.117.0'; @@ -86,6 +136,9 @@ export class SunoApi { private currentToken?: string; private deviceId?: string; private userAgent?: string; + // Subscription tier uuid (metadata.user_tier for advanced split); lazy-loaded + // from GET /api/billing/info/ -> plan.id (captured traffic source). + private userTier?: string; private cookies: Record; private solver = new Solver(getTwoCaptchaKey() || 'unused'); private captchaProvider = resolveCaptchaProvider(); @@ -1086,6 +1139,248 @@ export class SunoApi { })); } + /** + * Advanced Split (Suno Studio "Extract Stems and MIDI" → Advanced split tab). + * Contract captured from live suno.com traffic: ONE POST /api/generate/v2-web/ per instrument with + * task="gen_stem", stem_type_id=91 (constant for advanced mode, instrument is + * carried by stem_type_group_name/stem_name), stem_task="extract" and the + * source clip in continue_clip_id. Each call returns a batch of 4 clips: + * batch_index 0=audio extract, 1=audio remove, 2=MIDI extract, 3=MIDI remove. + * Audio stems are polled with GET /api/feed/?ids= until complete; MIDI + * note-events come from GET /api/gen/{midi_clip_id}/midi (state running→complete, + * the .mid file itself is assembled client-side by Suno — there is none on CDN). + * + * @param song_id The clip to split. + * @param options.instruments Instrument names as shown in the Suno dropdown + * ("Lead Vocal", "Drums", "Bass", ...). One generate call each. + * @param options.include_midi Also poll and attach MIDI note-events. + * @param options.mode Echoed back in the result (defaults to "advanced"). + */ + public async advancedSplit( + song_id: string, + options: { instruments: string[]; include_midi?: boolean; mode?: string } + ): Promise { + await this.keepAlive(); + const includeMidi = Boolean(options.include_midi); + // The captured web payload carries the source clip title and the + // subscription tier uuid; without user_tier Suno tier-gates the call + // (permission_denied "Upgrade your plan"). + const sourceClip: any = await this.getClip(song_id); + const title = sourceClip?.title || ''; + const sessionToken = await this.getSessionToken(); + const userTier = await this.getUserTier(); + const results: AdvancedSplitInstrument[] = []; + for (const instrument of options.instruments) { + const payload = { + token: await this.getCaptcha(), + task: 'gen_stem', + generation_type: 'TEXT', + title: title, + tags: '', + negative_tags: '', + mv: 'chirp-v3-0', + prompt: '', + make_instrumental: true, + user_uploaded_images_b64: null, + metadata: { + web_client_pathname: `/song/${song_id}`, + create_mode: 'custom', + user_tier: userTier, + create_session_token: sessionToken, + disable_volume_normalization: false, + is_remix: true + }, + override_fields: [], + cover_clip_id: null, + cover_start_s: null, + cover_end_s: null, + persona_id: null, + artist_clip_id: null, + artist_start_s: null, + artist_end_s: null, + continue_clip_id: song_id, + continued_aligned_prompt: null, + continue_at: null, + stem_type_id: 91, + stem_type_group_name: instrument, + stem_task: 'extract', + stem_name: instrument, + transaction_uuid: randomUUID(), + token_provider: null + }; + logger.info('advancedSplit request for instrument: ' + instrument); + const response = await this.client.post( + `${SunoApi.WEB_API_URL}/api/generate/v2-web/`, + payload, + { + timeout: 10000, + headers: { + ...SunoApi.WEB_CLIENT_HEADERS, + 'browser-token': JSON.stringify({ + token: Buffer.from( + JSON.stringify({ timestamp: Date.now() }) + ).toString('base64') + }), + Referer: 'https://suno.com/', + 'accept-language': 'en', + // Always set in the constructor (cookie ajs_anonymous_id or random). + 'device-id': this.deviceId as string + } + } + ); + if (response.status !== 200) { + throw new Error('Error response:' + response.statusText); + } + const byIndex = new Map(); + for (const clip of response.data.clips) byIndex.set(clip.batch_index, clip); + results.push({ + instrument, + extract: this.mapSplitStem(byIndex.get(0)), + remove: this.mapSplitStem(byIndex.get(1)), + midi_extract: { clip_id: byIndex.get(2)?.id }, + midi_remove: { clip_id: byIndex.get(3)?.id } + }); + await sleep(1, 2); + } + // Poll the feed until the audio stems (batch_index 0/1) finish. + const audioIds = results.flatMap(r => [r.extract.clip_id, r.remove.clip_id]) + .filter((id): id is string => Boolean(id)); + const feed = await this.waitAdvancedSplitFeed(audioIds); + for (const r of results) { + for (const key of ['extract', 'remove'] as const) { + const stem = r[key]; + const clip = feed[stem.clip_id as string]; + if (clip) { + stem.status = clip.status; + stem.title = clip.title; + // Link-first: cdn1 mp3 is the canonical stem URL once complete. + stem.audio_url = clip.audio_url + || (clip.status === 'complete' ? `https://cdn1.suno.ai/${stem.clip_id}.mp3` : undefined); + } + } + } + if (includeMidi) { + for (const r of results) { + for (const key of ['midi_extract', 'midi_remove'] as const) { + const entry = r[key]; + if (entry.clip_id) entry.midi = await this.pollMidiNotes(entry.clip_id); + } + } + } + return { + song_id, + mode: options.mode || 'advanced', + instruments: results + }; + } + + private mapSplitStem(clip: any): AdvancedSplitStem { + return { + clip_id: clip?.id, + title: clip?.title, + status: clip?.status + }; + } + + /** + * Subscription tier uuid for metadata.user_tier. Real source (captured + * traffic): GET /api/billing/info/ → plan.id. Cached on the instance; + * the tier does not change mid-session. + */ + private async getUserTier(): Promise { + if (this.userTier) return this.userTier; + const response = await this.client.get( + `${SunoApi.BASE_URL}/api/billing/info/`, + { timeout: 10000 } + ); + const tier = response.data?.plan?.id; + if (!tier) { + throw new Error('Failed to resolve user_tier from /api/billing/info/ (no plan.id)'); + } + this.userTier = tier; + return tier; + } + + /** + * Polls GET /api/feed/?ids= (bare JSON array, unlike /api/feed/v2) until every + * requested clip reaches a terminal status or the deadline passes. + */ + private async waitAdvancedSplitFeed( + ids: string[], + deadlineMs: number = 300000 + ): Promise> { + const byId: Record = {}; + if (ids.length === 0) return byId; + const start = Date.now(); + await sleep(5, 5); + while (Date.now() - start < deadlineMs) { + const response = await this.client.get( + `${SunoApi.WEB_API_URL}/api/feed/?ids=${ids.join(',')}`, + { + timeout: 10000, + // Captured feed envelope: desktop UA + sec-ch set, no browser-token/device-id. + headers: { ...SunoApi.WEB_CLIENT_HEADERS } + } + ); + const clips = Array.isArray(response.data) ? response.data : response.data?.clips || []; + let allDone = true; + for (const clip of clips) { + byId[clip.id] = clip; + if (clip.status !== 'complete' && clip.status !== 'error' && clip.status !== 'streaming') { + allDone = false; + } + } + if (clips.length >= ids.length && allDone) return byId; + await sleep(3); + await this.keepAlive(true); + } + logger.info('advancedSplit feed polling deadline reached, returning last state'); + return byId; + } + + /** + * Polls GET /api/gen/{midi_clip_id}/midi until state becomes "complete". + * Returns the note-events JSON ({ state, instruments: [{name, notes[]}] }). + */ + private async pollMidiNotes(midiClipId: string, deadlineMs: number = 180000): Promise { + const start = Date.now(); + while (Date.now() - start < deadlineMs) { + try { + const response = await this.client.get( + `${SunoApi.WEB_API_URL}/api/gen/${midiClipId}/midi`, + { + timeout: 10000, + headers: { + ...SunoApi.WEB_CLIENT_HEADERS, + 'browser-token': JSON.stringify({ + token: Buffer.from( + JSON.stringify({ timestamp: Date.now() }) + ).toString('base64') + }), + Referer: 'https://suno.com/', + 'accept-language': 'en', + 'device-id': this.deviceId as string + } + } + ); + if (response.data?.state === 'complete') return response.data; + await sleep(3); + } catch (err: any) { + // The MIDI clip is still generating upstream: 400 {detail: "Clip must be + // complete."}. Keep polling with backoff within the shared deadline; + // any other error propagates. + if (err?.response?.status === 400 && + String(err?.response?.data?.detail || '').includes('Clip must be complete')) { + await sleep(5, 10); + continue; + } + throw err; + } + } + logger.info('advancedSplit midi polling deadline reached for ' + midiClipId); + return null; + } + /** * Get the lyric alignment for a song.