Files
ace-step-ui/components/CreatePanel.tsx
T
2026-02-05 12:57:33 -05:00

2314 lines
113 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import React, { useState, useEffect, useRef, useCallback, useMemo } from 'react';
import { Sparkles, ChevronDown, Settings2, Trash2, Music2, Sliders, Dices, Hash, RefreshCw, Plus, Upload, Play, Pause, Loader2 } from 'lucide-react';
import { GenerationParams, Song } from '../types';
import { useAuth } from '../context/AuthContext';
import { generateApi } from '../services/api';
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;
createdSongs?: Song[];
pendingAudioSelection?: { target: 'reference' | 'source'; url: string; title?: string } | null;
onAudioSelectionApplied?: () => void;
}
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,
createdSongs = [],
pendingAudioSelection,
onAudioSelectionApplied,
}) => {
const { isAuthenticated, token, user } = 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');
const [vocalGender, setVocalGender] = useState<'male' | 'female' | ''>('');
// 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 [isTranscribingReference, setIsTranscribingReference] = useState(false);
const transcribeAbortRef = useRef<AbortController | null>(null);
const [uploadError, setUploadError] = useState<string | null>(null);
const [isFormattingStyle, setIsFormattingStyle] = useState(false);
const [isFormattingLyrics, setIsFormattingLyrics] = useState(false);
const [isDraggingFile, setIsDraggingFile] = useState(false);
const [dragKind, setDragKind] = useState<'file' | 'audio' | null>(null);
const referenceInputRef = useRef<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 [playingTrackSource, setPlayingTrackSource] = useState<'uploads' | 'created' | null>(null);
const modalAudioRef = useRef<HTMLAudioElement>(null);
const [modalTrackTime, setModalTrackTime] = useState(0);
const [modalTrackDuration, setModalTrackDuration] = useState(0);
const [libraryTab, setLibraryTab] = useState<'uploads' | 'created'>('uploads');
const createdTrackOptions = useMemo(() => {
return createdSongs
.filter(song => !song.isGenerating)
.filter(song => (user ? song.userId === user.id : true))
.filter(song => Boolean(song.audioUrl))
.map(song => ({
id: song.id,
title: song.title || 'Untitled',
audio_url: song.audioUrl!,
duration: song.duration,
}));
}, [createdSongs, user]);
const getAudioLabel = (url: string) => {
try {
const parsed = new URL(url);
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(() => {
if (!pendingAudioSelection) return;
applyAudioTargetUrl(
pendingAudioSelection.target,
pendingAudioSelection.url,
pendingAudioSelection.title
);
onAudioSelectionApplied?.();
}, [pendingAudioSelection, onAudioSelectionApplied]);
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 getDragKind = (e: DragEvent): 'file' | 'audio' | null => {
if (!e.dataTransfer) return null;
const types = Array.from(e.dataTransfer.types);
if (types.includes('Files')) return 'file';
if (types.includes('application/x-ace-audio')) return 'audio';
return null;
};
const handleDragEnter = (e: DragEvent) => {
const kind = getDragKind(e);
if (!kind) return;
dragDepthRef.current += 1;
setIsDraggingFile(true);
setDragKind(kind);
e.preventDefault();
};
const handleDragOver = (e: DragEvent) => {
const kind = getDragKind(e);
if (!kind) return;
setDragKind(kind);
e.preventDefault();
};
const handleDragLeave = (e: DragEvent) => {
const kind = getDragKind(e);
if (!kind) return;
dragDepthRef.current = Math.max(0, dragDepthRef.current - 1);
if (dragDepthRef.current === 0) {
setIsDraggingFile(false);
setDragKind(null);
}
};
const handleDrop = (e: DragEvent) => {
const kind = getDragKind(e);
if (!kind) return;
e.preventDefault();
dragDepthRef.current = 0;
setIsDraggingFile(false);
setDragKind(null);
};
window.addEventListener('dragenter', handleDragEnter);
window.addEventListener('dragover', handleDragOver);
window.addEventListener('dragleave', handleDragLeave);
window.addEventListener('drop', handleDrop);
return () => {
window.removeEventListener('dragenter', handleDragEnter);
window.removeEventListener('dragover', handleDragOver);
window.removeEventListener('dragleave', handleDragLeave);
window.removeEventListener('drop', handleDrop);
};
}, []);
const startResizing = (e: React.MouseEvent) => {
e.preventDefault();
setIsResizing(true);
};
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', tab: 'uploads' | 'created' = 'uploads') => {
setAudioModalTarget(target);
setTempAudioUrl('');
setLibraryTab(tab);
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);
if (data.whisper_available && data.track?.id) {
void transcribeReferenceTrack(data.track.id).then(() => undefined);
} else {
setShowAudioModal(false);
}
} catch (err) {
const message = err instanceof Error ? err.message : 'Upload failed';
setUploadError(message);
} finally {
setIsUploadingReference(false);
}
};
const transcribeReferenceTrack = async (trackId: string) => {
if (!token) return;
setIsTranscribingReference(true);
const controller = new AbortController();
transcribeAbortRef.current = controller;
try {
const response = await fetch(`/api/reference-tracks/${trackId}/transcribe`, {
method: 'POST',
headers: { Authorization: `Bearer ${token}` },
signal: controller.signal,
});
if (!response.ok) {
throw new Error('Failed to transcribe');
}
const data = await response.json();
if (data.lyrics) {
setLyrics(prev => prev || data.lyrics);
}
} catch (err) {
if (controller.signal.aborted) return;
console.error('Transcription failed:', err);
} finally {
if (transcribeAbortRef.current === controller) {
transcribeAbortRef.current = null;
}
setIsTranscribingReference(false);
}
};
const cancelTranscription = () => {
if (transcribeAbortRef.current) {
transcribeAbortRef.current.abort();
transcribeAbortRef.current = null;
}
setIsTranscribingReference(false);
};
const deleteReferenceTrack = async (trackId: string) => {
if (!token) return;
try {
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 && playingTrackSource === 'uploads') {
setPlayingTrackId(null);
setPlayingTrackSource(null);
if (modalAudioRef.current) {
modalAudioRef.current.pause();
}
}
}
} catch (err) {
console.error('Failed to delete track:', err);
}
};
const useReferenceTrack = (track: { audio_url: string; title?: string }) => {
applyAudioTargetUrl(audioModalTarget, track.audio_url, track.title);
setShowAudioModal(false);
setPlayingTrackId(null);
setPlayingTrackSource(null);
};
const toggleModalTrack = (track: { id: string; audio_url: string; source: 'uploads' | 'created' }) => {
if (playingTrackId === track.id) {
if (modalAudioRef.current) {
modalAudioRef.current.pause();
}
setPlayingTrackId(null);
setPlayingTrackSource(null);
} else {
setPlayingTrackId(track.id);
setPlayingTrackSource(track.source);
if (modalAudioRef.current) {
modalAudioRef.current.src = track.audio_url;
modalAudioRef.current.play().catch(() => undefined);
}
}
};
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);
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
}
}
};
const handleDragOver = (e: React.DragEvent<HTMLDivElement>) => {
e.preventDefault();
};
const handleWorkspaceDrop = (e: React.DragEvent<HTMLDivElement>) => {
if (e.dataTransfer.files?.length || e.dataTransfer.types.includes('application/x-ace-audio')) {
handleDrop(e, audioTab);
}
};
const handleWorkspaceDragOver = (e: React.DragEvent<HTMLDivElement>) => {
if (e.dataTransfer.types.includes('Files') || e.dataTransfer.types.includes('application/x-ace-audio')) {
e.preventDefault();
}
};
const handleGenerate = () => {
const styleWithGender = (() => {
if (!vocalGender) return style;
const genderHint = vocalGender === 'male' ? 'Male vocals' : 'Female vocals';
const trimmed = style.trim();
return trimmed ? `${trimmed}\n${genderHint}` : genderHint;
})();
// Bulk generation: loop bulkCount times
for (let i = 0; i < bulkCount; i++) {
// Seed handling: first job uses user's seed, rest get random seeds
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: styleWithGender,
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">
{dragKind !== 'audio' && (
<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">
{dragKind === 'audio' ? 'Drop to use audio' : 'Drop to upload'}
</div>
<div className="text-[11px] text-zinc-500 dark:text-zinc-400">
{dragKind === 'audio'
? `Using as ${audioTab === 'reference' ? 'Reference' : 'Cover'}`
: `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>
<div className="flex flex-wrap items-center gap-2 p-3">
<select
value={vocalLanguage}
onChange={(e) => setVocalLanguage(e.target.value)}
className="flex-1 min-w-[180px] bg-transparent 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 className="flex items-center gap-2">
<button
type="button"
onClick={() => setVocalGender(vocalGender === 'male' ? '' : 'male')}
className={`px-3 py-1.5 rounded-full text-xs font-semibold border transition-colors ${vocalGender === 'male' ? 'bg-pink-600 text-white border-pink-600' : 'border-zinc-200 dark:border-white/10 text-zinc-600 dark:text-zinc-300 hover:border-zinc-300 dark:hover:border-white/20'}`}
>
Male
</button>
<button
type="button"
onClick={() => setVocalGender(vocalGender === 'female' ? '' : 'female')}
className={`px-3 py-1.5 rounded-full text-xs font-semibold border transition-colors ${vocalGender === 'female' ? 'bg-pink-600 text-white border-pink-600' : 'border-zinc-200 dark:border-white/10 text-zinc-600 dark:text-zinc-300 hover:border-zinc-300 dark:hover:border-white/20'}`}
>
Female
</button>
</div>
</div>
</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, '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"
>
<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>
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 className="space-y-1.5">
<label className="text-xs font-bold text-zinc-500 dark:text-zinc-400 uppercase tracking-wide px-1">
Vocal Gender
</label>
<div className="flex items-center gap-2">
<button
type="button"
onClick={() => setVocalGender(vocalGender === 'male' ? '' : 'male')}
className={`flex-1 px-3 py-2 rounded-lg text-xs font-semibold border transition-colors ${vocalGender === 'male' ? 'bg-pink-600 text-white border-pink-600' : 'border-zinc-200 dark:border-white/10 text-zinc-600 dark:text-zinc-300 hover:border-zinc-300 dark:hover:border-white/20'}`}
>
Male
</button>
<button
type="button"
onClick={() => setVocalGender(vocalGender === 'female' ? '' : 'female')}
className={`flex-1 px-3 py-2 rounded-lg text-xs font-semibold border transition-colors ${vocalGender === 'female' ? 'bg-pink-600 text-white border-pink-600' : 'border-zinc-200 dark:border-white/10 text-zinc-600 dark:text-zinc-300 hover:border-zinc-300 dark:hover:border-white/20'}`}
>
Female
</button>
</div>
</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" title="Creates multiple variations in a single run. More variations = longer total time.">Variations Per Job</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">How many versions to create in one run</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" title="More steps usually improves quality but slows generation.">Quality 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">Higher = 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" title="How strongly the model follows the prompt. Higher = stricter, lower = freer.">Prompt Strength</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">Higher = follow prompt more, lower = more variation</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" title="Deterministic is more repeatable; stochastic adds randomness.">Sampler</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">Deterministic (ODE)</option>
<option value="sde">Stochastic (SDE)</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" title="Fixing the seed makes results repeatable. Random is recommended for variety.">Repeatable 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 ? 'Random every run (recommended)' : 'Same seed = repeatable 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" title="Lets the lyric model reason about structure and metadata. Slightly slower.">Reasoning Pass (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" title="Adjusts the diffusion schedule. Only affects base model.">Timestep 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">Adjusts sampling schedule (base models only)</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 title="Controls the 5Hz lyric/caption model sampling behavior.">Lyric Model Controls</span>
<span className="text-[11px] text-zinc-400 dark:text-zinc-500 font-normal">Controls the LLM used for lyrics/captions</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" title="Higher temperature = more random word choices.">Creativity (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">Lower = more deterministic, higher = more variety (02)</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" title="How strongly the lyric model follows the prompt.">Prompt Adherence (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">Higher = follows prompt more, lower = more freedom (13)</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" title="Restricts choices to the K most likely tokens. 0 disables.">Token Choice Limit (TopK)</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"
/>
<p className="text-[10px] text-zinc-500">Sample only from the top K likely tokens</p>
</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" title="Samples from the smallest set whose total probability is P.">Probability Mass (TopP)</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"
/>
<p className="text-[10px] text-zinc-500">Sample from smallest set totaling P probability</p>
</div>
</div>
{/* LM Negative Prompt */}
<div className="space-y-1.5">
<label className="text-xs font-medium text-zinc-600 dark:text-zinc-400" title="Words or ideas to steer the lyric model away from.">Avoid These (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">Works best when CFG is above 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" title="Controls how much the output follows the input audio.">Source Audio</h4>
<p className="text-[11px] text-zinc-400 dark:text-zinc-500">Controls how much to follow the input audio.</p>
</div>
<div className="space-y-1.5">
<label className="text-xs font-medium text-zinc-600 dark:text-zinc-400" title="Advanced: precomputed audio codes for conditioning.">Audio Codes (Advanced)</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" title="Choose text-to-music or audio-based modes.">Generation Mode</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" title="How strongly the source audio shapes the result.">Cover Influence</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" title="Start time for the region to repaint (seconds).">Repaint Start (sec)</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" title="End time for the region to repaint (seconds).">Repaint End (sec)</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" title="Additional directives to guide generation.">Extra Instructions</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" title="Controls when guidance is applied across diffusion steps.">Guidance Schedule</h4>
<p className="text-[11px] text-zinc-400 dark:text-zinc-500">When to apply guidance during sampling.</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" title="Fraction of the diffusion process to start applying guidance.">Guidance Start (01)</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" title="Fraction of the diffusion process to stop applying guidance.">Guidance End (01)</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" title="Override the default timestep schedule (advanced).">Custom Timesteps (Advanced)</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" title="Scales score-based guidance (advanced).">Score Weight</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" title="Bigger chunks can be faster but use more memory.">LM Batch Chunk Size (Performance)</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"
title="Adaptive Dual Guidance: dynamically adjusts CFG for quality. Base model only; slower."
>
<input type="checkbox" checked={useAdg} onChange={() => setUseAdg(!useAdg)} />
Adaptive Dual Guidance (ADG)
</label>
<label className="flex items-center gap-2 text-xs font-medium text-zinc-600 dark:text-zinc-400" title="Allow the LM to run in larger batches for speed (more VRAM).">
<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" title="Let the LM reason about metadata like BPM, key, duration.">
<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" title="Let the LM reason about the caption/style text.">
<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" title="Let the LM reason about language selection.">
<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" title="Auto-generate missing fields when possible.">
<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" title="Include debug info for constrained decoding.">
<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" title="Use the formatted caption produced by the AI formatter.">
<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" title="Return scorer outputs for diagnostics.">
<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" title="Return synced lyric (LRC) output when available.">
<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); 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">
{/* 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); 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"
>
<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 || 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"
>
{isUploadingReference ? (
<>
<RefreshCw size={16} className="animate-spin" />
Uploading...
</>
) : isTranscribingReference ? (
<>
<RefreshCw size={16} className="animate-spin" />
Transcribing...
</>
) : (
<>
<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>
)}
{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>
{/* Library Section */}
<div className="border-t border-zinc-100 dark:border-white/5">
<div className="px-5 py-3 flex items-center gap-2">
<div className="flex items-center gap-1 bg-zinc-200/60 dark:bg-white/10 rounded-full p-0.5">
<button
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>
{/* Track List */}
<div className="max-h-[280px] overflow-y-auto">
{libraryTab === 'uploads' ? (
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 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">
<Music2 size={24} className="mx-auto text-zinc-300 dark:text-zinc-600" />
<p className="text-sm text-zinc-400 mt-2">No created songs yet</p>
<p className="text-xs text-zinc-400 mt-1">Generate songs to reuse them as cover or reference</p>
</div>
) : (
<div className="divide-y divide-zinc-100 dark:divide-white/5">
{createdTrackOptions.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"
>
<button
type="button"
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"
>
{playingTrackId === track.id && playingTrackSource === 'created' ? (
<Pause size={14} fill="currentColor" />
) : (
<Play size={14} fill="currentColor" className="ml-0.5" />
)}
</button>
<div className="flex-1 min-w-0">
<div className="text-sm font-medium text-zinc-800 dark:text-zinc-200 truncate">
{track.title}
</div>
{playingTrackId === track.id && playingTrackSource === 'created' ? (
<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 || '--:--'}
</div>
)}
</div>
<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.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"
>
Use
</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 (playingTrackSource === 'uploads' && 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>
);
};