From b00af8a7046f44bd6108b1979a38aeb60ecf9651 Mon Sep 17 00:00:00 2001 From: fspecii <4722521+fspecii@users.noreply.github.com> Date: Sun, 8 Feb 2026 18:41:14 +0200 Subject: [PATCH] i18n CreatePanel with genre picker, model selector, EditableSlider, and LoRA panel Phase 4.3: Largest component change. Adds cascading genre picker using Phase 3 style data, DiT model selector with backend sync, EditableSlider for all numeric parameters, LoRA load/unload panel, bulk generate, vocal gender selector. Batch size max kept at 4 (not 8), inference steps max kept at 32 (not 200) to prevent OOM. Added LoRA API stubs to api.ts. Added ditModel to GenerationParams type. --- components/CreatePanel.tsx | 1225 +++++++++++++++++++++++------------- services/api.ts | 10 + types.ts | 1 + 3 files changed, 804 insertions(+), 432 deletions(-) diff --git a/components/CreatePanel.tsx b/components/CreatePanel.tsx index a041000..c02b466 100644 --- a/components/CreatePanel.tsx +++ b/components/CreatePanel.tsx @@ -2,7 +2,10 @@ import React, { useState, useEffect, useRef, useCallback, useMemo } from 'react' import { Sparkles, ChevronDown, Settings2, Trash2, Music2, Sliders, Dices, Hash, RefreshCw, Plus, Upload, Play, Pause, Loader2 } from 'lucide-react'; 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 { EditableSlider } from './EditableSlider'; interface ReferenceTrack { id: string; @@ -47,58 +50,58 @@ const KEY_SIGNATURES = [ const TIME_SIGNATURES = ['', '2/4', '3/4', '4/4', '6/8']; -const VOCAL_LANGUAGES = [ - { value: 'unknown', label: 'Auto / Instrumental' }, - { value: 'ar', label: 'Arabic' }, - { value: 'az', label: 'Azerbaijani' }, - { value: 'bg', label: 'Bulgarian' }, - { value: 'bn', label: 'Bengali' }, - { value: 'ca', label: 'Catalan' }, - { value: 'cs', label: 'Czech' }, - { value: 'da', label: 'Danish' }, - { value: 'de', label: 'German' }, - { value: 'el', label: 'Greek' }, - { value: 'en', label: 'English' }, - { value: 'es', label: 'Spanish' }, - { value: 'fa', label: 'Persian' }, - { value: 'fi', label: 'Finnish' }, - { value: 'fr', label: 'French' }, - { value: 'he', label: 'Hebrew' }, - { value: 'hi', label: 'Hindi' }, - { value: 'hr', label: 'Croatian' }, - { value: 'ht', label: 'Haitian Creole' }, - { value: 'hu', label: 'Hungarian' }, - { value: 'id', label: 'Indonesian' }, - { value: 'is', label: 'Icelandic' }, - { value: 'it', label: 'Italian' }, - { value: 'ja', label: 'Japanese' }, - { value: 'ko', label: 'Korean' }, - { value: 'la', label: 'Latin' }, - { value: 'lt', label: 'Lithuanian' }, - { value: 'ms', label: 'Malay' }, - { value: 'ne', label: 'Nepali' }, - { value: 'nl', label: 'Dutch' }, - { value: 'no', label: 'Norwegian' }, - { value: 'pa', label: 'Punjabi' }, - { value: 'pl', label: 'Polish' }, - { value: 'pt', label: 'Portuguese' }, - { value: 'ro', label: 'Romanian' }, - { value: 'ru', label: 'Russian' }, - { value: 'sa', label: 'Sanskrit' }, - { value: 'sk', label: 'Slovak' }, - { value: 'sr', label: 'Serbian' }, - { value: 'sv', label: 'Swedish' }, - { value: 'sw', label: 'Swahili' }, - { value: 'ta', label: 'Tamil' }, - { value: 'te', label: 'Telugu' }, - { value: 'th', label: 'Thai' }, - { value: 'tl', label: 'Tagalog' }, - { value: 'tr', label: 'Turkish' }, - { value: 'uk', label: 'Ukrainian' }, - { value: 'ur', label: 'Urdu' }, - { value: 'vi', label: 'Vietnamese' }, - { value: 'yue', label: 'Cantonese' }, - { value: 'zh', label: 'Chinese (Mandarin)' }, +const VOCAL_LANGUAGE_KEYS = [ + { value: 'unknown', key: 'autoInstrumental' as const }, + { value: 'ar', key: 'vocalArabic' as const }, + { value: 'az', key: 'vocalAzerbaijani' as const }, + { value: 'bg', key: 'vocalBulgarian' as const }, + { value: 'bn', key: 'vocalBengali' as const }, + { value: 'ca', key: 'vocalCatalan' as const }, + { value: 'cs', key: 'vocalCzech' as const }, + { value: 'da', key: 'vocalDanish' as const }, + { value: 'de', key: 'vocalGerman' as const }, + { value: 'el', key: 'vocalGreek' as const }, + { value: 'en', key: 'vocalEnglish' as const }, + { value: 'es', key: 'vocalSpanish' as const }, + { value: 'fa', key: 'vocalPersian' as const }, + { value: 'fi', key: 'vocalFinnish' as const }, + { value: 'fr', key: 'vocalFrench' as const }, + { value: 'he', key: 'vocalHebrew' as const }, + { value: 'hi', key: 'vocalHindi' as const }, + { value: 'hr', key: 'vocalCroatian' as const }, + { value: 'ht', key: 'vocalHaitianCreole' as const }, + { value: 'hu', key: 'vocalHungarian' as const }, + { value: 'id', key: 'vocalIndonesian' as const }, + { value: 'is', key: 'vocalIcelandic' as const }, + { value: 'it', key: 'vocalItalian' as const }, + { value: 'ja', key: 'vocalJapanese' as const }, + { value: 'ko', key: 'vocalKorean' as const }, + { value: 'la', key: 'vocalLatin' as const }, + { value: 'lt', key: 'vocalLithuanian' as const }, + { value: 'ms', key: 'vocalMalay' as const }, + { value: 'ne', key: 'vocalNepali' as const }, + { value: 'nl', key: 'vocalDutch' as const }, + { value: 'no', key: 'vocalNorwegian' as const }, + { value: 'pa', key: 'vocalPunjabi' as const }, + { value: 'pl', key: 'vocalPolish' as const }, + { value: 'pt', key: 'vocalPortuguese' as const }, + { value: 'ro', key: 'vocalRomanian' as const }, + { value: 'ru', key: 'vocalRussian' as const }, + { value: 'sa', key: 'vocalSanskrit' as const }, + { value: 'sk', key: 'vocalSlovak' as const }, + { value: 'sr', key: 'vocalSerbian' as const }, + { value: 'sv', key: 'vocalSwedish' as const }, + { value: 'sw', key: 'vocalSwahili' as const }, + { value: 'ta', key: 'vocalTamil' as const }, + { value: 'te', key: 'vocalTelugu' as const }, + { value: 'th', key: 'vocalThai' as const }, + { value: 'tl', key: 'vocalTagalog' as const }, + { value: 'tr', key: 'vocalTurkish' as const }, + { value: 'uk', key: 'vocalUkrainian' as const }, + { value: 'ur', key: 'vocalUrdu' as const }, + { value: 'vi', key: 'vocalVietnamese' as const }, + { value: 'yue', key: 'vocalCantonese' as const }, + { value: 'zh', key: 'vocalChineseMandarin' as const }, ]; export const CreatePanel: React.FC = ({ @@ -110,6 +113,19 @@ export const CreatePanel: React.FC = ({ onAudioSelectionApplied, }) => { const { isAuthenticated, token, user } = useAuth(); + const { t } = useI18n(); + + // Randomly select 6 music tags from MAIN_STYLES + const [musicTags, setMusicTags] = useState(() => { + const shuffled = [...MAIN_STYLES].sort(() => Math.random() - 0.5); + return shuffled.slice(0, 6); + }); + + // Function to refresh music tags + const refreshMusicTags = useCallback(() => { + const shuffled = [...MAIN_STYLES].sort(() => Math.random() - 0.5); + setMusicTags(shuffled.slice(0, 6)); + }, []); // Mode const [customMode, setCustomMode] = useState(true); @@ -195,6 +211,71 @@ export const CreatePanel: React.FC = ({ const [maxDurationWithLm, setMaxDurationWithLm] = useState(240); const [maxDurationWithoutLm, setMaxDurationWithoutLm] = useState(240); + // LoRA Parameters + const [showLoraPanel, setShowLoraPanel] = useState(false); + const [loraPath, setLoraPath] = useState('./lora_output/final/adapter'); + const [loraLoaded, setLoraLoaded] = useState(false); + const [loraScale, setLoraScale] = useState(1.0); + const [loraError, setLoraError] = useState(null); + const [isLoraLoading, setIsLoraLoading] = useState(false); + + // Model selection + const [selectedModel, setSelectedModel] = useState(() => { + return localStorage.getItem('ace-model') || 'acestep-v15-turbo-shift3'; + }); + const [showModelMenu, setShowModelMenu] = useState(false); + const modelMenuRef = useRef(null); + const previousModelRef = useRef(selectedModel); + + // Available models fetched from backend + const [fetchedModels, setFetchedModels] = useState<{ name: string; is_active: boolean; is_preloaded: boolean }[]>([]); + + // Fallback model list when backend is unavailable + const availableModels = useMemo(() => { + if (fetchedModels.length > 0) { + return fetchedModels.map(m => ({ id: m.name, name: m.name })); + } + return [ + { id: 'acestep-v15-base', name: 'acestep-v15-base' }, + { id: 'acestep-v15-sft', name: 'acestep-v15-sft' }, + { id: 'acestep-v15-turbo', name: 'acestep-v15-turbo' }, + { id: 'acestep-v15-turbo-shift1', name: 'acestep-v15-turbo-shift1' }, + { id: 'acestep-v15-turbo-shift3', name: 'acestep-v15-turbo-shift3' }, + { id: 'acestep-v15-turbo-continuous', name: 'acestep-v15-turbo-continuous' }, + ]; + }, [fetchedModels]); + + // Map model ID to short display name + const getModelDisplayName = (modelId: string): string => { + const mapping: Record = { + 'acestep-v15-base': '1.5B', + 'acestep-v15-sft': '1.5S', + 'acestep-v15-turbo-shift1': '1.5TS1', + 'acestep-v15-turbo-shift3': '1.5TS3', + 'acestep-v15-turbo-continuous': '1.5TC', + 'acestep-v15-turbo': '1.5T', + }; + return mapping[modelId] || modelId; + }; + + // Check if model is a turbo variant + const isTurboModel = (modelId: string): boolean => { + return modelId.includes('turbo'); + }; + + // Genre selection state (cascading) + const [selectedMainGenre, setSelectedMainGenre] = useState(''); + const [selectedSubGenre, setSelectedSubGenre] = useState(''); + + // 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); @@ -263,6 +344,99 @@ export const CreatePanel: React.FC = ({ const [isResizing, setIsResizing] = useState(false); const lyricsRef = useRef(null); + + // Close model menu when clicking outside + useEffect(() => { + const handleClickOutside = (event: MouseEvent) => { + if (modelMenuRef.current && !modelMenuRef.current.contains(event.target as Node)) { + setShowModelMenu(false); + } + }; + + if (showModelMenu) { + document.addEventListener('mousedown', handleClickOutside); + return () => document.removeEventListener('mousedown', handleClickOutside); + } + }, [showModelMenu]); + + // Auto-unload LoRA when model changes + useEffect(() => { + if (previousModelRef.current !== selectedModel && loraLoaded) { + void handleLoraUnload(); + } + previousModelRef.current = selectedModel; + }, [selectedModel, loraLoaded]); + + // Auto-disable thinking and ADG when LoRA is loaded + useEffect(() => { + if (loraLoaded) { + if (thinking) setThinking(false); + if (useAdg) setUseAdg(false); + } + }, [loraLoaded]); + + // LoRA API handlers + const handleLoraToggle = async () => { + if (!token) { + setLoraError('Please sign in to use LoRA'); + return; + } + if (!loraPath.trim()) { + setLoraError('Please enter a LoRA path'); + return; + } + + setIsLoraLoading(true); + setLoraError(null); + + try { + if (loraLoaded) { + await handleLoraUnload(); + } else { + const result = await generateApi.loadLora({ lora_path: loraPath }, token); + setLoraLoaded(true); + console.log('LoRA loaded:', result?.message); + } + } catch (err) { + const message = err instanceof Error ? err.message : 'LoRA operation failed'; + setLoraError(message); + console.error('LoRA error:', err); + } finally { + setIsLoraLoading(false); + } + }; + + const handleLoraUnload = async () => { + if (!token) return; + + setIsLoraLoading(true); + setLoraError(null); + + try { + const result = await generateApi.unloadLora(token); + setLoraLoaded(false); + console.log('LoRA unloaded:', result?.message); + } catch (err) { + const message = err instanceof Error ? err.message : 'Failed to unload LoRA'; + setLoraError(message); + console.error('Unload error:', err); + } finally { + setIsLoraLoading(false); + } + }; + + const handleLoraScaleChange = async (newScale: number) => { + setLoraScale(newScale); + + if (!token || !loraLoaded) return; + + try { + await generateApi.setLoraScale({ scale: newScale }, token); + } catch (err) { + console.error('Failed to set LoRA scale:', err); + } + }; + // Reuse Effect - must be after all state declarations useEffect(() => { if (initialData) { @@ -331,8 +505,32 @@ export const CreatePanel: React.FC = ({ }; }, [isResizing]); + const refreshModels = useCallback(async () => { + try { + const modelsRes = await fetch('/api/generate/models'); + if (modelsRes.ok) { + const data = await modelsRes.json(); + const models = data.models || []; + if (models.length > 0) { + setFetchedModels(models); + // Always sync to the backend's active model + const active = models.find((m: any) => m.is_active); + if (active) { + setSelectedModel(active.name); + localStorage.setItem('ace-model', active.name); + } + } + } + } catch { + // ignore - will use fallback model list + } + }, []); + useEffect(() => { - const loadLimits = async () => { + const loadModelsAndLimits = async () => { + await refreshModels(); + + // Fetch limits try { const response = await fetch('/api/generate/limits'); if (!response.ok) return; @@ -348,9 +546,18 @@ export const CreatePanel: React.FC = ({ } }; - loadLimits(); + loadModelsAndLimits(); }, []); + // Re-fetch models after generation completes to update active model + const prevIsGeneratingRef = useRef(isGenerating); + useEffect(() => { + if (prevIsGeneratingRef.current && !isGenerating) { + void refreshModels(); + } + prevIsGeneratingRef.current = isGenerating; + }, [isGenerating, refreshModels]); + const activeMaxDuration = thinking ? maxDurationWithLm : maxDurationWithoutLm; useEffect(() => { @@ -474,15 +681,18 @@ export const CreatePanel: React.FC = ({ lmBackend: lmBackend || 'pt', }, token); - if (result.success) { + if (result.caption || result.lyrics || result.bpm || result.duration) { // Update fields with LLM-generated values if (target === 'style' && result.caption) setStyle(result.caption); if (target === 'lyrics' && result.lyrics) setLyrics(result.lyrics); if (result.bpm && result.bpm > 0) setBpm(result.bpm); if (result.duration && result.duration > 0) setDuration(result.duration); if (result.key_scale) setKeyScale(result.key_scale); - if (result.time_signature) setTimeSignature(result.time_signature); - if (result.language) setVocalLanguage(result.language); + if (result.time_signature) { + const ts = String(result.time_signature); + setTimeSignature(ts.includes('/') ? ts : `${ts}/4`); + } + if (result.vocal_language) setVocalLanguage(result.vocal_language); if (target === 'style') setIsFormatCaption(true); } else { console.error('Format failed:', result.error || result.status_message); @@ -754,6 +964,7 @@ export const CreatePanel: React.FC = ({ lyrics, style: styleWithGender, title: bulkCount > 1 ? `${title} (${i + 1})` : title, + ditModel: selectedModel, instrumental, vocalLanguage, bpm, @@ -809,6 +1020,7 @@ export const CreatePanel: React.FC = ({ return parsed.length ? parsed : undefined; })(), isFormatCaption, + loraLoaded, }); } @@ -835,18 +1047,18 @@ export const CreatePanel: React.FC = ({ )}
- {dragKind === 'audio' ? 'Drop to use audio' : 'Drop to upload'} + {dragKind === 'audio' ? t('dropToUseAudio') : t('dropToUpload')}
{dragKind === 'audio' - ? `Using as ${audioTab === 'reference' ? 'Reference' : 'Cover'}` - : `Uploading as ${audioTab === 'reference' ? 'Reference' : 'Cover'}`} + ? (audioTab === 'reference' ? t('usingAsReference') : t('usingAsCover')) + : (audioTab === 'reference' ? t('uploadingAsReference') : t('uploadingAsCover'))}
)} -
+
= ({ onLoadedMetadata={(e) => setSourceDuration(e.currentTarget.duration || 0)} /> - {/* Header - Mode Toggle */} + {/* Header - Mode Toggle & Model Selection */}
ACE-Step v1.5
-
- - +
+ {/* Mode Toggle */} +
+ + +
+ + {/* Model Selection */} +
+ + + {/* Floating Model Menu */} + {showModelMenu && availableModels.length > 0 && ( +
+
+ {availableModels.map(model => ( + + ))} +
+
+ )} +
@@ -909,12 +1183,12 @@ export const CreatePanel: React.FC = ({ {/* Song Description */}
- Describe Your Song + {t('describeYourSong')}