diff --git a/.env.example b/.env.example index f24a9ec..584f217 100644 --- a/.env.example +++ b/.env.example @@ -25,6 +25,9 @@ MODELS_DIR=./models # # Override the primary DiT GGUF only if you want a non-default model: # ACESTEP_MODEL=/path/to/models/acestep-v15-turbo-Q8_0.gguf +# +# Override the base DiT GGUF used for lego mode (auto-detected from models/ by default): +# ACESTEP_BASE_MODEL=/path/to/models/acestep-v15-base-Q8_0.gguf # Mode 2 (advanced): Connect to a separately running acestep-cpp HTTP server. # Leave the above unset and configure ACESTEP_API_URL instead. diff --git a/App.tsx b/App.tsx index 80d27b0..0572e5b 100644 --- a/App.tsx +++ b/App.tsx @@ -23,6 +23,7 @@ import { SearchPage } from './components/SearchPage'; import { NewsPage } from './components/NewsPage'; import { ModelManager } from './components/ModelManager'; import { ConfirmDialog } from './components/ConfirmDialog'; +import { DebugPanel } from './components/DebugPanel'; function AppContent() { @@ -215,7 +216,8 @@ function AppContent() { const handleBackFromProfile = () => { setViewingUsername(null); setCurrentView('create'); - window.history.pushState({}, '', '/'); + const wid = new URLSearchParams(window.location.search).get('wid'); + window.history.pushState({}, '', wid ? `/?wid=${wid}` : '/'); }; // Navigate to Song Handler @@ -229,7 +231,8 @@ function AppContent() { const handleBackFromSong = () => { setViewingSongId(null); setCurrentView('create'); - window.history.pushState({}, '', '/'); + const wid = new URLSearchParams(window.location.search).get('wid'); + window.history.pushState({}, '', wid ? `/?wid=${wid}` : '/'); }; // Theme Effect @@ -290,6 +293,8 @@ function AppContent() { setCurrentView('news'); } else if (path === '/models') { setCurrentView('models'); + } else if (path === '/debug') { + setCurrentView('debug'); } }; @@ -736,6 +741,9 @@ function AppContent() { cleanupJob(jobId, tempId); console.error(`Job ${jobId} failed:`, status.error); showToast(`${t('generationFailed')}: ${status.error || 'Unknown error'}`, 'error'); + // Auto-open debug view so the user can inspect the logs + setCurrentView('debug'); + window.history.pushState({}, '', '/debug'); } } catch (pollError) { console.error(`Polling error for job ${jobId}:`, pollError); @@ -1305,6 +1313,13 @@ function AppContent() { case 'news': return ; + case 'debug': + return ( +
+ +
+ ); + case 'create': default: return ( @@ -1402,7 +1417,8 @@ function AppContent() { setCurrentView(v); if (v === 'create') { setMobileShowList(false); - window.history.pushState({}, '', '/'); + const wid = new URLSearchParams(window.location.search).get('wid'); + window.history.pushState({}, '', wid ? `/?wid=${wid}` : '/'); } else if (v === 'library') { window.history.pushState({}, '', '/library'); } else if (v === 'models') { @@ -1411,6 +1427,8 @@ function AppContent() { window.history.pushState({}, '', '/search'); } else if (v === 'news') { window.history.pushState({}, '', '/news'); + } else if (v === 'debug') { + window.history.pushState({}, '', '/debug'); } if (isMobile) setShowLeftSidebar(false); }} diff --git a/components/CreatePanel.tsx b/components/CreatePanel.tsx index b9a2a8f..925c4ae 100644 --- a/components/CreatePanel.tsx +++ b/components/CreatePanel.tsx @@ -1,5 +1,5 @@ import React, { useState, useEffect, useRef, useCallback, useMemo } from 'react'; -import { Sparkles, ChevronDown, Settings2, Trash2, Music2, Sliders, Dices, Hash, RefreshCw, Plus, Upload, Play, Pause, Loader2, AlertTriangle, CheckCircle2, ExternalLink } from 'lucide-react'; +import { Sparkles, ChevronDown, Settings2, Trash2, Music2, Sliders, Dices, Hash, RefreshCw, Plus, Upload, Play, Pause, Loader2, AlertTriangle, CheckCircle2, ExternalLink, Info } from 'lucide-react'; import { GenerationParams, Song } from '../types'; import { useAuth } from '../context/AuthContext'; import { useI18n } from '../context/I18nContext'; @@ -133,29 +133,47 @@ export const CreatePanel: React.FC = ({ }, []); // Mode - const [customMode, setCustomMode] = useState(true); + // Unified mode: always use the full-featured panel (no simple/custom split) + const customMode = true; + + // Workspace ID: read from URL ?wid= query param, generate if absent + const workspaceId = useMemo(() => { + const params = new URLSearchParams(window.location.search); + let wid = params.get('wid'); + if (!wid) { + wid = (typeof crypto !== 'undefined' && crypto.randomUUID ? crypto.randomUUID() : (Date.now().toString(36) + Math.random().toString(36).slice(2, 12))); + const newParams = new URLSearchParams(window.location.search); + newParams.set('wid', wid); + window.history.replaceState({}, '', window.location.pathname + '?' + newParams.toString()); + } + return wid; + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); // run once on mount - // Simple Mode - const [songDescription, setSongDescription] = useState(''); + // Load persisted settings once at mount (before any useState calls) + const savedSettings = useMemo(() => { + try { return JSON.parse(localStorage.getItem('ace-settings-' + workspaceId) || '{}'); } catch { return {}; } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); // empty dep array: run once on mount only // Custom Mode - const [lyrics, setLyrics] = useState(''); - const [style, setStyle] = useState(''); - const [title, setTitle] = useState(''); + const [lyrics, setLyrics] = useState(savedSettings.lyrics ?? ''); + const [style, setStyle] = useState(savedSettings.style ?? ''); + const [title, setTitle] = useState(savedSettings.title ?? ''); // Common - const [instrumental, setInstrumental] = useState(false); - const [vocalLanguage, setVocalLanguage] = useState('en'); - const [vocalGender, setVocalGender] = useState<'male' | 'female' | ''>(''); + const [instrumental, setInstrumental] = useState(savedSettings.instrumental ?? false); + const [vocalLanguage, setVocalLanguage] = useState(savedSettings.vocalLanguage ?? 'en'); + const [vocalGender, setVocalGender] = useState<'male' | 'female' | ''>(savedSettings.vocalGender ?? ''); // Music Parameters - const [bpm, setBpm] = useState(0); - const [keyScale, setKeyScale] = useState(''); - const [timeSignature, setTimeSignature] = useState(''); + const [bpm, setBpm] = useState(savedSettings.bpm ?? 0); + const [keyScale, setKeyScale] = useState(savedSettings.keyScale ?? ''); + const [timeSignature, setTimeSignature] = useState(savedSettings.timeSignature ?? ''); // Advanced Settings - const [showAdvanced, setShowAdvanced] = useState(false); - const [duration, setDuration] = useState(-1); + const [showAdvanced, setShowAdvanced] = useState(false); + const [duration, setDuration] = useState(savedSettings.duration ?? -1); const [batchSize, setBatchSize] = useState(() => { const stored = localStorage.getItem('ace-batchSize'); return stored ? Number(stored) : 1; @@ -164,29 +182,33 @@ export const CreatePanel: React.FC = ({ const stored = localStorage.getItem('ace-bulkCount'); return stored ? Number(stored) : 1; }); - const [guidanceScale, setGuidanceScale] = useState(9.0); - const [randomSeed, setRandomSeed] = useState(true); - const [seed, setSeed] = useState(-1); - const [thinking, setThinking] = useState(false); // Default false for GPU compatibility - const [enhance, setEnhance] = useState(false); // AI Enhance: uses LLM to enrich caption & generate metadata - const [audioFormat, setAudioFormat] = useState<'mp3' | 'flac'>('mp3'); - const [inferenceSteps, setInferenceSteps] = useState(12); - const [inferMethod, setInferMethod] = useState<'ode' | 'sde'>('ode'); - const [lmBackend, setLmBackend] = useState<'pt' | 'vllm'>('pt'); + const [guidanceScale, setGuidanceScale] = useState(savedSettings.guidanceScale ?? 9.0); + const [randomSeed, setRandomSeed] = useState(savedSettings.randomSeed ?? true); + const [seed, setSeed] = useState(savedSettings.seed ?? -1); + const [thinking, setThinking] = useState(savedSettings.thinking ?? false); // Default false for GPU compatibility + const [enhance, setEnhance] = useState(savedSettings.enhance ?? false); // AI Enhance: uses LLM to enrich caption & generate metadata + const [audioFormat, setAudioFormat] = useState<'wav' | 'mp3'>(() => { + const saved = savedSettings.audioFormat; + return (saved === 'wav' || saved === 'mp3') ? saved : 'mp3'; + }); + const [inferenceSteps, setInferenceSteps] = useState(savedSettings.inferenceSteps ?? 12); + const [inferMethod, setInferMethod] = useState<'ode' | 'sde'>(savedSettings.inferMethod ?? 'ode'); + const [lmBackend, setLmBackend] = useState<'pt' | 'vllm'>(savedSettings.lmBackend ?? 'pt'); const [lmModel, setLmModel] = useState(() => { return localStorage.getItem('ace-lmModel') || 'acestep-5Hz-lm-0.6B'; }); - const [shift, setShift] = useState(3.0); + const [shift, setShift] = useState(savedSettings.shift ?? 3.0); // LM Parameters (under Expert) const [showLmParams, setShowLmParams] = useState(false); - const [lmTemperature, setLmTemperature] = useState(0.8); - const [lmCfgScale, setLmCfgScale] = useState(2.2); - const [lmTopK, setLmTopK] = useState(0); - const [lmTopP, setLmTopP] = useState(0.92); - const [lmNegativePrompt, setLmNegativePrompt] = useState('NO USER INPUT'); + const [lmTemperature, setLmTemperature] = useState(savedSettings.lmTemperature ?? 0.8); + const [lmCfgScale, setLmCfgScale] = useState(savedSettings.lmCfgScale ?? 2.2); + const [lmTopK, setLmTopK] = useState(savedSettings.lmTopK ?? 0); + const [lmTopP, setLmTopP] = useState(savedSettings.lmTopP ?? 0.92); + const [lmNegativePrompt, setLmNegativePrompt] = useState(savedSettings.lmNegativePrompt ?? 'NO USER INPUT'); // Expert Parameters (now in Advanced section) + // Note: audio URLs are NOT persisted — they may point to deleted/temporary files const [referenceAudioUrl, setReferenceAudioUrl] = useState(''); const [sourceAudioUrl, setSourceAudioUrl] = useState(''); const [referenceAudioTitle, setReferenceAudioTitle] = useState(''); @@ -195,8 +217,8 @@ export const CreatePanel: React.FC = ({ const [repaintingStart, setRepaintingStart] = useState(0); const [repaintingEnd, setRepaintingEnd] = useState(-1); const [instruction, setInstruction] = useState('Fill the audio semantic mask based on the given conditions:'); - const [audioCoverStrength, setAudioCoverStrength] = useState(1.0); - const [taskType, setTaskType] = useState('text2music'); + const [audioCoverStrength, setAudioCoverStrength] = useState(savedSettings.audioCoverStrength ?? 1.0); + const [taskType, setTaskType] = useState(savedSettings.taskType ?? 'text2music'); const [useAdg, setUseAdg] = useState(false); const [cfgIntervalStart, setCfgIntervalStart] = useState(0.0); const [cfgIntervalEnd, setCfgIntervalEnd] = useState(1.0); @@ -211,9 +233,14 @@ export const CreatePanel: React.FC = ({ const [getLrc, setGetLrc] = useState(false); const [scoreScale, setScoreScale] = useState(0.5); const [lmBatchChunkSize, setLmBatchChunkSize] = useState(8); - const [trackName, setTrackName] = useState(''); - const [completeTrackClasses, setCompleteTrackClasses] = useState(''); + const [trackName, setTrackName] = useState(savedSettings.trackName ?? ''); + const [completeTrackClasses, setCompleteTrackClasses] = useState(savedSettings.completeTrackClasses ?? ''); const [isFormatCaption, setIsFormatCaption] = useState(false); + // Parsed array — memoised so the split doesn't run on every render + const completeTrackClassesParsed = useMemo( + () => completeTrackClasses.split(',').map(s => s.trim()).filter(Boolean), + [completeTrackClasses] + ); const [maxDurationWithLm, setMaxDurationWithLm] = useState(240); const [maxDurationWithoutLm, setMaxDurationWithoutLm] = useState(240); @@ -242,6 +269,9 @@ export const CreatePanel: React.FC = ({ // The SFT model GGUF file to download when not present (Q8_0 is the default quality tier) const SFT_MODEL_FILE = 'acestep-v15-sft-Q8_0.gguf'; + // The base DiT model name — required for lego mode + const BASE_MODEL_NAME = 'acestep-v15-base'; + // Fallback model list when backend is unavailable const availableModels = useMemo(() => { if (fetchedModels.length > 0) { @@ -280,11 +310,22 @@ export const CreatePanel: React.FC = ({ return modelId.includes('sft'); }; + // Check if model is the base variant (required for lego) + const isBaseModel = (modelId: string): boolean => { + return modelId.startsWith('acestep-v15-base'); + }; + // SFT model download/availability state for repaint mode type SftStatus = 'idle' | 'checking' | 'available' | 'downloading' | 'unavailable'; const [sftStatus, setSftStatus] = useState('idle'); const sftSseRef = useRef(null); + // Understand state — per audio target + type UnderstandStatus = 'idle' | 'running' | 'done' | 'error'; + const [understandStatus, setUnderstandStatus] = useState>({ reference: 'idle', source: 'idle' }); + const [understandResult, setUnderstandResult] = useState | null>>({ reference: null, source: null }); + const [understandError, setUnderstandError] = useState>({ reference: null, source: null }); + const [isUploadingReference, setIsUploadingReference] = useState(false); const [isUploadingSource, setIsUploadingSource] = useState(false); const [isTranscribingReference, setIsTranscribingReference] = useState(false); @@ -300,7 +341,7 @@ export const CreatePanel: React.FC = ({ const [showAudioModal, setShowAudioModal] = useState(false); const [audioModalTarget, setAudioModalTarget] = useState<'reference' | 'source'>('reference'); const [tempAudioUrl, setTempAudioUrl] = useState(''); - const [audioTab, setAudioTab] = useState<'reference' | 'source'>('reference'); + const [audioTab, setAudioTab] = useState<'reference' | 'source' | 'lego'>('reference'); const referenceAudioRef = useRef(null); const sourceAudioRef = useRef(null); const [referencePlaying, setReferencePlaying] = useState(false); @@ -504,7 +545,6 @@ export const CreatePanel: React.FC = ({ // Reuse Effect - must be after all state declarations useEffect(() => { if (initialData) { - setCustomMode(true); setLyrics(initialData.song.lyrics); setStyle(initialData.song.style); setTitle(initialData.song.title); @@ -668,6 +708,20 @@ export const CreatePanel: React.FC = ({ localStorage.setItem('ace-model', prevModelBeforeRepaintRef.current); prevModelBeforeRepaintRef.current = null; } + } else if (taskType === 'lego') { + // Entering lego mode: switch to base model if not already on one + if (!isBaseModel(selectedModel)) { + prevModelBeforeRepaintRef.current = selectedModel; + setSelectedModel(BASE_MODEL_NAME); + localStorage.setItem('ace-model', BASE_MODEL_NAME); + } + } else if (prevTaskType === 'lego') { + // Leaving lego mode: restore previous model if it was switched + if (prevModelBeforeRepaintRef.current && isBaseModel(selectedModel)) { + setSelectedModel(prevModelBeforeRepaintRef.current); + localStorage.setItem('ace-model', prevModelBeforeRepaintRef.current); + prevModelBeforeRepaintRef.current = null; + } } }, [taskType, checkAndEnsureSftModel]); @@ -706,6 +760,38 @@ export const CreatePanel: React.FC = ({ prevIsGeneratingRef.current = isGenerating; }, [isGenerating, refreshModels]); + // Persist all main settings to localStorage (debounced 500ms) + useEffect(() => { + const timer = setTimeout(() => { + try { + localStorage.setItem('ace-settings-' + workspaceId, JSON.stringify({ + lyrics, style, title, + instrumental, vocalLanguage, vocalGender, + bpm, keyScale, timeSignature, + duration, + guidanceScale, randomSeed, seed, + thinking, enhance, audioFormat, + inferenceSteps, inferMethod, lmBackend, shift, + lmTemperature, lmCfgScale, lmTopK, lmTopP, lmNegativePrompt, + audioCoverStrength, taskType, + trackName, completeTrackClasses, + })); + } catch { /* ignore quota errors */ } + }, 500); + return () => clearTimeout(timer); + }, [ + lyrics, style, title, + instrumental, vocalLanguage, vocalGender, + bpm, keyScale, timeSignature, + duration, + guidanceScale, randomSeed, seed, + thinking, enhance, audioFormat, + inferenceSteps, inferMethod, lmBackend, shift, + lmTemperature, lmCfgScale, lmTopK, lmTopP, lmNegativePrompt, + audioCoverStrength, taskType, + trackName, completeTrackClasses, + ]); + const activeMaxDuration = thinking ? maxDurationWithLm : maxDurationWithoutLm; useEffect(() => { @@ -940,6 +1026,34 @@ export const CreatePanel: React.FC = ({ setIsTranscribingReference(false); }; + /** Run ace-understand on the audio at the given URL and store the result. */ + const handleUnderstand = async (target: 'reference' | 'source', audioUrl: string) => { + if (!token || !audioUrl) return; + setUnderstandStatus(prev => ({ ...prev, [target]: 'running' })); + setUnderstandResult(prev => ({ ...prev, [target]: null })); + setUnderstandError(prev => ({ ...prev, [target]: null })); + try { + const result = await generateApi.understandAudioUrl(audioUrl, token); + setUnderstandResult(prev => ({ ...prev, [target]: result })); + setUnderstandStatus(prev => ({ ...prev, [target]: 'done' })); + } catch (err) { + const msg = err instanceof Error ? err.message : 'Analysis failed'; + setUnderstandError(prev => ({ ...prev, [target]: msg })); + setUnderstandStatus(prev => ({ ...prev, [target]: 'error' })); + } + }; + + /** Apply understand result fields to the generation form. */ + const applyUnderstandResult = (result: Record) => { + if (typeof result.caption === 'string' && result.caption) setStyle(result.caption); + if (typeof result.lyrics === 'string' && result.lyrics) setLyrics(result.lyrics); + if (typeof result.bpm === 'number' && result.bpm > 0) setBpm(result.bpm); + if (typeof result.duration === 'number' && result.duration > 0) setDuration(Math.round(result.duration)); + if (typeof result.keyscale === 'string' && result.keyscale) setKeyScale(result.keyscale); + if (typeof result.timesignature === 'string' && result.timesignature) setTimeSignature(result.timesignature); + if (typeof result.vocal_language === 'string' && result.vocal_language) setVocalLanguage(result.vocal_language); + }; + const deleteReferenceTrack = async (trackId: string) => { if (!token) return; try { @@ -1018,14 +1132,16 @@ export const CreatePanel: React.FC = ({ return `${minutes}:${String(seconds).padStart(2, '0')}`; }; - /** Clear the source audio and reset task type if it was cover/repaint. */ + /** Clear the source audio and reset task type if it was cover/repaint/lego. */ const handleClearSourceAudio = () => { setSourceAudioUrl(''); setSourceAudioTitle(''); setSourcePlaying(false); setSourceTime(0); setSourceDuration(0); - if (taskType === 'cover' || taskType === 'repaint') setTaskType('text2music'); + if (taskType === 'cover' || taskType === 'repaint' || taskType === 'lego') setTaskType('text2music'); + // If we're on the lego tab, switch away since there's no source audio anymore + if (audioTab === 'lego') setAudioTab('reference'); }; /** @@ -1083,7 +1199,8 @@ export const CreatePanel: React.FC = ({ const handleWorkspaceDrop = (e: React.DragEvent) => { if (e.dataTransfer.files?.length || e.dataTransfer.types.includes('application/x-ace-audio')) { - handleDrop(e, audioTab); + // Lego tab uses source audio slot for the backing track + handleDrop(e, audioTab === 'lego' ? 'source' : audioTab); } }; @@ -1093,6 +1210,16 @@ export const CreatePanel: React.FC = ({ } }; + /** Switch the audio tab; automatically syncs the taskType when entering/leaving lego. */ + const handleAudioTabChange = (tab: 'reference' | 'source' | 'lego') => { + setAudioTab(tab); + if (tab === 'lego') { + setTaskType('lego'); + } else if (taskType === 'lego') { + setTaskType('text2music'); + } + }; + const handleGenerate = () => { const styleWithGender = (() => { if (!vocalGender) return style; @@ -1113,8 +1240,6 @@ export const CreatePanel: React.FC = ({ } onGenerate({ - customMode, - songDescription: customMode ? undefined : songDescription, prompt: lyrics, lyrics, style: styleWithGender, @@ -1168,13 +1293,7 @@ export const CreatePanel: React.FC = ({ scoreScale, lmBatchChunkSize, trackName: trackName.trim() || undefined, - completeTrackClasses: (() => { - const parsed = completeTrackClasses - .split(',') - .map((item) => item.trim()) - .filter(Boolean); - return parsed.length ? parsed : undefined; - })(), + completeTrackClasses: completeTrackClassesParsed.length ? completeTrackClassesParsed : undefined, isFormatCaption, loraLoaded, }); @@ -1256,22 +1375,6 @@ export const CreatePanel: React.FC = ({
- {/* Mode Toggle */} -
- - -
- {/* Model Selection */}
- {/* SIMPLE MODE */} - {!customMode && ( -
- {/* Song Description */} -
-
- - {t('describeYourSong')} - + + {/* UNIFIED PANEL */} +
+ {/* Title Input */} +
+
+ {t('title')} +
+ setTitle(e.target.value)} + placeholder={t('nameSong')} + className="w-full bg-transparent p-3 text-sm text-zinc-900 dark:text-white placeholder-zinc-400 dark:placeholder-zinc-600 focus:outline-none" + /> +
+ + {/* Style Input */} +
+
+
+
+ {t('styleOfMusic')} + +
+

{t('genreMoodInstruments')}

+
+
+ + +
+
+