diff --git a/App.tsx b/App.tsx index a2e0b90..c49eb70 100644 --- a/App.tsx +++ b/App.tsx @@ -46,6 +46,7 @@ export default function App() { const [songs, setSongs] = useState([]); const [playlists, setPlaylists] = useState([]); const [likedSongIds, setLikedSongIds] = useState>(new Set()); + const [referenceTracks, setReferenceTracks] = useState([]); const [playQueue, setPlayQueue] = useState([]); const [queueIndex, setQueueIndex] = useState(-1); @@ -65,6 +66,7 @@ export default function App() { // UI State const [isGenerating, setIsGenerating] = useState(false); const [showRightSidebar, setShowRightSidebar] = useState(true); + const [pendingAudioSelection, setPendingAudioSelection] = useState<{ target: 'reference' | 'source'; url: string; title?: string } | null>(null); // Mobile UI Toggle const [mobileShowList, setMobileShowList] = useState(false); @@ -107,6 +109,17 @@ export default function App() { isVisible: false, }); + interface ReferenceTrack { + id: string; + filename: string; + storage_key: string; + duration: number | null; + file_size_bytes: number | null; + tags: string[] | null; + created_at: string; + audio_url: string; + } + const showToast = (message: string, type: ToastType = 'success') => { setToast({ message, type, isVisible: true }); }; @@ -281,6 +294,14 @@ export default function App() { viewCount: s.view_count || 0, userId: s.user_id, creator: s.creator, + generationParams: (() => { + try { + if (!s.generation_params) return undefined; + return typeof s.generation_params === 'string' ? JSON.parse(s.generation_params) : s.generation_params; + } catch { + return undefined; + } + })(), }); const mySongs = mySongsRes.songs.map(mapSong); @@ -307,6 +328,31 @@ export default function App() { loadSongs(); }, [isAuthenticated, token]); + const loadReferenceTracks = useCallback(async () => { + if (!isAuthenticated || !token) return; + try { + const response = await fetch('/api/reference-tracks', { + headers: { Authorization: `Bearer ${token}` } + }); + if (!response.ok) return; + const data = await response.json(); + setReferenceTracks(data.tracks || []); + } catch (error) { + console.error('Failed to load reference tracks:', error); + } + }, [isAuthenticated, token]); + + // Load reference tracks for Library + useEffect(() => { + loadReferenceTracks(); + }, [loadReferenceTracks]); + + useEffect(() => { + if (currentView === 'library') { + loadReferenceTracks(); + } + }, [currentView, loadReferenceTracks]); + // Player Logic const getActiveQueue = (song?: Song) => { if (playQueue.length > 0) return playQueue; @@ -520,6 +566,14 @@ export default function App() { viewCount: s.view_count || 0, userId: s.user_id, creator: s.creator, + generationParams: (() => { + try { + if (!s.generation_params) return undefined; + return typeof s.generation_params === 'string' ? JSON.parse(s.generation_params) : s.generation_params; + } catch { + return undefined; + } + })(), })); // Preserve any generating songs that aren't in the loaded list @@ -534,11 +588,82 @@ export default function App() { // Sort by creation date, newest first return mergedSongs.sort((a, b) => b.createdAt.getTime() - a.createdAt.getTime()); }); + + // If the current selection was a temp/generating song, replace it with newest real song + if (selectedSong?.isGenerating || (selectedSong && !loadedSongs.some(s => s.id === selectedSong.id))) { + setSelectedSong(loadedSongs[0] ?? null); + } } catch (error) { console.error('Failed to refresh songs:', error); } }, [token]); + const beginPollingJob = useCallback((jobId: string, tempId: string) => { + if (!token) return; + if (activeJobsRef.current.has(jobId)) return; + + const pollInterval = setInterval(async () => { + try { + const status = await generateApi.getStatus(jobId, token); + const normalizedProgress = Number.isFinite(Number(status.progress)) + ? (Number(status.progress) > 1 ? Number(status.progress) / 100 : Number(status.progress)) + : undefined; + + setSongs(prev => prev.map(s => { + if (s.id === tempId) { + return { + ...s, + queuePosition: status.status === 'queued' ? status.queuePosition : undefined, + progress: normalizedProgress ?? s.progress, + stage: status.stage ?? s.stage, + }; + } + return s; + })); + + if (status.status === 'succeeded' && status.result) { + cleanupJob(jobId, tempId); + await refreshSongsList(); + + if (window.innerWidth < 768) { + setMobileShowList(true); + } + } else if (status.status === 'failed') { + cleanupJob(jobId, tempId); + console.error(`Job ${jobId} failed:`, status.error); + showToast(`Generation failed: ${status.error || 'Unknown error'}`, 'error'); + } + } catch (pollError) { + console.error(`Polling error for job ${jobId}:`, pollError); + cleanupJob(jobId, tempId); + } + }, 2000); + + activeJobsRef.current.set(jobId, { tempId, pollInterval }); + setActiveJobCount(activeJobsRef.current.size); + + setTimeout(() => { + if (activeJobsRef.current.has(jobId)) { + console.warn(`Job ${jobId} timed out`); + cleanupJob(jobId, tempId); + showToast('Generation timed out', 'error'); + } + }, 600000); + }, [token, cleanupJob, refreshSongsList]); + + const buildTempSongFromParams = (params: GenerationParams, tempId: string, createdAt?: string) => ({ + id: tempId, + title: params.title || 'Generating...', + lyrics: '', + style: params.style || params.songDescription || '', + coverUrl: 'https://picsum.photos/200/200?blur=10', + duration: '--:--', + createdAt: createdAt ? new Date(createdAt) : new Date(), + isGenerating: true, + tags: params.customMode ? ['custom'] : ['simple'], + isPublic: true, + }); + // Handlers const handleGenerate = async (params: GenerationParams) => { if (!isAuthenticated || !token) { @@ -578,7 +703,7 @@ export default function App() { title: params.title, instrumental: params.instrumental, vocalLanguage: params.vocalLanguage, - duration: params.duration, + duration: params.duration && params.duration > 0 ? params.duration : undefined, bpm: params.bpm, keyScale: params.keyScale, timeSignature: params.timeSignature, @@ -599,6 +724,8 @@ export default function App() { lmBackend: params.lmBackend, referenceAudioUrl: params.referenceAudioUrl, sourceAudioUrl: params.sourceAudioUrl, + referenceAudioTitle: params.referenceAudioTitle, + sourceAudioTitle: params.sourceAudioTitle, audioCodes: params.audioCodes, repaintingStart: params.repaintingStart, repaintingEnd: params.repaintingEnd, @@ -624,52 +751,7 @@ export default function App() { isFormatCaption: params.isFormatCaption, }, token); - // Poll for completion - each job has its own polling interval - const pollInterval = setInterval(async () => { - try { - const status = await generateApi.getStatus(job.jobId, token); - - // Update queue position on the temp song - setSongs(prev => prev.map(s => { - if (s.id === tempId) { - return { - ...s, - queuePosition: status.status === 'queued' ? status.queuePosition : undefined, - }; - } - return s; - })); - - if (status.status === 'succeeded' && status.result) { - cleanupJob(job.jobId, tempId); - await refreshSongsList(); - - if (window.innerWidth < 768) { - setMobileShowList(true); - } - } else if (status.status === 'failed') { - cleanupJob(job.jobId, tempId); - console.error(`Job ${job.jobId} failed:`, status.error); - showToast(`Generation failed: ${status.error || 'Unknown error'}`, 'error'); - } - } catch (pollError) { - console.error(`Polling error for job ${job.jobId}:`, pollError); - cleanupJob(job.jobId, tempId); - } - }, 2000); - - // Track this job - activeJobsRef.current.set(job.jobId, { tempId, pollInterval }); - setActiveJobCount(activeJobsRef.current.size); - - // Timeout after 10 minutes - setTimeout(() => { - if (activeJobsRef.current.has(job.jobId)) { - console.warn(`Job ${job.jobId} timed out`); - cleanupJob(job.jobId, tempId); - showToast('Generation timed out', 'error'); - } - }, 600000); + beginPollingJob(job.jobId, tempId); } catch (e) { console.error('Generation error:', e); @@ -683,6 +765,59 @@ export default function App() { } }; + // Resume active jobs on refresh so progress keeps updating + useEffect(() => { + if (!isAuthenticated || !token) return; + + const resumeJobs = async () => { + try { + const history = await generateApi.getHistory(token); + const jobs = Array.isArray(history.jobs) ? history.jobs : []; + + const activeStatuses = new Set(['pending', 'queued', 'running']); + const jobsToResume = jobs.filter((job: any) => activeStatuses.has(job.status)); + + if (jobsToResume.length === 0) return; + + setSongs(prev => { + const existingIds = new Set(prev.map(s => s.id)); + const next = [...prev]; + + for (const job of jobsToResume) { + const jobId = job.id || job.jobId; + if (!jobId) continue; + const tempId = `job_${jobId}`; + if (existingIds.has(tempId)) continue; + + const params = (() => { + try { + if (!job.params) return {}; + return typeof job.params === 'string' ? JSON.parse(job.params) : job.params; + } catch { + return {}; + } + })(); + + next.unshift(buildTempSongFromParams(params, tempId, job.created_at)); + existingIds.add(tempId); + } + return next; + }); + + for (const job of jobsToResume) { + const jobId = job.id || job.jobId; + if (!jobId) continue; + const tempId = `job_${jobId}`; + beginPollingJob(jobId, tempId); + } + } catch (error) { + console.error('Failed to resume jobs:', error); + } + }; + + resumeJobs(); + }, [isAuthenticated, token, beginPollingJob]); + const togglePlay = () => { if (!currentSong) return; setIsPlaying(!isPlaying); @@ -817,6 +952,80 @@ export default function App() { } }; + const handleDeleteSongs = async (songsToDelete: Song[]) => { + if (!token || songsToDelete.length === 0) return; + + const confirmed = window.confirm( + `Delete ${songsToDelete.length} songs? This action cannot be undone.` + ); + if (!confirmed) return; + + const idsToDelete = new Set(songsToDelete.map(song => song.id)); + const succeeded: string[] = []; + const failed: string[] = []; + + for (const song of songsToDelete) { + try { + await songsApi.deleteSong(song.id, token); + succeeded.push(song.id); + } catch (error) { + console.error('Failed to delete song:', error); + failed.push(song.id); + } + } + + if (succeeded.length > 0) { + setSongs(prev => prev.filter(s => !idsToDelete.has(s.id) || failed.includes(s.id))); + + setLikedSongIds(prev => { + const next = new Set(prev); + succeeded.forEach(id => next.delete(id)); + return next; + }); + + if (selectedSong?.id && succeeded.includes(selectedSong.id)) { + setSelectedSong(null); + } + + if (currentSong?.id && succeeded.includes(currentSong.id)) { + setCurrentSong(null); + setIsPlaying(false); + if (audioRef.current) { + audioRef.current.pause(); + audioRef.current.src = ''; + } + } + + setPlayQueue(prev => prev.filter(s => !idsToDelete.has(s.id) || failed.includes(s.id))); + } + + if (failed.length > 0) { + showToast(`Deleted ${succeeded.length}/${songsToDelete.length} songs`, 'error'); + } else { + showToast('Songs deleted successfully'); + } + }; + + const handleDeleteReferenceTrack = async (trackId: string) => { + if (!token) return; + const confirmed = window.confirm('Delete this upload? This action cannot be undone.'); + if (!confirmed) return; + try { + const response = await fetch(`/api/reference-tracks/${trackId}`, { + method: 'DELETE', + headers: { Authorization: `Bearer ${token}` } + }); + if (!response.ok) { + throw new Error('Failed to delete upload'); + } + setReferenceTracks(prev => prev.filter(track => track.id !== trackId)); + showToast('Upload deleted successfully'); + } catch (error) { + console.error('Failed to delete upload:', error); + showToast('Failed to delete upload', 'error'); + } + }; + const createPlaylist = async (name: string, description: string) => { if (!token) return; try { @@ -859,6 +1068,40 @@ export default function App() { window.history.pushState({}, '', `/playlist/${playlistId}`); }; + const handleUseAsReference = (song: Song) => { + if (!song.audioUrl) return; + setPendingAudioSelection({ target: 'reference', url: song.audioUrl, title: song.title }); + setCurrentView('create'); + setMobileShowList(false); + }; + + const handleCoverSong = (song: Song) => { + if (!song.audioUrl) return; + setPendingAudioSelection({ target: 'source', url: song.audioUrl, title: song.title }); + setCurrentView('create'); + setMobileShowList(false); + }; + + const handleUseUploadAsReference = (track: { audio_url: string; filename: string }) => { + setPendingAudioSelection({ + target: 'reference', + url: track.audio_url, + title: track.filename.replace(/\.[^/.]+$/, ''), + }); + setCurrentView('create'); + setMobileShowList(false); + }; + + const handleCoverUpload = (track: { audio_url: string; filename: string }) => { + setPendingAudioSelection({ + target: 'source', + url: track.audio_url, + title: track.filename.replace(/\.[^/.]+$/, ''), + }); + setCurrentView('create'); + setMobileShowList(false); + }; + const handleBackFromPlaylist = () => { setViewingPlaylistId(null); setCurrentView('library'); @@ -883,19 +1126,28 @@ export default function App() { // Render Layout Logic const renderContent = () => { switch (currentView) { - case 'library': + case 'library': { + const allSongs = user ? songs.filter(s => s.userId === user.id) : []; return ( likedSongIds.has(s.id))} playlists={playlists} + referenceTracks={referenceTracks} onPlaySong={playSong} onCreatePlaylist={() => { setSongToAddToPlaylist(null); setIsCreatePlaylistModalOpen(true); }} onSelectPlaylist={(p) => handleNavigateToPlaylist(p.id)} + onAddToPlaylist={openAddToPlaylistModal} + onOpenVideo={openVideoGenerator} + onReusePrompt={handleReuse} + onDeleteSong={handleDeleteSong} + onDeleteReferenceTrack={handleDeleteReferenceTrack} /> ); + } case 'profile': if (!viewingUsername) return null; @@ -968,6 +1220,9 @@ export default function App() { onGenerate={handleGenerate} isGenerating={isGenerating} initialData={reuseData} + createdSongs={songs} + pendingAudioSelection={pendingAudioSelection} + onAudioSelectionApplied={() => setPendingAudioSelection(null)} /> @@ -982,6 +1237,7 @@ export default function App() { selectedSong={selectedSong} likedSongIds={likedSongIds} isPlaying={isPlaying} + referenceTracks={referenceTracks} onPlay={playSong} onSelect={(s) => { setSelectedSong(s); @@ -994,6 +1250,11 @@ export default function App() { onNavigateToProfile={handleNavigateToProfile} onReusePrompt={handleReuse} onDelete={handleDeleteSong} + onDeleteMany={handleDeleteSongs} + onUseAsReference={handleUseAsReference} + onCoverSong={handleCoverSong} + onUseUploadAsReference={handleUseUploadAsReference} + onCoverUpload={handleCoverUpload} /> diff --git a/components/CreatePanel.tsx b/components/CreatePanel.tsx index d0364c9..c54fb94 100644 --- a/components/CreatePanel.tsx +++ b/components/CreatePanel.tsx @@ -1,5 +1,5 @@ -import React, { useState, useEffect, useRef, useCallback } from 'react'; -import { Sparkles, ChevronDown, Settings2, Trash2, Music2, Sliders, Dices, Hash, RefreshCw, Plus, Upload, Play, Pause } from 'lucide-react'; +import React, { useState, useEffect, useRef, useCallback, useMemo } from 'react'; +import { Sparkles, ChevronDown, Settings2, Trash2, Music2, Sliders, Dices, Hash, RefreshCw, Plus, Upload, Play, Pause, Loader2 } from 'lucide-react'; import { GenerationParams, Song } from '../types'; import { useAuth } from '../context/AuthContext'; import { generateApi } from '../services/api'; @@ -19,6 +19,9 @@ interface CreatePanelProps { onGenerate: (params: GenerationParams) => void; isGenerating: boolean; initialData?: { song: Song, timestamp: number } | null; + createdSongs?: Song[]; + pendingAudioSelection?: { target: 'reference' | 'source'; url: string; title?: string } | null; + onAudioSelectionApplied?: () => void; } const KEY_SIGNATURES = [ @@ -98,8 +101,15 @@ const VOCAL_LANGUAGES = [ { value: 'zh', label: 'Chinese (Mandarin)' }, ]; -export const CreatePanel: React.FC = ({ onGenerate, isGenerating, initialData }) => { - const { isAuthenticated, token } = useAuth(); +export const CreatePanel: React.FC = ({ + onGenerate, + isGenerating, + initialData, + createdSongs = [], + pendingAudioSelection, + onAudioSelectionApplied, +}) => { + const { isAuthenticated, token, user } = useAuth(); // Mode const [customMode, setCustomMode] = useState(true); @@ -115,6 +125,7 @@ export const CreatePanel: React.FC = ({ onGenerate, isGenerati // Common const [instrumental, setInstrumental] = useState(false); const [vocalLanguage, setVocalLanguage] = useState('en'); + const [vocalGender, setVocalGender] = useState<'male' | 'female' | ''>(''); // Music Parameters const [bpm, setBpm] = useState(0); @@ -126,27 +137,29 @@ export const CreatePanel: React.FC = ({ onGenerate, isGenerati const [duration, setDuration] = useState(-1); const [batchSize, setBatchSize] = useState(2); const [bulkCount, setBulkCount] = useState(1); // Number of independent generation jobs to queue - const [guidanceScale, setGuidanceScale] = useState(7.0); + const [guidanceScale, setGuidanceScale] = useState(9.0); const [randomSeed, setRandomSeed] = useState(true); const [seed, setSeed] = useState(-1); const [thinking, setThinking] = useState(false); // Default false for GPU compatibility const [audioFormat, setAudioFormat] = useState<'mp3' | 'flac'>('mp3'); - const [inferenceSteps, setInferenceSteps] = useState(8); + const [inferenceSteps, setInferenceSteps] = useState(12); const [inferMethod, setInferMethod] = useState<'ode' | 'sde'>('ode'); const [lmBackend, setLmBackend] = useState<'pt' | 'vllm'>('pt'); const [shift, setShift] = useState(3.0); // LM Parameters (under Expert) const [showLmParams, setShowLmParams] = useState(false); - const [lmTemperature, setLmTemperature] = useState(0.85); - const [lmCfgScale, setLmCfgScale] = useState(2.0); + const [lmTemperature, setLmTemperature] = useState(0.8); + const [lmCfgScale, setLmCfgScale] = useState(2.2); const [lmTopK, setLmTopK] = useState(0); - const [lmTopP, setLmTopP] = useState(0.9); + const [lmTopP, setLmTopP] = useState(0.92); const [lmNegativePrompt, setLmNegativePrompt] = useState('NO USER INPUT'); // Expert Parameters (now in Advanced section) const [referenceAudioUrl, setReferenceAudioUrl] = useState(''); const [sourceAudioUrl, setSourceAudioUrl] = useState(''); + const [referenceAudioTitle, setReferenceAudioTitle] = useState(''); + const [sourceAudioTitle, setSourceAudioTitle] = useState(''); const [audioCodes, setAudioCodes] = useState(''); const [repaintingStart, setRepaintingStart] = useState(0); const [repaintingEnd, setRepaintingEnd] = useState(-1); @@ -170,13 +183,21 @@ export const CreatePanel: React.FC = ({ onGenerate, isGenerati const [trackName, setTrackName] = useState(''); const [completeTrackClasses, setCompleteTrackClasses] = useState(''); const [isFormatCaption, setIsFormatCaption] = useState(false); + const [maxDurationWithLm, setMaxDurationWithLm] = useState(240); + const [maxDurationWithoutLm, setMaxDurationWithoutLm] = useState(240); const [isUploadingReference, setIsUploadingReference] = useState(false); const [isUploadingSource, setIsUploadingSource] = useState(false); + const [isTranscribingReference, setIsTranscribingReference] = useState(false); + const transcribeAbortRef = useRef(null); const [uploadError, setUploadError] = useState(null); - const [isFormatting, setIsFormatting] = useState(false); + const [isFormattingStyle, setIsFormattingStyle] = useState(false); + const [isFormattingLyrics, setIsFormattingLyrics] = useState(false); + const [isDraggingFile, setIsDraggingFile] = useState(false); + const [dragKind, setDragKind] = useState<'file' | 'audio' | null>(null); const referenceInputRef = useRef(null); const sourceInputRef = useRef(null); + const dragDepthRef = useRef(0); const [showAudioModal, setShowAudioModal] = useState(false); const [audioModalTarget, setAudioModalTarget] = useState<'reference' | 'source'>('reference'); const [tempAudioUrl, setTempAudioUrl] = useState(''); @@ -194,17 +215,34 @@ export const CreatePanel: React.FC = ({ onGenerate, isGenerati const [referenceTracks, setReferenceTracks] = useState([]); const [isLoadingTracks, setIsLoadingTracks] = useState(false); const [playingTrackId, setPlayingTrackId] = useState(null); + const [playingTrackSource, setPlayingTrackSource] = useState<'uploads' | 'created' | null>(null); const modalAudioRef = useRef(null); const [modalTrackTime, setModalTrackTime] = useState(0); const [modalTrackDuration, setModalTrackDuration] = useState(0); + const [libraryTab, setLibraryTab] = useState<'uploads' | 'created'>('uploads'); + + const createdTrackOptions = useMemo(() => { + return createdSongs + .filter(song => !song.isGenerating) + .filter(song => (user ? song.userId === user.id : true)) + .filter(song => Boolean(song.audioUrl)) + .map(song => ({ + id: song.id, + title: song.title || 'Untitled', + audio_url: song.audioUrl!, + duration: song.duration, + })); + }, [createdSongs, user]); const getAudioLabel = (url: string) => { try { const parsed = new URL(url); - return decodeURIComponent(parsed.pathname.split('/').pop() || parsed.hostname); + const name = decodeURIComponent(parsed.pathname.split('/').pop() || parsed.hostname); + return name.replace(/\.[^/.]+$/, '') || name; } catch { const parts = url.split('/'); - return decodeURIComponent(parts[parts.length - 1] || url); + const name = decodeURIComponent(parts[parts.length - 1] || url); + return name.replace(/\.[^/.]+$/, '') || name; } }; @@ -227,6 +265,16 @@ export const CreatePanel: React.FC = ({ onGenerate, isGenerati } }, [initialData]); + useEffect(() => { + if (!pendingAudioSelection) return; + applyAudioTargetUrl( + pendingAudioSelection.target, + pendingAudioSelection.url, + pendingAudioSelection.title + ); + onAudioSelectionApplied?.(); + }, [pendingAudioSelection, onAudioSelectionApplied]); + useEffect(() => { const handleMouseMove = (e: MouseEvent) => { if (!isResizing) return; @@ -274,6 +322,91 @@ export const CreatePanel: React.FC = ({ onGenerate, isGenerati }; }, [isResizing]); + useEffect(() => { + const loadLimits = async () => { + try { + const response = await fetch('/api/generate/limits'); + if (!response.ok) return; + const data = await response.json(); + if (typeof data.max_duration_with_lm === 'number') { + setMaxDurationWithLm(data.max_duration_with_lm); + } + if (typeof data.max_duration_without_lm === 'number') { + setMaxDurationWithoutLm(data.max_duration_without_lm); + } + } catch { + // ignore limits fetch failures + } + }; + + loadLimits(); + }, []); + + const activeMaxDuration = thinking ? maxDurationWithLm : maxDurationWithoutLm; + + useEffect(() => { + if (duration > activeMaxDuration) { + setDuration(activeMaxDuration); + } + }, [duration, activeMaxDuration]); + + useEffect(() => { + const getDragKind = (e: DragEvent): 'file' | 'audio' | null => { + if (!e.dataTransfer) return null; + const types = Array.from(e.dataTransfer.types); + if (types.includes('Files')) return 'file'; + if (types.includes('application/x-ace-audio')) return 'audio'; + return null; + }; + + const handleDragEnter = (e: DragEvent) => { + const kind = getDragKind(e); + if (!kind) return; + dragDepthRef.current += 1; + setIsDraggingFile(true); + setDragKind(kind); + e.preventDefault(); + }; + + const handleDragOver = (e: DragEvent) => { + const kind = getDragKind(e); + if (!kind) return; + setDragKind(kind); + e.preventDefault(); + }; + + const handleDragLeave = (e: DragEvent) => { + const kind = getDragKind(e); + if (!kind) return; + dragDepthRef.current = Math.max(0, dragDepthRef.current - 1); + if (dragDepthRef.current === 0) { + setIsDraggingFile(false); + setDragKind(null); + } + }; + + const handleDrop = (e: DragEvent) => { + const kind = getDragKind(e); + if (!kind) return; + e.preventDefault(); + dragDepthRef.current = 0; + setIsDraggingFile(false); + setDragKind(null); + }; + + window.addEventListener('dragenter', handleDragEnter); + window.addEventListener('dragover', handleDragOver); + window.addEventListener('dragleave', handleDragLeave); + window.addEventListener('drop', handleDrop); + + return () => { + window.removeEventListener('dragenter', handleDragEnter); + window.removeEventListener('dragover', handleDragOver); + window.removeEventListener('dragleave', handleDragLeave); + window.removeEventListener('drop', handleDrop); + }; + }, []); + const startResizing = (e: React.MouseEvent) => { e.preventDefault(); setIsResizing(true); @@ -304,15 +437,19 @@ export const CreatePanel: React.FC = ({ onGenerate, isGenerati const handleFileSelect = (e: React.ChangeEvent, target: 'reference' | 'source') => { const file = e.target.files?.[0]; if (file) { - void uploadAudio(file, target); + void uploadReferenceTrack(file, target); } e.target.value = ''; }; - // Format handler - uses LLM to enhance style and auto-fill parameters - const handleFormat = async () => { + // Format handler - uses LLM to enhance style/lyrics and auto-fill parameters + const handleFormat = async (target: 'style' | 'lyrics') => { if (!token || !style.trim()) return; - setIsFormatting(true); + if (target === 'style') { + setIsFormattingStyle(true); + } else { + setIsFormattingLyrics(true); + } try { const result = await generateApi.formatInput({ caption: style, @@ -328,14 +465,14 @@ export const CreatePanel: React.FC = ({ onGenerate, isGenerati if (result.success) { // Update fields with LLM-generated values - if (result.caption) setStyle(result.caption); - if (result.lyrics) setLyrics(result.lyrics); + if (target === 'style' && result.caption) setStyle(result.caption); + if (target === 'lyrics' && result.lyrics) setLyrics(result.lyrics); if (result.bpm && result.bpm > 0) setBpm(result.bpm); if (result.duration && result.duration > 0) setDuration(result.duration); if (result.key_scale) setKeyScale(result.key_scale); if (result.time_signature) setTimeSignature(result.time_signature); if (result.language) setVocalLanguage(result.language); - setIsFormatCaption(true); + if (target === 'style') setIsFormatCaption(true); } else { console.error('Format failed:', result.error || result.status_message); alert(result.error || result.status_message || 'Format failed. Make sure the LLM is initialized.'); @@ -344,13 +481,18 @@ export const CreatePanel: React.FC = ({ onGenerate, isGenerati console.error('Format error:', err); alert('Format failed. The LLM may not be available.'); } finally { - setIsFormatting(false); + if (target === 'style') { + setIsFormattingStyle(false); + } else { + setIsFormattingLyrics(false); + } } }; - const openAudioModal = (target: 'reference' | 'source') => { + const openAudioModal = (target: 'reference' | 'source', tab: 'uploads' | 'created' = 'uploads') => { setAudioModalTarget(target); setTempAudioUrl(''); + setLibraryTab(tab); setShowAudioModal(true); void fetchReferenceTracks(); }; @@ -373,7 +515,7 @@ export const CreatePanel: React.FC = ({ onGenerate, isGenerati } }, [token]); - const uploadReferenceTrack = async (file: File) => { + const uploadReferenceTrack = async (file: File, target?: 'reference' | 'source') => { if (!token) { setUploadError('Please sign in to upload audio.'); return; @@ -399,12 +541,13 @@ export const CreatePanel: React.FC = ({ onGenerate, isGenerati setReferenceTracks(prev => [data.track, ...prev]); // Also set as current reference/source - if (audioModalTarget === 'reference') { - setReferenceAudioUrl(data.track.audio_url); + const selectedTarget = target ?? audioModalTarget; + applyAudioTargetUrl(selectedTarget, data.track.audio_url, data.track.filename); + if (data.whisper_available && data.track?.id) { + void transcribeReferenceTrack(data.track.id).then(() => undefined); } else { - setSourceAudioUrl(data.track.audio_url); + setShowAudioModal(false); } - setShowAudioModal(false); } catch (err) { const message = err instanceof Error ? err.message : 'Upload failed'; setUploadError(message); @@ -413,6 +556,43 @@ export const CreatePanel: React.FC = ({ onGenerate, isGenerati } }; + const transcribeReferenceTrack = async (trackId: string) => { + if (!token) return; + setIsTranscribingReference(true); + const controller = new AbortController(); + transcribeAbortRef.current = controller; + try { + const response = await fetch(`/api/reference-tracks/${trackId}/transcribe`, { + method: 'POST', + headers: { Authorization: `Bearer ${token}` }, + signal: controller.signal, + }); + if (!response.ok) { + throw new Error('Failed to transcribe'); + } + const data = await response.json(); + if (data.lyrics) { + setLyrics(prev => prev || data.lyrics); + } + } catch (err) { + if (controller.signal.aborted) return; + console.error('Transcription failed:', err); + } finally { + if (transcribeAbortRef.current === controller) { + transcribeAbortRef.current = null; + } + setIsTranscribingReference(false); + } + }; + + const cancelTranscription = () => { + if (transcribeAbortRef.current) { + transcribeAbortRef.current.abort(); + transcribeAbortRef.current = null; + } + setIsTranscribingReference(false); + }; + const deleteReferenceTrack = async (trackId: string) => { if (!token) return; try { @@ -422,8 +602,9 @@ export const CreatePanel: React.FC = ({ onGenerate, isGenerati }); if (response.ok) { setReferenceTracks(prev => prev.filter(t => t.id !== trackId)); - if (playingTrackId === trackId) { + if (playingTrackId === trackId && playingTrackSource === 'uploads') { setPlayingTrackId(null); + setPlayingTrackSource(null); if (modalAudioRef.current) { modalAudioRef.current.pause(); } @@ -434,24 +615,23 @@ export const CreatePanel: React.FC = ({ onGenerate, isGenerati } }; - const useReferenceTrack = (track: ReferenceTrack) => { - if (audioModalTarget === 'reference') { - setReferenceAudioUrl(track.audio_url); - } else { - setSourceAudioUrl(track.audio_url); - } + const useReferenceTrack = (track: { audio_url: string; title?: string }) => { + applyAudioTargetUrl(audioModalTarget, track.audio_url, track.title); setShowAudioModal(false); setPlayingTrackId(null); + setPlayingTrackSource(null); }; - const toggleModalTrack = (track: ReferenceTrack) => { + const toggleModalTrack = (track: { id: string; audio_url: string; source: 'uploads' | 'created' }) => { if (playingTrackId === track.id) { if (modalAudioRef.current) { modalAudioRef.current.pause(); } setPlayingTrackId(null); + setPlayingTrackSource(null); } else { setPlayingTrackId(track.id); + setPlayingTrackSource(track.source); if (modalAudioRef.current) { modalAudioRef.current.src = track.audio_url; modalAudioRef.current.play().catch(() => undefined); @@ -461,17 +641,27 @@ export const CreatePanel: React.FC = ({ onGenerate, isGenerati const applyAudioUrl = () => { if (!tempAudioUrl.trim()) return; - if (audioModalTarget === 'reference') { - setReferenceAudioUrl(tempAudioUrl.trim()); + applyAudioTargetUrl(audioModalTarget, tempAudioUrl.trim()); + setShowAudioModal(false); + setTempAudioUrl(''); + }; + + const applyAudioTargetUrl = (target: 'reference' | 'source', url: string, title?: string) => { + const derivedTitle = title ? title.replace(/\.[^/.]+$/, '') : getAudioLabel(url); + if (target === 'reference') { + setReferenceAudioUrl(url); + setReferenceAudioTitle(derivedTitle); setReferenceTime(0); setReferenceDuration(0); } else { - setSourceAudioUrl(tempAudioUrl.trim()); + setSourceAudioUrl(url); + setSourceAudioTitle(derivedTitle); setSourceTime(0); setSourceDuration(0); + if (taskType === 'text2music') { + setTaskType('cover'); + } } - setShowAudioModal(false); - setTempAudioUrl(''); }; const formatTime = (time: number) => { @@ -495,7 +685,19 @@ export const CreatePanel: React.FC = ({ onGenerate, isGenerati e.preventDefault(); const file = e.dataTransfer.files?.[0]; if (file) { - void uploadAudio(file, target); + void uploadReferenceTrack(file, target); + return; + } + const payload = e.dataTransfer.getData('application/x-ace-audio'); + if (payload) { + try { + const data = JSON.parse(payload); + if (data?.url) { + applyAudioTargetUrl(target, data.url, data.title); + } + } catch { + // ignore + } } }; @@ -503,7 +705,26 @@ export const CreatePanel: React.FC = ({ onGenerate, isGenerati e.preventDefault(); }; + const handleWorkspaceDrop = (e: React.DragEvent) => { + if (e.dataTransfer.files?.length || e.dataTransfer.types.includes('application/x-ace-audio')) { + handleDrop(e, audioTab); + } + }; + + const handleWorkspaceDragOver = (e: React.DragEvent) => { + if (e.dataTransfer.types.includes('Files') || e.dataTransfer.types.includes('application/x-ace-audio')) { + e.preventDefault(); + } + }; + const handleGenerate = () => { + const styleWithGender = (() => { + if (!vocalGender) return style; + const genderHint = vocalGender === 'male' ? 'Male vocals' : 'Female vocals'; + const trimmed = style.trim(); + return trimmed ? `${trimmed}\n${genderHint}` : genderHint; + })(); + // Bulk generation: loop bulkCount times for (let i = 0; i < bulkCount; i++) { // Seed handling: first job uses user's seed, rest get random seeds @@ -520,7 +741,7 @@ export const CreatePanel: React.FC = ({ onGenerate, isGenerati songDescription: customMode ? undefined : songDescription, prompt: lyrics, lyrics, - style, + style: styleWithGender, title: bulkCount > 1 ? `${title} (${i + 1})` : title, instrumental, vocalLanguage, @@ -545,6 +766,8 @@ export const CreatePanel: React.FC = ({ onGenerate, isGenerati lmNegativePrompt, referenceAudioUrl: referenceAudioUrl.trim() || undefined, sourceAudioUrl: sourceAudioUrl.trim() || undefined, + referenceAudioTitle: referenceAudioTitle.trim() || undefined, + sourceAudioTitle: sourceAudioTitle.trim() || undefined, audioCodes: audioCodes.trim() || undefined, repaintingStart, repaintingEnd, @@ -584,7 +807,33 @@ export const CreatePanel: React.FC = ({ onGenerate, isGenerati }; return ( -
+
+ {isDraggingFile && ( +
+
+
+
+ {dragKind !== 'audio' && ( +
+ +
+ )} +
+ {dragKind === 'audio' ? 'Drop to use audio' : 'Drop to upload'} +
+
+ {dragKind === 'audio' + ? `Using as ${audioTab === 'reference' ? 'Reference' : 'Cover'}` + : `Uploading as ${audioTab === 'reference' ? 'Reference' : 'Cover'}`} +
+
+
+
+ )}
= ({ onGenerate, isGenerati
Vocal Language
- +
+ +
+ + +
+
{/* Quick Settings (Simple Mode) */} @@ -692,7 +959,7 @@ export const CreatePanel: React.FC = ({ onGenerate, isGenerati setDuration(Number(e.target.value))} @@ -831,7 +1098,7 @@ export const CreatePanel: React.FC = ({ onGenerate, isGenerati
- {getAudioLabel(referenceAudioUrl)} + {referenceAudioTitle || getAudioLabel(referenceAudioUrl)}
{formatTime(referenceTime)} @@ -857,7 +1124,7 @@ export const CreatePanel: React.FC = ({ onGenerate, isGenerati
- {getAudioLabel(sourceAudioUrl)} + {sourceAudioTitle || getAudioLabel(sourceAudioUrl)}
{formatTime(sourceTime)} @@ -910,7 +1177,7 @@ export const CreatePanel: React.FC = ({ onGenerate, isGenerati