Fix multiple issues: format API, Gradio availability, storage, UI responsiveness

- 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)
This commit is contained in:
fspecii
2026-03-02 17:43:53 +02:00
parent 553e0bce2f
commit e1625a717d
7 changed files with 208 additions and 63 deletions
+14 -10
View File
@@ -707,17 +707,21 @@ function AppContent() {
? (Number(status.progress) > 1 ? Number(status.progress) / 100 : Number(status.progress))
: undefined;
setSongs(prev => prev.map(s => {
if (s.id === tempId) {
return {
...s,
queuePosition: status.status === 'queued' ? status.queuePosition : undefined,
progress: normalizedProgress ?? s.progress,
stage: status.stage ?? s.stage,
};
setSongs(prev => {
const song = prev.find(s => s.id === tempId);
if (!song) return prev;
const newQueuePos = status.status === 'queued' ? status.queuePosition : undefined;
const newProgress = normalizedProgress ?? song.progress;
const newStage = status.stage ?? song.stage;
// Skip update if nothing changed to avoid unnecessary re-renders
if (newProgress === song.progress && newStage === song.stage && newQueuePos === song.queuePosition) {
return prev;
}
return s;
}));
return prev.map(s => {
if (s.id !== tempId) return s;
return { ...s, queuePosition: newQueuePos, progress: newProgress, stage: newStage };
});
});
if (status.status === 'succeeded' && status.result) {
cleanupJob(jobId, tempId);
+8 -2
View File
@@ -69,6 +69,7 @@ export const Player: React.FC<PlayerProps> = ({
const progressBarRef = useRef<HTMLDivElement>(null);
const fullscreenProgressRef = useRef<HTMLDivElement>(null);
const [isHoveringVolume, setIsHoveringVolume] = useState(false);
const volumeHideTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
const [showDropdown, setShowDropdown] = useState(false);
const [isFullscreen, setIsFullscreen] = useState(false);
const [shareModalOpen, setShareModalOpen] = useState(false);
@@ -755,8 +756,13 @@ export const Player: React.FC<PlayerProps> = ({
{/* Volume Control with Vertical Slider */}
<div
className="relative group hidden md:block"
onMouseEnter={() => setIsHoveringVolume(true)}
onMouseLeave={() => setIsHoveringVolume(false)}
onMouseEnter={() => {
if (volumeHideTimer.current) clearTimeout(volumeHideTimer.current);
setIsHoveringVolume(true);
}}
onMouseLeave={() => {
volumeHideTimer.current = setTimeout(() => setIsHoveringVolume(false), 400);
}}
>
<button
onClick={() => onVolumeChange(volume === 0 ? 0.8 : 0)}
+15 -2
View File
@@ -228,17 +228,30 @@ export const VideoGeneratorModal: React.FC<VideoGeneratorModalProps> = ({ isOpen
}
});
const baseURL = 'https://unpkg.com/@ffmpeg/core@0.12.6/dist/esm';
const cdnBases = [
'https://unpkg.com/@ffmpeg/core@0.12.6/dist/esm',
'https://cdn.jsdelivr.net/npm/@ffmpeg/core@0.12.6/dist/esm',
];
let loaded = false;
for (const baseURL of cdnBases) {
try {
await ffmpeg.load({
coreURL: await toBlobURL(`${baseURL}/ffmpeg-core.js`, 'text/javascript'),
wasmURL: await toBlobURL(`${baseURL}/ffmpeg-core.wasm`, 'application/wasm'),
});
loaded = true;
break;
} catch {
console.warn(`FFmpeg load failed from ${baseURL}, trying next CDN...`);
}
}
if (!loaded) throw new Error('All CDN sources failed');
ffmpegRef.current = ffmpeg;
setFfmpegLoaded(true);
} catch (error) {
console.error('Failed to load FFmpeg:', error);
alert('Failed to load video encoder. Please refresh and try again.');
alert('Failed to load video encoder. Check your internet connection and try again.');
} finally {
setFfmpegLoading(false);
}
+91 -17
View File
@@ -22,6 +22,30 @@ import { getStorageProvider } from '../services/storage/factory.js';
const router = Router();
// Auto-generate a song title from lyrics or style when none is provided
function autoTitle(params: { title?: string; lyrics?: string; instrumental?: boolean; style?: string; songDescription?: string }): string {
if (params.title?.trim()) return params.title.trim();
// Try first meaningful lyric line (skip section markers like [verse], [chorus])
if (!params.instrumental && params.lyrics) {
for (const line of params.lyrics.split('\n')) {
const t = line.trim();
if (t && !/^\[.*\]$/.test(t)) {
return t.length > 40 ? t.slice(0, 40).trimEnd() + '…' : t;
}
}
}
// Fall back to first 4 words of style or description
const source = params.style || params.songDescription || '';
if (source) {
const words = source.trim().split(/\s+/).slice(0, 4).join(' ');
return words.charAt(0).toUpperCase() + words.slice(1);
}
return 'Untitled';
}
const audioUpload = multer({
storage: multer.memoryStorage(),
limits: { fileSize: 25 * 1024 * 1024 }, // 25MB max
@@ -125,6 +149,9 @@ interface GenerateBody {
trackName?: string;
completeTrackClasses?: string[];
isFormatCaption?: boolean;
// Model selection
ditModel?: string;
}
router.post('/upload-audio', authMiddleware, (req: AuthenticatedRequest, res: Response, next: Function) => {
@@ -237,6 +264,7 @@ router.post('/', authMiddleware, async (req: AuthenticatedRequest, res: Response
trackName,
completeTrackClasses,
isFormatCaption,
ditModel,
} = req.body as GenerateBody;
if (!customMode && !songDescription) {
@@ -304,6 +332,7 @@ router.post('/', authMiddleware, async (req: AuthenticatedRequest, res: Response
trackName,
completeTrackClasses,
isFormatCaption,
ditModel,
};
// Create job record in database
@@ -392,7 +421,7 @@ router.get('/status/:jobId', authMiddleware, async (req: AuthenticatedRequest, r
for (let i = 0; i < audioUrls.length; i++) {
const audioUrl = audioUrls[i];
const variationSuffix = audioUrls.length > 1 ? ` (v${i + 1})` : '';
const songTitle = (params.title || 'Untitled') + variationSuffix;
const songTitle = autoTitle(params) + variationSuffix;
const songId = generateUUID();
@@ -416,7 +445,7 @@ router.get('/status/:jobId', authMiddleware, async (req: AuthenticatedRequest, r
params.style,
params.style,
storedPath,
aceStatus.result.duration && aceStatus.result.duration > 0 ? aceStatus.result.duration : (params.duration && params.duration > 0 ? params.duration : 120),
aceStatus.result.duration && aceStatus.result.duration > 0 ? aceStatus.result.duration : (params.duration && params.duration > 0 ? params.duration : 0),
aceStatus.result.bpm || params.bpm,
aceStatus.result.keyScale || params.keyScale,
aceStatus.result.timeSignature || params.timeSignature,
@@ -442,7 +471,7 @@ router.get('/status/:jobId', authMiddleware, async (req: AuthenticatedRequest, r
params.style,
params.style,
audioUrl,
aceStatus.result.duration && aceStatus.result.duration > 0 ? aceStatus.result.duration : (params.duration && params.duration > 0 ? params.duration : 120),
aceStatus.result.duration && aceStatus.result.duration > 0 ? aceStatus.result.duration : (params.duration && params.duration > 0 ? params.duration : 0),
aceStatus.result.bpm || params.bpm,
aceStatus.result.keyScale || params.keyScale,
aceStatus.result.timeSignature || params.timeSignature,
@@ -750,8 +779,63 @@ router.post('/format', authMiddleware, async (req: AuthenticatedRequest, res: Re
return;
}
const { spawn } = await import('child_process');
const ACESTEP_API_URL = config.acestep.apiUrl;
// Build param_obj for the REST API
const paramObj: Record<string, unknown> = {};
if (bpm && bpm > 0) paramObj.bpm = bpm;
if (duration && duration > 0) paramObj.duration = duration;
if (keyScale) paramObj.key = keyScale;
if (timeSignature) paramObj.time_signature = timeSignature;
// Primary path: call ACE-Step's /format_input REST endpoint (avoids Python spawn ENOENT on Windows)
try {
console.log(`[Format] Calling REST API: ${ACESTEP_API_URL}/format_input`);
const apiRes = await fetch(`${ACESTEP_API_URL}/format_input`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
prompt: caption,
lyrics: lyrics || '',
temperature: temperature ?? 0.85,
param_obj: paramObj,
}),
signal: AbortSignal.timeout(300_000), // 5 min — LLM may need to init first
});
const apiData = await apiRes.json() as any;
if (!apiRes.ok || apiData.code !== 200) {
const errMsg = apiData.error || apiData.detail || `Format API returned ${apiRes.status}`;
console.error('[Format] API error:', errMsg);
res.status(500).json({ success: false, error: errMsg });
return;
}
const d = apiData.data;
res.json({
caption: d.caption,
lyrics: d.lyrics,
bpm: d.bpm,
duration: d.duration,
key_scale: d.key_scale,
time_signature: d.time_signature,
vocal_language: d.vocal_language,
});
return;
} catch (fetchErr: any) {
// Only fall back to Python spawn on network errors (service not yet reachable)
if (fetchErr?.name !== 'AbortError' && (fetchErr?.code === 'ECONNREFUSED' || fetchErr?.cause?.code === 'ECONNREFUSED')) {
console.warn('[Format] REST API unreachable, falling back to Python spawn');
} else {
console.error('[Format] REST API request failed:', fetchErr?.message);
res.status(500).json({ success: false, error: fetchErr?.message || 'Format request failed' });
return;
}
}
// Fallback: Python spawn (only reached when REST API is unreachable)
const { spawn } = await import('child_process');
const ACESTEP_DIR = process.env.ACESTEP_PATH || path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../../../ACE-Step-1.5');
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
@@ -759,12 +843,7 @@ router.post('/format', authMiddleware, async (req: AuthenticatedRequest, res: Re
const FORMAT_SCRIPT = path.join(SCRIPTS_DIR, 'format_sample.py');
const pythonPath = resolvePythonPath(ACESTEP_DIR);
const args = [
FORMAT_SCRIPT,
'--caption', caption,
'--json',
];
const args = [FORMAT_SCRIPT, '--caption', caption, '--json'];
if (lyrics) args.push('--lyrics', lyrics);
if (bpm && bpm > 0) args.push('--bpm', String(bpm));
if (duration && duration > 0) args.push('--duration', String(duration));
@@ -776,15 +855,11 @@ router.post('/format', authMiddleware, async (req: AuthenticatedRequest, res: Re
if (lmModel) args.push('--lm-model', lmModel);
if (lmBackend) args.push('--lm-backend', lmBackend);
console.log(`[Format] Running: ${pythonPath} ${args.join(' ')}`);
console.log(`[Format] CWD: ${ACESTEP_DIR}`);
console.log(`[Format] Fallback spawn: ${pythonPath} ${args.join(' ')}`);
const result = await new Promise<{ success: boolean; data?: any; error?: string }>((resolve) => {
const proc = spawn(pythonPath, args, {
cwd: ACESTEP_DIR,
env: {
...process.env,
ACESTEP_PATH: ACESTEP_DIR,
},
env: { ...process.env, ACESTEP_PATH: ACESTEP_DIR },
});
let stdout = '';
@@ -795,7 +870,6 @@ router.post('/format', authMiddleware, async (req: AuthenticatedRequest, res: Re
proc.on('close', (code) => {
if (code === 0 && stdout) {
// stdout may contain log lines before the JSON — extract last JSON line
const lines = stdout.trim().split('\n');
let jsonStr = '';
for (let i = lines.length - 1; i >= 0; i--) {
+53 -13
View File
@@ -77,7 +77,7 @@ const SCRIPTS_DIR = path.join(__dirname, '../../scripts');
const PYTHON_SCRIPT = path.join(SCRIPTS_DIR, 'simple_generate.py');
// ---------------------------------------------------------------------------
// Gradio generation: map params to the 45 positional args for /generation_wrapper
// Gradio generation: map params to the 51 positional args for /generation_wrapper
// ---------------------------------------------------------------------------
/**
@@ -186,16 +186,16 @@ async function buildGradioArgs(params: GenerationParams): Promise<unknown[]> {
params.constrainedDecodingDebug ?? false, // 38: Constrained Decoding Debug
params.allowLmBatch ?? true, // 39: ParallelThinking
params.getScores ?? false, // 40: Auto Score
// Note: auto_lrc (getLrc) is a hidden Gradio state param — NOT included in the public API args
params.scoreScale ?? 0.5, // 41: Quality Score Sensitivity (0.01-1.0)
params.lmBatchChunkSize ?? 8, // 42: LM Batch Chunk Size
params.trackName || null, // 43: Track Name
params.completeTrackClasses || [], // 44: Track Names
false, // 45: Enable Normalization (ACE-Step v1.5 new param, default false)
0.0, // 46: Normalization DB (ACE-Step v1.5 new param, default 0.0)
0.0, // 47: Latent Shift (ACE-Step v1.5 new param, default 0.0)
1.0, // 48: Latent Rescale (ACE-Step v1.5 new param, default 1.0)
params.autogen ?? false, // 49: AutoGen (visible last param)
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()
];
@@ -365,6 +365,40 @@ 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 };
@@ -472,6 +506,12 @@ async function processGenerationViaGradio(
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);
@@ -552,7 +592,7 @@ async function processGenerationViaGradio(
const finalDuration = actualDuration > 0
? actualDuration
: (metas.duration || params.duration || 60);
: (metas.duration || params.duration || 0);
job.status = 'succeeded';
job.result = {
@@ -703,7 +743,7 @@ async function processGenerationViaPython(
console.warn(`Job ${jobId}: Failed to cleanup output dir`, cleanupError);
}
const finalDuration = actualDuration > 0 ? actualDuration : (params.duration && params.duration > 0 ? params.duration : 60);
const finalDuration = actualDuration > 0 ? actualDuration : (params.duration && params.duration > 0 ? params.duration : 0);
job.status = 'succeeded';
job.result = {
+16 -7
View File
@@ -41,17 +41,26 @@ export function resetGradioClient(): void {
/**
* Check if the Gradio app is reachable.
* Tries multiple well-known endpoints to handle version differences.
*/
export async function isGradioAvailable(): Promise<boolean> {
const baseUrl = config.acestep.apiUrl;
const candidates = [
`${baseUrl}/gradio_api/info`, // Gradio 5+
`${baseUrl}/info`, // Gradio 4.x fallback
`${baseUrl}/`, // Any HTTP response means server is up
];
for (const url of candidates) {
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;
const timer = setTimeout(() => controller.abort(), 5000);
const response = await fetch(url, { signal: controller.signal });
clearTimeout(timer);
if (response.ok || response.status < 500) return true;
} catch {
return false;
// Try next candidate
}
}
return false;
}
+4 -5
View File
@@ -22,14 +22,13 @@ export class LocalStorageProvider implements StorageProvider {
}
async getUrl(key: string, _expiresIn?: number): Promise<string> {
return `/audio/${key}`;
const cleanKey = key.startsWith('/audio/') ? key.slice('/audio/'.length) : key;
return `/audio/${cleanKey.replace(/^\/+/, '')}`;
}
getPublicUrl(key: string): string {
if (key.startsWith('/audio/')) {
return key;
}
return `/audio/${key}`;
const cleanKey = key.startsWith('/audio/') ? key.slice('/audio/'.length) : key;
return `/audio/${cleanKey.replace(/^\/+/, '')}`;
}
async delete(key: string): Promise<void> {