Phase 6: Replace REST polling with @gradio/client for generation and LoRA
- Add @gradio/client dependency for direct Gradio API communication - Create gradio-client.ts: singleton client with lazy init and reconnection - Rewrite acestep.ts: generation via /generation_wrapper (45 params), Python spawn fallback - Remove REST polling code (submitToApi, pollApiResult, downloadAudioFromApi) - Create lora.ts routes: load/unload/scale/toggle/status via Gradio events - Register /api/lora routes in index.ts
This commit is contained in:
Generated
+19
@@ -8,6 +8,7 @@
|
|||||||
"name": "ace-step-ui-server",
|
"name": "ace-step-ui-server",
|
||||||
"version": "1.0.0",
|
"version": "1.0.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@gradio/client": "^2.0.4",
|
||||||
"@types/multer": "^2.0.0",
|
"@types/multer": "^2.0.0",
|
||||||
"better-sqlite3": "^11.0.0",
|
"better-sqlite3": "^11.0.0",
|
||||||
"cors": "^2.8.5",
|
"cors": "^2.8.5",
|
||||||
@@ -472,6 +473,18 @@
|
|||||||
"node": ">=18"
|
"node": ">=18"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@gradio/client": {
|
||||||
|
"version": "2.0.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/@gradio/client/-/client-2.0.4.tgz",
|
||||||
|
"integrity": "sha512-pYywxUpamTYQLca01YAlTBwr2eb3H8d9s7geeQdexuPkM7p+EN2LhyzgNRN4yZrpn4swwfwP1fOoadKgd1b6Ww==",
|
||||||
|
"license": "ISC",
|
||||||
|
"dependencies": {
|
||||||
|
"fetch-event-stream": "^0.1.5"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@types/better-sqlite3": {
|
"node_modules/@types/better-sqlite3": {
|
||||||
"version": "7.6.13",
|
"version": "7.6.13",
|
||||||
"resolved": "https://registry.npmjs.org/@types/better-sqlite3/-/better-sqlite3-7.6.13.tgz",
|
"resolved": "https://registry.npmjs.org/@types/better-sqlite3/-/better-sqlite3-7.6.13.tgz",
|
||||||
@@ -1160,6 +1173,12 @@
|
|||||||
"url": "https://opencollective.com/express"
|
"url": "https://opencollective.com/express"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/fetch-event-stream": {
|
||||||
|
"version": "0.1.6",
|
||||||
|
"resolved": "https://registry.npmjs.org/fetch-event-stream/-/fetch-event-stream-0.1.6.tgz",
|
||||||
|
"integrity": "sha512-GREtJ5HNikdU2AXtZ6E/5bk+aslMU6ie5mPG6H9nvsdDkkHQ6m5lHwmmmDTOBexok9hApQ7EprsXCdmz9ZC68w==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/file-uri-to-path": {
|
"node_modules/file-uri-to-path": {
|
||||||
"version": "1.0.0",
|
"version": "1.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/file-uri-to-path/-/file-uri-to-path-1.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/file-uri-to-path/-/file-uri-to-path-1.0.0.tgz",
|
||||||
|
|||||||
@@ -10,6 +10,7 @@
|
|||||||
"storage:test": "tsx src/scripts/test-storage.ts"
|
"storage:test": "tsx src/scripts/test-storage.ts"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@gradio/client": "^2.0.4",
|
||||||
"@types/multer": "^2.0.0",
|
"@types/multer": "^2.0.0",
|
||||||
"better-sqlite3": "^11.0.0",
|
"better-sqlite3": "^11.0.0",
|
||||||
"cors": "^2.8.5",
|
"cors": "^2.8.5",
|
||||||
|
|||||||
@@ -23,6 +23,7 @@ import usersRoutes from './routes/users.js';
|
|||||||
import playlistsRoutes from './routes/playlists.js';
|
import playlistsRoutes from './routes/playlists.js';
|
||||||
import contactRoutes from './routes/contact.js';
|
import contactRoutes from './routes/contact.js';
|
||||||
import referenceTrackRoutes from './routes/referenceTrack.js';
|
import referenceTrackRoutes from './routes/referenceTrack.js';
|
||||||
|
import loraRoutes from './routes/lora.js';
|
||||||
import { pool } from './db/pool.js';
|
import { pool } from './db/pool.js';
|
||||||
import './db/migrate.js';
|
import './db/migrate.js';
|
||||||
|
|
||||||
@@ -403,6 +404,7 @@ app.use('/api/users', usersRoutes);
|
|||||||
app.use('/api/playlists', playlistsRoutes);
|
app.use('/api/playlists', playlistsRoutes);
|
||||||
app.use('/api/contact', contactRoutes);
|
app.use('/api/contact', contactRoutes);
|
||||||
app.use('/api/reference-tracks', referenceTrackRoutes);
|
app.use('/api/reference-tracks', referenceTrackRoutes);
|
||||||
|
app.use('/api/lora', loraRoutes);
|
||||||
|
|
||||||
// Error handler
|
// Error handler
|
||||||
app.use((err: Error, _req: express.Request, res: express.Response, _next: express.NextFunction) => {
|
app.use((err: Error, _req: express.Request, res: express.Response, _next: express.NextFunction) => {
|
||||||
|
|||||||
@@ -0,0 +1,99 @@
|
|||||||
|
import { Router, Response } from 'express';
|
||||||
|
import { authMiddleware, AuthenticatedRequest } from '../middleware/auth.js';
|
||||||
|
import { getGradioClient } from '../services/gradio-client.js';
|
||||||
|
|
||||||
|
const router = Router();
|
||||||
|
|
||||||
|
// Local LoRA state tracking (Gradio doesn't have a dedicated status endpoint)
|
||||||
|
let loraState = {
|
||||||
|
loaded: false,
|
||||||
|
active: false,
|
||||||
|
scale: 1.0,
|
||||||
|
path: '',
|
||||||
|
};
|
||||||
|
|
||||||
|
// POST /api/lora/load — Load a LoRA adapter
|
||||||
|
router.post('/load', authMiddleware, async (req: AuthenticatedRequest, res: Response) => {
|
||||||
|
try {
|
||||||
|
const { lora_path } = req.body;
|
||||||
|
if (!lora_path || typeof lora_path !== 'string') {
|
||||||
|
res.status(400).json({ error: 'lora_path is required' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const client = await getGradioClient();
|
||||||
|
const result = await client.predict('/load_lora', [lora_path]);
|
||||||
|
const status = (result.data as unknown[])[0] as string;
|
||||||
|
|
||||||
|
loraState = { loaded: true, active: true, scale: loraState.scale, path: lora_path };
|
||||||
|
|
||||||
|
res.json({ message: status, lora_path, loaded: true });
|
||||||
|
} catch (error) {
|
||||||
|
console.error('[LoRA] Load error:', error);
|
||||||
|
res.status(500).json({ error: error instanceof Error ? error.message : 'Failed to load LoRA' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// POST /api/lora/unload — Unload the current LoRA adapter
|
||||||
|
router.post('/unload', authMiddleware, async (_req: AuthenticatedRequest, res: Response) => {
|
||||||
|
try {
|
||||||
|
const client = await getGradioClient();
|
||||||
|
const result = await client.predict('/unload_lora', []);
|
||||||
|
const status = (result.data as unknown[])[0] as string;
|
||||||
|
|
||||||
|
loraState = { loaded: false, active: false, scale: 1.0, path: '' };
|
||||||
|
|
||||||
|
res.json({ message: status });
|
||||||
|
} catch (error) {
|
||||||
|
console.error('[LoRA] Unload error:', error);
|
||||||
|
res.status(500).json({ error: error instanceof Error ? error.message : 'Failed to unload LoRA' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// POST /api/lora/scale — Set LoRA scale (0.0 - 1.0)
|
||||||
|
router.post('/scale', authMiddleware, async (req: AuthenticatedRequest, res: Response) => {
|
||||||
|
try {
|
||||||
|
const { scale } = req.body;
|
||||||
|
if (typeof scale !== 'number' || scale < 0 || scale > 1) {
|
||||||
|
res.status(400).json({ error: 'scale must be a number between 0 and 1' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const client = await getGradioClient();
|
||||||
|
const result = await client.predict('/set_lora_scale', [scale]);
|
||||||
|
const status = (result.data as unknown[])[0] as string;
|
||||||
|
|
||||||
|
loraState.scale = scale;
|
||||||
|
|
||||||
|
res.json({ message: status, scale });
|
||||||
|
} catch (error) {
|
||||||
|
console.error('[LoRA] Scale error:', error);
|
||||||
|
res.status(500).json({ error: error instanceof Error ? error.message : 'Failed to set LoRA scale' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// POST /api/lora/toggle — Toggle LoRA on/off
|
||||||
|
router.post('/toggle', authMiddleware, async (req: AuthenticatedRequest, res: Response) => {
|
||||||
|
try {
|
||||||
|
const { enabled } = req.body;
|
||||||
|
const useLoRA = typeof enabled === 'boolean' ? enabled : !loraState.active;
|
||||||
|
|
||||||
|
const client = await getGradioClient();
|
||||||
|
const result = await client.predict('/set_use_lora', [useLoRA]);
|
||||||
|
const status = (result.data as unknown[])[0] as string;
|
||||||
|
|
||||||
|
loraState.active = useLoRA;
|
||||||
|
|
||||||
|
res.json({ message: status, active: useLoRA });
|
||||||
|
} catch (error) {
|
||||||
|
console.error('[LoRA] Toggle error:', error);
|
||||||
|
res.status(500).json({ error: error instanceof Error ? error.message : 'Failed to toggle LoRA' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// GET /api/lora/status — Get current LoRA state
|
||||||
|
router.get('/status', authMiddleware, async (_req: AuthenticatedRequest, res: Response) => {
|
||||||
|
res.json(loraState);
|
||||||
|
});
|
||||||
|
|
||||||
|
export default router;
|
||||||
+316
-468
@@ -1,8 +1,8 @@
|
|||||||
import { writeFile, mkdir, copyFile, rm, stat, access } from 'fs/promises';
|
import { writeFile, mkdir, copyFile, rm, readFile } from 'fs/promises';
|
||||||
import { spawn, execSync } from 'child_process';
|
import { spawn, execSync } from 'child_process';
|
||||||
import { existsSync, createWriteStream } from 'fs';
|
import { existsSync } from 'fs';
|
||||||
import path from 'path';
|
import path from 'path';
|
||||||
import { pipeline } from 'stream/promises';
|
import { handle_file } from '@gradio/client';
|
||||||
|
|
||||||
// Get audio duration using ffprobe
|
// Get audio duration using ffprobe
|
||||||
function getAudioDuration(filePath: string): number {
|
function getAudioDuration(filePath: string): number {
|
||||||
@@ -20,6 +20,7 @@ function getAudioDuration(filePath: string): number {
|
|||||||
}
|
}
|
||||||
import { fileURLToPath } from 'url';
|
import { fileURLToPath } from 'url';
|
||||||
import { config } from '../config/index.js';
|
import { config } from '../config/index.js';
|
||||||
|
import { getGradioClient, resetGradioClient, isGradioAvailable } from './gradio-client.js';
|
||||||
|
|
||||||
const __filename = fileURLToPath(import.meta.url);
|
const __filename = fileURLToPath(import.meta.url);
|
||||||
const __dirname = path.dirname(__filename);
|
const __dirname = path.dirname(__filename);
|
||||||
@@ -64,291 +65,150 @@ const ACESTEP_DIR = resolveAceStepPath();
|
|||||||
const SCRIPTS_DIR = path.join(__dirname, '../../scripts');
|
const SCRIPTS_DIR = path.join(__dirname, '../../scripts');
|
||||||
const PYTHON_SCRIPT = path.join(SCRIPTS_DIR, 'simple_generate.py');
|
const PYTHON_SCRIPT = path.join(SCRIPTS_DIR, 'simple_generate.py');
|
||||||
|
|
||||||
// Cache API availability status (check once, remember for session)
|
// ---------------------------------------------------------------------------
|
||||||
let apiAvailableCache: boolean | null = null;
|
// Gradio generation: map params to the 45 positional args for /generation_wrapper
|
||||||
let apiCheckPromise: Promise<boolean> | null = null;
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
// Check if ACE-Step API is running
|
/**
|
||||||
async function isApiAvailable(): Promise<boolean> {
|
* Resolve an audio URL (e.g. /audio/file.mp3) to an absolute local file path.
|
||||||
// Return cached result if available
|
*/
|
||||||
if (apiAvailableCache !== null) {
|
function resolveAudioPath(audioUrl: string): string {
|
||||||
return apiAvailableCache;
|
if (audioUrl.startsWith('/audio/')) {
|
||||||
|
return path.join(AUDIO_DIR, audioUrl.replace('/audio/', ''));
|
||||||
}
|
}
|
||||||
|
if (audioUrl.startsWith('http')) {
|
||||||
// Prevent multiple concurrent checks
|
|
||||||
if (apiCheckPromise) {
|
|
||||||
return apiCheckPromise;
|
|
||||||
}
|
|
||||||
|
|
||||||
apiCheckPromise = (async () => {
|
|
||||||
try {
|
try {
|
||||||
const controller = new AbortController();
|
const parsed = new URL(audioUrl);
|
||||||
const timeout = setTimeout(() => controller.abort(), 3000);
|
if (parsed.pathname.startsWith('/audio/')) {
|
||||||
|
return path.join(AUDIO_DIR, parsed.pathname.replace('/audio/', ''));
|
||||||
const response = await fetch(`${ACESTEP_API}/health`, {
|
|
||||||
signal: controller.signal,
|
|
||||||
});
|
|
||||||
clearTimeout(timeout);
|
|
||||||
|
|
||||||
if (response.ok) {
|
|
||||||
const data = await response.json();
|
|
||||||
apiAvailableCache = data.status === 'ok' || data.healthy === true || data.data?.status === 'ok';
|
|
||||||
console.log(`[ACE-Step] API available at ${ACESTEP_API}: ${apiAvailableCache}`);
|
|
||||||
return apiAvailableCache;
|
|
||||||
}
|
}
|
||||||
apiAvailableCache = false;
|
} catch { /* fall through */ }
|
||||||
return false;
|
}
|
||||||
} catch (error) {
|
return audioUrl;
|
||||||
console.log(`[ACE-Step] API not available at ${ACESTEP_API}, will use Python spawn`);
|
}
|
||||||
apiAvailableCache = false;
|
|
||||||
return false;
|
/**
|
||||||
} finally {
|
* Prepare a local audio file for Gradio upload.
|
||||||
apiCheckPromise = null;
|
* 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 mimeType = ext === '.flac' ? 'audio/flac' : ext === '.wav' ? 'audio/wav' : '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;
|
||||||
|
}
|
||||||
return apiCheckPromise;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Reset API cache (useful if API starts/stops)
|
/**
|
||||||
export function resetApiCache(): void {
|
* Build the 45 positional arguments for the Gradio /generation_wrapper endpoint.
|
||||||
apiAvailableCache = null;
|
*/
|
||||||
apiCheckPromise = null;
|
async function buildGradioArgs(params: GenerationParams): Promise<unknown[]> {
|
||||||
}
|
|
||||||
|
|
||||||
// Submit generation job to ACE-Step API
|
|
||||||
async function submitToApi(params: GenerationParams): Promise<{ taskId: string }> {
|
|
||||||
const caption = params.style || 'pop music';
|
const caption = params.style || 'pop music';
|
||||||
const prompt = params.customMode ? caption : (params.songDescription || caption);
|
const prompt = params.customMode ? caption : (params.songDescription || caption);
|
||||||
const lyrics = params.instrumental ? '' : (params.lyrics || '');
|
const lyrics = params.instrumental ? '' : (params.lyrics || '');
|
||||||
|
const isThinking = params.thinking ?? false;
|
||||||
|
|
||||||
const body: Record<string, unknown> = {
|
// Prepare audio files (async — reads from disk)
|
||||||
prompt,
|
const referenceAudio = await prepareAudioFile(params.referenceAudioUrl);
|
||||||
lyrics,
|
const sourceAudio = await prepareAudioFile(params.sourceAudioUrl);
|
||||||
batch_size: params.batchSize ?? 1,
|
|
||||||
inference_steps: params.inferenceSteps ?? 8,
|
|
||||||
guidance_scale: params.guidanceScale ?? 10.0,
|
|
||||||
audio_format: params.audioFormat ?? 'mp3',
|
|
||||||
vocal_language: params.vocalLanguage || 'en',
|
|
||||||
use_random_seed: params.randomSeed !== false,
|
|
||||||
shift: params.shift ?? 3.0,
|
|
||||||
thinking: params.thinking ?? false, // Respect frontend choice, default false for GPU compatibility
|
|
||||||
use_cot_caption: false, // Explicitly disable CoT features that require LLM
|
|
||||||
use_cot_language: false, // Explicitly disable CoT features that require LLM
|
|
||||||
use_cot_metas: false, // Explicitly disable CoT features that require LLM
|
|
||||||
lm_backend: params.lmBackend || 'pt',
|
|
||||||
lm_model_path: params.lmModel || undefined,
|
|
||||||
};
|
|
||||||
|
|
||||||
if (params.duration && params.duration > 0) body.audio_duration = params.duration;
|
return [
|
||||||
if (params.bpm && params.bpm > 0) body.bpm = params.bpm;
|
prompt, // 0: Music Caption
|
||||||
if (params.keyScale) body.key_scale = params.keyScale;
|
lyrics, // 1: Lyrics
|
||||||
if (params.timeSignature) body.time_signature = params.timeSignature;
|
params.bpm && params.bpm > 0 ? params.bpm : 0, // 2: BPM (0 = auto)
|
||||||
if (params.seed !== undefined && params.seed >= 0 && !params.randomSeed) {
|
params.keyScale || '', // 3: KeyScale
|
||||||
body.seed = params.seed;
|
params.timeSignature || '', // 4: Time Signature
|
||||||
body.use_random_seed = false;
|
params.vocalLanguage || 'en', // 5: Vocal Language
|
||||||
}
|
params.inferenceSteps ?? 8, // 6: DiT Inference Steps
|
||||||
if (params.taskType && params.taskType !== 'text2music') body.task_type = params.taskType;
|
params.guidanceScale ?? 7.0, // 7: DiT Guidance Scale
|
||||||
if (params.audioCodes) body.audio_code_string = params.audioCodes;
|
params.randomSeed !== false, // 8: Random Seed
|
||||||
if (params.repaintingStart !== undefined && params.repaintingStart > 0) body.repainting_start = params.repaintingStart;
|
String(params.seed ?? -1), // 9: Seed
|
||||||
if (params.repaintingEnd !== undefined && params.repaintingEnd > 0) body.repainting_end = params.repaintingEnd;
|
referenceAudio, // 10: Reference Audio (filepath | null)
|
||||||
// Always send audio_cover_strength for cover/repaint tasks, otherwise only when not default
|
params.duration && params.duration > 0 ? params.duration : -1, // 11: Audio Duration (-1 = auto)
|
||||||
if (params.taskType === 'cover' || params.taskType === 'repaint' || params.sourceAudioUrl) {
|
params.batchSize ?? 1, // 12: Batch Size
|
||||||
body.audio_cover_strength = params.audioCoverStrength ?? 1.0;
|
sourceAudio, // 13: Source Audio (filepath | null)
|
||||||
} else if (params.audioCoverStrength !== undefined && params.audioCoverStrength !== 1.0) {
|
params.audioCodes || '', // 14: LM Codes Hints
|
||||||
body.audio_cover_strength = params.audioCoverStrength;
|
params.repaintingStart ?? 0.0, // 15: Repainting Start
|
||||||
}
|
params.repaintingEnd ?? -1, // 16: Repainting End
|
||||||
if (params.instruction) body.instruction = params.instruction;
|
params.instruction || 'Fill the audio semantic mask with the style described in the text prompt.', // 17: Instruction
|
||||||
// LLM and CoT parameters only sent when thinking mode is enabled
|
params.audioCoverStrength ?? 1.0, // 18: LM Codes Strength
|
||||||
if (params.thinking) {
|
params.taskType || 'text2music', // 19: Task Type
|
||||||
if (params.lmTemperature !== undefined) body.lm_temperature = params.lmTemperature;
|
params.useAdg ?? false, // 20: Use ADG
|
||||||
if (params.lmCfgScale !== undefined) body.lm_cfg_scale = params.lmCfgScale;
|
params.cfgIntervalStart ?? 0.0, // 21: CFG Interval Start
|
||||||
if (params.lmTopK !== undefined && params.lmTopK > 0) body.lm_top_k = params.lmTopK;
|
params.cfgIntervalEnd ?? 1.0, // 22: CFG Interval End
|
||||||
if (params.lmTopP !== undefined) body.lm_top_p = params.lmTopP;
|
params.shift ?? 3.0, // 23: Shift
|
||||||
if (params.useCotCaption !== undefined) body.use_cot_caption = params.useCotCaption;
|
params.inferMethod || 'ode', // 24: Inference Method
|
||||||
if (params.useCotLanguage !== undefined) body.use_cot_language = params.useCotLanguage;
|
params.customTimesteps || '', // 25: Custom Timesteps
|
||||||
if (params.useCotMetas !== undefined) body.use_cot_metas = params.useCotMetas;
|
params.audioFormat || 'mp3', // 26: Audio Format
|
||||||
}
|
params.lmTemperature ?? 0.85, // 27: LM Temperature
|
||||||
if (params.useAdg) body.use_adg = true;
|
isThinking, // 28: Think
|
||||||
if (params.cfgIntervalStart !== undefined && params.cfgIntervalStart > 0) body.cfg_interval_start = params.cfgIntervalStart;
|
params.lmCfgScale ?? 2.0, // 29: LM CFG Scale
|
||||||
if (params.cfgIntervalEnd !== undefined && params.cfgIntervalEnd < 1.0) body.cfg_interval_end = params.cfgIntervalEnd;
|
params.lmTopK ?? 0, // 30: LM Top-K
|
||||||
|
params.lmTopP ?? 0.9, // 31: LM Top-P
|
||||||
const resolveAudioPath = (audioUrl: string): string => {
|
params.lmNegativePrompt || 'NO USER INPUT', // 32: LM Negative Prompt
|
||||||
if (audioUrl.startsWith('/audio/')) {
|
isThinking ? (params.useCotMetas ?? true) : false, // 33: CoT Metas
|
||||||
return path.join(AUDIO_DIR, audioUrl.replace('/audio/', ''));
|
isThinking ? (params.useCotCaption ?? true) : false, // 34: CaptionRewrite
|
||||||
}
|
isThinking ? (params.useCotLanguage ?? true) : false, // 35: CoT Language
|
||||||
if (audioUrl.startsWith('http')) {
|
params.constrainedDecodingDebug ?? false, // 36: Constrained Decoding Debug
|
||||||
try {
|
params.allowLmBatch ?? true, // 37: ParallelThinking
|
||||||
const parsed = new URL(audioUrl);
|
params.getScores ?? false, // 38: Auto Score
|
||||||
if (parsed.pathname.startsWith('/audio/')) {
|
params.getLrc ?? false, // 39: Auto LRC
|
||||||
return path.join(AUDIO_DIR, parsed.pathname.replace('/audio/', ''));
|
params.scoreScale ?? 0.5, // 40: Quality Score Sensitivity
|
||||||
}
|
params.lmBatchChunkSize ?? 8, // 41: LM Batch Chunk Size
|
||||||
} catch {
|
params.trackName || '', // 42: Track Name
|
||||||
// fall through
|
params.completeTrackClasses || [], // 43: Track Names
|
||||||
}
|
params.autogen ?? false, // 44: AutoGen
|
||||||
}
|
];
|
||||||
return audioUrl;
|
|
||||||
};
|
|
||||||
|
|
||||||
// Guard: cover/audio2audio requires a source or audio codes
|
|
||||||
if ((params.taskType === 'cover' || params.taskType === 'audio2audio') && !params.sourceAudioUrl && !params.audioCodes) {
|
|
||||||
throw new Error(`task_type='${params.taskType}' requires a source audio or audio codes`);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Handle reference audio - need to pass file path
|
|
||||||
if (params.referenceAudioUrl) {
|
|
||||||
body.reference_audio_path = resolveAudioPath(params.referenceAudioUrl);
|
|
||||||
}
|
|
||||||
if (params.sourceAudioUrl) {
|
|
||||||
body.src_audio_path = resolveAudioPath(params.sourceAudioUrl);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (params.taskType === 'cover' || params.taskType === 'audio2audio') {
|
|
||||||
console.log(`[ACE-Step] cover/audio2audio inputs`, {
|
|
||||||
reference_audio_path: body.reference_audio_path,
|
|
||||||
src_audio_path: body.src_audio_path,
|
|
||||||
has_audio_codes: Boolean(params.audioCodes),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
const response = await fetch(`${ACESTEP_API}/release_task`, {
|
|
||||||
method: 'POST',
|
|
||||||
headers: { 'Content-Type': 'application/json' },
|
|
||||||
body: JSON.stringify(body),
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!response.ok) {
|
|
||||||
const errorText = await response.text();
|
|
||||||
throw new Error(`API error: ${response.status} - ${errorText}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
const result = await response.json();
|
|
||||||
const taskId = result.data?.task_id || result.data?.job_id || result.job_id || result.task_id;
|
|
||||||
if (!taskId) {
|
|
||||||
throw new Error('No task ID returned from API');
|
|
||||||
}
|
|
||||||
|
|
||||||
return { taskId };
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Poll API for job result
|
/**
|
||||||
interface ApiTaskResult {
|
* Download a Gradio audio result file to local storage.
|
||||||
status: number; // 0 = processing, 1 = done, 2 = failed
|
* Gradio returns file objects with { url, path, orig_name, ... }.
|
||||||
audioPaths: string[];
|
* We copy from the server-local path (same machine) or download via URL.
|
||||||
metas?: {
|
*/
|
||||||
bpm?: number;
|
async function downloadGradioAudioFile(
|
||||||
duration?: number;
|
fileObj: { url?: string; path?: string; orig_name?: string },
|
||||||
genres?: string;
|
destPath: string,
|
||||||
keyscale?: string;
|
): Promise<void> {
|
||||||
timesignature?: string;
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
async function pollApiResult(taskId: string, maxWaitMs = 600000): Promise<ApiTaskResult> {
|
|
||||||
const startTime = Date.now();
|
|
||||||
const pollInterval = 2000; // 2 seconds
|
|
||||||
|
|
||||||
while (Date.now() - startTime < maxWaitMs) {
|
|
||||||
const response = await fetch(`${ACESTEP_API}/query_result`, {
|
|
||||||
method: 'POST',
|
|
||||||
headers: { 'Content-Type': 'application/json' },
|
|
||||||
body: JSON.stringify({ task_id_list: [taskId] }),
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!response.ok) {
|
|
||||||
throw new Error(`API poll error: ${response.status}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
const result = await response.json();
|
|
||||||
const taskData = result.data?.[0];
|
|
||||||
|
|
||||||
if (!taskData) {
|
|
||||||
await new Promise(resolve => setTimeout(resolve, pollInterval));
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Status: 0 = processing, 1 = done, 2 = failed
|
|
||||||
if (taskData.status === 1) {
|
|
||||||
// Parse result JSON
|
|
||||||
let resultData;
|
|
||||||
try {
|
|
||||||
resultData = typeof taskData.result === 'string' ? JSON.parse(taskData.result) : taskData.result;
|
|
||||||
} catch {
|
|
||||||
resultData = [];
|
|
||||||
}
|
|
||||||
|
|
||||||
const audioPaths = Array.isArray(resultData)
|
|
||||||
? resultData.map((r: { file?: string }) => r.file).filter(Boolean)
|
|
||||||
: [];
|
|
||||||
const metas = resultData[0]?.metas;
|
|
||||||
|
|
||||||
return { status: 1, audioPaths, metas };
|
|
||||||
} else if (taskData.status === 2) {
|
|
||||||
const details = taskData.error
|
|
||||||
|| taskData.message
|
|
||||||
|| taskData.status_message
|
|
||||||
|| taskData.result
|
|
||||||
|| JSON.stringify(taskData);
|
|
||||||
throw new Error(`Generation failed on API side: ${details}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Log progress while processing (if provided)
|
|
||||||
if (taskData.result) {
|
|
||||||
try {
|
|
||||||
const resultData = typeof taskData.result === 'string' ? JSON.parse(taskData.result) : taskData.result;
|
|
||||||
const item = Array.isArray(resultData) ? resultData[0] : resultData;
|
|
||||||
if (item && typeof item === 'object' && typeof (item as any).progress === 'number') {
|
|
||||||
const pct = Math.round((item as any).progress * 100);
|
|
||||||
console.log(`[ACE-Step] API task ${taskId} progress: ${pct}%`);
|
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
// ignore parse failures
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Still processing
|
|
||||||
await new Promise(resolve => setTimeout(resolve, pollInterval));
|
|
||||||
}
|
|
||||||
|
|
||||||
throw new Error('API generation timeout');
|
|
||||||
}
|
|
||||||
|
|
||||||
// Download audio from API
|
|
||||||
async function downloadAudioFromApi(audioPath: string, destPath: string): Promise<void> {
|
|
||||||
// Check if audioPath is already a relative URL (starts with /v1/audio)
|
|
||||||
const url = audioPath.startsWith('/v1/audio')
|
|
||||||
? `${ACESTEP_API}${audioPath}`
|
|
||||||
: `${ACESTEP_API}/v1/audio?path=${encodeURIComponent(audioPath)}`;
|
|
||||||
const response = await fetch(url);
|
|
||||||
|
|
||||||
if (!response.ok) {
|
|
||||||
throw new Error(`Failed to download audio: ${response.status}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
const body = response.body;
|
|
||||||
if (!body) {
|
|
||||||
throw new Error('No response body');
|
|
||||||
}
|
|
||||||
|
|
||||||
await mkdir(path.dirname(destPath), { recursive: true });
|
await mkdir(path.dirname(destPath), { recursive: true });
|
||||||
const fileStream = createWriteStream(destPath);
|
|
||||||
|
|
||||||
// Convert web ReadableStream to Node stream
|
// Prefer direct filesystem copy (both servers on same machine)
|
||||||
const reader = body.getReader();
|
if (fileObj.path && existsSync(fileObj.path)) {
|
||||||
const nodeStream = new (await import('stream')).Readable({
|
await copyFile(fileObj.path, destPath);
|
||||||
async read() {
|
return;
|
||||||
const { done, value } = await reader.read();
|
}
|
||||||
if (done) {
|
|
||||||
this.push(null);
|
// Fall back to HTTP download via Gradio URL
|
||||||
} else {
|
if (fileObj.url) {
|
||||||
this.push(Buffer.from(value));
|
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());
|
||||||
|
await writeFile(destPath, buffer);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
await pipeline(nodeStream, fileStream);
|
throw new Error('Gradio file object has neither path nor url');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Generation types & interfaces (unchanged public API)
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
export interface GenerationParams {
|
export interface GenerationParams {
|
||||||
// Mode
|
// Mode
|
||||||
customMode: boolean;
|
customMode: boolean;
|
||||||
@@ -419,6 +279,9 @@ export interface GenerationParams {
|
|||||||
trackName?: string;
|
trackName?: string;
|
||||||
completeTrackClasses?: string[];
|
completeTrackClasses?: string[];
|
||||||
isFormatCaption?: boolean;
|
isFormatCaption?: boolean;
|
||||||
|
|
||||||
|
// Model selection
|
||||||
|
ditModel?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface GenerationResult {
|
interface GenerationResult {
|
||||||
@@ -460,28 +323,25 @@ const activeJobs = new Map<string, ActiveJob>();
|
|||||||
const jobQueue: string[] = [];
|
const jobQueue: string[] = [];
|
||||||
let isProcessingQueue = false;
|
let isProcessingQueue = false;
|
||||||
|
|
||||||
// Health check - verify Python script exists
|
// Health check - verify Gradio app is reachable
|
||||||
export async function checkSpaceHealth(): Promise<boolean> {
|
export async function checkSpaceHealth(): Promise<boolean> {
|
||||||
try {
|
return isGradioAvailable();
|
||||||
const { access } = await import('fs/promises');
|
|
||||||
await access(PYTHON_SCRIPT);
|
|
||||||
return true;
|
|
||||||
} catch {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Discover endpoints (for compatibility)
|
// Discover endpoints (for compatibility)
|
||||||
export async function discoverEndpoints(): Promise<unknown> {
|
export async function discoverEndpoints(): Promise<unknown> {
|
||||||
return { provider: 'acestep-local', endpoint: ACESTEP_API };
|
return { provider: 'acestep-gradio', endpoint: ACESTEP_API };
|
||||||
}
|
}
|
||||||
|
|
||||||
// Reset client (no-op for REST API)
|
// Reset client — forces Gradio reconnection on next request
|
||||||
export function resetClient(): void {
|
export function resetClient(): void {
|
||||||
// No client to reset for REST API
|
resetGradioClient();
|
||||||
}
|
}
|
||||||
|
|
||||||
// Process the job queue sequentially
|
// ---------------------------------------------------------------------------
|
||||||
|
// Job queue
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
async function processQueue(): Promise<void> {
|
async function processQueue(): Promise<void> {
|
||||||
if (isProcessingQueue) return;
|
if (isProcessingQueue) return;
|
||||||
isProcessingQueue = true;
|
isProcessingQueue = true;
|
||||||
@@ -515,8 +375,6 @@ async function processQueue(): Promise<void> {
|
|||||||
|
|
||||||
// Submit generation job to queue
|
// Submit generation job to queue
|
||||||
export async function generateMusicViaAPI(params: GenerationParams): Promise<{ jobId: string }> {
|
export async function generateMusicViaAPI(params: GenerationParams): Promise<{ jobId: string }> {
|
||||||
// Force a fresh API availability check when starting a job
|
|
||||||
resetApiCache();
|
|
||||||
const jobId = `job_${Date.now()}_${Math.random().toString(36).slice(2, 9)}`;
|
const jobId = `job_${Date.now()}_${Math.random().toString(36).slice(2, 9)}`;
|
||||||
|
|
||||||
const job: ActiveJob = {
|
const job: ActiveJob = {
|
||||||
@@ -537,83 +395,179 @@ export async function generateMusicViaAPI(params: GenerationParams): Promise<{ j
|
|||||||
return { jobId };
|
return { jobId };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// processGeneration — Gradio primary, Python spawn fallback
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
async function processGeneration(
|
async function processGeneration(
|
||||||
jobId: string,
|
jobId: string,
|
||||||
params: GenerationParams,
|
params: GenerationParams,
|
||||||
job: ActiveJob
|
job: ActiveJob,
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
job.status = 'running';
|
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> {
|
||||||
|
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[];
|
||||||
|
|
||||||
|
// 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 || 60);
|
||||||
|
|
||||||
|
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 caption = params.style || 'pop music';
|
||||||
const prompt = params.customMode ? caption : (params.songDescription || caption);
|
const prompt = params.customMode ? caption : (params.songDescription || caption);
|
||||||
const lyrics = params.instrumental ? '' : (params.lyrics || '');
|
const lyrics = params.instrumental ? '' : (params.lyrics || '');
|
||||||
|
|
||||||
// Check if ACE-Step API is available
|
console.log(`Job ${jobId}: Using Python spawn (Gradio not available)`, {
|
||||||
const useApi = await isApiAvailable();
|
|
||||||
|
|
||||||
if (useApi) {
|
|
||||||
console.log(`Job ${jobId}: Using ACE-Step REST API`, {
|
|
||||||
prompt: prompt.slice(0, 50),
|
|
||||||
duration: params.duration,
|
|
||||||
});
|
|
||||||
|
|
||||||
try {
|
|
||||||
// Submit to API
|
|
||||||
const { taskId } = await submitToApi(params);
|
|
||||||
job.taskId = taskId;
|
|
||||||
console.log(`Job ${jobId}: Submitted to API as task ${taskId}`);
|
|
||||||
|
|
||||||
// Poll for result
|
|
||||||
const apiResult = await pollApiResult(taskId);
|
|
||||||
|
|
||||||
if (!apiResult.audioPaths || apiResult.audioPaths.length === 0) {
|
|
||||||
throw new Error('No audio files generated by API');
|
|
||||||
}
|
|
||||||
|
|
||||||
// Download audio files from API to local storage
|
|
||||||
const audioUrls: string[] = [];
|
|
||||||
let actualDuration = 0;
|
|
||||||
const audioFormat = params.audioFormat ?? 'mp3';
|
|
||||||
|
|
||||||
for (const apiAudioPath of apiResult.audioPaths) {
|
|
||||||
const ext = apiAudioPath.includes('.flac') ? '.flac' : `.${audioFormat}`;
|
|
||||||
const filename = `${jobId}_${audioUrls.length}${ext}`;
|
|
||||||
const destPath = path.join(AUDIO_DIR, filename);
|
|
||||||
|
|
||||||
await downloadAudioFromApi(apiAudioPath, destPath);
|
|
||||||
|
|
||||||
if (audioUrls.length === 0) {
|
|
||||||
actualDuration = getAudioDuration(destPath);
|
|
||||||
}
|
|
||||||
|
|
||||||
audioUrls.push(`/audio/${filename}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
const finalDuration = actualDuration > 0
|
|
||||||
? actualDuration
|
|
||||||
: (apiResult.metas?.duration || params.duration || 60);
|
|
||||||
|
|
||||||
job.status = 'succeeded';
|
|
||||||
job.result = {
|
|
||||||
audioUrls,
|
|
||||||
duration: finalDuration,
|
|
||||||
bpm: apiResult.metas?.bpm || params.bpm,
|
|
||||||
keyScale: apiResult.metas?.keyscale || params.keyScale,
|
|
||||||
timeSignature: apiResult.metas?.timesignature || params.timeSignature,
|
|
||||||
status: 'succeeded',
|
|
||||||
};
|
|
||||||
console.log(`Job ${jobId}: Completed via API with ${audioUrls.length} audio files`);
|
|
||||||
|
|
||||||
} catch (error) {
|
|
||||||
console.error(`Job ${jobId}: API generation failed`, error);
|
|
||||||
job.status = 'failed';
|
|
||||||
job.error = error instanceof Error ? error.message : 'API generation failed';
|
|
||||||
}
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Fall back to Python spawn if API not available
|
|
||||||
console.log(`Job ${jobId}: Using Python spawn (API not available)`, {
|
|
||||||
prompt: prompt.slice(0, 50),
|
prompt: prompt.slice(0, 50),
|
||||||
lyricsPreview: lyrics.slice(0, 50),
|
lyricsPreview: lyrics.slice(0, 50),
|
||||||
duration: params.duration,
|
duration: params.duration,
|
||||||
@@ -647,23 +601,14 @@ async function processGeneration(
|
|||||||
if (params.taskType && params.taskType !== 'text2music') args.push('--task-type', params.taskType);
|
if (params.taskType && params.taskType !== 'text2music') args.push('--task-type', params.taskType);
|
||||||
|
|
||||||
if (params.referenceAudioUrl) {
|
if (params.referenceAudioUrl) {
|
||||||
let refAudioPath = params.referenceAudioUrl;
|
args.push('--reference-audio', resolveAudioPath(params.referenceAudioUrl));
|
||||||
if (refAudioPath.startsWith('/audio/')) {
|
|
||||||
refAudioPath = path.join(AUDIO_DIR, refAudioPath.replace('/audio/', ''));
|
|
||||||
}
|
|
||||||
args.push('--reference-audio', refAudioPath);
|
|
||||||
}
|
}
|
||||||
if (params.sourceAudioUrl) {
|
if (params.sourceAudioUrl) {
|
||||||
let srcAudioPath = params.sourceAudioUrl;
|
args.push('--src-audio', resolveAudioPath(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.audioCodes) args.push('--audio-codes', params.audioCodes);
|
||||||
if (params.repaintingStart !== undefined && params.repaintingStart > 0) args.push('--repainting-start', String(params.repaintingStart));
|
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.repaintingEnd !== undefined && params.repaintingEnd > 0) args.push('--repainting-end', String(params.repaintingEnd));
|
||||||
// Always send audio_cover_strength for cover/repaint tasks, otherwise only when not default
|
|
||||||
if (params.taskType === 'cover' || params.taskType === 'repaint' || params.sourceAudioUrl) {
|
if (params.taskType === 'cover' || params.taskType === 'repaint' || params.sourceAudioUrl) {
|
||||||
args.push('--audio-cover-strength', String(params.audioCoverStrength ?? 1.0));
|
args.push('--audio-cover-strength', String(params.audioCoverStrength ?? 1.0));
|
||||||
} else if (params.audioCoverStrength !== undefined && params.audioCoverStrength !== 1.0) {
|
} else if (params.audioCoverStrength !== undefined && params.audioCoverStrength !== 1.0) {
|
||||||
@@ -730,7 +675,7 @@ async function processGeneration(
|
|||||||
status: 'succeeded',
|
status: 'succeeded',
|
||||||
};
|
};
|
||||||
job.rawResponse = result;
|
job.rawResponse = result;
|
||||||
console.log(`Job ${jobId}: Completed in ${result.elapsed_seconds?.toFixed(1)}s with ${audioUrls.length} audio files`);
|
console.log(`Job ${jobId}: Completed via Python in ${result.elapsed_seconds?.toFixed(1)}s with ${audioUrls.length} audio files`);
|
||||||
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error(`Job ${jobId}: Generation failed`, error);
|
console.error(`Job ${jobId}: Generation failed`, error);
|
||||||
@@ -773,7 +718,6 @@ function runPythonGeneration(scriptArgs: string[]): Promise<PythonResult> {
|
|||||||
|
|
||||||
proc.stderr.on('data', (data) => {
|
proc.stderr.on('data', (data) => {
|
||||||
stderr += data.toString();
|
stderr += data.toString();
|
||||||
// Log progress to console
|
|
||||||
const lines = data.toString().split('\n');
|
const lines = data.toString().split('\n');
|
||||||
for (const line of lines) {
|
for (const line of lines) {
|
||||||
if (line.trim()) {
|
if (line.trim()) {
|
||||||
@@ -788,7 +732,6 @@ function runPythonGeneration(scriptArgs: string[]): Promise<PythonResult> {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Find the JSON output (last line that starts with {)
|
|
||||||
const lines = stdout.split('\n').filter(l => l.trim());
|
const lines = stdout.split('\n').filter(l => l.trim());
|
||||||
const jsonLine = lines.find(l => l.startsWith('{'));
|
const jsonLine = lines.find(l => l.startsWith('{'));
|
||||||
|
|
||||||
@@ -811,58 +754,10 @@ function runPythonGeneration(scriptArgs: string[]): Promise<PythonResult> {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
function extractAudioFiles(result: unknown): string[] {
|
// ---------------------------------------------------------------------------
|
||||||
const urls: string[] = [];
|
// Job status (simplified — no more REST polling for progress)
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
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> {
|
export async function getJobStatus(jobId: string): Promise<JobStatus> {
|
||||||
const job = activeJobs.get(jobId);
|
const job = activeJobs.get(jobId);
|
||||||
|
|
||||||
@@ -889,60 +784,18 @@ export async function getJobStatus(jobId: string): Promise<JobStatus> {
|
|||||||
|
|
||||||
const elapsed = Math.floor((Date.now() - job.startTime) / 1000);
|
const elapsed = Math.floor((Date.now() - job.startTime) / 1000);
|
||||||
|
|
||||||
// Include queue position if queued
|
|
||||||
if (job.status === 'queued') {
|
if (job.status === 'queued') {
|
||||||
return {
|
return {
|
||||||
status: job.status,
|
status: job.status,
|
||||||
queuePosition: job.queuePosition,
|
queuePosition: job.queuePosition,
|
||||||
etaSeconds: (job.queuePosition || 1) * 180, // ~3 min per job estimate
|
etaSeconds: (job.queuePosition || 1) * 180,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
if (job.status === 'running' && job.taskId) {
|
// Running — Gradio handles its own queue, we just report estimated time
|
||||||
try {
|
|
||||||
const response = await fetch(`${ACESTEP_API}/query_result`, {
|
|
||||||
method: 'POST',
|
|
||||||
headers: { 'Content-Type': 'application/json' },
|
|
||||||
body: JSON.stringify({ task_id_list: [job.taskId] }),
|
|
||||||
});
|
|
||||||
|
|
||||||
if (response.ok) {
|
|
||||||
const result = await response.json();
|
|
||||||
const taskData = result.data?.[0];
|
|
||||||
if (taskData?.result) {
|
|
||||||
let resultData: unknown = taskData.result;
|
|
||||||
if (typeof resultData === 'string') {
|
|
||||||
try {
|
|
||||||
resultData = JSON.parse(resultData);
|
|
||||||
} catch {
|
|
||||||
resultData = null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const item = Array.isArray(resultData) ? resultData[0] : resultData;
|
|
||||||
if (item && typeof item === 'object') {
|
|
||||||
const rawProgress = (item as any).progress;
|
|
||||||
const progress = Number.isFinite(Number(rawProgress)) ? Number(rawProgress) : undefined;
|
|
||||||
const stage = typeof (item as any).stage === 'string' ? (item as any).stage : undefined;
|
|
||||||
if (progress !== undefined) job.progress = progress;
|
|
||||||
if (stage) job.stage = stage;
|
|
||||||
return {
|
|
||||||
status: job.status,
|
|
||||||
etaSeconds: Math.max(0, 180 - elapsed),
|
|
||||||
progress: progress ?? job.progress,
|
|
||||||
stage: stage ?? job.stage,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
// ignore progress fetch failures, fall back to ETA only
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
status: job.status,
|
status: job.status,
|
||||||
etaSeconds: Math.max(0, 180 - elapsed), // 3 min estimate
|
etaSeconds: Math.max(0, 180 - elapsed),
|
||||||
progress: job.progress,
|
progress: job.progress,
|
||||||
stage: job.stage,
|
stage: job.stage,
|
||||||
};
|
};
|
||||||
@@ -954,18 +807,18 @@ export function getJobRawResponse(jobId: string): unknown | null {
|
|||||||
return job?.rawResponse || null;
|
return job?.rawResponse || null;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Get audio stream from local file or remote URL
|
// ---------------------------------------------------------------------------
|
||||||
|
// Audio helpers (unchanged)
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
export async function getAudioStream(audioPath: string): Promise<Response> {
|
export async function getAudioStream(audioPath: string): Promise<Response> {
|
||||||
// If it's already a full URL, fetch directly
|
|
||||||
if (audioPath.startsWith('http')) {
|
if (audioPath.startsWith('http')) {
|
||||||
return fetch(audioPath);
|
return fetch(audioPath);
|
||||||
}
|
}
|
||||||
|
|
||||||
// If it's a local /audio/ path, read from filesystem
|
|
||||||
if (audioPath.startsWith('/audio/')) {
|
if (audioPath.startsWith('/audio/')) {
|
||||||
const localPath = path.join(AUDIO_DIR, audioPath.replace('/audio/', ''));
|
const localPath = path.join(AUDIO_DIR, audioPath.replace('/audio/', ''));
|
||||||
try {
|
try {
|
||||||
const { readFile } = await import('fs/promises');
|
|
||||||
const buffer = await readFile(localPath);
|
const buffer = await readFile(localPath);
|
||||||
const ext = localPath.endsWith('.flac') ? 'flac' : 'mpeg';
|
const ext = localPath.endsWith('.flac') ? 'flac' : 'mpeg';
|
||||||
return new Response(buffer, {
|
return new Response(buffer, {
|
||||||
@@ -978,13 +831,11 @@ export async function getAudioStream(audioPath: string): Promise<Response> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Otherwise, use the ACE-Step audio endpoint
|
|
||||||
const url = `${ACESTEP_API}/v1/audio?path=${encodeURIComponent(audioPath)}`;
|
const url = `${ACESTEP_API}/v1/audio?path=${encodeURIComponent(audioPath)}`;
|
||||||
console.log('Fetching audio from:', url);
|
console.log('Fetching audio from:', url);
|
||||||
return fetch(url);
|
return fetch(url);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Download audio to local storage
|
|
||||||
export async function downloadAudio(remoteUrl: string, songId: string): Promise<string> {
|
export async function downloadAudio(remoteUrl: string, songId: string): Promise<string> {
|
||||||
await mkdir(AUDIO_DIR, { recursive: true });
|
await mkdir(AUDIO_DIR, { recursive: true });
|
||||||
|
|
||||||
@@ -1004,7 +855,6 @@ export async function downloadAudio(remoteUrl: string, songId: string): Promise<
|
|||||||
return `/audio/${filename}`;
|
return `/audio/${filename}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Download audio to buffer
|
|
||||||
export async function downloadAudioToBuffer(remoteUrl: string): Promise<{ buffer: Buffer; size: number }> {
|
export async function downloadAudioToBuffer(remoteUrl: string): Promise<{ buffer: Buffer; size: number }> {
|
||||||
const response = await getAudioStream(remoteUrl);
|
const response = await getAudioStream(remoteUrl);
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
@@ -1016,12 +866,10 @@ export async function downloadAudioToBuffer(remoteUrl: string): Promise<{ buffer
|
|||||||
return { buffer, size: buffer.length };
|
return { buffer, size: buffer.length };
|
||||||
}
|
}
|
||||||
|
|
||||||
// Cleanup job from memory
|
|
||||||
export function cleanupJob(jobId: string): void {
|
export function cleanupJob(jobId: string): void {
|
||||||
activeJobs.delete(jobId);
|
activeJobs.delete(jobId);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Cleanup old jobs
|
|
||||||
export function cleanupOldJobs(maxAgeMs: number = 3600000): void {
|
export function cleanupOldJobs(maxAgeMs: number = 3600000): void {
|
||||||
const now = Date.now();
|
const now = Date.now();
|
||||||
for (const [jobId, job] of activeJobs) {
|
for (const [jobId, job] of activeJobs) {
|
||||||
|
|||||||
@@ -0,0 +1,57 @@
|
|||||||
|
import { Client } from "@gradio/client";
|
||||||
|
import { config } from '../config/index.js';
|
||||||
|
|
||||||
|
let clientInstance: Client | null = null;
|
||||||
|
let connectionPromise: Promise<Client> | null = null;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get a lazy-initialized Gradio client connected to the ACE-Step Gradio app.
|
||||||
|
* Caches the connection for reuse across requests.
|
||||||
|
*/
|
||||||
|
export async function getGradioClient(): Promise<Client> {
|
||||||
|
if (clientInstance) return clientInstance;
|
||||||
|
if (connectionPromise) return connectionPromise;
|
||||||
|
|
||||||
|
connectionPromise = (async () => {
|
||||||
|
try {
|
||||||
|
const client = await Client.connect(config.acestep.apiUrl, {
|
||||||
|
events: ["data", "status"],
|
||||||
|
});
|
||||||
|
clientInstance = client;
|
||||||
|
console.log(`[Gradio] Connected to ${config.acestep.apiUrl}`);
|
||||||
|
return client;
|
||||||
|
} catch (error) {
|
||||||
|
console.error(`[Gradio] Failed to connect to ${config.acestep.apiUrl}:`, error);
|
||||||
|
throw error;
|
||||||
|
} finally {
|
||||||
|
connectionPromise = null;
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
|
||||||
|
return connectionPromise;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reset the cached Gradio client, forcing a new connection on next use.
|
||||||
|
*/
|
||||||
|
export function resetGradioClient(): void {
|
||||||
|
clientInstance = null;
|
||||||
|
connectionPromise = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if the Gradio app is reachable.
|
||||||
|
*/
|
||||||
|
export async function isGradioAvailable(): Promise<boolean> {
|
||||||
|
try {
|
||||||
|
const controller = new AbortController();
|
||||||
|
const timeout = setTimeout(() => controller.abort(), 3000);
|
||||||
|
const response = await fetch(`${config.acestep.apiUrl}/gradio_api/info`, {
|
||||||
|
signal: controller.signal,
|
||||||
|
});
|
||||||
|
clearTimeout(timeout);
|
||||||
|
return response.ok;
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user