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 => { 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;