Some drag and drop and other UI enhancements
This commit is contained in:
@@ -66,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);
|
||||||
@@ -1066,6 +1067,20 @@ 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 handleBackFromPlaylist = () => {
|
const handleBackFromPlaylist = () => {
|
||||||
setViewingPlaylistId(null);
|
setViewingPlaylistId(null);
|
||||||
setCurrentView('library');
|
setCurrentView('library');
|
||||||
@@ -1184,6 +1199,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>
|
||||||
|
|
||||||
@@ -1198,6 +1216,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);
|
||||||
@@ -1211,6 +1230,8 @@ export default function App() {
|
|||||||
onReusePrompt={handleReuse}
|
onReusePrompt={handleReuse}
|
||||||
onDelete={handleDeleteSong}
|
onDelete={handleDeleteSong}
|
||||||
onDeleteMany={handleDeleteSongs}
|
onDeleteMany={handleDeleteSongs}
|
||||||
|
onUseAsReference={handleUseAsReference}
|
||||||
|
onCoverSong={handleCoverSong}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
+295
-68
@@ -1,4 +1,4 @@
|
|||||||
import React, { useState, useEffect, useRef, useCallback } from 'react';
|
import React, { useState, useEffect, useRef, useCallback, useMemo } from 'react';
|
||||||
import { Sparkles, ChevronDown, Settings2, Trash2, Music2, Sliders, Dices, Hash, RefreshCw, Plus, Upload, Play, Pause, Loader2 } from 'lucide-react';
|
import { Sparkles, ChevronDown, Settings2, Trash2, Music2, Sliders, Dices, Hash, RefreshCw, Plus, Upload, Play, Pause, Loader2 } from 'lucide-react';
|
||||||
import { GenerationParams, Song } from '../types';
|
import { GenerationParams, Song } from '../types';
|
||||||
import { useAuth } from '../context/AuthContext';
|
import { useAuth } from '../context/AuthContext';
|
||||||
@@ -19,6 +19,9 @@ interface CreatePanelProps {
|
|||||||
onGenerate: (params: GenerationParams) => void;
|
onGenerate: (params: GenerationParams) => void;
|
||||||
isGenerating: boolean;
|
isGenerating: boolean;
|
||||||
initialData?: { song: Song, timestamp: number } | null;
|
initialData?: { song: Song, timestamp: number } | null;
|
||||||
|
createdSongs?: Song[];
|
||||||
|
pendingAudioSelection?: { target: 'reference' | 'source'; url: string; title?: string } | null;
|
||||||
|
onAudioSelectionApplied?: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
const KEY_SIGNATURES = [
|
const KEY_SIGNATURES = [
|
||||||
@@ -98,8 +101,15 @@ const VOCAL_LANGUAGES = [
|
|||||||
{ value: 'zh', label: 'Chinese (Mandarin)' },
|
{ value: 'zh', label: 'Chinese (Mandarin)' },
|
||||||
];
|
];
|
||||||
|
|
||||||
export const CreatePanel: React.FC<CreatePanelProps> = ({ onGenerate, isGenerating, initialData }) => {
|
export const CreatePanel: React.FC<CreatePanelProps> = ({
|
||||||
const { isAuthenticated, token } = useAuth();
|
onGenerate,
|
||||||
|
isGenerating,
|
||||||
|
initialData,
|
||||||
|
createdSongs = [],
|
||||||
|
pendingAudioSelection,
|
||||||
|
onAudioSelectionApplied,
|
||||||
|
}) => {
|
||||||
|
const { isAuthenticated, token, user } = useAuth();
|
||||||
|
|
||||||
// Mode
|
// Mode
|
||||||
const [customMode, setCustomMode] = useState(true);
|
const [customMode, setCustomMode] = useState(true);
|
||||||
@@ -177,10 +187,13 @@ export const CreatePanel: React.FC<CreatePanelProps> = ({ onGenerate, isGenerati
|
|||||||
|
|
||||||
const [isUploadingReference, setIsUploadingReference] = useState(false);
|
const [isUploadingReference, setIsUploadingReference] = useState(false);
|
||||||
const [isUploadingSource, setIsUploadingSource] = useState(false);
|
const [isUploadingSource, setIsUploadingSource] = useState(false);
|
||||||
|
const [isTranscribingReference, setIsTranscribingReference] = useState(false);
|
||||||
|
const transcribeAbortRef = useRef<AbortController | null>(null);
|
||||||
const [uploadError, setUploadError] = useState<string | null>(null);
|
const [uploadError, setUploadError] = useState<string | null>(null);
|
||||||
const [isFormattingStyle, setIsFormattingStyle] = useState(false);
|
const [isFormattingStyle, setIsFormattingStyle] = useState(false);
|
||||||
const [isFormattingLyrics, setIsFormattingLyrics] = useState(false);
|
const [isFormattingLyrics, setIsFormattingLyrics] = useState(false);
|
||||||
const [isDraggingFile, setIsDraggingFile] = useState(false);
|
const [isDraggingFile, setIsDraggingFile] = useState(false);
|
||||||
|
const [dragKind, setDragKind] = useState<'file' | 'audio' | null>(null);
|
||||||
const referenceInputRef = useRef<HTMLInputElement>(null);
|
const referenceInputRef = useRef<HTMLInputElement>(null);
|
||||||
const sourceInputRef = useRef<HTMLInputElement>(null);
|
const sourceInputRef = useRef<HTMLInputElement>(null);
|
||||||
const dragDepthRef = useRef(0);
|
const dragDepthRef = useRef(0);
|
||||||
@@ -201,9 +214,24 @@ export const CreatePanel: React.FC<CreatePanelProps> = ({ onGenerate, isGenerati
|
|||||||
const [referenceTracks, setReferenceTracks] = useState<ReferenceTrack[]>([]);
|
const [referenceTracks, setReferenceTracks] = useState<ReferenceTrack[]>([]);
|
||||||
const [isLoadingTracks, setIsLoadingTracks] = useState(false);
|
const [isLoadingTracks, setIsLoadingTracks] = useState(false);
|
||||||
const [playingTrackId, setPlayingTrackId] = useState<string | null>(null);
|
const [playingTrackId, setPlayingTrackId] = useState<string | null>(null);
|
||||||
|
const [playingTrackSource, setPlayingTrackSource] = useState<'uploads' | 'created' | null>(null);
|
||||||
const modalAudioRef = useRef<HTMLAudioElement>(null);
|
const modalAudioRef = useRef<HTMLAudioElement>(null);
|
||||||
const [modalTrackTime, setModalTrackTime] = useState(0);
|
const [modalTrackTime, setModalTrackTime] = useState(0);
|
||||||
const [modalTrackDuration, setModalTrackDuration] = useState(0);
|
const [modalTrackDuration, setModalTrackDuration] = useState(0);
|
||||||
|
const [libraryTab, setLibraryTab] = useState<'uploads' | 'created'>('uploads');
|
||||||
|
|
||||||
|
const createdTrackOptions = useMemo(() => {
|
||||||
|
return createdSongs
|
||||||
|
.filter(song => !song.isGenerating)
|
||||||
|
.filter(song => (user ? song.userId === user.id : true))
|
||||||
|
.filter(song => Boolean(song.audioUrl))
|
||||||
|
.map(song => ({
|
||||||
|
id: song.id,
|
||||||
|
title: song.title || 'Untitled',
|
||||||
|
audio_url: song.audioUrl!,
|
||||||
|
duration: song.duration,
|
||||||
|
}));
|
||||||
|
}, [createdSongs, user]);
|
||||||
|
|
||||||
const getAudioLabel = (url: string) => {
|
const getAudioLabel = (url: string) => {
|
||||||
try {
|
try {
|
||||||
@@ -236,6 +264,16 @@ export const CreatePanel: React.FC<CreatePanelProps> = ({ onGenerate, isGenerati
|
|||||||
}
|
}
|
||||||
}, [initialData]);
|
}, [initialData]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!pendingAudioSelection) return;
|
||||||
|
applyAudioTargetUrl(
|
||||||
|
pendingAudioSelection.target,
|
||||||
|
pendingAudioSelection.url,
|
||||||
|
pendingAudioSelection.title
|
||||||
|
);
|
||||||
|
onAudioSelectionApplied?.();
|
||||||
|
}, [pendingAudioSelection, onAudioSelectionApplied]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const handleMouseMove = (e: MouseEvent) => {
|
const handleMouseMove = (e: MouseEvent) => {
|
||||||
if (!isResizing) return;
|
if (!isResizing) return;
|
||||||
@@ -312,34 +350,47 @@ export const CreatePanel: React.FC<CreatePanelProps> = ({ onGenerate, isGenerati
|
|||||||
}, [duration, activeMaxDuration]);
|
}, [duration, activeMaxDuration]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const isFileDrag = (e: DragEvent) =>
|
const getDragKind = (e: DragEvent): 'file' | 'audio' | null => {
|
||||||
!!(e.dataTransfer && Array.from(e.dataTransfer.types).includes('Files'));
|
if (!e.dataTransfer) return null;
|
||||||
|
const types = Array.from(e.dataTransfer.types);
|
||||||
|
if (types.includes('Files')) return 'file';
|
||||||
|
if (types.includes('application/x-ace-audio')) return 'audio';
|
||||||
|
return null;
|
||||||
|
};
|
||||||
|
|
||||||
const handleDragEnter = (e: DragEvent) => {
|
const handleDragEnter = (e: DragEvent) => {
|
||||||
if (!isFileDrag(e)) return;
|
const kind = getDragKind(e);
|
||||||
|
if (!kind) return;
|
||||||
dragDepthRef.current += 1;
|
dragDepthRef.current += 1;
|
||||||
setIsDraggingFile(true);
|
setIsDraggingFile(true);
|
||||||
|
setDragKind(kind);
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleDragOver = (e: DragEvent) => {
|
const handleDragOver = (e: DragEvent) => {
|
||||||
if (!isFileDrag(e)) return;
|
const kind = getDragKind(e);
|
||||||
|
if (!kind) return;
|
||||||
|
setDragKind(kind);
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleDragLeave = (e: DragEvent) => {
|
const handleDragLeave = (e: DragEvent) => {
|
||||||
if (!isFileDrag(e)) return;
|
const kind = getDragKind(e);
|
||||||
|
if (!kind) return;
|
||||||
dragDepthRef.current = Math.max(0, dragDepthRef.current - 1);
|
dragDepthRef.current = Math.max(0, dragDepthRef.current - 1);
|
||||||
if (dragDepthRef.current === 0) {
|
if (dragDepthRef.current === 0) {
|
||||||
setIsDraggingFile(false);
|
setIsDraggingFile(false);
|
||||||
|
setDragKind(null);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleDrop = (e: DragEvent) => {
|
const handleDrop = (e: DragEvent) => {
|
||||||
if (!isFileDrag(e)) return;
|
const kind = getDragKind(e);
|
||||||
|
if (!kind) return;
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
dragDepthRef.current = 0;
|
dragDepthRef.current = 0;
|
||||||
setIsDraggingFile(false);
|
setIsDraggingFile(false);
|
||||||
|
setDragKind(null);
|
||||||
};
|
};
|
||||||
|
|
||||||
window.addEventListener('dragenter', handleDragEnter);
|
window.addEventListener('dragenter', handleDragEnter);
|
||||||
@@ -437,9 +488,10 @@ export const CreatePanel: React.FC<CreatePanelProps> = ({ onGenerate, isGenerati
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const openAudioModal = (target: 'reference' | 'source') => {
|
const openAudioModal = (target: 'reference' | 'source', tab: 'uploads' | 'created' = 'uploads') => {
|
||||||
setAudioModalTarget(target);
|
setAudioModalTarget(target);
|
||||||
setTempAudioUrl('');
|
setTempAudioUrl('');
|
||||||
|
setLibraryTab(tab);
|
||||||
setShowAudioModal(true);
|
setShowAudioModal(true);
|
||||||
void fetchReferenceTracks();
|
void fetchReferenceTracks();
|
||||||
};
|
};
|
||||||
@@ -490,7 +542,11 @@ export const CreatePanel: React.FC<CreatePanelProps> = ({ onGenerate, isGenerati
|
|||||||
// Also set as current reference/source
|
// Also set as current reference/source
|
||||||
const selectedTarget = target ?? audioModalTarget;
|
const selectedTarget = target ?? audioModalTarget;
|
||||||
applyAudioTargetUrl(selectedTarget, data.track.audio_url, data.track.filename);
|
applyAudioTargetUrl(selectedTarget, data.track.audio_url, data.track.filename);
|
||||||
setShowAudioModal(false);
|
if (data.whisper_available && data.track?.id) {
|
||||||
|
void transcribeReferenceTrack(data.track.id).then(() => undefined);
|
||||||
|
} else {
|
||||||
|
setShowAudioModal(false);
|
||||||
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
const message = err instanceof Error ? err.message : 'Upload failed';
|
const message = err instanceof Error ? err.message : 'Upload failed';
|
||||||
setUploadError(message);
|
setUploadError(message);
|
||||||
@@ -499,6 +555,43 @@ export const CreatePanel: React.FC<CreatePanelProps> = ({ onGenerate, isGenerati
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const transcribeReferenceTrack = async (trackId: string) => {
|
||||||
|
if (!token) return;
|
||||||
|
setIsTranscribingReference(true);
|
||||||
|
const controller = new AbortController();
|
||||||
|
transcribeAbortRef.current = controller;
|
||||||
|
try {
|
||||||
|
const response = await fetch(`/api/reference-tracks/${trackId}/transcribe`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { Authorization: `Bearer ${token}` },
|
||||||
|
signal: controller.signal,
|
||||||
|
});
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error('Failed to transcribe');
|
||||||
|
}
|
||||||
|
const data = await response.json();
|
||||||
|
if (data.lyrics) {
|
||||||
|
setLyrics(prev => prev || data.lyrics);
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
if (controller.signal.aborted) return;
|
||||||
|
console.error('Transcription failed:', err);
|
||||||
|
} finally {
|
||||||
|
if (transcribeAbortRef.current === controller) {
|
||||||
|
transcribeAbortRef.current = null;
|
||||||
|
}
|
||||||
|
setIsTranscribingReference(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const cancelTranscription = () => {
|
||||||
|
if (transcribeAbortRef.current) {
|
||||||
|
transcribeAbortRef.current.abort();
|
||||||
|
transcribeAbortRef.current = null;
|
||||||
|
}
|
||||||
|
setIsTranscribingReference(false);
|
||||||
|
};
|
||||||
|
|
||||||
const deleteReferenceTrack = async (trackId: string) => {
|
const deleteReferenceTrack = async (trackId: string) => {
|
||||||
if (!token) return;
|
if (!token) return;
|
||||||
try {
|
try {
|
||||||
@@ -508,8 +601,9 @@ export const CreatePanel: React.FC<CreatePanelProps> = ({ onGenerate, isGenerati
|
|||||||
});
|
});
|
||||||
if (response.ok) {
|
if (response.ok) {
|
||||||
setReferenceTracks(prev => prev.filter(t => t.id !== trackId));
|
setReferenceTracks(prev => prev.filter(t => t.id !== trackId));
|
||||||
if (playingTrackId === trackId) {
|
if (playingTrackId === trackId && playingTrackSource === 'uploads') {
|
||||||
setPlayingTrackId(null);
|
setPlayingTrackId(null);
|
||||||
|
setPlayingTrackSource(null);
|
||||||
if (modalAudioRef.current) {
|
if (modalAudioRef.current) {
|
||||||
modalAudioRef.current.pause();
|
modalAudioRef.current.pause();
|
||||||
}
|
}
|
||||||
@@ -520,20 +614,23 @@ export const CreatePanel: React.FC<CreatePanelProps> = ({ onGenerate, isGenerati
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const useReferenceTrack = (track: ReferenceTrack) => {
|
const useReferenceTrack = (track: { audio_url: string; title?: string }) => {
|
||||||
applyAudioTargetUrl(audioModalTarget, track.audio_url, track.filename);
|
applyAudioTargetUrl(audioModalTarget, track.audio_url, track.title);
|
||||||
setShowAudioModal(false);
|
setShowAudioModal(false);
|
||||||
setPlayingTrackId(null);
|
setPlayingTrackId(null);
|
||||||
|
setPlayingTrackSource(null);
|
||||||
};
|
};
|
||||||
|
|
||||||
const toggleModalTrack = (track: ReferenceTrack) => {
|
const toggleModalTrack = (track: { id: string; audio_url: string; source: 'uploads' | 'created' }) => {
|
||||||
if (playingTrackId === track.id) {
|
if (playingTrackId === track.id) {
|
||||||
if (modalAudioRef.current) {
|
if (modalAudioRef.current) {
|
||||||
modalAudioRef.current.pause();
|
modalAudioRef.current.pause();
|
||||||
}
|
}
|
||||||
setPlayingTrackId(null);
|
setPlayingTrackId(null);
|
||||||
|
setPlayingTrackSource(null);
|
||||||
} else {
|
} else {
|
||||||
setPlayingTrackId(track.id);
|
setPlayingTrackId(track.id);
|
||||||
|
setPlayingTrackSource(track.source);
|
||||||
if (modalAudioRef.current) {
|
if (modalAudioRef.current) {
|
||||||
modalAudioRef.current.src = track.audio_url;
|
modalAudioRef.current.src = track.audio_url;
|
||||||
modalAudioRef.current.play().catch(() => undefined);
|
modalAudioRef.current.play().catch(() => undefined);
|
||||||
@@ -588,6 +685,18 @@ export const CreatePanel: React.FC<CreatePanelProps> = ({ onGenerate, isGenerati
|
|||||||
const file = e.dataTransfer.files?.[0];
|
const file = e.dataTransfer.files?.[0];
|
||||||
if (file) {
|
if (file) {
|
||||||
void uploadReferenceTrack(file, target);
|
void uploadReferenceTrack(file, target);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const payload = e.dataTransfer.getData('application/x-ace-audio');
|
||||||
|
if (payload) {
|
||||||
|
try {
|
||||||
|
const data = JSON.parse(payload);
|
||||||
|
if (data?.url) {
|
||||||
|
applyAudioTargetUrl(target, data.url, data.title);
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// ignore
|
||||||
|
}
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -602,7 +711,7 @@ export const CreatePanel: React.FC<CreatePanelProps> = ({ onGenerate, isGenerati
|
|||||||
};
|
};
|
||||||
|
|
||||||
const handleWorkspaceDragOver = (e: React.DragEvent<HTMLDivElement>) => {
|
const handleWorkspaceDragOver = (e: React.DragEvent<HTMLDivElement>) => {
|
||||||
if (e.dataTransfer.types.includes('Files')) {
|
if (e.dataTransfer.types.includes('Files') || e.dataTransfer.types.includes('application/x-ace-audio')) {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
@@ -706,12 +815,16 @@ export const CreatePanel: React.FC<CreatePanelProps> = ({ onGenerate, isGenerati
|
|||||||
<div className="absolute inset-0 bg-white/70 dark:bg-black/50 backdrop-blur-sm" />
|
<div className="absolute inset-0 bg-white/70 dark:bg-black/50 backdrop-blur-sm" />
|
||||||
<div className="absolute inset-0 flex items-center justify-center">
|
<div className="absolute inset-0 flex items-center justify-center">
|
||||||
<div className="flex flex-col items-center gap-2 rounded-2xl border border-zinc-200 dark:border-white/10 bg-white/90 dark:bg-zinc-900/90 px-6 py-5 shadow-xl">
|
<div className="flex flex-col items-center gap-2 rounded-2xl border border-zinc-200 dark:border-white/10 bg-white/90 dark:bg-zinc-900/90 px-6 py-5 shadow-xl">
|
||||||
<div className="w-12 h-12 rounded-full bg-gradient-to-br from-pink-500 to-purple-600 text-white flex items-center justify-center shadow-lg">
|
<div className={`w-12 h-12 rounded-full ${dragKind === 'audio' ? 'bg-green-500/90' : 'bg-gradient-to-br from-pink-500 to-purple-600'} text-white flex items-center justify-center shadow-lg`}>
|
||||||
<Upload size={22} />
|
{dragKind === 'audio' ? <Plus size={22} /> : <Upload size={22} />}
|
||||||
|
</div>
|
||||||
|
<div className="text-sm font-semibold text-zinc-900 dark:text-white">
|
||||||
|
{dragKind === 'audio' ? 'Drop to use audio' : 'Drop to upload'}
|
||||||
</div>
|
</div>
|
||||||
<div className="text-sm font-semibold text-zinc-900 dark:text-white">Drop to upload</div>
|
|
||||||
<div className="text-[11px] text-zinc-500 dark:text-zinc-400">
|
<div className="text-[11px] text-zinc-500 dark:text-zinc-400">
|
||||||
Uploading as {audioTab === 'reference' ? 'Reference' : 'Cover'}
|
{dragKind === 'audio'
|
||||||
|
? `Using as ${audioTab === 'reference' ? 'Reference' : 'Cover'}`
|
||||||
|
: `Uploading as ${audioTab === 'reference' ? 'Reference' : 'Cover'}`}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -1072,13 +1185,13 @@ export const CreatePanel: React.FC<CreatePanelProps> = ({ onGenerate, isGenerati
|
|||||||
<div className="flex gap-2">
|
<div className="flex gap-2">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => openAudioModal(audioTab)}
|
onClick={() => openAudioModal(audioTab, 'uploads')}
|
||||||
className="flex-1 flex items-center justify-center gap-1.5 rounded-lg px-3 py-2 text-[11px] font-medium bg-zinc-100 dark:bg-white/5 text-zinc-600 dark:text-zinc-300 hover:bg-zinc-200 dark:hover:bg-white/10 transition-colors"
|
className="flex-1 flex items-center justify-center gap-1.5 rounded-lg px-3 py-2 text-[11px] font-medium bg-zinc-100 dark:bg-white/5 text-zinc-600 dark:text-zinc-300 hover:bg-zinc-200 dark:hover:bg-white/10 transition-colors"
|
||||||
>
|
>
|
||||||
<svg className="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
<svg className="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 19V6l12-3v13M9 19c0 1.105-1.343 2-3 2s-3-.895-3-2 1.343-2 3-2 3 .895 3 2zm12-3c0 1.105-1.343 2-3 2s-3-.895-3-2 1.343-2 3-2 3 .895 3 2zM9 10l12-3"/>
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 19V6l12-3v13M9 19c0 1.105-1.343 2-3 2s-3-.895-3-2 1.343-2 3-2 3 .895 3 2zm12-3c0 1.105-1.343 2-3 2s-3-.895-3-2 1.343-2 3-2 3 .895 3 2zM9 10l12-3"/>
|
||||||
</svg>
|
</svg>
|
||||||
{audioTab === 'reference' ? 'From library' : 'From library'}
|
Library
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
@@ -1854,7 +1967,7 @@ export const CreatePanel: React.FC<CreatePanelProps> = ({ onGenerate, isGenerati
|
|||||||
<div className="fixed inset-0 z-[120] flex items-center justify-center">
|
<div className="fixed inset-0 z-[120] flex items-center justify-center">
|
||||||
<div
|
<div
|
||||||
className="absolute inset-0 bg-black/60 backdrop-blur-sm"
|
className="absolute inset-0 bg-black/60 backdrop-blur-sm"
|
||||||
onClick={() => { setShowAudioModal(false); setPlayingTrackId(null); }}
|
onClick={() => { setShowAudioModal(false); setPlayingTrackId(null); setPlayingTrackSource(null); }}
|
||||||
/>
|
/>
|
||||||
<div className="relative w-[92%] max-w-lg rounded-2xl bg-white dark:bg-zinc-900 border border-zinc-200 dark:border-white/10 shadow-2xl overflow-hidden">
|
<div className="relative w-[92%] max-w-lg rounded-2xl bg-white dark:bg-zinc-900 border border-zinc-200 dark:border-white/10 shadow-2xl overflow-hidden">
|
||||||
{/* Header */}
|
{/* Header */}
|
||||||
@@ -1871,7 +1984,7 @@ export const CreatePanel: React.FC<CreatePanelProps> = ({ onGenerate, isGenerati
|
|||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<button
|
<button
|
||||||
onClick={() => { setShowAudioModal(false); setPlayingTrackId(null); }}
|
onClick={() => { setShowAudioModal(false); setPlayingTrackId(null); setPlayingTrackSource(null); }}
|
||||||
className="p-1.5 rounded-lg hover:bg-zinc-100 dark:hover:bg-white/10 text-zinc-400 hover:text-zinc-900 dark:hover:text-white transition-colors"
|
className="p-1.5 rounded-lg hover:bg-zinc-100 dark:hover:bg-white/10 text-zinc-400 hover:text-zinc-900 dark:hover:text-white transition-colors"
|
||||||
>
|
>
|
||||||
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
@@ -1893,7 +2006,7 @@ export const CreatePanel: React.FC<CreatePanelProps> = ({ onGenerate, isGenerati
|
|||||||
};
|
};
|
||||||
input.click();
|
input.click();
|
||||||
}}
|
}}
|
||||||
disabled={isUploadingReference}
|
disabled={isUploadingReference || isTranscribingReference}
|
||||||
className="mt-4 w-full flex items-center justify-center gap-2 rounded-xl border border-dashed border-zinc-300 dark:border-white/20 bg-zinc-50 dark:bg-white/5 px-4 py-3 text-sm font-medium text-zinc-700 dark:text-zinc-200 hover:bg-zinc-100 dark:hover:bg-white/10 hover:border-zinc-400 dark:hover:border-white/30 transition-all"
|
className="mt-4 w-full flex items-center justify-center gap-2 rounded-xl border border-dashed border-zinc-300 dark:border-white/20 bg-zinc-50 dark:bg-white/5 px-4 py-3 text-sm font-medium text-zinc-700 dark:text-zinc-200 hover:bg-zinc-100 dark:hover:bg-white/10 hover:border-zinc-400 dark:hover:border-white/30 transition-all"
|
||||||
>
|
>
|
||||||
{isUploadingReference ? (
|
{isUploadingReference ? (
|
||||||
@@ -1901,6 +2014,11 @@ export const CreatePanel: React.FC<CreatePanelProps> = ({ onGenerate, isGenerati
|
|||||||
<RefreshCw size={16} className="animate-spin" />
|
<RefreshCw size={16} className="animate-spin" />
|
||||||
Uploading...
|
Uploading...
|
||||||
</>
|
</>
|
||||||
|
) : isTranscribingReference ? (
|
||||||
|
<>
|
||||||
|
<RefreshCw size={16} className="animate-spin" />
|
||||||
|
Transcribing...
|
||||||
|
</>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
<Upload size={16} />
|
<Upload size={16} />
|
||||||
@@ -1913,67 +2031,184 @@ export const CreatePanel: React.FC<CreatePanelProps> = ({ onGenerate, isGenerati
|
|||||||
{uploadError && (
|
{uploadError && (
|
||||||
<div className="mt-2 text-xs text-rose-500">{uploadError}</div>
|
<div className="mt-2 text-xs text-rose-500">{uploadError}</div>
|
||||||
)}
|
)}
|
||||||
|
{isTranscribingReference && (
|
||||||
|
<div className="mt-2 flex items-center justify-between text-xs text-zinc-400">
|
||||||
|
<span>Transcribing with Whisper…</span>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={cancelTranscription}
|
||||||
|
className="text-zinc-600 dark:text-zinc-300 hover:text-zinc-900 dark:hover:text-white"
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Mine Section */}
|
{/* Library Section */}
|
||||||
<div className="border-t border-zinc-100 dark:border-white/5">
|
<div className="border-t border-zinc-100 dark:border-white/5">
|
||||||
<div className="px-5 py-3 flex items-center gap-2">
|
<div className="px-5 py-3 flex items-center gap-2">
|
||||||
<span className="px-3 py-1 rounded-full bg-zinc-900 dark:bg-white text-white dark:text-zinc-900 text-xs font-semibold">
|
<div className="flex items-center gap-1 bg-zinc-200/60 dark:bg-white/10 rounded-full p-0.5">
|
||||||
Mine
|
<button
|
||||||
</span>
|
type="button"
|
||||||
|
onClick={() => setLibraryTab('uploads')}
|
||||||
|
className={`px-3 py-1 rounded-full text-xs font-semibold transition-colors ${
|
||||||
|
libraryTab === 'uploads'
|
||||||
|
? 'bg-zinc-900 dark:bg-white text-white dark:text-zinc-900'
|
||||||
|
: 'text-zinc-500 dark:text-zinc-400 hover:text-zinc-700 dark:hover:text-zinc-200'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
Uploaded
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setLibraryTab('created')}
|
||||||
|
className={`px-3 py-1 rounded-full text-xs font-semibold transition-colors ${
|
||||||
|
libraryTab === 'created'
|
||||||
|
? 'bg-zinc-900 dark:bg-white text-white dark:text-zinc-900'
|
||||||
|
: 'text-zinc-500 dark:text-zinc-400 hover:text-zinc-700 dark:hover:text-zinc-200'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
Created
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Track List */}
|
{/* Track List */}
|
||||||
<div className="max-h-[280px] overflow-y-auto">
|
<div className="max-h-[280px] overflow-y-auto">
|
||||||
{isLoadingTracks ? (
|
{libraryTab === 'uploads' ? (
|
||||||
<div className="px-5 py-8 text-center">
|
isLoadingTracks ? (
|
||||||
<RefreshCw size={20} className="animate-spin mx-auto text-zinc-400" />
|
<div className="px-5 py-8 text-center">
|
||||||
<p className="text-xs text-zinc-400 mt-2">Loading tracks...</p>
|
<RefreshCw size={20} className="animate-spin mx-auto text-zinc-400" />
|
||||||
</div>
|
<p className="text-xs text-zinc-400 mt-2">Loading tracks...</p>
|
||||||
) : referenceTracks.length === 0 ? (
|
</div>
|
||||||
|
) : referenceTracks.length === 0 ? (
|
||||||
|
<div className="px-5 py-8 text-center">
|
||||||
|
<Music2 size={24} className="mx-auto text-zinc-300 dark:text-zinc-600" />
|
||||||
|
<p className="text-sm text-zinc-400 mt-2">No uploads yet</p>
|
||||||
|
<p className="text-xs text-zinc-400 mt-1">Upload audio files to use them as references</p>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="divide-y divide-zinc-100 dark:divide-white/5">
|
||||||
|
{referenceTracks.map((track) => (
|
||||||
|
<div
|
||||||
|
key={track.id}
|
||||||
|
className="px-5 py-3 flex items-center gap-3 hover:bg-zinc-50 dark:hover:bg-white/[0.02] transition-colors group"
|
||||||
|
>
|
||||||
|
{/* Play Button */}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => toggleModalTrack({ id: track.id, audio_url: track.audio_url, source: 'uploads' })}
|
||||||
|
className="flex-shrink-0 w-9 h-9 rounded-full bg-zinc-100 dark:bg-white/10 text-zinc-600 dark:text-zinc-300 flex items-center justify-center hover:bg-zinc-200 dark:hover:bg-white/20 transition-colors"
|
||||||
|
>
|
||||||
|
{playingTrackId === track.id && playingTrackSource === 'uploads' ? (
|
||||||
|
<Pause size={14} fill="currentColor" />
|
||||||
|
) : (
|
||||||
|
<Play size={14} fill="currentColor" className="ml-0.5" />
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{/* Track Info */}
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className="text-sm font-medium text-zinc-800 dark:text-zinc-200 truncate">
|
||||||
|
{track.filename.replace(/\.[^/.]+$/, '')}
|
||||||
|
</span>
|
||||||
|
{track.tags && track.tags.length > 0 && (
|
||||||
|
<div className="flex gap-1">
|
||||||
|
{track.tags.slice(0, 2).map((tag, i) => (
|
||||||
|
<span key={i} className="px-1.5 py-0.5 rounded text-[10px] font-medium bg-zinc-200 dark:bg-white/10 text-zinc-600 dark:text-zinc-400">
|
||||||
|
{tag}
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{/* Progress bar with seek - show when this track is playing */}
|
||||||
|
{playingTrackId === track.id && playingTrackSource === 'uploads' ? (
|
||||||
|
<div className="flex items-center gap-2 mt-1.5">
|
||||||
|
<span className="text-[10px] text-zinc-400 tabular-nums w-8">
|
||||||
|
{formatTime(modalTrackTime)}
|
||||||
|
</span>
|
||||||
|
<div
|
||||||
|
className="flex-1 h-1.5 rounded-full bg-zinc-200 dark:bg-white/10 cursor-pointer group/seek"
|
||||||
|
onClick={(e) => {
|
||||||
|
if (modalAudioRef.current && modalTrackDuration > 0) {
|
||||||
|
const rect = e.currentTarget.getBoundingClientRect();
|
||||||
|
const percent = (e.clientX - rect.left) / rect.width;
|
||||||
|
modalAudioRef.current.currentTime = percent * modalTrackDuration;
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className="h-full bg-gradient-to-r from-pink-500 to-purple-500 rounded-full relative"
|
||||||
|
style={{ width: modalTrackDuration > 0 ? `${(modalTrackTime / modalTrackDuration) * 100}%` : '0%' }}
|
||||||
|
>
|
||||||
|
<div className="absolute right-0 top-1/2 -translate-y-1/2 w-2.5 h-2.5 rounded-full bg-white shadow-md opacity-0 group-hover/seek:opacity-100 transition-opacity" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<span className="text-[10px] text-zinc-400 tabular-nums w-8 text-right">
|
||||||
|
{formatTime(modalTrackDuration)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="text-xs text-zinc-400 mt-0.5">
|
||||||
|
{track.duration ? formatTime(track.duration) : '--:--'}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Actions */}
|
||||||
|
<div className="flex items-center gap-2 opacity-0 group-hover:opacity-100 transition-opacity">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => useReferenceTrack({ audio_url: track.audio_url, title: track.filename })}
|
||||||
|
className="px-3 py-1.5 rounded-lg bg-zinc-900 dark:bg-white text-white dark:text-zinc-900 text-xs font-semibold hover:bg-zinc-800 dark:hover:bg-zinc-100 transition-colors"
|
||||||
|
>
|
||||||
|
Use
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => void deleteReferenceTrack(track.id)}
|
||||||
|
className="p-1.5 rounded-lg hover:bg-zinc-200 dark:hover:bg-white/10 text-zinc-400 hover:text-rose-500 transition-colors"
|
||||||
|
>
|
||||||
|
<Trash2 size={14} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
) : createdTrackOptions.length === 0 ? (
|
||||||
<div className="px-5 py-8 text-center">
|
<div className="px-5 py-8 text-center">
|
||||||
<Music2 size={24} className="mx-auto text-zinc-300 dark:text-zinc-600" />
|
<Music2 size={24} className="mx-auto text-zinc-300 dark:text-zinc-600" />
|
||||||
<p className="text-sm text-zinc-400 mt-2">No tracks yet</p>
|
<p className="text-sm text-zinc-400 mt-2">No created songs yet</p>
|
||||||
<p className="text-xs text-zinc-400 mt-1">Upload audio files to use them as references</p>
|
<p className="text-xs text-zinc-400 mt-1">Generate songs to reuse them as cover or reference</p>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="divide-y divide-zinc-100 dark:divide-white/5">
|
<div className="divide-y divide-zinc-100 dark:divide-white/5">
|
||||||
{referenceTracks.map((track) => (
|
{createdTrackOptions.map((track) => (
|
||||||
<div
|
<div
|
||||||
key={track.id}
|
key={track.id}
|
||||||
className="px-5 py-3 flex items-center gap-3 hover:bg-zinc-50 dark:hover:bg-white/[0.02] transition-colors group"
|
className="px-5 py-3 flex items-center gap-3 hover:bg-zinc-50 dark:hover:bg-white/[0.02] transition-colors group"
|
||||||
>
|
>
|
||||||
{/* Play Button */}
|
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => toggleModalTrack(track)}
|
onClick={() => toggleModalTrack({ id: track.id, audio_url: track.audio_url, source: 'created' })}
|
||||||
className="flex-shrink-0 w-9 h-9 rounded-full bg-zinc-100 dark:bg-white/10 text-zinc-600 dark:text-zinc-300 flex items-center justify-center hover:bg-zinc-200 dark:hover:bg-white/20 transition-colors"
|
className="flex-shrink-0 w-9 h-9 rounded-full bg-zinc-100 dark:bg-white/10 text-zinc-600 dark:text-zinc-300 flex items-center justify-center hover:bg-zinc-200 dark:hover:bg-white/20 transition-colors"
|
||||||
>
|
>
|
||||||
{playingTrackId === track.id ? (
|
{playingTrackId === track.id && playingTrackSource === 'created' ? (
|
||||||
<Pause size={14} fill="currentColor" />
|
<Pause size={14} fill="currentColor" />
|
||||||
) : (
|
) : (
|
||||||
<Play size={14} fill="currentColor" className="ml-0.5" />
|
<Play size={14} fill="currentColor" className="ml-0.5" />
|
||||||
)}
|
)}
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
{/* Track Info */}
|
|
||||||
<div className="flex-1 min-w-0">
|
<div className="flex-1 min-w-0">
|
||||||
<div className="flex items-center gap-2">
|
<div className="text-sm font-medium text-zinc-800 dark:text-zinc-200 truncate">
|
||||||
<span className="text-sm font-medium text-zinc-800 dark:text-zinc-200 truncate">
|
{track.title}
|
||||||
{track.filename.replace(/\.[^/.]+$/, '')}
|
|
||||||
</span>
|
|
||||||
{track.tags && track.tags.length > 0 && (
|
|
||||||
<div className="flex gap-1">
|
|
||||||
{track.tags.slice(0, 2).map((tag, i) => (
|
|
||||||
<span key={i} className="px-1.5 py-0.5 rounded text-[10px] font-medium bg-zinc-200 dark:bg-white/10 text-zinc-600 dark:text-zinc-400">
|
|
||||||
{tag}
|
|
||||||
</span>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
{/* Progress bar with seek - show when this track is playing */}
|
{playingTrackId === track.id && playingTrackSource === 'created' ? (
|
||||||
{playingTrackId === track.id ? (
|
|
||||||
<div className="flex items-center gap-2 mt-1.5">
|
<div className="flex items-center gap-2 mt-1.5">
|
||||||
<span className="text-[10px] text-zinc-400 tabular-nums w-8">
|
<span className="text-[10px] text-zinc-400 tabular-nums w-8">
|
||||||
{formatTime(modalTrackTime)}
|
{formatTime(modalTrackTime)}
|
||||||
@@ -2001,27 +2236,19 @@ export const CreatePanel: React.FC<CreatePanelProps> = ({ onGenerate, isGenerati
|
|||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="text-xs text-zinc-400 mt-0.5">
|
<div className="text-xs text-zinc-400 mt-0.5">
|
||||||
{track.duration ? formatTime(track.duration) : '--:--'}
|
{track.duration || '--:--'}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Actions */}
|
|
||||||
<div className="flex items-center gap-2 opacity-0 group-hover:opacity-100 transition-opacity">
|
<div className="flex items-center gap-2 opacity-0 group-hover:opacity-100 transition-opacity">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => useReferenceTrack(track)}
|
onClick={() => useReferenceTrack({ audio_url: track.audio_url, title: track.title })}
|
||||||
className="px-3 py-1.5 rounded-lg bg-zinc-900 dark:bg-white text-white dark:text-zinc-900 text-xs font-semibold hover:bg-zinc-800 dark:hover:bg-zinc-100 transition-colors"
|
className="px-3 py-1.5 rounded-lg bg-zinc-900 dark:bg-white text-white dark:text-zinc-900 text-xs font-semibold hover:bg-zinc-800 dark:hover:bg-zinc-100 transition-colors"
|
||||||
>
|
>
|
||||||
Use
|
Use
|
||||||
</button>
|
</button>
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() => void deleteReferenceTrack(track.id)}
|
|
||||||
className="p-1.5 rounded-lg hover:bg-zinc-200 dark:hover:bg-white/10 text-zinc-400 hover:text-rose-500 transition-colors"
|
|
||||||
>
|
|
||||||
<Trash2 size={14} />
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
@@ -2043,7 +2270,7 @@ export const CreatePanel: React.FC<CreatePanelProps> = ({ onGenerate, isGenerati
|
|||||||
setModalTrackDuration(modalAudioRef.current.duration);
|
setModalTrackDuration(modalAudioRef.current.duration);
|
||||||
// Update track duration in database if not set
|
// Update track duration in database if not set
|
||||||
const track = referenceTracks.find(t => t.id === playingTrackId);
|
const track = referenceTracks.find(t => t.id === playingTrackId);
|
||||||
if (track && !track.duration && token) {
|
if (playingTrackSource === 'uploads' && track && !track.duration && token) {
|
||||||
fetch(`/api/reference-tracks/${track.id}`, {
|
fetch(`/api/reference-tracks/${track.id}`, {
|
||||||
method: 'PATCH',
|
method: 'PATCH',
|
||||||
headers: {
|
headers: {
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|
||||||
@@ -190,6 +194,18 @@ export const SongDropdownMenu: React.FC<SongDropdownMenuProps> = ({
|
|||||||
label="Reuse Prompt"
|
label="Reuse Prompt"
|
||||||
onClick={() => handleAction(onReusePrompt)}
|
onClick={() => handleAction(onReusePrompt)}
|
||||||
/>
|
/>
|
||||||
|
<MenuItem
|
||||||
|
icon={<Layers size={14} />}
|
||||||
|
label="Use as Reference"
|
||||||
|
onClick={() => handleAction(onUseAsReference)}
|
||||||
|
disabled={!song.audioUrl}
|
||||||
|
/>
|
||||||
|
<MenuItem
|
||||||
|
icon={<Layers size={14} />}
|
||||||
|
label="Cover Song"
|
||||||
|
onClick={() => handleAction(onCoverSong)}
|
||||||
|
disabled={!song.audioUrl}
|
||||||
|
/>
|
||||||
|
|
||||||
<MenuDivider />
|
<MenuDivider />
|
||||||
|
|
||||||
|
|||||||
+155
-34
@@ -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;
|
||||||
@@ -22,6 +23,8 @@ interface SongListProps {
|
|||||||
onReusePrompt?: (song: Song) => void;
|
onReusePrompt?: (song: Song) => void;
|
||||||
onDelete?: (song: Song) => void;
|
onDelete?: (song: Song) => void;
|
||||||
onDeleteMany?: (songs: Song[]) => void;
|
onDeleteMany?: (songs: Song[]) => void;
|
||||||
|
onUseAsReference?: (song: Song) => void;
|
||||||
|
onCoverSong?: (song: Song) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
// ... existing code ...
|
// ... existing code ...
|
||||||
@@ -44,6 +47,7 @@ export const SongList: React.FC<SongListProps> = ({
|
|||||||
selectedSong,
|
selectedSong,
|
||||||
likedSongIds,
|
likedSongIds,
|
||||||
isPlaying,
|
isPlaying,
|
||||||
|
referenceTracks = [],
|
||||||
onPlay,
|
onPlay,
|
||||||
onSelect,
|
onSelect,
|
||||||
onToggleLike,
|
onToggleLike,
|
||||||
@@ -53,7 +57,9 @@ export const SongList: React.FC<SongListProps> = ({
|
|||||||
onNavigateToProfile,
|
onNavigateToProfile,
|
||||||
onReusePrompt,
|
onReusePrompt,
|
||||||
onDelete,
|
onDelete,
|
||||||
onDeleteMany
|
onDeleteMany,
|
||||||
|
onUseAsReference,
|
||||||
|
onCoverSong
|
||||||
}) => {
|
}) => {
|
||||||
const { user } = useAuth();
|
const { user } = useAuth();
|
||||||
const [searchQuery, setSearchQuery] = useState('');
|
const [searchQuery, setSearchQuery] = useState('');
|
||||||
@@ -120,6 +126,31 @@ 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(
|
const selectableSongs = useMemo(
|
||||||
() => filteredSongs.filter(song => !song.isGenerating),
|
() => filteredSongs.filter(song => !song.isGenerating),
|
||||||
[filteredSongs]
|
[filteredSongs]
|
||||||
@@ -254,7 +285,7 @@ export const SongList: React.FC<SongListProps> = ({
|
|||||||
|
|
||||||
{/* 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} />
|
||||||
@@ -268,36 +299,59 @@ export const SongList: React.FC<SongListProps> = ({
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
filteredSongs.map((song) => (
|
listItems.map((item) => (
|
||||||
<SongItem
|
item.type === 'song' ? (
|
||||||
key={song.id}
|
<SongItem
|
||||||
song={song}
|
key={item.id}
|
||||||
isCurrent={currentSong?.id === song.id}
|
song={item.song}
|
||||||
isSelected={selectedSong?.id === song.id}
|
isCurrent={currentSong?.id === item.song.id}
|
||||||
isSelectionMode={isSelecting}
|
isSelected={selectedSong?.id === item.song.id}
|
||||||
isChecked={selectedIds.has(song.id)}
|
isSelectionMode={isSelecting}
|
||||||
isLiked={likedSongIds.has(song.id)}
|
isChecked={selectedIds.has(item.song.id)}
|
||||||
isPlaying={isPlaying}
|
isLiked={likedSongIds.has(item.song.id)}
|
||||||
isOwner={user?.id === song.userId}
|
isPlaying={isPlaying}
|
||||||
onPlay={() => onPlay(song)}
|
isOwner={user?.id === item.song.userId}
|
||||||
onSelect={() => onSelect(song)}
|
onPlay={() => onPlay(item.song)}
|
||||||
onToggleSelect={() => {
|
onSelect={() => onSelect(item.song)}
|
||||||
if (song.isGenerating) return;
|
onToggleSelect={() => {
|
||||||
setSelectedIds(prev => {
|
if (item.song.isGenerating) return;
|
||||||
const next = new Set(prev);
|
setSelectedIds(prev => {
|
||||||
if (next.has(song.id)) next.delete(song.id);
|
const next = new Set(prev);
|
||||||
else next.add(song.id);
|
if (next.has(item.song.id)) next.delete(item.song.id);
|
||||||
return next;
|
else next.add(item.song.id);
|
||||||
});
|
return next;
|
||||||
}}
|
});
|
||||||
onToggleLike={() => onToggleLike(song.id)}
|
}}
|
||||||
onAddToPlaylist={() => onAddToPlaylist(song)}
|
onToggleLike={() => onToggleLike(item.song.id)}
|
||||||
onOpenVideo={() => onOpenVideo && onOpenVideo(song)}
|
onAddToPlaylist={() => onAddToPlaylist(item.song)}
|
||||||
onShowDetails={() => onShowDetails && onShowDetails(song)}
|
onOpenVideo={() => onOpenVideo && onOpenVideo(item.song)}
|
||||||
onNavigateToProfile={onNavigateToProfile}
|
onShowDetails={() => onShowDetails && onShowDetails(item.song)}
|
||||||
onReusePrompt={() => onReusePrompt?.(song)}
|
onNavigateToProfile={onNavigateToProfile}
|
||||||
onDelete={() => onDelete?.(song)}
|
onReusePrompt={() => onReusePrompt?.(item.song)}
|
||||||
/>
|
onDelete={() => onDelete?.(item.song)}
|
||||||
|
onUseAsReference={() => onUseAsReference?.(item.song)}
|
||||||
|
onCoverSong={() => onCoverSong?.(item.song)}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<UploadItem
|
||||||
|
key={`upload_${item.id}`}
|
||||||
|
track={item.track}
|
||||||
|
onPlay={(audioUrl, title) => {
|
||||||
|
onPlay({
|
||||||
|
id: `upload_${item.id}`,
|
||||||
|
title,
|
||||||
|
lyrics: '',
|
||||||
|
style: 'Upload',
|
||||||
|
coverUrl: '',
|
||||||
|
duration: '0:00',
|
||||||
|
createdAt: item.createdAt,
|
||||||
|
tags: [],
|
||||||
|
audioUrl,
|
||||||
|
isPublic: false,
|
||||||
|
} as Song);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)
|
||||||
))
|
))
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@@ -325,6 +379,8 @@ 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> = ({
|
||||||
@@ -345,7 +401,9 @@ const SongItem: React.FC<SongItemProps> = ({
|
|||||||
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);
|
||||||
@@ -355,7 +413,17 @@ 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',
|
||||||
|
}));
|
||||||
|
}}
|
||||||
|
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 && (
|
{isSelectionMode && (
|
||||||
<button
|
<button
|
||||||
@@ -553,6 +621,8 @@ const SongItem: React.FC<SongItemProps> = ({
|
|||||||
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>
|
||||||
@@ -577,3 +647,54 @@ 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;
|
||||||
|
}> = ({ track, onPlay }) => {
|
||||||
|
const title = track.filename.replace(/\.[^/.]+$/, '');
|
||||||
|
const duration = track.duration
|
||||||
|
? `${Math.floor(track.duration / 60)}:${String(Math.floor(track.duration % 60)).padStart(2, '0')}`
|
||||||
|
: '--:--';
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className="group flex items-center gap-4 p-2 rounded-lg hover:bg-zinc-100 dark:hover:bg-[#18181b] transition-all cursor-grab active:cursor-grabbing border border-transparent"
|
||||||
|
draggable
|
||||||
|
onDragStart={(e) => {
|
||||||
|
e.dataTransfer.effectAllowed = 'copy';
|
||||||
|
e.dataTransfer.setData('application/x-ace-audio', JSON.stringify({
|
||||||
|
url: track.audio_url,
|
||||||
|
title,
|
||||||
|
source: 'upload',
|
||||||
|
}));
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div className="relative w-16 h-16 flex-shrink-0 rounded-md bg-zinc-200 dark:bg-zinc-800 overflow-hidden shadow-sm">
|
||||||
|
<AlbumCover seed={track.id || title} size="full" className="w-full h-full opacity-40" />
|
||||||
|
<div
|
||||||
|
className="absolute inset-0 bg-black/40 flex items-center justify-center cursor-pointer transition-opacity"
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
onPlay(track.audio_url, title);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div className="w-9 h-9 rounded-full bg-white flex items-center justify-center shadow-lg">
|
||||||
|
<Play fill="black" className="text-black ml-0.5 w-4 h-4" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<div className="text-sm font-bold text-zinc-900 dark:text-white truncate">
|
||||||
|
{title}
|
||||||
|
</div>
|
||||||
|
<div className="text-[11px] text-zinc-400 mt-1">Upload</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="text-xs font-mono text-zinc-500 dark:text-zinc-600 self-start pt-1">
|
||||||
|
{duration}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|||||||
@@ -1,11 +1,18 @@
|
|||||||
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(),
|
||||||
@@ -31,6 +38,93 @@ const upload = multer({
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
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 {
|
||||||
@@ -72,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;
|
||||||
@@ -87,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);
|
||||||
@@ -153,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 {
|
||||||
|
|||||||
Reference in New Issue
Block a user