Merge PR #24: Various UI improvements from riversedge

Includes progress bar, drag and drop, gender buttons, upload
improvements, and dynamic duration limits.
This commit is contained in:
fspecii
2026-02-05 22:34:01 +02:00
19 changed files with 1892 additions and 285 deletions
+309 -48
View File
@@ -46,6 +46,7 @@ export default function App() {
const [songs, setSongs] = useState<Song[]>([]); const [songs, setSongs] = useState<Song[]>([]);
const [playlists, setPlaylists] = useState<Playlist[]>([]); const [playlists, setPlaylists] = useState<Playlist[]>([]);
const [likedSongIds, setLikedSongIds] = useState<Set<string>>(new Set()); const [likedSongIds, setLikedSongIds] = useState<Set<string>>(new Set());
const [referenceTracks, setReferenceTracks] = useState<ReferenceTrack[]>([]);
const [playQueue, setPlayQueue] = useState<Song[]>([]); const [playQueue, setPlayQueue] = useState<Song[]>([]);
const [queueIndex, setQueueIndex] = useState(-1); const [queueIndex, setQueueIndex] = useState(-1);
@@ -65,6 +66,7 @@ export default function App() {
// UI State // UI State
const [isGenerating, setIsGenerating] = useState(false); const [isGenerating, setIsGenerating] = useState(false);
const [showRightSidebar, setShowRightSidebar] = useState(true); const [showRightSidebar, setShowRightSidebar] = useState(true);
const [pendingAudioSelection, setPendingAudioSelection] = useState<{ target: 'reference' | 'source'; url: string; title?: string } | null>(null);
// Mobile UI Toggle // Mobile UI Toggle
const [mobileShowList, setMobileShowList] = useState(false); const [mobileShowList, setMobileShowList] = useState(false);
@@ -107,6 +109,17 @@ export default function App() {
isVisible: false, 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') => { const showToast = (message: string, type: ToastType = 'success') => {
setToast({ message, type, isVisible: true }); setToast({ message, type, isVisible: true });
}; };
@@ -281,6 +294,14 @@ export default function App() {
viewCount: s.view_count || 0, viewCount: s.view_count || 0,
userId: s.user_id, userId: s.user_id,
creator: s.creator, 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); const mySongs = mySongsRes.songs.map(mapSong);
@@ -307,6 +328,31 @@ export default function App() {
loadSongs(); loadSongs();
}, [isAuthenticated, token]); }, [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 // Player Logic
const getActiveQueue = (song?: Song) => { const getActiveQueue = (song?: Song) => {
if (playQueue.length > 0) return playQueue; if (playQueue.length > 0) return playQueue;
@@ -520,6 +566,14 @@ export default function App() {
viewCount: s.view_count || 0, viewCount: s.view_count || 0,
userId: s.user_id, userId: s.user_id,
creator: s.creator, 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 // 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 // Sort by creation date, newest first
return mergedSongs.sort((a, b) => b.createdAt.getTime() - a.createdAt.getTime()); 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) { } catch (error) {
console.error('Failed to refresh songs:', error); console.error('Failed to refresh songs:', error);
} }
}, [token]); }, [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 // Handlers
const handleGenerate = async (params: GenerationParams) => { const handleGenerate = async (params: GenerationParams) => {
if (!isAuthenticated || !token) { if (!isAuthenticated || !token) {
@@ -578,7 +703,7 @@ export default function App() {
title: params.title, title: params.title,
instrumental: params.instrumental, instrumental: params.instrumental,
vocalLanguage: params.vocalLanguage, vocalLanguage: params.vocalLanguage,
duration: params.duration, duration: params.duration && params.duration > 0 ? params.duration : undefined,
bpm: params.bpm, bpm: params.bpm,
keyScale: params.keyScale, keyScale: params.keyScale,
timeSignature: params.timeSignature, timeSignature: params.timeSignature,
@@ -599,6 +724,8 @@ export default function App() {
lmBackend: params.lmBackend, lmBackend: params.lmBackend,
referenceAudioUrl: params.referenceAudioUrl, referenceAudioUrl: params.referenceAudioUrl,
sourceAudioUrl: params.sourceAudioUrl, sourceAudioUrl: params.sourceAudioUrl,
referenceAudioTitle: params.referenceAudioTitle,
sourceAudioTitle: params.sourceAudioTitle,
audioCodes: params.audioCodes, audioCodes: params.audioCodes,
repaintingStart: params.repaintingStart, repaintingStart: params.repaintingStart,
repaintingEnd: params.repaintingEnd, repaintingEnd: params.repaintingEnd,
@@ -624,52 +751,7 @@ export default function App() {
isFormatCaption: params.isFormatCaption, isFormatCaption: params.isFormatCaption,
}, token); }, token);
// Poll for completion - each job has its own polling interval beginPollingJob(job.jobId, tempId);
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);
} catch (e) { } catch (e) {
console.error('Generation error:', 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 = () => { const togglePlay = () => {
if (!currentSong) return; if (!currentSong) return;
setIsPlaying(!isPlaying); 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) => { const createPlaylist = async (name: string, description: string) => {
if (!token) return; if (!token) return;
try { try {
@@ -859,6 +1068,40 @@ export default function App() {
window.history.pushState({}, '', `/playlist/${playlistId}`); 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 = () => { const handleBackFromPlaylist = () => {
setViewingPlaylistId(null); setViewingPlaylistId(null);
setCurrentView('library'); setCurrentView('library');
@@ -883,19 +1126,28 @@ export default function App() {
// Render Layout Logic // Render Layout Logic
const renderContent = () => { const renderContent = () => {
switch (currentView) { switch (currentView) {
case 'library': case 'library': {
const allSongs = user ? songs.filter(s => s.userId === user.id) : [];
return ( return (
<LibraryView <LibraryView
allSongs={allSongs}
likedSongs={songs.filter(s => likedSongIds.has(s.id))} likedSongs={songs.filter(s => likedSongIds.has(s.id))}
playlists={playlists} playlists={playlists}
referenceTracks={referenceTracks}
onPlaySong={playSong} onPlaySong={playSong}
onCreatePlaylist={() => { onCreatePlaylist={() => {
setSongToAddToPlaylist(null); setSongToAddToPlaylist(null);
setIsCreatePlaylistModalOpen(true); setIsCreatePlaylistModalOpen(true);
}} }}
onSelectPlaylist={(p) => handleNavigateToPlaylist(p.id)} onSelectPlaylist={(p) => handleNavigateToPlaylist(p.id)}
onAddToPlaylist={openAddToPlaylistModal}
onOpenVideo={openVideoGenerator}
onReusePrompt={handleReuse}
onDeleteSong={handleDeleteSong}
onDeleteReferenceTrack={handleDeleteReferenceTrack}
/> />
); );
}
case 'profile': case 'profile':
if (!viewingUsername) return null; if (!viewingUsername) return null;
@@ -968,6 +1220,9 @@ export default function App() {
onGenerate={handleGenerate} onGenerate={handleGenerate}
isGenerating={isGenerating} isGenerating={isGenerating}
initialData={reuseData} initialData={reuseData}
createdSongs={songs}
pendingAudioSelection={pendingAudioSelection}
onAudioSelectionApplied={() => setPendingAudioSelection(null)}
/> />
</div> </div>
@@ -982,6 +1237,7 @@ export default function App() {
selectedSong={selectedSong} selectedSong={selectedSong}
likedSongIds={likedSongIds} likedSongIds={likedSongIds}
isPlaying={isPlaying} isPlaying={isPlaying}
referenceTracks={referenceTracks}
onPlay={playSong} onPlay={playSong}
onSelect={(s) => { onSelect={(s) => {
setSelectedSong(s); setSelectedSong(s);
@@ -994,6 +1250,11 @@ export default function App() {
onNavigateToProfile={handleNavigateToProfile} onNavigateToProfile={handleNavigateToProfile}
onReusePrompt={handleReuse} onReusePrompt={handleReuse}
onDelete={handleDeleteSong} onDelete={handleDeleteSong}
onDeleteMany={handleDeleteSongs}
onUseAsReference={handleUseAsReference}
onCoverSong={handleCoverSong}
onUseUploadAsReference={handleUseUploadAsReference}
onCoverUpload={handleCoverUpload}
/> />
</div> </div>
+573 -166
View File
File diff suppressed because it is too large Load Diff
+175 -5
View File
@@ -1,26 +1,70 @@
import React, { useState } from 'react'; import React, { useState } from 'react';
import { Song, Playlist } from '../types'; import { Song, Playlist } from '../types';
import { Heart, Plus, Music, Play } from 'lucide-react'; import { Heart, Plus, Music, Play, MoreHorizontal, Trash2 } from 'lucide-react';
import { useAuth } from '../context/AuthContext';
import { SongDropdownMenu } from './SongDropdownMenu';
import { ShareModal } from './ShareModal';
import { AlbumCover } from './AlbumCover'; import { AlbumCover } from './AlbumCover';
interface LibraryViewProps { interface LibraryViewProps {
allSongs: Song[];
likedSongs: Song[]; likedSongs: Song[];
playlists: Playlist[]; playlists: Playlist[];
referenceTracks: ReferenceTrack[];
onPlaySong: (song: Song, list?: Song[]) => void; onPlaySong: (song: Song, list?: Song[]) => void;
onCreatePlaylist: () => void; onCreatePlaylist: () => void;
onSelectPlaylist: (playlist: Playlist) => void; onSelectPlaylist: (playlist: Playlist) => void;
onAddToPlaylist: (song: Song) => void;
onOpenVideo?: (song: Song) => void;
onReusePrompt?: (song: Song) => void;
onDeleteSong?: (song: Song) => void;
onDeleteReferenceTrack?: (trackId: string) => void;
}
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;
} }
export const LibraryView: React.FC<LibraryViewProps> = ({ export const LibraryView: React.FC<LibraryViewProps> = ({
allSongs,
likedSongs, likedSongs,
playlists, playlists,
referenceTracks,
onPlaySong, onPlaySong,
onCreatePlaylist, onCreatePlaylist,
onSelectPlaylist onSelectPlaylist,
onAddToPlaylist,
onOpenVideo,
onReusePrompt,
onDeleteSong,
onDeleteReferenceTrack,
}) => { }) => {
const [activeTab, setActiveTab] = useState<'playlists' | 'liked'>('liked'); const { user } = useAuth();
const [activeTab, setActiveTab] = useState<'all' | 'playlists' | 'liked' | 'uploads'>('all');
const [shareModalOpen, setShareModalOpen] = useState(false);
const [shareSong, setShareSong] = useState<Song | null>(null);
const formatBytes = (bytes?: number | null) => {
if (!bytes || bytes <= 0) return '0 B';
const units = ['B', 'KB', 'MB', 'GB'];
let size = bytes;
let unit = 0;
while (size >= 1024 && unit < units.length - 1) {
size /= 1024;
unit += 1;
}
return `${size.toFixed(size >= 10 || unit === 0 ? 0 : 1)} ${units[unit]}`;
};
return ( return (
<>
<div className="flex-1 bg-white dark:bg-black overflow-y-auto custom-scrollbar p-6 lg:p-10 pb-32 transition-colors duration-300"> <div className="flex-1 bg-white dark:bg-black overflow-y-auto custom-scrollbar p-6 lg:p-10 pb-32 transition-colors duration-300">
<div className="flex items-center justify-between mb-8"> <div className="flex items-center justify-between mb-8">
<h1 className="text-3xl font-bold text-zinc-900 dark:text-white">Your Library</h1> <h1 className="text-3xl font-bold text-zinc-900 dark:text-white">Your Library</h1>
@@ -35,6 +79,13 @@ export const LibraryView: React.FC<LibraryViewProps> = ({
{/* Tabs */} {/* Tabs */}
<div className="flex items-center gap-4 mb-8 border-b border-zinc-200 dark:border-white/10 pb-1"> <div className="flex items-center gap-4 mb-8 border-b border-zinc-200 dark:border-white/10 pb-1">
<button
onClick={() => setActiveTab('all')}
className={`pb-3 text-sm font-bold transition-colors relative ${activeTab === 'all' ? 'text-zinc-900 dark:text-white' : 'text-zinc-500 hover:text-zinc-900 dark:hover:text-white'}`}
>
All Songs
{activeTab === 'all' && <div className="absolute bottom-0 left-0 right-0 h-0.5 bg-green-500 rounded-full"></div>}
</button>
<button <button
onClick={() => setActiveTab('liked')} 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'}`} 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'}`}
@@ -49,10 +100,68 @@ export const LibraryView: React.FC<LibraryViewProps> = ({
Playlists Playlists
{activeTab === 'playlists' && <div className="absolute bottom-0 left-0 right-0 h-0.5 bg-green-500 rounded-full"></div>} {activeTab === 'playlists' && <div className="absolute bottom-0 left-0 right-0 h-0.5 bg-green-500 rounded-full"></div>}
</button> </button>
<button
onClick={() => setActiveTab('uploads')}
className={`pb-3 text-sm font-bold transition-colors relative ${activeTab === 'uploads' ? 'text-zinc-900 dark:text-white' : 'text-zinc-500 hover:text-zinc-900 dark:hover:text-white'}`}
>
Uploads
{activeTab === 'uploads' && <div className="absolute bottom-0 left-0 right-0 h-0.5 bg-green-500 rounded-full"></div>}
</button>
</div> </div>
{/* Content */} {/* Content */}
{activeTab === 'liked' ? ( {activeTab === 'all' && (
<div className="space-y-1">
{allSongs.length === 0 ? (
<div className="text-sm text-zinc-500 dark:text-zinc-400">No songs yet.</div>
) : (
allSongs.map((song, idx) => (
<div key={song.id} className="group flex items-center gap-4 p-2 rounded hover:bg-zinc-100 dark:hover:bg-white/10 transition-colors" onClick={() => onPlaySong(song, allSongs)}>
<span className="text-zinc-400 dark:text-zinc-500 w-6 text-center group-hover:hidden">{idx + 1}</span>
<span className="text-zinc-900 dark:text-white w-6 text-center hidden group-hover:block"><Play size={14} fill="currentColor" /></span>
{song.coverUrl ? (
<img src={song.coverUrl} className="w-10 h-10 rounded object-cover shadow-sm" alt="" onError={(e) => { e.currentTarget.style.display = 'none'; }} />
) : (
<AlbumCover seed={song.id || song.title} size="sm" className="w-10 h-10" />
)}
<div className="flex-1 min-w-0">
<div className="text-zinc-900 dark:text-white font-medium truncate">{song.title}</div>
<div className="text-zinc-500 dark:text-zinc-400 text-xs">{song.style}</div>
</div>
<div className="text-zinc-500 dark:text-zinc-400 text-sm font-mono">{song.duration}</div>
<div className="relative ml-2">
<button
className="p-2 rounded-full hover:bg-zinc-200 dark:hover:bg-white/5 text-zinc-400 hover:text-black dark:hover:text-white transition-colors"
onClick={(e) => {
e.stopPropagation();
setShareSong(prev => prev?.id === song.id ? null : song);
}}
>
<MoreHorizontal size={16} />
</button>
<SongDropdownMenu
song={song}
isOpen={shareSong?.id === song.id}
onClose={() => setShareSong(null)}
isOwner={user ? song.userId === user.id : false}
onCreateVideo={() => onOpenVideo?.(song)}
onReusePrompt={() => onReusePrompt?.(song)}
onAddToPlaylist={() => onAddToPlaylist(song)}
onDelete={() => onDeleteSong?.(song)}
onShare={() => {
setShareModalOpen(true);
}}
/>
</div>
</div>
))
)}
</div>
)}
{activeTab === 'liked' && (
<div> <div>
<div className="bg-gradient-to-b from-indigo-500/10 to-zinc-50 dark:from-indigo-800/50 dark:to-zinc-900/50 p-6 rounded-xl flex items-end gap-6 mb-8 cursor-pointer hover:bg-zinc-100 dark:hover:bg-white/5 transition-colors group border border-zinc-200 dark:border-white/5" onClick={() => likedSongs.length > 0 && onPlaySong(likedSongs[0], likedSongs)}> <div className="bg-gradient-to-b from-indigo-500/10 to-zinc-50 dark:from-indigo-800/50 dark:to-zinc-900/50 p-6 rounded-xl flex items-end gap-6 mb-8 cursor-pointer hover:bg-zinc-100 dark:hover:bg-white/5 transition-colors group border border-zinc-200 dark:border-white/5" onClick={() => likedSongs.length > 0 && onPlaySong(likedSongs[0], likedSongs)}>
<div className="w-40 h-40 bg-gradient-to-br from-indigo-500 to-purple-400 rounded shadow-2xl flex items-center justify-center"> <div className="w-40 h-40 bg-gradient-to-br from-indigo-500 to-purple-400 rounded shadow-2xl flex items-center justify-center">
@@ -91,11 +200,36 @@ export const LibraryView: React.FC<LibraryViewProps> = ({
<div className="text-zinc-500 dark:text-zinc-400 text-sm font-mono">{song.duration}</div> <div className="text-zinc-500 dark:text-zinc-400 text-sm font-mono">{song.duration}</div>
<div className="text-green-500"><Heart fill="#22c55e" size={16} /></div> <div className="text-green-500"><Heart fill="#22c55e" size={16} /></div>
<div className="relative ml-2">
<button
className="p-2 rounded-full hover:bg-zinc-200 dark:hover:bg-white/5 text-zinc-400 hover:text-black dark:hover:text-white transition-colors"
onClick={(e) => {
e.stopPropagation();
setShareSong(prev => prev?.id === song.id ? null : song);
}}
>
<MoreHorizontal size={16} />
</button>
<SongDropdownMenu
song={song}
isOpen={shareSong?.id === song.id}
onClose={() => setShareSong(null)}
isOwner={user ? song.userId === user.id : false}
onCreateVideo={() => onOpenVideo?.(song)}
onReusePrompt={() => onReusePrompt?.(song)}
onAddToPlaylist={() => onAddToPlaylist(song)}
onDelete={() => onDeleteSong?.(song)}
onShare={() => {
setShareModalOpen(true);
}}
/>
</div>
</div> </div>
))} ))}
</div> </div>
</div> </div>
) : ( )}
{activeTab === 'playlists' && (
<div className="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-4 xl:grid-cols-5 gap-6"> <div className="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-4 xl:grid-cols-5 gap-6">
{playlists.map((playlist) => ( {playlists.map((playlist) => (
<div key={playlist.id} className="bg-white dark:bg-zinc-900/40 p-4 rounded-lg border border-zinc-200 dark:border-white/5 hover:border-zinc-300 dark:hover:border-white/10 hover:shadow-lg dark:hover:bg-zinc-900 transition-all group cursor-pointer" onClick={() => onSelectPlaylist(playlist)}> <div key={playlist.id} className="bg-white dark:bg-zinc-900/40 p-4 rounded-lg border border-zinc-200 dark:border-white/5 hover:border-zinc-300 dark:hover:border-white/10 hover:shadow-lg dark:hover:bg-zinc-900 transition-all group cursor-pointer" onClick={() => onSelectPlaylist(playlist)}>
@@ -112,6 +246,42 @@ export const LibraryView: React.FC<LibraryViewProps> = ({
))} ))}
</div> </div>
)} )}
{activeTab === 'uploads' && (
<div className="space-y-2">
{referenceTracks.length === 0 ? (
<div className="text-sm text-zinc-500 dark:text-zinc-400">No uploads yet.</div>
) : (
referenceTracks.map((track) => (
<div key={track.id} className="flex items-center gap-4 p-3 rounded-lg border border-zinc-200 dark:border-white/5 bg-white dark:bg-zinc-900/40">
<div className="w-10 h-10 rounded bg-zinc-100 dark:bg-zinc-800 flex items-center justify-center">
<Music size={18} className="text-zinc-500 dark:text-zinc-400" />
</div>
<div className="flex-1 min-w-0">
<div className="text-sm font-medium text-zinc-900 dark:text-white truncate">{track.filename}</div>
<div className="text-xs text-zinc-500 dark:text-zinc-400">
{formatBytes(track.file_size_bytes)} {new Date(track.created_at).toLocaleDateString()}
</div>
</div>
<button
className="p-2 rounded-full hover:bg-zinc-200 dark:hover:bg-white/5 text-zinc-500 hover:text-red-600 transition-colors"
onClick={() => onDeleteReferenceTrack?.(track.id)}
title="Delete upload"
>
<Trash2 size={16} />
</button>
</div>
))
)}
</div>
)}
</div> </div>
{shareSong && (
<ShareModal
isOpen={shareModalOpen}
onClose={() => { setShareModalOpen(false); setShareSong(null); }}
song={shareSong}
/>
)}
</>
); );
}; };
+102
View File
@@ -95,6 +95,25 @@ export const RightSidebar: React.FC<RightSidebarProps> = ({ song, onClose, onOpe
} }
}; };
const getSourceLabel = (url?: string) => {
if (!url) return 'None';
try {
const parsed = new URL(url, window.location.origin);
const name = decodeURIComponent(parsed.pathname.split('/').pop() || url);
return name.replace(/\.[^/.]+$/, '') || name;
} catch {
const parts = url.split('/');
const name = decodeURIComponent(parts[parts.length - 1] || url);
return name.replace(/\.[^/.]+$/, '') || name;
}
};
const openSource = (url?: string) => {
if (!url) return;
const resolved = url.startsWith('http') ? url : `${window.location.origin}${url}`;
window.open(resolved, '_blank');
};
if (!song) return ( if (!song) return (
<div className="w-full h-full bg-zinc-50 dark:bg-suno-panel border-l border-zinc-200 dark:border-white/5 flex items-center justify-center text-zinc-400 dark:text-zinc-500 text-sm transition-colors duration-300"> <div className="w-full h-full bg-zinc-50 dark:bg-suno-panel border-l border-zinc-200 dark:border-white/5 flex items-center justify-center text-zinc-400 dark:text-zinc-500 text-sm transition-colors duration-300">
<div className="flex flex-col items-center gap-2"> <div className="flex flex-col items-center gap-2">
@@ -347,6 +366,89 @@ export const RightSidebar: React.FC<RightSidebarProps> = ({ song, onClose, onOpe
</div> </div>
</div> </div>
{(song.generationParams?.referenceAudioUrl || song.generationParams?.sourceAudioUrl) && (
<div className="space-y-3">
<div className="flex items-center gap-2 text-xs font-bold text-zinc-500 dark:text-zinc-400 uppercase tracking-wide">
<LinkIcon size={14} />
Sources
</div>
<div className="space-y-2">
{song.generationParams?.referenceAudioUrl && (
<div className="flex items-center justify-between gap-3 rounded-lg border border-zinc-200 dark:border-white/10 bg-white dark:bg-zinc-900/40 px-3 py-2">
<div className="flex items-center gap-2 min-w-0">
<Music size={14} className="text-zinc-400" />
<div className="min-w-0">
<div className="text-xs text-zinc-500">Reference</div>
<div className="text-sm font-medium text-zinc-900 dark:text-white truncate">
{song.generationParams?.referenceAudioTitle || getSourceLabel(song.generationParams?.referenceAudioUrl)}
</div>
</div>
</div>
<button
className="text-xs px-2 py-1 rounded-full border border-zinc-200 dark:border-white/10 text-zinc-600 dark:text-zinc-300 hover:bg-zinc-100 dark:hover:bg-white/10 transition-colors"
onClick={() => {
if (!song.generationParams?.referenceAudioUrl || !onPlay) return;
const previewSong = {
id: `ref_${song.id}`,
title: song.generationParams?.referenceAudioTitle || getSourceLabel(song.generationParams?.referenceAudioUrl),
lyrics: '',
style: 'Reference',
coverUrl: song.coverUrl,
duration: '0:00',
createdAt: new Date(),
tags: [],
audioUrl: song.generationParams?.referenceAudioUrl,
isPublic: false,
userId: song.userId,
creator: song.creator,
};
onPlay(previewSong);
}}
>
Play
</button>
</div>
)}
{song.generationParams?.sourceAudioUrl && (
<div className="flex items-center justify-between gap-3 rounded-lg border border-zinc-200 dark:border-white/10 bg-white dark:bg-zinc-900/40 px-3 py-2">
<div className="flex items-center gap-2 min-w-0">
<Layers size={14} className="text-zinc-400" />
<div className="min-w-0">
<div className="text-xs text-zinc-500">Cover</div>
<div className="text-sm font-medium text-zinc-900 dark:text-white truncate">
{song.generationParams?.sourceAudioTitle || getSourceLabel(song.generationParams?.sourceAudioUrl)}
</div>
</div>
</div>
<button
className="text-xs px-2 py-1 rounded-full border border-zinc-200 dark:border-white/10 text-zinc-600 dark:text-zinc-300 hover:bg-zinc-100 dark:hover:bg-white/10 transition-colors"
onClick={() => {
if (!song.generationParams?.sourceAudioUrl || !onPlay) return;
const previewSong = {
id: `cover_${song.id}`,
title: song.generationParams?.sourceAudioTitle || getSourceLabel(song.generationParams?.sourceAudioUrl),
lyrics: '',
style: 'Cover',
coverUrl: song.coverUrl,
duration: '0:00',
createdAt: new Date(),
tags: [],
audioUrl: song.generationParams?.sourceAudioUrl,
isPublic: false,
userId: song.userId,
creator: song.creator,
};
onPlay(previewSong);
}}
>
Play
</button>
</div>
)}
</div>
</div>
)}
<div className="h-px bg-zinc-200 dark:bg-white/5 w-full"></div> <div className="h-px bg-zinc-200 dark:bg-white/5 w-full"></div>
{/* Tags / Style */} {/* Tags / Style */}
+28 -6
View File
@@ -26,6 +26,8 @@ interface SongDropdownMenuProps {
onDownload?: () => void; onDownload?: () => void;
onShare?: () => void; onShare?: () => void;
onDelete?: () => void; onDelete?: () => void;
onUseAsReference?: () => void;
onCoverSong?: () => void;
} }
interface MenuItemProps { interface MenuItemProps {
@@ -70,7 +72,9 @@ export const SongDropdownMenu: React.FC<SongDropdownMenuProps> = ({
onAddToPlaylist, onAddToPlaylist,
onDownload, onDownload,
onShare, onShare,
onDelete onDelete,
onUseAsReference,
onCoverSong
}) => { }) => {
const menuRef = useRef<HTMLDivElement>(null); const menuRef = useRef<HTMLDivElement>(null);
@@ -185,11 +189,29 @@ export const SongDropdownMenu: React.FC<SongDropdownMenuProps> = ({
label="Extract Stems" label="Extract Stems"
onClick={onExtractStems ? () => handleAction(onExtractStems) : handleExtractStems} onClick={onExtractStems ? () => handleAction(onExtractStems) : handleExtractStems}
/> />
<MenuItem {onReusePrompt && (
icon={<Repeat size={14} />} <MenuItem
label="Reuse Prompt" icon={<Repeat size={14} />}
onClick={() => handleAction(onReusePrompt)} label="Reuse Prompt"
/> onClick={() => handleAction(onReusePrompt)}
/>
)}
{onUseAsReference && (
<MenuItem
icon={<Layers size={14} />}
label="Use as Reference"
onClick={() => handleAction(onUseAsReference)}
disabled={!song.audioUrl}
/>
)}
{onCoverSong && (
<MenuItem
icon={<Layers size={14} />}
label="Cover Song"
onClick={() => handleAction(onCoverSong)}
disabled={!song.audioUrl}
/>
)}
<MenuDivider /> <MenuDivider />
+315 -25
View File
@@ -12,6 +12,7 @@ interface SongListProps {
selectedSong: Song | null; selectedSong: Song | null;
likedSongIds: Set<string>; likedSongIds: Set<string>;
isPlaying: boolean; isPlaying: boolean;
referenceTracks?: { id: string; filename: string; audio_url: string; duration?: number | null; created_at?: string }[];
onPlay: (song: Song) => void; onPlay: (song: Song) => void;
onSelect: (song: Song) => void; onSelect: (song: Song) => void;
onToggleLike: (songId: string) => void; onToggleLike: (songId: string) => void;
@@ -21,6 +22,11 @@ interface SongListProps {
onNavigateToProfile?: (username: string) => void; onNavigateToProfile?: (username: string) => void;
onReusePrompt?: (song: Song) => void; onReusePrompt?: (song: Song) => void;
onDelete?: (song: Song) => void; onDelete?: (song: Song) => void;
onDeleteMany?: (songs: Song[]) => void;
onUseAsReference?: (song: Song) => void;
onCoverSong?: (song: Song) => void;
onUseUploadAsReference?: (track: { audio_url: string; filename: string }) => void;
onCoverUpload?: (track: { audio_url: string; filename: string }) => void;
} }
// ... existing code ... // ... existing code ...
@@ -37,12 +43,45 @@ const FILTERS: { id: FilterType; label: string; icon: React.ReactNode }[] = [
{ id: 'generating', label: 'Generating', icon: <Loader2 size={16} /> }, { id: 'generating', label: 'Generating', icon: <Loader2 size={16} /> },
]; ];
const createDragPreview = (element: HTMLElement) => {
const clone = element.cloneNode(true) as HTMLElement;
clone.style.width = `${element.offsetWidth}px`;
clone.style.position = 'fixed';
clone.style.top = '-1000px';
clone.style.left = '-1000px';
clone.style.pointerEvents = 'none';
clone.style.opacity = '0.95';
const badge = document.createElement('div');
badge.textContent = '+';
badge.style.position = 'absolute';
badge.style.left = '8px';
badge.style.bottom = '8px';
badge.style.width = '24px';
badge.style.height = '24px';
badge.style.display = 'flex';
badge.style.alignItems = 'center';
badge.style.justifyContent = 'center';
badge.style.borderRadius = '9999px';
badge.style.background = '#22c55e';
badge.style.color = 'white';
badge.style.boxShadow = '0 6px 16px rgba(0,0,0,0.25)';
badge.style.fontSize = '16px';
badge.style.lineHeight = '1';
clone.style.position = 'relative';
clone.appendChild(badge);
document.body.appendChild(clone);
return clone;
};
export const SongList: React.FC<SongListProps> = ({ export const SongList: React.FC<SongListProps> = ({
songs, songs,
currentSong, currentSong,
selectedSong, selectedSong,
likedSongIds, likedSongIds,
isPlaying, isPlaying,
referenceTracks = [],
onPlay, onPlay,
onSelect, onSelect,
onToggleLike, onToggleLike,
@@ -51,12 +90,19 @@ export const SongList: React.FC<SongListProps> = ({
onShowDetails, onShowDetails,
onNavigateToProfile, onNavigateToProfile,
onReusePrompt, onReusePrompt,
onDelete onDelete,
onDeleteMany,
onUseAsReference,
onCoverSong,
onUseUploadAsReference,
onCoverUpload
}) => { }) => {
const { user } = useAuth(); const { user } = useAuth();
const [searchQuery, setSearchQuery] = useState(''); const [searchQuery, setSearchQuery] = useState('');
const [activeFilters, setActiveFilters] = useState<Set<FilterType>>(new Set()); const [activeFilters, setActiveFilters] = useState<Set<FilterType>>(new Set());
const [isFilterOpen, setIsFilterOpen] = useState(false); const [isFilterOpen, setIsFilterOpen] = useState(false);
const [isSelecting, setIsSelecting] = useState(false);
const [selectedIds, setSelectedIds] = useState<Set<string>>(new Set());
const filterRef = useRef<HTMLDivElement>(null); const filterRef = useRef<HTMLDivElement>(null);
// Close filter dropdown when clicking outside // Close filter dropdown when clicking outside
@@ -70,6 +116,18 @@ export const SongList: React.FC<SongListProps> = ({
return () => document.removeEventListener('mousedown', handleClickOutside); return () => document.removeEventListener('mousedown', handleClickOutside);
}, []); }, []);
useEffect(() => {
setSelectedIds(prev => {
if (prev.size === 0) return prev;
const validIds = new Set(songs.map(song => song.id));
const next = new Set<string>();
prev.forEach(id => {
if (validIds.has(id)) next.add(id);
});
return next;
});
}, [songs]);
const toggleFilter = (filterId: FilterType) => { const toggleFilter = (filterId: FilterType) => {
setActiveFilters(prev => { setActiveFilters(prev => {
const newFilters = new Set(prev); const newFilters = new Set(prev);
@@ -104,6 +162,39 @@ export const SongList: React.FC<SongListProps> = ({
}); });
}, [songs, searchQuery, activeFilters, likedSongIds]); }, [songs, searchQuery, activeFilters, likedSongIds]);
const filteredUploads = useMemo(() => {
if (activeFilters.size > 0) return [];
if (!referenceTracks.length) return [];
return referenceTracks.filter(track => {
const title = track.filename.replace(/\.[^/.]+$/, '');
return title.toLowerCase().includes(searchQuery.toLowerCase());
});
}, [referenceTracks, searchQuery, activeFilters]);
const listItems = useMemo(() => {
const songItems = filteredSongs.map(song => ({
type: 'song' as const,
id: song.id,
createdAt: song.createdAt,
song
}));
const uploadItems = filteredUploads.map(track => ({
type: 'upload' as const,
id: track.id,
createdAt: new Date(track.created_at || Date.now()),
track
}));
return [...songItems, ...uploadItems].sort((a, b) => b.createdAt.getTime() - a.createdAt.getTime());
}, [filteredSongs, filteredUploads]);
const selectableSongs = useMemo(
() => filteredSongs.filter(song => !song.isGenerating),
[filteredSongs]
);
const allSelected = selectableSongs.length > 0 && selectableSongs.every(song => selectedIds.has(song.id));
const selectedSongs = selectableSongs.filter(song => selectedIds.has(song.id));
return ( return (
<div className="flex-1 bg-white dark:bg-black h-full overflow-y-auto custom-scrollbar p-6 pb-32 transition-colors duration-300"> <div className="flex-1 bg-white dark:bg-black h-full overflow-y-auto custom-scrollbar p-6 pb-32 transition-colors duration-300">
<div className="max-w-5xl mx-auto w-full"> {/* Container constraint */} <div className="max-w-5xl mx-auto w-full"> {/* Container constraint */}
@@ -175,12 +266,62 @@ export const SongList: React.FC<SongListProps> = ({
</div> </div>
)} )}
</div> </div>
<button
onClick={() => {
setIsSelecting(prev => !prev);
setSelectedIds(new Set());
}}
className={`border text-xs font-bold px-4 py-2.5 rounded-lg flex items-center gap-2 transition-all select-none ${isSelecting
? 'bg-zinc-900 dark:bg-white text-white dark:text-black border-transparent'
: 'bg-zinc-100 dark:bg-[#121214] hover:bg-zinc-200 dark:hover:bg-white/5 border-zinc-200 dark:border-white/10 text-zinc-700 dark:text-white'
}`}
>
Select
</button>
</div> </div>
{isSelecting && (
<div className="flex items-center justify-between gap-3 rounded-xl border border-zinc-200 dark:border-white/10 bg-zinc-50 dark:bg-white/5 px-4 py-3">
<div className="text-sm text-zinc-600 dark:text-zinc-300">
{selectedSongs.length} selected
</div>
<div className="flex items-center gap-2">
<button
onClick={() => {
const next = new Set<string>();
if (!allSelected) {
selectableSongs.forEach(song => next.add(song.id));
}
setSelectedIds(next);
}}
className="px-3 py-1.5 rounded-lg text-xs font-semibold border border-zinc-200 dark:border-white/10 text-zinc-600 dark:text-zinc-300 hover:border-zinc-300 dark:hover:border-white/20"
>
{allSelected ? 'Clear all' : 'Select all'}
</button>
<button
onClick={() => {
if (!selectedSongs.length) return;
onDeleteMany?.(selectedSongs);
setSelectedIds(new Set());
setIsSelecting(false);
}}
className={`px-3 py-1.5 rounded-lg text-xs font-semibold border ${selectedSongs.length
? 'border-red-500 text-red-600 hover:bg-red-50 dark:hover:bg-red-500/10'
: 'border-zinc-200 dark:border-white/10 text-zinc-400 cursor-not-allowed'
}`}
disabled={!selectedSongs.length}
>
Delete
</button>
</div>
</div>
)}
</div> </div>
{/* List */} {/* List */}
<div className="space-y-2"> {/* Reduced vertical spacing */} <div className="space-y-2"> {/* Reduced vertical spacing */}
{filteredSongs.length === 0 ? ( {listItems.length === 0 ? (
<div className="flex flex-col items-center justify-center h-64 text-zinc-500 space-y-4 border border-dashed border-zinc-200 dark:border-white/5 rounded-2xl bg-zinc-50 dark:bg-white/[0.02]"> <div className="flex flex-col items-center justify-center h-64 text-zinc-500 space-y-4 border border-dashed border-zinc-200 dark:border-white/5 rounded-2xl bg-zinc-50 dark:bg-white/[0.02]">
<div className="w-16 h-16 rounded-full bg-zinc-100 dark:bg-white/5 flex items-center justify-center"> <div className="w-16 h-16 rounded-full bg-zinc-100 dark:bg-white/5 flex items-center justify-center">
<Filter size={32} /> <Filter size={32} />
@@ -194,25 +335,61 @@ export const SongList: React.FC<SongListProps> = ({
</button> </button>
</div> </div>
) : ( ) : (
filteredSongs.map((song) => ( listItems.map((item) => (
<SongItem item.type === 'song' ? (
key={song.id} <SongItem
song={song} key={item.id}
isCurrent={currentSong?.id === song.id} song={item.song}
isSelected={selectedSong?.id === song.id} isCurrent={currentSong?.id === item.song.id}
isLiked={likedSongIds.has(song.id)} isSelected={selectedSong?.id === item.song.id}
isPlaying={isPlaying} isSelectionMode={isSelecting}
isOwner={user?.id === song.userId} isChecked={selectedIds.has(item.song.id)}
onPlay={() => onPlay(song)} isLiked={likedSongIds.has(item.song.id)}
onSelect={() => onSelect(song)} isPlaying={isPlaying}
onToggleLike={() => onToggleLike(song.id)} isOwner={user?.id === item.song.userId}
onAddToPlaylist={() => onAddToPlaylist(song)} onPlay={() => onPlay(item.song)}
onOpenVideo={() => onOpenVideo && onOpenVideo(song)} onSelect={() => onSelect(item.song)}
onShowDetails={() => onShowDetails && onShowDetails(song)} onToggleSelect={() => {
onNavigateToProfile={onNavigateToProfile} if (item.song.isGenerating) return;
onReusePrompt={() => onReusePrompt?.(song)} setSelectedIds(prev => {
onDelete={() => onDelete?.(song)} const next = new Set(prev);
/> if (next.has(item.song.id)) next.delete(item.song.id);
else next.add(item.song.id);
return next;
});
}}
onToggleLike={() => onToggleLike(item.song.id)}
onAddToPlaylist={() => onAddToPlaylist(item.song)}
onOpenVideo={() => onOpenVideo && onOpenVideo(item.song)}
onShowDetails={() => onShowDetails && onShowDetails(item.song)}
onNavigateToProfile={onNavigateToProfile}
onReusePrompt={() => onReusePrompt?.(item.song)}
onDelete={() => onDelete?.(item.song)}
onUseAsReference={() => onUseAsReference?.(item.song)}
onCoverSong={() => onCoverSong?.(item.song)}
/>
) : (
<UploadItem
key={`upload_${item.id}`}
track={item.track}
onPlay={(audioUrl, title) => {
onPlay({
id: `upload_${item.id}`,
title,
lyrics: '',
style: 'Upload',
coverUrl: '',
duration: '0:00',
createdAt: item.createdAt,
tags: [],
audioUrl,
isPublic: false,
} as Song);
}}
onUseAsReference={() => onUseUploadAsReference?.(item.track)}
onCoverSong={() => onCoverUpload?.(item.track)}
/>
)
)) ))
)} )}
</div> </div>
@@ -225,11 +402,14 @@ interface SongItemProps {
song: Song; song: Song;
isCurrent: boolean; isCurrent: boolean;
isSelected: boolean; isSelected: boolean;
isSelectionMode: boolean;
isChecked: boolean;
isLiked: boolean; isLiked: boolean;
isPlaying: boolean; isPlaying: boolean;
isOwner: boolean; isOwner: boolean;
onPlay: () => void; onPlay: () => void;
onSelect: () => void; onSelect: () => void;
onToggleSelect: () => void;
onToggleLike: () => void; onToggleLike: () => void;
onAddToPlaylist: () => void; onAddToPlaylist: () => void;
onOpenVideo?: () => void; onOpenVideo?: () => void;
@@ -237,24 +417,31 @@ interface SongItemProps {
onNavigateToProfile?: (username: string) => void; onNavigateToProfile?: (username: string) => void;
onReusePrompt?: () => void; onReusePrompt?: () => void;
onDelete?: () => void; onDelete?: () => void;
onUseAsReference?: () => void;
onCoverSong?: () => void;
} }
const SongItem: React.FC<SongItemProps> = ({ const SongItem: React.FC<SongItemProps> = ({
song, song,
isCurrent, isCurrent,
isSelected, isSelected,
isSelectionMode,
isChecked,
isLiked, isLiked,
isPlaying, isPlaying,
isOwner, isOwner,
onPlay, onPlay,
onSelect, onSelect,
onToggleSelect,
onToggleLike, onToggleLike,
onAddToPlaylist, onAddToPlaylist,
onOpenVideo, onOpenVideo,
onShowDetails, onShowDetails,
onNavigateToProfile, onNavigateToProfile,
onReusePrompt, onReusePrompt,
onDelete onDelete,
onUseAsReference,
onCoverSong
}) => { }) => {
const [showDropdown, setShowDropdown] = useState(false); const [showDropdown, setShowDropdown] = useState(false);
const [shareModalOpen, setShareModalOpen] = useState(false); const [shareModalOpen, setShareModalOpen] = useState(false);
@@ -264,8 +451,47 @@ const SongItem: React.FC<SongItemProps> = ({
<> <>
<div <div
onClick={onSelect} onClick={onSelect}
className={`group flex items-center gap-4 p-2 rounded-lg hover:bg-zinc-100 dark:hover:bg-[#18181b] transition-all cursor-pointer border ${isSelected ? 'bg-zinc-100 dark:bg-[#18181b] border-zinc-200 dark:border-white/10' : 'border-transparent bg-transparent'}`} draggable={Boolean(song.audioUrl) && !song.isGenerating}
onDragStart={(e) => {
if (!song.audioUrl || song.isGenerating) return;
e.dataTransfer.effectAllowed = 'copy';
e.dataTransfer.setData('application/x-ace-audio', JSON.stringify({
url: song.audioUrl,
title: song.title || 'Untitled',
source: 'song',
}));
const preview = createDragPreview(e.currentTarget);
const rect = e.currentTarget.getBoundingClientRect();
const offsetX = Math.max(0, Math.min(rect.width, e.clientX - rect.left));
const offsetY = Math.max(0, Math.min(rect.height, e.clientY - rect.top));
e.dataTransfer.setDragImage(preview, offsetX, offsetY);
setTimeout(() => {
try {
preview.remove();
} catch {
// ignore
}
}, 0);
}}
className={`group flex items-center gap-4 p-2 rounded-lg hover:bg-zinc-100 dark:hover:bg-[#18181b] transition-all cursor-pointer border ${isSelected ? 'bg-zinc-100 dark:bg-[#18181b] border-zinc-200 dark:border-white/10' : 'border-transparent bg-transparent'} ${song.audioUrl && !song.isGenerating ? 'cursor-grab active:cursor-grabbing' : ''}`}
> >
{isSelectionMode && (
<button
type="button"
onClick={(e) => {
e.stopPropagation();
onToggleSelect();
}}
className={`w-5 h-5 rounded border flex items-center justify-center transition-colors ${isChecked
? 'bg-pink-600 border-pink-600 text-white'
: 'border-zinc-300 dark:border-zinc-600 text-transparent hover:border-zinc-400 dark:hover:border-zinc-500'
} ${song.isGenerating ? 'opacity-40 cursor-not-allowed' : ''}`}
disabled={song.isGenerating}
aria-pressed={isChecked}
>
<Check size={12} strokeWidth={3} className={isChecked ? 'text-white' : 'text-transparent'} />
</button>
)}
{/* Cover Art - Reduced size */} {/* Cover Art - Reduced size */}
<div className="relative w-16 h-16 flex-shrink-0 rounded-md bg-zinc-200 dark:bg-zinc-800 overflow-hidden shadow-sm group/image"> <div className="relative w-16 h-16 flex-shrink-0 rounded-md bg-zinc-200 dark:bg-zinc-800 overflow-hidden shadow-sm group/image">
@@ -355,6 +581,21 @@ const SongItem: React.FC<SongItemProps> = ({
<p className="text-xs text-zinc-500 dark:text-zinc-500 line-clamp-2 pt-1 font-medium max-w-2xl"> <p className="text-xs text-zinc-500 dark:text-zinc-500 line-clamp-2 pt-1 font-medium max-w-2xl">
{song.style} {song.style}
</p> </p>
{song.isGenerating && (
<div className="pt-2">
<div className="h-1 rounded-full bg-zinc-200/70 dark:bg-white/10 overflow-hidden">
<div
className={`h-full bg-gradient-to-r from-pink-500 to-purple-600 transition-all ${song.progress === undefined ? 'opacity-40' : ''}`}
style={{
width: `${Math.min(
100,
Math.max(0, ((song.progress ?? 0) > 1 ? (song.progress ?? 0) / 100 : (song.progress ?? 0)) * 100)
)}%`,
}}
/>
</div>
</div>
)}
</div> </div>
{/* Actions Row - Hidden while generating */} {/* Actions Row - Hidden while generating */}
@@ -426,10 +667,12 @@ const SongItem: React.FC<SongItemProps> = ({
onClose={() => setShowDropdown(false)} onClose={() => setShowDropdown(false)}
isOwner={isOwner} isOwner={isOwner}
onCreateVideo={() => onOpenVideo?.(song)} onCreateVideo={() => onOpenVideo?.(song)}
onReusePrompt={() => onReusePrompt?.(song)} onReusePrompt={onReusePrompt ? () => onReusePrompt?.(song) : undefined}
onAddToPlaylist={() => onAddToPlaylist?.(song)} onAddToPlaylist={() => onAddToPlaylist?.(song)}
onDelete={() => onDelete?.(song)} onDelete={() => onDelete?.(song)}
onShare={() => setShareModalOpen(true)} onShare={() => setShareModalOpen(true)}
onUseAsReference={() => onUseAsReference?.()}
onCoverSong={() => onCoverSong?.()}
/> />
</div> </div>
</div> </div>
@@ -453,4 +696,51 @@ const SongItem: React.FC<SongItemProps> = ({
/> />
</> </>
); );
}; };
const UploadItem: React.FC<{
track: { id: string; filename: string; audio_url: string; duration?: number | null };
onPlay: (audioUrl: string, title: string) => void;
onUseAsReference?: () => void;
onCoverSong?: () => void;
}> = ({ track, onPlay, onUseAsReference, onCoverSong }) => {
const title = track.filename.replace(/\.[^/.]+$/, '');
const duration = track.duration
? `${Math.floor(track.duration / 60)}:${String(Math.floor(track.duration % 60)).padStart(2, '0')}`
: '--:--';
return (
<SongItem
song={{
id: `upload_${track.id}`,
title,
lyrics: '',
style: 'Upload',
coverUrl: '',
duration,
createdAt: new Date(),
tags: [],
audioUrl: track.audio_url,
isPublic: false,
} as Song}
isCurrent={false}
isSelected={false}
isSelectionMode={false}
isChecked={false}
isLiked={false}
isPlaying={false}
isOwner={false}
onPlay={() => onPlay(track.audio_url, title)}
onSelect={() => onPlay(track.audio_url, title)}
onToggleSelect={() => undefined}
onToggleLike={() => undefined}
onAddToPlaylist={() => undefined}
onOpenVideo={() => undefined}
onShowDetails={() => undefined}
onNavigateToProfile={() => undefined}
onReusePrompt={undefined}
onDelete={() => undefined}
onUseAsReference={onUseAsReference}
onCoverSong={onCoverSong}
/>
);
};
-5
View File
@@ -53,7 +53,6 @@
"integrity": "sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA==", "integrity": "sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"@babel/code-frame": "^7.29.0", "@babel/code-frame": "^7.29.0",
"@babel/generator": "^7.29.0", "@babel/generator": "^7.29.0",
@@ -1486,7 +1485,6 @@
} }
], ],
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"baseline-browser-mapping": "^2.9.0", "baseline-browser-mapping": "^2.9.0",
"caniuse-lite": "^1.0.30001759", "caniuse-lite": "^1.0.30001759",
@@ -2138,7 +2136,6 @@
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"peer": true,
"engines": { "engines": {
"node": ">=12" "node": ">=12"
}, },
@@ -2204,7 +2201,6 @@
"resolved": "https://registry.npmjs.org/react/-/react-19.2.4.tgz", "resolved": "https://registry.npmjs.org/react/-/react-19.2.4.tgz",
"integrity": "sha512-9nfp2hYpCwOjAN+8TZFGhtWEwgvWHXqESH8qT89AT/lWklpLON22Lc8pEtnpsZz7VmawabSU0gCjnj8aC0euHQ==", "integrity": "sha512-9nfp2hYpCwOjAN+8TZFGhtWEwgvWHXqESH8qT89AT/lWklpLON22Lc8pEtnpsZz7VmawabSU0gCjnj8aC0euHQ==",
"license": "MIT", "license": "MIT",
"peer": true,
"engines": { "engines": {
"node": ">=0.10.0" "node": ">=0.10.0"
} }
@@ -2540,7 +2536,6 @@
"integrity": "sha512-+Oxm7q9hDoLMyJOYfUYBuHQo+dkAloi33apOPP56pzj+vsdJDzr+j1NISE5pyaAuKL4A3UD34qd0lx5+kfKp2g==", "integrity": "sha512-+Oxm7q9hDoLMyJOYfUYBuHQo+dkAloi33apOPP56pzj+vsdJDzr+j1NISE5pyaAuKL4A3UD34qd0lx5+kfKp2g==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"esbuild": "^0.25.0", "esbuild": "^0.25.0",
"fdir": "^6.4.4", "fdir": "^6.4.4",
+8 -1
View File
@@ -9,6 +9,7 @@ import json
import os import os
import sys import sys
import time import time
import torch
# Get ACE-Step path from environment or use default # Get ACE-Step path from environment or use default
ACESTEP_PATH = os.environ.get('ACESTEP_PATH', '/home/ambsd/Desktop/aceui/ACE-Step-1.5') ACESTEP_PATH = os.environ.get('ACESTEP_PATH', '/home/ambsd/Desktop/aceui/ACE-Step-1.5')
@@ -27,12 +28,18 @@ def get_llm_handler():
# Initialize the LLM with the 0.6B model (lighter on VRAM) # Initialize the LLM with the 0.6B model (lighter on VRAM)
checkpoint_dir = os.path.join(ACESTEP_PATH, "checkpoints") checkpoint_dir = os.path.join(ACESTEP_PATH, "checkpoints")
lm_model_path = "acestep-5Hz-lm-0.6B" # Use the smaller 0.6B model lm_model_path = "acestep-5Hz-lm-0.6B" # Use the smaller 0.6B model
if torch.cuda.is_available():
device = "cuda"
elif torch.backends.mps.is_available():
device = "mps"
else:
device = "cpu"
status, success = _llm_handler.initialize( status, success = _llm_handler.initialize(
checkpoint_dir=checkpoint_dir, checkpoint_dir=checkpoint_dir,
lm_model_path=lm_model_path, lm_model_path=lm_model_path,
backend="pt", # Use PyTorch backend backend="pt", # Use PyTorch backend
device="cuda", device=device,
offload_to_cpu=True, offload_to_cpu=True,
) )
+25
View File
@@ -0,0 +1,25 @@
#!/usr/bin/env python3
import json
import os
import sys
ACESTEP_PATH = os.environ.get('ACESTEP_PATH', '/home/ambsd/Desktop/aceui/ACE-Step-1.5')
sys.path.insert(0, ACESTEP_PATH)
from acestep.gpu_config import get_gpu_config
def main():
cfg = get_gpu_config()
print(json.dumps({
"tier": cfg.tier,
"gpu_memory_gb": cfg.gpu_memory_gb,
"max_duration_with_lm": cfg.max_duration_with_lm,
"max_duration_without_lm": cfg.max_duration_without_lm,
"max_batch_size_with_lm": cfg.max_batch_size_with_lm,
"max_batch_size_without_lm": cfg.max_batch_size_without_lm,
}))
if __name__ == "__main__":
main()
+8 -1
View File
@@ -9,6 +9,7 @@ import json
import os import os
import sys import sys
import time import time
import torch
# Get ACE-Step path from environment or use default # Get ACE-Step path from environment or use default
ACESTEP_PATH = os.environ.get('ACESTEP_PATH', '/home/ambsd/Desktop/aceui/ACE-Step-1.5') ACESTEP_PATH = os.environ.get('ACESTEP_PATH', '/home/ambsd/Desktop/aceui/ACE-Step-1.5')
@@ -27,11 +28,17 @@ _llm_handler = None
def get_handlers(): def get_handlers():
global _handler, _llm_handler global _handler, _llm_handler
if _handler is None: if _handler is None:
if torch.cuda.is_available():
device = "cuda"
elif torch.backends.mps.is_available():
device = "mps"
else:
device = "cpu"
_handler = AceStepHandler() _handler = AceStepHandler()
_handler.initialize_service( _handler.initialize_service(
project_root=ACESTEP_PATH, project_root=ACESTEP_PATH,
config_path="acestep-v15-turbo", config_path="acestep-v15-turbo",
device="cuda", device=device,
offload_to_cpu=True, # For 12GB GPU offload_to_cpu=True, # For 12GB GPU
) )
_llm_handler = LLMHandler() # Create but don't initialize (not enough VRAM) _llm_handler = LLMHandler() # Create but don't initialize (not enough VRAM)
+72 -3
View File
@@ -34,13 +34,15 @@ const audioUpload = multer({
'audio/flac', 'audio/flac',
'audio/x-flac', 'audio/x-flac',
'audio/mp4', 'audio/mp4',
'audio/x-m4a',
'audio/aac', 'audio/aac',
'audio/ogg', 'audio/ogg',
'audio/webm', 'audio/webm',
'video/mp4',
]; ];
// Also check file extension as fallback // Also check file extension as fallback
const allowedExtensions = ['.mp3', '.wav', '.flac', '.m4a', '.aac', '.ogg', '.webm', '.opus']; const allowedExtensions = ['.mp3', '.wav', '.flac', '.m4a', '.mp4', '.aac', '.ogg', '.webm', '.opus'];
const fileExt = file.originalname.toLowerCase().match(/\.[^.]+$/)?.[0]; const fileExt = file.originalname.toLowerCase().match(/\.[^.]+$/)?.[0];
if (allowedTypes.includes(file.mimetype) || (fileExt && allowedExtensions.includes(fileExt))) { if (allowedTypes.includes(file.mimetype) || (fileExt && allowedExtensions.includes(fileExt))) {
@@ -95,6 +97,8 @@ interface GenerateBody {
// Expert Parameters // Expert Parameters
referenceAudioUrl?: string; referenceAudioUrl?: string;
sourceAudioUrl?: string; sourceAudioUrl?: string;
referenceAudioTitle?: string;
sourceAudioTitle?: string;
audioCodes?: string; audioCodes?: string;
repaintingStart?: number; repaintingStart?: number;
repaintingEnd?: number; repaintingEnd?: number;
@@ -142,10 +146,13 @@ router.post('/upload-audio', authMiddleware, audioUpload.single('audio'), async
case 'audio/ogg': case 'audio/ogg':
return '.ogg'; return '.ogg';
case 'audio/mp4': case 'audio/mp4':
case 'audio/x-m4a':
case 'audio/aac': case 'audio/aac':
return '.m4a'; return '.m4a';
case 'audio/webm': case 'audio/webm':
return '.webm'; return '.webm';
case 'video/mp4':
return '.mp4';
default: default:
return ''; return '';
} }
@@ -193,6 +200,8 @@ router.post('/', authMiddleware, async (req: AuthenticatedRequest, res: Response
lmBackend, lmBackend,
referenceAudioUrl, referenceAudioUrl,
sourceAudioUrl, sourceAudioUrl,
referenceAudioTitle,
sourceAudioTitle,
audioCodes, audioCodes,
repaintingStart, repaintingStart,
repaintingEnd, repaintingEnd,
@@ -257,6 +266,8 @@ router.post('/', authMiddleware, async (req: AuthenticatedRequest, res: Response
lmBackend, lmBackend,
referenceAudioUrl, referenceAudioUrl,
sourceAudioUrl, sourceAudioUrl,
referenceAudioTitle,
sourceAudioTitle,
audioCodes, audioCodes,
repaintingStart, repaintingStart,
repaintingEnd, repaintingEnd,
@@ -373,7 +384,8 @@ router.get('/status/:jobId', authMiddleware, async (req: AuthenticatedRequest, r
const { buffer } = await downloadAudioToBuffer(audioUrl); const { buffer } = await downloadAudioToBuffer(audioUrl);
const ext = audioUrl.includes('.flac') ? '.flac' : '.mp3'; const ext = audioUrl.includes('.flac') ? '.flac' : '.mp3';
const storageKey = `${req.user!.id}/${songId}${ext}`; const storageKey = `${req.user!.id}/${songId}${ext}`;
const storedPath = await storage.upload(storageKey, buffer, `audio/${ext.slice(1)}`); await storage.upload(storageKey, buffer, `audio/${ext.slice(1)}`);
const storedPath = storage.getPublicUrl(storageKey);
await pool.query( await pool.query(
`INSERT INTO songs (id, user_id, title, lyrics, style, caption, audio_url, `INSERT INTO songs (id, user_id, title, lyrics, style, caption, audio_url,
@@ -436,6 +448,8 @@ router.get('/status/:jobId', authMiddleware, async (req: AuthenticatedRequest, r
status: aceStatus.status, status: aceStatus.status,
queuePosition: aceStatus.queuePosition, queuePosition: aceStatus.queuePosition,
etaSeconds: aceStatus.etaSeconds, etaSeconds: aceStatus.etaSeconds,
progress: aceStatus.progress,
stage: aceStatus.stage,
result: aceStatus.result, result: aceStatus.result,
error: aceStatus.error, error: aceStatus.error,
}); });
@@ -449,6 +463,8 @@ router.get('/status/:jobId', authMiddleware, async (req: AuthenticatedRequest, r
res.json({ res.json({
jobId: req.params.jobId, jobId: req.params.jobId,
status: job.status, status: job.status,
progress: undefined,
stage: undefined,
result: job.result && typeof job.result === 'string' ? JSON.parse(job.result) : job.result, result: job.result && typeof job.result === 'string' ? JSON.parse(job.result) : job.result,
error: job.error, error: job.error,
}); });
@@ -544,6 +560,60 @@ router.get('/health', async (_req, res: Response) => {
} }
}); });
router.get('/limits', async (_req, res: Response) => {
try {
const { spawn } = await import('child_process');
const ACESTEP_DIR = process.env.ACESTEP_PATH || path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../../../../ACE-Step-1.5');
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
const SCRIPTS_DIR = path.join(__dirname, '../../scripts');
const LIMITS_SCRIPT = path.join(SCRIPTS_DIR, 'get_limits.py');
const pythonPath = resolvePythonPath(ACESTEP_DIR);
const result = await new Promise<{ success: boolean; data?: any; error?: string }>((resolve) => {
const proc = spawn(pythonPath, [LIMITS_SCRIPT], {
cwd: ACESTEP_DIR,
env: {
...process.env,
ACESTEP_PATH: ACESTEP_DIR,
},
});
let stdout = '';
let stderr = '';
proc.stdout.on('data', (data) => { stdout += data.toString(); });
proc.stderr.on('data', (data) => { stderr += data.toString(); });
proc.on('close', (code) => {
if (code === 0 && stdout) {
try {
const parsed = JSON.parse(stdout);
resolve({ success: true, data: parsed });
} catch {
resolve({ success: false, error: 'Failed to parse limits result' });
}
} else {
resolve({ success: false, error: stderr || 'Failed to read limits' });
}
});
proc.on('error', (err) => {
resolve({ success: false, error: err.message });
});
});
if (result.success && result.data) {
res.json(result.data);
} else {
res.status(500).json({ error: result.error || 'Failed to load limits' });
}
} catch (error) {
console.error('Limits error:', error);
res.status(500).json({ error: (error as Error).message });
}
});
router.get('/debug/:taskId', authMiddleware, async (req: AuthenticatedRequest, res: Response) => { router.get('/debug/:taskId', authMiddleware, async (req: AuthenticatedRequest, res: Response) => {
try { try {
const rawResponse = getJobRawResponse(req.params.taskId); const rawResponse = getJobRawResponse(req.params.taskId);
@@ -596,7 +666,6 @@ router.post('/format', authMiddleware, async (req: AuthenticatedRequest, res: Re
cwd: ACESTEP_DIR, cwd: ACESTEP_DIR,
env: { env: {
...process.env, ...process.env,
CUDA_VISIBLE_DEVICES: '0',
ACESTEP_PATH: ACESTEP_DIR, ACESTEP_PATH: ACESTEP_DIR,
}, },
}); });
+149 -4
View File
@@ -1,25 +1,130 @@
import { Router, Response } from 'express'; import { Router, Response } from 'express';
import multer from 'multer'; import multer from 'multer';
import path from 'path'; import path from 'path';
import os from 'os';
import { promises as fs } from 'fs';
import { fileURLToPath } from 'url';
import { pool } from '../db/pool.js'; import { pool } from '../db/pool.js';
import { authMiddleware, AuthenticatedRequest } from '../middleware/auth.js'; import { authMiddleware, AuthenticatedRequest } from '../middleware/auth.js';
import { getStorageProvider } from '../services/storage/factory.js'; import { getStorageProvider } from '../services/storage/factory.js';
import { spawn } from 'child_process';
const router = Router(); const router = Router();
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
const AUDIO_DIR = path.join(__dirname, '../../public/audio');
const upload = multer({ const upload = multer({
storage: multer.memoryStorage(), storage: multer.memoryStorage(),
limits: { fileSize: 50 * 1024 * 1024 }, // 50MB max limits: { fileSize: 50 * 1024 * 1024 }, // 50MB max
fileFilter: (_req, file, cb) => { fileFilter: (_req, file, cb) => {
const allowedTypes = ['audio/mpeg', 'audio/wav', 'audio/flac', 'audio/mp3', 'audio/x-wav', 'audio/x-flac']; const allowedTypes = [
if (allowedTypes.includes(file.mimetype) || file.originalname.match(/\.(mp3|wav|flac)$/i)) { 'audio/mpeg',
'audio/wav',
'audio/flac',
'audio/mp3',
'audio/x-wav',
'audio/x-flac',
'audio/mp4',
'audio/x-m4a',
'audio/aac',
'video/mp4',
];
if (allowedTypes.includes(file.mimetype) || file.originalname.match(/\.(mp3|wav|flac|m4a|mp4)$/i)) {
cb(null, true); cb(null, true);
} else { } else {
cb(new Error('Invalid file type. Only MP3, WAV, and FLAC are allowed.')); cb(new Error('Invalid file type. Only MP3, WAV, FLAC, M4A, and MP4 are allowed.'));
} }
} }
}); });
const findWhisperExecutable = async (): Promise<string | null> => {
if (process.env.WHISPER_CMD) return process.env.WHISPER_CMD;
const customPath = process.env.WHISPER_PATH;
if (customPath) {
const candidate = path.join(customPath, 'whisper');
try {
await fs.access(candidate);
return candidate;
} catch {
// ignore
}
}
const pathEntries = (process.env.PATH || '').split(path.delimiter);
for (const entry of pathEntries) {
const candidate = path.join(entry, 'whisper');
try {
await fs.access(candidate);
return candidate;
} catch {
// ignore
}
}
return null;
};
const transcribeWithWhisper = async (buffer: Buffer, originalFilename: string, signal?: AbortSignal): Promise<string | null> => {
const whisperCmd = await findWhisperExecutable();
if (!whisperCmd) return null;
const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), 'whisper-'));
const ext = path.extname(originalFilename) || '.mp3';
const inputPath = path.join(tempDir, `input${ext}`);
const outputDir = path.join(tempDir, 'out');
try {
await fs.mkdir(outputDir, { recursive: true });
await fs.writeFile(inputPath, buffer);
const args = [
inputPath,
'--model', 'base',
'--output_format', 'txt',
'--output_dir', outputDir,
'--fp16', 'False'
];
await new Promise<void>((resolve, reject) => {
const proc = spawn(whisperCmd, args, { stdio: 'ignore' });
const handleAbort = () => {
proc.kill('SIGTERM');
reject(new Error('Transcription cancelled'));
};
if (signal) {
if (signal.aborted) {
handleAbort();
return;
}
signal.addEventListener('abort', handleAbort, { once: true });
}
proc.on('error', reject);
proc.on('close', (code) => {
if (signal) {
signal.removeEventListener('abort', handleAbort);
}
if (code === 0) resolve();
else reject(new Error(`Whisper exited with code ${code}`));
});
});
const files = await fs.readdir(outputDir);
const txtFile = files.find((file) => file.endsWith('.txt'));
if (!txtFile) return null;
const text = await fs.readFile(path.join(outputDir, txtFile), 'utf8');
return text.trim() || null;
} catch (error) {
console.warn('Whisper transcription failed:', error);
return null;
} finally {
try {
await fs.rm(tempDir, { recursive: true, force: true });
} catch {
// ignore
}
}
};
// Get user's reference tracks // Get user's reference tracks
router.get('/', authMiddleware, async (req: AuthenticatedRequest, res: Response) => { router.get('/', authMiddleware, async (req: AuthenticatedRequest, res: Response) => {
try { try {
@@ -61,6 +166,7 @@ router.post('/', authMiddleware, upload.single('audio'), async (req: Authenticat
const storage = getStorageProvider(); const storage = getStorageProvider();
await storage.upload(key, req.file.buffer, req.file.mimetype); await storage.upload(key, req.file.buffer, req.file.mimetype);
const audioUrl = storage.getPublicUrl(key); const audioUrl = storage.getPublicUrl(key);
const whisperAvailable = Boolean(await findWhisperExecutable());
// Parse tags from request body if provided // Parse tags from request body if provided
const tags = req.body.tags ? JSON.parse(req.body.tags) : null; const tags = req.body.tags ? JSON.parse(req.body.tags) : null;
@@ -76,7 +182,8 @@ router.post('/', authMiddleware, upload.single('audio'), async (req: Authenticat
track: { track: {
...result.rows[0], ...result.rows[0],
audio_url: audioUrl audio_url: audioUrl
} },
whisper_available: whisperAvailable
}); });
} catch (error) { } catch (error) {
console.error('Upload reference track error:', error); console.error('Upload reference track error:', error);
@@ -142,6 +249,44 @@ router.patch('/:id', authMiddleware, async (req: AuthenticatedRequest, res: Resp
} }
}); });
// Transcribe a reference track with whisper (if available)
router.post('/:id/transcribe', authMiddleware, async (req: AuthenticatedRequest, res: Response) => {
try {
const whisperCmd = await findWhisperExecutable();
if (!whisperCmd) {
res.status(404).json({ error: 'Whisper not available' });
return;
}
const result = await pool.query(
'SELECT user_id, filename, storage_key FROM reference_tracks WHERE id = $1',
[req.params.id]
);
if (result.rows.length === 0) {
res.status(404).json({ error: 'Track not found' });
return;
}
if (result.rows[0].user_id !== req.user!.id) {
res.status(403).json({ error: 'Access denied' });
return;
}
const audioPath = path.join(AUDIO_DIR, result.rows[0].storage_key);
const buffer = await fs.readFile(audioPath);
const controller = new AbortController();
req.on('close', () => controller.abort());
const lyrics = await transcribeWithWhisper(buffer, result.rows[0].filename, controller.signal);
if (controller.signal.aborted) return;
res.json({ lyrics: lyrics || '' });
} catch (error) {
console.error('Transcribe reference track error:', error);
res.status(500).json({ error: 'Failed to transcribe' });
}
});
// Delete a reference track // Delete a reference track
router.delete('/:id', authMiddleware, async (req: AuthenticatedRequest, res: Response) => { router.delete('/:id', authMiddleware, async (req: AuthenticatedRequest, res: Response) => {
try { try {
+6 -6
View File
@@ -107,7 +107,7 @@ router.get('/', authMiddleware, async (req: AuthenticatedRequest, res: Response)
const result = await pool.query( const result = await pool.query(
`SELECT s.id, s.title, s.lyrics, s.style, s.caption, s.cover_url, s.audio_url, `SELECT s.id, s.title, s.lyrics, s.style, s.caption, s.cover_url, s.audio_url,
s.duration, s.bpm, s.key_scale, s.time_signature, s.tags, s.is_public, s.duration, s.bpm, s.key_scale, s.time_signature, s.tags, s.is_public,
s.like_count, s.view_count, s.user_id, s.created_at, s.like_count, s.view_count, s.user_id, s.created_at, s.generation_params,
COALESCE(u.username, 'Anonymous') as creator COALESCE(u.username, 'Anonymous') as creator
FROM songs s FROM songs s
LEFT JOIN users u ON s.user_id = u.id LEFT JOIN users u ON s.user_id = u.id
@@ -137,7 +137,7 @@ router.get('/public/featured', optionalAuthMiddleware, async (_req: Authenticate
const result = await pool.query( const result = await pool.query(
`SELECT s.id, s.title, s.lyrics, s.style, s.caption, s.cover_url, s.audio_url, `SELECT s.id, s.title, s.lyrics, s.style, s.caption, s.cover_url, s.audio_url,
s.duration, s.bpm, s.key_scale, s.time_signature, s.tags, s.like_count, s.view_count, s.created_at, s.user_id, s.duration, s.bpm, s.key_scale, s.time_signature, s.tags, s.like_count, s.view_count, s.created_at, s.user_id,
COALESCE(u.username, 'Anonymous') as creator, u.avatar_url as creator_avatar COALESCE(u.username, 'Anonymous') as creator, u.avatar_url as creator_avatar, s.generation_params
FROM songs s FROM songs s
LEFT JOIN users u ON s.user_id = u.id LEFT JOIN users u ON s.user_id = u.id
ORDER BY RANDOM() ORDER BY RANDOM()
@@ -184,7 +184,7 @@ router.get('/public', optionalAuthMiddleware, async (req: AuthenticatedRequest,
const result = await pool.query( const result = await pool.query(
`SELECT s.id, s.title, s.lyrics, s.style, s.caption, s.cover_url, s.audio_url, `SELECT s.id, s.title, s.lyrics, s.style, s.caption, s.cover_url, s.audio_url,
s.duration, s.bpm, s.key_scale, s.time_signature, s.tags, s.like_count, s.created_at, s.duration, s.bpm, s.key_scale, s.time_signature, s.tags, s.like_count, s.created_at,
COALESCE(u.username, 'Anonymous') as creator COALESCE(u.username, 'Anonymous') as creator, s.generation_params
FROM songs s FROM songs s
LEFT JOIN users u ON s.user_id = u.id LEFT JOIN users u ON s.user_id = u.id
WHERE s.is_public = true WHERE s.is_public = true
@@ -213,7 +213,7 @@ router.get('/:id', optionalAuthMiddleware, async (req: AuthenticatedRequest, res
const result = await pool.query( const result = await pool.query(
`SELECT s.id, s.user_id, s.title, s.lyrics, s.style, s.caption, s.cover_url, s.audio_url, `SELECT s.id, s.user_id, s.title, s.lyrics, s.style, s.caption, s.cover_url, s.audio_url,
s.duration, s.bpm, s.key_scale, s.time_signature, s.tags, s.is_public, s.like_count, s.view_count, s.created_at, s.duration, s.bpm, s.key_scale, s.time_signature, s.tags, s.is_public, s.like_count, s.view_count, s.created_at,
COALESCE(u.username, 'Anonymous') as creator, u.avatar_url as creator_avatar COALESCE(u.username, 'Anonymous') as creator, u.avatar_url as creator_avatar, s.generation_params
FROM songs s FROM songs s
LEFT JOIN users u ON s.user_id = u.id LEFT JOIN users u ON s.user_id = u.id
WHERE s.id = $1`, WHERE s.id = $1`,
@@ -252,7 +252,7 @@ router.get('/:id/full', optionalAuthMiddleware, async (req: AuthenticatedRequest
pool.query( pool.query(
`SELECT s.id, s.user_id, s.title, s.lyrics, s.style, s.caption, s.cover_url, s.audio_url, `SELECT s.id, s.user_id, s.title, s.lyrics, s.style, s.caption, s.cover_url, s.audio_url,
s.duration, s.bpm, s.key_scale, s.time_signature, s.tags, s.is_public, s.duration, s.bpm, s.key_scale, s.time_signature, s.tags, s.is_public,
s.like_count, s.view_count, s.created_at, s.like_count, s.view_count, s.created_at, s.generation_params,
COALESCE(u.username, 'Anonymous') as creator, u.avatar_url as creator_avatar COALESCE(u.username, 'Anonymous') as creator, u.avatar_url as creator_avatar
FROM songs s FROM songs s
LEFT JOIN users u ON s.user_id = u.id LEFT JOIN users u ON s.user_id = u.id
@@ -496,7 +496,7 @@ router.get('/liked/list', authMiddleware, async (req: AuthenticatedRequest, res:
const result = await pool.query( const result = await pool.query(
`SELECT s.id, s.title, s.lyrics, s.style, s.cover_url, s.audio_url, `SELECT s.id, s.title, s.lyrics, s.style, s.cover_url, s.audio_url,
s.duration, s.tags, s.like_count, s.created_at, s.is_public, s.duration, s.tags, s.like_count, s.created_at, s.is_public,
COALESCE(u.username, 'Anonymous') as creator COALESCE(u.username, 'Anonymous') as creator, s.generation_params
FROM liked_songs ls FROM liked_songs ls
JOIN songs s ON ls.song_id = s.id JOIN songs s ON ls.song_id = s.id
LEFT JOIN users u ON s.user_id = u.id LEFT JOIN users u ON s.user_id = u.id
+108 -14
View File
@@ -125,7 +125,6 @@ async function submitToApi(params: GenerationParams): Promise<{ taskId: string }
const body: Record<string, unknown> = { const body: Record<string, unknown> = {
prompt, prompt,
lyrics, lyrics,
audio_duration: params.duration ?? 60,
batch_size: params.batchSize ?? 1, batch_size: params.batchSize ?? 1,
inference_steps: params.inferenceSteps ?? 8, inference_steps: params.inferenceSteps ?? 8,
guidance_scale: params.guidanceScale ?? 10.0, guidance_scale: params.guidanceScale ?? 10.0,
@@ -140,6 +139,7 @@ async function submitToApi(params: GenerationParams): Promise<{ taskId: string }
lm_backend: params.lmBackend || 'pt', lm_backend: params.lmBackend || 'pt',
}; };
if (params.duration && params.duration > 0) body.audio_duration = params.duration;
if (params.bpm && params.bpm > 0) body.bpm = params.bpm; if (params.bpm && params.bpm > 0) body.bpm = params.bpm;
if (params.keyScale) body.key_scale = params.keyScale; if (params.keyScale) body.key_scale = params.keyScale;
if (params.timeSignature) body.time_signature = params.timeSignature; if (params.timeSignature) body.time_signature = params.timeSignature;
@@ -172,20 +172,42 @@ async function submitToApi(params: GenerationParams): Promise<{ taskId: string }
if (params.cfgIntervalStart !== undefined && params.cfgIntervalStart > 0) body.cfg_interval_start = params.cfgIntervalStart; if (params.cfgIntervalStart !== undefined && params.cfgIntervalStart > 0) body.cfg_interval_start = params.cfgIntervalStart;
if (params.cfgIntervalEnd !== undefined && params.cfgIntervalEnd < 1.0) body.cfg_interval_end = params.cfgIntervalEnd; if (params.cfgIntervalEnd !== undefined && params.cfgIntervalEnd < 1.0) body.cfg_interval_end = params.cfgIntervalEnd;
const resolveAudioPath = (audioUrl: string): string => {
if (audioUrl.startsWith('/audio/')) {
return path.join(AUDIO_DIR, audioUrl.replace('/audio/', ''));
}
if (audioUrl.startsWith('http')) {
try {
const parsed = new URL(audioUrl);
if (parsed.pathname.startsWith('/audio/')) {
return path.join(AUDIO_DIR, parsed.pathname.replace('/audio/', ''));
}
} catch {
// fall through
}
}
return audioUrl;
};
// Guard: cover/audio2audio requires a source or audio codes
if ((params.taskType === 'cover' || params.taskType === 'audio2audio') && !params.sourceAudioUrl && !params.audioCodes) {
throw new Error(`task_type='${params.taskType}' requires a source audio or audio codes`);
}
// Handle reference audio - need to pass file path // Handle reference audio - need to pass file path
if (params.referenceAudioUrl) { if (params.referenceAudioUrl) {
let refAudioPath = params.referenceAudioUrl; body.reference_audio_path = resolveAudioPath(params.referenceAudioUrl);
if (refAudioPath.startsWith('/audio/')) {
refAudioPath = path.join(AUDIO_DIR, refAudioPath.replace('/audio/', ''));
}
body.reference_audio_path = refAudioPath;
} }
if (params.sourceAudioUrl) { if (params.sourceAudioUrl) {
let srcAudioPath = params.sourceAudioUrl; body.src_audio_path = resolveAudioPath(params.sourceAudioUrl);
if (srcAudioPath.startsWith('/audio/')) { }
srcAudioPath = path.join(AUDIO_DIR, srcAudioPath.replace('/audio/', ''));
} if (params.taskType === 'cover' || params.taskType === 'audio2audio') {
body.src_audio_path = srcAudioPath; console.log(`[ACE-Step] cover/audio2audio inputs`, {
reference_audio_path: body.reference_audio_path,
src_audio_path: body.src_audio_path,
has_audio_codes: Boolean(params.audioCodes),
});
} }
const response = await fetch(`${ACESTEP_API}/release_task`, { const response = await fetch(`${ACESTEP_API}/release_task`, {
@@ -261,7 +283,26 @@ async function pollApiResult(taskId: string, maxWaitMs = 600000): Promise<ApiTas
return { status: 1, audioPaths, metas }; return { status: 1, audioPaths, metas };
} else if (taskData.status === 2) { } else if (taskData.status === 2) {
throw new Error('Generation failed on API side'); const details = taskData.error
|| taskData.message
|| taskData.status_message
|| taskData.result
|| JSON.stringify(taskData);
throw new Error(`Generation failed on API side: ${details}`);
}
// Log progress while processing (if provided)
if (taskData.result) {
try {
const resultData = typeof taskData.result === 'string' ? JSON.parse(taskData.result) : taskData.result;
const item = Array.isArray(resultData) ? resultData[0] : resultData;
if (item && typeof item === 'object' && typeof (item as any).progress === 'number') {
const pct = Math.round((item as any).progress * 100);
console.log(`[ACE-Step] API task ${taskId} progress: ${pct}%`);
}
} catch {
// ignore parse failures
}
} }
// Still processing // Still processing
@@ -351,6 +392,8 @@ export interface GenerationParams {
// Expert Parameters // Expert Parameters
referenceAudioUrl?: string; referenceAudioUrl?: string;
sourceAudioUrl?: string; sourceAudioUrl?: string;
referenceAudioTitle?: string;
sourceAudioTitle?: string;
audioCodes?: string; audioCodes?: string;
repaintingStart?: number; repaintingStart?: number;
repaintingEnd?: number; repaintingEnd?: number;
@@ -389,6 +432,8 @@ interface JobStatus {
status: 'queued' | 'running' | 'succeeded' | 'failed'; status: 'queued' | 'running' | 'succeeded' | 'failed';
queuePosition?: number; queuePosition?: number;
etaSeconds?: number; etaSeconds?: number;
progress?: number;
stage?: string;
result?: GenerationResult; result?: GenerationResult;
error?: string; error?: string;
} }
@@ -403,6 +448,8 @@ interface ActiveJob {
processPromise?: Promise<void>; processPromise?: Promise<void>;
rawResponse?: unknown; rawResponse?: unknown;
queuePosition?: number; queuePosition?: number;
progress?: number;
stage?: string;
} }
const activeJobs = new Map<string, ActiveJob>(); const activeJobs = new Map<string, ActiveJob>();
@@ -466,6 +513,8 @@ async function processQueue(): Promise<void> {
// Submit generation job to queue // Submit generation job to queue
export async function generateMusicViaAPI(params: GenerationParams): Promise<{ jobId: string }> { export async function generateMusicViaAPI(params: GenerationParams): Promise<{ jobId: string }> {
// Force a fresh API availability check when starting a job
resetApiCache();
const jobId = `job_${Date.now()}_${Math.random().toString(36).slice(2, 9)}`; const jobId = `job_${Date.now()}_${Math.random().toString(36).slice(2, 9)}`;
const job: ActiveJob = { const job: ActiveJob = {
@@ -509,6 +558,7 @@ async function processGeneration(
try { try {
// Submit to API // Submit to API
const { taskId } = await submitToApi(params); const { taskId } = await submitToApi(params);
job.taskId = taskId;
console.log(`Job ${jobId}: Submitted to API as task ${taskId}`); console.log(`Job ${jobId}: Submitted to API as task ${taskId}`);
// Poll for result // Poll for result
@@ -572,9 +622,10 @@ async function processGeneration(
const jobOutputDir = path.join(ACESTEP_DIR, 'output', jobId); const jobOutputDir = path.join(ACESTEP_DIR, 'output', jobId);
await mkdir(jobOutputDir, { recursive: true }); await mkdir(jobOutputDir, { recursive: true });
const durationToSend = params.duration && params.duration > 0 ? params.duration : 60;
const args = [ const args = [
'--prompt', prompt, '--prompt', prompt,
'--duration', String(params.duration ?? 60), '--duration', String(durationToSend),
'--batch-size', String(params.batchSize ?? 1), '--batch-size', String(params.batchSize ?? 1),
'--infer-steps', String(params.inferenceSteps ?? 8), '--infer-steps', String(params.inferenceSteps ?? 8),
'--guidance-scale', String(params.guidanceScale ?? 10.0), '--guidance-scale', String(params.guidanceScale ?? 10.0),
@@ -706,7 +757,6 @@ function runPythonGeneration(scriptArgs: string[]): Promise<PythonResult> {
cwd: ACESTEP_DIR, cwd: ACESTEP_DIR,
env: { env: {
...process.env, ...process.env,
CUDA_VISIBLE_DEVICES: '0',
ACESTEP_PATH: ACESTEP_DIR, ACESTEP_PATH: ACESTEP_DIR,
}, },
}); });
@@ -845,9 +895,53 @@ export async function getJobStatus(jobId: string): Promise<JobStatus> {
}; };
} }
if (job.status === 'running' && job.taskId) {
try {
const response = await fetch(`${ACESTEP_API}/query_result`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ task_id_list: [job.taskId] }),
});
if (response.ok) {
const result = await response.json();
const taskData = result.data?.[0];
if (taskData?.result) {
let resultData: unknown = taskData.result;
if (typeof resultData === 'string') {
try {
resultData = JSON.parse(resultData);
} catch {
resultData = null;
}
}
const item = Array.isArray(resultData) ? resultData[0] : resultData;
if (item && typeof item === 'object') {
const rawProgress = (item as any).progress;
const progress = Number.isFinite(Number(rawProgress)) ? Number(rawProgress) : undefined;
const stage = typeof (item as any).stage === 'string' ? (item as any).stage : undefined;
if (progress !== undefined) job.progress = progress;
if (stage) job.stage = stage;
return {
status: job.status,
etaSeconds: Math.max(0, 180 - elapsed),
progress: progress ?? job.progress,
stage: stage ?? job.stage,
};
}
}
}
} catch {
// ignore progress fetch failures, fall back to ETA only
}
}
return { return {
status: job.status, status: job.status,
etaSeconds: Math.max(0, 180 - elapsed), // 3 min estimate etaSeconds: Math.max(0, 180 - elapsed), // 3 min estimate
progress: job.progress,
stage: job.stage,
}; };
} }
+4 -1
View File
@@ -18,7 +18,7 @@ export class LocalStorageProvider implements StorageProvider {
const filepath = path.join(this.audioDir, key); const filepath = path.join(this.audioDir, key);
await mkdir(path.dirname(filepath), { recursive: true }); await mkdir(path.dirname(filepath), { recursive: true });
await writeFile(filepath, data); await writeFile(filepath, data);
return `/audio/${key}`; return key;
} }
async getUrl(key: string, _expiresIn?: number): Promise<string> { async getUrl(key: string, _expiresIn?: number): Promise<string> {
@@ -26,6 +26,9 @@ export class LocalStorageProvider implements StorageProvider {
} }
getPublicUrl(key: string): string { getPublicUrl(key: string): string {
if (key.startsWith('/audio/')) {
return key;
}
return `/audio/${key}`; return `/audio/${key}`;
} }
+5
View File
@@ -106,6 +106,7 @@ export interface Song {
user_id?: string; user_id?: string;
created_at: string; created_at: string;
creator?: string; creator?: string;
generation_params?: any;
} }
// Transform songs to have proper audio URLs // Transform songs to have proper audio URLs
@@ -237,6 +238,8 @@ export interface GenerationParams {
// Expert Parameters // Expert Parameters
referenceAudioUrl?: string; referenceAudioUrl?: string;
sourceAudioUrl?: string; sourceAudioUrl?: string;
referenceAudioTitle?: string;
sourceAudioTitle?: string;
audioCodes?: string; audioCodes?: string;
repaintingStart?: number; repaintingStart?: number;
repaintingEnd?: number; repaintingEnd?: number;
@@ -267,6 +270,8 @@ export interface GenerationJob {
status: 'pending' | 'queued' | 'running' | 'succeeded' | 'failed'; status: 'pending' | 'queued' | 'running' | 'succeeded' | 'failed';
queuePosition?: number; queuePosition?: number;
etaSeconds?: number; etaSeconds?: number;
progress?: number;
stage?: string;
result?: { result?: {
audioUrls: string[]; audioUrls: string[];
bpm?: number; bpm?: number;
Regular → Executable
View File
Regular → Executable
View File
+5
View File
@@ -8,6 +8,9 @@ export interface Song {
createdAt: Date; createdAt: Date;
isGenerating?: boolean; isGenerating?: boolean;
queuePosition?: number; // Position in queue (undefined = actively generating, number = waiting in queue) queuePosition?: number; // Position in queue (undefined = actively generating, number = waiting in queue)
progress?: number;
stage?: string;
generationParams?: any;
tags: string[]; tags: string[];
audioUrl?: string; audioUrl?: string;
isPublic?: boolean; isPublic?: boolean;
@@ -88,6 +91,8 @@ export interface GenerationParams {
// Expert Parameters // Expert Parameters
referenceAudioUrl?: string; referenceAudioUrl?: string;
sourceAudioUrl?: string; sourceAudioUrl?: string;
referenceAudioTitle?: string;
sourceAudioTitle?: string;
audioCodes?: string; audioCodes?: string;
repaintingStart?: number; repaintingStart?: number;
repaintingEnd?: number; repaintingEnd?: number;