From f9e460179212651640fe7396e49a8c7c8c72115d Mon Sep 17 00:00:00 2001 From: fspecii Date: Wed, 4 Feb 2026 14:38:19 +0200 Subject: [PATCH] Fix double model loading: use ACE-Step REST API when available - Check if ACE-Step API is running at startup (GET /health) - If API available: use REST endpoints (POST /release_task, POST /query_result, GET /v1/audio) - Model is already loaded by the API server, no double initialization - If API not available: fall back to Python spawn (existing behavior) - Model loads once via Python script This prevents the issue where running `acestep-api --port 8001` loads the model, then the UI spawns Python which loads it again, consuming double VRAM. API response is cached for the session to avoid repeated health checks. --- server/src/services/acestep.ts | 419 +++++++++++++++++++++++++-------- 1 file changed, 320 insertions(+), 99 deletions(-) diff --git a/server/src/services/acestep.ts b/server/src/services/acestep.ts index 9aa5fdb..e7f1108 100644 --- a/server/src/services/acestep.ts +++ b/server/src/services/acestep.ts @@ -1,7 +1,8 @@ import { writeFile, mkdir, copyFile, rm, stat, access } from 'fs/promises'; import { spawn, execSync } from 'child_process'; -import { existsSync } from 'fs'; +import { existsSync, createWriteStream } from 'fs'; import path from 'path'; +import { pipeline } from 'stream/promises'; // Get audio duration using ffprobe function getAudioDuration(filePath: string): number { @@ -63,6 +64,233 @@ 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; + +// Check if ACE-Step API is running +async function isApiAvailable(): Promise { + // Return cached result if available + if (apiAvailableCache !== null) { + return apiAvailableCache; + } + + // Prevent multiple concurrent checks + if (apiCheckPromise) { + return apiCheckPromise; + } + + apiCheckPromise = (async () => { + 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; + console.log(`[ACE-Step] API available at ${ACESTEP_API}: ${apiAvailableCache}`); + return apiAvailableCache; + } + 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; + } + })(); + + return apiCheckPromise; +} + +// 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 }> { + const caption = params.style || 'pop music'; + const prompt = params.customMode ? caption : (params.songDescription || caption); + const lyrics = params.instrumental ? '' : (params.lyrics || ''); + + const body: Record = { + prompt, + lyrics, + audio_duration: params.duration ?? 60, + 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, + }; + + 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; + if (params.audioCoverStrength !== undefined && params.audioCoverStrength !== 1.0) body.audio_cover_strength = params.audioCoverStrength; + if (params.instruction) body.instruction = params.instruction; + if (params.thinking) body.thinking = true; + 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.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; + + // Handle reference audio - need to pass file path + if (params.referenceAudioUrl) { + let refAudioPath = params.referenceAudioUrl; + if (refAudioPath.startsWith('/audio/')) { + refAudioPath = path.join(AUDIO_DIR, refAudioPath.replace('/audio/', '')); + } + body.reference_audio_path = refAudioPath; + } + if (params.sourceAudioUrl) { + let srcAudioPath = params.sourceAudioUrl; + if (srcAudioPath.startsWith('/audio/')) { + srcAudioPath = path.join(AUDIO_DIR, srcAudioPath.replace('/audio/', '')); + } + body.src_audio_path = srcAudioPath; + } + + 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?.job_id || result.job_id; + if (!taskId) { + throw new Error('No task ID returned from API'); + } + + return { taskId }; +} + +// 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) { + throw new Error('Generation failed on API side'); + } + + // 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 { + const url = `${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'); + } + + 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)); + } + } + }); + + await pipeline(nodeStream, fileStream); +} + export interface GenerationParams { // Mode customMode: boolean; @@ -248,12 +476,75 @@ async function processGeneration( ): Promise { job.status = 'running'; - // Build prompt for generation const caption = params.style || 'pop music'; const prompt = params.customMode ? caption : (params.songDescription || caption); const lyrics = params.instrumental ? '' : (params.lyrics || ''); - console.log(`Job ${jobId}: Starting generation via Python script`, { + // 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); + 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)`, { prompt: prompt.slice(0, 50), lyricsPreview: lyrics.slice(0, 50), duration: params.duration, @@ -261,11 +552,9 @@ async function processGeneration( }); try { - // Create unique output directory for this job to avoid conflicts with concurrent jobs const jobOutputDir = path.join(ACESTEP_DIR, 'output', jobId); await mkdir(jobOutputDir, { recursive: true }); - // Build command arguments for the Python script const args = [ '--prompt', prompt, '--duration', String(params.duration ?? 60), @@ -277,38 +566,17 @@ async function processGeneration( '--json', ]; - // Basic parameters - if (lyrics) { - args.push('--lyrics', lyrics); - } - if (params.instrumental) { - args.push('--instrumental'); - } - if (params.bpm && params.bpm > 0) { - args.push('--bpm', String(params.bpm)); - } - if (params.keyScale) { - args.push('--key-scale', params.keyScale); - } - if (params.timeSignature) { - args.push('--time-signature', params.timeSignature); - } - if (params.vocalLanguage) { - args.push('--vocal-language', params.vocalLanguage); - } - if (params.seed !== undefined && params.seed >= 0 && !params.randomSeed) { - args.push('--seed', String(params.seed)); - } - if (params.shift !== undefined) { - args.push('--shift', String(params.shift)); - } + if (lyrics) args.push('--lyrics', lyrics); + if (params.instrumental) args.push('--instrumental'); + if (params.bpm && params.bpm > 0) args.push('--bpm', String(params.bpm)); + if (params.keyScale) args.push('--key-scale', params.keyScale); + if (params.timeSignature) args.push('--time-signature', params.timeSignature); + if (params.vocalLanguage) args.push('--vocal-language', params.vocalLanguage); + if (params.seed !== undefined && params.seed >= 0 && !params.randomSeed) args.push('--seed', String(params.seed)); + if (params.shift !== undefined) args.push('--shift', String(params.shift)); + if (params.taskType && params.taskType !== 'text2music') args.push('--task-type', params.taskType); - // Task type parameters - if (params.taskType && params.taskType !== 'text2music') { - args.push('--task-type', params.taskType); - } if (params.referenceAudioUrl) { - // Convert URL path to filesystem path let refAudioPath = params.referenceAudioUrl; if (refAudioPath.startsWith('/audio/')) { refAudioPath = path.join(AUDIO_DIR, refAudioPath.replace('/audio/', '')); @@ -316,72 +584,30 @@ async function processGeneration( args.push('--reference-audio', refAudioPath); } if (params.sourceAudioUrl) { - // Convert URL path to filesystem path let srcAudioPath = params.sourceAudioUrl; if (srcAudioPath.startsWith('/audio/')) { srcAudioPath = path.join(AUDIO_DIR, srcAudioPath.replace('/audio/', '')); } args.push('--src-audio', srcAudioPath); } - 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)); - } - if (params.audioCoverStrength !== undefined && params.audioCoverStrength !== 1.0) { - args.push('--audio-cover-strength', String(params.audioCoverStrength)); - } - if (params.instruction) { - args.push('--instruction', params.instruction); - } + 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)); + if (params.audioCoverStrength !== undefined && params.audioCoverStrength !== 1.0) args.push('--audio-cover-strength', String(params.audioCoverStrength)); + if (params.instruction) args.push('--instruction', params.instruction); + if (params.thinking) args.push('--thinking'); + if (params.lmTemperature !== undefined) args.push('--lm-temperature', String(params.lmTemperature)); + if (params.lmCfgScale !== undefined) args.push('--lm-cfg-scale', String(params.lmCfgScale)); + if (params.lmTopK !== undefined && params.lmTopK > 0) args.push('--lm-top-k', String(params.lmTopK)); + if (params.lmTopP !== undefined) args.push('--lm-top-p', String(params.lmTopP)); + if (params.lmNegativePrompt) args.push('--lm-negative-prompt', params.lmNegativePrompt); + if (params.useCotMetas === false) args.push('--no-cot-metas'); + if (params.useCotCaption === false) args.push('--no-cot-caption'); + if (params.useCotLanguage === false) args.push('--no-cot-language'); + if (params.useAdg) args.push('--use-adg'); + if (params.cfgIntervalStart !== undefined && params.cfgIntervalStart > 0) args.push('--cfg-interval-start', String(params.cfgIntervalStart)); + if (params.cfgIntervalEnd !== undefined && params.cfgIntervalEnd < 1.0) args.push('--cfg-interval-end', String(params.cfgIntervalEnd)); - // LM/CoT parameters - if (params.thinking) { - args.push('--thinking'); - } - if (params.lmTemperature !== undefined) { - args.push('--lm-temperature', String(params.lmTemperature)); - } - if (params.lmCfgScale !== undefined) { - args.push('--lm-cfg-scale', String(params.lmCfgScale)); - } - if (params.lmTopK !== undefined && params.lmTopK > 0) { - args.push('--lm-top-k', String(params.lmTopK)); - } - if (params.lmTopP !== undefined) { - args.push('--lm-top-p', String(params.lmTopP)); - } - if (params.lmNegativePrompt) { - args.push('--lm-negative-prompt', params.lmNegativePrompt); - } - - // CoT parameters (pass when disabled, since they default to true) - if (params.useCotMetas === false) { - args.push('--no-cot-metas'); - } - if (params.useCotCaption === false) { - args.push('--no-cot-caption'); - } - if (params.useCotLanguage === false) { - args.push('--no-cot-language'); - } - - // Advanced parameters - if (params.useAdg) { - args.push('--use-adg'); - } - if (params.cfgIntervalStart !== undefined && params.cfgIntervalStart > 0) { - args.push('--cfg-interval-start', String(params.cfgIntervalStart)); - } - if (params.cfgIntervalEnd !== undefined && params.cfgIntervalEnd < 1.0) { - args.push('--cfg-interval-end', String(params.cfgIntervalEnd)); - } - - // Run the Python script const result = await runPythonGeneration(args); if (!result.success) { @@ -392,7 +618,6 @@ async function processGeneration( throw new Error('No audio files generated'); } - // Copy audio files to public directory and build URLs const audioUrls: string[] = []; let actualDuration = 0; for (const srcPath of result.audio_paths) { @@ -403,7 +628,6 @@ async function processGeneration( await mkdir(AUDIO_DIR, { recursive: true }); await copyFile(srcPath, destPath); - // Get actual audio duration from first file if (audioUrls.length === 0) { actualDuration = getAudioDuration(destPath); } @@ -411,14 +635,12 @@ async function processGeneration( audioUrls.push(`/audio/${filename}`); } - // Clean up job-specific output directory try { await rm(jobOutputDir, { recursive: true, force: true }); } catch (cleanupError) { console.warn(`Job ${jobId}: Failed to cleanup output dir`, cleanupError); } - // Use actual duration, or fall back to params if > 0, otherwise default to 60 const finalDuration = actualDuration > 0 ? actualDuration : (params.duration && params.duration > 0 ? params.duration : 60); job.status = 'succeeded'; @@ -438,7 +660,6 @@ async function processGeneration( job.status = 'failed'; job.error = error instanceof Error ? error.message : 'Generation failed'; - // Try to clean up job output directory on failure too try { const jobOutputDir = path.join(ACESTEP_DIR, 'output', jobId); await rm(jobOutputDir, { recursive: true, force: true });