Files
ace-step-ui/server/src/routes/generate.ts
T
fspecii 58d00a23ed Merge PR #24: Various UI improvements from riversedge
Includes progress bar, drag and drop, gender buttons, upload
improvements, and dynamic duration limits.
2026-02-05 22:34:07 +02:00

709 lines
21 KiB
TypeScript

import { Router, Response } from 'express';
import multer from 'multer';
import path from 'path';
import { fileURLToPath } from 'url';
import { pool } from '../db/pool.js';
import { generateUUID } from '../db/sqlite.js';
import { authMiddleware, AuthenticatedRequest } from '../middleware/auth.js';
import {
generateMusicViaAPI,
getJobStatus,
getAudioStream,
discoverEndpoints,
checkSpaceHealth,
cleanupJob,
getJobRawResponse,
downloadAudioToBuffer,
resolvePythonPath,
} from '../services/acestep.js';
import { getStorageProvider } from '../services/storage/factory.js';
const router = Router();
const audioUpload = multer({
storage: multer.memoryStorage(),
limits: { fileSize: 25 * 1024 * 1024 }, // 25MB max
fileFilter: (_req, file, cb) => {
const allowedTypes = [
'audio/mpeg',
'audio/mp3', // Alternative MIME type for MP3
'audio/mpeg3',
'audio/x-mpeg-3',
'audio/wav',
'audio/x-wav',
'audio/flac',
'audio/x-flac',
'audio/mp4',
'audio/x-m4a',
'audio/aac',
'audio/ogg',
'audio/webm',
'video/mp4',
];
// Also check file extension as fallback
const allowedExtensions = ['.mp3', '.wav', '.flac', '.m4a', '.mp4', '.aac', '.ogg', '.webm', '.opus'];
const fileExt = file.originalname.toLowerCase().match(/\.[^.]+$/)?.[0];
if (allowedTypes.includes(file.mimetype) || (fileExt && allowedExtensions.includes(fileExt))) {
cb(null, true);
} else {
cb(new Error(`Invalid file type. Only common audio formats are allowed. Received: ${file.mimetype} (${file.originalname})`));
}
}
});
interface GenerateBody {
// 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;
lmBackend?: 'pt' | 'vllm';
// 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;
}
router.post('/upload-audio', authMiddleware, audioUpload.single('audio'), async (req: AuthenticatedRequest, res: Response) => {
try {
if (!req.file) {
res.status(400).json({ error: 'Audio file is required' });
return;
}
const storage = getStorageProvider();
const extFromName = path.extname(req.file.originalname || '').toLowerCase();
const extFromType = (() => {
switch (req.file.mimetype) {
case 'audio/mpeg':
return '.mp3';
case 'audio/wav':
case 'audio/x-wav':
return '.wav';
case 'audio/flac':
case 'audio/x-flac':
return '.flac';
case 'audio/ogg':
return '.ogg';
case 'audio/mp4':
case 'audio/x-m4a':
case 'audio/aac':
return '.m4a';
case 'audio/webm':
return '.webm';
case 'video/mp4':
return '.mp4';
default:
return '';
}
})();
const ext = extFromName || extFromType || '.audio';
const key = `references/${req.user!.id}/${Date.now()}-${generateUUID()}${ext}`;
const storedKey = await storage.upload(key, req.file.buffer, req.file.mimetype);
const publicUrl = storedKey;
res.json({ url: publicUrl, key: storedKey });
} catch (error) {
console.error('Upload reference audio error:', error);
res.status(500).json({ error: 'Failed to upload audio' });
}
});
router.post('/', authMiddleware, async (req: AuthenticatedRequest, res: Response) => {
try {
const {
customMode,
songDescription,
lyrics,
style,
title,
instrumental,
vocalLanguage,
duration,
bpm,
keyScale,
timeSignature,
inferenceSteps,
guidanceScale,
batchSize,
randomSeed,
seed,
thinking,
audioFormat,
inferMethod,
shift,
lmTemperature,
lmCfgScale,
lmTopK,
lmTopP,
lmNegativePrompt,
lmBackend,
referenceAudioUrl,
sourceAudioUrl,
referenceAudioTitle,
sourceAudioTitle,
audioCodes,
repaintingStart,
repaintingEnd,
instruction,
audioCoverStrength,
taskType,
useAdg,
cfgIntervalStart,
cfgIntervalEnd,
customTimesteps,
useCotMetas,
useCotCaption,
useCotLanguage,
autogen,
constrainedDecodingDebug,
allowLmBatch,
getScores,
getLrc,
scoreScale,
lmBatchChunkSize,
trackName,
completeTrackClasses,
isFormatCaption,
} = req.body as GenerateBody;
if (!customMode && !songDescription) {
res.status(400).json({ error: 'Song description required for simple mode' });
return;
}
if (customMode && !style && !lyrics && !referenceAudioUrl) {
res.status(400).json({ error: 'Style, lyrics, or reference audio required for custom mode' });
return;
}
const params = {
customMode,
songDescription,
lyrics,
style,
title,
instrumental,
vocalLanguage,
duration,
bpm,
keyScale,
timeSignature,
inferenceSteps,
guidanceScale,
batchSize,
randomSeed,
seed,
thinking,
audioFormat,
inferMethod,
shift,
lmTemperature,
lmCfgScale,
lmTopK,
lmTopP,
lmNegativePrompt,
lmBackend,
referenceAudioUrl,
sourceAudioUrl,
referenceAudioTitle,
sourceAudioTitle,
audioCodes,
repaintingStart,
repaintingEnd,
instruction,
audioCoverStrength,
taskType,
useAdg,
cfgIntervalStart,
cfgIntervalEnd,
customTimesteps,
useCotMetas,
useCotCaption,
useCotLanguage,
autogen,
constrainedDecodingDebug,
allowLmBatch,
getScores,
getLrc,
scoreScale,
lmBatchChunkSize,
trackName,
completeTrackClasses,
isFormatCaption,
};
// Create job record in database
const localJobId = generateUUID();
await pool.query(
`INSERT INTO generation_jobs (id, user_id, status, params, created_at, updated_at)
VALUES (?, ?, 'queued', ?, datetime('now'), datetime('now'))`,
[localJobId, req.user!.id, JSON.stringify(params)]
);
// Start generation
const { jobId: hfJobId } = await generateMusicViaAPI(params);
// Update job with ACE-Step task ID
await pool.query(
`UPDATE generation_jobs SET acestep_task_id = ?, status = 'running', updated_at = datetime('now') WHERE id = ?`,
[hfJobId, localJobId]
);
res.json({
jobId: localJobId,
status: 'queued',
queuePosition: 1,
});
} catch (error) {
console.error('Generate error:', error);
res.status(500).json({ error: (error as Error).message || 'Generation failed' });
}
});
router.get('/status/:jobId', authMiddleware, async (req: AuthenticatedRequest, res: Response) => {
try {
const jobResult = await pool.query(
`SELECT id, user_id, acestep_task_id, status, params, result, error, created_at
FROM generation_jobs
WHERE id = ?`,
[req.params.jobId]
);
if (jobResult.rows.length === 0) {
res.status(404).json({ error: 'Job not found' });
return;
}
const job = jobResult.rows[0];
if (job.user_id !== req.user!.id) {
res.status(403).json({ error: 'Access denied' });
return;
}
// If job is still running, check ACE-Step status
if (['pending', 'queued', 'running'].includes(job.status) && job.acestep_task_id) {
try {
const aceStatus = await getJobStatus(job.acestep_task_id);
if (aceStatus.status !== job.status) {
let updateQuery = `UPDATE generation_jobs SET status = ?, updated_at = datetime('now')`;
const updateParams: unknown[] = [aceStatus.status];
if (aceStatus.status === 'succeeded' && aceStatus.result) {
updateQuery += `, result = ?`;
updateParams.push(JSON.stringify(aceStatus.result));
} else if (aceStatus.status === 'failed' && aceStatus.error) {
updateQuery += `, error = ?`;
updateParams.push(aceStatus.error);
}
updateQuery += ` WHERE id = ?`;
updateParams.push(req.params.jobId);
await pool.query(updateQuery, updateParams);
// If succeeded, create song records
if (aceStatus.status === 'succeeded' && aceStatus.result) {
const params = typeof job.params === 'string' ? JSON.parse(job.params) : job.params;
const audioUrls = aceStatus.result.audioUrls.filter((url: string) =>
url.endsWith('.mp3') || url.endsWith('.flac')
);
const localPaths: string[] = [];
const storage = getStorageProvider();
for (let i = 0; i < audioUrls.length; i++) {
const audioUrl = audioUrls[i];
const variationSuffix = audioUrls.length > 1 ? ` (v${i + 1})` : '';
const songTitle = (params.title || 'Untitled') + variationSuffix;
const songId = generateUUID();
try {
const { buffer } = await downloadAudioToBuffer(audioUrl);
const ext = audioUrl.includes('.flac') ? '.flac' : '.mp3';
const storageKey = `${req.user!.id}/${songId}${ext}`;
await storage.upload(storageKey, buffer, `audio/${ext.slice(1)}`);
const storedPath = storage.getPublicUrl(storageKey);
await pool.query(
`INSERT INTO songs (id, user_id, title, lyrics, style, caption, audio_url,
duration, bpm, key_scale, time_signature, tags, is_public, generation_params,
created_at, updated_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 1, ?, datetime('now'), datetime('now'))`,
[
songId,
req.user!.id,
songTitle,
params.instrumental ? '[Instrumental]' : params.lyrics,
params.style,
params.style,
storedPath,
aceStatus.result.duration && aceStatus.result.duration > 0 ? aceStatus.result.duration : (params.duration && params.duration > 0 ? params.duration : 120),
aceStatus.result.bpm || params.bpm,
aceStatus.result.keyScale || params.keyScale,
aceStatus.result.timeSignature || params.timeSignature,
JSON.stringify([]),
JSON.stringify(params),
]
);
localPaths.push(storedPath);
} catch (downloadError) {
console.error(`Failed to download audio ${i + 1}:`, downloadError);
// Still create song record with remote URL
await pool.query(
`INSERT INTO songs (id, user_id, title, lyrics, style, caption, audio_url,
duration, bpm, key_scale, time_signature, tags, is_public, generation_params,
created_at, updated_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 1, ?, datetime('now'), datetime('now'))`,
[
songId,
req.user!.id,
songTitle,
params.instrumental ? '[Instrumental]' : params.lyrics,
params.style,
params.style,
audioUrl,
aceStatus.result.duration && aceStatus.result.duration > 0 ? aceStatus.result.duration : (params.duration && params.duration > 0 ? params.duration : 120),
aceStatus.result.bpm || params.bpm,
aceStatus.result.keyScale || params.keyScale,
aceStatus.result.timeSignature || params.timeSignature,
JSON.stringify([]),
JSON.stringify(params),
]
);
localPaths.push(audioUrl);
}
}
aceStatus.result.audioUrls = localPaths;
cleanupJob(job.acestep_task_id);
}
}
res.json({
jobId: req.params.jobId,
status: aceStatus.status,
queuePosition: aceStatus.queuePosition,
etaSeconds: aceStatus.etaSeconds,
progress: aceStatus.progress,
stage: aceStatus.stage,
result: aceStatus.result,
error: aceStatus.error,
});
return;
} catch (aceError) {
console.error('ACE-Step status check error:', aceError);
}
}
// Return stored status
res.json({
jobId: req.params.jobId,
status: job.status,
progress: undefined,
stage: undefined,
result: job.result && typeof job.result === 'string' ? JSON.parse(job.result) : job.result,
error: job.error,
});
} catch (error) {
console.error('Status check error:', error);
res.status(500).json({ error: 'Internal server error' });
}
});
// Audio proxy endpoint
router.get('/audio', async (req, res: Response) => {
try {
const audioPath = req.query.path as string;
if (!audioPath) {
res.status(400).json({ error: 'Path required' });
return;
}
const audioResponse = await getAudioStream(audioPath);
if (!audioResponse.ok) {
res.status(audioResponse.status).json({ error: 'Failed to fetch audio' });
return;
}
const contentType = audioResponse.headers.get('content-type');
if (contentType) {
res.setHeader('Content-Type', contentType);
}
const contentLength = audioResponse.headers.get('content-length');
if (contentLength) {
res.setHeader('Content-Length', contentLength);
}
const reader = audioResponse.body?.getReader();
if (!reader) {
res.status(500).json({ error: 'Failed to read audio stream' });
return;
}
const pump = async (): Promise<void> => {
const { done, value } = await reader.read();
if (done) {
res.end();
return;
}
res.write(value);
return pump();
};
await pump();
} catch (error) {
console.error('Audio proxy error:', error);
res.status(500).json({ error: 'Internal server error' });
}
});
router.get('/history', authMiddleware, async (req: AuthenticatedRequest, res: Response) => {
try {
const result = await pool.query(
`SELECT id, acestep_task_id, status, params, result, error, created_at
FROM generation_jobs
WHERE user_id = ?
ORDER BY created_at DESC
LIMIT 50`,
[req.user!.id]
);
res.json({ jobs: result.rows });
} catch (error) {
console.error('Get history error:', error);
res.status(500).json({ error: 'Internal server error' });
}
});
router.get('/endpoints', authMiddleware, async (_req: AuthenticatedRequest, res: Response) => {
try {
const endpoints = await discoverEndpoints();
res.json({ endpoints });
} catch (error) {
console.error('Discover endpoints error:', error);
res.status(500).json({ error: 'Failed to discover endpoints' });
}
});
router.get('/health', async (_req, res: Response) => {
try {
const healthy = await checkSpaceHealth();
res.json({ healthy });
} catch (error) {
res.json({ healthy: false, error: (error as Error).message });
}
});
router.get('/limits', async (_req, res: Response) => {
try {
const { spawn } = await import('child_process');
const ACESTEP_DIR = process.env.ACESTEP_PATH || path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../../../../ACE-Step-1.5');
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
const SCRIPTS_DIR = path.join(__dirname, '../../scripts');
const LIMITS_SCRIPT = path.join(SCRIPTS_DIR, 'get_limits.py');
const pythonPath = resolvePythonPath(ACESTEP_DIR);
const result = await new Promise<{ success: boolean; data?: any; error?: string }>((resolve) => {
const proc = spawn(pythonPath, [LIMITS_SCRIPT], {
cwd: ACESTEP_DIR,
env: {
...process.env,
ACESTEP_PATH: ACESTEP_DIR,
},
});
let stdout = '';
let stderr = '';
proc.stdout.on('data', (data) => { stdout += data.toString(); });
proc.stderr.on('data', (data) => { stderr += data.toString(); });
proc.on('close', (code) => {
if (code === 0 && stdout) {
try {
const parsed = JSON.parse(stdout);
resolve({ success: true, data: parsed });
} catch {
resolve({ success: false, error: 'Failed to parse limits result' });
}
} else {
resolve({ success: false, error: stderr || 'Failed to read limits' });
}
});
proc.on('error', (err) => {
resolve({ success: false, error: err.message });
});
});
if (result.success && result.data) {
res.json(result.data);
} else {
res.status(500).json({ error: result.error || 'Failed to load limits' });
}
} catch (error) {
console.error('Limits error:', error);
res.status(500).json({ error: (error as Error).message });
}
});
router.get('/debug/:taskId', authMiddleware, async (req: AuthenticatedRequest, res: Response) => {
try {
const rawResponse = getJobRawResponse(req.params.taskId);
if (!rawResponse) {
res.status(404).json({ error: 'Job not found or no raw response available' });
return;
}
res.json({ rawResponse });
} catch (error) {
res.status(500).json({ error: (error as Error).message });
}
});
// Format endpoint - uses LLM to enhance style/lyrics
router.post('/format', authMiddleware, async (req: AuthenticatedRequest, res: Response) => {
try {
const { caption, lyrics, bpm, duration, keyScale, timeSignature, temperature, topK, topP } = req.body;
if (!caption) {
res.status(400).json({ error: 'Caption/style is required' });
return;
}
const { spawn } = await import('child_process');
const ACESTEP_DIR = process.env.ACESTEP_PATH || path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../../../../ACE-Step-1.5');
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
const SCRIPTS_DIR = path.join(__dirname, '../../scripts');
const FORMAT_SCRIPT = path.join(SCRIPTS_DIR, 'format_sample.py');
const pythonPath = resolvePythonPath(ACESTEP_DIR);
const args = [
FORMAT_SCRIPT,
'--caption', caption,
'--json',
];
if (lyrics) args.push('--lyrics', lyrics);
if (bpm && bpm > 0) args.push('--bpm', String(bpm));
if (duration && duration > 0) args.push('--duration', String(duration));
if (keyScale) args.push('--key-scale', keyScale);
if (timeSignature) args.push('--time-signature', timeSignature);
if (temperature !== undefined) args.push('--temperature', String(temperature));
if (topK && topK > 0) args.push('--top-k', String(topK));
if (topP !== undefined) args.push('--top-p', String(topP));
const result = await new Promise<{ success: boolean; data?: any; error?: string }>((resolve) => {
const proc = spawn(pythonPath, args, {
cwd: ACESTEP_DIR,
env: {
...process.env,
ACESTEP_PATH: ACESTEP_DIR,
},
});
let stdout = '';
let stderr = '';
proc.stdout.on('data', (data) => { stdout += data.toString(); });
proc.stderr.on('data', (data) => { stderr += data.toString(); });
proc.on('close', (code) => {
if (code === 0 && stdout) {
try {
const parsed = JSON.parse(stdout);
resolve({ success: true, data: parsed });
} catch {
resolve({ success: false, error: 'Failed to parse format result' });
}
} else {
resolve({ success: false, error: stderr || 'Format failed' });
}
});
proc.on('error', (err) => {
resolve({ success: false, error: err.message });
});
});
if (result.success && result.data) {
res.json(result.data);
} else {
res.status(500).json({ success: false, error: result.error });
}
} catch (error) {
console.error('Format error:', error);
res.status(500).json({ error: (error as Error).message });
}
});
export default router;