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:
fspecii
2026-02-08 19:39:24 +02:00
parent 193cf707cd
commit f42fde9b40
6 changed files with 494 additions and 468 deletions
+19
View File
@@ -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",
+1
View File
@@ -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",
+2
View File
@@ -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) => {
+99
View File
@@ -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
View File
@@ -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) {
+57
View File
@@ -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;
}
}