diff --git a/.gitignore b/.gitignore index 3f63869..3c6b3ba 100644 --- a/.gitignore +++ b/.gitignore @@ -10,6 +10,7 @@ venv_test/ venv/ env/ ENV/ +checkpoints/ # ---- CDMF runtime-heavy / user data (DO NOT COMMIT) ---- models/* diff --git a/ui/App.tsx b/ui/App.tsx index e3cd9a3..b8e83c5 100644 --- a/ui/App.tsx +++ b/ui/App.tsx @@ -23,9 +23,11 @@ import { TrainingPanel } from './components/TrainingPanel'; import { StemSplittingPanel } from './components/StemSplittingPanel'; import { VoiceCloningPanel } from './components/VoiceCloningPanel'; import { MidiPanel } from './components/MidiPanel'; +import { useTranslation } from 'react-i18next'; export default function App() { + const { t } = useTranslation(); // Responsive const { isMobile, isDesktop } = useResponsive(); @@ -436,9 +438,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('app.song_unavailable'), 'error'); } else { - showToast('Unable to play this song.', 'error'); + showToast(t('app.unable_to_play'), 'error'); } } setIsPlaying(false); @@ -476,7 +478,7 @@ 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('app.song_unavailable'), 'error'); } setIsPlaying(false); } @@ -725,7 +727,7 @@ export default function App() { if (activeJobsRef.current.has(job.jobId)) { console.warn(`Job ${job.jobId} timed out`); cleanupJob(job.jobId, tempId); - showToast('Generation timed out', 'error'); + showToast(t('app.generation_timed_out'), 'error'); } }, 600000); @@ -736,7 +738,7 @@ export default function App() { if (activeJobsRef.current.size === 0) { setIsGenerating(false); } - const msg = e instanceof Error ? e.message : 'Generation failed. Please try again.'; + const msg = e instanceof Error ? e.message : t('app.generation_failed', { error: 'Unknown' }); showToast(msg, 'error'); } }; @@ -839,7 +841,7 @@ export default function App() { const handleDeleteSong = async (song: Song) => { // Show confirmation dialog const confirmed = window.confirm( - `Are you sure you want to delete "${song.title}"? This action cannot be undone.` + t('app.confirm_delete_song', { title: song.title }) ); if (!confirmed) return; @@ -876,10 +878,10 @@ export default function App() { // Remove from play queue if present setPlayQueue(prev => prev.filter(s => s.id !== song.id)); - showToast('Song deleted successfully'); + showToast(t('app.song_deleted_success')); } catch (error) { console.error('Failed to delete song:', error); - showToast('Failed to delete song', 'error'); + showToast(t('app.delete_song_failed'), 'error'); } }; @@ -893,10 +895,10 @@ export default function App() { setSongToAddToPlaylist(null); playlistsApi.getMyPlaylists(token ?? undefined).then(r => setPlaylists(r.playlists)); } - showToast('Playlist created successfully!'); + showToast(t('app.playlist_created_success')); } catch (error) { console.error('Create playlist error:', error); - showToast('Failed to create playlist', 'error'); + showToast(t('app.playlist_create_failed'), 'error'); } }; @@ -910,11 +912,11 @@ export default function App() { try { await playlistsApi.addSong(playlistId, songToAddToPlaylist.id, token ?? ''); setSongToAddToPlaylist(null); - showToast('Song added to playlist'); + showToast(t('app.song_added_to_playlist')); playlistsApi.getMyPlaylists(token ?? undefined).then(r => setPlaylists(r.playlists)); } catch (error) { console.error('Add song error:', error); - showToast('Failed to add song to playlist', 'error'); + showToast(t('app.add_song_failed'), 'error'); } }; @@ -1083,7 +1085,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 ? 'Tools' : 'View List'} + {mobileShowList ? t('common.tools') : t('common.view_list')} @@ -1160,7 +1162,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('app.create_song') : t('common.view_list')} diff --git a/ui/components/CreatePanel.tsx b/ui/components/CreatePanel.tsx index 1f324cd..a4ecf9c 100644 --- a/ui/components/CreatePanel.tsx +++ b/ui/components/CreatePanel.tsx @@ -3,6 +3,7 @@ import { Sparkles, ChevronDown, Settings2, Trash2, Music2, Sliders, Dices, Hash, import { GenerationParams, Song } from '../types'; import { useAuth } from '../context/AuthContext'; import { generateApi, preferencesApi, aceStepModelsApi, type LoraAdapter } from '../services/api'; +import { useTranslation } from 'react-i18next'; /** Tasks that require ACE-Step Base model only (see docs/ACE-Step-Tutorial.md). */ const TASKS_REQUIRING_BASE = ['lego', 'extract', 'complete'] as const; @@ -156,6 +157,7 @@ const VOCAL_LANGUAGES = [ type CreateMode = 'simple' | 'custom' | 'cover' | 'lego'; export const CreatePanel: React.FC = ({ onGenerate, isGenerating, initialData, onOpenSettings }) => { + const { t } = useTranslation(); const { isAuthenticated, token } = useAuth(); // Mode: simple | custom | cover | lego @@ -1018,19 +1020,19 @@ export const CreatePanel: React.FC = ({ onGenerate, isGenerati onClick={() => { setCreateMode('simple'); setLegoValidationError(''); setCoverValidationError(''); }} className={`px-3 py-1.5 rounded-md text-xs font-semibold transition-all ${createMode === 'simple' ? 'bg-white dark:bg-zinc-800 text-black dark:text-white shadow-sm' : 'text-zinc-500 hover:text-zinc-900 dark:hover:text-zinc-300'}`} > - Simple + {t('create.modes.simple')} { setCreateMode('custom'); setLegoValidationError(''); setCoverValidationError(''); }} className={`px-3 py-1.5 rounded-md text-xs font-semibold transition-all ${createMode === 'custom' ? 'bg-white dark:bg-zinc-800 text-black dark:text-white shadow-sm' : 'text-zinc-500 hover:text-zinc-900 dark:hover:text-zinc-300'}`} > - Custom + {t('create.modes.custom')} { setCreateMode('cover'); setLegoValidationError(''); setCoverValidationError(''); }} className={`px-3 py-1.5 rounded-md text-xs font-semibold transition-all ${createMode === 'cover' ? 'bg-white dark:bg-zinc-800 text-black dark:text-white shadow-sm' : 'text-zinc-500 hover:text-zinc-900 dark:hover:text-zinc-300'}`} > - Cover + {t('create.modes.cover')} { @@ -1041,7 +1043,7 @@ export const CreatePanel: React.FC = ({ onGenerate, isGenerati }} className={`px-3 py-1.5 rounded-md text-xs font-semibold transition-all ${createMode === 'lego' ? 'bg-white dark:bg-zinc-800 text-black dark:text-white shadow-sm' : 'text-zinc-500 hover:text-zinc-900 dark:hover:text-zinc-300'}`} > - Lego + {t('create.modes.lego')} @@ -1052,13 +1054,13 @@ export const CreatePanel: React.FC = ({ onGenerate, isGenerati {/* Title (same as Custom mode) */} - Title + {t('create.inputs.title')} setTitle(e.target.value)} - placeholder="Name your song" + placeholder={t('create.inputs.title_placeholder')} 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" /> @@ -1066,12 +1068,12 @@ export const CreatePanel: React.FC = ({ onGenerate, isGenerati {/* Genre preset + Song Description */} - Describe Your Song - + {t('create.inputs.describe_song')} + - Genre preset: + {t('create.inputs.genre_preset')}: GENRE_PRESETS[k] === style) || 'Custom'} onChange={(e) => { @@ -1086,7 +1088,7 @@ export const CreatePanel: React.FC = ({ onGenerate, isGenerati }} className="bg-zinc-100 dark:bg-black/30 text-zinc-900 dark:text-white text-xs rounded-lg px-2.5 py-1.5 border-0 focus:ring-2 focus:ring-pink-500/50 focus:outline-none" > - Custom (type below) + {t('create.inputs.custom_genre')} {Object.keys(GENRE_PRESETS).map((name) => ( {name} ))} @@ -1095,7 +1097,7 @@ export const CreatePanel: React.FC = ({ onGenerate, isGenerati setStyle(e.target.value)} - placeholder="e.g. A happy pop song about summer... or use a genre preset above" + placeholder={t('create.inputs.describe_placeholder')} className="w-full h-28 bg-transparent text-sm text-zinc-900 dark:text-white placeholder-zinc-400 dark:placeholder-zinc-600 focus:outline-none resize-none border-0 p-0" /> @@ -1104,7 +1106,7 @@ export const CreatePanel: React.FC = ({ onGenerate, isGenerati {/* Vocal Language (Simple) */} - Vocal Language + {t('create.inputs.vocal_language')} = ({ onGenerate, isGenerati {/* Quality preset (Simple + Advanced) — Basic / Great / Best from ACE-Step docs */} - Quality - + {t('create.quality.label')} + {(['basic', 'great', 'best'] as const).map((p) => ( @@ -1135,7 +1137,7 @@ export const CreatePanel: React.FC = ({ onGenerate, isGenerati : 'bg-zinc-100 dark:bg-black/30 text-zinc-600 dark:text-zinc-400 hover:bg-zinc-200 dark:hover:bg-white/10' }`} > - {p === 'basic' ? 'Basic' : p === 'great' ? 'Great' : 'Best'} + {p === 'basic' ? t('create.quality.basic') : p === 'great' ? t('create.quality.great') : t('create.quality.best')} ))} @@ -1145,14 +1147,14 @@ export const CreatePanel: React.FC = ({ onGenerate, isGenerati - Exclude styles - + {t('create.inputs.exclude_styles')} + setNegativePrompt(e.target.value)} - placeholder="e.g. heavy distortion, screaming" + placeholder={t('create.inputs.exclude_placeholder')} className="w-full bg-transparent px-3 pb-3 pt-0 text-sm text-zinc-900 dark:text-white placeholder-zinc-400 dark:placeholder-zinc-600 focus:outline-none border-0" /> @@ -1161,15 +1163,15 @@ export const CreatePanel: React.FC = ({ onGenerate, isGenerati - Generation influence + {t('create.generation_influence')} Fine-tune how the model follows your description and reference (if any). - Weirdness - + {t('create.sliders.weirdness')} + {weirdness}% @@ -1187,8 +1189,8 @@ export const CreatePanel: React.FC = ({ onGenerate, isGenerati - Style influence - + {t('create.sliders.style_influence')} + {styleInfluence}% @@ -1207,8 +1209,8 @@ export const CreatePanel: React.FC = ({ onGenerate, isGenerati - Audio influence - + {t('create.sliders.audio_influence')} + {audioInfluence}% @@ -1227,7 +1229,7 @@ export const CreatePanel: React.FC = ({ onGenerate, isGenerati {/* Reference & cover (optional) — same as Custom but compact; no hidden features */} - Reference & cover (optional) + {t('create.reference_cover')} Use a style reference or a song to cover. Leave empty to generate from your description only. @@ -1235,7 +1237,7 @@ export const CreatePanel: React.FC = ({ onGenerate, isGenerati Reference style openAudioModal('reference')} className="flex items-center justify-center gap-1 rounded-lg px-2.5 py-1.5 text-[11px] font-medium bg-zinc-100 dark:bg-white/5 text-zinc-600 dark:text-zinc-300 hover:bg-zinc-200 dark:hover:bg-white/10 transition-colors"> - Choose from library + {t('create.inputs.choose_library')} @@ -1246,7 +1248,7 @@ export const CreatePanel: React.FC = ({ onGenerate, isGenerati Song to cover openAudioModal('source')} className="flex items-center justify-center gap-1 rounded-lg px-2.5 py-1.5 text-[11px] font-medium bg-zinc-100 dark:bg-white/5 text-zinc-600 dark:text-zinc-300 hover:bg-zinc-200 dark:hover:bg-white/10 transition-colors"> - Choose from library + {t('create.inputs.choose_library')} @@ -1259,13 +1261,13 @@ export const CreatePanel: React.FC = ({ onGenerate, isGenerati - Quick Settings + {t('create.quick_settings')} {/* Duration */} - Duration + {t('create.sliders.duration')} {duration === -1 ? 'Auto' : `${duration}s`} @@ -1284,7 +1286,7 @@ export const CreatePanel: React.FC = ({ onGenerate, isGenerati {/* BPM */} - BPM + {t('create.sliders.bpm')} {bpm === 0 ? 'Auto' : bpm} @@ -1333,7 +1335,7 @@ export const CreatePanel: React.FC = ({ onGenerate, isGenerati {/* Variations */} - Variations + {t('create.sliders.variations')} {batchSize} = ({ onGenerate, isGenerati - Generate a new version of your source audio in a different style. One source + one style description (e.g. "jazz piano cover with swing rhythm"). No semantic blending — pure cover task. + {t('create.audio_modal.cover_desc')} {/* Title (optional) */} - Title (optional) + {t('create.inputs.title_optional')} setTitle(e.target.value)} - placeholder="Name the output" + placeholder={t('create.inputs.title_placeholder_output')} 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" /> @@ -1377,17 +1379,17 @@ export const CreatePanel: React.FC = ({ onGenerate, isGenerati {/* Source audio (required) */} - Source audio + {t('create.inputs.source_audio')} * - + openAudioModal('source')} className="flex-1 flex items-center justify-center gap-1.5 rounded-lg px-3 py-2 text-xs font-medium bg-zinc-100 dark:bg-white/5 text-zinc-600 dark:text-zinc-300 hover:bg-zinc-200 dark:hover:bg-white/10 transition-colors"> - Choose from library + {t('create.inputs.choose_library')} - Pick a track from your library or upload in the picker (uploads go to the library). + {t('create.inputs.source_audio_desc')} {sourceAudioUrl ? ( {getAudioLabel(sourceAudioUrl)} ) : ( @@ -1399,14 +1401,14 @@ export const CreatePanel: React.FC = ({ onGenerate, isGenerati {/* Cover style (caption) */} - Cover style + {t('create.inputs.cover_style')} * - + setCoverCaption(e.target.value)} - placeholder="e.g. jazz piano cover with swing rhythm" + placeholder={t('create.inputs.cover_style_placeholder')} className="w-full h-24 bg-transparent p-3 text-sm text-zinc-900 dark:text-white placeholder-zinc-400 dark:placeholder-zinc-600 focus:outline-none resize-none" /> @@ -1414,8 +1416,8 @@ export const CreatePanel: React.FC = ({ onGenerate, isGenerati {/* Cover strength */} - Source influence - + {t('create.inputs.source_influence')} + @@ -1442,8 +1444,8 @@ export const CreatePanel: React.FC = ({ onGenerate, isGenerati {/* Instrumental / Lyrics override for cover */} - Vocals - + {t('create.inputs.vocal_mode')} + @@ -1454,13 +1456,13 @@ export const CreatePanel: React.FC = ({ onGenerate, isGenerati > - {instrumental ? 'Instrumental' : 'Vocal (custom lyrics)'} + {instrumental ? t('create.inputs.instrumental') : t('create.inputs.vocal_custom')} {!instrumental && ( setLyrics(e.target.value)} - placeholder="[Verse]\nYour lyrics for the cover...\n\n[Chorus]\n..." + placeholder={t('create.inputs.lyrics_placeholder_cover')} className="w-full h-28 bg-transparent p-3 text-sm text-zinc-900 dark:text-white placeholder-zinc-400 dark:placeholder-zinc-600 focus:outline-none resize-none font-mono leading-relaxed border border-zinc-200 dark:border-white/10 rounded-lg" /> )} @@ -1470,24 +1472,24 @@ export const CreatePanel: React.FC = ({ onGenerate, isGenerati {/* Optional: Blend with second audio */} - Optional: Blend with a second audio - + {t('create.inputs.blend_audio')} + - Combine the source above with another track: structure and length follow the source; style can follow the second audio. + {t('create.inputs.blend_desc')} openAudioModal('cover_style')} className="flex-1 flex items-center justify-center gap-1.5 rounded-lg px-3 py-2 text-xs font-medium bg-zinc-100 dark:bg-white/5 text-zinc-600 dark:text-zinc-300 hover:bg-zinc-200 dark:hover:bg-white/10 transition-colors"> - Choose from library + {t('create.inputs.choose_library')} - Pick from library or upload in the picker (uploads go to the library). + {t('create.inputs.source_audio_desc')} {coverStyleAudioUrl ? ( {getAudioLabel(coverStyleAudioUrl)} setCoverStyleAudioUrl('')} className="shrink-0 px-2 py-1 text-[11px] font-medium rounded bg-zinc-200 dark:bg-white/10 text-zinc-700 dark:text-zinc-300 hover:bg-zinc-300 dark:hover:bg-white/20"> - Clear + {t('create.inputs.clear')} ) : ( @@ -1522,8 +1524,8 @@ export const CreatePanel: React.FC = ({ onGenerate, isGenerati {/* Quality preset */} - Quality - + {t('create.quality.label')} + {(['basic', 'great', 'best'] as const).map((p) => ( @@ -1535,7 +1537,7 @@ export const CreatePanel: React.FC = ({ onGenerate, isGenerati qualityPreset === p ? 'bg-pink-500 text-white' : 'bg-zinc-100 dark:bg-white/5 text-zinc-600 dark:text-zinc-400 hover:bg-zinc-200 dark:hover:bg-white/10' }`} > - {p === 'basic' ? 'Basic' : p === 'great' ? 'Great' : 'Best'} + {p === 'basic' ? t('create.quality.basic') : p === 'great' ? t('create.quality.great') : t('create.quality.best')} ))} @@ -1559,13 +1561,13 @@ export const CreatePanel: React.FC = ({ onGenerate, isGenerati {/* Title (optional) */} - Title (optional) + {t('create.inputs.title_optional')} setTitle(e.target.value)} - placeholder="Name the output" + placeholder={t('create.inputs.title_placeholder_output')} 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" /> @@ -1573,17 +1575,17 @@ export const CreatePanel: React.FC = ({ onGenerate, isGenerati {/* Source audio (required for Lego) */} - Backing audio + {t('create.inputs.backing_audio')} * openAudioModal('source')} className="flex-1 flex items-center justify-center gap-1.5 rounded-lg px-3 py-2 text-xs font-medium bg-zinc-100 dark:bg-white/5 text-zinc-600 dark:text-zinc-300 hover:bg-zinc-200 dark:hover:bg-white/10 transition-colors"> - Choose from library + {t('create.inputs.choose_library')} - Pick a track from your library or upload in the picker (uploads go to the library). + {t('create.inputs.backing_audio_desc')} {sourceAudioUrl ? ( {getAudioLabel(sourceAudioUrl)} ) : ( @@ -1595,7 +1597,7 @@ export const CreatePanel: React.FC = ({ onGenerate, isGenerati {/* Track to generate */} - Track to generate + {t('create.inputs.track_to_generate')} = ({ onGenerate, isGenerati {/* Describe the track (caption) */} - Describe the track + {t('create.inputs.describe_track')} setLegoCaption(e.target.value)} - placeholder="e.g. lead guitar melody with bluesy feel, punchy drums, warm bass line..." + placeholder={t('create.inputs.describe_track_placeholder')} className="w-full h-24 bg-transparent p-3 text-sm text-zinc-900 dark:text-white placeholder-zinc-400 dark:placeholder-zinc-600 focus:outline-none resize-none" /> @@ -1624,8 +1626,8 @@ export const CreatePanel: React.FC = ({ onGenerate, isGenerati {/* Backing influence (critical for Lego: low = new instrument, high = copy) */} - Backing influence - + {t('create.inputs.backing_influence')} + @@ -1652,8 +1654,8 @@ export const CreatePanel: React.FC = ({ onGenerate, isGenerati {/* Quality preset (Lego) */} - Quality - + {t('create.quality.label')} + {(['basic', 'great', 'best'] as const).map((p) => ( @@ -1665,7 +1667,7 @@ export const CreatePanel: React.FC = ({ onGenerate, isGenerati qualityPreset === p ? 'bg-pink-500 text-white' : 'bg-zinc-100 dark:bg-white/5 text-zinc-600 dark:text-zinc-400 hover:bg-zinc-200 dark:hover:bg-white/10' }`} > - {p === 'basic' ? 'Basic' : p === 'great' ? 'Great' : 'Best'} + {p === 'basic' ? t('create.quality.basic') : p === 'great' ? t('create.quality.great') : t('create.quality.best')} ))} @@ -1680,7 +1682,7 @@ export const CreatePanel: React.FC = ({ onGenerate, isGenerati - Backing influence + {t('create.inputs.backing_influence')} {legoBackingInfluence.toFixed(2)} = ({ onGenerate, isGenerati - Guidance scale + {t('create.sliders.guidance_scale')} {guidanceScale.toFixed(1)} = ({ onGenerate, isGenerati - Inference steps + {t('create.sliders.inference_steps')} {inferenceSteps} = ({ onGenerate, isGenerati {/* Title */} - Title + {t('create.inputs.title')} setTitle(e.target.value)} - placeholder="Name your song" + placeholder={t('create.inputs.title_placeholder')} 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" /> @@ -1754,7 +1756,7 @@ export const CreatePanel: React.FC = ({ onGenerate, isGenerati - Style of Music + {t('create.inputs.style_of_music')} Genre, mood, instruments, vibe @@ -1770,7 +1772,7 @@ export const CreatePanel: React.FC = ({ onGenerate, isGenerati - Genre preset: + {t('create.inputs.genre_preset')}: GENRE_PRESETS[k] === style) || 'Custom'} onChange={(e) => { @@ -1785,7 +1787,7 @@ export const CreatePanel: React.FC = ({ onGenerate, isGenerati }} className="bg-zinc-100 dark:bg-black/30 text-zinc-900 dark:text-white text-xs rounded-lg px-2.5 py-1.5 border-0 focus:ring-2 focus:ring-pink-500/50 focus:outline-none" > - Custom (type below) + {t('create.inputs.custom_genre')} {Object.keys(GENRE_PRESETS).map((name) => ( {name} ))} @@ -1794,7 +1796,7 @@ export const CreatePanel: React.FC = ({ onGenerate, isGenerati setStyle(e.target.value)} - placeholder="e.g. upbeat pop rock, emotional ballad, 90s hip hop — or use a genre preset above" + placeholder={t('create.inputs.style_placeholder')} className="w-full h-20 bg-transparent text-sm text-zinc-900 dark:text-white placeholder-zinc-400 dark:placeholder-zinc-600 focus:outline-none resize-none border-0 p-0" /> @@ -1820,10 +1822,10 @@ export const CreatePanel: React.FC = ({ onGenerate, isGenerati - Lyrics + {t('create.inputs.lyrics')} - Leave empty for instrumental or switch to Instrumental below + Leave empty for instrumental or switch to Instrumental below = ({ onGenerate, isGenerati : 'bg-white dark:bg-suno-card border-zinc-200 dark:border-white/10 text-zinc-600 dark:text-zinc-200 hover:bg-zinc-100 dark:hover:bg-white/10' }`} > - {instrumental ? 'Instrumental' : 'Vocal'} + {instrumental ? t('create.inputs.instrumental') : t('create.inputs.vocal_mode')} = ({ onGenerate, isGenerati disabled={instrumental} value={lyrics} onChange={(e) => setLyrics(e.target.value)} - placeholder={instrumental ? "Instrumental mode - no lyrics needed" : "[Verse]\nYour lyrics here...\n\n[Chorus]\nThe catchy part..."} + placeholder={instrumental ? "Instrumental mode - no lyrics needed" : t('create.inputs.lyrics_placeholder')} 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 resize-none font-mono leading-relaxed ${instrumental ? 'opacity-30 cursor-not-allowed' : ''}`} style={{ height: `${lyricsHeight}px` }} /> @@ -1871,7 +1873,7 @@ export const CreatePanel: React.FC = ({ onGenerate, isGenerati {/* Vocal Language (Custom) */} - Vocal Language + {t('create.inputs.vocal_language')} = ({ onGenerate, isGenerati - Audio + {t('create.inputs.audio')} {referenceAudioUrl && ( Reference: {getAudioLabel(referenceAudioUrl)} @@ -2013,11 +2015,11 @@ export const CreatePanel: React.FC = ({ onGenerate, isGenerati openAudioModal('reference')} className="flex items-center justify-center gap-1.5 rounded-lg px-3 py-2 text-[11px] font-medium bg-zinc-100 dark:bg-white/5 text-zinc-600 dark:text-zinc-300 hover:bg-zinc-200 dark:hover:bg-white/10 transition-colors"> - Choose from Library + {t('create.inputs.choose_library')} openAudioModal('reference')} className="flex items-center justify-center gap-1.5 rounded-lg px-3 py-2 text-[11px] font-medium bg-zinc-100 dark:bg-white/5 text-zinc-600 dark:text-zinc-300 hover:bg-zinc-200 dark:hover:bg-white/10 transition-colors"> - Upload + {t('create.inputs.upload')} @@ -2872,14 +2874,14 @@ export const CreatePanel: React.FC = ({ onGenerate, isGenerati - {audioModalTarget === 'reference' ? 'Reference' : audioModalTarget === 'cover_style' ? 'Style audio (blend)' : 'Cover'} + {audioModalTarget === 'reference' ? t('create.audio_modal.reference_title') : audioModalTarget === 'cover_style' ? t('create.audio_modal.style_title') : t('create.audio_modal.cover_title')} {audioModalTarget === 'reference' - ? 'Create songs inspired by a reference track' + ? t('create.audio_modal.reference_desc') : audioModalTarget === 'cover_style' - ? 'Second audio to blend with the source — style/timbre from this track' - : 'Transform an existing track into a new version'} + ? t('create.audio_modal.style_desc') + : t('create.audio_modal.cover_desc')} = ({ onGenerate, isGenerati {isUploadingReference ? ( <> - Uploading... + {t('common.loading')} > ) : ( <> - Upload audio + {t('create.audio_modal.upload_btn')} MP3, WAV, FLAC > )} @@ -2932,7 +2934,7 @@ export const CreatePanel: React.FC = ({ onGenerate, isGenerati - Library & uploads + {t('create.audio_modal.library_uploads')} (local) = ({ onGenerate, isGenerati {isLoadingTracks ? ( - Loading library... + {t('common.loading')} ) : (() => { const filtered = libraryTagFilter === 'all' @@ -2988,7 +2990,7 @@ export const CreatePanel: React.FC = ({ onGenerate, isGenerati - {referenceTracks.length === 0 ? 'No tracks yet' : `No tracks with tag “${libraryTagFilter}”`} + {referenceTracks.length === 0 ? t('create.audio_modal.no_tracks') : `No tracks with tag “${libraryTagFilter}”`} {referenceTracks.length === 0 ? 'Upload audio or generate tracks to see them here' : 'Try “All” or another tag'} @@ -3064,7 +3066,7 @@ export const CreatePanel: React.FC = ({ onGenerate, isGenerati onClick={() => useReferenceTrack(track)} className="px-3 py-1.5 rounded-lg bg-zinc-900 dark:bg-white text-white dark:text-zinc-900 text-xs font-semibold hover:bg-zinc-800 dark:hover:bg-zinc-100 transition-colors" > - Use + {t('create.audio_modal.use')} {track.source === 'uploaded' && ( = ({ onGenerate, isGenerati {createMode === 'lego' - ? 'Generate Lego track' + ? t('create.generate_button.lego') : createMode === 'cover' - ? 'Generate cover' + ? t('create.generate_button.cover') : bulkCount > 1 - ? `Create ${bulkCount} Jobs (${bulkCount * batchSize} tracks)` - : `Create${batchSize > 1 ? ` (${batchSize} variations)` : ''}`} + ? t('create.generate_button.bulk', { count: bulkCount, total: bulkCount * batchSize }) + : t('create.generate_button.create') + (batchSize > 1 ? ` ${t('create.generate_button.variations', { count: batchSize })}` : '')} diff --git a/ui/components/LibraryView.tsx b/ui/components/LibraryView.tsx index 729ed2a..e631ba3 100644 --- a/ui/components/LibraryView.tsx +++ b/ui/components/LibraryView.tsx @@ -2,6 +2,7 @@ import React, { useState } from 'react'; import { Song, Playlist } from '../types'; import { Heart, Plus, Music, Play, RefreshCw } from 'lucide-react'; import { AlbumCover } from './AlbumCover'; +import { useTranslation } from 'react-i18next'; interface LibraryViewProps { likedSongs: Song[]; @@ -22,12 +23,13 @@ export const LibraryView: React.FC = ({ onRefreshLibrary, isRefreshingLibrary = false, }) => { + const { t } = useTranslation(); const [activeTab, setActiveTab] = useState<'playlists' | 'liked'>('liked'); return ( - Your Library + {t('library.title')} {onRefreshLibrary && ( = ({ className="flex items-center gap-2 bg-zinc-900 dark:bg-zinc-800 hover:bg-zinc-800 dark:hover:bg-zinc-700 text-white px-4 py-2 rounded-full font-medium transition-colors shadow-lg shadow-zinc-900/10 dark:shadow-none" > - New Playlist + {t('library.new_playlist')} @@ -57,14 +59,14 @@ export const LibraryView: React.FC = ({ onClick={() => setActiveTab('liked')} className={`pb-3 text-sm font-bold transition-colors relative ${activeTab === 'liked' ? 'text-zinc-900 dark:text-white' : 'text-zinc-500 hover:text-zinc-900 dark:hover:text-white'}`} > - Liked Songs + {t('library.tab_liked')} {activeTab === 'liked' && } setActiveTab('playlists')} className={`pb-3 text-sm font-bold transition-colors relative ${activeTab === 'playlists' ? 'text-zinc-900 dark:text-white' : 'text-zinc-500 hover:text-zinc-900 dark:hover:text-white'}`} > - Playlists + {t('library.tab_playlists')} {activeTab === 'playlists' && } @@ -77,10 +79,10 @@ export const LibraryView: React.FC = ({ - Playlist - Liked Songs + {t('library.playlist_subtitle')} + {t('library.tab_liked')} - {likedSongs.length} songs + {t('library.song_count', { count: likedSongs.length })} @@ -125,7 +127,7 @@ export const LibraryView: React.FC = ({ )} {playlist.name} - {playlist.description || `By You`} + {playlist.description || t('library.by_you')} ))} diff --git a/ui/components/MidiPanel.tsx b/ui/components/MidiPanel.tsx index 7cfa627..32772a0 100644 --- a/ui/components/MidiPanel.tsx +++ b/ui/components/MidiPanel.tsx @@ -1,5 +1,6 @@ import React, { useState, useEffect, useRef } from 'react'; import { Music2, Download, Loader2 } from 'lucide-react'; +import { useTranslation } from 'react-i18next'; import { toolsApi, preferencesApi } from '../services/api'; const POLL_INTERVAL_MS = 500; @@ -10,6 +11,7 @@ interface MidiPanelProps { } export const MidiPanel: React.FC = ({ onTracksUpdated }) => { + const { t } = useTranslation(); const [inputFile, setInputFile] = useState(null); const [outputFilename, setOutputFilename] = useState(''); const [onsetThreshold, setOnsetThreshold] = useState(0.5); @@ -51,7 +53,7 @@ export const MidiPanel: React.FC = ({ onTracksUpdated }) => { if (m?.melodia_trick != null) setMelodiaTrick(Boolean(m.melodia_trick)); if (m?.multiple_pitch_bends != null) setMultiplePitchBends(Boolean(m.multiple_pitch_bends)); }) - .catch(() => {}); + .catch(() => { }); }, []); useEffect(() => { @@ -70,14 +72,14 @@ export const MidiPanel: React.FC = ({ onTracksUpdated }) => { setModelState(r.state || ''); setModelMessage(r.message || ''); }) - .catch(() => {}); + .catch(() => { }); toolsApi.getProgress() .then((p) => { if (p.stage === 'midi_model_download') { setModelDownloadProgress(p.fraction); } }) - .catch(() => {}); + .catch(() => { }); }; poll(); modelPollRef.current = setInterval(poll, MODEL_POLL_MS); @@ -92,7 +94,7 @@ export const MidiPanel: React.FC = ({ onTracksUpdated }) => { toolsApi.getProgress().then((p) => { setProgress(p.fraction); if (p.done || p.error) setLoading(false); - }).catch(() => {}); + }).catch(() => { }); }, POLL_INTERVAL_MS); return () => clearInterval(t); }, [loading]); @@ -101,7 +103,7 @@ export const MidiPanel: React.FC = ({ onTracksUpdated }) => { setError(null); toolsApi.midiModelEnsure().then(() => { setModelState('downloading'); - setModelMessage('Downloading basic-pitch model (first use only). This may take several minutes.'); + setModelMessage(t('midi.downloading_model')); }).catch((e) => setError(e.message)); }; @@ -111,15 +113,15 @@ export const MidiPanel: React.FC = ({ onTracksUpdated }) => { setSuccess(null); const out = outputFilename.trim(); if (!out) { - setError('Output filename is required.'); + setError(t('midi.error_filename_required')); return; } if (!inputFile) { - setError('Please select an input audio file.'); + setError(t('midi.error_select_file')); return; } if (modelReady !== true || modelState === 'downloading') { - setError(modelState === 'downloading' ? 'Please wait for basic-pitch model download to finish.' : 'basic-pitch model is not ready. Click Download basic-pitch models first.'); + setError(modelState === 'downloading' ? t('midi.error_wait_download') : t('midi.error_model_not_ready')); return; } const formData = new FormData(); @@ -140,7 +142,7 @@ export const MidiPanel: React.FC = ({ onTracksUpdated }) => { if (prefs.output_dir) formData.set('out_dir', prefs.output_dir); const res = await toolsApi.midiGenerate(formData); if (res?.error) { - setError(res.message || 'MIDI generation failed.'); + setError(res.message || t('midi.error_failed')); setLoading(false); return; } @@ -154,11 +156,11 @@ export const MidiPanel: React.FC = ({ onTracksUpdated }) => { multiple_pitch_bends: multiplePitchBends, }, }); - setSuccess(res?.message || 'MIDI saved to output directory.'); + setSuccess(res?.message || t('midi.success_message')); setLoading(false); onTracksUpdated?.(); } catch (err) { - setError(err instanceof Error ? err.message : 'MIDI generation failed.'); + setError(err instanceof Error ? err.message : t('midi.error_failed')); setLoading(false); } }; @@ -167,29 +169,29 @@ export const MidiPanel: React.FC = ({ onTracksUpdated }) => { - Audio to MIDI + {t('midi.title')} - Convert audio to MIDI using basic-pitch. Upload an audio file and adjust detection parameters. + {t('midi.description')} {modelReady === false && modelState !== 'downloading' && ( - basic-pitch model is not downloaded yet. - {modelMessage || 'Click "Download basic-pitch models" to download it (first use only).'} + {t('midi.model_not_downloaded')} + {t('midi.model_download_hint')} - Download basic-pitch models + {t('midi.download_model')} )} {modelState === 'downloading' && ( - Downloading basic-pitch model… + {t('midi.downloading_model')} = ({ onTracksUpdated }) => { - Input Audio File + {t('midi.input_audio')} = ({ onTracksUpdated }) => { - Output filename (without extension) + {t('midi.output_filename')} setOutputFilename(e.target.value)} - placeholder="output_midi" + placeholder={t('midi.output_placeholder')} className="w-full rounded-lg border border-zinc-300 dark:border-zinc-600 bg-white dark:bg-zinc-800 px-3 py-2 text-sm" required /> - Onset Threshold: {onsetThreshold} + {t('midi.onset_threshold')}: {onsetThreshold} = ({ onTracksUpdated }) => { - Frame Threshold: {frameThreshold} + {t('midi.frame_threshold')}: {frameThreshold} = ({ onTracksUpdated }) => { - Minimum Note Length (ms): {minimumNoteLengthMs} + {t('midi.min_note_length')}: {minimumNoteLengthMs} = ({ onTracksUpdated }) => { - Minimum Frequency (Hz, optional) + {t('midi.min_frequency')} setMinimumFrequency(e.target.value)} - placeholder="None" + placeholder={t('midi.frequency_placeholder')} className="w-full rounded-lg border border-zinc-300 dark:border-zinc-600 bg-white dark:bg-zinc-800 px-3 py-2 text-sm" /> - Maximum Frequency (Hz, optional) + {t('midi.max_frequency')} setMaximumFrequency(e.target.value)} - placeholder="None" + placeholder={t('midi.frequency_placeholder')} className="w-full rounded-lg border border-zinc-300 dark:border-zinc-600 bg-white dark:bg-zinc-800 px-3 py-2 text-sm" /> - MIDI Tempo (BPM): {midiTempo} + {t('midi.midi_tempo')}: {midiTempo} = ({ onTracksUpdated }) => { checked={multiplePitchBends} onChange={(e) => setMultiplePitchBends(e.target.checked)} /> - Allow Multiple Pitch Bends + {t('midi.multiple_pitch_bends')} @@ -320,7 +322,7 @@ export const MidiPanel: React.FC = ({ onTracksUpdated }) => { checked={melodiaTrick} onChange={(e) => setMelodiaTrick(e.target.checked)} /> - Use Melodia Post-Processing + {t('midi.melodia_trick')} {loading && ( @@ -338,11 +340,11 @@ export const MidiPanel: React.FC = ({ onTracksUpdated }) => { className="rounded-lg bg-pink-500 text-white px-4 py-2 text-sm font-medium hover:bg-pink-600 disabled:opacity-50 disabled:cursor-not-allowed inline-flex items-center justify-center gap-2" > {modelState === 'downloading' ? ( - <> Downloading basic-pitch models…> + <> {t('midi.downloading_model')}> ) : loading ? ( - <> Generating MIDI…> + <> {t('midi.generating')}> ) : ( - 'Generate MIDI' + t('midi.generate_midi') )} diff --git a/ui/components/Player.tsx b/ui/components/Player.tsx index 47e96d6..8135645 100644 --- a/ui/components/Player.tsx +++ b/ui/components/Player.tsx @@ -6,6 +6,7 @@ import { useResponsive } from '../context/ResponsiveContext'; import { SongDropdownMenu } from './SongDropdownMenu'; import { ShareModal } from './ShareModal'; import { AlbumCover } from './AlbumCover'; +import { useTranslation } from 'react-i18next'; interface PlayerProps { currentSong: Song | null; @@ -54,6 +55,7 @@ export const Player: React.FC = ({ onAddToPlaylist, onDelete }) => { + const { t } = useTranslation(); const { user } = useAuth(); const { isMobile } = useResponsive(); const progressBarRef = useRef(null); @@ -83,7 +85,7 @@ export const Player: React.FC = ({ - Select a song to play + {t('player.select_song')} ); @@ -139,7 +141,7 @@ export const Player: React.FC = ({ > - Now Playing + {t('player.now_playing')} @@ -172,7 +174,7 @@ export const Player: React.FC = ({ {currentSong.title} - {currentSong.creator || 'Unknown Artist'} + {currentSong.creator || t('song_list.unknown_artist')} = ({ @@ -341,7 +343,7 @@ export const Player: React.FC = ({ {currentSong.title} - {currentSong.creator || 'Unknown Artist'} + {currentSong.creator || t('song_list.unknown_artist')} @@ -387,16 +389,15 @@ export const Player: React.FC = ({ > {/* Header with close button */} e.stopPropagation()}> - setIsFullscreen(false)} - className="p-2 text-zinc-600 dark:text-white/70 hover:bg-zinc-200 dark:hover:bg-white/10 rounded-full transition-colors" - > - - - Now Playing - - - + setIsFullscreen(false)} + className="p-2 text-zinc-600 dark:text-white/70 hover:bg-zinc-200 dark:hover:bg-white/10 rounded-full transition-colors" + > + + + {t('player.now_playing')} + + {/* Main content area */} e.stopPropagation()}> @@ -427,7 +428,7 @@ export const Player: React.FC = ({ {currentSong.title} - {currentSong.creator || 'Unknown Artist'} + {currentSong.creator || t('song_list.unknown_artist')} @@ -530,7 +531,7 @@ export const Player: React.FC = ({ @@ -607,7 +608,7 @@ export const Player: React.FC = ({ > {currentSong.title} - {currentSong.creator || 'Unknown Artist'} + {currentSong.creator || t('song_list.unknown_artist')} = ({ diff --git a/ui/components/RightSidebar.tsx b/ui/components/RightSidebar.tsx index f6ef307..605a2d3 100644 --- a/ui/components/RightSidebar.tsx +++ b/ui/components/RightSidebar.tsx @@ -6,6 +6,7 @@ import { useAuth, LOCAL_TOKEN } from '../context/AuthContext'; import { SongDropdownMenu } from './SongDropdownMenu'; import { ShareModal } from './ShareModal'; import { AlbumCover } from './AlbumCover'; +import { useTranslation } from 'react-i18next'; interface RightSidebarProps { song: Song | null; @@ -25,6 +26,7 @@ interface RightSidebarProps { } export const RightSidebar: React.FC = ({ song, onClose, onOpenVideo, onReuse, onSongUpdate, onNavigateToProfile, onNavigateToSong, isLiked, onToggleLike, onDelete, onAddToPlaylist, onPlay, isPlaying, currentSong }) => { + const { t } = useTranslation(); const { token, user } = useAuth(); const [showMenu, setShowMenu] = useState(false); const [isOwner, setIsOwner] = useState(false); @@ -70,7 +72,7 @@ export const RightSidebar: React.FC = ({ song, onClose, onOpe if (!song) return; const trimmed = titleDraft.trim(); if (!trimmed) { - setTitleError('Title cannot be empty.'); + setTitleError(t('song_details.title_empty')); return; } if (trimmed === song.title) { @@ -84,7 +86,7 @@ export const RightSidebar: React.FC = ({ song, onClose, onOpe onSongUpdate?.({ ...song, title: trimmed }); setIsEditingTitle(false); } catch (err) { - const message = err instanceof Error ? err.message : 'Rename failed'; + const message = err instanceof Error ? err.message : t('song_details.rename_failed'); setTitleError(message); } finally { setIsSavingTitle(false); @@ -95,7 +97,7 @@ export const RightSidebar: React.FC = ({ song, onClose, onOpe - Select a song to view details + {t('song_details.select_song')} ); @@ -105,7 +107,7 @@ export const RightSidebar: React.FC = ({ song, onClose, onOpe {/* Header */} - Song Details + {t('song_details.title')} = ({ song, onClose, onOpe disabled={isSavingTitle} className="px-3 py-1.5 rounded-md text-xs font-semibold bg-pink-600 text-white hover:bg-pink-700 disabled:opacity-60" > - {isSavingTitle ? 'Saving...' : 'Save'} + {isSavingTitle ? t('song_details.saving') : t('song_details.save')} - Cancel + {t('song_details.cancel')} {titleError && ( {titleError} @@ -218,7 +220,7 @@ export const RightSidebar: React.FC = ({ song, onClose, onOpe startTitleEdit(); }} className="text-zinc-400 hover:text-black dark:hover:text-white p-1 mr-1" - title="Rename song" + title={t('song_details.rename')} > @@ -257,7 +259,7 @@ export const RightSidebar: React.FC = ({ song, onClose, onOpe > {song.creator || 'Anonymous'} - Created {new Date(song.createdAt).toLocaleDateString()} + {t('song_details.created', { date: new Date(song.createdAt).toLocaleDateString() })} @@ -266,7 +268,7 @@ export const RightSidebar: React.FC = ({ song, onClose, onOpe @@ -277,14 +279,14 @@ export const RightSidebar: React.FC = ({ song, onClose, onOpe const audioUrl = song.audioUrl.startsWith('http') ? song.audioUrl : `${window.location.origin}${song.audioUrl}`; window.open(`/editor?audioUrl=${encodeURIComponent(audioUrl)}`, '_blank'); }} - title="Open in Editor" + title={t('song_details.open_editor')} className="p-3 text-zinc-500 hover:text-zinc-900 dark:text-zinc-400 dark:hover:text-white hover:bg-zinc-300/50 dark:hover:bg-white/10 rounded-xl transition-all duration-200" > onReuse && onReuse(song)} - title="Reuse Prompt" + title={t('song_details.reuse_prompt')} className="p-3 text-zinc-500 hover:text-zinc-900 dark:text-zinc-400 dark:hover:text-white hover:bg-zinc-300/50 dark:hover:bg-white/10 rounded-xl transition-all duration-200" > @@ -298,7 +300,7 @@ export const RightSidebar: React.FC = ({ song, onClose, onOpe const audioUrl = song.audioUrl.startsWith('http') ? song.audioUrl : `${baseUrl}${song.audioUrl}`; window.open(`${baseUrl}/demucs-web/?audioUrl=${encodeURIComponent(audioUrl)}`, '_blank'); }} - title="Extract Stems" + title={t('song_details.extract_stems')} className="p-3 text-zinc-500 hover:text-zinc-900 dark:text-zinc-400 dark:hover:text-white hover:bg-zinc-300/50 dark:hover:bg-white/10 rounded-xl transition-all duration-200" > @@ -319,7 +321,7 @@ export const RightSidebar: React.FC = ({ song, onClose, onOpe { if (!song.audioUrl) return; try { @@ -348,7 +350,7 @@ export const RightSidebar: React.FC = ({ song, onClose, onOpe {/* Tags / Style */} - Style & Tags + {t('song_details.style_tags')} { e.stopPropagation(); @@ -360,9 +362,9 @@ export const RightSidebar: React.FC = ({ song, onClose, onOpe setTimeout(() => setCopiedStyle(false), 2000); }} className={`flex items-center gap-1 text-[10px] font-medium transition-colors ${copiedStyle ? 'text-green-500' : 'text-zinc-500 hover:text-black dark:hover:text-white'}`} - title="Copy all tags" + title={t('song_details.copy_tags')} > - {copiedStyle ? 'Copied!' : 'Copy'} + {copiedStyle ? t('song_details.copied') : t('song_details.copy')} = ({ song, onClose, onOpe )} {!tagsExpanded && ( - +more + {t('song_details.more_tags')} )} @@ -393,7 +395,7 @@ export const RightSidebar: React.FC = ({ song, onClose, onOpe {/* Lyrics Section */} - Lyrics + {t('song_details.lyrics')} { if (song.lyrics) { @@ -404,12 +406,12 @@ export const RightSidebar: React.FC = ({ song, onClose, onOpe }} className={`flex items-center gap-1 text-[10px] font-medium transition-colors ${copiedLyrics ? 'text-green-500' : 'text-zinc-500 hover:text-black dark:hover:text-white'}`} > - {copiedLyrics ? 'Copied!' : 'Copy'} + {copiedLyrics ? t('song_details.copied') : t('song_details.copy')} - {song.lyrics || InstrumentalNo lyrics generated} + {song.lyrics || {t('song_details.instrumental')}{t('song_details.no_lyrics')}} diff --git a/ui/components/SearchPage.tsx b/ui/components/SearchPage.tsx index 6456825..6741b50 100644 --- a/ui/components/SearchPage.tsx +++ b/ui/components/SearchPage.tsx @@ -1,5 +1,6 @@ import React, { useState, useEffect, useRef, useCallback } from 'react'; import { Search, Play, Pause, Heart, ChevronRight, ChevronLeft, Copy, Check, X, Loader2 } from 'lucide-react'; +import { useTranslation } from 'react-i18next'; import { Song, Playlist } from '../types'; import { songsApi, usersApi, playlistsApi, searchApi, UserProfile, getAudioUrl } from '../services/api'; @@ -36,6 +37,7 @@ export const SearchPage: React.FC = ({ onNavigateToSong, onNavigateToPlaylist, }) => { + const { t } = useTranslation(); const [searchQuery, setSearchQuery] = useState(''); const [featuredSongs, setFeaturedSongs] = useState([]); const [featuredCreators, setFeaturedCreators] = useState>([]); @@ -210,7 +212,7 @@ export const SearchPage: React.FC = ({ handleSearchChange(e.target.value)} className="w-full h-11 pl-12 pr-12 bg-white dark:bg-zinc-900/80 border border-zinc-200 dark:border-white/10 rounded-full text-zinc-900 dark:text-white placeholder-zinc-400 focus:outline-none focus:border-pink-500 dark:focus:border-pink-500 focus:ring-2 focus:ring-pink-500/20 transition-all" @@ -232,7 +234,7 @@ export const SearchPage: React.FC = ({ - {isSearching ? `Songs matching "${searchQuery}"` : 'Featured Songs'} + {isSearching ? t('search.songs_matching', { query: searchQuery }) : t('search.featured_songs')} {isSearching && displaySongs.length > 0 && ( ({displaySongs.length}) )} @@ -278,7 +280,7 @@ export const SearchPage: React.FC = ({ ) : isSearching ? ( - No songs found matching "{searchQuery}" + {t('search.no_songs_found', { query: searchQuery })} ) : null} @@ -287,7 +289,7 @@ export const SearchPage: React.FC = ({ - {isSearching ? `Creators matching "${searchQuery}"` : 'Featured Creators'} + {isSearching ? t('search.creators_matching', { query: searchQuery }) : t('search.featured_creators')} {isSearching && displayCreators.length > 0 && ( ({displayCreators.length}) )} @@ -337,7 +339,7 @@ export const SearchPage: React.FC = ({ ) : ( - {isSearching ? `No creators found matching "${searchQuery}"` : 'No creators yet. Be the first to share your music!'} + {isSearching ? t('search.no_creators_found', { query: searchQuery }) : t('search.no_creators_yet')} )} @@ -346,7 +348,7 @@ export const SearchPage: React.FC = ({ - {isSearching ? `Playlists matching "${searchQuery}"` : 'Featured Playlists'} + {isSearching ? t('search.playlists_matching', { query: searchQuery }) : t('search.featured_playlists')} {isSearching && displayPlaylists.length > 0 && ( ({displayPlaylists.length}) )} @@ -398,24 +400,23 @@ export const SearchPage: React.FC = ({ ) : ( - {isSearching ? `No playlists found matching "${searchQuery}"` : 'No public playlists yet. Create one and share your favorites!'} + {isSearching ? t('search.no_playlists_found', { query: searchQuery }) : t('search.no_playlists_yet')} )} {/* Genres */} - Genres + {t('search.genres')} {GENRES.map((genre) => ( handleGenreClick(genre)} - className={`px-3 py-1.5 border rounded-full text-sm transition-all duration-200 group flex items-center gap-1.5 ${ - searchQuery === genre - ? 'bg-pink-500 border-pink-500 text-white' - : 'bg-zinc-100 dark:bg-zinc-800/60 border-zinc-200 dark:border-white/5 text-zinc-600 dark:text-zinc-300 hover:bg-zinc-200 dark:hover:bg-zinc-700/60 hover:border-pink-500/30 hover:text-pink-600 dark:hover:text-pink-400' - }`} + className={`px-3 py-1.5 border rounded-full text-sm transition-all duration-200 group flex items-center gap-1.5 ${searchQuery === genre + ? 'bg-pink-500 border-pink-500 text-white' + : 'bg-zinc-100 dark:bg-zinc-800/60 border-zinc-200 dark:border-white/5 text-zinc-600 dark:text-zinc-300 hover:bg-zinc-200 dark:hover:bg-zinc-700/60 hover:border-pink-500/30 hover:text-pink-600 dark:hover:text-pink-400' + }`} > {genre} = ({ isOpen, onClose, theme, onToggleTheme, onNavigateToProfile }) => { + const { t, i18n } = useTranslation(); const { user } = useAuth(); const [isEditProfileOpen, setIsEditProfileOpen] = useState(false); const [modelsFolder, setModelsFolder] = useState(''); @@ -133,6 +135,10 @@ export const SettingsModal: React.FC = ({ isOpen, onClose, t .catch(() => {}); }; + const toggleLanguage = (lang: string) => { + i18n.changeLanguage(lang); + }; + if (!isOpen || !user) { if (isEditProfileOpen && user) { return ( @@ -154,7 +160,7 @@ export const SettingsModal: React.FC = ({ isOpen, onClose, t > {/* Header */} - Settings + {t('settings.title')} = ({ isOpen, onClose, t + + {/* Language Section */} + + + + {t('settings.language')} + + + + toggleLanguage('en')} + className={`flex-1 py-3 px-4 rounded-lg border-2 font-medium transition-colors ${i18n.language.startsWith('en') + ? 'border-indigo-500 bg-indigo-50 text-indigo-700' + : 'border-zinc-300 dark:border-zinc-700 hover:border-zinc-400 dark:hover:border-zinc-600' + }`} + > + English + + toggleLanguage('zh')} + className={`flex-1 py-3 px-4 rounded-lg border-2 font-medium transition-colors ${i18n.language.startsWith('zh') + ? 'border-indigo-500 bg-indigo-950 text-indigo-300' + : 'border-zinc-300 dark:border-zinc-700 hover:border-zinc-400 dark:hover:border-zinc-600' + }`} + > + 中文 + + + + + {/* AceForge paths (models, output) */} - Paths + {t('settings.paths')} - Models folder - Where ACE-Step and other models are stored. Leave blank for app default. Change takes effect immediately. + {t('settings.models_folder')} + {t('settings.models_folder_desc')} setModelsFolder(e.target.value)} onBlur={saveModelsFolder} - placeholder="Default (app data folder)" + placeholder={t('settings.default_placeholder')} className="flex-1 rounded-lg border border-zinc-300 dark:border-zinc-600 bg-white dark:bg-zinc-800 px-3 py-2 text-sm text-zinc-900 dark:text-white" /> = ({ isOpen, onClose, t onClick={saveModelsFolder} className="px-3 py-2 rounded-lg bg-pink-500 text-white text-sm font-medium hover:bg-pink-600" > - {modelsFolderSaved ? 'Saved' : 'Save'} + {modelsFolderSaved ? t('settings.saved') : t('settings.save')} - Output directory - Where generated tracks, stems, voice clones, and MIDI are saved. Leave blank for app default. + {t('settings.output_dir')} + {t('settings.output_dir_desc')} setOutputDir(e.target.value)} onBlur={saveOutputDir} - placeholder="Default (app data folder)" + placeholder={t('settings.default_placeholder')} className="flex-1 rounded-lg border border-zinc-300 dark:border-zinc-600 bg-white dark:bg-zinc-800 px-3 py-2 text-sm text-zinc-900 dark:text-white" /> = ({ isOpen, onClose, t onClick={saveOutputDir} className="px-3 py-2 rounded-lg bg-pink-500 text-white text-sm font-medium hover:bg-pink-600" > - {outputDirSaved ? 'Saved' : 'Save'} + {outputDirSaved ? t('settings.saved') : t('settings.save')} @@ -220,14 +257,14 @@ export const SettingsModal: React.FC = ({ isOpen, onClose, t - Models + {t('settings.models')} - ACE-Step executor (DiT) and planner (LM). See Tutorial for VRAM and quality trade-offs. + {t('settings.models_desc')} - Current ACE-Step model + {t('settings.current_model')} {(() => { const discovered = aceStepList?.discovered_models ?? []; @@ -240,8 +277,8 @@ export const SettingsModal: React.FC = ({ isOpen, onClose, t - ACE-Step (DiT) model - Installed and discovered models in the checkpoints folder. Custom models appear when placed there. + {t('settings.dit_model')} + {t('settings.dit_model_desc')} m.id === aceStepDitModel && m.installed) || (aceStepList?.discovered_models ?? []).some((d) => d.id === aceStepDitModel) ? aceStepDitModel : (aceStepList?.dit_models.find((m) => m.installed)?.id ?? aceStepList?.discovered_models?.[0]?.id ?? 'turbo')} onChange={(e) => saveAceStepModels(e.target.value, undefined)} @@ -268,8 +305,8 @@ export const SettingsModal: React.FC = ({ isOpen, onClose, t - LM planner - Bundled ACE-Step 5Hz LM (no external LLM). Used when "Thinking" is on in Create. Download the model below if needed; only installed options appear here. + {t('settings.lm_planner')} + {t('settings.lm_planner_desc')} m.id === aceStepLm && m.installed) ? aceStepLm : (aceStepList?.lm_models.find((m) => m.installed)?.id ?? 'none')} onChange={(e) => saveAceStepModels(undefined, e.target.value)} @@ -283,14 +320,14 @@ export const SettingsModal: React.FC = ({ isOpen, onClose, t : Loading…} - {modelsSaved && Saved} + {modelsSaved && {t('settings.saved')}} {/* Download models */} {aceStepList && ( - Download models + {t('settings.download_models')} {aceStepList.acestep_download_available - ? 'Download DiT or LM models into the checkpoints folder. (Bundled in app.)' + ? t('settings.download_models_desc') : 'Downloader not available in this build. Default (Turbo) uses the app download.'} {downloadError && {downloadError}} @@ -308,7 +345,7 @@ export const SettingsModal: React.FC = ({ isOpen, onClose, t }} className="text-xs px-2 py-1 rounded bg-red-500/90 text-white hover:bg-red-600" > - Cancel + {t('common.cancel')} @@ -413,12 +450,12 @@ export const SettingsModal: React.FC = ({ isOpen, onClose, t - Display + {t('settings.display')} - UI zoom - Window zoom level. Takes effect on next app launch. + {t('settings.ui_zoom')} + {t('settings.ui_zoom_desc')} {ZOOM_OPTIONS.map((pct) => ( = ({ isOpen, onClose, t {pct}% ))} - {uiZoomSaved && Saved} + {uiZoomSaved && {t('settings.saved')}} @@ -457,7 +494,7 @@ export const SettingsModal: React.FC = ({ isOpen, onClose, t @{user.username} - Member since {new Date(user.createdAt).toLocaleDateString('en-US', { month: 'long', year: 'numeric' })} + {t('settings.member_since')} {new Date(user.createdAt).toLocaleDateString('en-US', { month: 'long', year: 'numeric' })} @@ -469,7 +506,7 @@ export const SettingsModal: React.FC = ({ isOpen, onClose, t className="flex items-center gap-2 px-4 py-2 bg-indigo-600 text-white rounded-lg text-sm font-medium hover:bg-indigo-700 transition-colors" > - Edit Profile + {t('settings.edit_profile')} { @@ -479,7 +516,7 @@ export const SettingsModal: React.FC = ({ isOpen, onClose, t className="flex items-center gap-2 px-4 py-2 bg-zinc-200 dark:bg-zinc-700 text-zinc-900 dark:text-white rounded-lg text-sm font-medium hover:bg-zinc-300 dark:hover:bg-zinc-600 transition-colors" > - View Profile + {t('settings.view_profile')} @@ -489,52 +526,21 @@ export const SettingsModal: React.FC = ({ isOpen, onClose, t - Account + {t('settings.account')} - Username + {t('settings.username')} @{user.username} - {/* Output directory (global for generation, stems, voice clone, MIDI) */} - - - - Output - - - - Output directory - Where generated tracks, stems, voice clones, and MIDI are saved. Leave blank for app default. - - setOutputDir(e.target.value)} - onBlur={saveOutputDir} - placeholder="Default (app data folder)" - className="flex-1 rounded-lg border border-zinc-300 dark:border-zinc-600 bg-white dark:bg-zinc-800 px-3 py-2 text-sm text-zinc-900 dark:text-white" - /> - - {outputDirSaved ? 'Saved' : 'Save'} - - - - - - {/* Theme Section */} - Appearance + {t('settings.appearance')} @@ -545,7 +551,7 @@ export const SettingsModal: React.FC = ({ isOpen, onClose, t : 'border-zinc-300 dark:border-zinc-700 hover:border-zinc-400 dark:hover:border-zinc-600' }`} > - Light + {t('common.light')} = ({ isOpen, onClose, t : 'border-zinc-300 dark:border-zinc-700 hover:border-zinc-400 dark:hover:border-zinc-600' }`} > - Dark + {t('common.dark')} @@ -564,13 +570,13 @@ export const SettingsModal: React.FC = ({ isOpen, onClose, t - About + {t('settings.about')} - Version 1.0.0 + {t('settings.version')} 1.0.0 AceForge - Powered by ACE-Step 1.5. Open source and free to use. + {t('settings.powered_by')} AceForge @@ -582,11 +588,11 @@ export const SettingsModal: React.FC = ({ isOpen, onClose, t className="inline-flex items-center gap-2 px-4 py-2 bg-zinc-800 dark:bg-zinc-700 text-white rounded-lg text-sm font-medium hover:bg-zinc-700 dark:hover:bg-zinc-600 transition-colors" > - GitHub Repo + {t('settings.github_repo')} - Report issues or request features on GitHub + {t('settings.report_issues')} @@ -599,7 +605,7 @@ export const SettingsModal: React.FC = ({ isOpen, onClose, t onClick={onClose} className="px-6 py-2 bg-zinc-900 dark:bg-white text-white dark:text-black font-semibold rounded-lg hover:bg-zinc-800 dark:hover:bg-zinc-200 transition-colors" > - Done + {t('settings.done')} diff --git a/ui/components/Sidebar.tsx b/ui/components/Sidebar.tsx index 59fe67a..2c4a71e 100644 --- a/ui/components/Sidebar.tsx +++ b/ui/components/Sidebar.tsx @@ -1,6 +1,7 @@ import React from 'react'; import { Library, Disc, Search, Terminal, Sun, Moon, GraduationCap, Layers, Mic, Music2, Settings } from 'lucide-react'; import { View } from '../types'; +import { useTranslation } from 'react-i18next'; interface SidebarProps { currentView: View; @@ -21,6 +22,8 @@ export const Sidebar: React.FC = ({ onOpenConsole, onOpenSettings, }) => { + const { t } = useTranslation(); + return ( {/* Logo */} @@ -35,43 +38,43 @@ export const Sidebar: React.FC = ({ } - label="Create" + label={t('navigation.create')} active={currentView === 'create'} onClick={() => onNavigate('create')} /> } - label="Library" + label={t('navigation.library')} active={currentView === 'library'} onClick={() => onNavigate('library')} /> } - label="Search" + label={t('navigation.search')} active={currentView === 'search'} onClick={() => onNavigate('search')} /> } - label="Training" + label={t('navigation.training')} active={currentView === 'training'} onClick={() => onNavigate('training')} /> } - label="Stem Splitting" + label={t('navigation.stem_splitting')} active={currentView === 'stem-splitting'} onClick={() => onNavigate('stem-splitting')} /> } - label="Voice Cloning" + label={t('navigation.voice_cloning')} active={currentView === 'voice-cloning'} onClick={() => onNavigate('voice-cloning')} /> } - label="Audio to MIDI" + label={t('navigation.midi')} active={currentView === 'midi'} onClick={() => onNavigate('midi')} /> @@ -80,7 +83,7 @@ export const Sidebar: React.FC = ({ {theme === 'dark' ? : } @@ -88,7 +91,7 @@ export const Sidebar: React.FC = ({ @@ -98,7 +101,7 @@ export const Sidebar: React.FC = ({ {user.avatar_url ? ( @@ -110,7 +113,7 @@ export const Sidebar: React.FC = ({ diff --git a/ui/components/SongList.tsx b/ui/components/SongList.tsx index d52d525..79b5675 100644 --- a/ui/components/SongList.tsx +++ b/ui/components/SongList.tsx @@ -5,6 +5,7 @@ import { useAuth } from '../context/AuthContext'; import { SongDropdownMenu } from './SongDropdownMenu'; import { ShareModal } from './ShareModal'; import { AlbumCover } from './AlbumCover'; +import { useTranslation } from 'react-i18next'; interface SongListProps { songs: Song[]; @@ -23,20 +24,9 @@ interface SongListProps { onDelete?: (song: Song) => void; } -// ... existing code ... - - - // Define Filter Types type FilterType = 'liked' | 'public' | 'private' | 'generating'; -const FILTERS: { id: FilterType; label: string; icon: React.ReactNode }[] = [ - { id: 'liked', label: 'Liked', icon: }, - { id: 'public', label: 'Public', icon: }, - { id: 'private', label: 'Private', icon: }, - { id: 'generating', label: 'Generating', icon: }, -]; - export const SongList: React.FC = ({ songs, currentSong, @@ -53,12 +43,20 @@ export const SongList: React.FC = ({ onReusePrompt, onDelete }) => { + const { t } = useTranslation(); const { user } = useAuth(); const [searchQuery, setSearchQuery] = useState(''); const [activeFilters, setActiveFilters] = useState>(new Set()); const [isFilterOpen, setIsFilterOpen] = useState(false); const filterRef = useRef(null); + const FILTERS: { id: FilterType; label: string; icon: React.ReactNode }[] = [ + { id: 'liked', label: t('song_list.filter_liked'), icon: }, + { id: 'public', label: t('song_list.filter_public'), icon: }, + { id: 'private', label: t('song_list.filter_private'), icon: }, + { id: 'generating', label: t('song_list.filter_generating'), icon: }, + ]; + // Close filter dropdown when clicking outside useEffect(() => { const handleClickOutside = (event: MouseEvent) => { @@ -111,9 +109,9 @@ export const SongList: React.FC = ({ {/* Header */} - Workspaces + {t('song_list.workspaces')} › - My Workspace + {t('song_list.my_workspace')} @@ -122,7 +120,7 @@ export const SongList: React.FC = ({ type="text" value={searchQuery} onChange={(e) => setSearchQuery(e.target.value)} - placeholder="Search your songs..." + placeholder={t('song_list.search_placeholder')} className="w-full bg-zinc-100 dark:bg-[#121214] border border-zinc-200 dark:border-white/10 rounded-lg pl-10 pr-4 py-2.5 text-sm text-zinc-900 dark:text-white focus:outline-none focus:border-zinc-400 dark:focus:border-white/20 placeholder-zinc-500 dark:placeholder-zinc-600 transition-colors" /> @@ -140,14 +138,14 @@ export const SongList: React.FC = ({ `} > 0 ? "currentColor" : "none"} /> - Filters {activeFilters.size > 0 && `(${activeFilters.size})`} + {t('song_list.filters')} {activeFilters.size > 0 && `(${activeFilters.size})`} {/* Filter Dropdown */} {isFilterOpen && ( - Refine By + {t('song_list.refine_by')} {FILTERS.map(filter => ( = ({ - No songs match your filters. + {t('song_list.no_matches')} { setActiveFilters(new Set()); setSearchQuery(''); }} className="text-pink-600 dark:text-pink-500 text-sm font-bold hover:underline" > - Clear filters + {t('song_list.clear_filters')} ) : ( @@ -256,6 +254,7 @@ const SongItem: React.FC = ({ onReusePrompt, onDelete }) => { + const { t } = useTranslation(); const [showDropdown, setShowDropdown] = useState(false); const [shareModalOpen, setShareModalOpen] = useState(false); const [imageError, setImageError] = useState(false); @@ -289,7 +288,7 @@ const SongItem: React.FC = ({ - Queue #{song.queuePosition} + {t('song_list.queued', { pos: song.queuePosition })} > ) : ( /* Generating - progress % and ETA */ @@ -336,7 +335,7 @@ const SongItem: React.FC = ({ - {song.title || (song.isGenerating ? (song.queuePosition ? "Queued..." : (song.generationPercent != null ? `Creating... ${Math.round(song.generationPercent)}%` : "Creating...")) : "Untitled")} + {song.title || (song.isGenerating ? (song.queuePosition ? t('song_list.queued_text') : (song.generationPercent != null ? t('song_list.creating_percent', { percent: Math.round(song.generationPercent) }) : t('song_list.creating'))) : t('song_list.untitled'))} v1.5 @@ -359,7 +358,7 @@ const SongItem: React.FC = ({ {(song.creator?.[0] || 'U').toUpperCase()} - {song.creator || 'Unknown'} + {song.creator || t('song_list.unknown')} @@ -391,7 +390,7 @@ const SongItem: React.FC = ({ { e.stopPropagation(); setShareModalOpen(true); }} - title="Share" + title={t('song_list.share')} > @@ -399,7 +398,7 @@ const SongItem: React.FC = ({ { e.stopPropagation(); if (onOpenVideo) onOpenVideo(); }} - title="Create Video" + title={t('song_list.create_video')} > @@ -407,7 +406,7 @@ const SongItem: React.FC = ({ { e.stopPropagation(); onAddToPlaylist(); }} - title="Add to Playlist" + title={t('song_list.add_to_playlist')} > @@ -416,7 +415,7 @@ const SongItem: React.FC = ({ { e.stopPropagation(); if (onShowDetails) onShowDetails(); }} - title="Song Details" + title={t('song_list.song_details')} > @@ -451,7 +450,7 @@ const SongItem: React.FC = ({ {song.isGenerating ? ( - {song.queuePosition ? `#${song.queuePosition}` : (song.generationPercent != null ? `${Math.round(song.generationPercent)}%` : 'Creating...')} + {song.queuePosition ? `#${song.queuePosition}` : (song.generationPercent != null ? `${Math.round(song.generationPercent)}%` : t('song_list.creating'))} ) : song.duration} diff --git a/ui/components/SongProfile.tsx b/ui/components/SongProfile.tsx index f07578b..92e30ed 100644 --- a/ui/components/SongProfile.tsx +++ b/ui/components/SongProfile.tsx @@ -4,6 +4,7 @@ import { songsApi, getAudioUrl } from '../services/api'; import { useAuth } from '../context/AuthContext'; import { ArrowLeft, Play, Pause, Heart, Share2, MoreHorizontal, ThumbsDown, Music as MusicIcon, Edit3, Eye } from 'lucide-react'; import { ShareModal } from './ShareModal'; +import { useTranslation } from 'react-i18next'; interface SongProfileProps { songId: string; @@ -80,6 +81,7 @@ const resetMetaTags = () => { }; export const SongProfile: React.FC = ({ songId, onBack, onPlay, onNavigateToProfile, currentSong, isPlaying, likedSongIds = new Set(), onToggleLike }) => { + const { t } = useTranslation(); const { user, token } = useAuth(); const [song, setSong] = useState(null); const [loading, setLoading] = useState(true); @@ -138,7 +140,7 @@ export const SongProfile: React.FC = ({ songId, onBack, onPlay - Loading song... + {t('song_profile.loading')} ); @@ -147,9 +149,9 @@ export const SongProfile: React.FC = ({ songId, onBack, onPlay if (!song) { return ( - Song not found + {t('song_profile.not_found')} - Go Back + {t('song_profile.back')} ); @@ -164,7 +166,7 @@ export const SongProfile: React.FC = ({ songId, onBack, onPlay className="flex items-center gap-2 text-zinc-500 dark:text-zinc-400 hover:text-zinc-900 dark:hover:text-white mb-4 transition-colors" > - Back + {t('song_profile.back')} @@ -198,7 +200,7 @@ export const SongProfile: React.FC = ({ songId, onBack, onPlay {new Date(song.createdAt).toLocaleDateString('en-US', { month: 'long', day: 'numeric', year: 'numeric' })} at {new Date(song.createdAt).toLocaleTimeString('en-US', { hour: '2-digit', minute: '2-digit' })} {!song.isPublic && song.userId === user?.id && ( - Private + {t('song_profile.private')} )} @@ -206,13 +208,13 @@ export const SongProfile: React.FC = ({ songId, onBack, onPlay {/* Related Songs Tab - Hidden on mobile */} - Similar + {t('song_profile.similar')} song.creator && onNavigateToProfile(song.creator)} className="px-4 py-2 text-zinc-500 dark:text-zinc-400 hover:text-zinc-900 dark:hover:text-white text-sm font-semibold transition-colors" > - By {song.creator || 'Artist'} + {t('song_profile.by_artist', { artist: song.creator || 'Artist' })} @@ -272,7 +274,7 @@ export const SongProfile: React.FC = ({ songId, onBack, onPlay className="flex items-center gap-2 bg-indigo-600 hover:bg-indigo-700 px-3 py-2 rounded-full text-sm font-semibold transition-colors text-white" > - Edit + {t('song_profile.edit')} )} = ({ songId, onBack, onPlay {/* Lyrics */} {song.lyrics && ( - Lyrics + {t('song_profile.lyrics')} {song.lyrics} diff --git a/ui/components/StemSplittingPanel.tsx b/ui/components/StemSplittingPanel.tsx index 598cb66..62e5982 100644 --- a/ui/components/StemSplittingPanel.tsx +++ b/ui/components/StemSplittingPanel.tsx @@ -1,5 +1,6 @@ import React, { useState, useEffect, useRef } from 'react'; import { Layers, Download, Loader2 } from 'lucide-react'; +import { useTranslation } from 'react-i18next'; import { toolsApi, preferencesApi } from '../services/api'; const POLL_INTERVAL_MS = 500; @@ -10,6 +11,7 @@ interface StemSplittingPanelProps { } export const StemSplittingPanel: React.FC = ({ onTracksUpdated }) => { + const { t } = useTranslation(); const [inputFile, setInputFile] = useState(null); const [baseFilename, setBaseFilename] = useState(''); const [stemCount, setStemCount] = useState('4'); @@ -46,7 +48,7 @@ export const StemSplittingPanel: React.FC = ({ onTracks if (s?.device_preference != null) setDevice(s.device_preference); if (s?.export_format != null) setExportFormat(s.export_format); }) - .catch(() => {}); + .catch(() => { }); }, []); useEffect(() => { @@ -65,14 +67,14 @@ export const StemSplittingPanel: React.FC = ({ onTracks setModelState(r.state || ''); setModelMessage(r.message || ''); }) - .catch(() => {}); + .catch(() => { }); toolsApi.getProgress() .then((p) => { if (p.stage === 'stem_split_model_download') { setModelDownloadProgress(p.fraction); } }) - .catch(() => {}); + .catch(() => { }); }; poll(); modelPollRef.current = setInterval(poll, MODEL_POLL_MS); @@ -95,7 +97,7 @@ export const StemSplittingPanel: React.FC = ({ onTracks } } }) - .catch(() => {}); + .catch(() => { }); }; poll(); pollRef.current = setInterval(poll, POLL_INTERVAL_MS); @@ -108,7 +110,7 @@ export const StemSplittingPanel: React.FC = ({ onTracks setError(null); toolsApi.stemSplitModelEnsure().then(() => { setModelState('downloading'); - setModelMessage('Downloading Demucs model (first use only). This may take several minutes.'); + setModelMessage(t('stem_splitting.downloading_demucs')); }).catch((e) => setError(e.message)); }; @@ -117,11 +119,11 @@ export const StemSplittingPanel: React.FC = ({ onTracks setError(null); setSuccess(null); if (!inputFile) { - setError('Please select an input audio file.'); + setError(t('stem_splitting.error_select_file')); return; } if (modelReady !== true || modelState === 'downloading') { - setError(modelState === 'downloading' ? 'Please wait for Demucs model download to finish.' : 'Demucs model is not ready. Click Download Demucs models first.'); + setError(modelState === 'downloading' ? t('stem_splitting.error_wait_download') : t('stem_splitting.error_model_not_ready')); return; } const formData = new FormData(); @@ -138,16 +140,16 @@ export const StemSplittingPanel: React.FC = ({ onTracks if (prefs.output_dir) formData.set('out_dir', prefs.output_dir); const res = await toolsApi.stemSplit(formData); if (res?.error) { - setError(res.message || 'Stem splitting failed.'); + setError(res.message || t('stem_splitting.error_failed')); setLoading(false); return; } await preferencesApi.update({ stem_split: { stem_count: stemCount, mode, device_preference: device, export_format: exportFormat } }); - setSuccess(res?.message || 'Stems saved to output directory.'); + setSuccess(res?.message || t('stem_splitting.success_message')); setLoading(false); onTracksUpdated?.(); } catch (err) { - setError(err instanceof Error ? err.message : 'Stem splitting failed.'); + setError(err instanceof Error ? err.message : t('stem_splitting.error_failed')); setLoading(false); } }; @@ -156,29 +158,29 @@ export const StemSplittingPanel: React.FC = ({ onTracks - Stem Splitting + {t('stem_splitting.title')} - Split audio into separate stems (vocals, drums, bass, etc.) using Demucs. Upload an audio file and choose the number of stems. + {t('stem_splitting.description')} {modelReady === false && modelState !== 'downloading' && ( - Demucs model is not downloaded yet. - {modelMessage || 'Click "Download Demucs models" to download it (first use only).'} + {t('stem_splitting.model_not_downloaded')} + {t('stem_splitting.model_download_hint')} - Download Demucs models + {t('stem_splitting.download_demucs')} )} {modelState === 'downloading' && ( - Downloading Demucs model… + {t('stem_splitting.downloading_demucs')} = ({ onTracks - Input Audio File + {t('stem_splitting.input_audio')} = ({ onTracks - Base filename (optional) + {t('stem_splitting.base_filename')} setBaseFilename(e.target.value)} - placeholder="Prefix for output filenames" + placeholder={t('stem_splitting.base_filename_placeholder')} className="w-full rounded-lg border border-zinc-300 dark:border-zinc-600 bg-white dark:bg-zinc-800 px-3 py-2 text-sm" /> - Number of Stems + {t('stem_splitting.stem_count')} setStemCount(e.target.value)} className="w-full rounded-lg border border-zinc-300 dark:border-zinc-600 bg-white dark:bg-zinc-800 px-3 py-2 text-sm" > - 2-Stem (Vocals / Instrumental) - 4-Stem (Vocals, Drums, Bass, Other) - 6-Stem (Vocals, Drums, Bass, Guitar, Piano, Other) + {t('stem_splitting.stem_2')} + {t('stem_splitting.stem_4')} + {t('stem_splitting.stem_6')} - Mode (2-Stem only) + {t('stem_splitting.mode')} setMode(e.target.value)} className="w-full rounded-lg border border-zinc-300 dark:border-zinc-600 bg-white dark:bg-zinc-800 px-3 py-2 text-sm" > - Standard (All Stems) - Acapella (Vocals Only) - Instrumental / Karaoke + {t('stem_splitting.mode_standard')} + {t('stem_splitting.mode_vocals')} + {t('stem_splitting.mode_instrumental')} - Device + {t('stem_splitting.device')} setDevice(e.target.value)} className="w-full rounded-lg border border-zinc-300 dark:border-zinc-600 bg-white dark:bg-zinc-800 px-3 py-2 text-sm" > - Auto (MPS if available, else CPU) - Apple Silicon GPU (MPS) - CPU + {t('stem_splitting.device_auto')} + {t('stem_splitting.device_mps')} + {t('stem_splitting.device_cpu')} - Export Format + {t('stem_splitting.export_format')} setExportFormat(e.target.value)} className="w-full rounded-lg border border-zinc-300 dark:border-zinc-600 bg-white dark:bg-zinc-800 px-3 py-2 text-sm" > - WAV (Uncompressed) - MP3 (256kbps) + {t('stem_splitting.format_wav')} + {t('stem_splitting.format_mp3')} @@ -283,11 +285,11 @@ export const StemSplittingPanel: React.FC = ({ onTracks className="rounded-lg bg-pink-500 text-white px-4 py-2 text-sm font-medium hover:bg-pink-600 disabled:opacity-50 disabled:cursor-not-allowed inline-flex items-center justify-center gap-2" > {modelState === 'downloading' ? ( - <> Downloading Demucs models…> + <> {t('stem_splitting.downloading_demucs')}> ) : loading ? ( - <> Splitting…> + <> {t('stem_splitting.splitting')}> ) : ( - 'Split Stems' + t('stem_splitting.split_stems') )} diff --git a/ui/components/TrainingPanel.tsx b/ui/components/TrainingPanel.tsx index ef538ca..6eb3494 100644 --- a/ui/components/TrainingPanel.tsx +++ b/ui/components/TrainingPanel.tsx @@ -1,5 +1,6 @@ import React, { useState, useEffect, useRef } from 'react'; import { GraduationCap, Play, Pause, Square, RotateCw, Download, HelpCircle } from 'lucide-react'; +import { useTranslation } from 'react-i18next'; import { toolsApi, preferencesApi } from '../services/api'; const POLL_INTERVAL_MS = 2000; @@ -10,6 +11,7 @@ interface TrainingPanelProps { } export const TrainingPanel: React.FC = ({ onTracksUpdated: _onTracksUpdated }) => { + const { t } = useTranslation(); const [datasetPath, setDatasetPath] = useState(''); const [expName, setExpName] = useState(''); const [loraConfigPath, setLoraConfigPath] = useState(''); @@ -32,7 +34,7 @@ export const TrainingPanel: React.FC = ({ onTracksUpdated: _ const [showAdvanced, setShowAdvanced] = useState(false); const [showLoraHelp, setShowLoraHelp] = useState(false); const [datasetFiles, setDatasetFiles] = useState(null); - const [statusText, setStatusText] = useState('Idle – no training in progress.'); + const [statusText, setStatusText] = useState(''); const [progress, setProgress] = useState(null); const [running, setRunning] = useState(false); const [paused, setPaused] = useState(false); @@ -75,7 +77,7 @@ export const TrainingPanel: React.FC = ({ onTracksUpdated: _ if (t?.max_epochs != null) setMaxEpochs(Number(t.max_epochs)); if (t?.learning_rate != null) setLearningRate(Number(t.learning_rate)); }) - .catch(() => {}); + .catch(() => { }); }, []); useEffect(() => { @@ -104,14 +106,14 @@ export const TrainingPanel: React.FC = ({ onTracksUpdated: _ setAceDownloadProgress(p.fraction); } }) - .catch(() => {}); + .catch(() => { }); toolsApi.aceModelStatus() .then((r) => { setAceReady(r.ready); setAceState(r.state || ''); setAceMessage(r.message || ''); }) - .catch(() => {}); + .catch(() => { }); }; poll(); acePollRef.current = setInterval(poll, MODEL_POLL_MS); @@ -137,7 +139,7 @@ export const TrainingPanel: React.FC = ({ onTracksUpdated: _ else if (typeof s.current_epoch === 'number' && typeof s.max_epochs === 'number' && s.max_epochs > 0) setProgress(s.current_epoch / s.max_epochs); }) - .catch(() => {}); + .catch(() => { }); }; poll(); pollRef.current = setInterval(poll, POLL_INTERVAL_MS); @@ -154,14 +156,14 @@ export const TrainingPanel: React.FC = ({ onTracksUpdated: _ if (!aceReady && aceState !== 'ready') { toolsApi.aceModelEnsure().then(() => { setAceState('downloading'); - setAceMessage('Downloading ACE-Step model…'); - }).catch((err) => setError(err instanceof Error ? err.message : 'Failed to start download.')); + setAceMessage(t('training.downloading_model')); + }).catch((err) => setError(err instanceof Error ? err.message : t('training.error_start_failed'))); return; } const hasPath = datasetPath.trim().length > 0; const hasFiles = datasetFiles && datasetFiles.length > 0; if (!hasPath && !hasFiles) { - setError('Please select a dataset folder or enter a dataset path.'); + setError(t('training.error_select_dataset')); return; } const formData = new FormData(); @@ -201,9 +203,9 @@ export const TrainingPanel: React.FC = ({ onTracksUpdated: _ }); setRunning(true); setPaused(false); - setStatusText('LoRA training is running… check the console for logs.'); + setStatusText(t('training.training_running')); } catch (err) { - setError(err instanceof Error ? err.message : 'Failed to start training.'); + setError(err instanceof Error ? err.message : t('training.error_start_failed')); } }; @@ -236,39 +238,48 @@ export const TrainingPanel: React.FC = ({ onTracksUpdated: _ const startModelDownload = () => { toolsApi.aceModelEnsure().then(() => { setAceState('downloading'); - setAceMessage('Downloading ACE-Step model from Hugging Face. This may take several minutes.'); + setAceMessage(t('training.downloading_model')); }).catch((e) => setError(e.message)); }; const canStartTraining = aceReady === true && !running && aceState !== 'downloading'; const showDownloadButton = aceReady === false && aceState !== 'downloading'; + const getStatusDisplay = () => { + if (returncode != null && !running && !paused) { + return returncode === 0 + ? (statusText || t('training.training_finished')) + : (statusText || t('training.training_error', { code: returncode })); + } + return statusText || t('training.idle'); + }; + return ( - Train Custom LoRA + {t('training.title')} - Run LoRA training on your dataset. Dataset folder must be under training_datasets. Use Browse to select a folder. When training finishes, the LoRA is saved automatically and will appear in Create → LoRA adapter (click Refresh there if needed). + {t('training.description')} {aceReady === false && aceState !== 'downloading' && ( - ACE-Step training model is not downloaded yet. - {aceMessage || 'Click "Download Training Model" to start the download. This is a large download (multiple GB).'} + {t('training.model_not_downloaded')} + {t('training.model_download_hint')} - Download Training Model + {t('training.download_training_model')} )} {aceState === 'downloading' && ( - Downloading ACE-Step model… + {t('training.downloading_model')} = ({ onTracksUpdated: _ - Dataset + {t('training.dataset')} { setDatasetPath(e.target.value); setDatasetFiles(null); }} - placeholder="Name of dataset subfolder" + placeholder={t('training.dataset_placeholder')} className="flex-1 rounded-lg border border-zinc-300 dark:border-zinc-600 bg-white dark:bg-zinc-800 px-3 py-2 text-sm" /> = ({ onTracksUpdated: _ onClick={() => fileInputRef.current?.click()} className="rounded-lg border border-zinc-300 dark:border-zinc-600 px-3 py-2 text-sm hover:bg-zinc-100 dark:hover:bg-zinc-700" > - Browse… + {t('training.browse')} {datasetFiles && ( - {datasetFiles.length} files selected + {t('training.files_selected', { count: datasetFiles.length })} )} - Experiment / adapter name + {t('training.exp_name')} setExpName(e.target.value)} - placeholder="e.g. lofi_chiptunes_v1" + placeholder={t('training.exp_name_placeholder')} className="w-full rounded-lg border border-zinc-300 dark:border-zinc-600 bg-white dark:bg-zinc-800 px-3 py-2 text-sm" /> - LoRA config (JSON) + {t('training.lora_config')} setShowLoraHelp(!showLoraHelp)} className="text-zinc-400 hover:text-zinc-600 dark:hover:text-zinc-300" > @@ -342,11 +353,11 @@ export const TrainingPanel: React.FC = ({ onTracksUpdated: _ {showLoraHelp && ( - Light / Medium / Heavy – How many LoRA parameters (light = subtle, heavy = more VRAM / overfit risk). - base_layers – Main self-attention layers; good for gentle style. - extended_attn – Base + cross-attention; stronger prompt control. - transformer_deep / full_stack – Larger LoRAs; full_stack includes conditioning stack. - default_config.json matches light_base_layers. + {t('training.lora_help_light')} + {t('training.lora_help_base')} + {t('training.lora_help_extended')} + {t('training.lora_help_deep')} + {t('training.lora_help_default')} )} = ({ onTracksUpdated: _ onChange={(e) => setLoraConfigPath(e.target.value)} className="w-full rounded-lg border border-zinc-300 dark:border-zinc-600 bg-white dark:bg-zinc-800 px-3 py-2 text-sm" > - {loadingConfigs ? Loading… : configs.map((c) => ( + {loadingConfigs ? {t('training.loading_configs')} : configs.map((c) => ( {c.label} ))} - Max steps + {t('training.max_steps')} = ({ onTracksUpdated: _ - Max epochs + {t('training.max_epochs')} = ({ onTracksUpdated: _ - Learning rate + {t('training.learning_rate')} = ({ onTracksUpdated: _ - Max clip seconds + {t('training.max_clip_seconds')} = ({ onTracksUpdated: _ - SSL loss weight + {t('training.ssl_loss_weight')} = ({ onTracksUpdated: _ onChange={(e) => setSslCoeff(Number(e.target.value))} className="w-full rounded-lg border border-zinc-300 dark:border-zinc-600 bg-white dark:bg-zinc-800 px-3 py-2 text-sm" /> - Set to 0 for pure instrumental / chiptune. + {t('training.ssl_note')} @@ -426,11 +437,11 @@ export const TrainingPanel: React.FC = ({ onTracksUpdated: _ checked={instrumentalOnly} onChange={(e) => setInstrumentalOnly(e.target.checked)} /> - Instrumental dataset (freeze lyric/speaker layers) + {t('training.instrumental_dataset')} - Save LoRA every N steps + {t('training.save_lora_every')} = ({ onTracksUpdated: _ onClick={() => setShowAdvanced(!showAdvanced)} className="text-sm text-pink-500 hover:underline" > - {showAdvanced ? 'Hide' : 'Show'} advanced trainer settings + {showAdvanced ? t('training.hide_advanced') : t('training.show_advanced')} {showAdvanced && ( <> - Precision + {t('training.precision')} setPrecision(e.target.value)} className="w-full rounded-lg border border-zinc-300 dark:border-zinc-600 bg-white dark:bg-zinc-800 px-3 py-2 text-sm" > - 32-bit (safe default) - 16-mixed (faster, less VRAM) - bf16-mixed (modern GPUs only) + {t('training.precision_32')} + {t('training.precision_16')} + {t('training.precision_bf16')} - Grad accumulation + {t('training.grad_accumulation')} = ({ onTracksUpdated: _ /> - Gradient clip (norm) + {t('training.gradient_clip')} = ({ onTracksUpdated: _ /> - Clip algorithm + {t('training.clip_algorithm')} setGradientClipAlgorithm(e.target.value)} className="w-full rounded-lg border border-zinc-300 dark:border-zinc-600 bg-white dark:bg-zinc-800 px-3 py-2 text-sm" > - norm (recommended) - value + {t('training.clip_norm')} + {t('training.clip_value')} - Reload DataLoader every N epochs + {t('training.reload_dataloader')} = ({ onTracksUpdated: _ /> - Val check interval (batches, optional) + {t('training.val_check_interval')} setValCheckInterval(e.target.value)} - placeholder="blank = default" + placeholder={t('training.val_check_placeholder')} className="w-full rounded-lg border border-zinc-300 dark:border-zinc-600 bg-white dark:bg-zinc-800 px-3 py-2 text-sm" /> - Devices (GPUs) + {t('training.devices')} = ({ onTracksUpdated: _ )} - Status: - {returncode != null && !running && !paused - ? (returncode === 0 - ? (statusText || 'LoRA training finished successfully.') - : (statusText || `Training finished with errors (return code ${returncode}). See trainer.log for details.`)) - : statusText} + {t('training.status')}: + {getStatusDisplay()} {(running || paused) && ( @@ -552,7 +559,7 @@ export const TrainingPanel: React.FC = ({ onTracksUpdated: _ onClick={startModelDownload} className="inline-flex items-center gap-2 rounded-lg border border-amber-500 text-amber-600 dark:text-amber-400 px-4 py-2 text-sm font-medium hover:bg-amber-500/10" > - Download Training Model + {t('training.download_training_model')} )} = ({ onTracksUpdated: _ disabled={!canStartTraining} className="inline-flex items-center gap-2 rounded-lg bg-pink-500 text-white px-4 py-2 text-sm font-medium hover:bg-pink-600 disabled:opacity-50 disabled:cursor-not-allowed" > - Start Training + {t('training.start_training')} {running && ( <> {paused ? ( - Resume + {t('training.resume')} ) : ( - Pause + {t('training.pause')} )} - Cancel + {t('training.cancel')} > )} diff --git a/ui/components/UserProfile.tsx b/ui/components/UserProfile.tsx index 5556df3..bbec383 100644 --- a/ui/components/UserProfile.tsx +++ b/ui/components/UserProfile.tsx @@ -3,6 +3,7 @@ import { Song, Playlist } from '../types'; import { usersApi, getAudioUrl, UserProfile as UserProfileType, songsApi } from '../services/api'; import { useAuth } from '../context/AuthContext'; import { ArrowLeft, Play, Pause, Heart, Eye, Users, Music as MusicIcon, ChevronRight, Share2, MoreHorizontal, Edit3, X, Camera, Image as ImageIcon, Upload, Loader2 } from 'lucide-react'; +import { useTranslation } from 'react-i18next'; interface UserProfileProps { username: string; @@ -17,6 +18,7 @@ interface UserProfileProps { } export const UserProfile: React.FC = ({ username, onBack, onPlaySong, onNavigateToProfile, onNavigateToPlaylist, currentSong, isPlaying, likedSongIds = new Set(), onToggleLike }) => { + const { t } = useTranslation(); const { user: currentUser, token } = useAuth(); const [profileUser, setProfileUser] = useState(null); const [publicSongs, setPublicSongs] = useState([]); @@ -158,7 +160,7 @@ export const UserProfile: React.FC = ({ username, onBack, onPl - Loading profile... + {t('profile.loading')} ); @@ -167,9 +169,9 @@ export const UserProfile: React.FC = ({ username, onBack, onPl if (!profileUser) { return ( - User not found + {t('profile.user_not_found')} - Go Back + {t('profile.go_back')} ); @@ -250,7 +252,7 @@ export const UserProfile: React.FC = ({ username, onBack, onPl className="absolute top-4 left-4 flex items-center gap-2 text-white/80 hover:text-white bg-black/30 hover:bg-black/50 px-4 py-2 rounded-full backdrop-blur-sm transition-all z-20" > - Back + {t('profile.back')} {/* Edit Banner Button (Owner Only) - Visual Cue */} @@ -368,7 +370,7 @@ export const UserProfile: React.FC = ({ username, onBack, onPl )} - Joined {new Date(profileUser.created_at).toLocaleDateString('en-US', { month: 'long', year: 'numeric' })} + {t('profile.joined', { date: new Date(profileUser.created_at).toLocaleDateString('en-US', { month: 'long', year: 'numeric' }) })} @@ -379,7 +381,7 @@ export const UserProfile: React.FC = ({ username, onBack, onPl className="px-4 md:px-6 py-2 bg-zinc-900 dark:bg-white text-white dark:text-black hover:bg-zinc-800 dark:hover:bg-zinc-200 rounded-full font-bold transition-colors text-sm flex items-center gap-2" > - Edit Profile + {t('profile.edit_profile')} )} @@ -389,17 +391,17 @@ export const UserProfile: React.FC = ({ username, onBack, onPl {publicSongs.length} - Songs + {t('profile.songs')} {totalLikes} - Likes + {t('profile.likes')} {totalPlays} - Plays + {t('profile.plays')} @@ -412,7 +414,7 @@ export const UserProfile: React.FC = ({ username, onBack, onPl {/* Featured Songs */} {featuredSongs.length > 0 && ( - Featured Songs + {t('profile.featured_songs')} {featuredSongs.map((song) => { const isCurrentSong = currentSong?.id === song.id; @@ -478,7 +480,7 @@ export const UserProfile: React.FC = ({ username, onBack, onPl {/* Songs Section */} - Songs + {t('profile.songs')} = ({ username, onBack, onPl className={`px-3 md:px-4 py-1.5 md:py-2 rounded-full text-xs md:text-sm font-medium transition-colors ${songsTab === 'recent' ? 'bg-white dark:bg-white text-zinc-900 dark:text-black shadow-sm' : 'text-zinc-600 dark:text-zinc-400 hover:text-zinc-900 dark:hover:text-white' }`} > - Recent + {t('profile.recent')} setSongsTab('top')} className={`px-3 md:px-4 py-1.5 md:py-2 rounded-full text-xs md:text-sm font-medium transition-colors ${songsTab === 'top' ? 'bg-white dark:bg-white text-zinc-900 dark:text-black shadow-sm' : 'text-zinc-600 dark:text-zinc-400 hover:text-zinc-900 dark:hover:text-white' }`} > - Top + {t('profile.top')} @@ -502,7 +504,7 @@ export const UserProfile: React.FC = ({ username, onBack, onPl {displaySongs.length === 0 ? ( - No public songs yet + {t('profile.no_public_songs')} ) : ( @@ -563,9 +565,9 @@ export const UserProfile: React.FC = ({ username, onBack, onPl {publicPlaylists.length > 0 && ( - Playlists + {t('profile.playlists')} - See More + {t('profile.see_more')} @@ -597,7 +599,7 @@ export const UserProfile: React.FC = ({ username, onBack, onPl - Edit Profile + {t('profile.edit_profile_title')} setIsEditModalOpen(false)} className="text-zinc-500 dark:text-zinc-400 hover:text-zinc-900 dark:hover:text-white transition-colors"> @@ -606,7 +608,7 @@ export const UserProfile: React.FC = ({ username, onBack, onPl {/* Avatar Upload */} - Avatar Image + {t('profile.avatar_image')} {(avatarPreview || editAvatarUrl) ? ( @@ -640,16 +642,16 @@ export const UserProfile: React.FC = ({ username, onBack, onPl className="flex items-center gap-2 px-4 py-2 bg-zinc-100 dark:bg-zinc-800 hover:bg-zinc-200 dark:hover:bg-zinc-700 text-zinc-900 dark:text-white rounded-lg text-sm font-medium transition-colors" > - Upload Avatar + {t('profile.upload_avatar')} - JPG, PNG, WebP, GIF • Max 5MB + {t('profile.avatar_help')} {/* Banner Upload */} - Banner Image + {t('profile.banner_image')} bannerInputRef.current?.click()} className="relative w-full h-32 rounded-lg bg-zinc-100 dark:bg-zinc-800 border-2 border-zinc-300 dark:border-zinc-700 border-dashed overflow-hidden cursor-pointer hover:border-zinc-400 dark:hover:border-zinc-600 transition-colors" @@ -663,7 +665,7 @@ export const UserProfile: React.FC = ({ username, onBack, onPl ) : ( - Click to upload banner + {t('profile.banner_help')} )} {uploadingBanner && ( @@ -679,16 +681,16 @@ export const UserProfile: React.FC = ({ username, onBack, onPl onChange={handleBannerChange} className="hidden" /> - Recommended: 1500x500px • JPG, PNG, WebP, GIF • Max 5MB + {t('profile.banner_help_sub')} {/* Bio Input */} - Bio + {t('profile.bio')} setEditBio(e.target.value)} - placeholder="Tell us about yourself..." + placeholder={t('profile.bio_placeholder')} rows={4} className="w-full bg-zinc-50 dark:bg-black border border-zinc-300 dark:border-zinc-800 rounded-lg px-3 py-2 text-zinc-900 dark:text-white placeholder-zinc-400 dark:placeholder-zinc-600 focus:outline-none focus:border-pink-500 dark:focus:border-indigo-500 transition-colors resize-none" /> @@ -707,7 +709,7 @@ export const UserProfile: React.FC = ({ username, onBack, onPl className="px-4 py-2 text-sm font-medium text-zinc-600 dark:text-zinc-300 hover:text-zinc-900 dark:hover:text-white transition-colors" disabled={isSaving} > - Cancel + {t('common.cancel')} = ({ username, onBack, onPl className="px-6 py-2 bg-zinc-900 dark:bg-white text-white dark:text-black hover:bg-zinc-800 dark:hover:bg-zinc-200 rounded-full text-sm font-bold transition-colors disabled:opacity-50 disabled:cursor-not-allowed flex items-center gap-2" > {isSaving && } - {uploadingAvatar ? 'Uploading Avatar...' : uploadingBanner ? 'Uploading Banner...' : isSaving ? 'Saving...' : 'Save Changes'} + {uploadingAvatar ? t('profile.uploading_avatar') : uploadingBanner ? t('profile.uploading_banner') : isSaving ? t('song_details.saving') : t('profile.save_changes')} diff --git a/ui/components/VoiceCloningPanel.tsx b/ui/components/VoiceCloningPanel.tsx index 88ce46a..3fe6da7 100644 --- a/ui/components/VoiceCloningPanel.tsx +++ b/ui/components/VoiceCloningPanel.tsx @@ -1,26 +1,27 @@ import React, { useState, useEffect, useRef } from 'react'; import { Mic, Download, Loader2 } from 'lucide-react'; +import { useTranslation } from 'react-i18next'; import { toolsApi, preferencesApi } from '../services/api'; const MODEL_POLL_MS = 800; -const LANGUAGES = [ - { value: 'en', label: 'English' }, - { value: 'es', label: 'Spanish' }, - { value: 'fr', label: 'French' }, - { value: 'de', label: 'German' }, - { value: 'it', label: 'Italian' }, - { value: 'pt', label: 'Portuguese' }, - { value: 'pl', label: 'Polish' }, - { value: 'tr', label: 'Turkish' }, - { value: 'ru', label: 'Russian' }, - { value: 'nl', label: 'Dutch' }, - { value: 'cs', label: 'Czech' }, - { value: 'ar', label: 'Arabic' }, - { value: 'zh-cn', label: 'Chinese (Simplified)' }, - { value: 'ja', label: 'Japanese' }, - { value: 'hu', label: 'Hungarian' }, - { value: 'ko', label: 'Korean' }, +const LANGUAGE_KEYS = [ + { value: 'en', key: 'lang_en' }, + { value: 'es', key: 'lang_es' }, + { value: 'fr', key: 'lang_fr' }, + { value: 'de', key: 'lang_de' }, + { value: 'it', key: 'lang_it' }, + { value: 'pt', key: 'lang_pt' }, + { value: 'pl', key: 'lang_pl' }, + { value: 'tr', key: 'lang_tr' }, + { value: 'ru', key: 'lang_ru' }, + { value: 'nl', key: 'lang_nl' }, + { value: 'cs', key: 'lang_cs' }, + { value: 'ar', key: 'lang_ar' }, + { value: 'zh-cn', key: 'lang_zh' }, + { value: 'ja', key: 'lang_ja' }, + { value: 'hu', key: 'lang_hu' }, + { value: 'ko', key: 'lang_ko' }, ]; interface VoiceCloningPanelProps { @@ -28,6 +29,7 @@ interface VoiceCloningPanelProps { } export const VoiceCloningPanel: React.FC = ({ onTracksUpdated }) => { + const { t } = useTranslation(); const [text, setText] = useState(''); const [speakerFile, setSpeakerFile] = useState(null); const [outputFilename, setOutputFilename] = useState('voice_clone_output'); @@ -75,14 +77,14 @@ export const VoiceCloningPanel: React.FC = ({ onTracksUp setModelState(r.state || ''); setModelMessage(r.message || ''); }) - .catch(() => {}); + .catch(() => { }); toolsApi.getProgress() .then((p) => { if (p.stage === 'voice_clone_model_download') { setModelDownloadProgress(p.fraction); } }) - .catch(() => {}); + .catch(() => { }); }; poll(); modelPollRef.current = setInterval(poll, MODEL_POLL_MS); @@ -99,14 +101,14 @@ export const VoiceCloningPanel: React.FC = ({ onTracksUp if (v?.device_preference != null) setDevice(String(v.device_preference)); if (v?.output_filename != null) setOutputFilename(String(v.output_filename)); }) - .catch(() => {}); + .catch(() => { }); }, []); const handleDownloadModels = () => { setError(null); toolsApi.voiceCloneModelEnsure().then(() => { setModelState('downloading'); - setModelMessage('Downloading XTTS voice cloning model (first use only). This may take several minutes.'); + setModelMessage(t('voice_cloning.downloading_model')); }).catch((e) => setError(e.message)); }; @@ -116,21 +118,21 @@ export const VoiceCloningPanel: React.FC = ({ onTracksUp setSuccess(null); if (modelReady !== true || modelState === 'downloading') { setError(modelState === 'downloading' - ? 'Please wait for the voice cloning model download to finish.' - : 'Voice cloning model is not ready. Click "Download voice cloning model" first.'); + ? t('voice_cloning.error_wait_download') + : t('voice_cloning.error_model_not_ready')); return; } if (!(text || '').trim()) { - setError('Text to synthesize is required.'); + setError(t('voice_cloning.error_text_required')); return; } if (!speakerFile) { - setError('Reference audio file is required.'); + setError(t('voice_cloning.error_reference_required')); return; } const out = (outputFilename || 'voice_clone_output').trim(); if (!out) { - setError('Output filename is required.'); + setError(t('voice_cloning.error_filename_required')); return; } const formData = new FormData(); @@ -152,7 +154,7 @@ export const VoiceCloningPanel: React.FC = ({ onTracksUp if (prefs.output_dir) formData.set('out_dir', prefs.output_dir); const res = await toolsApi.voiceClone(formData); if (res?.error) { - setError(res.message || 'Voice cloning failed.'); + setError(res.message || t('voice_cloning.error_failed')); setLoading(false); return; } @@ -163,10 +165,10 @@ export const VoiceCloningPanel: React.FC = ({ onTracksUp output_filename: out.endsWith('.mp3') || out.endsWith('.wav') ? out : `${out}.mp3`, }, }); - setSuccess(res?.message || 'Voice cloning completed!'); + setSuccess(res?.message || t('voice_cloning.success_message')); onTracksUpdated?.(); } catch (err) { - setError(err instanceof Error ? err.message : 'Voice cloning failed.'); + setError(err instanceof Error ? err.message : t('voice_cloning.error_failed')); } setLoading(false); }; @@ -175,29 +177,29 @@ export const VoiceCloningPanel: React.FC = ({ onTracksUp - Voice Cloning + {t('voice_cloning.title')} - Clone a voice from a reference audio file using XTTS v2. Upload a reference and enter text to synthesize. + {t('voice_cloning.description')} {modelReady === false && modelState !== 'downloading' && ( - Voice cloning model is not downloaded yet. - {modelMessage || 'Click "Download voice cloning model" to download it (first use only).'} + {t('voice_cloning.model_not_downloaded')} + {t('voice_cloning.model_download_hint')} - Download voice cloning model + {t('voice_cloning.download_model')} )} {modelState === 'downloading' && ( - Downloading XTTS voice cloning model… + {t('voice_cloning.downloading_model')} = ({ onTracksUp - Text to Synthesize + {t('voice_cloning.text_to_synthesize')} setText(e.target.value)} rows={4} - placeholder="Enter the text you want to synthesize in the cloned voice..." + placeholder={t('voice_cloning.text_placeholder')} className="w-full rounded-lg border border-zinc-300 dark:border-zinc-600 bg-white dark:bg-zinc-800 px-3 py-2 text-sm" required /> - Reference Audio File + {t('voice_cloning.reference_audio')} = ({ onTracksUp - Output filename + {t('voice_cloning.output_filename')} = ({ onTracksUp - Language + {t('voice_cloning.language')} setLanguage(e.target.value)} className="w-full rounded-lg border border-zinc-300 dark:border-zinc-600 bg-white dark:bg-zinc-800 px-3 py-2 text-sm" > - {LANGUAGES.map((l) => ( - {l.label} + {LANGUAGE_KEYS.map((l) => ( + {t(`voice_cloning.${l.key}`)} ))} - Device + {t('voice_cloning.device')} setDevice(e.target.value)} className="w-full rounded-lg border border-zinc-300 dark:border-zinc-600 bg-white dark:bg-zinc-800 px-3 py-2 text-sm" > - Auto (MPS if available, else CPU) - Apple Silicon GPU (MPS) - CPU + {t('stem_splitting.device_auto')} + {t('stem_splitting.device_mps')} + {t('stem_splitting.device_cpu')} - Temperature: {temperature} + {t('voice_cloning.temperature')}: {temperature} = ({ onTracksUp - Length Penalty: {lengthPenalty} + {t('voice_cloning.length_penalty')}: {lengthPenalty} = ({ onTracksUp - Repetition Penalty: {repetitionPenalty} + {t('voice_cloning.repetition_penalty')}: {repetitionPenalty} = ({ onTracksUp - Top-K + {t('voice_cloning.top_k')} = ({ onTracksUp - Top-P: {topP} + {t('voice_cloning.top_p')}: {topP} = ({ onTracksUp - Speed: {speed} + {t('voice_cloning.speed')}: {speed} = ({ onTracksUp checked={enableTextSplitting} onChange={(e) => setEnableTextSplitting(e.target.checked)} /> - Enable Text Splitting + {t('voice_cloning.enable_text_splitting')} = ({ onTracksUp className="rounded-lg bg-pink-500 text-white px-4 py-2 text-sm font-medium hover:bg-pink-600 disabled:opacity-50" > {modelState === 'downloading' ? ( - <> Downloading model…> + <> {t('voice_cloning.downloading')}> ) : loading ? ( - 'Cloning…' + t('voice_cloning.cloning') ) : ( - 'Clone Voice' + t('voice_cloning.clone_voice') )} diff --git a/ui/i18n.ts b/ui/i18n.ts new file mode 100644 index 0000000..42f627f --- /dev/null +++ b/ui/i18n.ts @@ -0,0 +1,26 @@ +import i18n from 'i18next'; +import { initReactI18next } from 'react-i18next'; +import LanguageDetector from 'i18next-browser-languagedetector'; + +import en from './locales/en.json'; +import zh from './locales/zh.json'; + +i18n + .use(LanguageDetector) + .use(initReactI18next) + .init({ + resources: { + en: { translation: en }, + zh: { translation: zh }, + }, + fallbackLng: 'en', + interpolation: { + escapeValue: false, + }, + detection: { + order: ['localStorage', 'navigator'], + caches: ['localStorage'], + }, + }); + +export default i18n; diff --git a/ui/index.tsx b/ui/index.tsx index d298113..11459b0 100644 --- a/ui/index.tsx +++ b/ui/index.tsx @@ -1,5 +1,6 @@ import React from 'react'; import ReactDOM from 'react-dom/client'; +import './i18n'; import App from './App'; import { AuthProvider } from './context/AuthContext'; import { ResponsiveProvider } from './context/ResponsiveContext'; diff --git a/ui/locales/en.json b/ui/locales/en.json new file mode 100644 index 0000000..5d05ffc --- /dev/null +++ b/ui/locales/en.json @@ -0,0 +1,574 @@ +{ + "navigation": { + "create": "Create", + "library": "Library", + "search": "Search", + "training": "Training", + "stem_splitting": "Stem Splitting", + "voice_cloning": "Voice Cloning", + "midi": "Audio to MIDI", + "profile": "Profile" + }, + "common": { + "view_list": "View List", + "tools": "Tools", + "settings": "Settings", + "console": "Console", + "theme": "Theme", + "dark": "Dark", + "light": "Light", + "logout": "Logout", + "login": "Login", + "loading": "Loading...", + "success": "Success", + "error": "Error", + "cancel": "Cancel", + "save": "Save", + "delete": "Delete", + "confirm": "Confirm", + "back": "Back" + }, + "sidebar": { + "my_profile": "My Profile" + }, + "settings": { + "title": "Settings", + "language": "Language", + "paths": "Paths", + "models_folder": "Models folder", + "models_folder_desc": "Where ACE-Step and other models are stored. Leave blank for app default. Change takes effect immediately.", + "output_dir": "Output directory", + "output_dir_desc": "Where generated tracks, stems, voice clones, and MIDI are saved. Leave blank for app default.", + "default_placeholder": "Default (app data folder)", + "saved": "Saved", + "save": "Save", + "models": "Models", + "models_desc": "ACE-Step executor (DiT) and planner (LM). See Tutorial for VRAM and quality trade-offs.", + "current_model": "Current ACE-Step model", + "dit_model": "ACE-Step (DiT) model", + "dit_model_desc": "Installed and discovered models in the checkpoints folder. Custom models appear when placed there.", + "lm_planner": "LM planner", + "lm_planner_desc": "Bundled ACE-Step 5Hz LM (no external LLM). Used when \"Thinking\" is on in Create.", + "download_models": "Download models", + "download_models_desc": "Download DiT or LM models into the checkpoints folder. (Bundled in app.)", + "display": "Display", + "ui_zoom": "UI zoom", + "ui_zoom_desc": "Window zoom level. Takes effect on next app launch.", + "appearance": "Appearance", + "about": "About", + "version": "Version", + "powered_by": "Powered by ACE-Step 1.5. Open source and free to use.", + "github_repo": "GitHub Repo", + "report_issues": "Report issues or request features on GitHub", + "account": "Account", + "username": "Username", + "edit_profile": "Edit Profile", + "view_profile": "View Profile", + "member_since": "Member since", + "done": "Done" + }, + "app": { + "confirm_delete_song": "Are you sure you want to delete \"{{title}}\"? This action cannot be undone.", + "song_deleted_success": "Song deleted successfully", + "delete_song_failed": "Failed to delete song", + "playlist_created_success": "Playlist created successfully!", + "playlist_create_failed": "Failed to create playlist", + "song_added_to_playlist": "Song added to playlist", + "add_song_failed": "Failed to add song to playlist", + "playback_failed": "Playback failed", + "song_unavailable": "This song is no longer available.", + "unable_to_play": "Unable to play this song.", + "generation_timed_out": "Generation timed out", + "generation_failed": "Generation failed: {{error}}", + "create_song": "Create Song" + }, + "create": { + "title": "Create", + "modes": { + "simple": "Simple", + "custom": "Custom", + "cover": "Cover", + "lego": "Lego" + }, + "inputs": { + "title": "Title", + "title_placeholder": "Name your song", + "title_optional": "Title (optional)", + "title_placeholder_output": "Name the output", + "genre_preset": "Genre preset", + "custom_genre": "Custom (type below)", + "describe_song": "Describe Your Song", + "describe_placeholder": "e.g. A happy pop song about summer... or use a genre preset above", + "style_of_music": "Style of Music", + "style_placeholder": "e.g. upbeat pop rock, emotional ballad, 90s hip hop — or use a genre preset above", + "lyrics": "Lyrics", + "lyrics_placeholder": "[Verse]\\nYour lyrics here...\\n\\n[Chorus]\\nThe catchy part...", + "lyrics_placeholder_cover": "[Verse]\\nYour lyrics for the cover...\\n\\n[Chorus]\\n...", + "instrumental": "Instrumental", + "vocal_mode": "Vocal", + "vocal_custom": "Vocal (custom lyrics)", + "vocal_language": "Vocal Language", + "exclude_styles": "Exclude styles", + "exclude_placeholder": "e.g. heavy distortion, screaming", + "source_audio": "Source audio", + "source_audio_desc": "Pick a track from your library or upload in the picker (uploads go to the library).", + "cover_style": "Cover style", + "cover_style_placeholder": "e.g. jazz piano cover with swing rhythm", + "source_influence": "Source influence", + "blend_audio": "Optional: Blend with a second audio", + "blend_desc": "Combine the source above with another track: structure and length follow the source; style can follow the second audio.", + "backing_audio": "Backing audio", + "backing_audio_desc": "Pick a track from your library or upload in the picker (uploads go to the library).", + "track_to_generate": "Track to generate", + "describe_track": "Describe the track", + "describe_track_placeholder": "e.g. lead guitar melody with bluesy feel, punchy drums, warm bass line...", + "backing_influence": "Backing influence", + "choose_library": "Choose from library", + "upload": "Upload", + "clear": "Clear", + "audio": "Audio" + }, + "sliders": { + "weirdness": "Weirdness", + "style_influence": "Style influence", + "audio_influence": "Audio influence", + "duration": "Duration", + "bpm": "BPM", + "variations": "Variations", + "batch_size": "Batch Size (Variations)", + "inference_steps": "Inference Steps", + "guidance_scale": "Guidance Scale" + }, + "quick_settings": "Quick Settings", + "advanced_settings": "Advanced Settings", + "expert_controls": "Expert Controls", + "music_parameters": "Music Parameters", + "generation_influence": "Generation influence", + "reference_cover": "Reference & cover (optional)", + "quality": { + "label": "Quality", + "basic": "Basic", + "great": "Great", + "best": "Best" + }, + "generate_button": { + "lego": "Generate Lego track", + "cover": "Generate cover", + "bulk": "Create {{count}} Jobs ({{total}} tracks)", + "create": "Create", + "variations": "({{count}} variations)" + }, + "tooltips": { + "describe_song": "Use a genre preset to fill tags (style, instruments, BPM), or type your own description. Presets use ACE-Step-style comma-separated tags.", + "quality": "Basic: fast, fewer steps. Great: balanced. Best: maximum quality (more steps, higher guidance, LM thinking).", + "exclude_styles": "Things to avoid in the output (e.g. genres, instruments, mood). Added as negative guidance.", + "weirdness": "Higher = more creative/experimental; lower = more predictable and on-prompt.", + "style_influence": "How strongly the style/caption is followed. Higher = closer to your description.", + "audio_influence": "How much the reference/cover audio influences the result. Higher = stronger reference style.", + "source_influence": "How much the output follows the source (1 = strong adherence, lower = more influence from your cover style).", + "backing_influence": "How much the backing audio affects the result. Lower (0.2–0.4) = more new instrument from your description; higher = output closer to the backing.", + "instrumental": "Instrumental: no vocals. Vocal: override the cover with your own lyrics (e.g. [Verse], [Chorus])." + }, + "audio_modal": { + "reference_title": "Reference", + "style_title": "Style audio (blend)", + "cover_title": "Cover", + "reference_desc": "Create songs inspired by a reference track", + "style_desc": "Second audio to blend with the source — style/timbre from this track", + "cover_desc": "Transform an existing track into a new version", + "upload_btn": "Upload audio", + "library_uploads": "Library & uploads", + "no_tracks": "No tracks yet", + "use": "Use" + } + }, + "song_list": { + "workspaces": "Workspaces", + "my_workspace": "My Workspace", + "search_placeholder": "Search your songs...", + "filters": "Filters", + "refine_by": "Refine By", + "filter_liked": "Liked", + "filter_public": "Public", + "filter_private": "Private", + "filter_generating": "Generating", + "no_matches": "No songs match your filters.", + "clear_filters": "Clear filters", + "queued": "Queue #{{pos}}", + "creating": "Creating...", + "creating_percent": "Creating... {{percent}}%", + "queued_text": "Queued...", + "untitled": "Untitled", + "unknown_artist": "Unknown Artist", + "share": "Share", + "create_video": "Create Video", + "add_to_playlist": "Add to Playlist", + "song_details": "Song Details", + "unknown": "Unknown" + }, + "player": { + "select_song": "Select a song to play", + "now_playing": "Now Playing", + "download_audio": "Download Audio" + }, + "library": { + "title": "Your Library", + "new_playlist": "New Playlist", + "tab_liked": "Liked Songs", + "tab_playlists": "Playlists", + "playlist_subtitle": "Playlist", + "song_count": "{{count}} songs", + "by_you": "By You" + }, + "song_details": { + "title": "Song Details", + "select_song": "Select a song to view details", + "rename_failed": "Rename failed", + "title_empty": "Title cannot be empty.", + "saving": "Saving...", + "save": "Save", + "cancel": "Cancel", + "rename": "Rename song", + "created": "Created {{date}}", + "create_video": "Create Video", + "open_editor": "Open in Editor", + "reuse_prompt": "Reuse Prompt", + "extract_stems": "Extract Stems", + "download_audio": "Download Audio", + "style_tags": "Style & Tags", + "copy": "Copy", + "copied": "Copied!", + "copy_tags": "Copy all tags", + "more_tags": "+more", + "lyrics": "Lyrics", + "instrumental": "Instrumental", + "no_lyrics": "No lyrics generated" + }, + "profile": { + "loading": "Loading profile...", + "user_not_found": "User not found", + "go_back": "Go Back", + "edit_profile": "Edit Profile", + "songs": "Songs", + "likes": "Likes", + "plays": "Plays", + "featured_songs": "Featured Songs", + "recent": "Recent", + "top": "Top", + "no_public_songs": "No public songs yet", + "playlists": "Playlists", + "see_more": "See More", + "edit_profile_title": "Edit Profile", + "avatar_image": "Avatar Image", + "upload_avatar": "Upload Avatar", + "avatar_help": "JPG, PNG, WebP, GIF • Max 5MB", + "banner_image": "Banner Image", + "banner_help": "Click to upload banner", + "banner_help_sub": "Recommended: 1500x500px • JPG, PNG, WebP, GIF • Max 5MB", + "bio": "Bio", + "bio_placeholder": "Tell us about yourself...", + "save_changes": "Save Changes", + "uploading_avatar": "Uploading Avatar...", + "uploading_banner": "Uploading Banner...", + "update_failed": "Failed to update profile", + "joined": "Joined {{date}}", + "back": "Back" + }, + "song_profile": { + "loading": "Loading song...", + "not_found": "Song not found", + "back": "Back", + "similar": "Similar", + "by_artist": "By {{artist}}", + "private": "Private", + "lyrics": "Lyrics", + "edit": "Edit" + }, + "training": { + "title": "Train Custom LoRA", + "description": "Run LoRA training on your dataset. Dataset folder must be under training_datasets. Use Browse to select a folder. Once training completes, LoRA is saved automatically and appears in Create → LoRA Adapters (click Refresh if needed).", + "model_not_downloaded": "ACE-Step training model is not downloaded yet.", + "model_download_hint": "Click \"Download Training Model\" to start download. This is a large download (multiple GB).", + "download_training_model": "Download Training Model", + "downloading_model": "Downloading ACE-Step model…", + "dataset": "Dataset", + "dataset_placeholder": "Name of dataset subfolder", + "browse": "Browse…", + "files_selected": "{{count}} files selected", + "exp_name": "Experiment / adapter name", + "exp_name_placeholder": "e.g. lofi_chiptunes_v1", + "lora_config": "LoRA Config (JSON)", + "lora_config_help": "What do these configs do?", + "lora_help_light": "Light / Medium / Heavy – number of LoRA parameters (light = minimal, heavy = more VRAM/overfitting risk).", + "lora_help_base": "base_layers – primary self-attention layers; for light style tweaks.", + "lora_help_extended": "extended_attn – Base + cross-attention; stronger prompt control.", + "lora_help_deep": "transformer_deep / full_stack – larger LoRA; full_stack includes conditioning stack.", + "lora_help_default": "default_config.json corresponds to light_base_layers.", + "loading_configs": "Loading…", + "max_steps": "Max Steps", + "max_epochs": "Max Epochs", + "learning_rate": "Learning Rate", + "max_clip_seconds": "Max Clip Seconds", + "ssl_loss_weight": "SSL Loss Weight", + "ssl_note": "Set to 0 for instrumental/chiptune.", + "instrumental_dataset": "Instrumental dataset (freeze lyrics/speaker layers)", + "save_lora_every": "Save LoRA every N steps", + "show_advanced": "Show Advanced Trainer Settings", + "hide_advanced": "Hide Advanced Trainer Settings", + "precision": "Precision", + "precision_32": "32-bit (safe default)", + "precision_16": "16-mixed (faster, less VRAM)", + "precision_bf16": "bf16-mixed (newer GPUs only)", + "grad_accumulation": "Gradient Accumulation", + "gradient_clip": "Gradient Clip (norm)", + "clip_algorithm": "Clip Algorithm", + "clip_norm": "norm (recommended)", + "clip_value": "value", + "reload_dataloader": "Reload DataLoader every N epochs", + "val_check_interval": "Validation Check Interval (batches, optional)", + "val_check_placeholder": "Leave blank = default", + "devices": "Devices (GPUs)", + "status": "Status", + "idle": "Idle – no training in progress.", + "training_running": "LoRA training running… Check console logs.", + "training_finished": "LoRA training completed successfully.", + "training_error": "Training error (return code {{code}}). See trainer.log for details.", + "start_training": "Start Training", + "resume": "Resume", + "pause": "Pause", + "cancel": "Cancel", + "error_select_dataset": "Please select a dataset folder or enter dataset path.", + "error_start_failed": "Failed to start training." + }, + "stem_splitting": { + "title": "Stem Splitting", + "description": "Split audio into separate stems (vocals, drums, bass, etc.) using Demucs. Upload an audio file and choose the number of stems.", + "model_not_downloaded": "Demucs model is not downloaded yet.", + "model_download_hint": "Click \"Download Demucs models\" to download (first time only).", + "download_demucs": "Download Demucs models", + "downloading_demucs": "Downloading Demucs models…", + "input_audio": "Input Audio File", + "base_filename": "Base filename (optional)", + "base_filename_placeholder": "Output filename prefix", + "stem_count": "Number of Stems", + "stem_2": "2-Stem (Vocals / Instrumental)", + "stem_4": "4-Stem (Vocals, Drums, Bass, Other)", + "stem_6": "6-Stem (Vocals, Drums, Bass, Guitar, Piano, Other)", + "mode": "Mode (2-stem only)", + "mode_standard": "Standard (all stems)", + "mode_vocals": "Vocals only (A capella)", + "mode_instrumental": "Instrumental / Karaoke", + "device": "Device", + "device_auto": "Auto (prefer MPS, else CPU)", + "device_mps": "Apple Silicon GPU (MPS)", + "device_cpu": "CPU", + "export_format": "Export Format", + "format_wav": "WAV (lossless)", + "format_mp3": "MP3 (256kbps)", + "split_stems": "Split Stems", + "splitting": "Splitting…", + "error_select_file": "Please select an input audio file.", + "error_model_not_ready": "Demucs model not ready. Please click Download Demucs models first.", + "error_wait_download": "Please wait for Demucs model download to complete.", + "success_message": "Stems saved to output directory.", + "error_failed": "Stem splitting failed." + }, + "voice_cloning": { + "title": "Voice Cloning", + "description": "Clone a voice from a reference audio file using XTTS v2. Upload a reference audio and enter text to synthesize.", + "model_not_downloaded": "Voice cloning model is not downloaded yet.", + "model_download_hint": "Click \"Download voice cloning model\" to download (first time only).", + "download_model": "Download voice cloning model", + "downloading_model": "Downloading XTTS voice cloning model…", + "text_to_synthesize": "Text to Synthesize", + "text_placeholder": "Enter the text you want to synthesize with the cloned voice...", + "reference_audio": "Reference Audio File", + "output_filename": "Output Filename", + "language": "Language", + "lang_en": "English", + "lang_es": "Spanish", + "lang_fr": "French", + "lang_de": "German", + "lang_it": "Italian", + "lang_pt": "Portuguese", + "lang_pl": "Polish", + "lang_tr": "Turkish", + "lang_ru": "Russian", + "lang_nl": "Dutch", + "lang_cs": "Czech", + "lang_ar": "Arabic", + "lang_zh": "Chinese (Simplified)", + "lang_ja": "Japanese", + "lang_hu": "Hungarian", + "lang_ko": "Korean", + "device": "Device", + "temperature": "Temperature", + "length_penalty": "Length Penalty", + "repetition_penalty": "Repetition Penalty", + "top_k": "Top-K", + "top_p": "Top-P", + "speed": "Speed", + "enable_text_splitting": "Enable text splitting", + "clone_voice": "Clone Voice", + "cloning": "Cloning…", + "downloading": "Downloading model…", + "error_wait_download": "Please wait for voice cloning model download to complete.", + "error_model_not_ready": "Voice cloning model not ready. Please click \"Download voice cloning model\" first.", + "error_text_required": "Text to synthesize is required.", + "error_reference_required": "Reference audio file is required.", + "error_filename_required": "Output filename is required.", + "success_message": "Voice cloning complete!", + "error_failed": "Voice cloning failed." + }, + "midi": { + "title": "Audio to MIDI", + "description": "Convert audio to MIDI using basic-pitch. Upload an audio file and adjust detection parameters.", + "model_not_downloaded": "basic-pitch model is not downloaded yet.", + "model_download_hint": "Click \"Download basic-pitch model\" to download (first time only).", + "download_model": "Download basic-pitch model", + "downloading_model": "Downloading basic-pitch model…", + "input_audio": "Input Audio File", + "output_filename": "Output Filename (without extension)", + "output_placeholder": "output_midi", + "onset_threshold": "Onset Threshold", + "frame_threshold": "Frame Threshold", + "min_note_length": "Minimum Note Length (ms)", + "min_frequency": "Minimum Frequency (Hz, optional)", + "max_frequency": "Maximum Frequency (Hz, optional)", + "frequency_placeholder": "None", + "midi_tempo": "MIDI Tempo (BPM)", + "multiple_pitch_bends": "Allow multiple pitch bends", + "melodia_trick": "Use Melodia postprocessing", + "generate_midi": "Generate MIDI", + "generating": "Generating MIDI…", + "error_filename_required": "Output filename is required.", + "error_select_file": "Please select an input audio file.", + "error_model_not_ready": "basic-pitch model not ready. Please click Download basic-pitch model first.", + "error_wait_download": "Please wait for basic-pitch model download to complete.", + "success_message": "MIDI saved to output directory.", + "error_failed": "MIDI generation failed." + }, + "search": { + "placeholder": "Search for songs, playlists, creators, or genres", + "featured_songs": "Featured Songs", + "songs_matching": "Songs matching \"{{query}}\"", + "no_songs_found": "No songs found matching \"{{query}}\"", + "featured_creators": "Featured Creators", + "creators_matching": "Creators matching \"{{query}}\"", + "no_creators_found": "No creators found matching \"{{query}}\"", + "no_creators_yet": "No creators yet. Be the first to share your music!", + "featured_playlists": "Featured Playlists", + "playlists_matching": "Playlists matching \"{{query}}\"", + "no_playlists_found": "No playlists found matching \"{{query}}\"", + "no_playlists_yet": "No public playlists yet. Create one and share your favorites!", + "genres": "Genres", + "songs_count": "{{count}} songs" + }, + "console": { + "title": "Console", + "connected": "Connected", + "disconnected": "Disconnected", + "connecting": "Connecting...", + "copy_all": "Copy all to clipboard", + "close": "Close", + "log_connected": "[System] Log stream connected.", + "log_connecting": "[Console] Connecting..." + }, + "genres": { + "pop": "Pop", + "rock": "Rock", + "electronic": "Electronic", + "hip_hop": "Hip Hop", + "country": "Country", + "latin": "Latin", + "heavy_metal": "Heavy Metal", + "disco": "Disco", + "kpop": "K-Pop", + "edm": "EDM", + "rnb": "R&B", + "indie": "Indie", + "folk": "Folk", + "funk": "Funk", + "jazz": "Jazz", + "alternative_pop": "Alternative Pop", + "house": "House", + "afrobeats": "Afrobeats", + "reggaeton": "Reggaeton", + "rap": "Rap", + "blues": "Blues", + "gospel": "Gospel", + "reggae": "Reggae", + "synthwave": "Synthwave", + "jpop": "J-Pop", + "punk": "Punk", + "soul": "Soul", + "techno": "Techno", + "classical": "Classical", + "bossa_nova": "Bossa Nova", + "ska": "Ska", + "bluegrass": "Bluegrass", + "indie_surf": "Indie Surf", + "lofi_beats": "Lo-Fi Beats", + "trap": "Trap", + "grunge": "Grunge", + "chillhop": "Chillhop", + "new_wave": "New Wave", + "drum_and_bass": "Drum And Bass", + "acoustic_cover": "Acoustic Cover", + "cinematic_dubstep": "Cinematic Dubstep", + "modern_bollywood": "Modern Bollywood", + "opera": "Opera", + "ambient": "Ambient", + "focus": "Focus", + "a_capella": "A Capella", + "meditation": "Meditation", + "sleep": "Sleep" + }, + "create_extra": { + "style_desc": "Genre, mood, instruments, vibe", + "lyrics_hint": "Leave empty for instrumental, or toggle instrumental mode below", + "no_source_audio": "No source audio selected", + "style_only": "Style only", + "strong_source": "Strong source", + "lego_header": "Generate an instrument track to layer over a backing audio. Choose track type and describe its style.", + "no_backing_audio": "No backing audio selected", + "track_type_guitar": "Guitar", + "track_type_piano": "Piano", + "track_type_drums": "Drums", + "track_type_bass": "Bass", + "background_influence": "Background Influence", + "refresh_library_tooltip": "Refresh library (e.g. after API generation)", + "select_song_details": "Select a song to view details" + }, + "vocal_languages": { + "arabic": "Arabic", + "english": "English", + "spanish": "Spanish", + "french": "French", + "japanese": "Japanese", + "korean": "Korean", + "chinese": "Chinese (Mandarin)", + "german": "German", + "italian": "Italian", + "portuguese": "Portuguese", + "russian": "Russian", + "hindi": "Hindi", + "turkish": "Turkish", + "vietnamese": "Vietnamese", + "thai": "Thai", + "indonesian": "Indonesian", + "dutch": "Dutch", + "polish": "Polish", + "swedish": "Swedish", + "greek": "Greek", + "czech": "Czech", + "romanian": "Romanian", + "hungarian": "Hungarian", + "danish": "Danish", + "finnish": "Finnish", + "norwegian": "Norwegian", + "hebrew": "Hebrew", + "malay": "Malay", + "tagalog": "Tagalog" + } +} \ No newline at end of file diff --git a/ui/locales/zh.json b/ui/locales/zh.json new file mode 100644 index 0000000..2e23759 --- /dev/null +++ b/ui/locales/zh.json @@ -0,0 +1,574 @@ +{ + "navigation": { + "create": "创作", + "library": "曲库", + "search": "搜索", + "training": "模型训练", + "stem_splitting": "音轨分离", + "voice_cloning": "声音克隆", + "midi": "MIDI工具", + "profile": "个人主页" + }, + "common": { + "view_list": "查看列表", + "tools": "工具箱", + "settings": "系统设置", + "console": "控制台", + "theme": "界面主题", + "dark": "深色", + "light": "浅色", + "logout": "退出登录", + "login": "登录", + "loading": "加载中...", + "success": "成功", + "error": "错误", + "cancel": "取消", + "save": "保存", + "delete": "删除", + "confirm": "确认", + "back": "返回" + }, + "sidebar": { + "my_profile": "我的主页" + }, + "settings": { + "title": "系统设置", + "language": "语言选择", + "paths": "路径设置", + "models_folder": "模型文件夹", + "models_folder_desc": "存储 ACE-Step 等模型的位置。留空使用默认路径。修改立即生效。", + "output_dir": "输出目录", + "output_dir_desc": "保存生成的音乐、分轨、声音克隆和 MIDI 文件。留空使用默认路径。", + "default_placeholder": "默认 (应用数据文件夹)", + "saved": "已保存", + "save": "保存", + "models": "模型管理", + "models_desc": "ACE-Step 执行器 (DiT) 和规划器 (LM)。查看教程了解 VRAM 和质量权衡。", + "current_model": "当前 ACE-Step 模型", + "dit_model": "ACE-Step (DiT) 模型", + "dit_model_desc": "checkpoints 文件夹中的已安装和发现的模型。自定义模型也会显示在这里。", + "lm_planner": "LM 规划器", + "lm_planner_desc": "内置 ACE-Step 5Hz LM (无外部 LLM)。在创作模式开启 \"思考\" 时使用。", + "download_models": "下载模型", + "download_models_desc": "下载 DiT 或 LM 模型到 checkpoints 文件夹。(应用内置)", + "display": "显示设置", + "ui_zoom": "界面缩放", + "ui_zoom_desc": "窗口缩放级别。下次启动生效。", + "appearance": "外观主题", + "about": "关于软件", + "version": "版本", + "powered_by": "由 ACE-Step 1.5 驱动。开源且免费使用。", + "github_repo": "GitHub 仓库", + "report_issues": "在 GitHub 上报告问题或请求功能", + "account": "账户信息", + "username": "用户名", + "edit_profile": "编辑资料", + "view_profile": "查看主页", + "member_since": "注册时间", + "done": "完成" + }, + "app": { + "confirm_delete_song": "确定要删除 \"{{title}}\" 吗?此操作无法撤销。", + "song_deleted_success": "歌曲删除成功", + "delete_song_failed": "删除歌曲失败", + "playlist_created_success": "歌单创建成功!", + "playlist_create_failed": "创建歌单失败", + "song_added_to_playlist": "歌曲已添加到歌单", + "add_song_failed": "添加到歌单失败", + "playback_failed": "播放失败", + "song_unavailable": "该歌曲已不可用。", + "unable_to_play": "无法播放此歌曲。", + "generation_timed_out": "生成超时", + "generation_failed": "生成失败: {{error}}", + "create_song": "创作歌曲" + }, + "create": { + "title": "创作", + "modes": { + "simple": "简单模式", + "custom": "自定义", + "cover": "翻唱", + "lego": "Lego (分轨)" + }, + "inputs": { + "title": "标题", + "title_placeholder": "给你的歌起个名", + "title_optional": "标题 (可选)", + "title_placeholder_output": "输出文件名", + "genre_preset": "风格预设", + "custom_genre": "自定义 (下方输入)", + "describe_song": "描述你的歌曲", + "describe_placeholder": "例如:一首关于夏天的欢快流行歌... 或使用上方预设", + "style_of_music": "音乐风格", + "style_placeholder": "例如:欢快流行摇滚、伤感民谣、90年代嘻哈... 或使用上方预设", + "lyrics": "歌词", + "lyrics_placeholder": "[Verse]\\n在这里填入歌词...\\n\\n[Chorus]\\n副歌部分...", + "lyrics_placeholder_cover": "[Verse]\\n填入翻唱歌词...\\n\\n[Chorus]\\n...", + "instrumental": "纯音乐", + "vocal_mode": "人声", + "vocal_custom": "人声 (自定义歌词)", + "vocal_language": "人声语言", + "exclude_styles": "排除风格", + "exclude_placeholder": "例如:重失真、尖叫声", + "source_audio": "源音频", + "source_audio_desc": "从库中选择或上传 (上传后保存至库)。", + "cover_style": "翻唱风格", + "cover_style_placeholder": "例如:带摇摆节奏的爵士钢琴翻唱", + "source_influence": "原曲影响", + "blend_audio": "可选:混合第二音频", + "blend_desc": "将上述源音频与另一首曲目结合:结构和长度跟随源音频;风格可跟随第二音频。", + "backing_audio": "背景音频", + "backing_audio_desc": "从库中选择或上传。", + "track_to_generate": "生成轨道类型", + "describe_track": "描述轨道", + "describe_track_placeholder": "例如:带蓝调感觉的主音吉他旋律,有力度的鼓点...", + "backing_influence": "背景影响", + "choose_library": "从库中选择", + "upload": "上传", + "clear": "清除", + "audio": "音频" + }, + "sliders": { + "weirdness": "怪诞度", + "style_influence": "风格影响", + "audio_influence": "音频影响", + "duration": "时长", + "bpm": "BPM (速度)", + "variations": "变体数量", + "batch_size": "批次大小 (变体)", + "inference_steps": "推理解步数", + "guidance_scale": "引导系数" + }, + "quick_settings": "快速设置", + "advanced_settings": "高级设置", + "expert_controls": "专家控制", + "music_parameters": "音乐参数", + "generation_influence": "生成影响因子", + "reference_cover": "参考与翻唱 (可选)", + "quality": { + "label": "质量", + "basic": "基础", + "great": "优质", + "best": "最佳" + }, + "generate_button": { + "lego": "生成 Lego 轨道", + "cover": "生成翻唱", + "bulk": "创建 {{count}} 个任务 (共 {{total}} 首)", + "create": "开始创作", + "variations": "({{count}} 个变体)" + }, + "tooltips": { + "describe_song": "使用风格预设自动填充标签 (风格, 乐器, BPM),或输入自定义描述。", + "quality": "基础: 快速, 步数少。优质: 平衡。最佳: 最高质量 (更多步数, 更高引导, LM 思考)。", + "exclude_styles": "在结果中避免出现的元素 (如流派, 乐器, 情绪)。作为负面引导添加。", + "weirdness": "越高 = 越有创意/实验性;越低 = 越可预测且贴合提示词。", + "style_influence": "风格/描述的遵循程度。越高 = 越接近你的描述。", + "audio_influence": "参考/翻唱音频对结果的影响程度。越高 = 参考风格越强。", + "source_influence": "输出对源音频的遵循程度 (1 = 强遵循, 较低 = 更多受你的风格和歌词影响)。", + "backing_influence": "背景音频对结果的影响。较低 (0.2–0.4) = 更多基于描述的新乐器;较高 = 输出接近背景音频。", + "instrumental": "纯音乐: 无人声。人声: 用自定义歌词覆盖 (例如 [Verse], [Chorus])。" + }, + "audio_modal": { + "reference_title": "参考音频", + "style_title": "风格音频 (混合)", + "cover_title": "翻唱源音频", + "reference_desc": "创作受参考曲目启发的歌曲", + "style_desc": "与源音频混合的第二音频 — 取其风格/音色", + "cover_desc": "将现有曲目转换为新版本", + "upload_btn": "上传音频", + "library_uploads": "库与上传", + "no_tracks": "暂无曲目", + "use": "使用" + } + }, + "song_list": { + "workspaces": "工作区", + "my_workspace": "我的空间", + "search_placeholder": "搜索你的歌曲...", + "filters": "筛选", + "refine_by": "筛选条件", + "filter_liked": "已赞", + "filter_public": "公开", + "filter_private": "私有", + "filter_generating": "生成中", + "no_matches": "没有匹配的歌曲。", + "clear_filters": "清除筛选", + "queued": "排队中 #{{pos}}", + "creating": "创作中...", + "creating_percent": "创作中... {{percent}}%", + "queued_text": "已排队...", + "untitled": "无标题", + "unknown_artist": "未知艺术家", + "share": "分享", + "create_video": "生成视频", + "add_to_playlist": "加入歌单", + "song_details": "歌曲详情", + "unknown": "未知" + }, + "player": { + "select_song": "选择一首歌曲播放", + "now_playing": "正在播放", + "download_audio": "下载音频" + }, + "library": { + "title": "我的音乐库", + "new_playlist": "新建歌单", + "tab_liked": "已赞歌曲", + "tab_playlists": "歌单列表", + "playlist_subtitle": "歌单", + "song_count": "{{count}} 首歌曲", + "by_you": "创建者:你" + }, + "song_details": { + "title": "歌曲详情", + "select_song": "选择一首歌曲查看详情", + "rename_failed": "重命名失败", + "title_empty": "标题不能为空。", + "saving": "保存中...", + "save": "保存", + "cancel": "取消", + "rename": "重命名歌曲", + "created": "创建于 {{date}}", + "create_video": "生成视频", + "open_editor": "在编辑器打开", + "reuse_prompt": "复用提示词", + "extract_stems": "提取分轨", + "download_audio": "下载音频", + "style_tags": "风格与标签", + "copy": "复制", + "copied": "已复制!", + "copy_tags": "复制所有标签", + "more_tags": "+更多", + "lyrics": "歌词", + "instrumental": "纯音乐", + "no_lyrics": "未生成歌词" + }, + "profile": { + "loading": "加载资料中...", + "user_not_found": "用户不存在", + "go_back": "返回", + "edit_profile": "编辑资料", + "songs": "歌曲", + "likes": "获赞", + "plays": "播放", + "featured_songs": "精选歌曲", + "recent": "最近", + "top": "热门", + "no_public_songs": "暂无公开歌曲", + "playlists": "歌单", + "see_more": "查看更多", + "edit_profile_title": "编辑资料", + "avatar_image": "头像", + "upload_avatar": "上传头像", + "avatar_help": "JPG, PNG, WebP, GIF • 最大 5MB", + "banner_image": "背景图", + "banner_help": "点击上传背景图", + "banner_help_sub": "推荐尺寸: 1500x500px • JPG, PNG, WebP, GIF • 最大 5MB", + "bio": "简介", + "bio_placeholder": "介绍一下你自己...", + "save_changes": "保存修改", + "uploading_avatar": "正在上传头像...", + "uploading_banner": "正在上传背景图...", + "update_failed": "更新资料失败", + "joined": "加入于 {{date}}", + "back": "返回" + }, + "song_profile": { + "loading": "加载歌曲中...", + "not_found": "未找到歌曲", + "back": "返回", + "similar": "相似歌曲", + "by_artist": "来自 {{artist}}", + "private": "私有", + "lyrics": "歌词", + "edit": "编辑" + }, + "training": { + "title": "训练自定义 LoRA", + "description": "在你的数据集上运行 LoRA 训练。数据集文件夹必须位于 training_datasets 下。使用浏览按钮选择文件夹。训练完成后,LoRA 会自动保存并显示在 创作 → LoRA 适配器 中(如需要请点击刷新)。", + "model_not_downloaded": "ACE-Step 训练模型尚未下载。", + "model_download_hint": "点击\"下载训练模型\"开始下载。这是一个较大的下载(多个 GB)。", + "download_training_model": "下载训练模型", + "downloading_model": "正在下载 ACE-Step 模型…", + "dataset": "数据集", + "dataset_placeholder": "数据集子文件夹名称", + "browse": "浏览…", + "files_selected": "已选择 {{count}} 个文件", + "exp_name": "实验/适配器名称", + "exp_name_placeholder": "例如:lofi_chiptunes_v1", + "lora_config": "LoRA 配置 (JSON)", + "lora_config_help": "这些配置有什么作用?", + "lora_help_light": "Light / Medium / Heavy – LoRA 参数数量(light = 轻微,heavy = 更多 VRAM/过拟合风险)。", + "lora_help_base": "base_layers – 主自注意力层;适合轻微风格调整。", + "lora_help_extended": "extended_attn – Base + 交叉注意力;更强的提示词控制。", + "lora_help_deep": "transformer_deep / full_stack – 更大的 LoRA;full_stack 包含条件堆栈。", + "lora_help_default": "default_config.json 对应 light_base_layers。", + "loading_configs": "加载中…", + "max_steps": "最大步数", + "max_epochs": "最大轮次", + "learning_rate": "学习率", + "max_clip_seconds": "最大片段秒数", + "ssl_loss_weight": "SSL 损失权重", + "ssl_note": "纯音乐/芯片音乐设为 0。", + "instrumental_dataset": "纯音乐数据集(冻结歌词/说话人层)", + "save_lora_every": "每 N 步保存 LoRA", + "show_advanced": "显示高级训练器设置", + "hide_advanced": "隐藏高级训练器设置", + "precision": "精度", + "precision_32": "32位(安全默认)", + "precision_16": "16-mixed(更快,更少 VRAM)", + "precision_bf16": "bf16-mixed(仅限新款 GPU)", + "grad_accumulation": "梯度累积", + "gradient_clip": "梯度裁剪(范数)", + "clip_algorithm": "裁剪算法", + "clip_norm": "norm(推荐)", + "clip_value": "value", + "reload_dataloader": "每 N 轮重载 DataLoader", + "val_check_interval": "验证检查间隔(批次,可选)", + "val_check_placeholder": "留空 = 默认", + "devices": "设备(GPU)", + "status": "状态", + "idle": "空闲 – 无训练进行中。", + "training_running": "LoRA 训练运行中… 请查看控制台日志。", + "training_finished": "LoRA 训练成功完成。", + "training_error": "训练出错(返回码 {{code}})。详情请查看 trainer.log。", + "start_training": "开始训练", + "resume": "恢复", + "pause": "暂停", + "cancel": "取消", + "error_select_dataset": "请选择数据集文件夹或输入数据集路径。", + "error_start_failed": "启动训练失败。" + }, + "stem_splitting": { + "title": "音轨分离", + "description": "使用 Demucs 将音频分离为独立音轨(人声、鼓、贝斯等)。上传音频文件并选择音轨数量。", + "model_not_downloaded": "Demucs 模型尚未下载。", + "model_download_hint": "点击\"下载 Demucs 模型\"进行下载(仅首次使用)。", + "download_demucs": "下载 Demucs 模型", + "downloading_demucs": "正在下载 Demucs 模型…", + "input_audio": "输入音频文件", + "base_filename": "基础文件名(可选)", + "base_filename_placeholder": "输出文件名前缀", + "stem_count": "音轨数量", + "stem_2": "2 音轨(人声 / 伴奏)", + "stem_4": "4 音轨(人声、鼓、贝斯、其他)", + "stem_6": "6 音轨(人声、鼓、贝斯、吉他、钢琴、其他)", + "mode": "模式(仅2音轨)", + "mode_standard": "标准(全部音轨)", + "mode_vocals": "清唱(仅人声)", + "mode_instrumental": "伴奏 / 卡拉OK", + "device": "设备", + "device_auto": "自动(优先 MPS,否则 CPU)", + "device_mps": "Apple Silicon GPU (MPS)", + "device_cpu": "CPU", + "export_format": "导出格式", + "format_wav": "WAV(无损)", + "format_mp3": "MP3(256kbps)", + "split_stems": "分离音轨", + "splitting": "分离中…", + "error_select_file": "请选择输入音频文件。", + "error_model_not_ready": "Demucs 模型未就绪。请先点击下载 Demucs 模型。", + "error_wait_download": "请等待 Demucs 模型下载完成。", + "success_message": "音轨已保存到输出目录。", + "error_failed": "音轨分离失败。" + }, + "voice_cloning": { + "title": "声音克隆", + "description": "使用 XTTS v2 从参考音频克隆声音。上传参考音频并输入要合成的文本。", + "model_not_downloaded": "声音克隆模型尚未下载。", + "model_download_hint": "点击\"下载声音克隆模型\"进行下载(仅首次使用)。", + "download_model": "下载声音克隆模型", + "downloading_model": "正在下载 XTTS 声音克隆模型…", + "text_to_synthesize": "待合成文本", + "text_placeholder": "输入你想用克隆声音合成的文本...", + "reference_audio": "参考音频文件", + "output_filename": "输出文件名", + "language": "语言", + "lang_en": "英语", + "lang_es": "西班牙语", + "lang_fr": "法语", + "lang_de": "德语", + "lang_it": "意大利语", + "lang_pt": "葡萄牙语", + "lang_pl": "波兰语", + "lang_tr": "土耳其语", + "lang_ru": "俄语", + "lang_nl": "荷兰语", + "lang_cs": "捷克语", + "lang_ar": "阿拉伯语", + "lang_zh": "中文(简体)", + "lang_ja": "日语", + "lang_hu": "匈牙利语", + "lang_ko": "韩语", + "device": "设备", + "temperature": "温度", + "length_penalty": "长度惩罚", + "repetition_penalty": "重复惩罚", + "top_k": "Top-K", + "top_p": "Top-P", + "speed": "速度", + "enable_text_splitting": "启用文本分割", + "clone_voice": "克隆声音", + "cloning": "克隆中…", + "downloading": "下载模型中…", + "error_wait_download": "请等待声音克隆模型下载完成。", + "error_model_not_ready": "声音克隆模型未就绪。请先点击\"下载声音克隆模型\"。", + "error_text_required": "待合成文本为必填项。", + "error_reference_required": "参考音频文件为必填项。", + "error_filename_required": "输出文件名为必填项。", + "success_message": "声音克隆完成!", + "error_failed": "声音克隆失败。" + }, + "midi": { + "title": "音频转 MIDI", + "description": "使用 basic-pitch 将音频转换为 MIDI。上传音频文件并调整检测参数。", + "model_not_downloaded": "basic-pitch 模型尚未下载。", + "model_download_hint": "点击\"下载 basic-pitch 模型\"进行下载(仅首次使用)。", + "download_model": "下载 basic-pitch 模型", + "downloading_model": "正在下载 basic-pitch 模型…", + "input_audio": "输入音频文件", + "output_filename": "输出文件名(不含扩展名)", + "output_placeholder": "output_midi", + "onset_threshold": "起音阈值", + "frame_threshold": "帧阈值", + "min_note_length": "最小音符长度 (ms)", + "min_frequency": "最小频率 (Hz,可选)", + "max_frequency": "最大频率 (Hz,可选)", + "frequency_placeholder": "无", + "midi_tempo": "MIDI 速度 (BPM)", + "multiple_pitch_bends": "允许多重弯音", + "melodia_trick": "使用 Melodia 后处理", + "generate_midi": "生成 MIDI", + "generating": "生成 MIDI 中…", + "error_filename_required": "输出文件名为必填项。", + "error_select_file": "请选择输入音频文件。", + "error_model_not_ready": "basic-pitch 模型未就绪。请先点击下载 basic-pitch 模型。", + "error_wait_download": "请等待 basic-pitch 模型下载完成。", + "success_message": "MIDI 已保存到输出目录。", + "error_failed": "MIDI 生成失败。" + }, + "search": { + "placeholder": "搜索歌曲、歌单、创作者或风格", + "featured_songs": "精选歌曲", + "songs_matching": "匹配\"{{query}}\"的歌曲", + "no_songs_found": "未找到匹配\"{{query}}\"的歌曲", + "featured_creators": "精选创作者", + "creators_matching": "匹配\"{{query}}\"的创作者", + "no_creators_found": "未找到匹配\"{{query}}\"的创作者", + "no_creators_yet": "还没有创作者。成为第一个分享音乐的人!", + "featured_playlists": "精选歌单", + "playlists_matching": "匹配\"{{query}}\"的歌单", + "no_playlists_found": "未找到匹配\"{{query}}\"的歌单", + "no_playlists_yet": "还没有公开歌单。创建一个分享你的收藏!", + "genres": "音乐风格", + "songs_count": "{{count}} 首歌曲" + }, + "console": { + "title": "控制台", + "connected": "已连接", + "disconnected": "未连接", + "connecting": "连接中...", + "copy_all": "复制全部到剪贴板", + "close": "关闭", + "log_connected": "[系统] 日志流已连接。", + "log_connecting": "[控制台] 连接中..." + }, + "genres": { + "pop": "流行", + "rock": "摇滚", + "electronic": "电子", + "hip_hop": "嘻哈", + "country": "乡村", + "latin": "拉丁", + "heavy_metal": "重金属", + "disco": "迪斯科", + "kpop": "K-Pop", + "edm": "EDM", + "rnb": "R&B", + "indie": "独立", + "folk": "民谣", + "funk": "放克", + "jazz": "爵士", + "alternative_pop": "另类流行", + "house": "浩室", + "afrobeats": "非洲节拍", + "reggaeton": "雷鬼顿", + "rap": "说唱", + "blues": "蓝调", + "gospel": "福音", + "reggae": "雷鬼", + "synthwave": "合成波", + "jpop": "J-Pop", + "punk": "朋克", + "soul": "灵魂乐", + "techno": "科技舞曲", + "classical": "古典", + "bossa_nova": "波萨诺瓦", + "ska": "斯卡", + "bluegrass": "蓝草", + "indie_surf": "独立冲浪", + "lofi_beats": "Lo-Fi 节拍", + "trap": "陷阱音乐", + "grunge": "垃圾摇滚", + "chillhop": "Chillhop", + "new_wave": "新浪潮", + "drum_and_bass": "鼓打贝斯", + "acoustic_cover": "原声翻唱", + "cinematic_dubstep": "电影配乐风回响贝斯", + "modern_bollywood": "现代宝莱坞", + "opera": "歌剧", + "ambient": "氛围音乐", + "focus": "专注音乐", + "a_capella": "无伴奏合唱", + "meditation": "冥想音乐", + "sleep": "睡眠音乐" + }, + "create_extra": { + "style_desc": "风格、情绪、乐器、氛围", + "lyrics_hint": "留空为纯音乐,或切换至下方纯音乐模式", + "no_source_audio": "未选择源音频", + "style_only": "仅风格", + "strong_source": "强原曲", + "lego_header": "生成一条乐器音轨叠加到背景音频上。选择音轨类型并描述它的风格。", + "no_backing_audio": "未选择背景音频", + "track_type_guitar": "吉他", + "track_type_piano": "钢琴", + "track_type_drums": "鼓", + "track_type_bass": "贝斯", + "background_influence": "背景影响", + "refresh_library_tooltip": "刷新曲库(例如 API 生成后)", + "select_song_details": "选择一首歌曲查看详情" + }, + "vocal_languages": { + "arabic": "阿拉伯语", + "english": "英语", + "spanish": "西班牙语", + "french": "法语", + "japanese": "日语", + "korean": "韩语", + "chinese": "中文(普通话)", + "german": "德语", + "italian": "意大利语", + "portuguese": "葡萄牙语", + "russian": "俄语", + "hindi": "印地语", + "turkish": "土耳其语", + "vietnamese": "越南语", + "thai": "泰语", + "indonesian": "印尼语", + "dutch": "荷兰语", + "polish": "波兰语", + "swedish": "瑞典语", + "greek": "希腊语", + "czech": "捷克语", + "romanian": "罗马尼亚语", + "hungarian": "匈牙利语", + "danish": "丹麦语", + "finnish": "芬兰语", + "norwegian": "挪威语", + "hebrew": "希伯来语", + "malay": "马来语", + "tagalog": "他加禄语" + } +} \ No newline at end of file diff --git a/ui/package-lock.json b/ui/package-lock.json index 30adeaa..17fb4a1 100644 --- a/ui/package-lock.json +++ b/ui/package-lock.json @@ -11,9 +11,12 @@ "@ffmpeg/ffmpeg": "^0.12.15", "@ffmpeg/util": "^0.12.2", "@google/genai": "^1.38.0", + "i18next": "^25.8.4", + "i18next-browser-languagedetector": "^8.2.0", "lucide-react": "^0.563.0", "react": "^19.2.4", - "react-dom": "^19.2.4" + "react-dom": "^19.2.4", + "react-i18next": "^16.5.4" }, "devDependencies": { "@types/node": "^22.14.0", @@ -53,7 +56,6 @@ "integrity": "sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@babel/code-frame": "^7.29.0", "@babel/generator": "^7.29.0", @@ -257,6 +259,15 @@ "@babel/core": "^7.0.0-0" } }, + "node_modules/@babel/runtime": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.28.6.tgz", + "integrity": "sha512-05WQkdpL9COIMz4LjTxGpPNCdlpyimKppYNoJ5Di5EUObifl8t4tuLuUBBZEpoLYOmfvIWrsp9fCl0HoPRVTdA==", + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, "node_modules/@babel/template": { "version": "7.28.6", "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.28.6.tgz", @@ -1486,7 +1497,6 @@ } ], "license": "MIT", - "peer": true, "dependencies": { "baseline-browser-mapping": "^2.9.0", "caniuse-lite": "^1.0.30001759", @@ -1863,6 +1873,15 @@ "node": ">=18" } }, + "node_modules/html-parse-stringify": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/html-parse-stringify/-/html-parse-stringify-3.0.1.tgz", + "integrity": "sha512-KknJ50kTInJ7qIScF3jeaFRpMpE8/lfiTdzf/twXyPBLAGrLRTmkz3AdTnKeh40X8k9L2fdYwEp/42WGXIRGcg==", + "license": "MIT", + "dependencies": { + "void-elements": "3.1.0" + } + }, "node_modules/https-proxy-agent": { "version": "7.0.6", "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-7.0.6.tgz", @@ -1876,6 +1895,46 @@ "node": ">= 14" } }, + "node_modules/i18next": { + "version": "25.8.4", + "resolved": "https://registry.npmjs.org/i18next/-/i18next-25.8.4.tgz", + "integrity": "sha512-a9A0MnUjKvzjEN/26ZY1okpra9kA8MEwzYEz1BNm+IyxUKPRH6ihf0p7vj8YvULwZHKHl3zkJ6KOt4hewxBecQ==", + "funding": [ + { + "type": "individual", + "url": "https://locize.com" + }, + { + "type": "individual", + "url": "https://locize.com/i18next.html" + }, + { + "type": "individual", + "url": "https://www.i18next.com/how-to/faq#i18next-is-awesome.-how-can-i-support-the-project" + } + ], + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.28.4" + }, + "peerDependencies": { + "typescript": "^5" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/i18next-browser-languagedetector": { + "version": "8.2.0", + "resolved": "https://registry.npmjs.org/i18next-browser-languagedetector/-/i18next-browser-languagedetector-8.2.0.tgz", + "integrity": "sha512-P+3zEKLnOF0qmiesW383vsLdtQVyKtCNA9cjSoKCppTKPQVfKd2W8hbVo5ZhNJKDqeM7BOcvNoKJOjpHh4Js9g==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.23.2" + } + }, "node_modules/is-fullwidth-code-point": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", @@ -2138,7 +2197,6 @@ "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", "dev": true, "license": "MIT", - "peer": true, "engines": { "node": ">=12" }, @@ -2204,7 +2262,6 @@ "resolved": "https://registry.npmjs.org/react/-/react-19.2.4.tgz", "integrity": "sha512-9nfp2hYpCwOjAN+8TZFGhtWEwgvWHXqESH8qT89AT/lWklpLON22Lc8pEtnpsZz7VmawabSU0gCjnj8aC0euHQ==", "license": "MIT", - "peer": true, "engines": { "node": ">=0.10.0" } @@ -2221,6 +2278,33 @@ "react": "^19.2.4" } }, + "node_modules/react-i18next": { + "version": "16.5.4", + "resolved": "https://registry.npmjs.org/react-i18next/-/react-i18next-16.5.4.tgz", + "integrity": "sha512-6yj+dcfMncEC21QPhOTsW8mOSO+pzFmT6uvU7XXdvM/Cp38zJkmTeMeKmTrmCMD5ToT79FmiE/mRWiYWcJYW4g==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.28.4", + "html-parse-stringify": "^3.0.1", + "use-sync-external-store": "^1.6.0" + }, + "peerDependencies": { + "i18next": ">= 25.6.2", + "react": ">= 16.8.0", + "typescript": "^5" + }, + "peerDependenciesMeta": { + "react-dom": { + "optional": true + }, + "react-native": { + "optional": true + }, + "typescript": { + "optional": true + } + } + }, "node_modules/react-refresh": { "version": "0.18.0", "resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.18.0.tgz", @@ -2487,7 +2571,7 @@ "version": "5.8.3", "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.8.3.tgz", "integrity": "sha512-p1diW6TqL9L07nNxvRMM7hMMw4c5XOo/1ibL4aAIGmSAt9slTE1Xgw5KWuof2uTOvCg9BY7ZRi+GaF+7sfgPeQ==", - "dev": true, + "devOptional": true, "license": "Apache-2.0", "bin": { "tsc": "bin/tsc", @@ -2534,13 +2618,21 @@ "browserslist": ">= 4.21.0" } }, + "node_modules/use-sync-external-store": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/use-sync-external-store/-/use-sync-external-store-1.6.0.tgz", + "integrity": "sha512-Pp6GSwGP/NrPIrxVFAIkOQeyw8lFenOHijQWkUTrDvrF4ALqylP2C/KCkeS9dpUM3KvYRQhna5vt7IL95+ZQ9w==", + "license": "MIT", + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, "node_modules/vite": { "version": "6.4.1", "resolved": "https://registry.npmjs.org/vite/-/vite-6.4.1.tgz", "integrity": "sha512-+Oxm7q9hDoLMyJOYfUYBuHQo+dkAloi33apOPP56pzj+vsdJDzr+j1NISE5pyaAuKL4A3UD34qd0lx5+kfKp2g==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "esbuild": "^0.25.0", "fdir": "^6.4.4", @@ -2610,6 +2702,15 @@ } } }, + "node_modules/void-elements": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/void-elements/-/void-elements-3.1.0.tgz", + "integrity": "sha512-Dhxzh5HZuiHQhbvTW9AMetFfBHDMYpo23Uo9btPXgdYP+3T5S+p+jgNy7spra+veYhBP2dCSgxR/i2Y02h5/6w==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/web-streams-polyfill": { "version": "3.3.3", "resolved": "https://registry.npmjs.org/web-streams-polyfill/-/web-streams-polyfill-3.3.3.tgz", diff --git a/ui/package.json b/ui/package.json index 59fee44..6aaa088 100644 --- a/ui/package.json +++ b/ui/package.json @@ -13,9 +13,12 @@ "@ffmpeg/ffmpeg": "^0.12.15", "@ffmpeg/util": "^0.12.2", "@google/genai": "^1.38.0", + "i18next": "^25.8.4", + "i18next-browser-languagedetector": "^8.2.0", "lucide-react": "^0.563.0", "react": "^19.2.4", - "react-dom": "^19.2.4" + "react-dom": "^19.2.4", + "react-i18next": "^16.5.4" }, "devDependencies": { "@types/node": "^22.14.0",
Fine-tune how the model follows your description and reference (if any).
Use a style reference or a song to cover. Leave empty to generate from your description only.
- Generate a new version of your source audio in a different style. One source + one style description (e.g. "jazz piano cover with swing rhythm"). No semantic blending — pure cover task. + {t('create.audio_modal.cover_desc')}
Pick a track from your library or upload in the picker (uploads go to the library).
{t('create.inputs.source_audio_desc')}
{getAudioLabel(sourceAudioUrl)}
- Combine the source above with another track: structure and length follow the source; style can follow the second audio. + {t('create.inputs.blend_desc')}
Pick from library or upload in the picker (uploads go to the library).
{getAudioLabel(coverStyleAudioUrl)}
{t('create.inputs.backing_audio_desc')}
Genre, mood, instruments, vibe
Leave empty for instrumental or switch to Instrumental below
Reference: {getAudioLabel(referenceAudioUrl)} @@ -2013,11 +2015,11 @@ export const CreatePanel: React.FC = ({ onGenerate, isGenerati openAudioModal('reference')} className="flex items-center justify-center gap-1.5 rounded-lg px-3 py-2 text-[11px] font-medium bg-zinc-100 dark:bg-white/5 text-zinc-600 dark:text-zinc-300 hover:bg-zinc-200 dark:hover:bg-white/10 transition-colors"> - Choose from Library + {t('create.inputs.choose_library')} openAudioModal('reference')} className="flex items-center justify-center gap-1.5 rounded-lg px-3 py-2 text-[11px] font-medium bg-zinc-100 dark:bg-white/5 text-zinc-600 dark:text-zinc-300 hover:bg-zinc-200 dark:hover:bg-white/10 transition-colors"> - Upload + {t('create.inputs.upload')}
{audioModalTarget === 'reference' - ? 'Create songs inspired by a reference track' + ? t('create.audio_modal.reference_desc') : audioModalTarget === 'cover_style' - ? 'Second audio to blend with the source — style/timbre from this track' - : 'Transform an existing track into a new version'} + ? t('create.audio_modal.style_desc') + : t('create.audio_modal.cover_desc')}
Loading library...
{t('common.loading')}
- {referenceTracks.length === 0 ? 'No tracks yet' : `No tracks with tag “${libraryTagFilter}”`} + {referenceTracks.length === 0 ? t('create.audio_modal.no_tracks') : `No tracks with tag “${libraryTagFilter}”`}
{referenceTracks.length === 0 ? 'Upload audio or generate tracks to see them here' : 'Try “All” or another tag'} @@ -3064,7 +3066,7 @@ export const CreatePanel: React.FC = ({ onGenerate, isGenerati onClick={() => useReferenceTrack(track)} className="px-3 py-1.5 rounded-lg bg-zinc-900 dark:bg-white text-white dark:text-zinc-900 text-xs font-semibold hover:bg-zinc-800 dark:hover:bg-zinc-100 transition-colors" > - Use + {t('create.audio_modal.use')} {track.source === 'uploaded' && ( = ({ onGenerate, isGenerati {createMode === 'lego' - ? 'Generate Lego track' + ? t('create.generate_button.lego') : createMode === 'cover' - ? 'Generate cover' + ? t('create.generate_button.cover') : bulkCount > 1 - ? `Create ${bulkCount} Jobs (${bulkCount * batchSize} tracks)` - : `Create${batchSize > 1 ? ` (${batchSize} variations)` : ''}`} + ? t('create.generate_button.bulk', { count: bulkCount, total: bulkCount * batchSize }) + : t('create.generate_button.create') + (batchSize > 1 ? ` ${t('create.generate_button.variations', { count: batchSize })}` : '')}
{playlist.description || `By You`}
{playlist.description || t('library.by_you')}
- Convert audio to MIDI using basic-pitch. Upload an audio file and adjust detection parameters. + {t('midi.description')}
basic-pitch model is not downloaded yet.
{modelMessage || 'Click "Download basic-pitch models" to download it (first use only).'}
{t('midi.model_not_downloaded')}
{t('midi.model_download_hint')}
- Downloading basic-pitch model… + {t('midi.downloading_model')}
- {currentSong.creator || 'Unknown Artist'} + {currentSong.creator || t('song_list.unknown_artist')}
{currentSong.creator || 'Unknown Artist'}
{currentSong.creator || t('song_list.unknown_artist')}
Select a song to view details
{t('song_details.select_song')}
Where ACE-Step and other models are stored. Leave blank for app default. Change takes effect immediately.
{t('settings.models_folder_desc')}
Where generated tracks, stems, voice clones, and MIDI are saved. Leave blank for app default.
{t('settings.output_dir_desc')}
- ACE-Step executor (DiT) and planner (LM). See Tutorial for VRAM and quality trade-offs. + {t('settings.models_desc')}
{(() => { const discovered = aceStepList?.discovered_models ?? []; @@ -240,8 +277,8 @@ export const SettingsModal: React.FC = ({ isOpen, onClose, t
Installed and discovered models in the checkpoints folder. Custom models appear when placed there.
{t('settings.dit_model_desc')}
Bundled ACE-Step 5Hz LM (no external LLM). Used when "Thinking" is on in Create. Download the model below if needed; only installed options appear here.
{t('settings.lm_planner_desc')}
{aceStepList.acestep_download_available - ? 'Download DiT or LM models into the checkpoints folder. (Bundled in app.)' + ? t('settings.download_models_desc') : 'Downloader not available in this build. Default (Turbo) uses the app download.'}
{downloadError}
Window zoom level. Takes effect on next app launch.
{t('settings.ui_zoom_desc')}
- Member since {new Date(user.createdAt).toLocaleDateString('en-US', { month: 'long', year: 'numeric' })} + {t('settings.member_since')} {new Date(user.createdAt).toLocaleDateString('en-US', { month: 'long', year: 'numeric' })}
@{user.username}
Version 1.0.0
{t('settings.version')} 1.0.0
AceForge
- Powered by ACE-Step 1.5. Open source and free to use. + {t('settings.powered_by')}
- Report issues or request features on GitHub + {t('settings.report_issues')}
No songs match your filters.
{t('song_list.no_matches')}
- Split audio into separate stems (vocals, drums, bass, etc.) using Demucs. Upload an audio file and choose the number of stems. + {t('stem_splitting.description')}
Demucs model is not downloaded yet.
{modelMessage || 'Click "Download Demucs models" to download it (first use only).'}
{t('stem_splitting.model_not_downloaded')}
{t('stem_splitting.model_download_hint')}
- Downloading Demucs model… + {t('stem_splitting.downloading_demucs')}
- Run LoRA training on your dataset. Dataset folder must be under training_datasets. Use Browse to select a folder. When training finishes, the LoRA is saved automatically and will appear in Create → LoRA adapter (click Refresh there if needed). + {t('training.description')}
training_datasets
ACE-Step training model is not downloaded yet.
{aceMessage || 'Click "Download Training Model" to start the download. This is a large download (multiple GB).'}
{t('training.model_not_downloaded')}
{t('training.model_download_hint')}
Downloading ACE-Step model…
{t('training.downloading_model')}
{datasetFiles.length} files selected
{t('training.files_selected', { count: datasetFiles.length })}
Light / Medium / Heavy – How many LoRA parameters (light = subtle, heavy = more VRAM / overfit risk).
base_layers – Main self-attention layers; good for gentle style.
extended_attn – Base + cross-attention; stronger prompt control.
transformer_deep / full_stack – Larger LoRAs; full_stack includes conditioning stack.
default_config.json matches light_base_layers.
{t('training.lora_help_light')}
{t('training.lora_help_base')}
{t('training.lora_help_extended')}
{t('training.lora_help_deep')}
{t('training.lora_help_default')}
Set to 0 for pure instrumental / chiptune.
{t('training.ssl_note')}
- Joined {new Date(profileUser.created_at).toLocaleDateString('en-US', { month: 'long', year: 'numeric' })} + {t('profile.joined', { date: new Date(profileUser.created_at).toLocaleDateString('en-US', { month: 'long', year: 'numeric' }) })}
No public songs yet
{t('profile.no_public_songs')}
JPG, PNG, WebP, GIF • Max 5MB
{t('profile.avatar_help')}
Recommended: 1500x500px • JPG, PNG, WebP, GIF • Max 5MB
{t('profile.banner_help_sub')}
- Clone a voice from a reference audio file using XTTS v2. Upload a reference and enter text to synthesize. + {t('voice_cloning.description')}
Voice cloning model is not downloaded yet.
{modelMessage || 'Click "Download voice cloning model" to download it (first use only).'}
{t('voice_cloning.model_not_downloaded')}
{t('voice_cloning.model_download_hint')}
- Downloading XTTS voice cloning model… + {t('voice_cloning.downloading_model')}