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:
@@ -20,6 +20,8 @@ import { List } from 'lucide-react';
|
||||
import { PlaylistDetail } from './components/PlaylistDetail';
|
||||
import { Toast, ToastType } from './components/Toast';
|
||||
import { SearchPage } from './components/SearchPage';
|
||||
import { TrainingPanel } from './components/TrainingPanel';
|
||||
import { NewsPage } from './components/NewsPage';
|
||||
import { ConfirmDialog } from './components/ConfirmDialog';
|
||||
|
||||
|
||||
@@ -106,6 +108,7 @@ function AppContent() {
|
||||
const [reuseData, setReuseData] = useState<{ song: Song, timestamp: number } | null>(null);
|
||||
|
||||
const audioRef = useRef<HTMLAudioElement | null>(null);
|
||||
const selectedSongRef = useRef<Song | null>(null);
|
||||
const currentSongIdRef = useRef<string | null>(null);
|
||||
const pendingSeekRef = useRef<number | null>(null);
|
||||
const playNextRef = useRef<() => void>(() => {});
|
||||
@@ -164,6 +167,9 @@ function AppContent() {
|
||||
}
|
||||
}, [token]);
|
||||
|
||||
// Keep selectedSongRef in sync for use in callbacks without stale closures
|
||||
useEffect(() => { selectedSongRef.current = selectedSong; }, [selectedSong]);
|
||||
|
||||
// Cleanup active jobs on unmount
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
@@ -280,6 +286,8 @@ function AppContent() {
|
||||
}
|
||||
} else if (path === '/search') {
|
||||
setCurrentView('search');
|
||||
} else if (path === '/news') {
|
||||
setCurrentView('news');
|
||||
}
|
||||
};
|
||||
|
||||
@@ -622,7 +630,8 @@ function AppContent() {
|
||||
});
|
||||
|
||||
// If the current selection was a temp/generating song, replace it with newest real song
|
||||
if (selectedSong?.isGenerating || (selectedSong && !loadedSongs.some(s => s.id === selectedSong.id))) {
|
||||
const current = selectedSongRef.current;
|
||||
if (current?.isGenerating || (current && !loadedSongs.some(s => s.id === current.id))) {
|
||||
setSelectedSong(loadedSongs[0] ?? null);
|
||||
}
|
||||
} catch (error) {
|
||||
@@ -1040,7 +1049,7 @@ function AppContent() {
|
||||
if (songToAddToPlaylist) {
|
||||
await playlistsApi.addSong(res.playlist.id, songToAddToPlaylist.id, token);
|
||||
setSongToAddToPlaylist(null);
|
||||
playlistsApi.getMyPlaylists(token).then(r => setPlaylists(r.playlists));
|
||||
playlistsApi.getMyPlaylists(token).then(r => setPlaylists(r.playlists)).catch(() => {});
|
||||
}
|
||||
showToast(t('playlistCreated'));
|
||||
} catch (error) {
|
||||
@@ -1060,7 +1069,7 @@ function AppContent() {
|
||||
await playlistsApi.addSong(playlistId, songToAddToPlaylist.id, token);
|
||||
setSongToAddToPlaylist(null);
|
||||
showToast(t('songAddedToPlaylist'));
|
||||
playlistsApi.getMyPlaylists(token).then(r => setPlaylists(r.playlists));
|
||||
playlistsApi.getMyPlaylists(token).then(r => setPlaylists(r.playlists)).catch(() => {});
|
||||
} catch (error) {
|
||||
console.error('Add song error:', error);
|
||||
showToast(t('failedToAddSong'), 'error');
|
||||
@@ -1212,6 +1221,12 @@ function AppContent() {
|
||||
/>
|
||||
);
|
||||
|
||||
case 'training':
|
||||
return <TrainingPanel />;
|
||||
|
||||
case 'news':
|
||||
return <NewsPage />;
|
||||
|
||||
case 'create':
|
||||
default:
|
||||
return (
|
||||
@@ -1311,6 +1326,8 @@ function AppContent() {
|
||||
window.history.pushState({}, '', '/library');
|
||||
} else if (v === 'search') {
|
||||
window.history.pushState({}, '', '/search');
|
||||
} else if (v === 'news') {
|
||||
window.history.pushState({}, '', '/news');
|
||||
}
|
||||
if (isMobile) setShowLeftSidebar(false);
|
||||
}}
|
||||
|
||||
+197
-136
@@ -4,7 +4,7 @@ import { GenerationParams, Song } from '../types';
|
||||
import { useAuth } from '../context/AuthContext';
|
||||
import { useI18n } from '../context/I18nContext';
|
||||
import { generateApi } from '../services/api';
|
||||
import { MAIN_STYLES, SUB_STYLES } from '../data/genres';
|
||||
import { MAIN_STYLES } from '../data/genres';
|
||||
import { EditableSlider } from './EditableSlider';
|
||||
|
||||
interface ReferenceTrack {
|
||||
@@ -48,7 +48,12 @@ const KEY_SIGNATURES = [
|
||||
'B major', 'B minor'
|
||||
];
|
||||
|
||||
const TIME_SIGNATURES = ['', '2/4', '3/4', '4/4', '6/8'];
|
||||
const TIME_SIGNATURES = ['', '2', '3', '4', '6', 'N/A'];
|
||||
|
||||
const TRACK_NAMES = [
|
||||
'woodwinds', 'brass', 'fx', 'synth', 'strings', 'percussion',
|
||||
'keyboard', 'guitar', 'bass', 'drums', 'backing_vocals', 'vocals',
|
||||
];
|
||||
|
||||
const VOCAL_LANGUAGE_KEYS = [
|
||||
{ value: 'unknown', key: 'autoInstrumental' as const },
|
||||
@@ -215,6 +220,7 @@ export const CreatePanel: React.FC<CreatePanelProps> = ({
|
||||
const [showLoraPanel, setShowLoraPanel] = useState(false);
|
||||
const [loraPath, setLoraPath] = useState('./lora_output/final/adapter');
|
||||
const [loraLoaded, setLoraLoaded] = useState(false);
|
||||
const [loraEnabled, setLoraEnabled] = useState(true);
|
||||
const [loraScale, setLoraScale] = useState(1.0);
|
||||
const [loraError, setLoraError] = useState<string | null>(null);
|
||||
const [isLoraLoading, setIsLoraLoading] = useState(false);
|
||||
@@ -263,19 +269,6 @@ export const CreatePanel: React.FC<CreatePanelProps> = ({
|
||||
return modelId.includes('turbo');
|
||||
};
|
||||
|
||||
// Genre selection state (cascading)
|
||||
const [selectedMainGenre, setSelectedMainGenre] = useState<string>('');
|
||||
const [selectedSubGenre, setSelectedSubGenre] = useState<string>('');
|
||||
|
||||
// Filter sub-genres based on selected main genre
|
||||
const filteredSubGenres = useMemo(() => {
|
||||
if (!selectedMainGenre) return [];
|
||||
const mainLower = selectedMainGenre.toLowerCase().trim();
|
||||
return SUB_STYLES.filter(style =>
|
||||
style.toLowerCase().includes(mainLower)
|
||||
);
|
||||
}, [selectedMainGenre]);
|
||||
|
||||
const [isUploadingReference, setIsUploadingReference] = useState(false);
|
||||
const [isUploadingSource, setIsUploadingSource] = useState(false);
|
||||
const [isTranscribingReference, setIsTranscribingReference] = useState(false);
|
||||
@@ -437,6 +430,61 @@ export const CreatePanel: React.FC<CreatePanelProps> = ({
|
||||
}
|
||||
};
|
||||
|
||||
const handleLoraEnabledToggle = async () => {
|
||||
if (!token || !loraLoaded) return;
|
||||
const newEnabled = !loraEnabled;
|
||||
setLoraEnabled(newEnabled);
|
||||
try {
|
||||
await generateApi.toggleLora({ enabled: newEnabled }, token);
|
||||
} catch (err) {
|
||||
console.error('Failed to toggle LoRA:', err);
|
||||
setLoraEnabled(!newEnabled); // revert on error
|
||||
}
|
||||
};
|
||||
|
||||
// Load generation parameters from JSON file
|
||||
const handleLoadParamsFile = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const file = e.target.files?.[0];
|
||||
if (!file) return;
|
||||
const reader = new FileReader();
|
||||
reader.onload = (ev) => {
|
||||
try {
|
||||
const data = JSON.parse(ev.target?.result as string);
|
||||
if (data.lyrics !== undefined) setLyrics(data.lyrics);
|
||||
if (data.style !== undefined) setStyle(data.style);
|
||||
if (data.title !== undefined) setTitle(data.title);
|
||||
if (data.caption !== undefined) setStyle(data.caption);
|
||||
if (data.instrumental !== undefined) setInstrumental(data.instrumental);
|
||||
if (data.vocal_language !== undefined) setVocalLanguage(data.vocal_language);
|
||||
if (data.bpm !== undefined) setBpm(data.bpm);
|
||||
if (data.key_scale !== undefined) setKeyScale(data.key_scale);
|
||||
if (data.time_signature !== undefined) setTimeSignature(data.time_signature);
|
||||
if (data.duration !== undefined) setDuration(data.duration);
|
||||
if (data.inference_steps !== undefined) setInferenceSteps(data.inference_steps);
|
||||
if (data.guidance_scale !== undefined) setGuidanceScale(data.guidance_scale);
|
||||
if (data.audio_format !== undefined) setAudioFormat(data.audio_format);
|
||||
if (data.infer_method !== undefined) setInferMethod(data.infer_method);
|
||||
if (data.seed !== undefined) { setSeed(data.seed); setRandomSeed(false); }
|
||||
if (data.shift !== undefined) setShift(data.shift);
|
||||
if (data.lm_temperature !== undefined) setLmTemperature(data.lm_temperature);
|
||||
if (data.lm_cfg_scale !== undefined) setLmCfgScale(data.lm_cfg_scale);
|
||||
if (data.lm_top_k !== undefined) setLmTopK(data.lm_top_k);
|
||||
if (data.lm_top_p !== undefined) setLmTopP(data.lm_top_p);
|
||||
if (data.lm_negative_prompt !== undefined) setLmNegativePrompt(data.lm_negative_prompt);
|
||||
if (data.task_type !== undefined) setTaskType(data.task_type);
|
||||
if (data.audio_codes !== undefined) setAudioCodes(data.audio_codes);
|
||||
if (data.repainting_start !== undefined) setRepaintingStart(data.repainting_start);
|
||||
if (data.repainting_end !== undefined) setRepaintingEnd(data.repainting_end);
|
||||
if (data.instruction !== undefined) setInstruction(data.instruction);
|
||||
if (data.audio_cover_strength !== undefined) setAudioCoverStrength(data.audio_cover_strength);
|
||||
} catch {
|
||||
console.error('Failed to parse parameters JSON');
|
||||
}
|
||||
};
|
||||
reader.readAsText(file);
|
||||
e.target.value = ''; // reset so same file can be reloaded
|
||||
};
|
||||
|
||||
// Reuse Effect - must be after all state declarations
|
||||
useEffect(() => {
|
||||
if (initialData) {
|
||||
@@ -628,28 +676,6 @@ export const CreatePanel: React.FC<CreatePanelProps> = ({
|
||||
setIsResizing(true);
|
||||
};
|
||||
|
||||
const uploadAudio = async (file: File, target: 'reference' | 'source') => {
|
||||
if (!token) {
|
||||
setUploadError('Please sign in to upload audio.');
|
||||
return;
|
||||
}
|
||||
setUploadError(null);
|
||||
const setUploading = target === 'reference' ? setIsUploadingReference : setIsUploadingSource;
|
||||
const setUrl = target === 'reference' ? setReferenceAudioUrl : setSourceAudioUrl;
|
||||
setUploading(true);
|
||||
try {
|
||||
const result = await generateApi.uploadAudio(file, token);
|
||||
setUrl(result.url);
|
||||
setShowAudioModal(false);
|
||||
setTempAudioUrl('');
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : 'Upload failed';
|
||||
setUploadError(message);
|
||||
} finally {
|
||||
setUploading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleFileSelect = (e: React.ChangeEvent<HTMLInputElement>, target: 'reference' | 'source') => {
|
||||
const file = e.target.files?.[0];
|
||||
if (file) {
|
||||
@@ -1182,8 +1208,28 @@ export const CreatePanel: React.FC<CreatePanelProps> = ({
|
||||
<div className="space-y-5">
|
||||
{/* Song Description */}
|
||||
<div className="bg-white dark:bg-suno-card rounded-xl border border-zinc-200 dark:border-white/5 overflow-hidden">
|
||||
<div className="px-3 py-2.5 text-xs font-bold uppercase tracking-wide text-zinc-500 dark:text-zinc-400 border-b border-zinc-100 dark:border-white/5 bg-zinc-50 dark:bg-white/5">
|
||||
{t('describeYourSong')}
|
||||
<div className="px-3 py-2.5 flex items-center justify-between border-b border-zinc-100 dark:border-white/5 bg-zinc-50 dark:bg-white/5">
|
||||
<span className="text-xs font-bold uppercase tracking-wide text-zinc-500 dark:text-zinc-400">
|
||||
{t('describeYourSong')}
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={async () => {
|
||||
if (!token) return;
|
||||
try {
|
||||
const result = await generateApi.getRandomDescription(token);
|
||||
setSongDescription(result.description);
|
||||
setInstrumental(result.instrumental);
|
||||
setVocalLanguage(result.vocalLanguage || 'unknown');
|
||||
} catch (err) {
|
||||
console.error('Failed to load random description:', err);
|
||||
}
|
||||
}}
|
||||
title="Load random description"
|
||||
className="p-1 rounded-md text-zinc-400 hover:text-zinc-600 dark:hover:text-zinc-200 hover:bg-zinc-200 dark:hover:bg-white/10 transition-colors"
|
||||
>
|
||||
<Dices size={14} />
|
||||
</button>
|
||||
</div>
|
||||
<textarea
|
||||
value={songDescription}
|
||||
@@ -1194,32 +1240,37 @@ export const CreatePanel: React.FC<CreatePanelProps> = ({
|
||||
</div>
|
||||
|
||||
{/* Vocal Language (Simple) */}
|
||||
<div className="bg-white dark:bg-suno-card rounded-xl border border-zinc-200 dark:border-white/5 overflow-hidden">
|
||||
<div className="px-3 py-2.5 text-xs font-bold uppercase tracking-wide text-zinc-500 dark:text-zinc-400 border-b border-zinc-100 dark:border-white/5 bg-zinc-50 dark:bg-white/5">
|
||||
{t('vocalLanguage')}
|
||||
</div>
|
||||
<div className="flex flex-wrap items-center gap-2 p-3">
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div className="space-y-1.5">
|
||||
<label className="text-xs font-bold text-zinc-500 dark:text-zinc-400 uppercase tracking-wide px-1">
|
||||
{t('vocalLanguage')}
|
||||
</label>
|
||||
<select
|
||||
value={vocalLanguage}
|
||||
onChange={(e) => setVocalLanguage(e.target.value)}
|
||||
className="flex-1 min-w-[180px] bg-transparent text-sm text-zinc-900 dark:text-white focus:outline-none"
|
||||
className="w-full bg-white dark:bg-suno-card border border-zinc-200 dark:border-white/5 rounded-xl px-3 py-2 text-sm text-zinc-900 dark:text-white focus:outline-none focus:border-pink-500 dark:focus:border-pink-500 transition-colors cursor-pointer [&>option]:bg-white [&>option]:dark:bg-zinc-800 [&>option]:text-zinc-900 [&>option]:dark:text-white"
|
||||
>
|
||||
{VOCAL_LANGUAGE_KEYS.map(lang => (
|
||||
<option key={lang.value} value={lang.value}>{lang.key}</option>
|
||||
<option key={lang.value} value={lang.value}>{t(lang.key)}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<label className="text-xs font-bold text-zinc-500 dark:text-zinc-400 uppercase tracking-wide px-1">
|
||||
{t('vocalGender')}
|
||||
</label>
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setVocalGender(vocalGender === 'male' ? '' : 'male')}
|
||||
className={`px-3 py-1.5 rounded-full text-xs font-semibold border transition-colors ${vocalGender === 'male' ? 'bg-pink-600 text-white border-pink-600' : 'border-zinc-200 dark:border-white/10 text-zinc-600 dark:text-zinc-300 hover:border-zinc-300 dark:hover:border-white/20'}`}
|
||||
className={`flex-1 px-3 py-2 rounded-lg text-xs font-semibold border transition-colors ${vocalGender === 'male' ? 'bg-pink-600 text-white border-pink-600' : 'border-zinc-200 dark:border-white/10 text-zinc-600 dark:text-zinc-300 hover:border-zinc-300 dark:hover:border-white/20'}`}
|
||||
>
|
||||
{t('male')}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setVocalGender(vocalGender === 'female' ? '' : 'female')}
|
||||
className={`px-3 py-1.5 rounded-full text-xs font-semibold border transition-colors ${vocalGender === 'female' ? 'bg-pink-600 text-white border-pink-600' : 'border-zinc-200 dark:border-white/10 text-zinc-600 dark:text-zinc-300 hover:border-zinc-300 dark:hover:border-white/20'}`}
|
||||
className={`flex-1 px-3 py-2 rounded-lg text-xs font-semibold border transition-colors ${vocalGender === 'female' ? 'bg-pink-600 text-white border-pink-600' : 'border-zinc-200 dark:border-white/10 text-zinc-600 dark:text-zinc-300 hover:border-zinc-300 dark:hover:border-white/20'}`}
|
||||
>
|
||||
{t('female')}
|
||||
</button>
|
||||
@@ -1239,7 +1290,7 @@ export const CreatePanel: React.FC<CreatePanelProps> = ({
|
||||
label={t('duration')}
|
||||
value={duration}
|
||||
min={-1}
|
||||
max={600}
|
||||
max={activeMaxDuration}
|
||||
step={5}
|
||||
onChange={setDuration}
|
||||
formatDisplay={(val) => val === -1 ? t('auto') : `${val}${t('seconds')}`}
|
||||
@@ -1584,70 +1635,6 @@ export const CreatePanel: React.FC<CreatePanelProps> = ({
|
||||
className="w-full h-20 bg-transparent p-3 text-sm text-zinc-900 dark:text-white placeholder-zinc-400 dark:placeholder-zinc-600 focus:outline-none resize-none"
|
||||
/>
|
||||
<div className="px-3 pb-3 space-y-3">
|
||||
{/* Cascading Genre Selector */}
|
||||
<div className="space-y-2">
|
||||
{/* First Level: Main Genre */}
|
||||
<div className="flex gap-2">
|
||||
<select
|
||||
value={selectedMainGenre}
|
||||
onChange={(e) => {
|
||||
setSelectedMainGenre(e.target.value);
|
||||
setSelectedSubGenre(''); // Reset sub genre when main changes
|
||||
if (e.target.value) {
|
||||
setStyle(prev => prev ? `${prev}, ${e.target.value}` : e.target.value);
|
||||
}
|
||||
}}
|
||||
className="flex-1 bg-zinc-50 dark:bg-black/20 border border-zinc-200 dark:border-white/10 rounded-xl px-2 py-1.5 text-xs text-zinc-900 dark:text-white focus:outline-none focus:border-pink-500 dark:focus:border-pink-500 transition-colors cursor-pointer [&>option]:bg-white [&>option]:dark:bg-zinc-800 [&>option]:text-zinc-900 [&>option]:dark:text-white"
|
||||
>
|
||||
<option value="">{t('mainGenre')}</option>
|
||||
{MAIN_STYLES.map(genre => (
|
||||
<option key={genre} value={genre}>{genre}</option>
|
||||
))}
|
||||
</select>
|
||||
{selectedMainGenre && (
|
||||
<button
|
||||
onClick={() => {
|
||||
setSelectedMainGenre('');
|
||||
setSelectedSubGenre('');
|
||||
}}
|
||||
className="px-2 py-1.5 text-xs text-zinc-500 hover:text-zinc-900 dark:hover:text-white transition-colors"
|
||||
title={t('cancel')}
|
||||
>
|
||||
✕
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Second Level: Sub Genre (only show when main genre is selected) */}
|
||||
{selectedMainGenre && filteredSubGenres.length > 0 && (
|
||||
<div className="flex gap-2 pl-4 border-l-2 border-zinc-200 dark:border-white/10">
|
||||
<select
|
||||
value={selectedSubGenre}
|
||||
onChange={(e) => {
|
||||
setSelectedSubGenre(e.target.value);
|
||||
if (e.target.value) {
|
||||
setStyle(prev => prev ? `${prev}, ${e.target.value}` : e.target.value);
|
||||
}
|
||||
}}
|
||||
className="flex-1 bg-zinc-50 dark:bg-black/20 border border-zinc-200 dark:border-white/10 rounded-xl px-2 py-1.5 text-xs text-zinc-900 dark:text-white focus:outline-none focus:border-pink-500 dark:focus:border-pink-500 transition-colors cursor-pointer [&>option]:bg-white [&>option]:dark:bg-zinc-800 [&>option]:text-zinc-900 [&>option]:dark:text-white"
|
||||
>
|
||||
<option value="">{t('subGenre')} ({filteredSubGenres.length})</option>
|
||||
{filteredSubGenres.map(genre => (
|
||||
<option key={genre} value={genre}>{genre}</option>
|
||||
))}
|
||||
</select>
|
||||
{selectedSubGenre && (
|
||||
<button
|
||||
onClick={() => setSelectedSubGenre('')}
|
||||
className="px-2 py-1.5 text-xs text-zinc-500 hover:text-zinc-900 dark:hover:text-white transition-colors"
|
||||
title={t('cancel')}
|
||||
>
|
||||
✕
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{/* Quick Tags */}
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{musicTags.map(tag => (
|
||||
@@ -1799,13 +1786,27 @@ export const CreatePanel: React.FC<CreatePanelProps> = ({
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Use LoRA Checkbox (enable/disable without unloading) */}
|
||||
<div className={`flex items-center justify-between py-2 border-t border-zinc-100 dark:border-white/5 ${!loraLoaded ? 'opacity-40 pointer-events-none' : ''}`}>
|
||||
<label className="flex items-center gap-2 text-xs font-medium text-zinc-600 dark:text-zinc-400 cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={loraEnabled}
|
||||
onChange={handleLoraEnabledToggle}
|
||||
disabled={!loraLoaded}
|
||||
className="accent-pink-600"
|
||||
/>
|
||||
Use LoRA
|
||||
</label>
|
||||
</div>
|
||||
|
||||
{/* LoRA Scale Slider */}
|
||||
<div className={!loraLoaded ? 'opacity-40 pointer-events-none' : ''}>
|
||||
<div className={!loraLoaded || !loraEnabled ? 'opacity-40 pointer-events-none' : ''}>
|
||||
<EditableSlider
|
||||
label={t('loraScale')}
|
||||
value={loraScale}
|
||||
min={0}
|
||||
max={2}
|
||||
max={1}
|
||||
step={0.05}
|
||||
onChange={handleLoraScaleChange}
|
||||
formatDisplay={(val) => val.toFixed(2)}
|
||||
@@ -1881,6 +1882,17 @@ export const CreatePanel: React.FC<CreatePanelProps> = ({
|
||||
|
||||
{showAdvanced && (
|
||||
<div className="bg-white dark:bg-suno-card rounded-xl border border-zinc-200 dark:border-white/5 p-4 space-y-4">
|
||||
{/* Load Parameters from JSON */}
|
||||
<label className="flex items-center gap-2 px-3 py-2 rounded-lg border border-dashed border-zinc-300 dark:border-white/15 text-xs font-medium text-zinc-600 dark:text-zinc-400 hover:bg-zinc-50 dark:hover:bg-white/5 cursor-pointer transition-colors">
|
||||
<Upload size={14} />
|
||||
Load Parameters (JSON)
|
||||
<input
|
||||
type="file"
|
||||
accept=".json"
|
||||
onChange={handleLoadParamsFile}
|
||||
className="hidden"
|
||||
/>
|
||||
</label>
|
||||
|
||||
{/* Duration */}
|
||||
<EditableSlider
|
||||
@@ -1937,8 +1949,8 @@ export const CreatePanel: React.FC<CreatePanelProps> = ({
|
||||
<EditableSlider
|
||||
label={t('inferenceSteps')}
|
||||
value={inferenceSteps}
|
||||
min={4}
|
||||
max={32}
|
||||
min={1}
|
||||
max={isTurboModel(selectedModel) ? 20 : 200}
|
||||
step={1}
|
||||
onChange={setInferenceSteps}
|
||||
helpText={t('moreStepsBetterQuality')}
|
||||
@@ -1951,7 +1963,7 @@ export const CreatePanel: React.FC<CreatePanelProps> = ({
|
||||
value={guidanceScale}
|
||||
min={1}
|
||||
max={15}
|
||||
step={0.5}
|
||||
step={0.1}
|
||||
onChange={setGuidanceScale}
|
||||
formatDisplay={(val) => val.toFixed(1)}
|
||||
helpText={t('howCloselyFollowPrompt')}
|
||||
@@ -2098,8 +2110,8 @@ export const CreatePanel: React.FC<CreatePanelProps> = ({
|
||||
value={lmTemperature}
|
||||
min={0}
|
||||
max={2}
|
||||
step={0.05}
|
||||
onChange={(e) => setLmTemperature(Number(e.target.value))}
|
||||
step={0.1}
|
||||
onChange={setLmTemperature}
|
||||
formatDisplay={(val) => val.toFixed(2)}
|
||||
helpText={t('higherMoreRandom')}
|
||||
title="Higher temperature = more random word choices."
|
||||
@@ -2167,6 +2179,33 @@ export const CreatePanel: React.FC<CreatePanelProps> = ({
|
||||
placeholder={t('optionalAudioCodes')}
|
||||
className="w-full h-16 bg-zinc-50 dark:bg-black/20 border border-zinc-200 dark:border-white/10 rounded-lg p-2 text-xs text-zinc-900 dark:text-white focus:outline-none resize-none"
|
||||
/>
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
// Convert source audio to LM codes — requires Gradio lambda (not exposed as API)
|
||||
// This is a placeholder: Gradio's convert_src_audio_to_codes_wrapper is not a named endpoint
|
||||
console.log('Convert to Codes: requires source audio upload. Use Gradio UI for this feature.');
|
||||
}}
|
||||
disabled={!sourceAudioUrl}
|
||||
title="Convert source audio to LM codes (requires source audio)"
|
||||
className="px-2 py-1 rounded text-[10px] font-medium bg-zinc-100 dark:bg-zinc-800 text-zinc-500 dark:text-zinc-400 hover:bg-zinc-200 dark:hover:bg-zinc-700 disabled:opacity-40 disabled:cursor-not-allowed transition-colors"
|
||||
>
|
||||
Convert to Codes
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
// Transcribe audio codes to metadata — requires Gradio lambda (not exposed as API)
|
||||
console.log('Transcribe: requires audio codes. Use Gradio UI for this feature.');
|
||||
}}
|
||||
disabled={!audioCodes.trim()}
|
||||
title="Transcribe audio codes to metadata (requires audio codes)"
|
||||
className="px-2 py-1 rounded text-[10px] font-medium bg-zinc-100 dark:bg-zinc-800 text-zinc-500 dark:text-zinc-400 hover:bg-zinc-200 dark:hover:bg-zinc-700 disabled:opacity-40 disabled:cursor-not-allowed transition-colors"
|
||||
>
|
||||
Transcribe
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
@@ -2187,7 +2226,7 @@ export const CreatePanel: React.FC<CreatePanelProps> = ({
|
||||
<label className="text-xs font-medium text-zinc-600 dark:text-zinc-400" title="How strongly the source audio shapes the result.">{t('audioCoverStrength')}</label>
|
||||
<input
|
||||
type="number"
|
||||
step="0.05"
|
||||
step="0.01"
|
||||
min="0"
|
||||
max="1"
|
||||
value={audioCoverStrength}
|
||||
@@ -2238,7 +2277,7 @@ export const CreatePanel: React.FC<CreatePanelProps> = ({
|
||||
<label className="text-xs font-medium text-zinc-600 dark:text-zinc-400" title="Fraction of the diffusion process to start applying guidance.">{t('cfgIntervalStart')}</label>
|
||||
<input
|
||||
type="number"
|
||||
step="0.05"
|
||||
step="0.01"
|
||||
min="0"
|
||||
max="1"
|
||||
value={cfgIntervalStart}
|
||||
@@ -2250,7 +2289,7 @@ export const CreatePanel: React.FC<CreatePanelProps> = ({
|
||||
<label className="text-xs font-medium text-zinc-600 dark:text-zinc-400" title="Fraction of the diffusion process to stop applying guidance.">{t('cfgIntervalEnd')}</label>
|
||||
<input
|
||||
type="number"
|
||||
step="0.05"
|
||||
step="0.01"
|
||||
min="0"
|
||||
max="1"
|
||||
value={cfgIntervalEnd}
|
||||
@@ -2276,7 +2315,9 @@ export const CreatePanel: React.FC<CreatePanelProps> = ({
|
||||
<label className="text-xs font-medium text-zinc-600 dark:text-zinc-400" title="Scales score-based guidance (advanced).">{t('scoreScale')}</label>
|
||||
<input
|
||||
type="number"
|
||||
step="0.05"
|
||||
step="0.01"
|
||||
min="0.01"
|
||||
max="1"
|
||||
value={scoreScale}
|
||||
onChange={(e) => setScoreScale(Number(e.target.value))}
|
||||
className="w-full bg-zinc-50 dark:bg-black/20 border border-zinc-200 dark:border-white/10 rounded-lg px-3 py-2 text-xs text-zinc-900 dark:text-white focus:outline-none"
|
||||
@@ -2287,6 +2328,8 @@ export const CreatePanel: React.FC<CreatePanelProps> = ({
|
||||
<input
|
||||
type="number"
|
||||
min="1"
|
||||
max="32"
|
||||
step="1"
|
||||
value={lmBatchChunkSize}
|
||||
onChange={(e) => setLmBatchChunkSize(Number(e.target.value))}
|
||||
className="w-full bg-zinc-50 dark:bg-black/20 border border-zinc-200 dark:border-white/10 rounded-lg px-3 py-2 text-xs text-zinc-900 dark:text-white focus:outline-none"
|
||||
@@ -2296,24 +2339,42 @@ export const CreatePanel: React.FC<CreatePanelProps> = ({
|
||||
|
||||
<div className="space-y-1.5">
|
||||
<label className="text-xs font-medium text-zinc-600 dark:text-zinc-400">{t('trackName')}</label>
|
||||
<input
|
||||
type="text"
|
||||
<select
|
||||
value={trackName}
|
||||
onChange={(e) => setTrackName(e.target.value)}
|
||||
placeholder={t('optionalTrackName')}
|
||||
className="w-full bg-zinc-50 dark:bg-black/20 border border-zinc-200 dark:border-white/10 rounded-lg px-3 py-2 text-xs text-zinc-900 dark:text-white focus:outline-none"
|
||||
/>
|
||||
className="w-full bg-zinc-50 dark:bg-black/20 border border-zinc-200 dark:border-white/10 rounded-lg px-2 py-1.5 text-xs text-zinc-900 dark:text-white focus:outline-none cursor-pointer [&>option]:bg-white [&>option]:dark:bg-zinc-800"
|
||||
>
|
||||
<option value="">None</option>
|
||||
{TRACK_NAMES.map(name => (
|
||||
<option key={name} value={name}>{name}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1.5">
|
||||
<label className="text-xs font-medium text-zinc-600 dark:text-zinc-400">{t('completeTrackClasses')}</label>
|
||||
<input
|
||||
type="text"
|
||||
value={completeTrackClasses}
|
||||
onChange={(e) => setCompleteTrackClasses(e.target.value)}
|
||||
placeholder={t('trackClassesPlaceholder')}
|
||||
className="w-full bg-zinc-50 dark:bg-black/20 border border-zinc-200 dark:border-white/10 rounded-lg px-3 py-2 text-xs text-zinc-900 dark:text-white focus:outline-none"
|
||||
/>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{TRACK_NAMES.map(name => {
|
||||
const selected = completeTrackClasses.split(',').map(s => s.trim()).filter(Boolean);
|
||||
const isChecked = selected.includes(name);
|
||||
return (
|
||||
<label key={name} className="flex items-center gap-1 text-[10px] font-medium text-zinc-500 dark:text-zinc-400 cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={isChecked}
|
||||
onChange={() => {
|
||||
const next = isChecked
|
||||
? selected.filter(s => s !== name)
|
||||
: [...selected, name];
|
||||
setCompleteTrackClasses(next.join(','));
|
||||
}}
|
||||
className="accent-pink-600"
|
||||
/>
|
||||
{name}
|
||||
</label>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
|
||||
@@ -0,0 +1,180 @@
|
||||
import React, { useState } from 'react';
|
||||
import { Newspaper, X, Star, Github } from 'lucide-react';
|
||||
import { useI18n } from '../context/I18nContext';
|
||||
import newsData from '../data/news.json';
|
||||
|
||||
interface NewsItem {
|
||||
id: string;
|
||||
date: string;
|
||||
title: string;
|
||||
body: string;
|
||||
tags: string[];
|
||||
}
|
||||
|
||||
export const NewsPage: React.FC = () => {
|
||||
const { t } = useI18n();
|
||||
const [dismissedNews, setDismissedNews] = useState<Set<string>>(() => {
|
||||
try {
|
||||
const stored = localStorage.getItem('ace-dismissed-news');
|
||||
return stored ? new Set(JSON.parse(stored)) : new Set();
|
||||
} catch {
|
||||
return new Set();
|
||||
}
|
||||
});
|
||||
|
||||
const allNews = newsData as NewsItem[];
|
||||
const activeNews = allNews.filter(n => !dismissedNews.has(n.id));
|
||||
const dismissed = allNews.filter(n => dismissedNews.has(n.id));
|
||||
|
||||
const dismissNewsItem = (id: string) => {
|
||||
setDismissedNews(prev => {
|
||||
const next = new Set(prev);
|
||||
next.add(id);
|
||||
localStorage.setItem('ace-dismissed-news', JSON.stringify([...next]));
|
||||
return next;
|
||||
});
|
||||
};
|
||||
|
||||
const restoreNewsItem = (id: string) => {
|
||||
setDismissedNews(prev => {
|
||||
const next = new Set(prev);
|
||||
next.delete(id);
|
||||
localStorage.setItem('ace-dismissed-news', JSON.stringify([...next]));
|
||||
return next;
|
||||
});
|
||||
};
|
||||
|
||||
const tagColor = (tag: string) => {
|
||||
switch (tag) {
|
||||
case 'experimental':
|
||||
return 'bg-amber-500/15 text-amber-600 dark:text-amber-400';
|
||||
case 'backend':
|
||||
return 'bg-blue-500/15 text-blue-600 dark:text-blue-400';
|
||||
case 'training':
|
||||
return 'bg-purple-500/15 text-purple-600 dark:text-purple-400';
|
||||
case 'feature':
|
||||
return 'bg-green-500/15 text-green-600 dark:text-green-400';
|
||||
case 'bugfix':
|
||||
return 'bg-red-500/15 text-red-600 dark:text-red-400';
|
||||
default:
|
||||
return 'bg-zinc-200 dark:bg-white/10 text-zinc-500 dark:text-zinc-400';
|
||||
}
|
||||
};
|
||||
|
||||
const renderCard = (item: NewsItem, isDismissed: boolean) => (
|
||||
<div
|
||||
key={item.id}
|
||||
className={`
|
||||
group rounded-2xl border transition-all duration-200
|
||||
${isDismissed
|
||||
? 'bg-zinc-100 dark:bg-white/[0.02] border-zinc-200 dark:border-white/5 opacity-50'
|
||||
: 'bg-white dark:bg-suno-card border-zinc-200 dark:border-white/5 hover:border-zinc-300 dark:hover:border-white/10'
|
||||
}
|
||||
`}
|
||||
>
|
||||
<div className="p-5 sm:p-6">
|
||||
{/* Header */}
|
||||
<div className="flex items-start justify-between gap-4">
|
||||
<div className="flex-1 min-w-0">
|
||||
<h3 className="text-base sm:text-lg font-semibold text-zinc-900 dark:text-zinc-100 leading-snug">
|
||||
{item.title}
|
||||
</h3>
|
||||
<p className="text-xs text-zinc-400 dark:text-zinc-500 mt-1">{item.date}</p>
|
||||
</div>
|
||||
{!isDismissed ? (
|
||||
<button
|
||||
onClick={() => dismissNewsItem(item.id)}
|
||||
className="opacity-0 group-hover:opacity-100 p-1.5 rounded-lg text-zinc-400 hover:text-zinc-600 dark:hover:text-zinc-300 hover:bg-zinc-100 dark:hover:bg-white/10 transition-all flex-shrink-0"
|
||||
title="Dismiss"
|
||||
>
|
||||
<X size={16} />
|
||||
</button>
|
||||
) : (
|
||||
<button
|
||||
onClick={() => restoreNewsItem(item.id)}
|
||||
className="text-xs text-zinc-400 hover:text-zinc-600 dark:hover:text-zinc-300 hover:underline transition-colors flex-shrink-0"
|
||||
>
|
||||
Restore
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Body */}
|
||||
<p className="text-sm text-zinc-600 dark:text-zinc-400 mt-3 leading-relaxed">
|
||||
{item.body}
|
||||
</p>
|
||||
|
||||
{/* Tags */}
|
||||
<div className="flex flex-wrap items-center gap-2 mt-4">
|
||||
{item.tags.map(tag => (
|
||||
<span
|
||||
key={tag}
|
||||
className={`text-[11px] font-medium px-2.5 py-1 rounded-full ${tagColor(tag)}`}
|
||||
>
|
||||
{tag}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="flex-1 bg-white dark:bg-black overflow-y-auto p-6 lg:p-10 pb-32 transition-colors duration-300">
|
||||
<div className="max-w-2xl mx-auto">
|
||||
{/* Header */}
|
||||
<div className="flex items-center gap-3 mb-8">
|
||||
<div className="w-10 h-10 rounded-xl bg-amber-500/15 flex items-center justify-center flex-shrink-0">
|
||||
<Newspaper size={20} className="text-amber-600 dark:text-amber-400" />
|
||||
</div>
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-zinc-900 dark:text-white">{t('news')}</h1>
|
||||
<p className="text-sm text-zinc-500 dark:text-zinc-400">Updates and announcements</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Star Repo */}
|
||||
<a
|
||||
href="https://github.com/fspecii/ace-step-ui"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="flex items-center gap-3 mb-8 px-5 py-4 rounded-2xl border border-zinc-200 dark:border-white/5 bg-white dark:bg-suno-card hover:border-zinc-300 dark:hover:border-white/10 transition-all group"
|
||||
>
|
||||
<Github size={20} className="text-zinc-500 dark:text-zinc-400 flex-shrink-0" />
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="text-sm font-semibold text-zinc-900 dark:text-zinc-100">fspecii/ace-step-ui</p>
|
||||
<p className="text-xs text-zinc-500 dark:text-zinc-400">Star the repo to support the project</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-1.5 px-3 py-1.5 rounded-lg bg-zinc-100 dark:bg-white/10 text-zinc-700 dark:text-zinc-300 text-sm font-medium group-hover:bg-amber-500/15 group-hover:text-amber-600 dark:group-hover:text-amber-400 transition-colors flex-shrink-0">
|
||||
<Star size={14} />
|
||||
Star
|
||||
</div>
|
||||
</a>
|
||||
|
||||
{/* Active News */}
|
||||
{activeNews.length > 0 ? (
|
||||
<div className="space-y-4">
|
||||
{activeNews.map(item => renderCard(item, false))}
|
||||
</div>
|
||||
) : (
|
||||
<div className="text-center py-16">
|
||||
<Newspaper size={48} className="mx-auto text-zinc-300 dark:text-zinc-600 mb-4" />
|
||||
<p className="text-zinc-500 dark:text-zinc-400 text-sm">No new updates</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Dismissed News */}
|
||||
{dismissed.length > 0 && (
|
||||
<div className="mt-10">
|
||||
<h2 className="text-xs font-semibold uppercase tracking-wider text-zinc-400 dark:text-zinc-500 mb-4">
|
||||
Dismissed
|
||||
</h2>
|
||||
<div className="space-y-3">
|
||||
{dismissed.map(item => renderCard(item, true))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -40,7 +40,7 @@ export const PlaylistDetail: React.FC<PlaylistDetailProps> = ({ playlistId, onBa
|
||||
duration: s.duration,
|
||||
bpm: s.bpm,
|
||||
tags: s.tags || [],
|
||||
isPublic: s.is_public || false,
|
||||
is_public: s.is_public || false,
|
||||
likeCount: s.like_count || 0,
|
||||
viewCount: s.view_count || 0,
|
||||
creator: s.creator,
|
||||
|
||||
@@ -489,7 +489,7 @@ export const RightSidebar: React.FC<RightSidebarProps> = ({ song, onClose, onOpe
|
||||
</span>
|
||||
))
|
||||
) : (
|
||||
song.style.split(',').map((tag, idx) => (
|
||||
(song.style || '').split(',').filter(Boolean).map((tag, idx) => (
|
||||
<span key={idx} className="px-2 py-0.5 bg-zinc-100 dark:bg-white/5 hover:bg-zinc-200 dark:hover:bg-white/10 border border-zinc-200 dark:border-white/10 rounded text-[11px] font-medium text-zinc-600 dark:text-zinc-300 transition-colors">
|
||||
{tag.trim()}
|
||||
</span>
|
||||
|
||||
@@ -103,7 +103,6 @@ export const SearchPage: React.FC<SearchPageProps> = ({
|
||||
uniqueCreators.set(song.creator, {
|
||||
id: song.user_id || song.userId || song.creator,
|
||||
username: song.creator,
|
||||
email: '',
|
||||
created_at: song.created_at || song.createdAt,
|
||||
avatar_url: song.creator_avatar || song.creatorAvatar || null,
|
||||
});
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import React, { useState } from 'react';
|
||||
import React, { useState, useRef, useEffect } from 'react';
|
||||
import { X, User as UserIcon, Palette, Info, Edit3, ExternalLink, Globe, ChevronDown, Github } from 'lucide-react';
|
||||
import { useAuth } from '../context/AuthContext';
|
||||
import { useI18n } from '../context/I18nContext';
|
||||
@@ -16,6 +16,19 @@ export const SettingsModal: React.FC<SettingsModalProps> = ({ isOpen, onClose, t
|
||||
const { user } = useAuth();
|
||||
const { t, language, setLanguage } = useI18n();
|
||||
const [isEditProfileOpen, setIsEditProfileOpen] = useState(false);
|
||||
const [showLangInfo, setShowLangInfo] = useState(false);
|
||||
const langInfoRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!showLangInfo) return;
|
||||
const handleClick = (e: MouseEvent) => {
|
||||
if (langInfoRef.current && !langInfoRef.current.contains(e.target as Node)) {
|
||||
setShowLangInfo(false);
|
||||
}
|
||||
};
|
||||
document.addEventListener('mousedown', handleClick);
|
||||
return () => document.removeEventListener('mousedown', handleClick);
|
||||
}, [showLangInfo]);
|
||||
|
||||
if (!isOpen || !user) {
|
||||
if (isEditProfileOpen && user) {
|
||||
@@ -108,6 +121,43 @@ export const SettingsModal: React.FC<SettingsModalProps> = ({ isOpen, onClose, t
|
||||
<div className="flex items-center gap-2 text-zinc-900 dark:text-white">
|
||||
<Globe size={20} />
|
||||
<h3 className="font-semibold">{t('language')}</h3>
|
||||
<div className="relative" ref={langInfoRef}>
|
||||
<button
|
||||
onClick={() => setShowLangInfo(!showLangInfo)}
|
||||
className="p-1 rounded-full text-zinc-400 hover:text-zinc-600 dark:hover:text-zinc-300 hover:bg-zinc-100 dark:hover:bg-white/10 transition-colors"
|
||||
>
|
||||
<Info size={14} />
|
||||
</button>
|
||||
{showLangInfo && (
|
||||
<div className="absolute left-0 top-8 z-10 w-64 bg-white dark:bg-zinc-800 border border-zinc-200 dark:border-zinc-700 rounded-xl shadow-xl p-3">
|
||||
<p className="text-xs text-zinc-500 dark:text-zinc-400 mb-2">{t('localizedBy')}</p>
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
<a
|
||||
href="https://x.com/bdsqlsz"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="inline-flex items-center gap-1.5 px-2.5 py-1.5 bg-black dark:bg-white text-white dark:text-black rounded-lg text-xs font-medium hover:bg-zinc-800 dark:hover:bg-zinc-200 transition-colors"
|
||||
>
|
||||
<svg viewBox="0 0 24 24" width="12" height="12" fill="currentColor">
|
||||
<path d="M18.244 2.25h3.308l-7.227 8.26 8.502 11.24H16.17l-5.214-6.817L4.99 21.75H1.68l7.73-8.835L1.254 2.25H8.08l4.713 6.231zm-1.161 17.52h1.833L7.084 4.126H5.117z" />
|
||||
</svg>
|
||||
@bdsqlsz
|
||||
</a>
|
||||
<a
|
||||
href="https://space.bilibili.com/219296"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="inline-flex items-center gap-1.5 px-2.5 py-1.5 bg-[#00A1D6] text-white rounded-lg text-xs font-medium hover:bg-[#0090C0] transition-colors"
|
||||
>
|
||||
<svg viewBox="0 0 24 24" width="12" height="12" fill="currentColor">
|
||||
<path d="M17.813 4.653h.854c1.51.054 2.769.578 3.773 1.574 1.004.995 1.524 2.249 1.56 3.76v7.36c-.036 1.51-.556 2.769-1.56 3.773s-2.262 1.524-3.773 1.56H5.333c-1.51-.036-2.769-.556-3.773-1.56S.036 18.858 0 17.347v-7.36c.036-1.511.556-2.765 1.56-3.76 1.004-.996 2.262-1.52 3.773-1.574h.774l-1.174-1.12a1.234 1.234 0 0 1-.373-.906c0-.356.124-.658.373-.907l.027-.027c.267-.249.573-.373.92-.373.347 0 .653.124.92.373L9.653 4.44c.071.071.134.142.187.213h4.267a.836.836 0 0 1 .16-.213l2.853-2.747c.267-.249.573-.373.92-.373.347 0 .662.151.929.4.267.249.391.551.391.907 0 .355-.124.657-.373.906zM5.333 7.24c-.746.018-1.373.276-1.88.773-.506.498-.769 1.13-.786 1.894v7.52c.017.764.28 1.395.786 1.893.507.498 1.134.756 1.88.773h13.334c.746-.017 1.373-.275 1.88-.773.506-.498.769-1.129.786-1.893v-7.52c-.017-.765-.28-1.396-.786-1.894-.507-.497-1.134-.755-1.88-.773zM8 11.107c.373 0 .684.124.933.373.25.249.383.569.4.96v1.173c-.017.391-.15.711-.4.96-.249.25-.56.374-.933.374s-.684-.125-.933-.374c-.25-.249-.383-.569-.4-.96V12.44c0-.373.129-.689.386-.947.258-.257.574-.386.947-.386zm8 0c.373 0 .684.124.933.373.25.249.383.569.4.96v1.173c-.017.391-.15.711-.4.96-.249.25-.56.374-.933.374s-.684-.125-.933-.374c-.25-.249-.383-.569-.4-.96V12.44c.017-.391.15-.711.4-.96.249-.249.56-.373.933-.373Z"/>
|
||||
</svg>
|
||||
青龙圣者
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="pl-7 space-y-3">
|
||||
<div className="relative">
|
||||
@@ -200,33 +250,6 @@ export const SettingsModal: React.FC<SettingsModalProps> = ({ isOpen, onClose, t
|
||||
Report issues or request features on GitHub
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-zinc-900 dark:text-white font-medium mb-2">{t('localizedBy')}</p>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<a
|
||||
href="https://x.com/bdsqlsz"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="inline-flex items-center gap-2 px-4 py-2 bg-black dark:bg-white text-white dark:text-black rounded-lg text-sm font-medium hover:bg-zinc-800 dark:hover:bg-zinc-200 transition-colors"
|
||||
>
|
||||
<svg viewBox="0 0 24 24" width="16" height="16" fill="currentColor">
|
||||
<path d="M18.244 2.25h3.308l-7.227 8.26 8.502 11.24H16.17l-5.214-6.817L4.99 21.75H1.68l7.73-8.835L1.254 2.25H8.08l4.713 6.231zm-1.161 17.52h1.833L7.084 4.126H5.117z" />
|
||||
</svg>
|
||||
{t('follow')} @bdsqlsz
|
||||
</a>
|
||||
<a
|
||||
href="https://space.bilibili.com/219296"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="inline-flex items-center gap-2 px-4 py-2 bg-[#00A1D6] text-white rounded-lg text-sm font-medium hover:bg-[#0090C0] transition-colors"
|
||||
>
|
||||
<svg viewBox="0 0 24 24" width="16" height="16" fill="currentColor">
|
||||
<path d="M17.813 4.653h.854c1.51.054 2.769.578 3.773 1.574 1.004.995 1.524 2.249 1.56 3.76v7.36c-.036 1.51-.556 2.769-1.56 3.773s-2.262 1.524-3.773 1.56H5.333c-1.51-.036-2.769-.556-3.773-1.56S.036 18.858 0 17.347v-7.36c.036-1.511.556-2.765 1.56-3.76 1.004-.996 2.262-1.52 3.773-1.574h.774l-1.174-1.12a1.234 1.234 0 0 1-.373-.906c0-.356.124-.658.373-.907l.027-.027c.267-.249.573-.373.92-.373.347 0 .653.124.92.373L9.653 4.44c.071.071.134.142.187.213h4.267a.836.836 0 0 1 .16-.213l2.853-2.747c.267-.249.573-.373.92-.373.347 0 .662.151.929.4.267.249.391.551.391.907 0 .355-.124.657-.373.906zM5.333 7.24c-.746.018-1.373.276-1.88.773-.506.498-.769 1.13-.786 1.894v7.52c.017.764.28 1.395.786 1.893.507.498 1.134.756 1.88.773h13.334c.746-.017 1.373-.275 1.88-.773.506-.498.769-1.129.786-1.893v-7.52c-.017-.765-.28-1.396-.786-1.894-.507-.497-1.134-.755-1.88-.773zM8 11.107c.373 0 .684.124.933.373.25.249.383.569.4.96v1.173c-.017.391-.15.711-.4.96-.249.25-.56.374-.933.374s-.684-.125-.933-.374c-.25-.249-.383-.569-.4-.96V12.44c0-.373.129-.689.386-.947.258-.257.574-.386.947-.386zm8 0c.373 0 .684.124.933.373.25.249.383.569.4.96v1.173c-.017.391-.15.711-.4.96-.249.25-.56.374-.933.374s-.684-.125-.933-.374c-.25-.249-.383-.569-.4-.96V12.44c.017-.391.15-.711.4-.96.249-.249.56-.373.933-.373Z"/>
|
||||
</svg>
|
||||
{t('follow')} 青龙圣者
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
+16
-1
@@ -1,5 +1,5 @@
|
||||
import React from 'react';
|
||||
import { Library, Disc, Search, User, LogIn, LogOut, Sun, Moon } from 'lucide-react';
|
||||
import { Library, Disc, Search, LogIn, LogOut, Sun, Moon, GraduationCap, Newspaper } from 'lucide-react';
|
||||
import { View } from '../types';
|
||||
import { useI18n } from '../context/I18nContext';
|
||||
|
||||
@@ -104,6 +104,21 @@ export const Sidebar: React.FC<SidebarProps> = ({
|
||||
onClick={() => onNavigate('search')}
|
||||
isExpanded={isOpen}
|
||||
/>
|
||||
<NavItem
|
||||
icon={<GraduationCap size={20} />}
|
||||
label={t('training')}
|
||||
active={currentView === 'training'}
|
||||
onClick={() => onNavigate('training')}
|
||||
isExpanded={isOpen}
|
||||
/>
|
||||
<NavItem
|
||||
icon={<Newspaper size={20} />}
|
||||
label={t('news')}
|
||||
active={currentView === 'news'}
|
||||
onClick={() => onNavigate('news')}
|
||||
isExpanded={isOpen}
|
||||
/>
|
||||
|
||||
<div className="mt-auto flex flex-col gap-2">
|
||||
{/* Theme Toggle */}
|
||||
<button
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,16 @@
|
||||
[
|
||||
{
|
||||
"id": "v1-gradio-migration",
|
||||
"date": "2025-02-09",
|
||||
"title": "Switched to Gradio API Backend",
|
||||
"body": "We migrated the backend from REST API to Gradio API for direct communication with ACE-Step. This version is experimental — if you encounter any bugs, please report them or open a PR on our GitHub repo.",
|
||||
"tags": ["backend", "experimental"]
|
||||
},
|
||||
{
|
||||
"id": "v1-training-experimental",
|
||||
"date": "2025-02-09",
|
||||
"title": "LoRA Training (Experimental)",
|
||||
"body": "The training feature is now available but still experimental. You may encounter bugs during dataset building, preprocessing, or training. Bug reports and PRs are welcome!",
|
||||
"tags": ["training", "experimental"]
|
||||
}
|
||||
]
|
||||
@@ -7,6 +7,7 @@ export const translations = {
|
||||
library: 'Library',
|
||||
search: 'Search',
|
||||
training: 'Training',
|
||||
news: 'News',
|
||||
|
||||
// Theme
|
||||
lightMode: 'Light Mode',
|
||||
@@ -601,6 +602,7 @@ export const translations = {
|
||||
library: '音乐库',
|
||||
search: '搜索',
|
||||
training: '训练',
|
||||
news: '新闻',
|
||||
|
||||
// Theme
|
||||
lightMode: '浅色模式',
|
||||
@@ -1195,6 +1197,7 @@ export const translations = {
|
||||
library: 'ライブラリ',
|
||||
search: '検索',
|
||||
training: 'トレーニング',
|
||||
news: 'ニュース',
|
||||
|
||||
// Theme
|
||||
lightMode: 'ライトモード',
|
||||
@@ -1789,6 +1792,7 @@ export const translations = {
|
||||
library: '라이브러리',
|
||||
search: '검색',
|
||||
training: '훈련',
|
||||
news: '뉴스',
|
||||
|
||||
// Theme
|
||||
lightMode: '라이트 모드',
|
||||
|
||||
@@ -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);
|
||||
|
||||
+260
@@ -106,6 +106,7 @@ export interface Song {
|
||||
user_id?: string;
|
||||
created_at: string;
|
||||
creator?: string;
|
||||
creator_avatar?: string;
|
||||
ditModel?: string;
|
||||
generation_params?: any;
|
||||
}
|
||||
@@ -311,11 +312,14 @@ export interface GenerationParams {
|
||||
|
||||
export interface GenerationJob {
|
||||
jobId: string;
|
||||
id?: string;
|
||||
status: 'pending' | 'queued' | 'running' | 'succeeded' | 'failed';
|
||||
queuePosition?: number;
|
||||
etaSeconds?: number;
|
||||
progress?: number;
|
||||
stage?: string;
|
||||
params?: any;
|
||||
created_at?: string;
|
||||
result?: {
|
||||
audioUrls: string[];
|
||||
bpm?: number;
|
||||
@@ -375,6 +379,13 @@ export const generateApi = {
|
||||
error?: string;
|
||||
}> => api('/api/generate/format', { method: 'POST', body: params, token }),
|
||||
|
||||
// Random description from Gradio's example library
|
||||
getRandomDescription: (token: string): Promise<{
|
||||
description: string;
|
||||
instrumental: boolean;
|
||||
vocalLanguage: string;
|
||||
}> => api('/api/generate/random-description', { token }),
|
||||
|
||||
// LoRA Inference (requires ACE-Step training fork)
|
||||
loadLora: (params: {
|
||||
lora_path: string;
|
||||
@@ -393,6 +404,20 @@ export const generateApi = {
|
||||
message: string;
|
||||
scale: number;
|
||||
}> => api('/api/lora/scale', { method: 'POST', body: params, token }),
|
||||
|
||||
toggleLora: (params: {
|
||||
enabled: boolean;
|
||||
}, token: string): Promise<{
|
||||
message: string;
|
||||
active: boolean;
|
||||
}> => api('/api/lora/toggle', { method: 'POST', body: params, token }),
|
||||
|
||||
getLoraStatus: (token: string): Promise<{
|
||||
loaded: boolean;
|
||||
active: boolean;
|
||||
scale: number;
|
||||
path: string;
|
||||
}> => api('/api/lora/status', { token }),
|
||||
};
|
||||
|
||||
// Users API
|
||||
@@ -532,3 +557,238 @@ export const contactApi = {
|
||||
submit: (data: ContactFormData): Promise<{ success: boolean; message: string; id: string }> =>
|
||||
api('/api/contact', { method: 'POST', body: data }),
|
||||
};
|
||||
|
||||
// Training API (LoRA fine-tuning via Gradio)
|
||||
export interface TrainingSample {
|
||||
audio: unknown;
|
||||
filename: string;
|
||||
caption: string;
|
||||
genre: string;
|
||||
promptOverride: string;
|
||||
lyrics: string;
|
||||
bpm: number;
|
||||
key: string;
|
||||
timeSignature: string;
|
||||
duration: number;
|
||||
language: string;
|
||||
instrumental: boolean;
|
||||
rawLyrics?: string;
|
||||
}
|
||||
|
||||
export interface DatasetSettings {
|
||||
datasetName: string;
|
||||
customTag: string;
|
||||
tagPosition: 'prepend' | 'append' | 'replace';
|
||||
allInstrumental: boolean;
|
||||
genreRatio: number;
|
||||
}
|
||||
|
||||
export interface TrainingParams {
|
||||
tensorDir?: string;
|
||||
rank?: number;
|
||||
alpha?: number;
|
||||
dropout?: number;
|
||||
learningRate?: number;
|
||||
epochs?: number;
|
||||
batchSize?: number;
|
||||
gradientAccumulation?: number;
|
||||
saveEvery?: number;
|
||||
shift?: number;
|
||||
seed?: number;
|
||||
outputDir?: string;
|
||||
resumeCheckpoint?: string | null;
|
||||
}
|
||||
|
||||
// Helper: build proxy URL for training audio files
|
||||
export function getTrainingAudioUrl(audioPath: unknown, token?: string): string | undefined {
|
||||
if (!audioPath) return undefined;
|
||||
|
||||
// Handle Gradio FileData objects
|
||||
if (typeof audioPath === 'object' && audioPath !== null) {
|
||||
const fd = audioPath as Record<string, unknown>;
|
||||
if (fd.url && typeof fd.url === 'string') return fd.url;
|
||||
if (fd.path && typeof fd.path === 'string') {
|
||||
return `${API_BASE}/api/training/audio?path=${encodeURIComponent(fd.path)}`;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
// Handle absolute path string
|
||||
if (typeof audioPath === 'string') {
|
||||
if (audioPath.startsWith('http://') || audioPath.startsWith('https://') || audioPath.startsWith('/audio/')) {
|
||||
return audioPath;
|
||||
}
|
||||
return `${API_BASE}/api/training/audio?path=${encodeURIComponent(audioPath)}`;
|
||||
}
|
||||
|
||||
return undefined;
|
||||
}
|
||||
|
||||
export const trainingApi = {
|
||||
// Upload audio files for a dataset
|
||||
uploadAudio: async (files: File[], datasetName: string, token: string): Promise<{
|
||||
files: Array<{ filename: string; originalName: string; size: number; path: string }>;
|
||||
uploadDir: string;
|
||||
count: number;
|
||||
}> => {
|
||||
const formData = new FormData();
|
||||
formData.append('datasetName', datasetName);
|
||||
for (const file of files) {
|
||||
formData.append('audio', file);
|
||||
}
|
||||
const response = await fetch(`${API_BASE}/api/training/upload-audio`, {
|
||||
method: 'POST',
|
||||
headers: { 'Authorization': `Bearer ${token}` },
|
||||
body: formData,
|
||||
});
|
||||
if (!response.ok) {
|
||||
const error = await response.json().catch(() => ({ error: 'Upload failed' }));
|
||||
throw new Error(error.error || 'Upload failed');
|
||||
}
|
||||
return response.json();
|
||||
},
|
||||
|
||||
// Build dataset JSON from uploaded audio files
|
||||
buildDataset: (params: {
|
||||
datasetName: string;
|
||||
customTag?: string;
|
||||
tagPosition?: string;
|
||||
allInstrumental?: boolean;
|
||||
}, token: string): Promise<{
|
||||
status: string;
|
||||
dataframe: unknown;
|
||||
sampleCount: number;
|
||||
sample: TrainingSample;
|
||||
settings: DatasetSettings;
|
||||
datasetPath: string;
|
||||
}> => api('/api/training/build-dataset', { method: 'POST', body: params, token }),
|
||||
|
||||
// Scan directory for audio files (Node.js implementation)
|
||||
scanDirectory: (params: {
|
||||
audioDir: string;
|
||||
datasetName?: string;
|
||||
customTag?: string;
|
||||
tagPosition?: string;
|
||||
allInstrumental?: boolean;
|
||||
}, token: string): Promise<{
|
||||
status: string;
|
||||
dataframe: unknown;
|
||||
sampleCount: number;
|
||||
audioDir: string;
|
||||
}> => api('/api/training/scan-directory', { method: 'POST', body: params, token }),
|
||||
|
||||
// Auto-label dataset samples (requires model loaded in Gradio)
|
||||
autoLabel: (params: {
|
||||
skipMetas?: boolean;
|
||||
formatLyrics?: boolean;
|
||||
transcribeLyrics?: boolean;
|
||||
onlyUnlabeled?: boolean;
|
||||
}, token: string): Promise<{
|
||||
dataframe?: unknown;
|
||||
status: string;
|
||||
error?: string;
|
||||
hint?: string;
|
||||
}> => api('/api/training/auto-label', { method: 'POST', body: params, token }),
|
||||
|
||||
// Initialize model for training (requires Gradio)
|
||||
initModel: (params: {
|
||||
checkpoint?: string;
|
||||
configPath?: string;
|
||||
device?: string;
|
||||
initLlm?: boolean;
|
||||
lmModelPath?: string;
|
||||
backend?: string;
|
||||
useFlashAttention?: boolean;
|
||||
offloadToCpu?: boolean;
|
||||
offloadDitToCpu?: boolean;
|
||||
compileModel?: boolean;
|
||||
quantization?: boolean;
|
||||
}, token: string): Promise<{
|
||||
status: string;
|
||||
modelReady?: boolean;
|
||||
error?: string;
|
||||
hint?: string;
|
||||
}> => api('/api/training/init-model', { method: 'POST', body: params, token }),
|
||||
|
||||
// List available checkpoints
|
||||
getCheckpoints: (token: string): Promise<{
|
||||
checkpoints: string[];
|
||||
configs: string[];
|
||||
}> => api('/api/training/checkpoints', { token }),
|
||||
|
||||
// List LoRA training checkpoints
|
||||
getLoraCheckpoints: (dir: string, token: string): Promise<{
|
||||
checkpoints: string[];
|
||||
outputDir: string;
|
||||
}> => api(`/api/training/lora-checkpoints?dir=${encodeURIComponent(dir)}`, { token }),
|
||||
|
||||
// Preprocess dataset to tensors
|
||||
preprocess: (params: {
|
||||
datasetPath: string;
|
||||
outputDir?: string;
|
||||
}, token: string): Promise<{
|
||||
status: string;
|
||||
message?: string;
|
||||
output_files?: number;
|
||||
}> => api('/api/training/preprocess', { method: 'POST', body: params, token }),
|
||||
|
||||
loadDataset: (datasetPath: string, token: string): Promise<{
|
||||
status: string;
|
||||
dataframe: unknown;
|
||||
sampleCount: number;
|
||||
sample: TrainingSample;
|
||||
settings: DatasetSettings;
|
||||
}> => api('/api/training/load-dataset', { method: 'POST', body: { datasetPath }, token }),
|
||||
|
||||
getSamplePreview: (idx: number, token: string): Promise<TrainingSample> =>
|
||||
api(`/api/training/sample-preview?idx=${idx}`, { token }),
|
||||
|
||||
saveSample: (params: {
|
||||
sampleIdx: number;
|
||||
caption: string;
|
||||
genre: string;
|
||||
promptOverride: string;
|
||||
lyrics: string;
|
||||
bpm: number;
|
||||
key: string;
|
||||
timeSignature: string;
|
||||
language: string;
|
||||
instrumental: boolean;
|
||||
}, token: string): Promise<{ dataframe: unknown; status: string }> =>
|
||||
api('/api/training/save-sample', { method: 'POST', body: params, token }),
|
||||
|
||||
updateSettings: (params: {
|
||||
customTag: string;
|
||||
tagPosition: string;
|
||||
allInstrumental: boolean;
|
||||
genreRatio: number;
|
||||
}, token: string): Promise<{ success: boolean }> =>
|
||||
api('/api/training/update-settings', { method: 'POST', body: params, token }),
|
||||
|
||||
saveDataset: (params: {
|
||||
savePath?: string;
|
||||
datasetName?: string;
|
||||
}, token: string): Promise<{ status: string; path: string }> =>
|
||||
api('/api/training/save-dataset', { method: 'POST', body: params, token }),
|
||||
|
||||
loadTensors: (tensorDir: string, token: string): Promise<{ status: string }> =>
|
||||
api('/api/training/load-tensors', { method: 'POST', body: { tensorDir }, token }),
|
||||
|
||||
startTraining: (params: TrainingParams, token: string): Promise<{
|
||||
progress: string;
|
||||
log: string;
|
||||
metrics: unknown;
|
||||
}> => api('/api/training/start', { method: 'POST', body: params, token }),
|
||||
|
||||
stopTraining: (token: string): Promise<{ status: string }> =>
|
||||
api('/api/training/stop', { method: 'POST', token }),
|
||||
|
||||
exportLora: (params: {
|
||||
exportPath?: string;
|
||||
loraOutputDir?: string;
|
||||
}, token: string): Promise<{ status: string }> =>
|
||||
api('/api/training/export', { method: 'POST', body: params, token }),
|
||||
|
||||
importDataset: (datasetType: string, token: string): Promise<{ status: string }> =>
|
||||
api('/api/training/import-dataset', { method: 'POST', body: { datasetType }, token }),
|
||||
};
|
||||
|
||||
@@ -151,4 +151,4 @@ export interface UserProfile {
|
||||
}
|
||||
|
||||
// Simplified views for ACE-Step UI
|
||||
export type View = 'create' | 'library' | 'profile' | 'song' | 'playlist' | 'search';
|
||||
export type View = 'create' | 'library' | 'training' | 'profile' | 'song' | 'playlist' | 'search' | 'news';
|
||||
|
||||
Reference in New Issue
Block a user