Initial commit: ACE-Step UI - Open source music generation interface

This commit is contained in:
fspecii
2026-02-04 03:09:38 +02:00
commit 44f7563014
187 changed files with 99985 additions and 0 deletions
+668
View File
@@ -0,0 +1,668 @@
import { writeFile, mkdir, copyFile, rm, stat } from 'fs/promises';
import { spawn, execSync } from 'child_process';
import path from 'path';
// 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';
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
return path.resolve(__dirname, '../../../../ACE-Step-1.5');
}
const ACESTEP_DIR = resolveAceStepPath();
const SCRIPTS_DIR = path.join(__dirname, '../../scripts');
const PYTHON_SCRIPT = path.join(SCRIPTS_DIR, 'simple_generate.py');
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;
audioFormat?: 'mp3' | 'flac';
inferMethod?: 'ode' | 'sde';
shift?: number;
// LM Parameters
lmTemperature?: number;
lmCfgScale?: number;
lmTopK?: number;
lmTopP?: number;
lmNegativePrompt?: string;
// Expert Parameters
referenceAudioUrl?: string;
sourceAudioUrl?: 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;
}
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;
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;
}
const activeJobs = new Map<string, ActiveJob>();
// Job queue for sequential processing (GPU can only handle one job at a time)
const jobQueue: string[] = [];
let isProcessingQueue = false;
// Health check - verify Python script exists
export async function checkSpaceHealth(): Promise<boolean> {
try {
const { access } = await import('fs/promises');
await access(PYTHON_SCRIPT);
return true;
} catch {
return false;
}
}
// Discover endpoints (for compatibility)
export async function discoverEndpoints(): Promise<unknown> {
return { provider: 'acestep-local', endpoint: ACESTEP_API };
}
// Reset client (no-op for REST API)
export function resetClient(): void {
// No client to reset for REST API
}
// Process the job queue sequentially
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 };
}
async function processGeneration(
jobId: string,
params: GenerationParams,
job: ActiveJob
): Promise<void> {
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`, {
prompt: prompt.slice(0, 50),
lyricsPreview: lyrics.slice(0, 50),
duration: params.duration,
batchSize: params.batchSize,
});
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),
'--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',
];
// 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));
}
// 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/', ''));
}
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);
}
// 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) {
throw new Error(result.error || 'Generation failed');
}
if (!result.audio_paths || result.audio_paths.length === 0) {
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) {
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);
// Get actual audio duration from first file
if (audioUrls.length === 0) {
actualDuration = getAudioDuration(destPath);
}
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';
job.result = {
audioUrls,
duration: finalDuration,
bpm: params.bpm,
keyScale: params.keyScale,
timeSignature: params.timeSignature,
status: 'succeeded',
};
job.rawResponse = result;
console.log(`Job ${jobId}: Completed 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 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 });
} catch { /* ignore cleanup errors */ }
}
}
interface PythonResult {
success: boolean;
audio_paths?: string[];
elapsed_seconds?: number;
error?: string;
}
function runPythonGeneration(scriptArgs: string[]): Promise<PythonResult> {
return new Promise((resolve) => {
// Use the ACE-Step venv's Python directly
const pythonPath = path.join(ACESTEP_DIR, '.venv', 'bin', 'python');
const args = [PYTHON_SCRIPT, ...scriptArgs];
const proc = spawn(pythonPath, args, {
cwd: ACESTEP_DIR,
env: {
...process.env,
CUDA_VISIBLE_DEVICES: '0',
ACESTEP_PATH: ACESTEP_DIR,
},
});
let stdout = '';
let stderr = '';
proc.stdout.on('data', (data) => {
stdout += data.toString();
});
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()) {
console.log(`[ACE-Step] ${line}`);
}
}
});
proc.on('close', (code) => {
if (code !== 0) {
resolve({ success: false, error: stderr || `Process exited with code ${code}` });
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('{'));
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) => {
resolve({ success: false, error: err.message });
});
});
}
function extractAudioFiles(result: unknown): string[] {
const urls: string[] = [];
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<string, unknown>;
// 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<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);
// 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
};
}
return {
status: job.status,
etaSeconds: Math.max(0, 180 - elapsed), // 3 min estimate
};
}
// Get raw response for debugging
export function getJobRawResponse(jobId: string): unknown | null {
const job = activeJobs.get(jobId);
return job?.rawResponse || null;
}
// Get audio stream from local file or remote URL
export async function getAudioStream(audioPath: string): Promise<Response> {
// 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, {
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 });
}
}
// 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<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}`;
}
// Download audio to buffer
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 };
}
// 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) {
if (now - job.startTime > maxAgeMs) {
activeJobs.delete(jobId);
}
}
}