e1625a717d
- Format endpoint now calls ACE-Step /format_input REST API directly instead of spawning Python, fixing ENOENT errors on Windows (#44, #27, #34) - isGradioAvailable() tries /gradio_api/info, /info, / in sequence to handle Gradio 4.x/5.x/6.x version differences, fixing generation fallback (#53, #20) - Storage getUrl/getPublicUrl normalize /audio/ prefix to prevent double-prefix URLs when reference tracks are used for cover generation (#10) - Gradio args: fix normalization_db default from 0.0 to -1.0 (Gradio default) - Volume popover: add 400ms delay before hiding to prevent accidental dismissal (#51) - Polling: skip setSongs state update when nothing changed to reduce re-renders (#51) - FFmpeg: add jsdelivr CDN fallback when unpkg fails (#30) - Model switching: add switchModelIfNeeded() via /v1/init REST API (#45)
984 lines
35 KiB
TypeScript
984 lines
35 KiB
TypeScript
import { writeFile, mkdir, copyFile, rm, readFile } from 'fs/promises';
|
|
import { spawn, execSync } from 'child_process';
|
|
import { existsSync } from 'fs';
|
|
import path from 'path';
|
|
import { handle_file } from '@gradio/client';
|
|
|
|
// Get audio duration using ffprobe
|
|
function getAudioDuration(filePath: string): number {
|
|
try {
|
|
const result = execSync(
|
|
`ffprobe -v error -show_entries format=duration -of default=noprint_wrappers=1:nokey=1 "${filePath}"`,
|
|
{ encoding: 'utf-8', timeout: 10000 }
|
|
);
|
|
const duration = parseFloat(result.trim());
|
|
return isNaN(duration) ? 0 : Math.round(duration);
|
|
} catch (error) {
|
|
console.warn('Failed to get audio duration:', error);
|
|
return 0;
|
|
}
|
|
}
|
|
import { fileURLToPath } from 'url';
|
|
import { config } from '../config/index.js';
|
|
import { getGradioClient, resetGradioClient, isGradioAvailable } from './gradio-client.js';
|
|
|
|
const __filename = fileURLToPath(import.meta.url);
|
|
const __dirname = path.dirname(__filename);
|
|
const AUDIO_DIR = path.join(__dirname, '../../public/audio');
|
|
|
|
const ACESTEP_API = config.acestep.apiUrl;
|
|
|
|
// Resolve ACE-Step path (from env or default relative path)
|
|
function resolveAceStepPath(): string {
|
|
const envPath = process.env.ACESTEP_PATH;
|
|
if (envPath) {
|
|
return path.isAbsolute(envPath) ? envPath : path.resolve(process.cwd(), envPath);
|
|
}
|
|
// Default: sibling directory (server/src/services -> ../../../ACE-Step-1.5 = app/ACE-Step-1.5)
|
|
return path.resolve(__dirname, '../../../ACE-Step-1.5');
|
|
}
|
|
|
|
// Resolve Python path cross-platform (supports venv and portable installations)
|
|
export function resolvePythonPath(baseDir: string): string {
|
|
// Allow explicit override via env var
|
|
if (process.env.PYTHON_PATH) {
|
|
return process.env.PYTHON_PATH;
|
|
}
|
|
|
|
const isWindows = process.platform === 'win32';
|
|
const pythonExe = isWindows ? 'python.exe' : 'python';
|
|
|
|
// Check for portable installation first (python_embeded)
|
|
const portablePath = path.join(baseDir, 'python_embeded', pythonExe);
|
|
if (existsSync(portablePath)) {
|
|
return portablePath;
|
|
}
|
|
|
|
// Check common venv directory names (Pinokio uses 'env', others use '.venv' or 'venv')
|
|
const venvDirs = ['env', '.venv', 'venv'];
|
|
for (const venvDir of venvDirs) {
|
|
const venvPython = isWindows
|
|
? path.join(baseDir, venvDir, 'Scripts', pythonExe)
|
|
: path.join(baseDir, venvDir, 'bin', 'python');
|
|
if (existsSync(venvPython)) {
|
|
return venvPython;
|
|
}
|
|
}
|
|
|
|
// Fallback to first option (will produce a clear error if not found)
|
|
if (isWindows) {
|
|
return path.join(baseDir, 'env', 'Scripts', pythonExe);
|
|
}
|
|
return path.join(baseDir, 'env', 'bin', 'python');
|
|
}
|
|
|
|
const ACESTEP_DIR = resolveAceStepPath();
|
|
const SCRIPTS_DIR = path.join(__dirname, '../../scripts');
|
|
const PYTHON_SCRIPT = path.join(SCRIPTS_DIR, 'simple_generate.py');
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Gradio generation: map params to the 51 positional args for /generation_wrapper
|
|
// ---------------------------------------------------------------------------
|
|
|
|
/**
|
|
* Resolve an audio URL (e.g. /audio/file.mp3) to an absolute local file path.
|
|
*/
|
|
function resolveAudioPath(audioUrl: string): string {
|
|
if (audioUrl.startsWith('/audio/')) {
|
|
return path.join(AUDIO_DIR, audioUrl.replace('/audio/', ''));
|
|
}
|
|
if (audioUrl.startsWith('http')) {
|
|
try {
|
|
const parsed = new URL(audioUrl);
|
|
if (parsed.pathname.startsWith('/audio/')) {
|
|
return path.join(AUDIO_DIR, parsed.pathname.replace('/audio/', ''));
|
|
}
|
|
} catch { /* fall through */ }
|
|
}
|
|
return audioUrl;
|
|
}
|
|
|
|
/**
|
|
* Prepare a local audio file for Gradio upload.
|
|
* 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 mimeMap: Record<string, string> = {
|
|
'.flac': 'audio/flac', '.wav': 'audio/wav', '.ogg': 'audio/ogg',
|
|
'.opus': 'audio/opus', '.m4a': 'audio/mp4', '.mp4': 'audio/mp4',
|
|
};
|
|
const mimeType = mimeMap[ext] || '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;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Build the 50 positional arguments for the Gradio /generation_wrapper endpoint.
|
|
*/
|
|
async function buildGradioArgs(params: GenerationParams): Promise<unknown[]> {
|
|
const caption = params.style || 'pop music';
|
|
const prompt = params.customMode ? caption : (params.songDescription || caption);
|
|
const lyrics = params.instrumental ? '' : (params.lyrics || '');
|
|
const isThinking = params.thinking ?? false;
|
|
const isEnhance = params.enhance ?? false;
|
|
|
|
// Prepare audio files (async — reads from disk)
|
|
const referenceAudio = await prepareAudioFile(params.referenceAudioUrl);
|
|
const sourceAudio = await prepareAudioFile(params.sourceAudioUrl);
|
|
|
|
// CoT features are gated by enhance OR thinking (either enables LLM enrichment)
|
|
const useCot = isEnhance || isThinking;
|
|
|
|
return [
|
|
prompt, // 0: Music Caption
|
|
lyrics, // 1: Lyrics
|
|
params.bpm && params.bpm > 0 ? params.bpm : 0, // 2: BPM (0 = auto)
|
|
params.keyScale || '', // 3: KeyScale
|
|
params.timeSignature || '', // 4: Time Signature
|
|
params.vocalLanguage || 'en', // 5: Vocal Language
|
|
params.inferenceSteps ?? 8, // 6: DiT Inference Steps
|
|
params.guidanceScale ?? 7.0, // 7: DiT Guidance Scale
|
|
params.randomSeed !== false, // 8: Random Seed
|
|
String(params.seed ?? -1), // 9: Seed
|
|
referenceAudio, // 10: Reference Audio (filepath | null)
|
|
params.duration && params.duration > 0 ? params.duration : -1, // 11: Audio Duration (-1 = auto)
|
|
Math.min(Math.max(params.batchSize ?? 1, 1), 16), // 12: Batch Size (clamped 1-16)
|
|
sourceAudio, // 13: Source Audio (filepath | null)
|
|
params.audioCodes || '', // 14: LM Codes Hints
|
|
params.repaintingStart ?? 0.0, // 15: Repainting Start
|
|
params.repaintingEnd ?? -1, // 16: Repainting End
|
|
params.instruction || 'Fill the audio semantic mask with the style described in the text prompt.', // 17: Instruction
|
|
params.audioCoverStrength ?? 1.0, // 18: Audio Cover Strength
|
|
0.0, // 19: Cover Noise Strength (ACE-Step v1.5 new param, default 0.0)
|
|
params.taskType || 'text2music', // 20: Task Type
|
|
params.useAdg ?? false, // 21: Use ADG
|
|
params.cfgIntervalStart ?? 0.0, // 22: CFG Interval Start
|
|
params.cfgIntervalEnd ?? 1.0, // 23: CFG Interval End
|
|
params.shift ?? 3.0, // 24: Shift
|
|
params.inferMethod || 'ode', // 25: Inference Method
|
|
params.customTimesteps || '', // 26: Custom Timesteps
|
|
params.audioFormat || 'mp3', // 27: Audio Format
|
|
params.lmTemperature ?? 0.85, // 28: LM Temperature
|
|
isThinking, // 29: Think
|
|
params.lmCfgScale ?? 2.0, // 30: LM CFG Scale
|
|
params.lmTopK ?? 0, // 31: LM Top-K
|
|
params.lmTopP ?? 0.9, // 32: LM Top-P
|
|
params.lmNegativePrompt || 'NO USER INPUT', // 33: LM Negative Prompt
|
|
useCot ? (params.useCotMetas ?? true) : false, // 34: CoT Metas
|
|
useCot ? (params.useCotCaption ?? true) : false, // 35: CaptionRewrite
|
|
useCot ? (params.useCotLanguage ?? true) : false, // 36: CoT Language
|
|
params.isFormatCaption ?? false, // 37: Is Format Caption State
|
|
params.constrainedDecodingDebug ?? false, // 38: Constrained Decoding Debug
|
|
params.allowLmBatch ?? true, // 39: ParallelThinking
|
|
params.getScores ?? false, // 40: Auto Score
|
|
params.getLrc ?? false, // 41: Auto LRC (timestamped lyrics)
|
|
params.scoreScale ?? 0.5, // 42: Quality Score Sensitivity (0.01-1.0)
|
|
params.lmBatchChunkSize ?? 8, // 43: LM Batch Chunk Size
|
|
params.trackName || null, // 44: Track Name
|
|
params.completeTrackClasses || [], // 45: Track Names
|
|
true, // 46: Enable Normalization (ACE-Step v1.5, default true)
|
|
-1.0, // 47: Normalization DB (ACE-Step v1.5, default -1.0)
|
|
0.0, // 48: Latent Shift (ACE-Step v1.5, default 0.0)
|
|
1.0, // 49: Latent Rescale (ACE-Step v1.5, default 1.0)
|
|
params.autogen ?? false, // 50: AutoGen
|
|
// Note: current_batch_index, total_batches, batch_queue, generation_params_state
|
|
// are hidden Gradio state variables and must NOT be passed via client.predict()
|
|
];
|
|
}
|
|
|
|
/**
|
|
* Download a Gradio audio result file to local storage.
|
|
* Gradio returns file objects with { url, path, orig_name, ... }.
|
|
* We copy from the server-local path (same machine) or download via URL.
|
|
*/
|
|
async function downloadGradioAudioFile(
|
|
fileObj: { url?: string; path?: string; orig_name?: string },
|
|
destPath: string,
|
|
): Promise<void> {
|
|
await mkdir(path.dirname(destPath), { recursive: true });
|
|
|
|
// Prefer direct filesystem copy (both servers on same machine)
|
|
if (fileObj.path && existsSync(fileObj.path)) {
|
|
await copyFile(fileObj.path, destPath);
|
|
return;
|
|
}
|
|
|
|
// Fall back to HTTP download via Gradio URL (use temp file for atomicity)
|
|
if (fileObj.url) {
|
|
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());
|
|
if (buffer.length === 0) {
|
|
throw new Error('Downloaded audio file is empty');
|
|
}
|
|
const tmpPath = destPath + '.tmp';
|
|
await writeFile(tmpPath, buffer);
|
|
const { rename } = await import('fs/promises');
|
|
await rename(tmpPath, destPath);
|
|
return;
|
|
}
|
|
|
|
throw new Error('Gradio file object has neither path nor url');
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Generation types & interfaces (unchanged public API)
|
|
// ---------------------------------------------------------------------------
|
|
|
|
export interface GenerationParams {
|
|
// Mode
|
|
customMode: boolean;
|
|
|
|
// Simple Mode
|
|
songDescription?: string;
|
|
|
|
// Custom Mode
|
|
lyrics: string;
|
|
style: string;
|
|
title: string;
|
|
|
|
// Common
|
|
instrumental: boolean;
|
|
vocalLanguage?: string;
|
|
|
|
// Music Parameters
|
|
duration?: number;
|
|
bpm?: number;
|
|
keyScale?: string;
|
|
timeSignature?: string;
|
|
|
|
// Generation Settings
|
|
inferenceSteps?: number;
|
|
guidanceScale?: number;
|
|
batchSize?: number;
|
|
randomSeed?: boolean;
|
|
seed?: number;
|
|
thinking?: boolean;
|
|
enhance?: boolean;
|
|
audioFormat?: 'mp3' | 'flac';
|
|
inferMethod?: 'ode' | 'sde';
|
|
shift?: number;
|
|
|
|
// LM Parameters
|
|
lmTemperature?: number;
|
|
lmCfgScale?: number;
|
|
lmTopK?: number;
|
|
lmTopP?: number;
|
|
lmNegativePrompt?: string;
|
|
lmBackend?: 'pt' | 'vllm';
|
|
lmModel?: string;
|
|
|
|
// Expert Parameters
|
|
referenceAudioUrl?: string;
|
|
sourceAudioUrl?: string;
|
|
referenceAudioTitle?: string;
|
|
sourceAudioTitle?: string;
|
|
audioCodes?: string;
|
|
repaintingStart?: number;
|
|
repaintingEnd?: number;
|
|
instruction?: string;
|
|
audioCoverStrength?: number;
|
|
taskType?: string;
|
|
useAdg?: boolean;
|
|
cfgIntervalStart?: number;
|
|
cfgIntervalEnd?: number;
|
|
customTimesteps?: string;
|
|
useCotMetas?: boolean;
|
|
useCotCaption?: boolean;
|
|
useCotLanguage?: boolean;
|
|
autogen?: boolean;
|
|
constrainedDecodingDebug?: boolean;
|
|
allowLmBatch?: boolean;
|
|
getScores?: boolean;
|
|
getLrc?: boolean;
|
|
scoreScale?: number;
|
|
lmBatchChunkSize?: number;
|
|
trackName?: string;
|
|
completeTrackClasses?: string[];
|
|
isFormatCaption?: boolean;
|
|
|
|
// Model selection
|
|
ditModel?: string;
|
|
}
|
|
|
|
interface GenerationResult {
|
|
audioUrls: string[];
|
|
duration: number;
|
|
bpm?: number;
|
|
keyScale?: string;
|
|
timeSignature?: string;
|
|
status: string;
|
|
}
|
|
|
|
interface JobStatus {
|
|
status: 'queued' | 'running' | 'succeeded' | 'failed';
|
|
queuePosition?: number;
|
|
etaSeconds?: number;
|
|
progress?: number;
|
|
stage?: string;
|
|
result?: GenerationResult;
|
|
error?: string;
|
|
}
|
|
|
|
interface ActiveJob {
|
|
params: GenerationParams;
|
|
startTime: number;
|
|
status: 'queued' | 'running' | 'succeeded' | 'failed';
|
|
taskId?: string;
|
|
result?: GenerationResult;
|
|
error?: string;
|
|
processPromise?: Promise<void>;
|
|
rawResponse?: unknown;
|
|
queuePosition?: number;
|
|
progress?: number;
|
|
stage?: string;
|
|
}
|
|
|
|
const activeJobs = new Map<string, ActiveJob>();
|
|
|
|
// Periodic cleanup of old jobs (every 10 minutes, remove jobs older than 1 hour)
|
|
setInterval(() => cleanupOldJobs(3600000), 600000);
|
|
|
|
// Job queue for sequential processing (GPU can only handle one job at a time)
|
|
const jobQueue: string[] = [];
|
|
let isProcessingQueue = false;
|
|
|
|
// Health check - verify Gradio app is reachable
|
|
export async function checkSpaceHealth(): Promise<boolean> {
|
|
return isGradioAvailable();
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Model switching — call /v1/init to change the active DiT model
|
|
// ---------------------------------------------------------------------------
|
|
|
|
async function getActiveModel(): Promise<string | null> {
|
|
try {
|
|
const res = await fetch(`${ACESTEP_API}/v1/models`);
|
|
if (!res.ok) return null;
|
|
const data = await res.json() as any;
|
|
const models = data?.data?.models || data?.models || [];
|
|
return models[0]?.name || null;
|
|
} catch {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
async function switchModelIfNeeded(ditModel: string): Promise<void> {
|
|
const activeModel = await getActiveModel();
|
|
if (activeModel === ditModel) return; // already loaded, no-op
|
|
|
|
console.log(`[Model] Switching from '${activeModel ?? 'unknown'}' to '${ditModel}'`);
|
|
const res = await fetch(`${ACESTEP_API}/v1/init`, {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ model: ditModel, init_llm: false }),
|
|
});
|
|
|
|
if (!res.ok) {
|
|
const err = await res.text().catch(() => '');
|
|
throw new Error(`Model switch to '${ditModel}' failed: ${res.status} ${err}`);
|
|
}
|
|
console.log(`[Model] Switched to '${ditModel}'`);
|
|
}
|
|
|
|
// Discover endpoints (for compatibility)
|
|
export async function discoverEndpoints(): Promise<unknown> {
|
|
return { provider: 'acestep-gradio', endpoint: ACESTEP_API };
|
|
}
|
|
|
|
// Reset client — forces Gradio reconnection on next request
|
|
export function resetClient(): void {
|
|
resetGradioClient();
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Job queue
|
|
// ---------------------------------------------------------------------------
|
|
|
|
async function processQueue(): Promise<void> {
|
|
if (isProcessingQueue) return;
|
|
isProcessingQueue = true;
|
|
|
|
while (jobQueue.length > 0) {
|
|
const jobId = jobQueue[0];
|
|
const job = activeJobs.get(jobId);
|
|
|
|
if (job && job.status === 'queued') {
|
|
try {
|
|
await processGeneration(jobId, job.params, job);
|
|
} catch (error) {
|
|
console.error(`Queue processing error for ${jobId}:`, error);
|
|
}
|
|
}
|
|
|
|
// Remove from queue after processing (whether success or failure)
|
|
jobQueue.shift();
|
|
|
|
// Update queue positions for remaining jobs
|
|
jobQueue.forEach((id, index) => {
|
|
const queuedJob = activeJobs.get(id);
|
|
if (queuedJob) {
|
|
queuedJob.queuePosition = index + 1;
|
|
}
|
|
});
|
|
}
|
|
|
|
isProcessingQueue = false;
|
|
}
|
|
|
|
// Submit generation job to queue
|
|
export async function generateMusicViaAPI(params: GenerationParams): Promise<{ jobId: string }> {
|
|
const jobId = `job_${Date.now()}_${Math.random().toString(36).slice(2, 9)}`;
|
|
|
|
const job: ActiveJob = {
|
|
params,
|
|
startTime: Date.now(),
|
|
status: 'queued',
|
|
queuePosition: jobQueue.length + 1,
|
|
};
|
|
|
|
activeJobs.set(jobId, job);
|
|
jobQueue.push(jobId);
|
|
|
|
console.log(`Job ${jobId}: Queued at position ${job.queuePosition}`);
|
|
|
|
// Start processing the queue (will be a no-op if already processing)
|
|
processQueue().catch(err => console.error('Queue processing error:', err));
|
|
|
|
return { jobId };
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// processGeneration — Gradio primary, Python spawn fallback
|
|
// ---------------------------------------------------------------------------
|
|
|
|
async function processGeneration(
|
|
jobId: string,
|
|
params: GenerationParams,
|
|
job: ActiveJob,
|
|
): Promise<void> {
|
|
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> {
|
|
// Switch DiT model if a specific one was requested
|
|
if (params.ditModel) {
|
|
job.stage = `Loading model ${params.ditModel}...`;
|
|
await switchModelIfNeeded(params.ditModel);
|
|
}
|
|
|
|
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[];
|
|
|
|
if (!Array.isArray(data) || data.length === 0) {
|
|
throw new Error(`Gradio returned unexpected data format: ${typeof data}`);
|
|
}
|
|
|
|
// 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 || 0);
|
|
|
|
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 prompt = params.customMode ? caption : (params.songDescription || caption);
|
|
const lyrics = params.instrumental ? '' : (params.lyrics || '');
|
|
|
|
console.log(`Job ${jobId}: Using Python spawn (Gradio not available)`, {
|
|
prompt: prompt.slice(0, 50),
|
|
lyricsPreview: lyrics.slice(0, 50),
|
|
duration: params.duration,
|
|
batchSize: params.batchSize,
|
|
});
|
|
|
|
try {
|
|
const jobOutputDir = path.join(ACESTEP_DIR, 'output', jobId);
|
|
await mkdir(jobOutputDir, { recursive: true });
|
|
|
|
const durationToSend = params.duration && params.duration > 0 ? params.duration : 60;
|
|
const args = [
|
|
'--prompt', prompt,
|
|
'--duration', String(durationToSend),
|
|
'--batch-size', String(params.batchSize ?? 1),
|
|
'--infer-steps', String(params.inferenceSteps ?? 8),
|
|
'--guidance-scale', String(params.guidanceScale ?? 10.0),
|
|
'--audio-format', params.audioFormat ?? 'mp3',
|
|
'--output-dir', jobOutputDir,
|
|
'--json',
|
|
];
|
|
|
|
if (lyrics) args.push('--lyrics', lyrics);
|
|
if (params.instrumental) args.push('--instrumental');
|
|
if (params.bpm && params.bpm > 0) args.push('--bpm', String(params.bpm));
|
|
if (params.keyScale) args.push('--key-scale', params.keyScale);
|
|
if (params.timeSignature) args.push('--time-signature', params.timeSignature);
|
|
if (params.vocalLanguage) args.push('--vocal-language', params.vocalLanguage);
|
|
if (params.seed !== undefined && params.seed >= 0 && !params.randomSeed) args.push('--seed', String(params.seed));
|
|
if (params.shift !== undefined) args.push('--shift', String(params.shift));
|
|
if (params.taskType && params.taskType !== 'text2music') args.push('--task-type', params.taskType);
|
|
|
|
if (params.referenceAudioUrl) {
|
|
args.push('--reference-audio', resolveAudioPath(params.referenceAudioUrl));
|
|
}
|
|
if (params.sourceAudioUrl) {
|
|
args.push('--src-audio', resolveAudioPath(params.sourceAudioUrl));
|
|
}
|
|
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.repaintingEnd !== undefined && params.repaintingEnd > 0) args.push('--repainting-end', String(params.repaintingEnd));
|
|
if (params.taskType === 'cover' || params.taskType === 'repaint' || params.sourceAudioUrl) {
|
|
args.push('--audio-cover-strength', String(params.audioCoverStrength ?? 1.0));
|
|
} else if (params.audioCoverStrength !== undefined && params.audioCoverStrength !== 1.0) {
|
|
args.push('--audio-cover-strength', String(params.audioCoverStrength));
|
|
}
|
|
if (params.instruction) args.push('--instruction', params.instruction);
|
|
if (params.thinking) args.push('--thinking');
|
|
if (params.lmTemperature !== undefined) args.push('--lm-temperature', String(params.lmTemperature));
|
|
if (params.lmCfgScale !== undefined) args.push('--lm-cfg-scale', String(params.lmCfgScale));
|
|
if (params.lmTopK !== undefined && params.lmTopK > 0) args.push('--lm-top-k', String(params.lmTopK));
|
|
if (params.lmTopP !== undefined) args.push('--lm-top-p', String(params.lmTopP));
|
|
if (params.lmNegativePrompt) args.push('--lm-negative-prompt', params.lmNegativePrompt);
|
|
// Note: --lm-backend and --lm-model are not supported by simple_generate.py
|
|
if (params.useCotMetas === false) args.push('--no-cot-metas');
|
|
if (params.useCotCaption === false) args.push('--no-cot-caption');
|
|
if (params.useCotLanguage === false) args.push('--no-cot-language');
|
|
if (params.useAdg) args.push('--use-adg');
|
|
if (params.cfgIntervalStart !== undefined && params.cfgIntervalStart > 0) args.push('--cfg-interval-start', String(params.cfgIntervalStart));
|
|
if (params.cfgIntervalEnd !== undefined && params.cfgIntervalEnd < 1.0) args.push('--cfg-interval-end', String(params.cfgIntervalEnd));
|
|
|
|
const result = await runPythonGeneration(args);
|
|
|
|
if (!result.success) {
|
|
throw new Error(result.error || 'Generation failed');
|
|
}
|
|
|
|
if (!result.audio_paths || result.audio_paths.length === 0) {
|
|
throw new Error('No audio files generated');
|
|
}
|
|
|
|
const audioUrls: string[] = [];
|
|
let actualDuration = 0;
|
|
for (const srcPath of result.audio_paths) {
|
|
const ext = srcPath.includes('.flac') ? '.flac' : '.mp3';
|
|
const filename = `${jobId}_${audioUrls.length}${ext}`;
|
|
const destPath = path.join(AUDIO_DIR, filename);
|
|
|
|
await mkdir(AUDIO_DIR, { recursive: true });
|
|
await copyFile(srcPath, destPath);
|
|
|
|
if (audioUrls.length === 0) {
|
|
actualDuration = getAudioDuration(destPath);
|
|
}
|
|
|
|
audioUrls.push(`/audio/${filename}`);
|
|
}
|
|
|
|
try {
|
|
await rm(jobOutputDir, { recursive: true, force: true });
|
|
} catch (cleanupError) {
|
|
console.warn(`Job ${jobId}: Failed to cleanup output dir`, cleanupError);
|
|
}
|
|
|
|
const finalDuration = actualDuration > 0 ? actualDuration : (params.duration && params.duration > 0 ? params.duration : 0);
|
|
|
|
job.status = 'succeeded';
|
|
job.result = {
|
|
audioUrls,
|
|
duration: finalDuration,
|
|
bpm: params.bpm,
|
|
keyScale: params.keyScale,
|
|
timeSignature: params.timeSignature,
|
|
status: 'succeeded',
|
|
};
|
|
job.rawResponse = result;
|
|
console.log(`Job ${jobId}: Completed via Python in ${result.elapsed_seconds?.toFixed(1)}s with ${audioUrls.length} audio files`);
|
|
|
|
} catch (error) {
|
|
console.error(`Job ${jobId}: Generation failed`, error);
|
|
job.status = 'failed';
|
|
job.error = error instanceof Error ? error.message : 'Generation failed';
|
|
|
|
try {
|
|
const jobOutputDir = path.join(ACESTEP_DIR, 'output', jobId);
|
|
await rm(jobOutputDir, { recursive: true, force: true });
|
|
} catch { /* ignore cleanup errors */ }
|
|
}
|
|
}
|
|
|
|
interface PythonResult {
|
|
success: boolean;
|
|
audio_paths?: string[];
|
|
elapsed_seconds?: number;
|
|
error?: string;
|
|
}
|
|
|
|
function runPythonGeneration(scriptArgs: string[], timeoutMs = 600000): Promise<PythonResult> {
|
|
return new Promise((resolve) => {
|
|
const pythonPath = resolvePythonPath(ACESTEP_DIR);
|
|
const args = [PYTHON_SCRIPT, ...scriptArgs];
|
|
|
|
const proc = spawn(pythonPath, args, {
|
|
cwd: ACESTEP_DIR,
|
|
env: {
|
|
...process.env,
|
|
ACESTEP_PATH: ACESTEP_DIR,
|
|
},
|
|
});
|
|
|
|
// Kill process after timeout (default 10 minutes)
|
|
const timer = setTimeout(() => {
|
|
proc.kill('SIGTERM');
|
|
setTimeout(() => { if (!proc.killed) proc.kill('SIGKILL'); }, 5000);
|
|
resolve({ success: false, error: `Generation timed out after ${timeoutMs / 1000}s` });
|
|
}, timeoutMs);
|
|
|
|
let stdout = '';
|
|
let stderr = '';
|
|
|
|
proc.stdout.on('data', (data) => {
|
|
stdout += data.toString();
|
|
});
|
|
|
|
proc.stderr.on('data', (data) => {
|
|
stderr += data.toString();
|
|
const lines = data.toString().split('\n');
|
|
for (const line of lines) {
|
|
if (line.trim()) {
|
|
console.log(`[ACE-Step] ${line}`);
|
|
}
|
|
}
|
|
});
|
|
|
|
proc.on('close', (code) => {
|
|
clearTimeout(timer);
|
|
if (code !== 0) {
|
|
resolve({ success: false, error: stderr || `Process exited with code ${code}` });
|
|
return;
|
|
}
|
|
|
|
const lines = stdout.split('\n').filter(l => l.trim());
|
|
const jsonLine = lines.find(l => l.startsWith('{'));
|
|
|
|
if (!jsonLine) {
|
|
resolve({ success: false, error: 'No JSON output from generation script' });
|
|
return;
|
|
}
|
|
|
|
try {
|
|
const result = JSON.parse(jsonLine);
|
|
resolve(result);
|
|
} catch {
|
|
resolve({ success: false, error: 'Invalid JSON from generation script' });
|
|
}
|
|
});
|
|
|
|
proc.on('error', (err) => {
|
|
clearTimeout(timer);
|
|
resolve({ success: false, error: err.message });
|
|
});
|
|
});
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Job status (simplified — no more REST polling for progress)
|
|
// ---------------------------------------------------------------------------
|
|
|
|
export async function getJobStatus(jobId: string): Promise<JobStatus> {
|
|
const job = activeJobs.get(jobId);
|
|
|
|
if (!job) {
|
|
return {
|
|
status: 'failed',
|
|
error: 'Job not found',
|
|
};
|
|
}
|
|
|
|
if (job.status === 'succeeded' && job.result) {
|
|
return {
|
|
status: 'succeeded',
|
|
result: job.result,
|
|
};
|
|
}
|
|
|
|
if (job.status === 'failed') {
|
|
return {
|
|
status: 'failed',
|
|
error: job.error || 'Generation failed',
|
|
};
|
|
}
|
|
|
|
const elapsed = Math.floor((Date.now() - job.startTime) / 1000);
|
|
|
|
if (job.status === 'queued') {
|
|
return {
|
|
status: job.status,
|
|
queuePosition: job.queuePosition,
|
|
etaSeconds: (job.queuePosition || 1) * 180,
|
|
};
|
|
}
|
|
|
|
// Running — Gradio handles its own queue, we just report estimated time
|
|
return {
|
|
status: job.status,
|
|
etaSeconds: Math.max(0, 180 - elapsed),
|
|
progress: job.progress,
|
|
stage: job.stage,
|
|
};
|
|
}
|
|
|
|
// Get raw response for debugging
|
|
export function getJobRawResponse(jobId: string): unknown | null {
|
|
const job = activeJobs.get(jobId);
|
|
return job?.rawResponse || null;
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Audio helpers (unchanged)
|
|
// ---------------------------------------------------------------------------
|
|
|
|
export async function getAudioStream(audioPath: string): Promise<Response> {
|
|
if (audioPath.startsWith('http')) {
|
|
return fetch(audioPath);
|
|
}
|
|
|
|
if (audioPath.startsWith('/audio/')) {
|
|
const localPath = path.join(AUDIO_DIR, audioPath.replace('/audio/', ''));
|
|
try {
|
|
const buffer = await readFile(localPath);
|
|
const ext = localPath.endsWith('.flac') ? 'flac' : 'mpeg';
|
|
return new Response(buffer, {
|
|
status: 200,
|
|
headers: { 'Content-Type': `audio/${ext}` }
|
|
});
|
|
} catch (err) {
|
|
console.error('Failed to read local audio file:', localPath, err);
|
|
return new Response(null, { status: 404 });
|
|
}
|
|
}
|
|
|
|
// Absolute path — try reading directly from disk (Gradio output files)
|
|
if (audioPath.startsWith('/')) {
|
|
try {
|
|
const buffer = await readFile(audioPath);
|
|
const ext = audioPath.endsWith('.flac') ? 'flac' : audioPath.endsWith('.wav') ? 'wav' : 'mpeg';
|
|
return new Response(buffer, {
|
|
status: 200,
|
|
headers: { 'Content-Type': `audio/${ext}` }
|
|
});
|
|
} catch {
|
|
// Fall through to Gradio API
|
|
}
|
|
}
|
|
|
|
const url = `${ACESTEP_API}/v1/audio?path=${encodeURIComponent(audioPath)}`;
|
|
console.log('Fetching audio from:', url);
|
|
return fetch(url);
|
|
}
|
|
|
|
export async function downloadAudio(remoteUrl: string, songId: string): Promise<string> {
|
|
await mkdir(AUDIO_DIR, { recursive: true });
|
|
|
|
const response = await getAudioStream(remoteUrl);
|
|
if (!response.ok) {
|
|
throw new Error(`Failed to download audio: ${response.status}`);
|
|
}
|
|
|
|
const buffer = await response.arrayBuffer();
|
|
const ext = remoteUrl.includes('.flac') ? '.flac' : '.mp3';
|
|
const filename = `${songId}${ext}`;
|
|
const filepath = path.join(AUDIO_DIR, filename);
|
|
|
|
await writeFile(filepath, Buffer.from(buffer));
|
|
console.log(`Downloaded audio to ${filepath}`);
|
|
|
|
return `/audio/${filename}`;
|
|
}
|
|
|
|
export async function downloadAudioToBuffer(remoteUrl: string): Promise<{ buffer: Buffer; size: number }> {
|
|
const response = await getAudioStream(remoteUrl);
|
|
if (!response.ok) {
|
|
throw new Error(`Failed to download audio: ${response.status}`);
|
|
}
|
|
|
|
const arrayBuffer = await response.arrayBuffer();
|
|
const buffer = Buffer.from(arrayBuffer);
|
|
return { buffer, size: buffer.length };
|
|
}
|
|
|
|
export function cleanupJob(jobId: string): void {
|
|
activeJobs.delete(jobId);
|
|
}
|
|
|
|
export function cleanupOldJobs(maxAgeMs: number = 3600000): void {
|
|
const now = Date.now();
|
|
for (const [jobId, job] of activeJobs) {
|
|
if (now - job.startTime > maxAgeMs) {
|
|
activeJobs.delete(jobId);
|
|
}
|
|
}
|
|
}
|