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:
@@ -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()
|
||||
@@ -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',
|
||||
|
||||
@@ -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 };
|
||||
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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
@@ -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');
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user