diff --git a/App.tsx b/App.tsx index 0f8bdfd..747f640 100644 --- a/App.tsx +++ b/App.tsx @@ -11,17 +11,23 @@ import { UsernameModal } from './components/UsernameModal'; import { UserProfile } from './components/UserProfile'; import { SettingsModal } from './components/SettingsModal'; import { SongProfile } from './components/SongProfile'; +import { TrainingPanel } from './components/TrainingPanel'; import { Song, GenerationParams, View, Playlist } from './types'; import { generateApi, songsApi, playlistsApi, getAudioUrl } from './services/api'; import { useAuth } from './context/AuthContext'; import { useResponsive } from './context/ResponsiveContext'; +import { I18nProvider, useI18n } from './context/I18nContext'; import { List } from 'lucide-react'; import { PlaylistDetail } from './components/PlaylistDetail'; import { Toast, ToastType } from './components/Toast'; import { SearchPage } from './components/SearchPage'; +import { ConfirmDialog } from './components/ConfirmDialog'; -export default function App() { +function AppContent() { + // i18n + const { t } = useI18n(); + // Responsive const { isMobile, isDesktop } = useResponsive(); @@ -59,13 +65,18 @@ export default function App() { const [isPlaying, setIsPlaying] = useState(false); const [currentTime, setCurrentTime] = useState(0); const [duration, setDuration] = useState(0); - const [volume, setVolume] = useState(0.8); + const [volume, setVolume] = useState(() => { + const stored = localStorage.getItem('volume'); + return stored ? parseFloat(stored) : 0.8; + }); + const [playbackRate, setPlaybackRate] = useState(1.0); const [isShuffle, setIsShuffle] = useState(false); - const [repeatMode, setRepeatMode] = useState<'none' | 'all' | 'one'>('all'); + const [repeatMode, setRepeatMode] = useState<'none' | 'all' | 'one'>('none'); // UI State const [isGenerating, setIsGenerating] = useState(false); const [showRightSidebar, setShowRightSidebar] = useState(true); + const [showLeftSidebar, setShowLeftSidebar] = useState(false); const [pendingAudioSelection, setPendingAudioSelection] = useState<{ target: 'reference' | 'source'; url: string; title?: string } | null>(null); // Mobile UI Toggle @@ -96,6 +107,7 @@ export default function App() { const [reuseData, setReuseData] = useState<{ song: Song, timestamp: number } | null>(null); const audioRef = useRef(null); + const currentSongIdRef = useRef(null); const pendingSeekRef = useRef(null); const playNextRef = useRef<() => void>(() => {}); @@ -109,6 +121,13 @@ export default function App() { isVisible: false, }); + // Confirm Dialog State + const [confirmDialog, setConfirmDialog] = useState<{ + title: string; + message: string; + onConfirm: () => void; + } | null>(null); + interface ReferenceTrack { id: string; filename: string; @@ -172,6 +191,9 @@ export default function App() { // Song Update Handler const handleSongUpdate = (updatedSong: Song) => { setSongs(prev => prev.map(s => s.id === updatedSong.id ? updatedSong : s)); + if (currentSong?.id === updatedSong.id) { + setCurrentSong(updatedSong); + } if (selectedSong?.id === updatedSong.id) { setSelectedSong(updatedSong); } @@ -239,6 +261,8 @@ export default function App() { setMobileShowList(false); } else if (path === '/library') { setCurrentView('library'); + } else if (path === '/training') { + setCurrentView('training'); } else if (path.startsWith('/@')) { const username = path.substring(2); if (username) { @@ -294,6 +318,7 @@ export default function App() { viewCount: s.view_count || 0, userId: s.user_id, creator: s.creator, + ditModel: s.ditModel, generationParams: (() => { try { if (!s.generation_params) return undefined; @@ -464,9 +489,9 @@ export default function App() { if (audio.error && audio.error.code !== 1) { console.error("Audio playback error:", audio.error); if (audio.error.code === 4) { - showToast('This song is no longer available.', 'error'); + showToast(t('songNotAvailable'), 'error'); } else { - showToast('Unable to play this song.', 'error'); + showToast(t('unableToPlay'), 'error'); } } setIsPlaying(false); @@ -502,14 +527,15 @@ export default function App() { if (err instanceof Error && err.name !== 'AbortError') { console.error("Playback failed:", err); if (err.name === 'NotSupportedError') { - showToast('This song is no longer available.', 'error'); + showToast(t('songNotAvailable'), 'error'); } setIsPlaying(false); } } }; - if (audio.src !== currentSong.audioUrl) { + if (currentSongIdRef.current !== currentSong.id) { + currentSongIdRef.current = currentSong.id; audio.src = currentSong.audioUrl; audio.load(); if (isPlaying) playAudio(); @@ -526,6 +552,13 @@ export default function App() { } }, [volume]); + // Handle Playback Rate + useEffect(() => { + if (audioRef.current) { + audioRef.current.playbackRate = playbackRate; + } + }, [playbackRate]); + // Helper to cleanup a job and check if all jobs are done const cleanupJob = useCallback((jobId: string, tempId: string) => { const jobData = activeJobsRef.current.get(jobId); @@ -566,6 +599,7 @@ export default function App() { viewCount: s.view_count || 0, userId: s.user_id, creator: s.creator, + ditModel: s.ditModel, generationParams: (() => { try { if (!s.generation_params) return undefined; @@ -701,6 +735,7 @@ export default function App() { lyrics: params.lyrics, style: params.style, title: params.title, + ditModel: params.ditModel, instrumental: params.instrumental, vocalLanguage: params.vocalLanguage, duration: params.duration && params.duration > 0 ? params.duration : undefined, @@ -762,7 +797,7 @@ export default function App() { if (activeJobsRef.current.size === 0) { setIsGenerating(false); } - showToast('Generation failed. Please try again.', 'error'); + showToast(t('generationFailed'), 'error'); } }; @@ -904,127 +939,99 @@ export default function App() { } }; - const handleDeleteSong = async (song: Song) => { - if (!token) return; - - // Show confirmation dialog - const confirmed = window.confirm( - `Are you sure you want to delete "${song.title}"? This action cannot be undone.` - ); - - if (!confirmed) return; + const handleDeleteSong = (song: Song) => { + handleDeleteSongs([song]); + }; - try { - // Call API to delete song - await songsApi.deleteSong(song.id, token); + const handleDeleteSongs = (songsToDelete: Song[]) => { + if (!token || songsToDelete.length === 0) return; - // Remove from songs list - setSongs(prev => prev.filter(s => s.id !== song.id)); + const isSingle = songsToDelete.length === 1; + const title = isSingle ? t('confirmDeleteTitle') : t('confirmDeleteManyTitle'); + const message = isSingle + ? t('deleteSongConfirm').replace('{title}', songsToDelete[0].title) + : t('deleteSongsConfirm').replace('{count}', String(songsToDelete.length)); - // Remove from liked songs if it was liked - setLikedSongIds(prev => { - const next = new Set(prev); - next.delete(song.id); - return next; - }); + setConfirmDialog({ + title, + message, + onConfirm: async () => { + setConfirmDialog(null); - // Handle if deleted song is currently selected - if (selectedSong?.id === song.id) { - setSelectedSong(null); - } + const idsToDelete = new Set(songsToDelete.map(song => song.id)); + const succeeded: string[] = []; + const failed: string[] = []; - // Handle if deleted song is currently playing - if (currentSong?.id === song.id) { - setCurrentSong(null); - setIsPlaying(false); - if (audioRef.current) { - audioRef.current.pause(); - audioRef.current.src = ''; + for (const song of songsToDelete) { + try { + await songsApi.deleteSong(song.id, token!); + succeeded.push(song.id); + } catch (error) { + console.error('Failed to delete song:', error); + failed.push(song.id); + } } - } - - // Remove from play queue if present - setPlayQueue(prev => prev.filter(s => s.id !== song.id)); - - showToast('Song deleted successfully'); - } catch (error) { - console.error('Failed to delete song:', error); - showToast('Failed to delete song', 'error'); - } - }; - - const handleDeleteSongs = async (songsToDelete: Song[]) => { - if (!token || songsToDelete.length === 0) return; - - const confirmed = window.confirm( - `Delete ${songsToDelete.length} songs? This action cannot be undone.` - ); - if (!confirmed) return; - const idsToDelete = new Set(songsToDelete.map(song => song.id)); - const succeeded: string[] = []; - const failed: string[] = []; + if (succeeded.length > 0) { + setSongs(prev => prev.filter(s => !idsToDelete.has(s.id) || failed.includes(s.id))); - for (const song of songsToDelete) { - try { - await songsApi.deleteSong(song.id, token); - succeeded.push(song.id); - } catch (error) { - console.error('Failed to delete song:', error); - failed.push(song.id); - } - } + setLikedSongIds(prev => { + const next = new Set(prev); + succeeded.forEach(id => next.delete(id)); + return next; + }); - if (succeeded.length > 0) { - setSongs(prev => prev.filter(s => !idsToDelete.has(s.id) || failed.includes(s.id))); - - setLikedSongIds(prev => { - const next = new Set(prev); - succeeded.forEach(id => next.delete(id)); - return next; - }); + if (selectedSong?.id && succeeded.includes(selectedSong.id)) { + setSelectedSong(null); + } - if (selectedSong?.id && succeeded.includes(selectedSong.id)) { - setSelectedSong(null); - } + if (currentSong?.id && succeeded.includes(currentSong.id)) { + setCurrentSong(null); + setIsPlaying(false); + if (audioRef.current) { + audioRef.current.pause(); + audioRef.current.src = ''; + } + } - if (currentSong?.id && succeeded.includes(currentSong.id)) { - setCurrentSong(null); - setIsPlaying(false); - if (audioRef.current) { - audioRef.current.pause(); - audioRef.current.src = ''; + setPlayQueue(prev => prev.filter(s => !idsToDelete.has(s.id) || failed.includes(s.id))); } - } - - setPlayQueue(prev => prev.filter(s => !idsToDelete.has(s.id) || failed.includes(s.id))); - } - if (failed.length > 0) { - showToast(`Deleted ${succeeded.length}/${songsToDelete.length} songs`, 'error'); - } else { - showToast('Songs deleted successfully'); - } + if (failed.length > 0) { + showToast(t('songsDeletedPartial').replace('{succeeded}', String(succeeded.length)).replace('{total}', String(songsToDelete.length)), 'error'); + } else if (isSingle) { + showToast(t('songDeleted')); + } else { + showToast(t('songsDeletedSuccess')); + } + }, + }); }; - const handleDeleteReferenceTrack = async (trackId: string) => { + const handleDeleteReferenceTrack = (trackId: string) => { if (!token) return; - const confirmed = window.confirm('Delete this upload? This action cannot be undone.'); - if (!confirmed) return; - try { - const response = await fetch(`/api/reference-tracks/${trackId}`, { - method: 'DELETE', - headers: { Authorization: `Bearer ${token}` } - }); - if (!response.ok) { - throw new Error('Failed to delete upload'); - } - setReferenceTracks(prev => prev.filter(track => track.id !== trackId)); - showToast('Upload deleted successfully'); - } catch (error) { - console.error('Failed to delete upload:', error); - showToast('Failed to delete upload', 'error'); - } + + setConfirmDialog({ + title: t('delete'), + message: t('deleteUploadConfirm'), + onConfirm: async () => { + setConfirmDialog(null); + try { + const response = await fetch(`/api/reference-tracks/${trackId}`, { + method: 'DELETE', + headers: { Authorization: `Bearer ${token!}` } + }); + if (!response.ok) { + throw new Error('Failed to delete upload'); + } + setReferenceTracks(prev => prev.filter(track => track.id !== trackId)); + showToast(t('songDeleted')); + } catch (error) { + console.error('Failed to delete upload:', error); + showToast(t('failedToDeleteSong'), 'error'); + } + }, + }); }; const createPlaylist = async (name: string, description: string) => { @@ -1038,10 +1045,10 @@ export default function App() { setSongToAddToPlaylist(null); playlistsApi.getMyPlaylists(token).then(r => setPlaylists(r.playlists)); } - showToast('Playlist created successfully!'); + showToast(t('playlistCreated')); } catch (error) { console.error('Create playlist error:', error); - showToast('Failed to create playlist', 'error'); + showToast(t('failedToCreatePlaylist'), 'error'); } }; @@ -1055,11 +1062,11 @@ export default function App() { try { await playlistsApi.addSong(playlistId, songToAddToPlaylist.id, token); setSongToAddToPlaylist(null); - showToast('Song added to playlist'); + showToast(t('songAddedToPlaylist')); playlistsApi.getMyPlaylists(token).then(r => setPlaylists(r.playlists)); } catch (error) { console.error('Add song error:', error); - showToast('Failed to add song to playlist', 'error'); + showToast(t('failedToAddSong'), 'error'); } }; @@ -1193,6 +1200,7 @@ export default function App() { isPlaying={isPlaying} likedSongIds={likedSongIds} onToggleLike={toggleLike} + onDelete={handleDeleteSong} /> ); @@ -1208,6 +1216,9 @@ export default function App() { /> ); + case 'training': + return ; + case 'create': default: return ( @@ -1256,6 +1267,7 @@ export default function App() { onCoverSong={handleCoverSong} onUseUploadAsReference={handleUseUploadAsReference} onCoverUpload={handleCoverUpload} + onSongUpdate={handleSongUpdate} /> @@ -1272,9 +1284,6 @@ export default function App() { onNavigateToSong={handleNavigateToSong} isLiked={selectedSong ? likedSongIds.has(selectedSong.id) : false} onToggleLike={toggleLike} - onPlay={playSong} - isPlaying={isPlaying} - currentSong={currentSong} onDelete={handleDeleteSong} /> @@ -1286,7 +1295,7 @@ export default function App() { onClick={() => setMobileShowList(!mobileShowList)} className="bg-zinc-800 text-white px-4 py-2 rounded-full shadow-lg border border-white/10 flex items-center gap-2 text-sm font-bold" > - {mobileShowList ? 'Create Song' : 'View List'} + {mobileShowList ? t('createSong') : t('viewList')} @@ -1309,7 +1318,10 @@ export default function App() { window.history.pushState({}, '', '/library'); } else if (v === 'search') { window.history.pushState({}, '', '/search'); + } else if (v === 'training') { + window.history.pushState({}, '', '/training'); } + if (isMobile) setShowLeftSidebar(false); }} theme={theme} onToggleTheme={toggleTheme} @@ -1317,6 +1329,8 @@ export default function App() { onLogin={() => setShowUsernameModal(true)} onLogout={logout} onOpenSettings={() => setShowSettingsModal(true)} + isOpen={showLeftSidebar} + onToggle={() => setShowLeftSidebar(!showLeftSidebar)} />
@@ -1335,6 +1349,9 @@ export default function App() { onPrevious={playPrevious} volume={volume} onVolumeChange={setVolume} + playbackRate={playbackRate} + onPlaybackRateChange={setPlaybackRate} + audioRef={audioRef} isShuffle={isShuffle} onToggleShuffle={() => setIsShuffle(!isShuffle)} repeatMode={repeatMode} @@ -1388,7 +1405,7 @@ export default function App() { {/* Mobile Details Modal */} {showMobileDetails && selectedSong && ( -
+
setShowMobileDetails(false)} @@ -1404,14 +1421,27 @@ export default function App() { onNavigateToSong={handleNavigateToSong} isLiked={selectedSong ? likedSongIds.has(selectedSong.id) : false} onToggleLike={toggleLike} - onPlay={playSong} - isPlaying={isPlaying} - currentSong={currentSong} onDelete={handleDeleteSong} />
)} + + confirmDialog?.onConfirm()} + onCancel={() => setConfirmDialog(null)} + />
); } + +export default function App() { + return ( + + + + ); +} diff --git a/I18N_USAGE.md b/I18N_USAGE.md new file mode 100644 index 0000000..78639ec --- /dev/null +++ b/I18N_USAGE.md @@ -0,0 +1,88 @@ +# ACE-Step UI 国际化使用指南 + +## 概述 + +本项目已实现中英文双语支持,默认语言为中文。 + +## 架构 + +``` +ace-step-ui/ +├── i18n/ +│ └── translations.ts # 翻译文件 +├── context/ +│ └── I18nContext.tsx # i18n Context Provider +└── components/ # 已支持国际化的组件 +``` + +## 如何使用 + +### 1. 在组件中使用翻译 + +```tsx +import { useI18n } from '../context/I18nContext'; + +function YourComponent() { + const { t } = useI18n(); + + return
{t('yourTranslationKey')}
; +} +``` + +### 2. 切换语言 + +在设置面板中可以切换语言,或通过代码: + +```tsx +const { language, setLanguage } = useI18n(); + +// 切换到英文 +setLanguage('en'); + +// 切换到中文 +setLanguage('zh'); +``` + +### 3. 添加新的翻译键 + +在 `i18n/translations.ts` 中同时添加英文和中文翻译: + +```typescript +export const translations = { + en: { + // 添加英文 + yourNewKey: 'Your English Text', + }, + zh: { + // 添加中文 + yourNewKey: '你的中文文本', + } +}; +``` + +## 已翻译的组件 + +- ✅ App.tsx (主应用、错误消息、Toast提示) +- ✅ Sidebar.tsx (导航栏) +- ✅ UsernameModal.tsx (用户名设置弹窗) +- ✅ SettingsModal.tsx (设置面板,包含语言切换) + +## 翻译覆盖范围 + +- 导航菜单 (创作/音乐库/搜索) +- 用户认证 (登录/登出) +- 主题切换 (浅色/深色模式) +- 错误和成功提示 +- 设置界面 +- 移动端按钮 + +## 语言持久化 + +用户选择的语言会自动保存到 `localStorage`,下次访问时会自动应用。 + +## 注意事项 + +1. 所有翻译键必须同时在 `en` 和 `zh` 中定义 +2. 使用 TypeScript 类型 `TranslationKey` 确保类型安全 +3. 默认语言为中文 (zh) +4. 如果翻译键不存在,会返回键名本身 diff --git a/components/ConfirmDialog.tsx b/components/ConfirmDialog.tsx new file mode 100644 index 0000000..9e40e45 --- /dev/null +++ b/components/ConfirmDialog.tsx @@ -0,0 +1,90 @@ +import React, { useEffect, useRef } from 'react'; +import { AlertTriangle } from 'lucide-react'; +import { useI18n } from '../context/I18nContext'; + +interface ConfirmDialogProps { + isOpen: boolean; + title: string; + message: string; + confirmLabel?: string; + cancelLabel?: string; + danger?: boolean; + onConfirm: () => void; + onCancel: () => void; +} + +export const ConfirmDialog: React.FC = ({ + isOpen, + title, + message, + confirmLabel, + cancelLabel, + danger = true, + onConfirm, + onCancel, +}) => { + const { t } = useI18n(); + const dialogRef = useRef(null); + + useEffect(() => { + if (!isOpen) return; + const handleEscape = (e: KeyboardEvent) => { + if (e.key === 'Escape') onCancel(); + }; + document.addEventListener('keydown', handleEscape); + return () => document.removeEventListener('keydown', handleEscape); + }, [isOpen, onCancel]); + + if (!isOpen) return null; + + return ( +
{ if (e.target === e.currentTarget) onCancel(); }} + > +
+
+ {danger && ( +
+ +
+ )} +
+

+ {title} +

+

+ {message} +

+
+
+ +
+ + +
+
+
+ ); +}; diff --git a/components/CreatePanel.tsx b/components/CreatePanel.tsx index a041000..cc52885 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')}