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:
@@ -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>
|
||||||
|
|
||||||
|
|||||||
+533
-126
File diff suppressed because it is too large
Load Diff
+175
-5
@@ -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>
|
||||||
|
<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>
|
||||||
|
{shareSong && (
|
||||||
|
<ShareModal
|
||||||
|
isOpen={shareModalOpen}
|
||||||
|
onClose={() => { setShareModalOpen(false); setShareSong(null); }}
|
||||||
|
song={shareSong}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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 */}
|
||||||
|
|||||||
@@ -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}
|
||||||
/>
|
/>
|
||||||
|
{onReusePrompt && (
|
||||||
<MenuItem
|
<MenuItem
|
||||||
icon={<Repeat size={14} />}
|
icon={<Repeat size={14} />}
|
||||||
label="Reuse Prompt"
|
label="Reuse Prompt"
|
||||||
onClick={() => handleAction(onReusePrompt)}
|
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 />
|
||||||
|
|
||||||
|
|||||||
+310
-20
@@ -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) => (
|
||||||
|
item.type === 'song' ? (
|
||||||
<SongItem
|
<SongItem
|
||||||
key={song.id}
|
key={item.id}
|
||||||
song={song}
|
song={item.song}
|
||||||
isCurrent={currentSong?.id === song.id}
|
isCurrent={currentSong?.id === item.song.id}
|
||||||
isSelected={selectedSong?.id === song.id}
|
isSelected={selectedSong?.id === item.song.id}
|
||||||
isLiked={likedSongIds.has(song.id)}
|
isSelectionMode={isSelecting}
|
||||||
|
isChecked={selectedIds.has(item.song.id)}
|
||||||
|
isLiked={likedSongIds.has(item.song.id)}
|
||||||
isPlaying={isPlaying}
|
isPlaying={isPlaying}
|
||||||
isOwner={user?.id === song.userId}
|
isOwner={user?.id === item.song.userId}
|
||||||
onPlay={() => onPlay(song)}
|
onPlay={() => onPlay(item.song)}
|
||||||
onSelect={() => onSelect(song)}
|
onSelect={() => onSelect(item.song)}
|
||||||
onToggleLike={() => onToggleLike(song.id)}
|
onToggleSelect={() => {
|
||||||
onAddToPlaylist={() => onAddToPlaylist(song)}
|
if (item.song.isGenerating) return;
|
||||||
onOpenVideo={() => onOpenVideo && onOpenVideo(song)}
|
setSelectedIds(prev => {
|
||||||
onShowDetails={() => onShowDetails && onShowDetails(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}
|
onNavigateToProfile={onNavigateToProfile}
|
||||||
onReusePrompt={() => onReusePrompt?.(song)}
|
onReusePrompt={() => onReusePrompt?.(item.song)}
|
||||||
onDelete={() => onDelete?.(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>
|
||||||
@@ -454,3 +697,50 @@ 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}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|||||||
Generated
-5
@@ -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",
|
||||||
|
|||||||
@@ -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,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -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()
|
||||||
@@ -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)
|
||||||
|
|||||||
@@ -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,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
+107
-13
@@ -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/', ''));
|
|
||||||
}
|
}
|
||||||
body.src_audio_path = srcAudioPath;
|
|
||||||
|
if (params.taskType === 'cover' || params.taskType === 'audio2audio') {
|
||||||
|
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,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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
Regular → Executable
@@ -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;
|
||||||
|
|||||||
Reference in New Issue
Block a user