diff --git a/server/package-lock.json b/server/package-lock.json index e252cb4..e9c4111 100644 --- a/server/package-lock.json +++ b/server/package-lock.json @@ -8,6 +8,7 @@ "name": "ace-step-ui-server", "version": "1.0.0", "dependencies": { + "@gradio/client": "^2.0.4", "@types/multer": "^2.0.0", "better-sqlite3": "^11.0.0", "cors": "^2.8.5", @@ -472,6 +473,18 @@ "node": ">=18" } }, + "node_modules/@gradio/client": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/@gradio/client/-/client-2.0.4.tgz", + "integrity": "sha512-pYywxUpamTYQLca01YAlTBwr2eb3H8d9s7geeQdexuPkM7p+EN2LhyzgNRN4yZrpn4swwfwP1fOoadKgd1b6Ww==", + "license": "ISC", + "dependencies": { + "fetch-event-stream": "^0.1.5" + }, + "engines": { + "node": ">=18.0.0" + } + }, "node_modules/@types/better-sqlite3": { "version": "7.6.13", "resolved": "https://registry.npmjs.org/@types/better-sqlite3/-/better-sqlite3-7.6.13.tgz", @@ -1160,6 +1173,12 @@ "url": "https://opencollective.com/express" } }, + "node_modules/fetch-event-stream": { + "version": "0.1.6", + "resolved": "https://registry.npmjs.org/fetch-event-stream/-/fetch-event-stream-0.1.6.tgz", + "integrity": "sha512-GREtJ5HNikdU2AXtZ6E/5bk+aslMU6ie5mPG6H9nvsdDkkHQ6m5lHwmmmDTOBexok9hApQ7EprsXCdmz9ZC68w==", + "license": "MIT" + }, "node_modules/file-uri-to-path": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/file-uri-to-path/-/file-uri-to-path-1.0.0.tgz", diff --git a/server/package.json b/server/package.json index 027abad..99fd6bd 100644 --- a/server/package.json +++ b/server/package.json @@ -10,6 +10,7 @@ "storage:test": "tsx src/scripts/test-storage.ts" }, "dependencies": { + "@gradio/client": "^2.0.4", "@types/multer": "^2.0.0", "better-sqlite3": "^11.0.0", "cors": "^2.8.5", diff --git a/server/src/index.ts b/server/src/index.ts index 902e8a6..031bcd1 100644 --- a/server/src/index.ts +++ b/server/src/index.ts @@ -23,6 +23,7 @@ import usersRoutes from './routes/users.js'; import playlistsRoutes from './routes/playlists.js'; import contactRoutes from './routes/contact.js'; import referenceTrackRoutes from './routes/referenceTrack.js'; +import loraRoutes from './routes/lora.js'; import { pool } from './db/pool.js'; import './db/migrate.js'; @@ -403,6 +404,7 @@ app.use('/api/users', usersRoutes); app.use('/api/playlists', playlistsRoutes); app.use('/api/contact', contactRoutes); app.use('/api/reference-tracks', referenceTrackRoutes); +app.use('/api/lora', loraRoutes); // Error handler app.use((err: Error, _req: express.Request, res: express.Response, _next: express.NextFunction) => { diff --git a/server/src/routes/lora.ts b/server/src/routes/lora.ts new file mode 100644 index 0000000..c4e9e15 --- /dev/null +++ b/server/src/routes/lora.ts @@ -0,0 +1,99 @@ +import { Router, Response } from 'express'; +import { authMiddleware, AuthenticatedRequest } from '../middleware/auth.js'; +import { getGradioClient } from '../services/gradio-client.js'; + +const router = Router(); + +// Local LoRA state tracking (Gradio doesn't have a dedicated status endpoint) +let loraState = { + loaded: false, + active: false, + scale: 1.0, + path: '', +}; + +// POST /api/lora/load — Load a LoRA adapter +router.post('/load', authMiddleware, async (req: AuthenticatedRequest, res: Response) => { + try { + const { lora_path } = req.body; + if (!lora_path || typeof lora_path !== 'string') { + res.status(400).json({ error: 'lora_path is required' }); + return; + } + + const client = await getGradioClient(); + const result = await client.predict('/load_lora', [lora_path]); + const status = (result.data as unknown[])[0] as string; + + loraState = { loaded: true, active: true, scale: loraState.scale, path: lora_path }; + + res.json({ message: status, lora_path, loaded: true }); + } catch (error) { + console.error('[LoRA] Load error:', error); + res.status(500).json({ error: error instanceof Error ? error.message : 'Failed to load LoRA' }); + } +}); + +// POST /api/lora/unload — Unload the current LoRA adapter +router.post('/unload', authMiddleware, async (_req: AuthenticatedRequest, res: Response) => { + try { + const client = await getGradioClient(); + const result = await client.predict('/unload_lora', []); + const status = (result.data as unknown[])[0] as string; + + loraState = { loaded: false, active: false, scale: 1.0, path: '' }; + + res.json({ message: status }); + } catch (error) { + console.error('[LoRA] Unload error:', error); + res.status(500).json({ error: error instanceof Error ? error.message : 'Failed to unload LoRA' }); + } +}); + +// POST /api/lora/scale — Set LoRA scale (0.0 - 1.0) +router.post('/scale', authMiddleware, async (req: AuthenticatedRequest, res: Response) => { + try { + const { scale } = req.body; + if (typeof scale !== 'number' || scale < 0 || scale > 1) { + res.status(400).json({ error: 'scale must be a number between 0 and 1' }); + return; + } + + const client = await getGradioClient(); + const result = await client.predict('/set_lora_scale', [scale]); + const status = (result.data as unknown[])[0] as string; + + loraState.scale = scale; + + res.json({ message: status, scale }); + } catch (error) { + console.error('[LoRA] Scale error:', error); + res.status(500).json({ error: error instanceof Error ? error.message : 'Failed to set LoRA scale' }); + } +}); + +// POST /api/lora/toggle — Toggle LoRA on/off +router.post('/toggle', authMiddleware, async (req: AuthenticatedRequest, res: Response) => { + try { + const { enabled } = req.body; + const useLoRA = typeof enabled === 'boolean' ? enabled : !loraState.active; + + const client = await getGradioClient(); + const result = await client.predict('/set_use_lora', [useLoRA]); + const status = (result.data as unknown[])[0] as string; + + loraState.active = useLoRA; + + res.json({ message: status, active: useLoRA }); + } catch (error) { + console.error('[LoRA] Toggle error:', error); + res.status(500).json({ error: error instanceof Error ? error.message : 'Failed to toggle LoRA' }); + } +}); + +// GET /api/lora/status — Get current LoRA state +router.get('/status', authMiddleware, async (_req: AuthenticatedRequest, res: Response) => { + res.json(loraState); +}); + +export default router; diff --git a/server/src/services/acestep.ts b/server/src/services/acestep.ts index fd41b63..a3dbe18 100644 --- a/server/src/services/acestep.ts +++ b/server/src/services/acestep.ts @@ -1,8 +1,8 @@ -import { writeFile, mkdir, copyFile, rm, stat, access } from 'fs/promises'; +import { writeFile, mkdir, copyFile, rm, readFile } from 'fs/promises'; import { spawn, execSync } from 'child_process'; -import { existsSync, createWriteStream } from 'fs'; +import { existsSync } from 'fs'; import path from 'path'; -import { pipeline } from 'stream/promises'; +import { handle_file } from '@gradio/client'; // Get audio duration using ffprobe function getAudioDuration(filePath: string): number { @@ -20,6 +20,7 @@ function getAudioDuration(filePath: string): number { } import { fileURLToPath } from 'url'; import { config } from '../config/index.js'; +import { getGradioClient, resetGradioClient, isGradioAvailable } from './gradio-client.js'; const __filename = fileURLToPath(import.meta.url); const __dirname = path.dirname(__filename); @@ -64,291 +65,150 @@ const ACESTEP_DIR = resolveAceStepPath(); const SCRIPTS_DIR = path.join(__dirname, '../../scripts'); const PYTHON_SCRIPT = path.join(SCRIPTS_DIR, 'simple_generate.py'); -// Cache API availability status (check once, remember for session) -let apiAvailableCache: boolean | null = null; -let apiCheckPromise: Promise | null = null; +// --------------------------------------------------------------------------- +// Gradio generation: map params to the 45 positional args for /generation_wrapper +// --------------------------------------------------------------------------- -// Check if ACE-Step API is running -async function isApiAvailable(): Promise { - // Return cached result if available - if (apiAvailableCache !== null) { - return apiAvailableCache; +/** + * Resolve an audio URL (e.g. /audio/file.mp3) to an absolute local file path. + */ +function resolveAudioPath(audioUrl: string): string { + if (audioUrl.startsWith('/audio/')) { + return path.join(AUDIO_DIR, audioUrl.replace('/audio/', '')); } - - // Prevent multiple concurrent checks - if (apiCheckPromise) { - return apiCheckPromise; - } - - apiCheckPromise = (async () => { + if (audioUrl.startsWith('http')) { try { - const controller = new AbortController(); - const timeout = setTimeout(() => controller.abort(), 3000); - - const response = await fetch(`${ACESTEP_API}/health`, { - signal: controller.signal, - }); - clearTimeout(timeout); - - if (response.ok) { - const data = await response.json(); - apiAvailableCache = data.status === 'ok' || data.healthy === true || data.data?.status === 'ok'; - console.log(`[ACE-Step] API available at ${ACESTEP_API}: ${apiAvailableCache}`); - return apiAvailableCache; + const parsed = new URL(audioUrl); + if (parsed.pathname.startsWith('/audio/')) { + return path.join(AUDIO_DIR, parsed.pathname.replace('/audio/', '')); } - apiAvailableCache = false; - return false; - } catch (error) { - console.log(`[ACE-Step] API not available at ${ACESTEP_API}, will use Python spawn`); - apiAvailableCache = false; - return false; - } finally { - apiCheckPromise = null; + } catch { /* fall through */ } + } + return audioUrl; +} + +/** + * Prepare a local audio file for Gradio upload. + * Returns a handle_file() wrapper or null if no file. + */ +async function prepareAudioFile(audioUrl: string | undefined): Promise { + if (!audioUrl) return null; + + const filePath = resolveAudioPath(audioUrl); + + try { + const buffer = await readFile(filePath); + const ext = path.extname(filePath).toLowerCase(); + const mimeType = ext === '.flac' ? 'audio/flac' : ext === '.wav' ? 'audio/wav' : 'audio/mpeg'; + const blob = new Blob([buffer], { type: mimeType }); + return handle_file(blob); + } catch (error) { + console.warn(`[Gradio] Failed to read audio file ${filePath}:`, error); + // Fall back to URL-based reference if file can't be read locally + if (audioUrl.startsWith('http')) { + return handle_file(audioUrl); } - })(); - - return apiCheckPromise; + return null; + } } -// Reset API cache (useful if API starts/stops) -export function resetApiCache(): void { - apiAvailableCache = null; - apiCheckPromise = null; -} - -// Submit generation job to ACE-Step API -async function submitToApi(params: GenerationParams): Promise<{ taskId: string }> { +/** + * Build the 45 positional arguments for the Gradio /generation_wrapper endpoint. + */ +async function buildGradioArgs(params: GenerationParams): Promise { const caption = params.style || 'pop music'; const prompt = params.customMode ? caption : (params.songDescription || caption); const lyrics = params.instrumental ? '' : (params.lyrics || ''); + const isThinking = params.thinking ?? false; - const body: Record = { - prompt, - lyrics, - batch_size: params.batchSize ?? 1, - inference_steps: params.inferenceSteps ?? 8, - guidance_scale: params.guidanceScale ?? 10.0, - audio_format: params.audioFormat ?? 'mp3', - vocal_language: params.vocalLanguage || 'en', - use_random_seed: params.randomSeed !== false, - shift: params.shift ?? 3.0, - thinking: params.thinking ?? false, // Respect frontend choice, default false for GPU compatibility - use_cot_caption: false, // Explicitly disable CoT features that require LLM - use_cot_language: false, // Explicitly disable CoT features that require LLM - use_cot_metas: false, // Explicitly disable CoT features that require LLM - lm_backend: params.lmBackend || 'pt', - lm_model_path: params.lmModel || undefined, - }; + // Prepare audio files (async — reads from disk) + const referenceAudio = await prepareAudioFile(params.referenceAudioUrl); + const sourceAudio = await prepareAudioFile(params.sourceAudioUrl); - if (params.duration && params.duration > 0) body.audio_duration = params.duration; - if (params.bpm && params.bpm > 0) body.bpm = params.bpm; - if (params.keyScale) body.key_scale = params.keyScale; - if (params.timeSignature) body.time_signature = params.timeSignature; - if (params.seed !== undefined && params.seed >= 0 && !params.randomSeed) { - body.seed = params.seed; - body.use_random_seed = false; - } - if (params.taskType && params.taskType !== 'text2music') body.task_type = params.taskType; - if (params.audioCodes) body.audio_code_string = params.audioCodes; - if (params.repaintingStart !== undefined && params.repaintingStart > 0) body.repainting_start = params.repaintingStart; - if (params.repaintingEnd !== undefined && params.repaintingEnd > 0) body.repainting_end = params.repaintingEnd; - // Always send audio_cover_strength for cover/repaint tasks, otherwise only when not default - if (params.taskType === 'cover' || params.taskType === 'repaint' || params.sourceAudioUrl) { - body.audio_cover_strength = params.audioCoverStrength ?? 1.0; - } else if (params.audioCoverStrength !== undefined && params.audioCoverStrength !== 1.0) { - body.audio_cover_strength = params.audioCoverStrength; - } - if (params.instruction) body.instruction = params.instruction; - // LLM and CoT parameters only sent when thinking mode is enabled - if (params.thinking) { - if (params.lmTemperature !== undefined) body.lm_temperature = params.lmTemperature; - if (params.lmCfgScale !== undefined) body.lm_cfg_scale = params.lmCfgScale; - if (params.lmTopK !== undefined && params.lmTopK > 0) body.lm_top_k = params.lmTopK; - if (params.lmTopP !== undefined) body.lm_top_p = params.lmTopP; - if (params.useCotCaption !== undefined) body.use_cot_caption = params.useCotCaption; - if (params.useCotLanguage !== undefined) body.use_cot_language = params.useCotLanguage; - if (params.useCotMetas !== undefined) body.use_cot_metas = params.useCotMetas; - } - if (params.useAdg) body.use_adg = true; - if (params.cfgIntervalStart !== undefined && params.cfgIntervalStart > 0) body.cfg_interval_start = params.cfgIntervalStart; - if (params.cfgIntervalEnd !== undefined && params.cfgIntervalEnd < 1.0) body.cfg_interval_end = params.cfgIntervalEnd; - - const resolveAudioPath = (audioUrl: string): string => { - if (audioUrl.startsWith('/audio/')) { - return path.join(AUDIO_DIR, audioUrl.replace('/audio/', '')); - } - if (audioUrl.startsWith('http')) { - try { - const parsed = new URL(audioUrl); - if (parsed.pathname.startsWith('/audio/')) { - return path.join(AUDIO_DIR, parsed.pathname.replace('/audio/', '')); - } - } catch { - // fall through - } - } - return audioUrl; - }; - - // Guard: cover/audio2audio requires a source or audio codes - if ((params.taskType === 'cover' || params.taskType === 'audio2audio') && !params.sourceAudioUrl && !params.audioCodes) { - throw new Error(`task_type='${params.taskType}' requires a source audio or audio codes`); - } - - // Handle reference audio - need to pass file path - if (params.referenceAudioUrl) { - body.reference_audio_path = resolveAudioPath(params.referenceAudioUrl); - } - if (params.sourceAudioUrl) { - body.src_audio_path = resolveAudioPath(params.sourceAudioUrl); - } - - if (params.taskType === 'cover' || params.taskType === 'audio2audio') { - console.log(`[ACE-Step] cover/audio2audio inputs`, { - reference_audio_path: body.reference_audio_path, - src_audio_path: body.src_audio_path, - has_audio_codes: Boolean(params.audioCodes), - }); - } - - const response = await fetch(`${ACESTEP_API}/release_task`, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify(body), - }); - - if (!response.ok) { - const errorText = await response.text(); - throw new Error(`API error: ${response.status} - ${errorText}`); - } - - const result = await response.json(); - const taskId = result.data?.task_id || result.data?.job_id || result.job_id || result.task_id; - if (!taskId) { - throw new Error('No task ID returned from API'); - } - - return { taskId }; + return [ + prompt, // 0: Music Caption + lyrics, // 1: Lyrics + params.bpm && params.bpm > 0 ? params.bpm : 0, // 2: BPM (0 = auto) + params.keyScale || '', // 3: KeyScale + params.timeSignature || '', // 4: Time Signature + params.vocalLanguage || 'en', // 5: Vocal Language + params.inferenceSteps ?? 8, // 6: DiT Inference Steps + params.guidanceScale ?? 7.0, // 7: DiT Guidance Scale + params.randomSeed !== false, // 8: Random Seed + String(params.seed ?? -1), // 9: Seed + referenceAudio, // 10: Reference Audio (filepath | null) + params.duration && params.duration > 0 ? params.duration : -1, // 11: Audio Duration (-1 = auto) + params.batchSize ?? 1, // 12: Batch Size + sourceAudio, // 13: Source Audio (filepath | null) + params.audioCodes || '', // 14: LM Codes Hints + params.repaintingStart ?? 0.0, // 15: Repainting Start + params.repaintingEnd ?? -1, // 16: Repainting End + params.instruction || 'Fill the audio semantic mask with the style described in the text prompt.', // 17: Instruction + params.audioCoverStrength ?? 1.0, // 18: LM Codes Strength + params.taskType || 'text2music', // 19: Task Type + params.useAdg ?? false, // 20: Use ADG + params.cfgIntervalStart ?? 0.0, // 21: CFG Interval Start + params.cfgIntervalEnd ?? 1.0, // 22: CFG Interval End + params.shift ?? 3.0, // 23: Shift + params.inferMethod || 'ode', // 24: Inference Method + params.customTimesteps || '', // 25: Custom Timesteps + params.audioFormat || 'mp3', // 26: Audio Format + params.lmTemperature ?? 0.85, // 27: LM Temperature + isThinking, // 28: Think + params.lmCfgScale ?? 2.0, // 29: LM CFG Scale + params.lmTopK ?? 0, // 30: LM Top-K + params.lmTopP ?? 0.9, // 31: LM Top-P + params.lmNegativePrompt || 'NO USER INPUT', // 32: LM Negative Prompt + isThinking ? (params.useCotMetas ?? true) : false, // 33: CoT Metas + isThinking ? (params.useCotCaption ?? true) : false, // 34: CaptionRewrite + isThinking ? (params.useCotLanguage ?? true) : false, // 35: CoT Language + params.constrainedDecodingDebug ?? false, // 36: Constrained Decoding Debug + params.allowLmBatch ?? true, // 37: ParallelThinking + params.getScores ?? false, // 38: Auto Score + params.getLrc ?? false, // 39: Auto LRC + params.scoreScale ?? 0.5, // 40: Quality Score Sensitivity + params.lmBatchChunkSize ?? 8, // 41: LM Batch Chunk Size + params.trackName || '', // 42: Track Name + params.completeTrackClasses || [], // 43: Track Names + params.autogen ?? false, // 44: AutoGen + ]; } -// Poll API for job result -interface ApiTaskResult { - status: number; // 0 = processing, 1 = done, 2 = failed - audioPaths: string[]; - metas?: { - bpm?: number; - duration?: number; - genres?: string; - keyscale?: string; - timesignature?: string; - }; -} - -async function pollApiResult(taskId: string, maxWaitMs = 600000): Promise { - const startTime = Date.now(); - const pollInterval = 2000; // 2 seconds - - while (Date.now() - startTime < maxWaitMs) { - const response = await fetch(`${ACESTEP_API}/query_result`, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ task_id_list: [taskId] }), - }); - - if (!response.ok) { - throw new Error(`API poll error: ${response.status}`); - } - - const result = await response.json(); - const taskData = result.data?.[0]; - - if (!taskData) { - await new Promise(resolve => setTimeout(resolve, pollInterval)); - continue; - } - - // Status: 0 = processing, 1 = done, 2 = failed - if (taskData.status === 1) { - // Parse result JSON - let resultData; - try { - resultData = typeof taskData.result === 'string' ? JSON.parse(taskData.result) : taskData.result; - } catch { - resultData = []; - } - - const audioPaths = Array.isArray(resultData) - ? resultData.map((r: { file?: string }) => r.file).filter(Boolean) - : []; - const metas = resultData[0]?.metas; - - return { status: 1, audioPaths, metas }; - } else if (taskData.status === 2) { - const details = taskData.error - || taskData.message - || taskData.status_message - || taskData.result - || JSON.stringify(taskData); - throw new Error(`Generation failed on API side: ${details}`); - } - - // Log progress while processing (if provided) - if (taskData.result) { - try { - const resultData = typeof taskData.result === 'string' ? JSON.parse(taskData.result) : taskData.result; - const item = Array.isArray(resultData) ? resultData[0] : resultData; - if (item && typeof item === 'object' && typeof (item as any).progress === 'number') { - const pct = Math.round((item as any).progress * 100); - console.log(`[ACE-Step] API task ${taskId} progress: ${pct}%`); - } - } catch { - // ignore parse failures - } - } - - // Still processing - await new Promise(resolve => setTimeout(resolve, pollInterval)); - } - - throw new Error('API generation timeout'); -} - -// Download audio from API -async function downloadAudioFromApi(audioPath: string, destPath: string): Promise { - // Check if audioPath is already a relative URL (starts with /v1/audio) - const url = audioPath.startsWith('/v1/audio') - ? `${ACESTEP_API}${audioPath}` - : `${ACESTEP_API}/v1/audio?path=${encodeURIComponent(audioPath)}`; - const response = await fetch(url); - - if (!response.ok) { - throw new Error(`Failed to download audio: ${response.status}`); - } - - const body = response.body; - if (!body) { - throw new Error('No response body'); - } - +/** + * Download a Gradio audio result file to local storage. + * Gradio returns file objects with { url, path, orig_name, ... }. + * We copy from the server-local path (same machine) or download via URL. + */ +async function downloadGradioAudioFile( + fileObj: { url?: string; path?: string; orig_name?: string }, + destPath: string, +): Promise { await mkdir(path.dirname(destPath), { recursive: true }); - const fileStream = createWriteStream(destPath); - // Convert web ReadableStream to Node stream - const reader = body.getReader(); - const nodeStream = new (await import('stream')).Readable({ - async read() { - const { done, value } = await reader.read(); - if (done) { - this.push(null); - } else { - this.push(Buffer.from(value)); - } + // Prefer direct filesystem copy (both servers on same machine) + if (fileObj.path && existsSync(fileObj.path)) { + await copyFile(fileObj.path, destPath); + return; + } + + // Fall back to HTTP download via Gradio URL + if (fileObj.url) { + const response = await fetch(fileObj.url); + if (!response.ok) { + throw new Error(`Failed to download Gradio audio: ${response.status}`); } - }); + const buffer = Buffer.from(await response.arrayBuffer()); + await writeFile(destPath, buffer); + return; + } - await pipeline(nodeStream, fileStream); + throw new Error('Gradio file object has neither path nor url'); } +// --------------------------------------------------------------------------- +// Generation types & interfaces (unchanged public API) +// --------------------------------------------------------------------------- + export interface GenerationParams { // Mode customMode: boolean; @@ -419,6 +279,9 @@ export interface GenerationParams { trackName?: string; completeTrackClasses?: string[]; isFormatCaption?: boolean; + + // Model selection + ditModel?: string; } interface GenerationResult { @@ -460,28 +323,25 @@ const activeJobs = new Map(); const jobQueue: string[] = []; let isProcessingQueue = false; -// Health check - verify Python script exists +// Health check - verify Gradio app is reachable export async function checkSpaceHealth(): Promise { - try { - const { access } = await import('fs/promises'); - await access(PYTHON_SCRIPT); - return true; - } catch { - return false; - } + return isGradioAvailable(); } // Discover endpoints (for compatibility) export async function discoverEndpoints(): Promise { - return { provider: 'acestep-local', endpoint: ACESTEP_API }; + return { provider: 'acestep-gradio', endpoint: ACESTEP_API }; } -// Reset client (no-op for REST API) +// Reset client — forces Gradio reconnection on next request export function resetClient(): void { - // No client to reset for REST API + resetGradioClient(); } -// Process the job queue sequentially +// --------------------------------------------------------------------------- +// Job queue +// --------------------------------------------------------------------------- + async function processQueue(): Promise { if (isProcessingQueue) return; isProcessingQueue = true; @@ -515,8 +375,6 @@ async function processQueue(): Promise { // Submit generation job to queue export async function generateMusicViaAPI(params: GenerationParams): Promise<{ jobId: string }> { - // Force a fresh API availability check when starting a job - resetApiCache(); const jobId = `job_${Date.now()}_${Math.random().toString(36).slice(2, 9)}`; const job: ActiveJob = { @@ -537,83 +395,179 @@ export async function generateMusicViaAPI(params: GenerationParams): Promise<{ j return { jobId }; } +// --------------------------------------------------------------------------- +// processGeneration — Gradio primary, Python spawn fallback +// --------------------------------------------------------------------------- + async function processGeneration( jobId: string, params: GenerationParams, - job: ActiveJob + job: ActiveJob, ): Promise { job.status = 'running'; + job.stage = 'Starting generation...'; + // Guard: cover/audio2audio requires a source or audio codes + if ((params.taskType === 'cover' || params.taskType === 'audio2audio') && !params.sourceAudioUrl && !params.audioCodes) { + job.status = 'failed'; + job.error = `task_type='${params.taskType}' requires a source audio or audio codes`; + return; + } + + // Try Gradio first + const gradioUp = await isGradioAvailable(); + if (gradioUp) { + try { + await processGenerationViaGradio(jobId, params, job); + return; + } catch (error) { + console.error(`Job ${jobId}: Gradio generation failed, trying Python spawn fallback`, error); + // Fall through to Python spawn + } + } + + // Fallback: Python spawn + await processGenerationViaPython(jobId, params, job); +} + +async function processGenerationViaGradio( + jobId: string, + params: GenerationParams, + job: ActiveJob, +): Promise { + const client = await getGradioClient(); + const args = await buildGradioArgs(params); + + const caption = params.style || 'pop music'; + const prompt = params.customMode ? caption : (params.songDescription || caption); + + console.log(`Job ${jobId}: Using Gradio /generation_wrapper`, { + prompt: prompt.slice(0, 50), + duration: params.duration, + batchSize: params.batchSize, + }); + + job.stage = 'Generating music via Gradio...'; + + // predict() blocks until generation is complete + const result = await client.predict('/generation_wrapper', args); + const data = result.data as unknown[]; + + // Extract audio files from the result + // Outputs 0-7: individual audio samples (filepath objects) + // Output 8: "All Generated Files" as list[filepath] + // Output 9: "Generation Details" (string) + // Output 10: "Generation Status" (string) + // Output 11: "Seed" (string) + const allFiles = data[8]; // list of file objects + const genDetails = data[9] as string | undefined; + const genStatus = data[10] as string | undefined; + + // Collect audio file objects — prefer the "All Generated Files" list + let audioFileObjects: Array<{ url?: string; path?: string; orig_name?: string }> = []; + + if (Array.isArray(allFiles) && allFiles.length > 0) { + audioFileObjects = allFiles.filter( + (f: any) => f && (f.path || f.url) && isAudioFile(f.orig_name || f.path || '') + ); + } + + // Fallback: check individual sample outputs (indices 0-7) + if (audioFileObjects.length === 0) { + for (let i = 0; i < 8; i++) { + const fileObj = data[i] as any; + if (fileObj && (fileObj.path || fileObj.url)) { + audioFileObjects.push(fileObj); + } + } + } + + if (audioFileObjects.length === 0) { + throw new Error(`Gradio generation returned no audio files. Status: ${genStatus || 'unknown'}. Details: ${genDetails || 'none'}`); + } + + // Download audio files to local storage + const audioUrls: string[] = []; + let actualDuration = 0; + const audioFormat = params.audioFormat ?? 'mp3'; + + for (const fileObj of audioFileObjects) { + const origName = fileObj.orig_name || fileObj.path || ''; + const ext = origName.includes('.flac') ? '.flac' : `.${audioFormat}`; + const filename = `${jobId}_${audioUrls.length}${ext}`; + const destPath = path.join(AUDIO_DIR, filename); + + await downloadGradioAudioFile(fileObj, destPath); + + if (audioUrls.length === 0) { + actualDuration = getAudioDuration(destPath); + } + + audioUrls.push(`/audio/${filename}`); + } + + // Parse metadata from generation details if available + const metas = parseGenerationDetails(genDetails); + + const finalDuration = actualDuration > 0 + ? actualDuration + : (metas.duration || params.duration || 60); + + job.status = 'succeeded'; + job.result = { + audioUrls, + duration: finalDuration, + bpm: metas.bpm || params.bpm, + keyScale: metas.keyScale || params.keyScale, + timeSignature: metas.timeSignature || params.timeSignature, + status: 'succeeded', + }; + job.rawResponse = { genDetails, genStatus }; + console.log(`Job ${jobId}: Completed via Gradio with ${audioUrls.length} audio files`); +} + +function isAudioFile(name: string): boolean { + return /\.(mp3|flac|wav|ogg|m4a)$/i.test(name); +} + +function parseGenerationDetails(details: string | undefined): { + bpm?: number; + duration?: number; + keyScale?: string; + timeSignature?: string; +} { + if (!details) return {}; + try { + // Generation details may contain key-value pairs + const bpmMatch = details.match(/BPM:\s*(\d+)/i); + const durationMatch = details.match(/Duration:\s*([\d.]+)/i); + const keyMatch = details.match(/Key:\s*([A-G][#b]?\s*(?:major|minor))/i); + const timeMatch = details.match(/Time Signature:\s*(\d+\/\d+)/i); + return { + bpm: bpmMatch ? parseInt(bpmMatch[1]) : undefined, + duration: durationMatch ? parseFloat(durationMatch[1]) : undefined, + keyScale: keyMatch ? keyMatch[1] : undefined, + timeSignature: timeMatch ? timeMatch[1] : undefined, + }; + } catch { + return {}; + } +} + +// --------------------------------------------------------------------------- +// Python spawn fallback (kept from original for offline/fallback use) +// --------------------------------------------------------------------------- + +async function processGenerationViaPython( + jobId: string, + params: GenerationParams, + job: ActiveJob, +): Promise { const caption = params.style || 'pop music'; const prompt = params.customMode ? caption : (params.songDescription || caption); const lyrics = params.instrumental ? '' : (params.lyrics || ''); - // Check if ACE-Step API is available - const useApi = await isApiAvailable(); - - if (useApi) { - console.log(`Job ${jobId}: Using ACE-Step REST API`, { - prompt: prompt.slice(0, 50), - duration: params.duration, - }); - - try { - // Submit to API - const { taskId } = await submitToApi(params); - job.taskId = taskId; - console.log(`Job ${jobId}: Submitted to API as task ${taskId}`); - - // Poll for result - const apiResult = await pollApiResult(taskId); - - if (!apiResult.audioPaths || apiResult.audioPaths.length === 0) { - throw new Error('No audio files generated by API'); - } - - // Download audio files from API to local storage - const audioUrls: string[] = []; - let actualDuration = 0; - const audioFormat = params.audioFormat ?? 'mp3'; - - for (const apiAudioPath of apiResult.audioPaths) { - const ext = apiAudioPath.includes('.flac') ? '.flac' : `.${audioFormat}`; - const filename = `${jobId}_${audioUrls.length}${ext}`; - const destPath = path.join(AUDIO_DIR, filename); - - await downloadAudioFromApi(apiAudioPath, destPath); - - if (audioUrls.length === 0) { - actualDuration = getAudioDuration(destPath); - } - - audioUrls.push(`/audio/${filename}`); - } - - const finalDuration = actualDuration > 0 - ? actualDuration - : (apiResult.metas?.duration || params.duration || 60); - - job.status = 'succeeded'; - job.result = { - audioUrls, - duration: finalDuration, - bpm: apiResult.metas?.bpm || params.bpm, - keyScale: apiResult.metas?.keyscale || params.keyScale, - timeSignature: apiResult.metas?.timesignature || params.timeSignature, - status: 'succeeded', - }; - console.log(`Job ${jobId}: Completed via API with ${audioUrls.length} audio files`); - - } catch (error) { - console.error(`Job ${jobId}: API generation failed`, error); - job.status = 'failed'; - job.error = error instanceof Error ? error.message : 'API generation failed'; - } - return; - } - - // Fall back to Python spawn if API not available - console.log(`Job ${jobId}: Using Python spawn (API not available)`, { + console.log(`Job ${jobId}: Using Python spawn (Gradio not available)`, { prompt: prompt.slice(0, 50), lyricsPreview: lyrics.slice(0, 50), duration: params.duration, @@ -647,23 +601,14 @@ async function processGeneration( if (params.taskType && params.taskType !== 'text2music') args.push('--task-type', params.taskType); if (params.referenceAudioUrl) { - let refAudioPath = params.referenceAudioUrl; - if (refAudioPath.startsWith('/audio/')) { - refAudioPath = path.join(AUDIO_DIR, refAudioPath.replace('/audio/', '')); - } - args.push('--reference-audio', refAudioPath); + args.push('--reference-audio', resolveAudioPath(params.referenceAudioUrl)); } if (params.sourceAudioUrl) { - let srcAudioPath = params.sourceAudioUrl; - if (srcAudioPath.startsWith('/audio/')) { - srcAudioPath = path.join(AUDIO_DIR, srcAudioPath.replace('/audio/', '')); - } - args.push('--src-audio', srcAudioPath); + args.push('--src-audio', resolveAudioPath(params.sourceAudioUrl)); } if (params.audioCodes) args.push('--audio-codes', params.audioCodes); if (params.repaintingStart !== undefined && params.repaintingStart > 0) args.push('--repainting-start', String(params.repaintingStart)); if (params.repaintingEnd !== undefined && params.repaintingEnd > 0) args.push('--repainting-end', String(params.repaintingEnd)); - // Always send audio_cover_strength for cover/repaint tasks, otherwise only when not default if (params.taskType === 'cover' || params.taskType === 'repaint' || params.sourceAudioUrl) { args.push('--audio-cover-strength', String(params.audioCoverStrength ?? 1.0)); } else if (params.audioCoverStrength !== undefined && params.audioCoverStrength !== 1.0) { @@ -730,7 +675,7 @@ async function processGeneration( status: 'succeeded', }; job.rawResponse = result; - console.log(`Job ${jobId}: Completed in ${result.elapsed_seconds?.toFixed(1)}s with ${audioUrls.length} audio files`); + console.log(`Job ${jobId}: Completed via Python in ${result.elapsed_seconds?.toFixed(1)}s with ${audioUrls.length} audio files`); } catch (error) { console.error(`Job ${jobId}: Generation failed`, error); @@ -773,7 +718,6 @@ function runPythonGeneration(scriptArgs: string[]): Promise { proc.stderr.on('data', (data) => { stderr += data.toString(); - // Log progress to console const lines = data.toString().split('\n'); for (const line of lines) { if (line.trim()) { @@ -788,7 +732,6 @@ function runPythonGeneration(scriptArgs: string[]): Promise { return; } - // Find the JSON output (last line that starts with {) const lines = stdout.split('\n').filter(l => l.trim()); const jsonLine = lines.find(l => l.startsWith('{')); @@ -811,58 +754,10 @@ function runPythonGeneration(scriptArgs: string[]): Promise { }); } -function extractAudioFiles(result: unknown): string[] { - const urls: string[] = []; +// --------------------------------------------------------------------------- +// Job status (simplified — no more REST polling for progress) +// --------------------------------------------------------------------------- - function processItem(item: unknown): void { - if (!item) return; - - if (typeof item === 'string') { - if (item.includes('.mp3') || item.includes('.wav') || item.includes('.flac')) { - urls.push(item); - } - return; - } - - if (Array.isArray(item)) { - for (const subItem of item) { - processItem(subItem); - } - return; - } - - if (typeof item === 'object') { - const obj = item as Record; - - // Check common audio path fields - if (obj.audio_path && typeof obj.audio_path === 'string') { - urls.push(obj.audio_path); - } - if (obj.path && typeof obj.path === 'string') { - urls.push(obj.path); - } - if (obj.url && typeof obj.url === 'string') { - urls.push(obj.url); - } - if (obj.file && typeof obj.file === 'string') { - urls.push(obj.file); - } - - // Recursively check arrays and objects - for (const key of Object.keys(obj)) { - const val = obj[key]; - if (Array.isArray(val) || (typeof val === 'object' && val !== null)) { - processItem(val); - } - } - } - } - - processItem(result); - return [...new Set(urls)]; -} - -// Get job status export async function getJobStatus(jobId: string): Promise { const job = activeJobs.get(jobId); @@ -889,60 +784,18 @@ export async function getJobStatus(jobId: string): Promise { const elapsed = Math.floor((Date.now() - job.startTime) / 1000); - // Include queue position if queued if (job.status === 'queued') { return { status: job.status, queuePosition: job.queuePosition, - etaSeconds: (job.queuePosition || 1) * 180, // ~3 min per job estimate + etaSeconds: (job.queuePosition || 1) * 180, }; } - if (job.status === 'running' && job.taskId) { - try { - const response = await fetch(`${ACESTEP_API}/query_result`, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ task_id_list: [job.taskId] }), - }); - - if (response.ok) { - const result = await response.json(); - const taskData = result.data?.[0]; - if (taskData?.result) { - let resultData: unknown = taskData.result; - if (typeof resultData === 'string') { - try { - resultData = JSON.parse(resultData); - } catch { - resultData = null; - } - } - - const item = Array.isArray(resultData) ? resultData[0] : resultData; - if (item && typeof item === 'object') { - const rawProgress = (item as any).progress; - const progress = Number.isFinite(Number(rawProgress)) ? Number(rawProgress) : undefined; - const stage = typeof (item as any).stage === 'string' ? (item as any).stage : undefined; - if (progress !== undefined) job.progress = progress; - if (stage) job.stage = stage; - return { - status: job.status, - etaSeconds: Math.max(0, 180 - elapsed), - progress: progress ?? job.progress, - stage: stage ?? job.stage, - }; - } - } - } - } catch { - // ignore progress fetch failures, fall back to ETA only - } - } - + // Running — Gradio handles its own queue, we just report estimated time return { status: job.status, - etaSeconds: Math.max(0, 180 - elapsed), // 3 min estimate + etaSeconds: Math.max(0, 180 - elapsed), progress: job.progress, stage: job.stage, }; @@ -954,18 +807,18 @@ export function getJobRawResponse(jobId: string): unknown | null { return job?.rawResponse || null; } -// Get audio stream from local file or remote URL +// --------------------------------------------------------------------------- +// Audio helpers (unchanged) +// --------------------------------------------------------------------------- + export async function getAudioStream(audioPath: string): Promise { - // If it's already a full URL, fetch directly if (audioPath.startsWith('http')) { return fetch(audioPath); } - // If it's a local /audio/ path, read from filesystem if (audioPath.startsWith('/audio/')) { const localPath = path.join(AUDIO_DIR, audioPath.replace('/audio/', '')); try { - const { readFile } = await import('fs/promises'); const buffer = await readFile(localPath); const ext = localPath.endsWith('.flac') ? 'flac' : 'mpeg'; return new Response(buffer, { @@ -978,13 +831,11 @@ export async function getAudioStream(audioPath: string): Promise { } } - // Otherwise, use the ACE-Step audio endpoint const url = `${ACESTEP_API}/v1/audio?path=${encodeURIComponent(audioPath)}`; console.log('Fetching audio from:', url); return fetch(url); } -// Download audio to local storage export async function downloadAudio(remoteUrl: string, songId: string): Promise { await mkdir(AUDIO_DIR, { recursive: true }); @@ -1004,7 +855,6 @@ export async function downloadAudio(remoteUrl: string, songId: string): Promise< return `/audio/${filename}`; } -// Download audio to buffer export async function downloadAudioToBuffer(remoteUrl: string): Promise<{ buffer: Buffer; size: number }> { const response = await getAudioStream(remoteUrl); if (!response.ok) { @@ -1016,12 +866,10 @@ export async function downloadAudioToBuffer(remoteUrl: string): Promise<{ buffer return { buffer, size: buffer.length }; } -// Cleanup job from memory export function cleanupJob(jobId: string): void { activeJobs.delete(jobId); } -// Cleanup old jobs export function cleanupOldJobs(maxAgeMs: number = 3600000): void { const now = Date.now(); for (const [jobId, job] of activeJobs) { diff --git a/server/src/services/gradio-client.ts b/server/src/services/gradio-client.ts new file mode 100644 index 0000000..b527311 --- /dev/null +++ b/server/src/services/gradio-client.ts @@ -0,0 +1,57 @@ +import { Client } from "@gradio/client"; +import { config } from '../config/index.js'; + +let clientInstance: Client | null = null; +let connectionPromise: Promise | null = null; + +/** + * Get a lazy-initialized Gradio client connected to the ACE-Step Gradio app. + * Caches the connection for reuse across requests. + */ +export async function getGradioClient(): Promise { + if (clientInstance) return clientInstance; + if (connectionPromise) return connectionPromise; + + connectionPromise = (async () => { + try { + const client = await Client.connect(config.acestep.apiUrl, { + events: ["data", "status"], + }); + clientInstance = client; + console.log(`[Gradio] Connected to ${config.acestep.apiUrl}`); + return client; + } catch (error) { + console.error(`[Gradio] Failed to connect to ${config.acestep.apiUrl}:`, error); + throw error; + } finally { + connectionPromise = null; + } + })(); + + return connectionPromise; +} + +/** + * Reset the cached Gradio client, forcing a new connection on next use. + */ +export function resetGradioClient(): void { + clientInstance = null; + connectionPromise = null; +} + +/** + * Check if the Gradio app is reachable. + */ +export async function isGradioAvailable(): Promise { + try { + const controller = new AbortController(); + const timeout = setTimeout(() => controller.abort(), 3000); + const response = await fetch(`${config.acestep.apiUrl}/gradio_api/info`, { + signal: controller.signal, + }); + clearTimeout(timeout); + return response.ok; + } catch { + return false; + } +}