Files
ace-step-ui/server/src/services/acestep.ts
T
fspecii e1625a717d Fix multiple issues: format API, Gradio availability, storage, UI responsiveness
- Format endpoint now calls ACE-Step /format_input REST API directly instead of
  spawning Python, fixing ENOENT errors on Windows (#44, #27, #34)
- isGradioAvailable() tries /gradio_api/info, /info, / in sequence to handle
  Gradio 4.x/5.x/6.x version differences, fixing generation fallback (#53, #20)
- Storage getUrl/getPublicUrl normalize /audio/ prefix to prevent double-prefix
  URLs when reference tracks are used for cover generation (#10)
- Gradio args: fix normalization_db default from 0.0 to -1.0 (Gradio default)
- Volume popover: add 400ms delay before hiding to prevent accidental dismissal (#51)
- Polling: skip setSongs state update when nothing changed to reduce re-renders (#51)
- FFmpeg: add jsdelivr CDN fallback when unpkg fails (#30)
- Model switching: add switchModelIfNeeded() via /v1/init REST API (#45)
2026-03-02 17:43:53 +02:00

984 lines
35 KiB
TypeScript

import { writeFile, mkdir, copyFile, rm, readFile } from 'fs/promises';
import { spawn, execSync } from 'child_process';
import { existsSync } from 'fs';
import path from 'path';
import { handle_file } from '@gradio/client';
// Get audio duration using ffprobe
function getAudioDuration(filePath: string): number {
try {
const result = execSync(
`ffprobe -v error -show_entries format=duration -of default=noprint_wrappers=1:nokey=1 "${filePath}"`,
{ encoding: 'utf-8', timeout: 10000 }
);
const duration = parseFloat(result.trim());
return isNaN(duration) ? 0 : Math.round(duration);
} catch (error) {
console.warn('Failed to get audio duration:', error);
return 0;
}
}
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);
const AUDIO_DIR = path.join(__dirname, '../../public/audio');
const ACESTEP_API = config.acestep.apiUrl;
// Resolve ACE-Step path (from env or default relative path)
function resolveAceStepPath(): string {
const envPath = process.env.ACESTEP_PATH;
if (envPath) {
return path.isAbsolute(envPath) ? envPath : path.resolve(process.cwd(), envPath);
}
// Default: sibling directory (server/src/services -> ../../../ACE-Step-1.5 = app/ACE-Step-1.5)
return path.resolve(__dirname, '../../../ACE-Step-1.5');
}
// Resolve Python path cross-platform (supports venv and portable installations)
export function resolvePythonPath(baseDir: string): string {
// Allow explicit override via env var
if (process.env.PYTHON_PATH) {
return process.env.PYTHON_PATH;
}
const isWindows = process.platform === 'win32';
const pythonExe = isWindows ? 'python.exe' : 'python';
// Check for portable installation first (python_embeded)
const portablePath = path.join(baseDir, 'python_embeded', pythonExe);
if (existsSync(portablePath)) {
return portablePath;
}
// Check common venv directory names (Pinokio uses 'env', others use '.venv' or 'venv')
const venvDirs = ['env', '.venv', 'venv'];
for (const venvDir of venvDirs) {
const venvPython = isWindows
? path.join(baseDir, venvDir, 'Scripts', pythonExe)
: path.join(baseDir, venvDir, 'bin', 'python');
if (existsSync(venvPython)) {
return venvPython;
}
}
// Fallback to first option (will produce a clear error if not found)
if (isWindows) {
return path.join(baseDir, 'env', 'Scripts', pythonExe);
}
return path.join(baseDir, 'env', 'bin', 'python');
}
const ACESTEP_DIR = resolveAceStepPath();
const SCRIPTS_DIR = path.join(__dirname, '../../scripts');
const PYTHON_SCRIPT = path.join(SCRIPTS_DIR, 'simple_generate.py');
// ---------------------------------------------------------------------------
// Gradio generation: map params to the 51 positional args for /generation_wrapper
// ---------------------------------------------------------------------------
/**
* 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/', ''));
}
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;
}
/**
* 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<unknown> {
if (!audioUrl) return null;
const filePath = resolveAudioPath(audioUrl);
try {
const buffer = await readFile(filePath);
const ext = path.extname(filePath).toLowerCase();
const mimeMap: Record<string, string> = {
'.flac': 'audio/flac', '.wav': 'audio/wav', '.ogg': 'audio/ogg',
'.opus': 'audio/opus', '.m4a': 'audio/mp4', '.mp4': 'audio/mp4',
};
const mimeType = mimeMap[ext] || '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 null;
}
}
/**
* Build the 50 positional arguments for the Gradio /generation_wrapper endpoint.
*/
async function buildGradioArgs(params: GenerationParams): Promise<unknown[]> {
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 isEnhance = params.enhance ?? false;
// Prepare audio files (async — reads from disk)
const referenceAudio = await prepareAudioFile(params.referenceAudioUrl);
const sourceAudio = await prepareAudioFile(params.sourceAudioUrl);
// CoT features are gated by enhance OR thinking (either enables LLM enrichment)
const useCot = isEnhance || isThinking;
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)
Math.min(Math.max(params.batchSize ?? 1, 1), 16), // 12: Batch Size (clamped 1-16)
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: Audio Cover Strength
0.0, // 19: Cover Noise Strength (ACE-Step v1.5 new param, default 0.0)
params.taskType || 'text2music', // 20: Task Type
params.useAdg ?? false, // 21: Use ADG
params.cfgIntervalStart ?? 0.0, // 22: CFG Interval Start
params.cfgIntervalEnd ?? 1.0, // 23: CFG Interval End
params.shift ?? 3.0, // 24: Shift
params.inferMethod || 'ode', // 25: Inference Method
params.customTimesteps || '', // 26: Custom Timesteps
params.audioFormat || 'mp3', // 27: Audio Format
params.lmTemperature ?? 0.85, // 28: LM Temperature
isThinking, // 29: Think
params.lmCfgScale ?? 2.0, // 30: LM CFG Scale
params.lmTopK ?? 0, // 31: LM Top-K
params.lmTopP ?? 0.9, // 32: LM Top-P
params.lmNegativePrompt || 'NO USER INPUT', // 33: LM Negative Prompt
useCot ? (params.useCotMetas ?? true) : false, // 34: CoT Metas
useCot ? (params.useCotCaption ?? true) : false, // 35: CaptionRewrite
useCot ? (params.useCotLanguage ?? true) : false, // 36: CoT Language
params.isFormatCaption ?? false, // 37: Is Format Caption State
params.constrainedDecodingDebug ?? false, // 38: Constrained Decoding Debug
params.allowLmBatch ?? true, // 39: ParallelThinking
params.getScores ?? false, // 40: Auto Score
params.getLrc ?? false, // 41: Auto LRC (timestamped lyrics)
params.scoreScale ?? 0.5, // 42: Quality Score Sensitivity (0.01-1.0)
params.lmBatchChunkSize ?? 8, // 43: LM Batch Chunk Size
params.trackName || null, // 44: Track Name
params.completeTrackClasses || [], // 45: Track Names
true, // 46: Enable Normalization (ACE-Step v1.5, default true)
-1.0, // 47: Normalization DB (ACE-Step v1.5, default -1.0)
0.0, // 48: Latent Shift (ACE-Step v1.5, default 0.0)
1.0, // 49: Latent Rescale (ACE-Step v1.5, default 1.0)
params.autogen ?? false, // 50: AutoGen
// Note: current_batch_index, total_batches, batch_queue, generation_params_state
// are hidden Gradio state variables and must NOT be passed via client.predict()
];
}
/**
* 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<void> {
await mkdir(path.dirname(destPath), { recursive: true });
// 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 (use temp file for atomicity)
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());
if (buffer.length === 0) {
throw new Error('Downloaded audio file is empty');
}
const tmpPath = destPath + '.tmp';
await writeFile(tmpPath, buffer);
const { rename } = await import('fs/promises');
await rename(tmpPath, destPath);
return;
}
throw new Error('Gradio file object has neither path nor url');
}
// ---------------------------------------------------------------------------
// Generation types & interfaces (unchanged public API)
// ---------------------------------------------------------------------------
export interface GenerationParams {
// Mode
customMode: boolean;
// Simple Mode
songDescription?: string;
// Custom Mode
lyrics: string;
style: string;
title: string;
// Common
instrumental: boolean;
vocalLanguage?: string;
// Music Parameters
duration?: number;
bpm?: number;
keyScale?: string;
timeSignature?: string;
// Generation Settings
inferenceSteps?: number;
guidanceScale?: number;
batchSize?: number;
randomSeed?: boolean;
seed?: number;
thinking?: boolean;
enhance?: boolean;
audioFormat?: 'mp3' | 'flac';
inferMethod?: 'ode' | 'sde';
shift?: number;
// LM Parameters
lmTemperature?: number;
lmCfgScale?: number;
lmTopK?: number;
lmTopP?: number;
lmNegativePrompt?: string;
lmBackend?: 'pt' | 'vllm';
lmModel?: string;
// Expert Parameters
referenceAudioUrl?: string;
sourceAudioUrl?: string;
referenceAudioTitle?: string;
sourceAudioTitle?: string;
audioCodes?: string;
repaintingStart?: number;
repaintingEnd?: number;
instruction?: string;
audioCoverStrength?: number;
taskType?: string;
useAdg?: boolean;
cfgIntervalStart?: number;
cfgIntervalEnd?: number;
customTimesteps?: string;
useCotMetas?: boolean;
useCotCaption?: boolean;
useCotLanguage?: boolean;
autogen?: boolean;
constrainedDecodingDebug?: boolean;
allowLmBatch?: boolean;
getScores?: boolean;
getLrc?: boolean;
scoreScale?: number;
lmBatchChunkSize?: number;
trackName?: string;
completeTrackClasses?: string[];
isFormatCaption?: boolean;
// Model selection
ditModel?: string;
}
interface GenerationResult {
audioUrls: string[];
duration: number;
bpm?: number;
keyScale?: string;
timeSignature?: string;
status: string;
}
interface JobStatus {
status: 'queued' | 'running' | 'succeeded' | 'failed';
queuePosition?: number;
etaSeconds?: number;
progress?: number;
stage?: string;
result?: GenerationResult;
error?: string;
}
interface ActiveJob {
params: GenerationParams;
startTime: number;
status: 'queued' | 'running' | 'succeeded' | 'failed';
taskId?: string;
result?: GenerationResult;
error?: string;
processPromise?: Promise<void>;
rawResponse?: unknown;
queuePosition?: number;
progress?: number;
stage?: string;
}
const activeJobs = new Map<string, ActiveJob>();
// Periodic cleanup of old jobs (every 10 minutes, remove jobs older than 1 hour)
setInterval(() => cleanupOldJobs(3600000), 600000);
// Job queue for sequential processing (GPU can only handle one job at a time)
const jobQueue: string[] = [];
let isProcessingQueue = false;
// Health check - verify Gradio app is reachable
export async function checkSpaceHealth(): Promise<boolean> {
return isGradioAvailable();
}
// ---------------------------------------------------------------------------
// Model switching — call /v1/init to change the active DiT model
// ---------------------------------------------------------------------------
async function getActiveModel(): Promise<string | null> {
try {
const res = await fetch(`${ACESTEP_API}/v1/models`);
if (!res.ok) return null;
const data = await res.json() as any;
const models = data?.data?.models || data?.models || [];
return models[0]?.name || null;
} catch {
return null;
}
}
async function switchModelIfNeeded(ditModel: string): Promise<void> {
const activeModel = await getActiveModel();
if (activeModel === ditModel) return; // already loaded, no-op
console.log(`[Model] Switching from '${activeModel ?? 'unknown'}' to '${ditModel}'`);
const res = await fetch(`${ACESTEP_API}/v1/init`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ model: ditModel, init_llm: false }),
});
if (!res.ok) {
const err = await res.text().catch(() => '');
throw new Error(`Model switch to '${ditModel}' failed: ${res.status} ${err}`);
}
console.log(`[Model] Switched to '${ditModel}'`);
}
// Discover endpoints (for compatibility)
export async function discoverEndpoints(): Promise<unknown> {
return { provider: 'acestep-gradio', endpoint: ACESTEP_API };
}
// Reset client — forces Gradio reconnection on next request
export function resetClient(): void {
resetGradioClient();
}
// ---------------------------------------------------------------------------
// Job queue
// ---------------------------------------------------------------------------
async function processQueue(): Promise<void> {
if (isProcessingQueue) return;
isProcessingQueue = true;
while (jobQueue.length > 0) {
const jobId = jobQueue[0];
const job = activeJobs.get(jobId);
if (job && job.status === 'queued') {
try {
await processGeneration(jobId, job.params, job);
} catch (error) {
console.error(`Queue processing error for ${jobId}:`, error);
}
}
// Remove from queue after processing (whether success or failure)
jobQueue.shift();
// Update queue positions for remaining jobs
jobQueue.forEach((id, index) => {
const queuedJob = activeJobs.get(id);
if (queuedJob) {
queuedJob.queuePosition = index + 1;
}
});
}
isProcessingQueue = false;
}
// Submit generation job to queue
export async function generateMusicViaAPI(params: GenerationParams): Promise<{ jobId: string }> {
const jobId = `job_${Date.now()}_${Math.random().toString(36).slice(2, 9)}`;
const job: ActiveJob = {
params,
startTime: Date.now(),
status: 'queued',
queuePosition: jobQueue.length + 1,
};
activeJobs.set(jobId, job);
jobQueue.push(jobId);
console.log(`Job ${jobId}: Queued at position ${job.queuePosition}`);
// Start processing the queue (will be a no-op if already processing)
processQueue().catch(err => console.error('Queue processing error:', err));
return { jobId };
}
// ---------------------------------------------------------------------------
// processGeneration — Gradio primary, Python spawn fallback
// ---------------------------------------------------------------------------
async function processGeneration(
jobId: string,
params: GenerationParams,
job: ActiveJob,
): Promise<void> {
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<void> {
// Switch DiT model if a specific one was requested
if (params.ditModel) {
job.stage = `Loading model ${params.ditModel}...`;
await switchModelIfNeeded(params.ditModel);
}
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[];
if (!Array.isArray(data) || data.length === 0) {
throw new Error(`Gradio returned unexpected data format: ${typeof data}`);
}
// 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 || 0);
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<void> {
const caption = params.style || 'pop music';
const prompt = params.customMode ? caption : (params.songDescription || caption);
const lyrics = params.instrumental ? '' : (params.lyrics || '');
console.log(`Job ${jobId}: Using Python spawn (Gradio not available)`, {
prompt: prompt.slice(0, 50),
lyricsPreview: lyrics.slice(0, 50),
duration: params.duration,
batchSize: params.batchSize,
});
try {
const jobOutputDir = path.join(ACESTEP_DIR, 'output', jobId);
await mkdir(jobOutputDir, { recursive: true });
const durationToSend = params.duration && params.duration > 0 ? params.duration : 60;
const args = [
'--prompt', prompt,
'--duration', String(durationToSend),
'--batch-size', String(params.batchSize ?? 1),
'--infer-steps', String(params.inferenceSteps ?? 8),
'--guidance-scale', String(params.guidanceScale ?? 10.0),
'--audio-format', params.audioFormat ?? 'mp3',
'--output-dir', jobOutputDir,
'--json',
];
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);
if (params.referenceAudioUrl) {
args.push('--reference-audio', resolveAudioPath(params.referenceAudioUrl));
}
if (params.sourceAudioUrl) {
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));
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) {
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);
// Note: --lm-backend and --lm-model are not supported by simple_generate.py
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));
const result = await runPythonGeneration(args);
if (!result.success) {
throw new Error(result.error || 'Generation failed');
}
if (!result.audio_paths || result.audio_paths.length === 0) {
throw new Error('No audio files generated');
}
const audioUrls: string[] = [];
let actualDuration = 0;
for (const srcPath of result.audio_paths) {
const ext = srcPath.includes('.flac') ? '.flac' : '.mp3';
const filename = `${jobId}_${audioUrls.length}${ext}`;
const destPath = path.join(AUDIO_DIR, filename);
await mkdir(AUDIO_DIR, { recursive: true });
await copyFile(srcPath, destPath);
if (audioUrls.length === 0) {
actualDuration = getAudioDuration(destPath);
}
audioUrls.push(`/audio/${filename}`);
}
try {
await rm(jobOutputDir, { recursive: true, force: true });
} catch (cleanupError) {
console.warn(`Job ${jobId}: Failed to cleanup output dir`, cleanupError);
}
const finalDuration = actualDuration > 0 ? actualDuration : (params.duration && params.duration > 0 ? params.duration : 0);
job.status = 'succeeded';
job.result = {
audioUrls,
duration: finalDuration,
bpm: params.bpm,
keyScale: params.keyScale,
timeSignature: params.timeSignature,
status: 'succeeded',
};
job.rawResponse = result;
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);
job.status = 'failed';
job.error = error instanceof Error ? error.message : 'Generation failed';
try {
const jobOutputDir = path.join(ACESTEP_DIR, 'output', jobId);
await rm(jobOutputDir, { recursive: true, force: true });
} catch { /* ignore cleanup errors */ }
}
}
interface PythonResult {
success: boolean;
audio_paths?: string[];
elapsed_seconds?: number;
error?: string;
}
function runPythonGeneration(scriptArgs: string[], timeoutMs = 600000): Promise<PythonResult> {
return new Promise((resolve) => {
const pythonPath = resolvePythonPath(ACESTEP_DIR);
const args = [PYTHON_SCRIPT, ...scriptArgs];
const proc = spawn(pythonPath, args, {
cwd: ACESTEP_DIR,
env: {
...process.env,
ACESTEP_PATH: ACESTEP_DIR,
},
});
// Kill process after timeout (default 10 minutes)
const timer = setTimeout(() => {
proc.kill('SIGTERM');
setTimeout(() => { if (!proc.killed) proc.kill('SIGKILL'); }, 5000);
resolve({ success: false, error: `Generation timed out after ${timeoutMs / 1000}s` });
}, timeoutMs);
let stdout = '';
let stderr = '';
proc.stdout.on('data', (data) => {
stdout += data.toString();
});
proc.stderr.on('data', (data) => {
stderr += data.toString();
const lines = data.toString().split('\n');
for (const line of lines) {
if (line.trim()) {
console.log(`[ACE-Step] ${line}`);
}
}
});
proc.on('close', (code) => {
clearTimeout(timer);
if (code !== 0) {
resolve({ success: false, error: stderr || `Process exited with code ${code}` });
return;
}
const lines = stdout.split('\n').filter(l => l.trim());
const jsonLine = lines.find(l => l.startsWith('{'));
if (!jsonLine) {
resolve({ success: false, error: 'No JSON output from generation script' });
return;
}
try {
const result = JSON.parse(jsonLine);
resolve(result);
} catch {
resolve({ success: false, error: 'Invalid JSON from generation script' });
}
});
proc.on('error', (err) => {
clearTimeout(timer);
resolve({ success: false, error: err.message });
});
});
}
// ---------------------------------------------------------------------------
// Job status (simplified — no more REST polling for progress)
// ---------------------------------------------------------------------------
export async function getJobStatus(jobId: string): Promise<JobStatus> {
const job = activeJobs.get(jobId);
if (!job) {
return {
status: 'failed',
error: 'Job not found',
};
}
if (job.status === 'succeeded' && job.result) {
return {
status: 'succeeded',
result: job.result,
};
}
if (job.status === 'failed') {
return {
status: 'failed',
error: job.error || 'Generation failed',
};
}
const elapsed = Math.floor((Date.now() - job.startTime) / 1000);
if (job.status === 'queued') {
return {
status: job.status,
queuePosition: job.queuePosition,
etaSeconds: (job.queuePosition || 1) * 180,
};
}
// Running — Gradio handles its own queue, we just report estimated time
return {
status: job.status,
etaSeconds: Math.max(0, 180 - elapsed),
progress: job.progress,
stage: job.stage,
};
}
// Get raw response for debugging
export function getJobRawResponse(jobId: string): unknown | null {
const job = activeJobs.get(jobId);
return job?.rawResponse || null;
}
// ---------------------------------------------------------------------------
// Audio helpers (unchanged)
// ---------------------------------------------------------------------------
export async function getAudioStream(audioPath: string): Promise<Response> {
if (audioPath.startsWith('http')) {
return fetch(audioPath);
}
if (audioPath.startsWith('/audio/')) {
const localPath = path.join(AUDIO_DIR, audioPath.replace('/audio/', ''));
try {
const buffer = await readFile(localPath);
const ext = localPath.endsWith('.flac') ? 'flac' : 'mpeg';
return new Response(buffer, {
status: 200,
headers: { 'Content-Type': `audio/${ext}` }
});
} catch (err) {
console.error('Failed to read local audio file:', localPath, err);
return new Response(null, { status: 404 });
}
}
// Absolute path — try reading directly from disk (Gradio output files)
if (audioPath.startsWith('/')) {
try {
const buffer = await readFile(audioPath);
const ext = audioPath.endsWith('.flac') ? 'flac' : audioPath.endsWith('.wav') ? 'wav' : 'mpeg';
return new Response(buffer, {
status: 200,
headers: { 'Content-Type': `audio/${ext}` }
});
} catch {
// Fall through to Gradio API
}
}
const url = `${ACESTEP_API}/v1/audio?path=${encodeURIComponent(audioPath)}`;
console.log('Fetching audio from:', url);
return fetch(url);
}
export async function downloadAudio(remoteUrl: string, songId: string): Promise<string> {
await mkdir(AUDIO_DIR, { recursive: true });
const response = await getAudioStream(remoteUrl);
if (!response.ok) {
throw new Error(`Failed to download audio: ${response.status}`);
}
const buffer = await response.arrayBuffer();
const ext = remoteUrl.includes('.flac') ? '.flac' : '.mp3';
const filename = `${songId}${ext}`;
const filepath = path.join(AUDIO_DIR, filename);
await writeFile(filepath, Buffer.from(buffer));
console.log(`Downloaded audio to ${filepath}`);
return `/audio/${filename}`;
}
export async function downloadAudioToBuffer(remoteUrl: string): Promise<{ buffer: Buffer; size: number }> {
const response = await getAudioStream(remoteUrl);
if (!response.ok) {
throw new Error(`Failed to download audio: ${response.status}`);
}
const arrayBuffer = await response.arrayBuffer();
const buffer = Buffer.from(arrayBuffer);
return { buffer, size: buffer.length };
}
export function cleanupJob(jobId: string): void {
activeJobs.delete(jobId);
}
export function cleanupOldJobs(maxAgeMs: number = 3600000): void {
const now = Date.now();
for (const [jobId, job] of activeJobs) {
if (now - job.startTime > maxAgeMs) {
activeJobs.delete(jobId);
}
}
}