Initial commit: ACE-Step UI - Open source music generation interface
This commit is contained in:
@@ -0,0 +1,627 @@
|
||||
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,
|
||||
} 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/wav',
|
||||
'audio/x-wav',
|
||||
'audio/flac',
|
||||
'audio/x-flac',
|
||||
'audio/mp4',
|
||||
'audio/aac',
|
||||
'audio/ogg',
|
||||
'audio/webm',
|
||||
];
|
||||
if (allowedTypes.includes(file.mimetype)) {
|
||||
cb(null, true);
|
||||
} else {
|
||||
cb(new Error('Invalid file type. Only common audio formats are allowed.'));
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
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;
|
||||
|
||||
// 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;
|
||||
}
|
||||
|
||||
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/aac':
|
||||
return '.m4a';
|
||||
case 'audio/webm':
|
||||
return '.webm';
|
||||
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 = storage.getPublicUrl(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,
|
||||
referenceAudioUrl,
|
||||
sourceAudioUrl,
|
||||
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) {
|
||||
res.status(400).json({ error: 'Style or lyrics 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,
|
||||
referenceAudioUrl,
|
||||
sourceAudioUrl,
|
||||
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}`;
|
||||
const storedPath = await storage.upload(storageKey, buffer, `audio/${ext.slice(1)}`);
|
||||
|
||||
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,
|
||||
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,
|
||||
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('/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 || '/home/ambsd/Desktop/aceui/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 = path.join(ACESTEP_DIR, '.venv', 'bin', 'python');
|
||||
|
||||
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,
|
||||
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(); });
|
||||
|
||||
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;
|
||||
Reference in New Issue
Block a user