Gradio API migration, training pipeline, news page, and UI improvements

- Migrate backend from REST API to Gradio @gradio/client for generation
- Fix Gradio parameter alignment (positions 36-49) for reference/cover audio
- Add LoRA training pipeline with dataset upload, preprocessing, and export
- Add News page with dismiss/restore and GitHub star button
- Add localization info icon in Settings language section
- Fix upload audio URL prefix, add missing MIME types
- Add training API routes and Python preprocess script
- Update i18n with news keys for all languages
This commit is contained in:
fspecii
2026-02-09 22:30:15 +02:00
parent f42fde9b40
commit 565faacb7b
23 changed files with 3145 additions and 236 deletions
+140
View File
@@ -0,0 +1,140 @@
"""
Standalone dataset preprocessor for ACE-Step LoRA training.
Converts labeled audio samples from a dataset JSON into pre-computed
tensor files (.pt) suitable for training. This script loads the VAE and
text encoder independently, so it does NOT require the Gradio app to be
running.
Usage:
python preprocess_dataset.py --dataset /path/to/dataset.json --output /path/to/tensors [--json]
The --json flag makes the script output a final JSON summary line to stdout.
"""
import argparse
import json
import os
import sys
def main():
parser = argparse.ArgumentParser(description="Preprocess dataset to tensors for LoRA training")
parser.add_argument("--dataset", required=True, help="Path to dataset JSON file")
parser.add_argument("--output", required=True, help="Output directory for tensor files")
parser.add_argument("--max-duration", type=float, default=240.0, help="Max audio duration in seconds")
parser.add_argument("--json", action="store_true", help="Output JSON summary")
args = parser.parse_args()
if not os.path.exists(args.dataset):
print(f"Error: Dataset file not found: {args.dataset}", file=sys.stderr)
sys.exit(1)
# Add ACE-Step root to path for imports
ace_step_root = os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
# Walk up to find ACE-Step-1.5 directory
for candidate in [
os.path.join(ace_step_root, "ACE-Step-1.5"),
os.path.join(os.path.dirname(ace_step_root), "ACE-Step-1.5"),
os.getcwd(),
]:
if os.path.isdir(candidate) and os.path.isdir(os.path.join(candidate, "acestep")):
ace_step_root = candidate
break
if ace_step_root not in sys.path:
sys.path.insert(0, ace_step_root)
try:
from acestep.training.dataset_builder import DatasetBuilder
except ImportError as e:
print(f"Error: Could not import ACE-Step modules: {e}", file=sys.stderr)
print("Make sure this script is run from the ACE-Step-1.5 directory or with the correct Python environment.", file=sys.stderr)
sys.exit(1)
# Load dataset JSON
print(f"Loading dataset: {args.dataset}")
with open(args.dataset, "r") as f:
dataset_data = json.load(f)
# Reconstruct DatasetBuilder from JSON
builder = DatasetBuilder()
builder.load_from_dict(dataset_data)
labeled_count = sum(1 for s in builder.samples if s.labeled)
total_count = len(builder.samples)
print(f"Dataset loaded: {total_count} samples, {labeled_count} labeled")
if labeled_count == 0:
msg = "No labeled samples found. Please label samples before preprocessing."
print(f"Warning: {msg}", file=sys.stderr)
if args.json:
print(json.dumps({"status": "error", "message": msg, "labeled": 0, "total": total_count}))
sys.exit(1)
# Load models for preprocessing
print("Loading models for preprocessing (this may take a moment)...")
try:
from acestep.pipeline_ace_step import ACEStepPipeline
checkpoint_dir = os.path.join(ace_step_root, "checkpoints")
if not os.path.isdir(checkpoint_dir):
checkpoint_dir = os.path.join(ace_step_root, "checkpoints", "ACE-Step-v1.5")
pipe = ACEStepPipeline(checkpoint_dir=checkpoint_dir)
pipe.load_checkpoint()
# Create a minimal dit_handler-like object for preprocess_to_tensors
class DitHandlerProxy:
def __init__(self, pipeline):
self.model = pipeline.dit
self.vae = pipeline.vae
self.text_encoder = pipeline.text_encoder
self.text_tokenizer = pipeline.text_tokenizer
self.silence_latent = getattr(pipeline, "silence_latent", None)
self.device = pipeline.device
self.dtype = pipeline.dtype
handler = DitHandlerProxy(pipe)
except Exception as e:
# If pipeline loading fails, try a simpler approach
print(f"Warning: Could not load full pipeline: {e}", file=sys.stderr)
print("Preprocessing requires model access. Please use the Gradio UI for preprocessing.", file=sys.stderr)
if args.json:
print(json.dumps({
"status": "error",
"message": f"Model loading failed: {str(e)}. Use Gradio UI preprocess instead.",
"labeled": labeled_count,
"total": total_count,
}))
sys.exit(1)
# Run preprocessing
os.makedirs(args.output, exist_ok=True)
print(f"Preprocessing to: {args.output}")
def progress_cb(msg):
print(f" {msg}")
output_paths, status = builder.preprocess_to_tensors(
dit_handler=handler,
output_dir=args.output,
max_duration=args.max_duration,
progress_callback=progress_cb,
)
print(f"Done: {status}")
print(f"Output files: {len(output_paths)}")
if args.json:
print(json.dumps({
"status": "complete",
"message": status,
"output_files": len(output_paths),
"output_dir": args.output,
"labeled": labeled_count,
"total": total_count,
}))
if __name__ == "__main__":
main()
+6
View File
@@ -35,6 +35,12 @@ export const config = {
audioDir: process.env.AUDIO_DIR || path.join(__dirname, '../../public/audio'),
},
// Training datasets (inside ACE-Step-1.5 so Gradio can access them)
datasets: {
dir: process.env.DATASETS_DIR || path.join(__dirname, '../../../ACE-Step-1.5/datasets'),
uploadsDir: process.env.DATASETS_UPLOADS_DIR || path.join(__dirname, '../../../ACE-Step-1.5/datasets/uploads'),
},
// Simplified JWT (for local session, not critical security)
jwt: {
secret: process.env.JWT_SECRET || 'ace-step-ui-local-secret',
+1
View File
@@ -19,6 +19,7 @@ try {
const dbInstance = new Database(config.database.path);
dbInstance.pragma('journal_mode = WAL');
dbInstance.pragma('foreign_keys = ON');
dbInstance.pragma('busy_timeout = 5000');
export { dbInstance as db };
+2
View File
@@ -24,6 +24,7 @@ import playlistsRoutes from './routes/playlists.js';
import contactRoutes from './routes/contact.js';
import referenceTrackRoutes from './routes/referenceTrack.js';
import loraRoutes from './routes/lora.js';
import trainingRoutes from './routes/training.js';
import { pool } from './db/pool.js';
import './db/migrate.js';
@@ -405,6 +406,7 @@ app.use('/api/playlists', playlistsRoutes);
app.use('/api/contact', contactRoutes);
app.use('/api/reference-tracks', referenceTrackRoutes);
app.use('/api/lora', loraRoutes);
app.use('/api/training', trainingRoutes);
// Error handler
app.use((err: Error, _req: express.Request, res: express.Response, _next: express.NextFunction) => {
+1 -15
View File
@@ -36,21 +36,7 @@ router.post('/', async (req: Request, res: Response) => {
return;
}
// Create table if not exists
await pool.query(`
CREATE TABLE IF NOT EXISTS contact_submissions (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
name VARCHAR(255) NOT NULL,
email VARCHAR(255) NOT NULL,
subject VARCHAR(500) NOT NULL,
message TEXT NOT NULL,
category VARCHAR(50) DEFAULT 'general',
is_read BOOLEAN DEFAULT FALSE,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
)
`);
// Insert submission
// Insert submission (table created in migrate.ts)
const result = await pool.query(
`INSERT INTO contact_submissions (name, email, subject, message, category)
VALUES ($1, $2, $3, $4, $5)
+122 -12
View File
@@ -4,7 +4,9 @@ import path from 'path';
import { fileURLToPath } from 'url';
import { pool } from '../db/pool.js';
import { generateUUID } from '../db/sqlite.js';
import { config } from '../config/index.js';
import { authMiddleware, AuthenticatedRequest } from '../middleware/auth.js';
import { getGradioClient } from '../services/gradio-client.js';
import {
generateMusicViaAPI,
getJobStatus,
@@ -125,7 +127,15 @@ interface GenerateBody {
isFormatCaption?: boolean;
}
router.post('/upload-audio', authMiddleware, audioUpload.single('audio'), async (req: AuthenticatedRequest, res: Response) => {
router.post('/upload-audio', authMiddleware, (req: AuthenticatedRequest, res: Response, next: Function) => {
audioUpload.single('audio')(req, res, (err: any) => {
if (err) {
res.status(400).json({ error: err.message || 'Invalid file upload' });
return;
}
next();
});
}, async (req: AuthenticatedRequest, res: Response) => {
try {
if (!req.file) {
res.status(400).json({ error: 'Audio file is required' });
@@ -161,7 +171,7 @@ router.post('/upload-audio', authMiddleware, audioUpload.single('audio'), async
const ext = extFromName || extFromType || '.audio';
const key = `references/${req.user!.id}/${Date.now()}-${generateUUID()}${ext}`;
const storedKey = await storage.upload(key, req.file.buffer, req.file.mimetype);
const publicUrl = storedKey;
const publicUrl = storage.getPublicUrl(storedKey);
res.json({ url: publicUrl, key: storedKey });
} catch (error) {
@@ -351,6 +361,7 @@ router.get('/status/:jobId', authMiddleware, async (req: AuthenticatedRequest, r
const aceStatus = await getJobStatus(job.acestep_task_id);
if (aceStatus.status !== job.status) {
// Use optimistic lock: only update if status hasn't changed (prevents duplicate song creation)
let updateQuery = `UPDATE generation_jobs SET status = ?, updated_at = datetime('now')`;
const updateParams: unknown[] = [aceStatus.status];
@@ -362,17 +373,19 @@ router.get('/status/:jobId', authMiddleware, async (req: AuthenticatedRequest, r
updateParams.push(aceStatus.error);
}
updateQuery += ` WHERE id = ?`;
updateParams.push(req.params.jobId);
updateQuery += ` WHERE id = ? AND status = ?`;
updateParams.push(req.params.jobId, job.status);
await pool.query(updateQuery, updateParams);
const updateResult = await pool.query(updateQuery, updateParams);
const wasUpdated = updateResult.rowCount > 0;
// If succeeded, create song records
if (aceStatus.status === 'succeeded' && aceStatus.result) {
// If succeeded AND we were the first to update (optimistic lock), create song records
if (aceStatus.status === 'succeeded' && aceStatus.result && wasUpdated) {
const params = typeof job.params === 'string' ? JSON.parse(job.params) : job.params;
const audioUrls = aceStatus.result.audioUrls.filter((url: string) =>
url.endsWith('.mp3') || url.endsWith('.flac')
);
const audioUrls = aceStatus.result.audioUrls.filter((url: string) => {
const lower = url.toLowerCase();
return lower.endsWith('.mp3') || lower.endsWith('.flac') || lower.endsWith('.wav');
});
const localPaths: string[] = [];
const storage = getStorageProvider();
@@ -554,6 +567,103 @@ router.get('/endpoints', authMiddleware, async (_req: AuthenticatedRequest, res:
}
});
router.get('/models', async (_req, res: Response) => {
try {
const ACESTEP_DIR = process.env.ACESTEP_PATH || path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../../../ACE-Step-1.5');
const checkpointsDir = path.join(ACESTEP_DIR, 'checkpoints');
// All known DiT models from Gradio's model_downloader.py registry:
// - MAIN_MODEL_COMPONENTS includes "acestep-v15-turbo" (bundled with main download)
// - SUBMODEL_REGISTRY includes the rest (separate HuggingFace repos, auto-downloaded on init)
const ALL_DIT_MODELS = [
'acestep-v15-turbo', // default, from main model repo
'acestep-v15-base', // submodel
'acestep-v15-sft', // submodel
'acestep-v15-turbo-shift1', // submodel
'acestep-v15-turbo-shift3', // submodel
'acestep-v15-turbo-continuous', // submodel
];
// Query Gradio /v1/models to get the currently loaded/active model
let activeModel: string | null = null;
try {
const apiRes = await fetch(`${config.acestep.apiUrl}/v1/models`);
if (apiRes.ok) {
const data = await apiRes.json() as any;
const gradioModels = data?.data?.models || data?.models || [];
if (gradioModels.length > 0) {
activeModel = gradioModels[0]?.name || null;
}
}
} catch {
// Gradio API unavailable
}
// Check which models are downloaded (exist on disk)
// Matches Gradio's handler.py check_model_exists() and get_available_acestep_v15_models()
const { existsSync, statSync } = await import('fs');
const downloaded = new Set<string>();
for (const model of ALL_DIT_MODELS) {
const modelPath = path.join(checkpointsDir, model);
try {
if (existsSync(modelPath) && statSync(modelPath).isDirectory()) {
downloaded.add(model);
}
} catch { /* skip */ }
}
// Also scan for any additional acestep-v15-* models on disk not in the registry
// (e.g. user-trained or community models)
try {
const { readdirSync } = await import('fs');
for (const entry of readdirSync(checkpointsDir)) {
if (entry.startsWith('acestep-v15-') && statSync(path.join(checkpointsDir, entry)).isDirectory()) {
downloaded.add(entry);
if (!ALL_DIT_MODELS.includes(entry)) {
ALL_DIT_MODELS.push(entry);
}
}
}
} catch { /* checkpoints dir may not exist */ }
const models = ALL_DIT_MODELS.map(name => ({
name,
is_active: name === activeModel,
is_preloaded: downloaded.has(name),
}));
// Sort: active first, then downloaded, then alphabetical
models.sort((a, b) => {
if (a.is_active !== b.is_active) return a.is_active ? -1 : 1;
if (a.is_preloaded !== b.is_preloaded) return a.is_preloaded ? -1 : 1;
return a.name.localeCompare(b.name);
});
res.json({ models });
} catch (error) {
console.error('Models error:', error);
res.status(500).json({ error: (error as Error).message });
}
});
// GET /api/generate/random-description — Load a random simple description from Gradio
router.get('/random-description', authMiddleware, async (_req: AuthenticatedRequest, res: Response) => {
try {
const client = await getGradioClient();
const result = await client.predict('/load_random_simple_description', []);
const data = result.data as unknown[];
// Returns [description, instrumental, vocal_language]
res.json({
description: data[0] || '',
instrumental: data[1] || false,
vocalLanguage: data[2] || 'unknown',
});
} catch (error) {
console.error('Random description error:', error);
res.status(500).json({ error: (error as Error).message });
}
});
router.get('/health', async (_req, res: Response) => {
try {
const healthy = await checkSpaceHealth();
@@ -566,7 +676,7 @@ router.get('/health', async (_req, res: Response) => {
router.get('/limits', async (_req, res: Response) => {
try {
const { spawn } = await import('child_process');
const ACESTEP_DIR = process.env.ACESTEP_PATH || path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../../../../ACE-Step-1.5');
const ACESTEP_DIR = process.env.ACESTEP_PATH || path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../../../ACE-Step-1.5');
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
const SCRIPTS_DIR = path.join(__dirname, '../../scripts');
@@ -642,7 +752,7 @@ router.post('/format', authMiddleware, async (req: AuthenticatedRequest, res: Re
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 ACESTEP_DIR = process.env.ACESTEP_PATH || path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../../../ACE-Step-1.5');
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
const SCRIPTS_DIR = path.join(__dirname, '../../scripts');
+1 -11
View File
@@ -519,13 +519,9 @@ router.get('/liked/list', authMiddleware, async (req: AuthenticatedRequest, res:
}
});
// Toggle song privacy (paid users only can make songs private)
// Toggle song privacy
router.patch('/:id/privacy', authMiddleware, async (req: AuthenticatedRequest, res: Response) => {
try {
// Get user's account tier
const userResult = await pool.query('SELECT account_tier FROM users WHERE id = $1', [req.user!.id]);
const accountTier = userResult.rows[0]?.account_tier || 'free';
const check = await pool.query('SELECT user_id, is_public FROM songs WHERE id = $1', [req.params.id]);
if (check.rows.length === 0) {
res.status(404).json({ error: 'Song not found' });
@@ -538,12 +534,6 @@ router.patch('/:id/privacy', authMiddleware, async (req: AuthenticatedRequest, r
const newPublicState = !check.rows[0].is_public;
// Free users cannot make songs private
if (accountTier === 'free' && !newPublicState) {
res.status(403).json({ error: 'Upgrade to Pro or Unlimited to make songs private' });
return;
}
await pool.query('UPDATE songs SET is_public = $1, updated_at = CURRENT_TIMESTAMP WHERE id = $2', [
newPublicState,
req.params.id,
+870
View File
@@ -0,0 +1,870 @@
import { Router, Request, Response } from 'express';
import { authMiddleware, AuthenticatedRequest } from '../middleware/auth.js';
import { getGradioClient } from '../services/gradio-client.js';
import { config } from '../config/index.js';
import { resolvePythonPath } from '../services/acestep.js';
import multer from 'multer';
import path from 'path';
import { existsSync, readdirSync, statSync, readFileSync } from 'fs';
import { mkdir, writeFile, readFile } from 'fs/promises';
import { execSync, spawn } from 'child_process';
import { randomUUID } from 'crypto';
const router = Router();
// --- Audio upload via multer disk storage ---
const AUDIO_EXTENSIONS = ['.wav', '.mp3', '.flac', '.ogg', '.opus'];
const audioStorage = multer.diskStorage({
destination: async (_req: Request, _file, cb) => {
const datasetName = (_req.body?.datasetName as string) || 'default';
const dest = path.join(config.datasets.uploadsDir, datasetName);
try {
await mkdir(dest, { recursive: true });
cb(null, dest);
} catch (err) {
cb(err as Error, dest);
}
},
filename: (_req, file, cb) => {
// Preserve original filename but ensure uniqueness
const ext = path.extname(file.originalname).toLowerCase();
const base = path.basename(file.originalname, ext);
const safeName = base.replace(/[^a-zA-Z0-9_\-. ]/g, '_');
cb(null, `${safeName}${ext}`);
},
});
const audioUpload = multer({
storage: audioStorage,
limits: { fileSize: 100 * 1024 * 1024 }, // 100MB per file
fileFilter: (_req, file, cb) => {
const ext = path.extname(file.originalname).toLowerCase();
if (AUDIO_EXTENSIONS.includes(ext)) {
cb(null, true);
} else {
cb(new Error(`Unsupported file type: ${ext}. Allowed: ${AUDIO_EXTENSIONS.join(', ')}`));
}
},
});
// Get audio duration via 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 {
return 0;
}
}
// Resolve ACE-Step base directory
function getAceStepDir(): string {
const envPath = process.env.ACESTEP_PATH;
if (envPath) {
return path.isAbsolute(envPath) ? envPath : path.resolve(process.cwd(), envPath);
}
return path.resolve(config.datasets.dir, '..');
}
// ================== NEW ROUTES ==================
// POST /api/training/upload-audio — Upload audio files for a dataset
router.post('/upload-audio', authMiddleware, audioUpload.array('audio', 50), async (req: AuthenticatedRequest, res: Response) => {
try {
const files = req.files as Express.Multer.File[];
if (!files || files.length === 0) {
res.status(400).json({ error: 'No audio files uploaded' });
return;
}
const datasetName = (req.body?.datasetName as string) || 'default';
const uploadDir = path.join(config.datasets.uploadsDir, datasetName);
res.json({
files: files.map(f => ({
filename: f.filename,
originalName: f.originalname,
size: f.size,
path: f.path,
})),
uploadDir,
count: files.length,
});
} catch (error) {
console.error('[Training] Upload audio error:', error);
res.status(500).json({ error: error instanceof Error ? error.message : 'Upload failed' });
}
});
// POST /api/training/build-dataset — Scan audio directory + create dataset JSON
router.post('/build-dataset', authMiddleware, async (req: AuthenticatedRequest, res: Response) => {
try {
const {
datasetName = 'my_lora_dataset',
customTag = '',
tagPosition = 'prepend',
allInstrumental = true,
} = req.body;
const audioDir = path.join(config.datasets.uploadsDir, datasetName);
if (!existsSync(audioDir)) {
res.status(400).json({ error: `Audio directory not found: uploads/${datasetName}` });
return;
}
// Scan for audio files
const entries = readdirSync(audioDir);
const audioFiles = entries.filter(f => AUDIO_EXTENSIONS.includes(path.extname(f).toLowerCase()));
if (audioFiles.length === 0) {
res.status(400).json({ error: 'No audio files found in directory' });
return;
}
// Build samples in Gradio's exact format
const samples = audioFiles.map(filename => {
const audioPath = path.join(audioDir, filename);
const duration = getAudioDuration(audioPath);
const baseName = path.basename(filename, path.extname(filename));
// Check for companion .txt lyrics file
let rawLyrics = '';
const lyricsPath = path.join(audioDir, `${baseName}.txt`);
if (existsSync(lyricsPath)) {
try {
rawLyrics = readFileSync(lyricsPath, 'utf-8').trim();
} catch { /* ignore */ }
}
const isInstrumental = allInstrumental || !rawLyrics;
return {
id: randomUUID().slice(0, 8),
audio_path: audioPath,
filename,
caption: '',
genre: '',
lyrics: isInstrumental ? '[Instrumental]' : rawLyrics,
raw_lyrics: rawLyrics,
formatted_lyrics: '',
bpm: null as number | null,
keyscale: '',
timesignature: '',
duration,
language: isInstrumental ? 'instrumental' : 'unknown',
is_instrumental: isInstrumental,
custom_tag: customTag,
labeled: false,
prompt_override: null as string | null,
};
});
// Build dataset JSON
const dataset = {
metadata: {
name: datasetName,
custom_tag: customTag,
tag_position: tagPosition,
created_at: new Date().toISOString(),
num_samples: samples.length,
all_instrumental: allInstrumental,
genre_ratio: 0,
},
samples,
};
// Save JSON to datasets dir
await mkdir(config.datasets.dir, { recursive: true });
const jsonPath = path.join(config.datasets.dir, `${datasetName}.json`);
await writeFile(jsonPath, JSON.stringify(dataset, null, 2), 'utf-8');
// Now load into Gradio state via the existing endpoint
try {
const client = await getGradioClient();
const result = await client.predict('/load_existing_dataset_for_preprocess', [jsonPath]);
const data = result.data as unknown[];
res.json({
status: data[0],
dataframe: data[1],
sampleCount: samples.length,
sample: {
index: data[2],
audio: data[3],
filename: data[4],
caption: data[5],
genre: data[6],
promptOverride: data[7],
lyrics: data[8],
bpm: data[9],
key: data[10],
timeSignature: data[11],
duration: data[12],
language: data[13],
instrumental: data[14],
rawLyrics: data[15],
},
settings: {
datasetName: data[16],
customTag: data[17],
tagPosition: data[18],
allInstrumental: data[19],
genreRatio: data[20],
},
datasetPath: jsonPath,
});
} catch (gradioError) {
// Gradio may not be running — still return dataset info
console.warn('[Training] Gradio load failed, returning dataset JSON only:', gradioError);
res.json({
status: `Dataset saved (${samples.length} samples). Gradio not available for live preview.`,
dataframe: null,
sampleCount: samples.length,
sample: samples.length > 0 ? {
index: 0,
audio: null,
filename: samples[0].filename,
caption: samples[0].caption,
genre: samples[0].genre,
promptOverride: null,
lyrics: samples[0].lyrics,
bpm: samples[0].bpm,
key: samples[0].keyscale,
timeSignature: samples[0].timesignature,
duration: samples[0].duration,
language: samples[0].language,
instrumental: samples[0].is_instrumental,
rawLyrics: samples[0].raw_lyrics,
} : null,
settings: {
datasetName,
customTag,
tagPosition,
allInstrumental,
genreRatio: 0,
},
datasetPath: jsonPath,
});
}
} catch (error) {
console.error('[Training] Build dataset error:', error);
res.status(500).json({ error: error instanceof Error ? error.message : 'Failed to build dataset' });
}
});
// GET /api/training/audio — Proxy audio files from datasets directory
router.get('/audio', authMiddleware, async (req: AuthenticatedRequest, res: Response) => {
try {
let filePath: string;
const aceStepDir = getAceStepDir();
if (req.query.path) {
filePath = req.query.path as string;
} else if (req.query.file) {
// Relative path within datasets dir
filePath = path.join(config.datasets.dir, req.query.file as string);
} else {
res.status(400).json({ error: 'path or file parameter required' });
return;
}
// Path traversal protection
const resolved = path.resolve(filePath);
if (resolved.includes('..') || !resolved.startsWith(aceStepDir)) {
res.status(403).json({ error: 'Access denied: path outside ACE-Step directory' });
return;
}
if (!existsSync(resolved)) {
res.status(404).json({ error: 'Audio file not found' });
return;
}
// Determine content type
const ext = path.extname(resolved).toLowerCase();
const mimeTypes: Record<string, string> = {
'.wav': 'audio/wav',
'.mp3': 'audio/mpeg',
'.flac': 'audio/flac',
'.ogg': 'audio/ogg',
'.opus': 'audio/opus',
};
res.setHeader('Content-Type', mimeTypes[ext] || 'application/octet-stream');
res.sendFile(resolved);
} catch (error) {
console.error('[Training] Audio proxy error:', error);
res.status(500).json({ error: error instanceof Error ? error.message : 'Failed to serve audio' });
}
});
// POST /api/training/preprocess — Spawn Python preprocessing script
router.post('/preprocess', authMiddleware, async (req: AuthenticatedRequest, res: Response) => {
try {
const { datasetPath, outputDir } = req.body;
if (!datasetPath) {
res.status(400).json({ error: 'datasetPath is required' });
return;
}
const aceStepDir = getAceStepDir();
const scriptPath = path.resolve(__dirname, '../../scripts/preprocess_dataset.py');
const pythonPath = resolvePythonPath(aceStepDir);
const resolvedOutput = outputDir || path.join(config.datasets.dir, 'preprocessed_tensors');
// Ensure output dir exists
await mkdir(resolvedOutput, { recursive: true });
// Spawn Python process
const child = spawn(pythonPath, [
scriptPath,
'--dataset', datasetPath,
'--output', resolvedOutput,
'--json',
], {
cwd: aceStepDir,
env: { ...process.env },
});
let stdout = '';
let stderr = '';
child.stdout.on('data', (data: Buffer) => { stdout += data.toString(); });
child.stderr.on('data', (data: Buffer) => { stderr += data.toString(); });
child.on('close', (code: number | null) => {
if (code === 0) {
// Try to parse JSON output
try {
const result = JSON.parse(stdout.trim().split('\n').pop() || '{}');
res.json({ status: 'Preprocessing complete', ...result });
} catch {
res.json({ status: 'Preprocessing complete', output: stdout.trim() });
}
} else {
res.status(500).json({
error: 'Preprocessing failed',
code,
stderr: stderr.trim(),
stdout: stdout.trim(),
});
}
});
child.on('error', (err: Error) => {
res.status(500).json({ error: `Failed to spawn process: ${err.message}` });
});
} catch (error) {
console.error('[Training] Preprocess error:', error);
res.status(500).json({ error: error instanceof Error ? error.message : 'Preprocessing failed' });
}
});
// POST /api/training/scan-directory — Scan a directory for audio files (Node.js implementation)
router.post('/scan-directory', authMiddleware, async (req: AuthenticatedRequest, res: Response) => {
try {
const {
audioDir,
datasetName = 'my_lora_dataset',
customTag = '',
tagPosition = 'prepend',
allInstrumental = true,
} = req.body;
if (!audioDir || typeof audioDir !== 'string') {
res.status(400).json({ error: 'audioDir is required' });
return;
}
// Resolve path — if relative, resolve from ACE-Step dir
const aceStepDir = getAceStepDir();
const resolvedDir = path.isAbsolute(audioDir)
? audioDir
: path.resolve(aceStepDir, audioDir);
if (!existsSync(resolvedDir)) {
res.status(400).json({ error: `Directory not found: ${audioDir}` });
return;
}
// Scan for audio files
const entries = readdirSync(resolvedDir);
const audioFiles = entries.filter(f => AUDIO_EXTENSIONS.includes(path.extname(f).toLowerCase()));
if (audioFiles.length === 0) {
res.status(400).json({ error: 'No audio files found in directory' });
return;
}
// Build table data matching Gradio's format: [#, Filename, Duration, Lyrics, Labeled, BPM, Key, Caption]
const tableHeaders = ['#', 'Filename', 'Duration', 'Lyrics', 'Labeled', 'BPM', 'Key', 'Caption'];
const tableData = audioFiles.map((filename, i) => {
const audioPath = path.join(resolvedDir, filename);
const duration = getAudioDuration(audioPath);
const baseName = path.basename(filename, path.extname(filename));
// Check for companion .txt lyrics file
let lyrics = allInstrumental ? '[Instrumental]' : '';
const lyricsPath = path.join(resolvedDir, `${baseName}.txt`);
if (existsSync(lyricsPath)) {
try {
lyrics = readFileSync(lyricsPath, 'utf-8').trim().slice(0, 50) + '...';
} catch { /* ignore */ }
}
return [i + 1, filename, `${duration}s`, lyrics, '❌', '', '', ''];
});
res.json({
status: `Found ${audioFiles.length} audio files`,
dataframe: {
headers: tableHeaders,
data: tableData,
},
sampleCount: audioFiles.length,
audioDir: resolvedDir,
});
} catch (error) {
console.error('[Training] Scan directory error:', error);
res.status(500).json({ error: error instanceof Error ? error.message : 'Failed to scan directory' });
}
});
// POST /api/training/auto-label — Auto-label dataset samples
// NOTE: Auto-labeling requires the DIT model + LLM to be loaded in Gradio.
// This endpoint attempts to call the Gradio handler. If the Gradio app does not
// expose auto_label_all as a named API, this will fail and the user should use
// the Gradio UI directly.
router.post('/auto-label', authMiddleware, async (req: AuthenticatedRequest, res: Response) => {
try {
const {
skipMetas = false,
formatLyrics = false,
transcribeLyrics = false,
onlyUnlabeled = false,
} = req.body;
// auto_label_all is a lambda-wrapped handler in Gradio, so it may not be accessible
// by name. We try the likely endpoint name; if it fails, return a helpful message.
const client = await getGradioClient();
try {
const result = await client.predict('/auto_label_all', [
skipMetas,
formatLyrics,
transcribeLyrics,
onlyUnlabeled,
]);
const data = result.data as unknown[];
res.json({
dataframe: data[0],
status: data[1],
});
} catch (gradioError) {
// Lambda endpoints aren't named — suggest using Gradio UI
res.status(501).json({
error: 'Auto-labeling requires the Gradio UI. The model must be initialized and the dataset loaded in the Gradio training tab.',
hint: 'Use the Gradio UI at the ACE-Step server URL to auto-label your dataset, then reload it here.',
});
}
} catch (error) {
console.error('[Training] Auto-label error:', error);
res.status(500).json({ error: error instanceof Error ? error.message : 'Auto-label failed' });
}
});
// POST /api/training/init-model — Initialize or change model for training
// NOTE: Model initialization requires the Gradio app. This endpoint attempts to
// call the init_service_wrapper. Since it's a lambda, this may not be accessible.
router.post('/init-model', authMiddleware, async (req: AuthenticatedRequest, res: Response) => {
try {
const {
checkpoint,
configPath,
device = 'auto',
initLlm = false,
lmModelPath = '',
backend = 'pt',
useFlashAttention = false,
offloadToCpu = false,
offloadDitToCpu = false,
compileModel = false,
quantization = false,
} = req.body;
const client = await getGradioClient();
try {
// Try calling by function name (may work if Gradio auto-names it)
const result = await client.predict('/init_service_wrapper', [
checkpoint ?? '',
configPath ?? '',
device,
initLlm,
lmModelPath,
backend,
useFlashAttention,
offloadToCpu,
offloadDitToCpu,
compileModel,
quantization,
]);
const data = result.data as unknown[];
res.json({
status: data[0],
modelReady: !!data[1],
});
} catch (gradioError) {
// Lambda endpoints aren't named — suggest using Gradio UI
res.status(501).json({
error: 'Model initialization requires the Gradio UI.',
hint: 'Initialize the model in the ACE-Step Gradio UI service configuration section, then return here for training.',
});
}
} catch (error) {
console.error('[Training] Init model error:', error);
res.status(500).json({ error: error instanceof Error ? error.message : 'Model init failed' });
}
});
// GET /api/training/checkpoints — List available model checkpoints
router.get('/checkpoints', authMiddleware, async (_req: AuthenticatedRequest, res: Response) => {
try {
const aceStepDir = getAceStepDir();
const checkpointDir = path.join(aceStepDir, 'checkpoints');
if (!existsSync(checkpointDir)) {
res.json({ checkpoints: [], configs: [] });
return;
}
// List checkpoint directories
const entries = readdirSync(checkpointDir);
const checkpoints = entries.filter(e => {
const fullPath = path.join(checkpointDir, e);
return statSync(fullPath).isDirectory();
});
// List config directories (acestep-v15-*)
const configDirs = entries.filter(e =>
e.startsWith('acestep-v15') && statSync(path.join(checkpointDir, e)).isDirectory()
);
res.json({ checkpoints, configs: configDirs });
} catch (error) {
console.error('[Training] List checkpoints error:', error);
res.status(500).json({ error: error instanceof Error ? error.message : 'Failed to list checkpoints' });
}
});
// GET /api/training/lora-checkpoints — List LoRA training checkpoints in output dir
router.get('/lora-checkpoints', authMiddleware, async (req: AuthenticatedRequest, res: Response) => {
try {
const outputDir = (req.query.dir as string) || './lora_output';
const aceStepDir = getAceStepDir();
const resolvedDir = path.isAbsolute(outputDir)
? outputDir
: path.resolve(aceStepDir, outputDir);
if (!existsSync(resolvedDir)) {
res.json({ checkpoints: [] });
return;
}
const entries = readdirSync(resolvedDir);
const checkpointsDir = path.join(resolvedDir, 'checkpoints');
const checkpoints: string[] = [];
if (existsSync(checkpointsDir)) {
const cpEntries = readdirSync(checkpointsDir);
cpEntries.forEach(e => {
if (statSync(path.join(checkpointsDir, e)).isDirectory()) {
checkpoints.push(path.join(checkpointsDir, e));
}
});
}
// Also check for "final" directory
const finalDir = path.join(resolvedDir, 'final');
if (existsSync(finalDir)) {
checkpoints.push(finalDir);
}
res.json({ checkpoints, outputDir: resolvedDir });
} catch (error) {
console.error('[Training] List LoRA checkpoints error:', error);
res.status(500).json({ error: error instanceof Error ? error.message : 'Failed to list checkpoints' });
}
});
// ================== EXISTING ROUTES ==================
// POST /api/training/load-dataset — Load an existing dataset JSON for preprocessing
router.post('/load-dataset', authMiddleware, async (req: AuthenticatedRequest, res: Response) => {
try {
const { datasetPath } = req.body;
if (!datasetPath || typeof datasetPath !== 'string') {
res.status(400).json({ error: 'datasetPath is required' });
return;
}
// Reject path traversal
if (datasetPath.includes('..')) {
res.status(400).json({ error: 'Invalid path' });
return;
}
const client = await getGradioClient();
const result = await client.predict('/load_existing_dataset_for_preprocess', [datasetPath]);
const data = result.data as unknown[];
// Returns: [status, dataframe, sampleIdx, audioPreview, filename, caption, genre,
// promptOverride, lyrics, bpm, key, timesig, duration, language, instrumental,
// rawLyrics, datasetName, customTag, tagPosition, allInstrumental, genreRatio]
res.json({
status: data[0],
dataframe: data[1],
sampleCount: Array.isArray((data[1] as any)?.data) ? (data[1] as any).data.length : 0,
sample: {
index: data[2],
audio: data[3],
filename: data[4],
caption: data[5],
genre: data[6],
promptOverride: data[7],
lyrics: data[8],
bpm: data[9],
key: data[10],
timeSignature: data[11],
duration: data[12],
language: data[13],
instrumental: data[14],
rawLyrics: data[15],
},
settings: {
datasetName: data[16],
customTag: data[17],
tagPosition: data[18],
allInstrumental: data[19],
genreRatio: data[20],
},
});
} catch (error) {
console.error('[Training] Load dataset error:', error);
res.status(500).json({ error: error instanceof Error ? error.message : 'Failed to load dataset' });
}
});
// GET /api/training/sample-preview — Get preview data for a specific sample
router.get('/sample-preview', authMiddleware, async (req: AuthenticatedRequest, res: Response) => {
try {
const idx = parseInt(req.query.idx as string) || 0;
const client = await getGradioClient();
const result = await client.predict('/get_sample_preview', [idx]);
const data = result.data as unknown[];
// Returns: [audio, filename, caption, genre, promptOverride, lyrics, bpm, key, timesig, duration, language, instrumental, rawLyrics]
res.json({
audio: data[0],
filename: data[1],
caption: data[2],
genre: data[3],
promptOverride: data[4],
lyrics: data[5],
bpm: data[6],
key: data[7],
timeSignature: data[8],
duration: data[9],
language: data[10],
instrumental: data[11],
rawLyrics: data[12],
});
} catch (error) {
console.error('[Training] Sample preview error:', error);
res.status(500).json({ error: error instanceof Error ? error.message : 'Failed to get sample preview' });
}
});
// POST /api/training/save-sample — Save edits to a dataset sample
router.post('/save-sample', authMiddleware, async (req: AuthenticatedRequest, res: Response) => {
try {
const { sampleIdx, caption, genre, promptOverride, lyrics, bpm, key, timeSignature, language, instrumental } = req.body;
const client = await getGradioClient();
const result = await client.predict('/save_sample_edit', [
sampleIdx ?? 0,
caption ?? '',
genre ?? '',
promptOverride ?? 'Use Global Ratio',
lyrics ?? '',
bpm ?? 120,
key ?? '',
timeSignature ?? '',
language ?? 'instrumental',
instrumental ?? true,
]);
const data = result.data as unknown[];
// Returns: [dataframe, editStatus]
res.json({
dataframe: data[0],
status: data[1],
});
} catch (error) {
console.error('[Training] Save sample error:', error);
res.status(500).json({ error: error instanceof Error ? error.message : 'Failed to save sample edit' });
}
});
// POST /api/training/update-settings — Update dataset global settings
router.post('/update-settings', authMiddleware, async (req: AuthenticatedRequest, res: Response) => {
try {
const { customTag, tagPosition, allInstrumental, genreRatio } = req.body;
const client = await getGradioClient();
await client.predict('/update_settings', [
customTag ?? '',
tagPosition ?? 'replace',
allInstrumental ?? true,
genreRatio ?? 0,
]);
res.json({ success: true });
} catch (error) {
console.error('[Training] Update settings error:', error);
res.status(500).json({ error: error instanceof Error ? error.message : 'Failed to update settings' });
}
});
// POST /api/training/save-dataset — Save the dataset to a JSON file
router.post('/save-dataset', authMiddleware, async (req: AuthenticatedRequest, res: Response) => {
try {
const { savePath, datasetName } = req.body;
const client = await getGradioClient();
const result = await client.predict('/save_dataset', [
savePath ?? './datasets/my_lora_dataset.json',
datasetName ?? 'my_lora_dataset',
]);
const data = result.data as unknown[];
// Returns: [saveStatus, savePath]
res.json({
status: data[0],
path: data[1],
});
} catch (error) {
console.error('[Training] Save dataset error:', error);
res.status(500).json({ error: error instanceof Error ? error.message : 'Failed to save dataset' });
}
});
// POST /api/training/load-tensors — Load preprocessed tensors for training
router.post('/load-tensors', authMiddleware, async (req: AuthenticatedRequest, res: Response) => {
try {
const { tensorDir } = req.body;
const client = await getGradioClient();
const result = await client.predict('/load_training_dataset', [
tensorDir ?? './datasets/preprocessed_tensors',
]);
const data = result.data as unknown[];
res.json({ status: data[0] });
} catch (error) {
console.error('[Training] Load tensors error:', error);
res.status(500).json({ error: error instanceof Error ? error.message : 'Failed to load training dataset' });
}
});
// POST /api/training/start — Start LoRA training
router.post('/start', authMiddleware, async (req: AuthenticatedRequest, res: Response) => {
try {
const {
tensorDir, rank, alpha, dropout, learningRate,
epochs, batchSize, gradientAccumulation, saveEvery,
shift, seed, outputDir, resumeCheckpoint,
} = req.body;
const client = await getGradioClient();
const result = await client.predict('/training_wrapper', [
tensorDir ?? './datasets/preprocessed_tensors',
rank ?? 64,
alpha ?? 128,
dropout ?? 0.1,
learningRate ?? 0.0003,
epochs ?? 1000,
batchSize ?? 1,
gradientAccumulation ?? 1,
saveEvery ?? 200,
shift ?? 3.0,
seed ?? 42,
outputDir ?? './lora_output',
resumeCheckpoint ?? null,
]);
const data = result.data as unknown[];
// Returns: [trainingProgress, trainingLog, lineplotData]
res.json({
progress: data[0],
log: data[1],
metrics: data[2],
});
} catch (error) {
console.error('[Training] Start training error:', error);
res.status(500).json({ error: error instanceof Error ? error.message : 'Failed to start training' });
}
});
// POST /api/training/stop — Stop current training
router.post('/stop', authMiddleware, async (_req: AuthenticatedRequest, res: Response) => {
try {
const client = await getGradioClient();
const result = await client.predict('/stop_training', []);
const data = result.data as unknown[];
res.json({ status: data[0] });
} catch (error) {
console.error('[Training] Stop training error:', error);
res.status(500).json({ error: error instanceof Error ? error.message : 'Failed to stop training' });
}
});
// POST /api/training/export — Export trained LoRA weights
router.post('/export', authMiddleware, async (req: AuthenticatedRequest, res: Response) => {
try {
const { exportPath, loraOutputDir } = req.body;
const client = await getGradioClient();
const result = await client.predict('/export_lora', [
exportPath ?? './lora_output/final_lora',
loraOutputDir ?? './lora_output',
]);
const data = result.data as unknown[];
res.json({ status: data[0] });
} catch (error) {
console.error('[Training] Export LoRA error:', error);
res.status(500).json({ error: error instanceof Error ? error.message : 'Failed to export LoRA' });
}
});
// POST /api/training/import-dataset — Import train/test split
router.post('/import-dataset', authMiddleware, async (req: AuthenticatedRequest, res: Response) => {
try {
const { datasetType } = req.body;
const client = await getGradioClient();
const result = await client.predict('/import_dataset', [
datasetType ?? 'train',
]);
const data = result.data as unknown[];
res.json({ status: data[0] });
} catch (error) {
console.error('[Training] Import dataset error:', error);
res.status(500).json({ error: error instanceof Error ? error.message : 'Failed to import dataset' });
}
});
export default router;
+2
View File
@@ -31,9 +31,11 @@ async function main() {
tier: job.tier as 'free' | 'pro' | 'unlimited',
createdAt: Date.now(),
params: {
customMode: true,
lyrics: 'test',
style: 'test',
title: 'test',
instrumental: false,
duration: 30,
},
run: async () => {
+77 -21
View File
@@ -34,8 +34,8 @@ function resolveAceStepPath(): string {
if (envPath) {
return path.isAbsolute(envPath) ? envPath : path.resolve(process.cwd(), envPath);
}
// Default: sibling directory
return path.resolve(__dirname, '../../../../ACE-Step-1.5');
// 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)
@@ -54,11 +54,22 @@ export function resolvePythonPath(baseDir: string): string {
return portablePath;
}
// Standard venv path (different structure on Windows vs Unix)
if (isWindows) {
return path.join(baseDir, '.venv', 'Scripts', pythonExe);
// 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;
}
}
return path.join(baseDir, '.venv', 'bin', 'python');
// 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();
@@ -99,7 +110,11 @@ async function prepareAudioFile(audioUrl: string | undefined): Promise<unknown>
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 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) {
@@ -113,7 +128,7 @@ async function prepareAudioFile(audioUrl: string | undefined): Promise<unknown>
}
/**
* Build the 45 positional arguments for the Gradio /generation_wrapper endpoint.
* Build the 50 positional arguments for the Gradio /generation_wrapper endpoint.
*/
async function buildGradioArgs(params: GenerationParams): Promise<unknown[]> {
const caption = params.style || 'pop music';
@@ -138,7 +153,7 @@ async function buildGradioArgs(params: GenerationParams): Promise<unknown[]> {
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)
params.batchSize ?? 1, // 12: Batch Size
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
@@ -162,15 +177,20 @@ async function buildGradioArgs(params: GenerationParams): Promise<unknown[]> {
isThinking ? (params.useCotMetas ?? true) : false, // 33: CoT Metas
isThinking ? (params.useCotCaption ?? true) : false, // 34: CaptionRewrite
isThinking ? (params.useCotLanguage ?? true) : false, // 35: CoT Language
params.constrainedDecodingDebug ?? false, // 36: Constrained Decoding Debug
params.allowLmBatch ?? true, // 37: ParallelThinking
params.getScores ?? false, // 38: Auto Score
params.getLrc ?? false, // 39: Auto LRC
params.scoreScale ?? 0.5, // 40: Quality Score Sensitivity
params.lmBatchChunkSize ?? 8, // 41: LM Batch Chunk Size
params.trackName || '', // 42: Track Name
params.completeTrackClasses || [], // 43: Track Names
params.autogen ?? false, // 44: AutoGen
params.isFormatCaption ?? false, // 36: Is Format Caption State
params.constrainedDecodingDebug ?? false, // 37: Constrained Decoding Debug
params.allowLmBatch ?? true, // 38: ParallelThinking
params.getScores ?? false, // 39: Auto Score
params.getLrc ?? false, // 40: Auto LRC
params.scoreScale ?? 0.5, // 41: Quality Score Sensitivity
params.lmBatchChunkSize ?? 8, // 42: LM Batch Chunk Size
params.trackName || null, // 43: Track Name
params.completeTrackClasses || [], // 44: Track Names
params.autogen ?? false, // 45: AutoGen
0, // 46: Current Batch Index
1, // 47: Total Batches
[], // 48: Batch Queue
{}, // 49: Generation Params State
];
}
@@ -191,14 +211,20 @@ async function downloadGradioAudioFile(
return;
}
// Fall back to HTTP download via Gradio URL
// 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());
await writeFile(destPath, buffer);
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;
}
@@ -319,6 +345,9 @@ interface ActiveJob {
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;
@@ -453,6 +482,10 @@ async function processGenerationViaGradio(
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]
@@ -696,7 +729,7 @@ interface PythonResult {
error?: string;
}
function runPythonGeneration(scriptArgs: string[]): Promise<PythonResult> {
function runPythonGeneration(scriptArgs: string[], timeoutMs = 600000): Promise<PythonResult> {
return new Promise((resolve) => {
const pythonPath = resolvePythonPath(ACESTEP_DIR);
const args = [PYTHON_SCRIPT, ...scriptArgs];
@@ -709,6 +742,13 @@ function runPythonGeneration(scriptArgs: string[]): Promise<PythonResult> {
},
});
// 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 = '';
@@ -727,6 +767,7 @@ function runPythonGeneration(scriptArgs: string[]): Promise<PythonResult> {
});
proc.on('close', (code) => {
clearTimeout(timer);
if (code !== 0) {
resolve({ success: false, error: stderr || `Process exited with code ${code}` });
return;
@@ -749,6 +790,7 @@ function runPythonGeneration(scriptArgs: string[]): Promise<PythonResult> {
});
proc.on('error', (err) => {
clearTimeout(timer);
resolve({ success: false, error: err.message });
});
});
@@ -831,6 +873,20 @@ export async function getAudioStream(audioPath: string): Promise<Response> {
}
}
// 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);