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

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