2033 lines
96 KiB
TypeScript
2033 lines
96 KiB
TypeScript
import React, { useState, useEffect, useRef, useCallback } from 'react';
|
|
import { Sparkles, ChevronDown, Settings2, Trash2, Music2, Sliders, Dices, Hash, RefreshCw, Plus, Upload, Play, Pause, Loader2 } from 'lucide-react';
|
|
import { GenerationParams, Song } from '../types';
|
|
import { useAuth } from '../context/AuthContext';
|
|
import { generateApi } from '../services/api';
|
|
|
|
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;
|
|
}
|
|
|
|
interface CreatePanelProps {
|
|
onGenerate: (params: GenerationParams) => void;
|
|
isGenerating: boolean;
|
|
initialData?: { song: Song, timestamp: number } | null;
|
|
}
|
|
|
|
const KEY_SIGNATURES = [
|
|
'',
|
|
'C major', 'C minor',
|
|
'C# major', 'C# minor',
|
|
'Db major', 'Db minor',
|
|
'D major', 'D minor',
|
|
'D# major', 'D# minor',
|
|
'Eb major', 'Eb minor',
|
|
'E major', 'E minor',
|
|
'F major', 'F minor',
|
|
'F# major', 'F# minor',
|
|
'Gb major', 'Gb minor',
|
|
'G major', 'G minor',
|
|
'G# major', 'G# minor',
|
|
'Ab major', 'Ab minor',
|
|
'A major', 'A minor',
|
|
'A# major', 'A# minor',
|
|
'Bb major', 'Bb minor',
|
|
'B major', 'B minor'
|
|
];
|
|
|
|
const TIME_SIGNATURES = ['', '2/4', '3/4', '4/4', '6/8'];
|
|
|
|
const VOCAL_LANGUAGES = [
|
|
{ value: 'unknown', label: 'Auto / Instrumental' },
|
|
{ value: 'ar', label: 'Arabic' },
|
|
{ value: 'az', label: 'Azerbaijani' },
|
|
{ value: 'bg', label: 'Bulgarian' },
|
|
{ value: 'bn', label: 'Bengali' },
|
|
{ value: 'ca', label: 'Catalan' },
|
|
{ value: 'cs', label: 'Czech' },
|
|
{ value: 'da', label: 'Danish' },
|
|
{ value: 'de', label: 'German' },
|
|
{ value: 'el', label: 'Greek' },
|
|
{ value: 'en', label: 'English' },
|
|
{ value: 'es', label: 'Spanish' },
|
|
{ value: 'fa', label: 'Persian' },
|
|
{ value: 'fi', label: 'Finnish' },
|
|
{ value: 'fr', label: 'French' },
|
|
{ value: 'he', label: 'Hebrew' },
|
|
{ value: 'hi', label: 'Hindi' },
|
|
{ value: 'hr', label: 'Croatian' },
|
|
{ value: 'ht', label: 'Haitian Creole' },
|
|
{ value: 'hu', label: 'Hungarian' },
|
|
{ value: 'id', label: 'Indonesian' },
|
|
{ value: 'is', label: 'Icelandic' },
|
|
{ value: 'it', label: 'Italian' },
|
|
{ value: 'ja', label: 'Japanese' },
|
|
{ value: 'ko', label: 'Korean' },
|
|
{ value: 'la', label: 'Latin' },
|
|
{ value: 'lt', label: 'Lithuanian' },
|
|
{ value: 'ms', label: 'Malay' },
|
|
{ value: 'ne', label: 'Nepali' },
|
|
{ value: 'nl', label: 'Dutch' },
|
|
{ value: 'no', label: 'Norwegian' },
|
|
{ value: 'pa', label: 'Punjabi' },
|
|
{ value: 'pl', label: 'Polish' },
|
|
{ value: 'pt', label: 'Portuguese' },
|
|
{ value: 'ro', label: 'Romanian' },
|
|
{ value: 'ru', label: 'Russian' },
|
|
{ value: 'sa', label: 'Sanskrit' },
|
|
{ value: 'sk', label: 'Slovak' },
|
|
{ value: 'sr', label: 'Serbian' },
|
|
{ value: 'sv', label: 'Swedish' },
|
|
{ value: 'sw', label: 'Swahili' },
|
|
{ value: 'ta', label: 'Tamil' },
|
|
{ value: 'te', label: 'Telugu' },
|
|
{ value: 'th', label: 'Thai' },
|
|
{ value: 'tl', label: 'Tagalog' },
|
|
{ value: 'tr', label: 'Turkish' },
|
|
{ value: 'uk', label: 'Ukrainian' },
|
|
{ value: 'ur', label: 'Urdu' },
|
|
{ value: 'vi', label: 'Vietnamese' },
|
|
{ value: 'yue', label: 'Cantonese' },
|
|
{ value: 'zh', label: 'Chinese (Mandarin)' },
|
|
];
|
|
|
|
export const CreatePanel: React.FC<CreatePanelProps> = ({ onGenerate, isGenerating, initialData }) => {
|
|
const { isAuthenticated, token } = useAuth();
|
|
|
|
// Mode
|
|
const [customMode, setCustomMode] = useState(true);
|
|
|
|
// Simple Mode
|
|
const [songDescription, setSongDescription] = useState('');
|
|
|
|
// Custom Mode
|
|
const [lyrics, setLyrics] = useState('');
|
|
const [style, setStyle] = useState('');
|
|
const [title, setTitle] = useState('');
|
|
|
|
// Common
|
|
const [instrumental, setInstrumental] = useState(false);
|
|
const [vocalLanguage, setVocalLanguage] = useState('en');
|
|
|
|
// Music Parameters
|
|
const [bpm, setBpm] = useState(0);
|
|
const [keyScale, setKeyScale] = useState('');
|
|
const [timeSignature, setTimeSignature] = useState('');
|
|
|
|
// Advanced Settings
|
|
const [showAdvanced, setShowAdvanced] = useState(false);
|
|
const [duration, setDuration] = useState(-1);
|
|
const [batchSize, setBatchSize] = useState(2);
|
|
const [bulkCount, setBulkCount] = useState(1); // Number of independent generation jobs to queue
|
|
const [guidanceScale, setGuidanceScale] = useState(7.0);
|
|
const [randomSeed, setRandomSeed] = useState(true);
|
|
const [seed, setSeed] = useState(-1);
|
|
const [thinking, setThinking] = useState(false); // Default false for GPU compatibility
|
|
const [audioFormat, setAudioFormat] = useState<'mp3' | 'flac'>('mp3');
|
|
const [inferenceSteps, setInferenceSteps] = useState(8);
|
|
const [inferMethod, setInferMethod] = useState<'ode' | 'sde'>('ode');
|
|
const [shift, setShift] = useState(3.0);
|
|
|
|
// LM Parameters (under Expert)
|
|
const [showLmParams, setShowLmParams] = useState(false);
|
|
const [lmTemperature, setLmTemperature] = useState(0.85);
|
|
const [lmCfgScale, setLmCfgScale] = useState(2.0);
|
|
const [lmTopK, setLmTopK] = useState(0);
|
|
const [lmTopP, setLmTopP] = useState(0.9);
|
|
const [lmNegativePrompt, setLmNegativePrompt] = useState('NO USER INPUT');
|
|
|
|
// Expert Parameters (now in Advanced section)
|
|
const [referenceAudioUrl, setReferenceAudioUrl] = useState('');
|
|
const [sourceAudioUrl, setSourceAudioUrl] = useState('');
|
|
const [referenceAudioTitle, setReferenceAudioTitle] = useState('');
|
|
const [sourceAudioTitle, setSourceAudioTitle] = useState('');
|
|
const [audioCodes, setAudioCodes] = useState('');
|
|
const [repaintingStart, setRepaintingStart] = useState(0);
|
|
const [repaintingEnd, setRepaintingEnd] = useState(-1);
|
|
const [instruction, setInstruction] = useState('Fill the audio semantic mask based on the given conditions:');
|
|
const [audioCoverStrength, setAudioCoverStrength] = useState(1.0);
|
|
const [taskType, setTaskType] = useState('text2music');
|
|
const [useAdg, setUseAdg] = useState(false);
|
|
const [cfgIntervalStart, setCfgIntervalStart] = useState(0.0);
|
|
const [cfgIntervalEnd, setCfgIntervalEnd] = useState(1.0);
|
|
const [customTimesteps, setCustomTimesteps] = useState('');
|
|
const [useCotMetas, setUseCotMetas] = useState(true);
|
|
const [useCotCaption, setUseCotCaption] = useState(true);
|
|
const [useCotLanguage, setUseCotLanguage] = useState(true);
|
|
const [autogen, setAutogen] = useState(false);
|
|
const [constrainedDecodingDebug, setConstrainedDecodingDebug] = useState(false);
|
|
const [allowLmBatch, setAllowLmBatch] = useState(true);
|
|
const [getScores, setGetScores] = useState(false);
|
|
const [getLrc, setGetLrc] = useState(false);
|
|
const [scoreScale, setScoreScale] = useState(0.5);
|
|
const [lmBatchChunkSize, setLmBatchChunkSize] = useState(8);
|
|
const [trackName, setTrackName] = useState('');
|
|
const [completeTrackClasses, setCompleteTrackClasses] = useState('');
|
|
const [isFormatCaption, setIsFormatCaption] = useState(false);
|
|
const [maxDurationWithLm, setMaxDurationWithLm] = useState(240);
|
|
const [maxDurationWithoutLm, setMaxDurationWithoutLm] = useState(240);
|
|
|
|
const [isUploadingReference, setIsUploadingReference] = useState(false);
|
|
const [isUploadingSource, setIsUploadingSource] = useState(false);
|
|
const [uploadError, setUploadError] = useState<string | null>(null);
|
|
const [isFormattingStyle, setIsFormattingStyle] = useState(false);
|
|
const [isFormattingLyrics, setIsFormattingLyrics] = useState(false);
|
|
const [isDraggingFile, setIsDraggingFile] = useState(false);
|
|
const referenceInputRef = useRef<HTMLInputElement>(null);
|
|
const sourceInputRef = useRef<HTMLInputElement>(null);
|
|
const dragDepthRef = useRef(0);
|
|
const [showAudioModal, setShowAudioModal] = useState(false);
|
|
const [audioModalTarget, setAudioModalTarget] = useState<'reference' | 'source'>('reference');
|
|
const [tempAudioUrl, setTempAudioUrl] = useState('');
|
|
const [audioTab, setAudioTab] = useState<'reference' | 'source'>('reference');
|
|
const referenceAudioRef = useRef<HTMLAudioElement>(null);
|
|
const sourceAudioRef = useRef<HTMLAudioElement>(null);
|
|
const [referencePlaying, setReferencePlaying] = useState(false);
|
|
const [sourcePlaying, setSourcePlaying] = useState(false);
|
|
const [referenceTime, setReferenceTime] = useState(0);
|
|
const [sourceTime, setSourceTime] = useState(0);
|
|
const [referenceDuration, setReferenceDuration] = useState(0);
|
|
const [sourceDuration, setSourceDuration] = useState(0);
|
|
|
|
// Reference tracks modal state
|
|
const [referenceTracks, setReferenceTracks] = useState<ReferenceTrack[]>([]);
|
|
const [isLoadingTracks, setIsLoadingTracks] = useState(false);
|
|
const [playingTrackId, setPlayingTrackId] = useState<string | null>(null);
|
|
const modalAudioRef = useRef<HTMLAudioElement>(null);
|
|
const [modalTrackTime, setModalTrackTime] = useState(0);
|
|
const [modalTrackDuration, setModalTrackDuration] = useState(0);
|
|
|
|
const getAudioLabel = (url: string) => {
|
|
try {
|
|
const parsed = new URL(url);
|
|
const name = decodeURIComponent(parsed.pathname.split('/').pop() || parsed.hostname);
|
|
return name.replace(/\.[^/.]+$/, '') || name;
|
|
} catch {
|
|
const parts = url.split('/');
|
|
const name = decodeURIComponent(parts[parts.length - 1] || url);
|
|
return name.replace(/\.[^/.]+$/, '') || name;
|
|
}
|
|
};
|
|
|
|
// Resize Logic
|
|
const [lyricsHeight, setLyricsHeight] = useState(() => {
|
|
const saved = localStorage.getItem('acestep_lyrics_height');
|
|
return saved ? parseInt(saved, 10) : 144; // Default h-36 is 144px (9rem * 16)
|
|
});
|
|
const [isResizing, setIsResizing] = useState(false);
|
|
const lyricsRef = useRef<HTMLDivElement>(null);
|
|
|
|
// Reuse Effect - must be after all state declarations
|
|
useEffect(() => {
|
|
if (initialData) {
|
|
setCustomMode(true);
|
|
setLyrics(initialData.song.lyrics);
|
|
setStyle(initialData.song.style);
|
|
setTitle(initialData.song.title);
|
|
setInstrumental(initialData.song.lyrics.length === 0);
|
|
}
|
|
}, [initialData]);
|
|
|
|
useEffect(() => {
|
|
const handleMouseMove = (e: MouseEvent) => {
|
|
if (!isResizing) return;
|
|
|
|
// Calculate new height based on mouse position relative to the lyrics container top
|
|
// We can't easily get the container top here without a ref to it,
|
|
// but we can use dy (delta y) from the previous position if we tracked it,
|
|
// OR simpler: just update based on movement if we track the start.
|
|
//
|
|
// Better approach for absolute sizing:
|
|
// 1. Get the bounding rect of the textarea wrapper on mount/resize start?
|
|
// We can just rely on the fact that we are dragging the bottom.
|
|
// So new height = currentMouseY - topOfElement.
|
|
|
|
if (lyricsRef.current) {
|
|
const rect = lyricsRef.current.getBoundingClientRect();
|
|
const newHeight = e.clientY - rect.top;
|
|
// detailed limits: min 96px (h-24), max 600px
|
|
if (newHeight > 96 && newHeight < 600) {
|
|
setLyricsHeight(newHeight);
|
|
}
|
|
}
|
|
};
|
|
|
|
const handleMouseUp = () => {
|
|
setIsResizing(false);
|
|
document.body.style.cursor = 'default';
|
|
document.body.style.userSelect = 'auto';
|
|
// Save height to localStorage
|
|
localStorage.setItem('acestep_lyrics_height', String(lyricsHeight));
|
|
};
|
|
|
|
if (isResizing) {
|
|
window.addEventListener('mousemove', handleMouseMove);
|
|
window.addEventListener('mouseup', handleMouseUp);
|
|
document.body.style.cursor = 'ns-resize';
|
|
document.body.style.userSelect = 'none'; // Prevent text selection while dragging
|
|
}
|
|
|
|
return () => {
|
|
window.removeEventListener('mousemove', handleMouseMove);
|
|
window.removeEventListener('mouseup', handleMouseUp);
|
|
document.body.style.cursor = 'default';
|
|
document.body.style.userSelect = 'auto';
|
|
};
|
|
}, [isResizing]);
|
|
|
|
useEffect(() => {
|
|
const loadLimits = async () => {
|
|
try {
|
|
const response = await fetch('/api/generate/limits');
|
|
if (!response.ok) return;
|
|
const data = await response.json();
|
|
if (typeof data.max_duration_with_lm === 'number') {
|
|
setMaxDurationWithLm(data.max_duration_with_lm);
|
|
}
|
|
if (typeof data.max_duration_without_lm === 'number') {
|
|
setMaxDurationWithoutLm(data.max_duration_without_lm);
|
|
}
|
|
} catch {
|
|
// ignore limits fetch failures
|
|
}
|
|
};
|
|
|
|
loadLimits();
|
|
}, []);
|
|
|
|
const activeMaxDuration = thinking ? maxDurationWithLm : maxDurationWithoutLm;
|
|
|
|
useEffect(() => {
|
|
if (duration > activeMaxDuration) {
|
|
setDuration(activeMaxDuration);
|
|
}
|
|
}, [duration, activeMaxDuration]);
|
|
|
|
useEffect(() => {
|
|
const isFileDrag = (e: DragEvent) =>
|
|
!!(e.dataTransfer && Array.from(e.dataTransfer.types).includes('Files'));
|
|
|
|
const handleDragEnter = (e: DragEvent) => {
|
|
if (!isFileDrag(e)) return;
|
|
dragDepthRef.current += 1;
|
|
setIsDraggingFile(true);
|
|
e.preventDefault();
|
|
};
|
|
|
|
const handleDragOver = (e: DragEvent) => {
|
|
if (!isFileDrag(e)) return;
|
|
e.preventDefault();
|
|
};
|
|
|
|
const handleDragLeave = (e: DragEvent) => {
|
|
if (!isFileDrag(e)) return;
|
|
dragDepthRef.current = Math.max(0, dragDepthRef.current - 1);
|
|
if (dragDepthRef.current === 0) {
|
|
setIsDraggingFile(false);
|
|
}
|
|
};
|
|
|
|
const handleDrop = (e: DragEvent) => {
|
|
if (!isFileDrag(e)) return;
|
|
e.preventDefault();
|
|
dragDepthRef.current = 0;
|
|
setIsDraggingFile(false);
|
|
};
|
|
|
|
window.addEventListener('dragenter', handleDragEnter);
|
|
window.addEventListener('dragover', handleDragOver);
|
|
window.addEventListener('dragleave', handleDragLeave);
|
|
window.addEventListener('drop', handleDrop);
|
|
|
|
return () => {
|
|
window.removeEventListener('dragenter', handleDragEnter);
|
|
window.removeEventListener('dragover', handleDragOver);
|
|
window.removeEventListener('dragleave', handleDragLeave);
|
|
window.removeEventListener('drop', handleDrop);
|
|
};
|
|
}, []);
|
|
|
|
const startResizing = (e: React.MouseEvent) => {
|
|
e.preventDefault();
|
|
setIsResizing(true);
|
|
};
|
|
|
|
const uploadAudio = async (file: File, target: 'reference' | 'source') => {
|
|
if (!token) {
|
|
setUploadError('Please sign in to upload audio.');
|
|
return;
|
|
}
|
|
setUploadError(null);
|
|
const setUploading = target === 'reference' ? setIsUploadingReference : setIsUploadingSource;
|
|
const setUrl = target === 'reference' ? setReferenceAudioUrl : setSourceAudioUrl;
|
|
setUploading(true);
|
|
try {
|
|
const result = await generateApi.uploadAudio(file, token);
|
|
setUrl(result.url);
|
|
setShowAudioModal(false);
|
|
setTempAudioUrl('');
|
|
} catch (err) {
|
|
const message = err instanceof Error ? err.message : 'Upload failed';
|
|
setUploadError(message);
|
|
} finally {
|
|
setUploading(false);
|
|
}
|
|
};
|
|
|
|
const handleFileSelect = (e: React.ChangeEvent<HTMLInputElement>, target: 'reference' | 'source') => {
|
|
const file = e.target.files?.[0];
|
|
if (file) {
|
|
void uploadReferenceTrack(file, target);
|
|
}
|
|
e.target.value = '';
|
|
};
|
|
|
|
// Format handler - uses LLM to enhance style/lyrics and auto-fill parameters
|
|
const handleFormat = async (target: 'style' | 'lyrics') => {
|
|
if (!token || !style.trim()) return;
|
|
if (target === 'style') {
|
|
setIsFormattingStyle(true);
|
|
} else {
|
|
setIsFormattingLyrics(true);
|
|
}
|
|
try {
|
|
const result = await generateApi.formatInput({
|
|
caption: style,
|
|
lyrics: lyrics,
|
|
bpm: bpm > 0 ? bpm : undefined,
|
|
duration: duration > 0 ? duration : undefined,
|
|
keyScale: keyScale || undefined,
|
|
timeSignature: timeSignature || undefined,
|
|
temperature: lmTemperature,
|
|
topK: lmTopK > 0 ? lmTopK : undefined,
|
|
topP: lmTopP,
|
|
}, token);
|
|
|
|
if (result.success) {
|
|
// Update fields with LLM-generated values
|
|
if (target === 'style' && result.caption) setStyle(result.caption);
|
|
if (target === 'lyrics' && result.lyrics) setLyrics(result.lyrics);
|
|
if (result.bpm && result.bpm > 0) setBpm(result.bpm);
|
|
if (result.duration && result.duration > 0) setDuration(result.duration);
|
|
if (result.key_scale) setKeyScale(result.key_scale);
|
|
if (result.time_signature) setTimeSignature(result.time_signature);
|
|
if (result.language) setVocalLanguage(result.language);
|
|
if (target === 'style') setIsFormatCaption(true);
|
|
} else {
|
|
console.error('Format failed:', result.error || result.status_message);
|
|
alert(result.error || result.status_message || 'Format failed. Make sure the LLM is initialized.');
|
|
}
|
|
} catch (err) {
|
|
console.error('Format error:', err);
|
|
alert('Format failed. The LLM may not be available.');
|
|
} finally {
|
|
if (target === 'style') {
|
|
setIsFormattingStyle(false);
|
|
} else {
|
|
setIsFormattingLyrics(false);
|
|
}
|
|
}
|
|
};
|
|
|
|
const openAudioModal = (target: 'reference' | 'source') => {
|
|
setAudioModalTarget(target);
|
|
setTempAudioUrl('');
|
|
setShowAudioModal(true);
|
|
void fetchReferenceTracks();
|
|
};
|
|
|
|
const fetchReferenceTracks = useCallback(async () => {
|
|
if (!token) return;
|
|
setIsLoadingTracks(true);
|
|
try {
|
|
const response = await fetch('/api/reference-tracks', {
|
|
headers: { Authorization: `Bearer ${token}` }
|
|
});
|
|
if (response.ok) {
|
|
const data = await response.json();
|
|
setReferenceTracks(data.tracks || []);
|
|
}
|
|
} catch (err) {
|
|
console.error('Failed to fetch reference tracks:', err);
|
|
} finally {
|
|
setIsLoadingTracks(false);
|
|
}
|
|
}, [token]);
|
|
|
|
const uploadReferenceTrack = async (file: File, target?: 'reference' | 'source') => {
|
|
if (!token) {
|
|
setUploadError('Please sign in to upload audio.');
|
|
return;
|
|
}
|
|
setUploadError(null);
|
|
setIsUploadingReference(true);
|
|
try {
|
|
const formData = new FormData();
|
|
formData.append('audio', file);
|
|
|
|
const response = await fetch('/api/reference-tracks', {
|
|
method: 'POST',
|
|
headers: { Authorization: `Bearer ${token}` },
|
|
body: formData
|
|
});
|
|
|
|
if (!response.ok) {
|
|
const err = await response.json();
|
|
throw new Error(err.error || 'Upload failed');
|
|
}
|
|
|
|
const data = await response.json();
|
|
setReferenceTracks(prev => [data.track, ...prev]);
|
|
|
|
// Also set as current reference/source
|
|
const selectedTarget = target ?? audioModalTarget;
|
|
applyAudioTargetUrl(selectedTarget, data.track.audio_url, data.track.filename);
|
|
setShowAudioModal(false);
|
|
} catch (err) {
|
|
const message = err instanceof Error ? err.message : 'Upload failed';
|
|
setUploadError(message);
|
|
} finally {
|
|
setIsUploadingReference(false);
|
|
}
|
|
};
|
|
|
|
const deleteReferenceTrack = async (trackId: string) => {
|
|
if (!token) return;
|
|
try {
|
|
const response = await fetch(`/api/reference-tracks/${trackId}`, {
|
|
method: 'DELETE',
|
|
headers: { Authorization: `Bearer ${token}` }
|
|
});
|
|
if (response.ok) {
|
|
setReferenceTracks(prev => prev.filter(t => t.id !== trackId));
|
|
if (playingTrackId === trackId) {
|
|
setPlayingTrackId(null);
|
|
if (modalAudioRef.current) {
|
|
modalAudioRef.current.pause();
|
|
}
|
|
}
|
|
}
|
|
} catch (err) {
|
|
console.error('Failed to delete track:', err);
|
|
}
|
|
};
|
|
|
|
const useReferenceTrack = (track: ReferenceTrack) => {
|
|
applyAudioTargetUrl(audioModalTarget, track.audio_url, track.filename);
|
|
setShowAudioModal(false);
|
|
setPlayingTrackId(null);
|
|
};
|
|
|
|
const toggleModalTrack = (track: ReferenceTrack) => {
|
|
if (playingTrackId === track.id) {
|
|
if (modalAudioRef.current) {
|
|
modalAudioRef.current.pause();
|
|
}
|
|
setPlayingTrackId(null);
|
|
} else {
|
|
setPlayingTrackId(track.id);
|
|
if (modalAudioRef.current) {
|
|
modalAudioRef.current.src = track.audio_url;
|
|
modalAudioRef.current.play().catch(() => undefined);
|
|
}
|
|
}
|
|
};
|
|
|
|
const applyAudioUrl = () => {
|
|
if (!tempAudioUrl.trim()) return;
|
|
applyAudioTargetUrl(audioModalTarget, tempAudioUrl.trim());
|
|
setShowAudioModal(false);
|
|
setTempAudioUrl('');
|
|
};
|
|
|
|
const applyAudioTargetUrl = (target: 'reference' | 'source', url: string, title?: string) => {
|
|
const derivedTitle = title ? title.replace(/\.[^/.]+$/, '') : getAudioLabel(url);
|
|
if (target === 'reference') {
|
|
setReferenceAudioUrl(url);
|
|
setReferenceAudioTitle(derivedTitle);
|
|
setReferenceTime(0);
|
|
setReferenceDuration(0);
|
|
} else {
|
|
setSourceAudioUrl(url);
|
|
setSourceAudioTitle(derivedTitle);
|
|
setSourceTime(0);
|
|
setSourceDuration(0);
|
|
if (taskType === 'text2music') {
|
|
setTaskType('cover');
|
|
}
|
|
}
|
|
};
|
|
|
|
const formatTime = (time: number) => {
|
|
if (!Number.isFinite(time) || time <= 0) return '0:00';
|
|
const minutes = Math.floor(time / 60);
|
|
const seconds = Math.floor(time % 60);
|
|
return `${minutes}:${String(seconds).padStart(2, '0')}`;
|
|
};
|
|
|
|
const toggleAudio = (target: 'reference' | 'source') => {
|
|
const audio = target === 'reference' ? referenceAudioRef.current : sourceAudioRef.current;
|
|
if (!audio) return;
|
|
if (audio.paused) {
|
|
audio.play().catch(() => undefined);
|
|
} else {
|
|
audio.pause();
|
|
}
|
|
};
|
|
|
|
const handleDrop = (e: React.DragEvent<HTMLDivElement>, target: 'reference' | 'source') => {
|
|
e.preventDefault();
|
|
const file = e.dataTransfer.files?.[0];
|
|
if (file) {
|
|
void uploadReferenceTrack(file, target);
|
|
}
|
|
};
|
|
|
|
const handleDragOver = (e: React.DragEvent<HTMLDivElement>) => {
|
|
e.preventDefault();
|
|
};
|
|
|
|
const handleWorkspaceDrop = (e: React.DragEvent<HTMLDivElement>) => {
|
|
if (e.dataTransfer.files?.length) {
|
|
handleDrop(e, audioTab);
|
|
}
|
|
};
|
|
|
|
const handleWorkspaceDragOver = (e: React.DragEvent<HTMLDivElement>) => {
|
|
if (e.dataTransfer.types.includes('Files')) {
|
|
e.preventDefault();
|
|
}
|
|
};
|
|
|
|
const handleGenerate = () => {
|
|
// Bulk generation: loop bulkCount times
|
|
for (let i = 0; i < bulkCount; i++) {
|
|
// Seed handling: first job uses user's seed, rest get random seeds
|
|
let jobSeed = -1;
|
|
if (!randomSeed && i === 0) {
|
|
jobSeed = seed;
|
|
} else if (!randomSeed && i > 0) {
|
|
// Subsequent jobs get random seeds for variety
|
|
jobSeed = Math.floor(Math.random() * 4294967295);
|
|
}
|
|
|
|
onGenerate({
|
|
customMode,
|
|
songDescription: customMode ? undefined : songDescription,
|
|
prompt: lyrics,
|
|
lyrics,
|
|
style,
|
|
title: bulkCount > 1 ? `${title} (${i + 1})` : title,
|
|
instrumental,
|
|
vocalLanguage,
|
|
bpm,
|
|
keyScale,
|
|
timeSignature,
|
|
duration,
|
|
inferenceSteps,
|
|
guidanceScale,
|
|
batchSize,
|
|
randomSeed: randomSeed || i > 0, // Force random for subsequent bulk jobs
|
|
seed: jobSeed,
|
|
thinking,
|
|
audioFormat,
|
|
inferMethod,
|
|
shift,
|
|
lmTemperature,
|
|
lmCfgScale,
|
|
lmTopK,
|
|
lmTopP,
|
|
lmNegativePrompt,
|
|
referenceAudioUrl: referenceAudioUrl.trim() || undefined,
|
|
sourceAudioUrl: sourceAudioUrl.trim() || undefined,
|
|
referenceAudioTitle: referenceAudioTitle.trim() || undefined,
|
|
sourceAudioTitle: sourceAudioTitle.trim() || undefined,
|
|
audioCodes: audioCodes.trim() || undefined,
|
|
repaintingStart,
|
|
repaintingEnd,
|
|
instruction,
|
|
audioCoverStrength,
|
|
taskType,
|
|
useAdg,
|
|
cfgIntervalStart,
|
|
cfgIntervalEnd,
|
|
customTimesteps: customTimesteps.trim() || undefined,
|
|
useCotMetas,
|
|
useCotCaption,
|
|
useCotLanguage,
|
|
autogen,
|
|
constrainedDecodingDebug,
|
|
allowLmBatch,
|
|
getScores,
|
|
getLrc,
|
|
scoreScale,
|
|
lmBatchChunkSize,
|
|
trackName: trackName.trim() || undefined,
|
|
completeTrackClasses: (() => {
|
|
const parsed = completeTrackClasses
|
|
.split(',')
|
|
.map((item) => item.trim())
|
|
.filter(Boolean);
|
|
return parsed.length ? parsed : undefined;
|
|
})(),
|
|
isFormatCaption,
|
|
});
|
|
}
|
|
|
|
// Reset bulk count after generation
|
|
if (bulkCount > 1) {
|
|
setBulkCount(1);
|
|
}
|
|
};
|
|
|
|
return (
|
|
<div
|
|
className="relative flex flex-col h-full bg-zinc-50 dark:bg-suno-panel w-full overflow-y-auto custom-scrollbar transition-colors duration-300"
|
|
onDrop={handleWorkspaceDrop}
|
|
onDragOver={handleWorkspaceDragOver}
|
|
>
|
|
{isDraggingFile && (
|
|
<div className="absolute inset-0 z-[90] pointer-events-none">
|
|
<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="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">
|
|
<Upload size={22} />
|
|
</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">
|
|
Uploading as {audioTab === 'reference' ? 'Reference' : 'Cover'}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
)}
|
|
<div className="p-4 pt-14 md:pt-4 space-y-5">
|
|
<input
|
|
ref={referenceInputRef}
|
|
type="file"
|
|
accept="audio/*"
|
|
onChange={(e) => handleFileSelect(e, 'reference')}
|
|
className="hidden"
|
|
/>
|
|
<input
|
|
ref={sourceInputRef}
|
|
type="file"
|
|
accept="audio/*"
|
|
onChange={(e) => handleFileSelect(e, 'source')}
|
|
className="hidden"
|
|
/>
|
|
<audio
|
|
ref={referenceAudioRef}
|
|
src={referenceAudioUrl || undefined}
|
|
onPlay={() => setReferencePlaying(true)}
|
|
onPause={() => setReferencePlaying(false)}
|
|
onEnded={() => setReferencePlaying(false)}
|
|
onTimeUpdate={(e) => setReferenceTime(e.currentTarget.currentTime)}
|
|
onLoadedMetadata={(e) => setReferenceDuration(e.currentTarget.duration || 0)}
|
|
/>
|
|
<audio
|
|
ref={sourceAudioRef}
|
|
src={sourceAudioUrl || undefined}
|
|
onPlay={() => setSourcePlaying(true)}
|
|
onPause={() => setSourcePlaying(false)}
|
|
onEnded={() => setSourcePlaying(false)}
|
|
onTimeUpdate={(e) => setSourceTime(e.currentTarget.currentTime)}
|
|
onLoadedMetadata={(e) => setSourceDuration(e.currentTarget.duration || 0)}
|
|
/>
|
|
|
|
{/* Header - Mode Toggle */}
|
|
<div className="flex items-center justify-between">
|
|
<div className="flex items-center gap-2">
|
|
<div className="w-2 h-2 rounded-full bg-green-500 animate-pulse"></div>
|
|
<span className="text-xs font-medium text-zinc-500 dark:text-zinc-400">ACE-Step v1.5</span>
|
|
</div>
|
|
|
|
<div className="flex items-center bg-zinc-200 dark:bg-black/40 rounded-lg p-1 border border-zinc-300 dark:border-white/5">
|
|
<button
|
|
onClick={() => setCustomMode(false)}
|
|
className={`px-3 py-1.5 rounded-md text-xs font-semibold transition-all ${!customMode ? 'bg-white dark:bg-zinc-800 text-black dark:text-white shadow-sm' : 'text-zinc-500 hover:text-zinc-900 dark:hover:text-zinc-300'}`}
|
|
>
|
|
Simple
|
|
</button>
|
|
<button
|
|
onClick={() => setCustomMode(true)}
|
|
className={`px-3 py-1.5 rounded-md text-xs font-semibold transition-all ${customMode ? 'bg-white dark:bg-zinc-800 text-black dark:text-white shadow-sm' : 'text-zinc-500 hover:text-zinc-900 dark:hover:text-zinc-300'}`}
|
|
>
|
|
Custom
|
|
</button>
|
|
</div>
|
|
</div>
|
|
|
|
{/* SIMPLE MODE */}
|
|
{!customMode && (
|
|
<div className="space-y-5">
|
|
{/* Song Description */}
|
|
<div className="bg-white dark:bg-suno-card rounded-xl border border-zinc-200 dark:border-white/5 overflow-hidden">
|
|
<div className="px-3 py-2.5 text-xs font-bold uppercase tracking-wide text-zinc-500 dark:text-zinc-400 border-b border-zinc-100 dark:border-white/5 bg-zinc-50 dark:bg-white/5">
|
|
Describe Your Song
|
|
</div>
|
|
<textarea
|
|
value={songDescription}
|
|
onChange={(e) => setSongDescription(e.target.value)}
|
|
placeholder="A happy pop song about summer adventures with friends..."
|
|
className="w-full h-32 bg-transparent p-3 text-sm text-zinc-900 dark:text-white placeholder-zinc-400 dark:placeholder-zinc-600 focus:outline-none resize-none"
|
|
/>
|
|
</div>
|
|
|
|
{/* Vocal Language (Simple) */}
|
|
<div className="bg-white dark:bg-suno-card rounded-xl border border-zinc-200 dark:border-white/5 overflow-hidden">
|
|
<div className="px-3 py-2.5 text-xs font-bold uppercase tracking-wide text-zinc-500 dark:text-zinc-400 border-b border-zinc-100 dark:border-white/5 bg-zinc-50 dark:bg-white/5">
|
|
Vocal Language
|
|
</div>
|
|
<select
|
|
value={vocalLanguage}
|
|
onChange={(e) => setVocalLanguage(e.target.value)}
|
|
className="w-full bg-transparent p-3 text-sm text-zinc-900 dark:text-white focus:outline-none"
|
|
>
|
|
{VOCAL_LANGUAGES.map(lang => (
|
|
<option key={lang.value} value={lang.value}>{lang.label}</option>
|
|
))}
|
|
</select>
|
|
</div>
|
|
|
|
{/* Quick Settings (Simple Mode) */}
|
|
<div className="bg-white dark:bg-suno-card rounded-xl border border-zinc-200 dark:border-white/5 p-4 space-y-4">
|
|
<h3 className="text-xs font-bold text-zinc-500 dark:text-zinc-400 uppercase tracking-wide flex items-center gap-2">
|
|
<Sliders size={14} />
|
|
Quick Settings
|
|
</h3>
|
|
|
|
{/* Duration */}
|
|
<div className="space-y-2">
|
|
<div className="flex items-center justify-between">
|
|
<label className="text-xs font-medium text-zinc-600 dark:text-zinc-400">Duration</label>
|
|
<span className="text-xs font-mono text-zinc-900 dark:text-white bg-zinc-100 dark:bg-black/20 px-2 py-0.5 rounded">
|
|
{duration === -1 ? 'Auto' : `${duration}s`}
|
|
</span>
|
|
</div>
|
|
<input
|
|
type="range"
|
|
min="-1"
|
|
max={activeMaxDuration}
|
|
step="5"
|
|
value={duration}
|
|
onChange={(e) => setDuration(Number(e.target.value))}
|
|
className="w-full h-2 bg-zinc-200 dark:bg-zinc-700 rounded-lg appearance-none cursor-pointer accent-pink-500"
|
|
/>
|
|
</div>
|
|
|
|
{/* BPM */}
|
|
<div className="space-y-2">
|
|
<div className="flex items-center justify-between">
|
|
<label className="text-xs font-medium text-zinc-600 dark:text-zinc-400">BPM</label>
|
|
<span className="text-xs font-mono text-zinc-900 dark:text-white bg-zinc-100 dark:bg-black/20 px-2 py-0.5 rounded">
|
|
{bpm === 0 ? 'Auto' : bpm}
|
|
</span>
|
|
</div>
|
|
<input
|
|
type="range"
|
|
min="0"
|
|
max="300"
|
|
step="5"
|
|
value={bpm}
|
|
onChange={(e) => setBpm(Number(e.target.value))}
|
|
className="w-full h-2 bg-zinc-200 dark:bg-zinc-700 rounded-lg appearance-none cursor-pointer accent-pink-500"
|
|
/>
|
|
</div>
|
|
|
|
{/* Key & Time Signature */}
|
|
<div className="grid grid-cols-2 gap-3">
|
|
<div className="space-y-1.5">
|
|
<label className="text-xs font-medium text-zinc-600 dark:text-zinc-400">Key</label>
|
|
<select
|
|
value={keyScale}
|
|
onChange={(e) => setKeyScale(e.target.value)}
|
|
className="w-full bg-zinc-50 dark:bg-black/20 border border-zinc-200 dark:border-white/10 rounded-lg px-2 py-1.5 text-xs text-zinc-900 dark:text-white focus:outline-none"
|
|
>
|
|
<option value="">Auto</option>
|
|
{KEY_SIGNATURES.filter(k => k).map(key => (
|
|
<option key={key} value={key}>{key}</option>
|
|
))}
|
|
</select>
|
|
</div>
|
|
<div className="space-y-1.5">
|
|
<label className="text-xs font-medium text-zinc-600 dark:text-zinc-400">Time</label>
|
|
<select
|
|
value={timeSignature}
|
|
onChange={(e) => setTimeSignature(e.target.value)}
|
|
className="w-full bg-zinc-50 dark:bg-black/20 border border-zinc-200 dark:border-white/10 rounded-lg px-2 py-1.5 text-xs text-zinc-900 dark:text-white focus:outline-none"
|
|
>
|
|
<option value="">Auto</option>
|
|
{TIME_SIGNATURES.filter(t => t).map(time => (
|
|
<option key={time} value={time}>{time}</option>
|
|
))}
|
|
</select>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Variations */}
|
|
<div className="space-y-2">
|
|
<div className="flex items-center justify-between">
|
|
<label className="text-xs font-medium text-zinc-600 dark:text-zinc-400">Variations</label>
|
|
<span className="text-xs font-mono text-zinc-900 dark:text-white bg-zinc-100 dark:bg-black/20 px-2 py-0.5 rounded">{batchSize}</span>
|
|
</div>
|
|
<input
|
|
type="range"
|
|
min="1"
|
|
max="4"
|
|
step="1"
|
|
value={batchSize}
|
|
onChange={(e) => setBatchSize(Number(e.target.value))}
|
|
className="w-full h-2 bg-zinc-200 dark:bg-zinc-700 rounded-lg appearance-none cursor-pointer accent-pink-500"
|
|
/>
|
|
<p className="text-[10px] text-zinc-500">Number of song variations to generate</p>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{/* CUSTOM MODE */}
|
|
{customMode && (
|
|
<div className="space-y-5">
|
|
{/* Audio Section */}
|
|
<div
|
|
onDrop={(e) => handleDrop(e, audioTab)}
|
|
onDragOver={handleDragOver}
|
|
className="bg-white dark:bg-[#1a1a1f] rounded-xl border border-zinc-200 dark:border-white/5 overflow-hidden"
|
|
>
|
|
{/* Header with Audio label and tabs */}
|
|
<div className="px-3 py-2.5 border-b border-zinc-100 dark:border-white/5 bg-zinc-50 dark:bg-white/[0.02]">
|
|
<div className="flex items-center justify-between">
|
|
<span className="text-xs font-bold text-zinc-500 dark:text-zinc-400 uppercase tracking-wide">Audio</span>
|
|
<div className="flex items-center gap-1 bg-zinc-200/50 dark:bg-black/30 rounded-lg p-0.5">
|
|
<button
|
|
type="button"
|
|
onClick={() => setAudioTab('reference')}
|
|
className={`px-2.5 py-1 rounded-md text-[11px] font-medium transition-all ${
|
|
audioTab === 'reference'
|
|
? 'bg-white dark:bg-zinc-700 text-zinc-900 dark:text-white shadow-sm'
|
|
: 'text-zinc-500 dark:text-zinc-400 hover:text-zinc-700 dark:hover:text-zinc-200'
|
|
}`}
|
|
>
|
|
Reference
|
|
</button>
|
|
<button
|
|
type="button"
|
|
onClick={() => setAudioTab('source')}
|
|
className={`px-2.5 py-1 rounded-md text-[11px] font-medium transition-all ${
|
|
audioTab === 'source'
|
|
? 'bg-white dark:bg-zinc-700 text-zinc-900 dark:text-white shadow-sm'
|
|
: 'text-zinc-500 dark:text-zinc-400 hover:text-zinc-700 dark:hover:text-zinc-200'
|
|
}`}
|
|
>
|
|
Cover
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Audio Content */}
|
|
<div className="p-3 space-y-2">
|
|
{/* Reference Audio Player */}
|
|
{audioTab === 'reference' && referenceAudioUrl && (
|
|
<div className="flex items-center gap-3 p-2 rounded-lg bg-zinc-50 dark:bg-white/[0.03] border border-zinc-100 dark:border-white/5">
|
|
<button
|
|
type="button"
|
|
onClick={() => toggleAudio('reference')}
|
|
className="relative flex-shrink-0 w-10 h-10 rounded-full bg-gradient-to-br from-pink-500 to-purple-600 text-white flex items-center justify-center shadow-lg shadow-pink-500/20 hover:scale-105 transition-transform"
|
|
>
|
|
{referencePlaying ? (
|
|
<svg className="w-4 h-4" fill="currentColor" viewBox="0 0 24 24"><path d="M6 4h4v16H6V4zm8 0h4v16h-4V4z"/></svg>
|
|
) : (
|
|
<svg className="w-4 h-4 ml-0.5" fill="currentColor" viewBox="0 0 24 24"><path d="M8 5v14l11-7z"/></svg>
|
|
)}
|
|
<span className="absolute -bottom-1 -right-1 text-[8px] font-bold bg-zinc-900 text-white px-1 py-0.5 rounded">
|
|
{formatTime(referenceDuration)}
|
|
</span>
|
|
</button>
|
|
<div className="flex-1 min-w-0">
|
|
<div className="text-xs font-medium text-zinc-800 dark:text-zinc-200 truncate mb-1.5">
|
|
{referenceAudioTitle || getAudioLabel(referenceAudioUrl)}
|
|
</div>
|
|
<div className="flex items-center gap-2">
|
|
<span className="text-[10px] text-zinc-400 tabular-nums">{formatTime(referenceTime)}</span>
|
|
<div
|
|
className="flex-1 h-1.5 rounded-full bg-zinc-200 dark:bg-white/10 cursor-pointer group/seek"
|
|
onClick={(e) => {
|
|
if (referenceAudioRef.current && referenceDuration > 0) {
|
|
const rect = e.currentTarget.getBoundingClientRect();
|
|
const percent = (e.clientX - rect.left) / rect.width;
|
|
referenceAudioRef.current.currentTime = percent * referenceDuration;
|
|
}
|
|
}}
|
|
>
|
|
<div
|
|
className="h-full bg-gradient-to-r from-pink-500 to-purple-500 rounded-full transition-all relative"
|
|
style={{ width: referenceDuration ? `${Math.min(100, (referenceTime / referenceDuration) * 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">{formatTime(referenceDuration)}</span>
|
|
</div>
|
|
</div>
|
|
<button
|
|
type="button"
|
|
onClick={() => { setReferenceAudioUrl(''); setReferenceAudioTitle(''); setReferencePlaying(false); setReferenceTime(0); setReferenceDuration(0); }}
|
|
className="p-1.5 rounded-full hover:bg-zinc-200 dark:hover:bg-white/10 text-zinc-400 hover:text-zinc-600 dark:hover:text-white transition-colors"
|
|
>
|
|
<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="M6 18L18 6M6 6l12 12"/></svg>
|
|
</button>
|
|
</div>
|
|
)}
|
|
|
|
{/* Source/Cover Audio Player */}
|
|
{audioTab === 'source' && sourceAudioUrl && (
|
|
<div className="flex items-center gap-3 p-2 rounded-lg bg-zinc-50 dark:bg-white/[0.03] border border-zinc-100 dark:border-white/5">
|
|
<button
|
|
type="button"
|
|
onClick={() => toggleAudio('source')}
|
|
className="relative flex-shrink-0 w-10 h-10 rounded-full bg-gradient-to-br from-emerald-500 to-teal-600 text-white flex items-center justify-center shadow-lg shadow-emerald-500/20 hover:scale-105 transition-transform"
|
|
>
|
|
{sourcePlaying ? (
|
|
<svg className="w-4 h-4" fill="currentColor" viewBox="0 0 24 24"><path d="M6 4h4v16H6V4zm8 0h4v16h-4V4z"/></svg>
|
|
) : (
|
|
<svg className="w-4 h-4 ml-0.5" fill="currentColor" viewBox="0 0 24 24"><path d="M8 5v14l11-7z"/></svg>
|
|
)}
|
|
<span className="absolute -bottom-1 -right-1 text-[8px] font-bold bg-zinc-900 text-white px-1 py-0.5 rounded">
|
|
{formatTime(sourceDuration)}
|
|
</span>
|
|
</button>
|
|
<div className="flex-1 min-w-0">
|
|
<div className="text-xs font-medium text-zinc-800 dark:text-zinc-200 truncate mb-1.5">
|
|
{sourceAudioTitle || getAudioLabel(sourceAudioUrl)}
|
|
</div>
|
|
<div className="flex items-center gap-2">
|
|
<span className="text-[10px] text-zinc-400 tabular-nums">{formatTime(sourceTime)}</span>
|
|
<div
|
|
className="flex-1 h-1.5 rounded-full bg-zinc-200 dark:bg-white/10 cursor-pointer group/seek"
|
|
onClick={(e) => {
|
|
if (sourceAudioRef.current && sourceDuration > 0) {
|
|
const rect = e.currentTarget.getBoundingClientRect();
|
|
const percent = (e.clientX - rect.left) / rect.width;
|
|
sourceAudioRef.current.currentTime = percent * sourceDuration;
|
|
}
|
|
}}
|
|
>
|
|
<div
|
|
className="h-full bg-gradient-to-r from-emerald-500 to-teal-500 rounded-full transition-all relative"
|
|
style={{ width: sourceDuration ? `${Math.min(100, (sourceTime / sourceDuration) * 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">{formatTime(sourceDuration)}</span>
|
|
</div>
|
|
</div>
|
|
<button
|
|
type="button"
|
|
onClick={() => { setSourceAudioUrl(''); setSourceAudioTitle(''); setSourcePlaying(false); setSourceTime(0); setSourceDuration(0); }}
|
|
className="p-1.5 rounded-full hover:bg-zinc-200 dark:hover:bg-white/10 text-zinc-400 hover:text-zinc-600 dark:hover:text-white transition-colors"
|
|
>
|
|
<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="M6 18L18 6M6 6l12 12"/></svg>
|
|
</button>
|
|
</div>
|
|
)}
|
|
|
|
{/* Action buttons */}
|
|
<div className="flex gap-2">
|
|
<button
|
|
type="button"
|
|
onClick={() => openAudioModal(audioTab)}
|
|
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">
|
|
<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>
|
|
{audioTab === 'reference' ? 'From library' : 'From library'}
|
|
</button>
|
|
<button
|
|
type="button"
|
|
onClick={() => {
|
|
if (audioTab === 'reference') {
|
|
referenceInputRef.current?.click();
|
|
} else {
|
|
sourceInputRef.current?.click();
|
|
}
|
|
}}
|
|
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">
|
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-8l-4-4m0 0L8 8m4-4v12"/>
|
|
</svg>
|
|
Upload
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Lyrics Input */}
|
|
<div
|
|
ref={lyricsRef}
|
|
className="bg-white dark:bg-suno-card rounded-xl border border-zinc-200 dark:border-white/5 overflow-hidden transition-colors group focus-within:border-zinc-400 dark:focus-within:border-white/20 relative flex flex-col"
|
|
style={{ height: 'auto' }}
|
|
>
|
|
<div className="flex items-center justify-between px-3 py-2.5 bg-zinc-50 dark:bg-white/5 border-b border-zinc-100 dark:border-white/5 flex-shrink-0">
|
|
<div>
|
|
<span className="text-xs font-bold text-zinc-500 dark:text-zinc-400 uppercase tracking-wide">Lyrics</span>
|
|
<p className="text-[11px] text-zinc-400 dark:text-zinc-500 mt-0.5">Leave empty for instrumental or toggle below</p>
|
|
</div>
|
|
<div className="flex items-center gap-2">
|
|
<button
|
|
onClick={() => setInstrumental(!instrumental)}
|
|
className={`px-2.5 py-1 rounded-full text-[10px] font-semibold border transition-colors ${
|
|
instrumental
|
|
? 'bg-pink-600 text-white border-pink-500'
|
|
: 'bg-white dark:bg-suno-card border-zinc-200 dark:border-white/10 text-zinc-600 dark:text-zinc-200 hover:bg-zinc-100 dark:hover:bg-white/10'
|
|
}`}
|
|
>
|
|
{instrumental ? 'Instrumental' : 'Vocal'}
|
|
</button>
|
|
<button
|
|
className={`p-1.5 hover:bg-zinc-200 dark:hover:bg-white/10 rounded transition-colors ${isFormattingLyrics ? 'text-pink-500' : 'text-zinc-500 hover:text-black dark:hover:text-white'}`}
|
|
title="AI Format - Enhance style & auto-fill parameters"
|
|
onClick={() => handleFormat('lyrics')}
|
|
disabled={isFormattingLyrics || !style.trim()}
|
|
>
|
|
{isFormattingLyrics ? <Loader2 size={14} className="animate-spin" /> : <Sparkles size={14} />}
|
|
</button>
|
|
<button
|
|
className="p-1.5 hover:bg-zinc-200 dark:hover:bg-white/10 rounded text-zinc-500 hover:text-black dark:hover:text-white transition-colors"
|
|
onClick={() => setLyrics('')}
|
|
>
|
|
<Trash2 size={14} />
|
|
</button>
|
|
</div>
|
|
</div>
|
|
<textarea
|
|
disabled={instrumental}
|
|
value={lyrics}
|
|
onChange={(e) => setLyrics(e.target.value)}
|
|
placeholder={instrumental ? "Instrumental mode - no lyrics needed" : "[Verse]\nYour lyrics here...\n\n[Chorus]\nThe catchy part..."}
|
|
className={`w-full bg-transparent p-3 text-sm text-zinc-900 dark:text-white placeholder-zinc-400 dark:placeholder-zinc-600 focus:outline-none resize-none font-mono leading-relaxed ${instrumental ? 'opacity-30 cursor-not-allowed' : ''}`}
|
|
style={{ height: `${lyricsHeight}px` }}
|
|
/>
|
|
{/* Resize Handle */}
|
|
<div
|
|
onMouseDown={startResizing}
|
|
className="h-3 w-full cursor-ns-resize flex items-center justify-center hover:bg-zinc-100 dark:hover:bg-white/5 transition-colors absolute bottom-0 left-0 z-10"
|
|
>
|
|
<div className="w-8 h-1 rounded-full bg-zinc-300 dark:bg-zinc-700"></div>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Style Input */}
|
|
<div className="bg-white dark:bg-suno-card rounded-xl border border-zinc-200 dark:border-white/5 overflow-hidden transition-colors group focus-within:border-zinc-400 dark:focus-within:border-white/20">
|
|
<div className="flex items-center justify-between px-3 py-2.5 bg-zinc-50 dark:bg-white/5 border-b border-zinc-100 dark:border-white/5">
|
|
<div>
|
|
<span className="text-xs font-bold text-zinc-500 dark:text-zinc-400 uppercase tracking-wide">Style of Music</span>
|
|
<p className="text-[11px] text-zinc-400 dark:text-zinc-500 mt-0.5">Genre, mood, instruments, vibe</p>
|
|
</div>
|
|
<button
|
|
className={`p-1.5 hover:bg-zinc-200 dark:hover:bg-white/10 rounded transition-colors ${isFormattingStyle ? 'text-pink-500' : 'text-zinc-500 hover:text-black dark:hover:text-white'}`}
|
|
title="AI Format - Enhance style & auto-fill parameters"
|
|
onClick={() => handleFormat('style')}
|
|
disabled={isFormattingStyle || !style.trim()}
|
|
>
|
|
{isFormattingStyle ? <Loader2 size={14} className="animate-spin" /> : <Sparkles size={14} />}
|
|
</button>
|
|
</div>
|
|
<textarea
|
|
value={style}
|
|
onChange={(e) => setStyle(e.target.value)}
|
|
placeholder="e.g. upbeat pop rock, emotional ballad, 90s hip hop"
|
|
className="w-full h-20 bg-transparent p-3 text-sm text-zinc-900 dark:text-white placeholder-zinc-400 dark:placeholder-zinc-600 focus:outline-none resize-none"
|
|
/>
|
|
<div className="px-3 pb-3 flex flex-wrap gap-2">
|
|
{['Pop', 'Rock', 'Electronic', 'Hip Hop', 'Jazz', 'Classical'].map(tag => (
|
|
<button
|
|
key={tag}
|
|
onClick={() => setStyle(prev => prev ? `${prev}, ${tag}` : tag)}
|
|
className="text-[10px] font-medium bg-zinc-100 dark:bg-white/5 hover:bg-zinc-200 dark:hover:bg-white/10 text-zinc-600 dark:text-zinc-400 hover:text-black dark:hover:text-white px-2.5 py-1 rounded-full transition-colors border border-zinc-200 dark:border-white/5"
|
|
>
|
|
{tag}
|
|
</button>
|
|
))}
|
|
</div>
|
|
</div>
|
|
|
|
{/* Title Input */}
|
|
<div className="bg-white dark:bg-suno-card rounded-xl border border-zinc-200 dark:border-white/5 overflow-hidden">
|
|
<div className="px-3 py-2.5 text-xs font-bold uppercase tracking-wide text-zinc-500 dark:text-zinc-400 border-b border-zinc-100 dark:border-white/5 bg-zinc-50 dark:bg-white/5">
|
|
Title
|
|
</div>
|
|
<input
|
|
type="text"
|
|
value={title}
|
|
onChange={(e) => setTitle(e.target.value)}
|
|
placeholder="Name your song"
|
|
className="w-full bg-transparent p-3 text-sm text-zinc-900 dark:text-white placeholder-zinc-400 dark:placeholder-zinc-600 focus:outline-none"
|
|
/>
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{/* COMMON SETTINGS */}
|
|
<div className="space-y-4">
|
|
{/* Instrumental Toggle (Simple Mode) */}
|
|
{!customMode && (
|
|
<div className="flex items-center justify-between px-1 py-2">
|
|
<div className="flex items-center gap-2">
|
|
<Music2 size={14} className="text-zinc-500" />
|
|
<span className="text-sm font-medium text-zinc-700 dark:text-zinc-300">Instrumental</span>
|
|
</div>
|
|
<button
|
|
onClick={() => setInstrumental(!instrumental)}
|
|
className={`w-11 h-6 rounded-full flex items-center transition-colors duration-200 px-1 border border-zinc-200 dark:border-white/5 ${instrumental ? 'bg-pink-600' : 'bg-zinc-300 dark:bg-black/40'}`}
|
|
>
|
|
<div className={`w-4 h-4 rounded-full bg-white transform transition-transform duration-200 shadow-sm ${instrumental ? 'translate-x-5' : 'translate-x-0'}`} />
|
|
</button>
|
|
</div>
|
|
)}
|
|
|
|
{/* Vocal Language (Custom mode) */}
|
|
{customMode && !instrumental && (
|
|
<div className="grid grid-cols-2 gap-3">
|
|
<div className="space-y-1.5">
|
|
<label className="text-xs font-bold text-zinc-500 dark:text-zinc-400 uppercase tracking-wide px-1">
|
|
Vocal Language
|
|
</label>
|
|
<select
|
|
value={vocalLanguage}
|
|
onChange={(e) => setVocalLanguage(e.target.value)}
|
|
className="w-full bg-white dark:bg-suno-card border border-zinc-200 dark:border-white/5 rounded-lg px-3 py-2 text-sm text-zinc-900 dark:text-white focus:outline-none"
|
|
>
|
|
{VOCAL_LANGUAGES.map(lang => (
|
|
<option key={lang.value} value={lang.value}>{lang.label}</option>
|
|
))}
|
|
</select>
|
|
</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
{/* MUSIC PARAMETERS */}
|
|
<div className="bg-white dark:bg-suno-card rounded-xl border border-zinc-200 dark:border-white/5 p-4 space-y-4">
|
|
<h3 className="text-xs font-bold text-zinc-500 dark:text-zinc-400 uppercase tracking-wide flex items-center gap-2">
|
|
<Sliders size={14} />
|
|
Music Parameters
|
|
</h3>
|
|
|
|
{/* BPM */}
|
|
<div className="space-y-2">
|
|
<div className="flex items-center justify-between">
|
|
<label className="text-xs font-medium text-zinc-600 dark:text-zinc-400">BPM</label>
|
|
<span className="text-xs font-mono text-zinc-900 dark:text-white bg-zinc-100 dark:bg-black/20 px-2 py-0.5 rounded">
|
|
{bpm === 0 ? 'Auto' : bpm}
|
|
</span>
|
|
</div>
|
|
<input
|
|
type="range"
|
|
min="0"
|
|
max="300"
|
|
step="5"
|
|
value={bpm}
|
|
onChange={(e) => setBpm(Number(e.target.value))}
|
|
className="w-full h-2 bg-zinc-200 dark:bg-zinc-700 rounded-lg appearance-none cursor-pointer accent-pink-500"
|
|
/>
|
|
<div className="flex justify-between text-[10px] text-zinc-500">
|
|
<span>Auto</span>
|
|
<span>300</span>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Key & Time Signature */}
|
|
<div className="grid grid-cols-2 gap-3">
|
|
<div className="space-y-1.5">
|
|
<label className="text-xs font-medium text-zinc-600 dark:text-zinc-400">Key</label>
|
|
<select
|
|
value={keyScale}
|
|
onChange={(e) => setKeyScale(e.target.value)}
|
|
className="w-full bg-zinc-50 dark:bg-black/20 border border-zinc-200 dark:border-white/10 rounded-lg px-2 py-1.5 text-xs text-zinc-900 dark:text-white focus:outline-none"
|
|
>
|
|
<option value="">Auto</option>
|
|
{KEY_SIGNATURES.filter(k => k).map(key => (
|
|
<option key={key} value={key}>{key}</option>
|
|
))}
|
|
</select>
|
|
</div>
|
|
<div className="space-y-1.5">
|
|
<label className="text-xs font-medium text-zinc-600 dark:text-zinc-400">Time</label>
|
|
<select
|
|
value={timeSignature}
|
|
onChange={(e) => setTimeSignature(e.target.value)}
|
|
className="w-full bg-zinc-50 dark:bg-black/20 border border-zinc-200 dark:border-white/10 rounded-lg px-2 py-1.5 text-xs text-zinc-900 dark:text-white focus:outline-none"
|
|
>
|
|
<option value="">Auto</option>
|
|
{TIME_SIGNATURES.filter(t => t).map(time => (
|
|
<option key={time} value={time}>{time}</option>
|
|
))}
|
|
</select>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
{/* ADVANCED SETTINGS */}
|
|
<button
|
|
onClick={() => setShowAdvanced(!showAdvanced)}
|
|
className="w-full flex items-center justify-between px-4 py-3 bg-white dark:bg-suno-card rounded-xl border border-zinc-200 dark:border-white/5 text-sm font-medium text-zinc-700 dark:text-zinc-300 hover:bg-zinc-50 dark:hover:bg-white/5 transition-colors"
|
|
>
|
|
<div className="flex items-center gap-2">
|
|
<Settings2 size={16} className="text-zinc-500" />
|
|
<span>Advanced Settings</span>
|
|
</div>
|
|
<ChevronDown size={16} className={`text-zinc-500 transition-transform ${showAdvanced ? 'rotate-180' : ''}`} />
|
|
</button>
|
|
|
|
{showAdvanced && (
|
|
<div className="bg-white dark:bg-suno-card rounded-xl border border-zinc-200 dark:border-white/5 p-4 space-y-4">
|
|
|
|
{/* Duration */}
|
|
<div className="space-y-2">
|
|
<div className="flex items-center justify-between">
|
|
<label className="text-xs font-medium text-zinc-600 dark:text-zinc-400">Duration</label>
|
|
<span className="text-xs font-mono text-zinc-900 dark:text-white bg-zinc-100 dark:bg-black/20 px-2 py-0.5 rounded">
|
|
{duration === -1 ? 'Auto' : `${duration}s`}
|
|
</span>
|
|
</div>
|
|
<input
|
|
type="range"
|
|
min="-1"
|
|
max={activeMaxDuration}
|
|
step="5"
|
|
value={duration}
|
|
onChange={(e) => setDuration(Number(e.target.value))}
|
|
className="w-full h-2 bg-zinc-200 dark:bg-zinc-700 rounded-lg appearance-none cursor-pointer accent-pink-500"
|
|
/>
|
|
<div className="flex justify-between text-[10px] text-zinc-500">
|
|
<span>Auto</span>
|
|
<span>{Math.round(activeMaxDuration / 60)} min</span>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Batch Size */}
|
|
<div className="space-y-2">
|
|
<div className="flex items-center justify-between">
|
|
<label className="text-xs font-medium text-zinc-600 dark:text-zinc-400">Batch Size (Variations)</label>
|
|
<span className="text-xs font-mono text-zinc-900 dark:text-white bg-zinc-100 dark:bg-black/20 px-2 py-0.5 rounded">{batchSize}</span>
|
|
</div>
|
|
<input
|
|
type="range"
|
|
min="1"
|
|
max="4"
|
|
step="1"
|
|
value={batchSize}
|
|
onChange={(e) => setBatchSize(Number(e.target.value))}
|
|
className="w-full h-2 bg-zinc-200 dark:bg-zinc-700 rounded-lg appearance-none cursor-pointer accent-pink-500"
|
|
/>
|
|
<p className="text-[10px] text-zinc-500">Number of song variations to generate</p>
|
|
</div>
|
|
|
|
{/* Bulk Generate */}
|
|
<div className="space-y-2">
|
|
<div className="flex items-center justify-between">
|
|
<label className="text-xs font-medium text-zinc-600 dark:text-zinc-400">Bulk Generate</label>
|
|
<span className="text-xs font-mono text-zinc-900 dark:text-white bg-zinc-100 dark:bg-black/20 px-2 py-0.5 rounded">
|
|
{bulkCount} {bulkCount === 1 ? 'job' : 'jobs'}
|
|
</span>
|
|
</div>
|
|
<div className="flex items-center gap-1">
|
|
{[1, 2, 3, 5, 10].map((count) => (
|
|
<button
|
|
key={count}
|
|
onClick={() => setBulkCount(count)}
|
|
className={`flex-1 py-2 rounded-lg text-xs font-bold transition-all ${
|
|
bulkCount === count
|
|
? 'bg-gradient-to-r from-orange-500 to-pink-600 text-white shadow-md'
|
|
: 'bg-zinc-100 dark:bg-zinc-800 text-zinc-600 dark:text-zinc-400 hover:bg-zinc-200 dark:hover:bg-zinc-700'
|
|
}`}
|
|
>
|
|
{count}
|
|
</button>
|
|
))}
|
|
</div>
|
|
<p className="text-[10px] text-zinc-500">Queue multiple independent generation jobs with same settings</p>
|
|
</div>
|
|
|
|
{/* Inference Steps */}
|
|
<div className="space-y-2">
|
|
<div className="flex items-center justify-between">
|
|
<label className="text-xs font-medium text-zinc-600 dark:text-zinc-400">Inference Steps</label>
|
|
<span className="text-xs font-mono text-zinc-900 dark:text-white bg-zinc-100 dark:bg-black/20 px-2 py-0.5 rounded">{inferenceSteps}</span>
|
|
</div>
|
|
<input
|
|
type="range"
|
|
min="4"
|
|
max="32"
|
|
step="1"
|
|
value={inferenceSteps}
|
|
onChange={(e) => setInferenceSteps(Number(e.target.value))}
|
|
className="w-full h-2 bg-zinc-200 dark:bg-zinc-700 rounded-lg appearance-none cursor-pointer accent-pink-500"
|
|
/>
|
|
<p className="text-[10px] text-zinc-500">More steps = better quality, slower (8 recommended for turbo)</p>
|
|
</div>
|
|
|
|
{/* Guidance Scale */}
|
|
<div className="space-y-2">
|
|
<div className="flex items-center justify-between">
|
|
<label className="text-xs font-medium text-zinc-600 dark:text-zinc-400">Guidance Scale</label>
|
|
<span className="text-xs font-mono text-zinc-900 dark:text-white bg-zinc-100 dark:bg-black/20 px-2 py-0.5 rounded">{guidanceScale.toFixed(1)}</span>
|
|
</div>
|
|
<input
|
|
type="range"
|
|
min="1"
|
|
max="15"
|
|
step="0.5"
|
|
value={guidanceScale}
|
|
onChange={(e) => setGuidanceScale(Number(e.target.value))}
|
|
className="w-full h-2 bg-zinc-200 dark:bg-zinc-700 rounded-lg appearance-none cursor-pointer accent-pink-500"
|
|
/>
|
|
<p className="text-[10px] text-zinc-500">How closely to follow the prompt</p>
|
|
</div>
|
|
|
|
{/* Audio Format & Inference Method */}
|
|
<div className="grid grid-cols-2 gap-3">
|
|
<div className="space-y-1.5">
|
|
<label className="text-xs font-medium text-zinc-600 dark:text-zinc-400">Audio Format</label>
|
|
<select
|
|
value={audioFormat}
|
|
onChange={(e) => setAudioFormat(e.target.value as 'mp3' | 'flac')}
|
|
className="w-full bg-zinc-50 dark:bg-black/20 border border-zinc-200 dark:border-white/10 rounded-lg px-2 py-1.5 text-xs text-zinc-900 dark:text-white focus:outline-none"
|
|
>
|
|
<option value="mp3">MP3 (smaller)</option>
|
|
<option value="flac">FLAC (lossless)</option>
|
|
</select>
|
|
</div>
|
|
<div className="space-y-1.5">
|
|
<label className="text-xs font-medium text-zinc-600 dark:text-zinc-400">Inference Method</label>
|
|
<select
|
|
value={inferMethod}
|
|
onChange={(e) => setInferMethod(e.target.value as 'ode' | 'sde')}
|
|
className="w-full bg-zinc-50 dark:bg-black/20 border border-zinc-200 dark:border-white/10 rounded-lg px-2 py-1.5 text-xs text-zinc-900 dark:text-white focus:outline-none"
|
|
>
|
|
<option value="ode">ODE (deterministic)</option>
|
|
<option value="sde">SDE (stochastic)</option>
|
|
</select>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Seed */}
|
|
<div className="space-y-2">
|
|
<div className="flex items-center justify-between">
|
|
<div className="flex items-center gap-2">
|
|
<Dices size={14} className="text-zinc-500" />
|
|
<span className="text-xs font-medium text-zinc-600 dark:text-zinc-400">Seed</span>
|
|
</div>
|
|
<button
|
|
onClick={() => setRandomSeed(!randomSeed)}
|
|
className={`w-10 h-5 rounded-full flex items-center transition-colors duration-200 px-0.5 border border-zinc-200 dark:border-white/5 ${randomSeed ? 'bg-pink-600' : 'bg-zinc-300 dark:bg-black/40'}`}
|
|
>
|
|
<div className={`w-4 h-4 rounded-full bg-white transform transition-transform duration-200 shadow-sm ${randomSeed ? 'translate-x-5' : 'translate-x-0'}`} />
|
|
</button>
|
|
</div>
|
|
<div className="flex items-center gap-2">
|
|
<Hash size={14} className="text-zinc-500" />
|
|
<input
|
|
type="number"
|
|
value={seed}
|
|
onChange={(e) => setSeed(Number(e.target.value))}
|
|
placeholder="Enter fixed seed"
|
|
disabled={randomSeed}
|
|
className={`flex-1 bg-zinc-50 dark:bg-black/20 border border-zinc-200 dark:border-white/10 rounded-lg px-3 py-1.5 text-xs text-zinc-900 dark:text-white focus:outline-none ${randomSeed ? 'opacity-40 cursor-not-allowed' : ''}`}
|
|
/>
|
|
</div>
|
|
<p className="text-[10px] text-zinc-500">{randomSeed ? 'Randomized every run (recommended)' : 'Fixed seed for reproducible results'}</p>
|
|
</div>
|
|
|
|
{/* Thinking Toggle */}
|
|
<div className="flex items-center justify-between py-2 border-t border-zinc-100 dark:border-white/5">
|
|
<span className="text-xs font-medium text-zinc-600 dark:text-zinc-400">Thinking (CoT)</span>
|
|
<button
|
|
onClick={() => setThinking(!thinking)}
|
|
className={`w-10 h-5 rounded-full flex items-center transition-colors duration-200 px-0.5 border border-zinc-200 dark:border-white/5 ${thinking ? 'bg-pink-600' : 'bg-zinc-300 dark:bg-black/40'}`}
|
|
>
|
|
<div className={`w-4 h-4 rounded-full bg-white transform transition-transform duration-200 shadow-sm ${thinking ? 'translate-x-5' : 'translate-x-0'}`} />
|
|
</button>
|
|
</div>
|
|
|
|
{/* Shift */}
|
|
<div className="space-y-2">
|
|
<div className="flex items-center justify-between">
|
|
<label className="text-xs font-medium text-zinc-600 dark:text-zinc-400">Shift</label>
|
|
<span className="text-xs font-mono text-zinc-900 dark:text-white bg-zinc-100 dark:bg-black/20 px-2 py-0.5 rounded">{shift.toFixed(1)}</span>
|
|
</div>
|
|
<input
|
|
type="range"
|
|
min="1"
|
|
max="5"
|
|
step="0.1"
|
|
value={shift}
|
|
onChange={(e) => setShift(Number(e.target.value))}
|
|
className="w-full h-2 bg-zinc-200 dark:bg-zinc-700 rounded-lg appearance-none cursor-pointer accent-pink-500"
|
|
/>
|
|
<p className="text-[10px] text-zinc-500">Timestep shift for base models (not effective for turbo)</p>
|
|
</div>
|
|
|
|
{/* Divider */}
|
|
<div className="border-t border-zinc-200 dark:border-white/10 pt-4">
|
|
<p className="text-[10px] text-zinc-500 uppercase tracking-wide font-bold mb-3">Expert Controls</p>
|
|
</div>
|
|
|
|
{uploadError && (
|
|
<div className="text-[11px] text-rose-500">{uploadError}</div>
|
|
)}
|
|
|
|
{/* LM Parameters */}
|
|
<button
|
|
onClick={() => setShowLmParams(!showLmParams)}
|
|
className="w-full flex items-center justify-between px-4 py-3 bg-white/60 dark:bg-black/20 rounded-xl border border-zinc-200/70 dark:border-white/10 text-sm font-medium text-zinc-700 dark:text-zinc-300 hover:bg-zinc-50 dark:hover:bg-white/5 transition-colors"
|
|
>
|
|
<div className="flex items-center gap-2">
|
|
<Music2 size={16} className="text-zinc-500" />
|
|
<div className="flex flex-col items-start">
|
|
<span>LM Parameters</span>
|
|
<span className="text-[11px] text-zinc-400 dark:text-zinc-500 font-normal">Control lyric generation + creativity</span>
|
|
</div>
|
|
</div>
|
|
<ChevronDown size={16} className={`text-zinc-500 transition-transform ${showLmParams ? 'rotate-180' : ''}`} />
|
|
</button>
|
|
|
|
{showLmParams && (
|
|
<div className="bg-white dark:bg-suno-card rounded-xl border border-zinc-200 dark:border-white/5 p-4 space-y-4">
|
|
{/* LM Temperature */}
|
|
<div className="space-y-2">
|
|
<div className="flex items-center justify-between">
|
|
<label className="text-xs font-medium text-zinc-600 dark:text-zinc-400">LM Temperature</label>
|
|
<span className="text-xs font-mono text-zinc-900 dark:text-white bg-zinc-100 dark:bg-black/20 px-2 py-0.5 rounded">{lmTemperature.toFixed(2)}</span>
|
|
</div>
|
|
<input
|
|
type="range"
|
|
min="0"
|
|
max="2"
|
|
step="0.05"
|
|
value={lmTemperature}
|
|
onChange={(e) => setLmTemperature(Number(e.target.value))}
|
|
className="w-full h-2 bg-zinc-200 dark:bg-zinc-700 rounded-lg appearance-none cursor-pointer accent-pink-500"
|
|
/>
|
|
<p className="text-[10px] text-zinc-500">Higher = more random (0-2)</p>
|
|
</div>
|
|
|
|
{/* LM CFG Scale */}
|
|
<div className="space-y-2">
|
|
<div className="flex items-center justify-between">
|
|
<label className="text-xs font-medium text-zinc-600 dark:text-zinc-400">LM CFG Scale</label>
|
|
<span className="text-xs font-mono text-zinc-900 dark:text-white bg-zinc-100 dark:bg-black/20 px-2 py-0.5 rounded">{lmCfgScale.toFixed(1)}</span>
|
|
</div>
|
|
<input
|
|
type="range"
|
|
min="1"
|
|
max="3"
|
|
step="0.1"
|
|
value={lmCfgScale}
|
|
onChange={(e) => setLmCfgScale(Number(e.target.value))}
|
|
className="w-full h-2 bg-zinc-200 dark:bg-zinc-700 rounded-lg appearance-none cursor-pointer accent-pink-500"
|
|
/>
|
|
<p className="text-[10px] text-zinc-500">1.0 = no CFG (1-3)</p>
|
|
</div>
|
|
|
|
{/* LM Top-K & Top-P */}
|
|
<div className="grid grid-cols-2 gap-3">
|
|
<div className="space-y-2">
|
|
<div className="flex items-center justify-between">
|
|
<label className="text-xs font-medium text-zinc-600 dark:text-zinc-400">Top-K</label>
|
|
<span className="text-xs font-mono text-zinc-900 dark:text-white bg-zinc-100 dark:bg-black/20 px-2 py-0.5 rounded">{lmTopK}</span>
|
|
</div>
|
|
<input
|
|
type="range"
|
|
min="0"
|
|
max="100"
|
|
step="1"
|
|
value={lmTopK}
|
|
onChange={(e) => setLmTopK(Number(e.target.value))}
|
|
className="w-full h-2 bg-zinc-200 dark:bg-zinc-700 rounded-lg appearance-none cursor-pointer accent-pink-500"
|
|
/>
|
|
</div>
|
|
<div className="space-y-2">
|
|
<div className="flex items-center justify-between">
|
|
<label className="text-xs font-medium text-zinc-600 dark:text-zinc-400">Top-P</label>
|
|
<span className="text-xs font-mono text-zinc-900 dark:text-white bg-zinc-100 dark:bg-black/20 px-2 py-0.5 rounded">{lmTopP.toFixed(2)}</span>
|
|
</div>
|
|
<input
|
|
type="range"
|
|
min="0"
|
|
max="1"
|
|
step="0.01"
|
|
value={lmTopP}
|
|
onChange={(e) => setLmTopP(Number(e.target.value))}
|
|
className="w-full h-2 bg-zinc-200 dark:bg-zinc-700 rounded-lg appearance-none cursor-pointer accent-pink-500"
|
|
/>
|
|
</div>
|
|
</div>
|
|
|
|
{/* LM Negative Prompt */}
|
|
<div className="space-y-1.5">
|
|
<label className="text-xs font-medium text-zinc-600 dark:text-zinc-400">LM Negative Prompt</label>
|
|
<textarea
|
|
value={lmNegativePrompt}
|
|
onChange={(e) => setLmNegativePrompt(e.target.value)}
|
|
placeholder="Things to avoid..."
|
|
className="w-full h-16 bg-zinc-50 dark:bg-black/20 border border-zinc-200 dark:border-white/10 rounded-lg p-2 text-xs text-zinc-900 dark:text-white focus:outline-none resize-none"
|
|
/>
|
|
<p className="text-[10px] text-zinc-500">Use when LM CFG Scale {">"} 1.0</p>
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
<div className="space-y-1">
|
|
<h4 className="text-xs font-bold text-zinc-500 dark:text-zinc-400 uppercase tracking-wide">Transform</h4>
|
|
<p className="text-[11px] text-zinc-400 dark:text-zinc-500">Control how much the model follows the source audio.</p>
|
|
</div>
|
|
<div className="space-y-1.5">
|
|
<label className="text-xs font-medium text-zinc-600 dark:text-zinc-400">Audio Codes</label>
|
|
<textarea
|
|
value={audioCodes}
|
|
onChange={(e) => setAudioCodes(e.target.value)}
|
|
placeholder="Optional audio codes payload"
|
|
className="w-full h-16 bg-zinc-50 dark:bg-black/20 border border-zinc-200 dark:border-white/10 rounded-lg p-2 text-xs text-zinc-900 dark:text-white focus:outline-none resize-none"
|
|
/>
|
|
</div>
|
|
|
|
<div className="grid grid-cols-2 gap-3">
|
|
<div className="space-y-1.5">
|
|
<label className="text-xs font-medium text-zinc-600 dark:text-zinc-400">Task Type</label>
|
|
<select
|
|
value={taskType}
|
|
onChange={(e) => setTaskType(e.target.value)}
|
|
className="w-full bg-zinc-50 dark:bg-black/20 border border-zinc-200 dark:border-white/10 rounded-lg px-2 py-1.5 text-xs text-zinc-900 dark:text-white focus:outline-none"
|
|
>
|
|
<option value="text2music">Text → Music</option>
|
|
<option value="audio2audio">Audio → Audio</option>
|
|
<option value="cover">Cover</option>
|
|
<option value="repaint">Repaint</option>
|
|
</select>
|
|
</div>
|
|
<div className="space-y-1.5">
|
|
<label className="text-xs font-medium text-zinc-600 dark:text-zinc-400">Audio Cover Strength</label>
|
|
<input
|
|
type="number"
|
|
step="0.05"
|
|
min="0"
|
|
max="1"
|
|
value={audioCoverStrength}
|
|
onChange={(e) => setAudioCoverStrength(Number(e.target.value))}
|
|
className="w-full bg-zinc-50 dark:bg-black/20 border border-zinc-200 dark:border-white/10 rounded-lg px-3 py-2 text-xs text-zinc-900 dark:text-white focus:outline-none"
|
|
/>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="grid grid-cols-2 gap-3">
|
|
<div className="space-y-1.5">
|
|
<label className="text-xs font-medium text-zinc-600 dark:text-zinc-400">Repainting Start</label>
|
|
<input
|
|
type="number"
|
|
step="0.1"
|
|
value={repaintingStart}
|
|
onChange={(e) => setRepaintingStart(Number(e.target.value))}
|
|
className="w-full bg-zinc-50 dark:bg-black/20 border border-zinc-200 dark:border-white/10 rounded-lg px-3 py-2 text-xs text-zinc-900 dark:text-white focus:outline-none"
|
|
/>
|
|
</div>
|
|
<div className="space-y-1.5">
|
|
<label className="text-xs font-medium text-zinc-600 dark:text-zinc-400">Repainting End</label>
|
|
<input
|
|
type="number"
|
|
step="0.1"
|
|
value={repaintingEnd}
|
|
onChange={(e) => setRepaintingEnd(Number(e.target.value))}
|
|
className="w-full bg-zinc-50 dark:bg-black/20 border border-zinc-200 dark:border-white/10 rounded-lg px-3 py-2 text-xs text-zinc-900 dark:text-white focus:outline-none"
|
|
/>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="space-y-1.5">
|
|
<label className="text-xs font-medium text-zinc-600 dark:text-zinc-400">Instruction</label>
|
|
<textarea
|
|
value={instruction}
|
|
onChange={(e) => setInstruction(e.target.value)}
|
|
className="w-full h-16 bg-zinc-50 dark:bg-black/20 border border-zinc-200 dark:border-white/10 rounded-lg p-2 text-xs text-zinc-900 dark:text-white focus:outline-none resize-none"
|
|
/>
|
|
</div>
|
|
|
|
<div className="space-y-1">
|
|
<h4 className="text-xs font-bold text-zinc-500 dark:text-zinc-400 uppercase tracking-wide">Guidance</h4>
|
|
<p className="text-[11px] text-zinc-400 dark:text-zinc-500">Advanced CFG scheduling controls.</p>
|
|
</div>
|
|
<div className="grid grid-cols-2 gap-3">
|
|
<div className="space-y-1.5">
|
|
<label className="text-xs font-medium text-zinc-600 dark:text-zinc-400">CFG Interval Start</label>
|
|
<input
|
|
type="number"
|
|
step="0.05"
|
|
min="0"
|
|
max="1"
|
|
value={cfgIntervalStart}
|
|
onChange={(e) => setCfgIntervalStart(Number(e.target.value))}
|
|
className="w-full bg-zinc-50 dark:bg-black/20 border border-zinc-200 dark:border-white/10 rounded-lg px-3 py-2 text-xs text-zinc-900 dark:text-white focus:outline-none"
|
|
/>
|
|
</div>
|
|
<div className="space-y-1.5">
|
|
<label className="text-xs font-medium text-zinc-600 dark:text-zinc-400">CFG Interval End</label>
|
|
<input
|
|
type="number"
|
|
step="0.05"
|
|
min="0"
|
|
max="1"
|
|
value={cfgIntervalEnd}
|
|
onChange={(e) => setCfgIntervalEnd(Number(e.target.value))}
|
|
className="w-full bg-zinc-50 dark:bg-black/20 border border-zinc-200 dark:border-white/10 rounded-lg px-3 py-2 text-xs text-zinc-900 dark:text-white focus:outline-none"
|
|
/>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="space-y-1.5">
|
|
<label className="text-xs font-medium text-zinc-600 dark:text-zinc-400">Custom Timesteps</label>
|
|
<input
|
|
type="text"
|
|
value={customTimesteps}
|
|
onChange={(e) => setCustomTimesteps(e.target.value)}
|
|
placeholder="e.g. 1,3,5,7"
|
|
className="w-full bg-zinc-50 dark:bg-black/20 border border-zinc-200 dark:border-white/10 rounded-lg px-3 py-2 text-xs text-zinc-900 dark:text-white focus:outline-none"
|
|
/>
|
|
</div>
|
|
|
|
<div className="grid grid-cols-2 gap-3">
|
|
<div className="space-y-1.5">
|
|
<label className="text-xs font-medium text-zinc-600 dark:text-zinc-400">Score Scale</label>
|
|
<input
|
|
type="number"
|
|
step="0.05"
|
|
value={scoreScale}
|
|
onChange={(e) => setScoreScale(Number(e.target.value))}
|
|
className="w-full bg-zinc-50 dark:bg-black/20 border border-zinc-200 dark:border-white/10 rounded-lg px-3 py-2 text-xs text-zinc-900 dark:text-white focus:outline-none"
|
|
/>
|
|
</div>
|
|
<div className="space-y-1.5">
|
|
<label className="text-xs font-medium text-zinc-600 dark:text-zinc-400">LM Batch Chunk Size</label>
|
|
<input
|
|
type="number"
|
|
min="1"
|
|
value={lmBatchChunkSize}
|
|
onChange={(e) => setLmBatchChunkSize(Number(e.target.value))}
|
|
className="w-full bg-zinc-50 dark:bg-black/20 border border-zinc-200 dark:border-white/10 rounded-lg px-3 py-2 text-xs text-zinc-900 dark:text-white focus:outline-none"
|
|
/>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="space-y-1.5">
|
|
<label className="text-xs font-medium text-zinc-600 dark:text-zinc-400">Track Name</label>
|
|
<input
|
|
type="text"
|
|
value={trackName}
|
|
onChange={(e) => setTrackName(e.target.value)}
|
|
placeholder="Optional track name"
|
|
className="w-full bg-zinc-50 dark:bg-black/20 border border-zinc-200 dark:border-white/10 rounded-lg px-3 py-2 text-xs text-zinc-900 dark:text-white focus:outline-none"
|
|
/>
|
|
</div>
|
|
|
|
<div className="space-y-1.5">
|
|
<label className="text-xs font-medium text-zinc-600 dark:text-zinc-400">Complete Track Classes</label>
|
|
<input
|
|
type="text"
|
|
value={completeTrackClasses}
|
|
onChange={(e) => setCompleteTrackClasses(e.target.value)}
|
|
placeholder="class-a, class-b"
|
|
className="w-full bg-zinc-50 dark:bg-black/20 border border-zinc-200 dark:border-white/10 rounded-lg px-3 py-2 text-xs text-zinc-900 dark:text-white focus:outline-none"
|
|
/>
|
|
</div>
|
|
|
|
<div className="grid grid-cols-2 gap-3">
|
|
<label className="flex items-center gap-2 text-xs font-medium text-zinc-600 dark:text-zinc-400">
|
|
<input type="checkbox" checked={useAdg} onChange={() => setUseAdg(!useAdg)} />
|
|
Use ADG
|
|
</label>
|
|
<label className="flex items-center gap-2 text-xs font-medium text-zinc-600 dark:text-zinc-400">
|
|
<input type="checkbox" checked={allowLmBatch} onChange={() => setAllowLmBatch(!allowLmBatch)} />
|
|
Allow LM Batch
|
|
</label>
|
|
<label className="flex items-center gap-2 text-xs font-medium text-zinc-600 dark:text-zinc-400">
|
|
<input type="checkbox" checked={useCotMetas} onChange={() => setUseCotMetas(!useCotMetas)} />
|
|
Use CoT Metas
|
|
</label>
|
|
<label className="flex items-center gap-2 text-xs font-medium text-zinc-600 dark:text-zinc-400">
|
|
<input type="checkbox" checked={useCotCaption} onChange={() => setUseCotCaption(!useCotCaption)} />
|
|
Use CoT Caption
|
|
</label>
|
|
<label className="flex items-center gap-2 text-xs font-medium text-zinc-600 dark:text-zinc-400">
|
|
<input type="checkbox" checked={useCotLanguage} onChange={() => setUseCotLanguage(!useCotLanguage)} />
|
|
Use CoT Language
|
|
</label>
|
|
<label className="flex items-center gap-2 text-xs font-medium text-zinc-600 dark:text-zinc-400">
|
|
<input type="checkbox" checked={autogen} onChange={() => setAutogen(!autogen)} />
|
|
Autogen
|
|
</label>
|
|
<label className="flex items-center gap-2 text-xs font-medium text-zinc-600 dark:text-zinc-400">
|
|
<input type="checkbox" checked={constrainedDecodingDebug} onChange={() => setConstrainedDecodingDebug(!constrainedDecodingDebug)} />
|
|
Constrained Decoding Debug
|
|
</label>
|
|
<label className="flex items-center gap-2 text-xs font-medium text-zinc-600 dark:text-zinc-400">
|
|
<input type="checkbox" checked={isFormatCaption} onChange={() => setIsFormatCaption(!isFormatCaption)} />
|
|
Format Caption
|
|
</label>
|
|
<label className="flex items-center gap-2 text-xs font-medium text-zinc-600 dark:text-zinc-400">
|
|
<input type="checkbox" checked={getScores} onChange={() => setGetScores(!getScores)} />
|
|
Get Scores
|
|
</label>
|
|
<label className="flex items-center gap-2 text-xs font-medium text-zinc-600 dark:text-zinc-400">
|
|
<input type="checkbox" checked={getLrc} onChange={() => setGetLrc(!getLrc)} />
|
|
Get LRC (Lyrics)
|
|
</label>
|
|
</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
{showAudioModal && (
|
|
<div className="fixed inset-0 z-[120] flex items-center justify-center">
|
|
<div
|
|
className="absolute inset-0 bg-black/60 backdrop-blur-sm"
|
|
onClick={() => { setShowAudioModal(false); setPlayingTrackId(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">
|
|
{/* Header */}
|
|
<div className="p-5 pb-4">
|
|
<div className="flex items-start justify-between">
|
|
<div>
|
|
<h3 className="text-xl font-semibold text-zinc-900 dark:text-white">
|
|
{audioModalTarget === 'reference' ? 'Reference' : 'Cover'}
|
|
</h3>
|
|
<p className="text-sm text-zinc-500 dark:text-zinc-400 mt-1">
|
|
{audioModalTarget === 'reference'
|
|
? 'Create songs inspired by a reference track'
|
|
: 'Transform an existing track into a new version'}
|
|
</p>
|
|
</div>
|
|
<button
|
|
onClick={() => { setShowAudioModal(false); setPlayingTrackId(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"
|
|
>
|
|
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12"/>
|
|
</svg>
|
|
</button>
|
|
</div>
|
|
|
|
{/* Upload Button */}
|
|
<button
|
|
type="button"
|
|
onClick={() => {
|
|
const input = document.createElement('input');
|
|
input.type = 'file';
|
|
input.accept = '.mp3,.wav,.flac,.m4a,.mp4,audio/*';
|
|
input.onchange = (e) => {
|
|
const file = (e.target as HTMLInputElement).files?.[0];
|
|
if (file) void uploadReferenceTrack(file);
|
|
};
|
|
input.click();
|
|
}}
|
|
disabled={isUploadingReference}
|
|
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 ? (
|
|
<>
|
|
<RefreshCw size={16} className="animate-spin" />
|
|
Uploading...
|
|
</>
|
|
) : (
|
|
<>
|
|
<Upload size={16} />
|
|
Upload audio
|
|
<span className="text-xs text-zinc-400 ml-1">MP3, WAV, FLAC, M4A, MP4</span>
|
|
</>
|
|
)}
|
|
</button>
|
|
|
|
{uploadError && (
|
|
<div className="mt-2 text-xs text-rose-500">{uploadError}</div>
|
|
)}
|
|
</div>
|
|
|
|
{/* Mine Section */}
|
|
<div className="border-t border-zinc-100 dark:border-white/5">
|
|
<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">
|
|
Mine
|
|
</span>
|
|
</div>
|
|
|
|
{/* Track List */}
|
|
<div className="max-h-[280px] overflow-y-auto">
|
|
{isLoadingTracks ? (
|
|
<div className="px-5 py-8 text-center">
|
|
<RefreshCw size={20} className="animate-spin mx-auto text-zinc-400" />
|
|
<p className="text-xs text-zinc-400 mt-2">Loading tracks...</p>
|
|
</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 tracks 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(track)}
|
|
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 ? (
|
|
<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 ? (
|
|
<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(track)}
|
|
className="px-3 py-1.5 rounded-lg bg-zinc-900 dark:bg-white text-white dark:text-zinc-900 text-xs font-semibold hover:bg-zinc-800 dark:hover:bg-zinc-100 transition-colors"
|
|
>
|
|
Use
|
|
</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>
|
|
</div>
|
|
|
|
{/* Hidden audio element for modal playback */}
|
|
<audio
|
|
ref={modalAudioRef}
|
|
onTimeUpdate={() => {
|
|
if (modalAudioRef.current) {
|
|
setModalTrackTime(modalAudioRef.current.currentTime);
|
|
}
|
|
}}
|
|
onLoadedMetadata={() => {
|
|
if (modalAudioRef.current) {
|
|
setModalTrackDuration(modalAudioRef.current.duration);
|
|
// Update track duration in database if not set
|
|
const track = referenceTracks.find(t => t.id === playingTrackId);
|
|
if (track && !track.duration && token) {
|
|
fetch(`/api/reference-tracks/${track.id}`, {
|
|
method: 'PATCH',
|
|
headers: {
|
|
'Content-Type': 'application/json',
|
|
Authorization: `Bearer ${token}`
|
|
},
|
|
body: JSON.stringify({ duration: Math.round(modalAudioRef.current.duration) })
|
|
}).then(() => {
|
|
setReferenceTracks(prev => prev.map(t =>
|
|
t.id === track.id ? { ...t, duration: Math.round(modalAudioRef.current?.duration || 0) } : t
|
|
));
|
|
}).catch(() => undefined);
|
|
}
|
|
}
|
|
}}
|
|
onEnded={() => setPlayingTrackId(null)}
|
|
/>
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{/* Footer Create Button */}
|
|
<div className="p-4 mt-auto sticky bottom-0 bg-zinc-50/95 dark:bg-suno-panel/95 backdrop-blur-sm z-10 border-t border-zinc-200 dark:border-white/5 space-y-3">
|
|
<button
|
|
onClick={handleGenerate}
|
|
className="w-full h-12 rounded-xl font-bold text-base flex items-center justify-center gap-2 transition-all transform active:scale-[0.98] bg-gradient-to-r from-orange-500 to-pink-600 text-white shadow-lg hover:brightness-110"
|
|
>
|
|
<Sparkles size={18} />
|
|
<span>
|
|
{bulkCount > 1
|
|
? `Create ${bulkCount} Jobs (${bulkCount * batchSize} tracks)`
|
|
: `Create${batchSize > 1 ? ` (${batchSize} variations)` : ''}`}
|
|
</span>
|
|
</button>
|
|
</div>
|
|
</div>
|
|
);
|
|
};
|