import React, { useCallback, useEffect, useRef, useState } from 'react'; import { deleteMusicTrack, generateMusic, generateMusicLyrics, getMusicLibrary, getMusicProviders, getMusicStatus, getMusicModels, extendMusicTrack, removeVocals, generateCoverImage, convertToWav, splitStems, getTimestampedLyrics, generateStyleBoost, generateVideo, startBatchGen, getBatchJob, listGenres, } from '../../api'; import PullToRefresh from '../../components/PullToRefresh'; import FAB from '../../components/FAB'; import './MusicStudio.css'; import AudioPlayer from './components/AudioPlayer'; import { fmtTime } from './components/AudioPlayer'; import CreditsBadge from './components/CreditsBadge'; import CoverArtModal from './components/CoverArtModal'; import LyricsTab from './components/LyricsTab'; import StemModal from './components/StemModal'; import SyncedLyricsPlayer from './components/SyncedLyricsPlayer'; import RemixTab from './components/RemixTab'; import YoutubeTab from './components/YoutubeTab'; import BatchProgress from './components/BatchProgress'; /* ───────────────────────────────────────────── 데이터 상수 ───────────────────────────────────────────── */ const GENRES = [ { id: 'lofi', label: 'Lo-Fi', icon: '📻', color: '#f5a623', desc: 'Warm · Nostalgic' }, { id: 'electronic', label: 'Electronic', icon: '⚡', color: '#60a5fa', desc: 'Pulse · Synth' }, { id: 'jazz', label: 'Jazz', icon: '🎷', color: '#c084fc', desc: 'Smooth · Soul' }, { id: 'classical', label: 'Classical', icon: '🎻', color: '#f9b6b1', desc: 'Orchestral · Grand' }, { id: 'ambient', label: 'Ambient', icon: '🌊', color: '#4aad8b', desc: 'Space · Float' }, { id: 'hiphop', label: 'Hip-Hop', icon: '🎤', color: '#f472b6', desc: 'Beat · Urban' }, { id: 'rock', label: 'Rock', icon: '🎸', color: '#e85c3a', desc: 'Raw · Drive' }, { id: 'cinematic', label: 'Cinematic', icon: '🎬', color: '#fbbf24', desc: 'Epic · Sweeping' }, ]; const MOODS = [ { id: 'energetic', label: 'Energetic', color: '#e85c3a' }, { id: 'chill', label: 'Chill', color: '#60a5fa' }, { id: 'dark', label: 'Dark', color: '#9333ea' }, { id: 'uplifting', label: 'Uplifting', color: '#f5a623' }, { id: 'romantic', label: 'Romantic', color: '#f472b6' }, { id: 'epic', label: 'Epic', color: '#fbbf24' }, { id: 'melancholic', label: 'Melancholic', color: '#4aad8b' }, ]; const INSTRUMENTS = [ { id: 'piano', label: 'Piano', freq: '261Hz' }, { id: 'guitar', label: 'Guitar', freq: '82Hz' }, { id: 'drums', label: 'Drums', freq: '60Hz' }, { id: 'synth', label: 'Synth', freq: '440Hz' }, { id: 'bass', label: 'Bass', freq: '41Hz' }, { id: 'strings', label: 'Strings', freq: '196Hz' }, { id: 'brass', label: 'Brass', freq: '146Hz' }, { id: 'flute', label: 'Flute', freq: '523Hz' }, { id: 'violin', label: 'Violin', freq: '659Hz' }, { id: 'choir', label: 'Choir', freq: '330Hz' }, ]; const DURATIONS = [ { id: '30s', label: '0:30', sec: 30 }, { id: '60s', label: '1:00', sec: 60 }, { id: '90s', label: '1:30', sec: 90 }, { id: '2m', label: '2:00', sec: 120 }, { id: '3m', label: '3:00', sec: 180 }, { id: '5m', label: '5:00', sec: 300 }, ]; const BPM_PRESETS = [ { label: 'Slow', bpm: 70 }, { label: 'Mid', bpm: 100 }, { label: 'Fast', bpm: 130 }, { label: 'EDM', bpm: 160 }, ]; const KEYS = ['C','C#','D','D#','E','F','F#','G','G#','A','A#','B']; const SCALES = ['Major','Minor','Dorian','Phrygian','Lydian','Mixolydian']; /* 시뮬레이션 폴백용 단계 메시지 */ const SIM_STEPS = [ { msg: 'Analyzing musical patterns…', pct: 16 }, { msg: 'Building harmonic structure…', pct: 32 }, { msg: 'Rendering instruments…', pct: 52 }, { msg: 'Mixing and mastering…', pct: 72 }, { msg: 'Applying final polish…', pct: 90 }, { msg: 'Track ready!', pct: 100 }, ]; /* ───────────────────────────────────────────── Loading Skeleton ───────────────────────────────────────────── */ const SkeletonCard = () => (
); /* ───────────────────────────────────────────── Waveform Canvas ───────────────────────────────────────────── */ const WaveformCanvas = ({ isGenerating, accentColor }) => { const canvasRef = useRef(null); const rafRef = useRef(null); const phaseRef = useRef(0); const intensityRef = useRef(0); useEffect(() => { const canvas = canvasRef.current; if (!canvas) return; const ctx = canvas.getContext('2d'); let dpr = window.devicePixelRatio || 1; const resize = () => { dpr = window.devicePixelRatio || 1; canvas.width = canvas.offsetWidth * dpr; canvas.height = canvas.offsetHeight * dpr; ctx.setTransform(dpr, 0, 0, dpr, 0, 0); }; resize(); const ro = new ResizeObserver(resize); ro.observe(canvas); const draw = () => { const w = canvas.offsetWidth, h = canvas.offsetHeight; ctx.clearRect(0, 0, w, h); const target = isGenerating ? 1.0 : 0.25; intensityRef.current += (target - intensityRef.current) * 0.04; const intensity = intensityRef.current; const layers = [ { amp: h * 0.38 * intensity, freq: 1.6, speed: 0.022, alpha: 0.9, lw: 2 }, { amp: h * 0.22 * intensity, freq: 2.8, speed: 0.034, alpha: 0.5, lw: 1.2 }, { amp: h * 0.12 * intensity, freq: 4.5, speed: 0.055, alpha: 0.3, lw: 0.8 }, ]; layers.forEach(({ amp, freq, speed, alpha, lw }, li) => { ctx.beginPath(); ctx.strokeStyle = accentColor; ctx.globalAlpha = alpha; ctx.lineWidth = lw; ctx.shadowBlur = isGenerating ? 16 : 6; ctx.shadowColor = accentColor; for (let x = 0; x <= w; x += 2) { const t = (x / w) * Math.PI * 2 * freq + phaseRef.current * (1 + li * speed * 10); const h2 = Math.sin(t * 2.1 + li) * 0.35; const h3 = Math.sin(t * 0.45 + li * 0.7) * 0.18; const y = h / 2 + (Math.sin(t) + h2 + h3) * amp; x === 0 ? ctx.moveTo(x, y) : ctx.lineTo(x, y); } ctx.stroke(); }); ctx.globalAlpha = 1; ctx.shadowBlur = 0; phaseRef.current += isGenerating ? 0.055 : 0.018; rafRef.current = requestAnimationFrame(draw); }; draw(); return () => { ro.disconnect(); cancelAnimationFrame(rafRef.current); }; }, [isGenerating, accentColor]); return ; }; /* ───────────────────────────────────────────── Sonic Radar (헤더 비주얼 — 모듈 로드 시 1회 계산) ───────────────────────────────────────────── */ const RADAR_N = 48; const RADAR_INNER = 26; // 중심~바 시작 거리(SVG unit) const RADAR_DATA = Array.from({ length: RADAR_N }, (_, i) => ({ angle: `${((i / RADAR_N) * 360).toFixed(2)}deg`, delay: `${((i / RADAR_N) * 1.8).toFixed(2)}s`, rnd: `${(0.22 + Math.random() * 0.78).toFixed(2)}`, })); const SonicRadar = ({ isGenerating, accentColor }) => (
{/* SVG — 링·크로스헤어·스윕 */} {/* 가이드 링 3개 */} {/* 크로스헤어 틱 마크 */} {/* 대각선 코너 틱 */} {/* 레이더 스윕 라인 (generating 시 회전) */} {/* 센터 글로우 링 */} {/* CSS 방사형 바 (HTML div) */} {RADAR_DATA.map((bar, i) => (
))} {/* 센터 도트 */}
{/* 상태 레이블 */}
{isGenerating ? 'GENERATING' : 'STANDBY'}
); /* ───────────────────────────────────────────── Progress Bar ───────────────────────────────────────────── */ const GenerationProgress = ({ progress, stepMsg }) => (
{stepMsg} {progress}%
); /* ───────────────────────────────────────────── Track Result Card ───────────────────────────────────────────── */ const TrackResult = ({ track, onDownload, onNew }) => { const genre = GENRES.find((g) => g.id === track.genre); const totalSec = DURATIONS.find((d) => d.id === track.duration)?.sec ?? track.duration_sec ?? 60; return (
✓ Generated {track.created_at ?? track.createdAt}
{genre?.icon}

{track.title}

{fmtTime(totalSec)} · {track.bpm} BPM · {track.key} {track.scale}

{track.provider && ( {track.provider === 'suno' ? '🎙️ Suno' : '🤖 MusicGen'} )} {(track.instruments ?? []).slice(0, 4).map((inst) => ( {inst} ))} {track.moods?.map?.((m) => ( {m} ))}
{track.lyrics && (
🎤 가사 보기
{track.lyrics}
)}
{track.audio_url && ( ↓ Download )}

✓ 생성 완료 — Library에 자동 저장되었습니다

); }; /* ───────────────────────────────────────────── Library Card ───────────────────────────────────────────── */ const LibraryCard = ({ track, onDelete, onPlay, isPlaying, onExtend, onVocalRemoval, onCoverArt, onWavConvert, onStemSplit, onSyncedLyrics, onVideoGenerate, onVideoProject, onVideoPipeline, isGenerating }) => { const [menuOpen, setMenuOpen] = useState(false); const genre = GENRES.find((g) => g.id === track.genre); const totalSec = track.duration_sec ?? null; const filename = track.audio_url ? track.audio_url.split('/').pop() : ''; const hasSunoId = !!track.suno_id; return (
{genre?.icon ?? '🎵'}

{track.title}

{filename}

{totalSec != null ? fmtTime(totalSec) : '--:--'} · {track.bpm ? `${track.bpm} BPM` : ''} {track.key} {track.scale}

{isPlaying && ( )}
{track.provider && ( {track.provider === 'suno' ? '🎙️ Suno' : '🤖 MusicGen'} )} {(track.instruments ?? []).slice(0, 3).map((i) => ( {i} ))} {(track.moods ?? []).slice(0, 2).map((m) => ( {m} ))}
{hasSunoId && (
{track.audio_url && ( ↓ Download )}
{menuOpen && (
)}
)} {!hasSunoId && track.audio_url && (
↓ Download
)}

{track.created_at ? new Date(track.created_at).toLocaleDateString('ko-KR') : ''}

); }; /* ───────────────────────────────────────────── Library Section ───────────────────────────────────────────── */ const Library = ({ tracks, onDelete, onRefresh, onExtend, onVocalRemoval, onCoverArt, onWavConvert, onStemSplit, onSyncedLyrics, onVideoGenerate, onVideoProject, onVideoPipeline, isGenerating, loading }) => { const [playingId, setPlayingId] = useState(null); const handlePlay = (track) => { setPlayingId((prev) => (prev === track.id ? null : track.id)); }; if (loading) { return (

My Library

{Array.from({ length: 4 }, (_, i) => )}
); } if (tracks.length === 0) { return (
🎵

라이브러리가 비어있습니다

트랙을 생성하고 Library에 저장하면 여기서 확인할 수 있습니다

); } return (

My Library

{tracks.length} tracks
{tracks.map((track) => ( ))}
); }; /* ───────────────────────────────────────────── Main Page ───────────────────────────────────────────── */ export default function MusicStudio() { /* ── 탭 ── */ const [tab, setTab] = useState('create'); const [initialTrackId, setInitialTrackId] = useState(null); const [openPipelineFor, setOpenPipelineFor] = useState(null); /* ── Provider 상태 ── */ const [providers, setProviders] = useState([]); const [provider, setProvider] = useState('suno'); const [providerError, setProviderError] = useState(false); /* ── 컨트롤 상태 ── */ const [genre, setGenre] = useState(null); const [moods, setMoods] = useState([]); const [instruments, setInstruments] = useState([]); const [duration, setDuration] = useState('60s'); const [bpm, setBpm] = useState(100); const [musicalKey, setMusicalKey] = useState('C'); const [scale, setScale] = useState('Major'); const [prompt, setPrompt] = useState(''); const [customTitle, setCustomTitle] = useState(''); /* ── Suno 전용 상태 ── */ const [lyrics, setLyrics] = useState(''); const [instrumental, setInstrumental] = useState(false); const [lyricsLoading, setLyricsLoading] = useState(false); const [model, setModel] = useState('V4'); const [models, setModels] = useState([]); /* ── Phase 1: 신규 파라미터 ── */ const [vocalGender, setVocalGender] = useState(null); // "m" | "f" | null const [negativeTags, setNegativeTags] = useState(''); const [styleWeight, setStyleWeight] = useState(50); // UI: 0~100 const [audioWeight, setAudioWeight] = useState(50); /* ── CoverArt 상태 ── */ const [coverArtModal, setCoverArtModal] = useState(null); // { trackId, images } /* ── Phase 2 상태 ── */ const [stemModal, setStemModal] = useState(null); // { stems: {} } const [syncedLyrics, setSyncedLyrics] = useState(null); // { audioUrl, words } const [styleBoostLoading, setStyleBoostLoading] = useState(false); /* ── 생성 상태 ── */ const [isGenerating, setIsGenerating] = useState(false); const [genProgress, setGenProgress] = useState(0); const [genStep, setGenStep] = useState(''); const [genError, setGenError] = useState(null); const [track, setTrack] = useState(null); /* ── 라이브러리 상태 ── */ const [library, setLibrary] = useState([]); const [libLoading, setLibLoading] = useState(false); /* ── 설명 토글 ── */ const [openDescs, setOpenDescs] = useState(new Set()); const toggleDesc = (id) => setOpenDescs((prev) => { const next = new Set(prev); next.has(id) ? next.delete(id) : next.add(id); return next; }); const isOpen = (id) => openDescs.has(id); /* ── refs ── */ const pollRef = useRef(null); const taskIdRef = useRef(null); /* ── 배치 생성 상태 ── */ const [batchOpen, setBatchOpen] = useState(false); const [batchGenre, setBatchGenre] = useState('lo-fi'); const [batchCount, setBatchCount] = useState(10); const [batchDuration, setBatchDuration] = useState(180); const [batchAutoPipe, setBatchAutoPipe] = useState(true); const [currentBatch, setCurrentBatch] = useState(null); const [batchPolling, setBatchPolling] = useState(false); const [batchGenresList, setBatchGenresList] = useState(['lo-fi', 'phonk', 'ambient', 'pop']); const batchPollRef = useRef(null); const activeGenre = GENRES.find((g) => g.id === genre); const accentColor = activeGenre?.color ?? '#f5a623'; /* ── Provider 로드 ── */ useEffect(() => { getMusicProviders() .then((data) => { const list = data.providers ?? []; setProviders(list); setProviderError(false); if (list.length > 0 && !list.find((p) => p.id === provider)) { setProvider(list[0].id); } }) .catch(() => setProviderError(true)); }, []); // eslint-disable-line react-hooks/exhaustive-deps /* ── 모델 로드 ── */ useEffect(() => { getMusicModels() .then((data) => setModels(data.models ?? [])) .catch(() => {}); }, []); /* ── 가사 AI 생성 ── */ const handleGenerateLyrics = async () => { if (!prompt && !genre) return; setLyricsLoading(true); try { const desc = prompt || `${GENRES.find((g) => g.id === genre)?.label ?? ''} ${moods.map((id) => MOODS.find((m) => m.id === id)?.label).join(' ')}`; const result = await generateMusicLyrics(desc); if (result?.text) setLyrics(result.text); } catch {} finally { setLyricsLoading(false); } }; /* ── 라이브러리 로드 ── */ const loadLibrary = useCallback(async () => { setLibLoading(true); try { const data = await getMusicLibrary(); setLibrary(data.tracks ?? []); } catch { /* 백엔드 미구현 시 무시 */ } finally { setLibLoading(false); } }, []); useEffect(() => { loadLibrary(); }, [loadLibrary]); /* ── 탭 전환 시 라이브러리 갱신 ── */ useEffect(() => { if (tab === 'library') loadLibrary(); }, [tab, loadLibrary]); /* ── 언마운트 시 폴링 정리 ── */ useEffect(() => () => clearInterval(pollRef.current), []); /* ── 배치 생성 시작 ── */ const startBatch = async () => { try { const res = await startBatchGen({ genre: batchGenre, count: batchCount, target_duration_sec: batchDuration, auto_pipeline: batchAutoPipe, }); setCurrentBatch(res); setBatchPolling(true); } catch (e) { alert(`배치 시작 실패: ${e.message || e}`); } }; /* ── 배치: 지원 장르 목록 fetch (mount 시 1회) ── */ useEffect(() => { listGenres() .then((r) => { if (Array.isArray(r?.genres) && r.genres.length) { setBatchGenresList(r.genres); if (!r.genres.includes(batchGenre)) setBatchGenre(r.genres[0]); } }) .catch(() => { /* fallback hardcoded list 유지 */ }); // eslint-disable-next-line react-hooks/exhaustive-deps }, []); /* ── 배치 폴링 ── */ useEffect(() => { if (!batchPolling || !currentBatch?.id) return; const tick = async () => { try { const j = await getBatchJob(currentBatch.id); if (j) { setCurrentBatch(j); if (['piped', 'failed', 'cancelled'].includes(j.status)) { setBatchPolling(false); // library 갱신 (새 트랙들 표시되도록) if (typeof loadLibrary === 'function') loadLibrary(); } } } catch { /* swallow */ } }; batchPollRef.current = setInterval(tick, 5000); return () => clearInterval(batchPollRef.current); // eslint-disable-next-line react-hooks/exhaustive-deps }, [batchPolling, currentBatch?.id]); /* ── helpers ── */ const toggleMood = (id) => setMoods((prev) => prev.includes(id) ? prev.filter((m) => m !== id) : prev.length < 3 ? [...prev, id] : prev ); const toggleInstrument = (id) => setInstruments((prev) => prev.includes(id) ? prev.filter((i) => i !== id) : [...prev, id] ); /* ── 시뮬레이션 폴백 ── */ const runSimulation = async (fallbackData) => { for (const step of SIM_STEPS) { setGenStep(step.msg); await new Promise((r) => setTimeout(r, 700 + Math.random() * 500)); setGenProgress(step.pct); } setIsGenerating(false); const durSec = DURATIONS.find((d) => d.id === duration)?.sec ?? 60; setTrack({ id: `sim_${Date.now()}`, title: `${activeGenre?.label} — ${moods[0] ? MOODS.find((m) => m.id === moods[0])?.label : 'Original'} Mix`, genre, duration, duration_sec: durSec, bpm, key: musicalKey, scale, moods, instruments: instruments.length > 0 ? instruments : ['piano', 'synth', 'bass'], audio_url: null, created_at: new Date().toLocaleTimeString('ko-KR'), ...fallbackData, }); }; /* ── 폴링 ── */ const startPolling = (taskId, trackTitle) => { clearInterval(pollRef.current); pollRef.current = setInterval(async () => { try { const status = await getMusicStatus(taskId); setGenProgress(status.progress ?? 0); setGenStep(status.message ?? '처리 중…'); if (status.status === 'succeeded') { clearInterval(pollRef.current); setIsGenerating(false); setGenProgress(100); /* status.track이 있으면 library와 동일한 객체 사용, 없으면 로컬 state로 조립 */ if (status.track) { setTrack(status.track); } else { const durSec = DURATIONS.find((d) => d.id === duration)?.sec ?? 60; setTrack({ id: taskId, title: trackTitle, genre, duration_sec: durSec, bpm, key: musicalKey, scale, moods, instruments: instruments.length > 0 ? instruments : ['piano', 'synth', 'bass'], audio_url: status.audio_url ?? null, created_at: new Date().toLocaleTimeString('ko-KR'), }); } /* 백엔드 auto-register 후 Library 자동 갱신 */ loadLibrary(); } else if (status.status === 'failed') { clearInterval(pollRef.current); setIsGenerating(false); setGenError(`생성 실패: ${status.error ?? '알 수 없는 오류'}`); } } catch { clearInterval(pollRef.current); runSimulation({}); } }, 3000); }; /* ── 생성 핸들러 ── */ const handleGenerate = async () => { if (!genre || isGenerating) return; setIsGenerating(true); setTrack(null); setGenProgress(0); setGenStep('요청 전송 중…'); setGenError(null); const durSec = DURATIONS.find((d) => d.id === duration)?.sec ?? 60; const moodLabel = moods[0] ? MOODS.find((m) => m.id === moods[0])?.label : 'Original'; const title = customTitle.trim() || `${activeGenre?.label} — ${moodLabel} Mix`; const instList = instruments.length > 0 ? instruments : ['piano', 'synth', 'bass']; const payload = { provider, model, title, genre, moods, instruments: instList, duration_sec: durSec, bpm, key: musicalKey, scale, prompt: prompt || undefined, ...(provider === 'suno' ? { lyrics: lyrics || undefined, instrumental, vocal_gender: vocalGender || undefined, negative_tags: negativeTags || undefined, style_weight: styleWeight !== 50 ? styleWeight / 100 : undefined, audio_weight: audioWeight !== 50 ? audioWeight / 100 : undefined, } : {}), }; try { const res = await generateMusic(payload); if (res?.task_id) { taskIdRef.current = res.task_id; setGenStep('AI가 음악을 작곡하고 있습니다…'); setGenProgress(5); startPolling(res.task_id, title); } else { /* task_id 없이 바로 결과 반환하는 경우 */ setIsGenerating(false); setGenProgress(100); setTrack({ ...payload, id: Date.now(), audio_url: res.audio_url ?? null, created_at: new Date().toLocaleTimeString('ko-KR') }); loadLibrary(); } } catch { /* API 없을 때 시뮬레이션 폴백 */ setGenStep('오프라인 모드: 시뮬레이션 진행 중…'); await runSimulation({ title }); } }; /* ── 라이브러리 삭제 ── */ const handleDeleteFromLibrary = async (id) => { try { await deleteMusicTrack(id); } catch { /* 무시 */ } setLibrary((prev) => prev.filter((t) => t.id !== id)); }; /* ── 곡 연장 핸들러 ── */ const handleExtend = async (track) => { if (!track.suno_id || isGenerating) return; setTab('create'); setIsGenerating(true); setTrack(null); setGenProgress(0); setGenStep('곡 연장 요청 중…'); setGenError(null); try { const res = await extendMusicTrack({ suno_id: track.suno_id, continue_at: track.duration_sec ?? 60, prompt: '', style: track.genre ?? '', title: `${track.title} (Extended)`, model, }); if (res?.task_id) { taskIdRef.current = res.task_id; setGenStep('AI가 곡을 연장하고 있습니다…'); setGenProgress(5); startPolling(res.task_id, `${track.title} (Extended)`); } } catch { setIsGenerating(false); setGenError('곡 연장에 실패했습니다'); } }; /* ── 보컬 분리 핸들러 ── */ const handleVocalRemoval = async (track) => { if (!track.suno_id || isGenerating) return; setTab('create'); setIsGenerating(true); setTrack(null); setGenProgress(0); setGenStep('보컬 분리 요청 중…'); setGenError(null); try { const res = await removeVocals({ suno_id: track.suno_id, title: track.title, }); if (res?.task_id) { taskIdRef.current = res.task_id; setGenStep('AI가 보컬을 분리하고 있습니다…'); setGenProgress(5); startPolling(res.task_id, `${track.title} (Vocal Removed)`); } } catch { setIsGenerating(false); setGenError('보컬 분리에 실패했습니다'); } }; /* ── 커버 아트 핸들러 ── */ const handleCoverArt = async (track) => { if (!track.task_id || isGenerating) return; setTab('create'); setIsGenerating(true); setTrack(null); setGenProgress(0); setGenStep('커버 이미지 생성 요청 중…'); setGenError(null); try { const res = await generateCoverImage({ suno_task_id: track.task_id, track_id: track.id, }); if (res?.task_id) { taskIdRef.current = res.task_id; setGenStep('AI가 커버 이미지를 생성하고 있습니다…'); setGenProgress(5); clearInterval(pollRef.current); pollRef.current = setInterval(async () => { try { const status = await getMusicStatus(res.task_id); setGenProgress(status.progress ?? 0); setGenStep(status.message ?? '처리 중…'); if (status.status === 'succeeded') { clearInterval(pollRef.current); setIsGenerating(false); const images = JSON.parse(status.audio_url || '[]'); setCoverArtModal({ trackId: track.id, images }); } else if (status.status === 'failed') { clearInterval(pollRef.current); setIsGenerating(false); setGenError(`커버 이미지 생성 실패: ${status.error ?? '알 수 없는 오류'}`); } } catch { clearInterval(pollRef.current); setIsGenerating(false); setGenError('커버 이미지 상태 조회 실패'); } }, 3000); } } catch { setIsGenerating(false); setGenError('커버 이미지 생성에 실패했습니다'); } }; const handleCoverSelect = (imageUrl) => { if (coverArtModal?.trackId) { setLibrary((prev) => prev.map((t) => t.id === coverArtModal.trackId ? { ...t, cover_images: [imageUrl, ...(coverArtModal.images || []).filter(u => u !== imageUrl)] } : t )); } setCoverArtModal(null); }; /* ── WAV 변환 핸들러 ── */ const handleWavConvert = async (track) => { if (!track.task_id || !track.suno_id || isGenerating) return; setTab('create'); setIsGenerating(true); setTrack(null); setGenProgress(0); setGenStep('WAV 변환 요청 중…'); setGenError(null); try { const res = await convertToWav({ suno_task_id: track.task_id, suno_id: track.suno_id, track_id: track.id, }); if (res?.task_id) { taskIdRef.current = res.task_id; setGenStep('WAV 변환 처리 중…'); setGenProgress(5); clearInterval(pollRef.current); pollRef.current = setInterval(async () => { try { const status = await getMusicStatus(res.task_id); setGenProgress(status.progress ?? 0); setGenStep(status.message ?? '처리 중…'); if (status.status === 'succeeded') { clearInterval(pollRef.current); setIsGenerating(false); const wavUrl = status.audio_url; if (wavUrl) { const a = document.createElement('a'); a.href = wavUrl; a.download = `${track.title || 'track'}.wav`; a.click(); } setGenStep('WAV 변환 완료!'); } else if (status.status === 'failed') { clearInterval(pollRef.current); setIsGenerating(false); setGenError(`WAV 변환 실패: ${status.error ?? '알 수 없는 오류'}`); } } catch { clearInterval(pollRef.current); setIsGenerating(false); setGenError('WAV 변환 상태 조회 실패'); } }, 3000); } } catch { setIsGenerating(false); setGenError('WAV 변환에 실패했습니다'); } }; /* ── 12스템 분리 핸들러 ── */ const handleStemSplit = async (track) => { if (!track.task_id || !track.suno_id || isGenerating) return; setTab('create'); setIsGenerating(true); setTrack(null); setGenProgress(0); setGenStep('12스템 분리 요청 중…'); setGenError(null); try { const res = await splitStems({ suno_task_id: track.task_id, suno_id: track.suno_id, track_id: track.id, }); if (res?.task_id) { taskIdRef.current = res.task_id; setGenStep('12스템 분리 처리 중 (약 2~3분)…'); setGenProgress(5); clearInterval(pollRef.current); pollRef.current = setInterval(async () => { try { const status = await getMusicStatus(res.task_id); setGenProgress(status.progress ?? 0); setGenStep(status.message ?? '처리 중…'); if (status.status === 'succeeded') { clearInterval(pollRef.current); setIsGenerating(false); const stems = JSON.parse(status.audio_url || '{}'); setStemModal({ stems }); } else if (status.status === 'failed') { clearInterval(pollRef.current); setIsGenerating(false); setGenError(`스템 분리 실패: ${status.error ?? '알 수 없는 오류'}`); } } catch { clearInterval(pollRef.current); setIsGenerating(false); setGenError('스템 분리 상태 조회 실패'); } }, 3000); } } catch { setIsGenerating(false); setGenError('12스템 분리에 실패했습니다'); } }; /* ── 타임스탬프 가사 핸들러 ── */ const handleSyncedLyrics = async (track) => { if (!track.task_id || !track.suno_id) return; try { const result = await getTimestampedLyrics(track.task_id, track.suno_id); if (result?.alignedWords || result?.aligned_words) { setSyncedLyrics({ audioUrl: track.audio_url, words: result.alignedWords || result.aligned_words, }); } } catch { setGenError('타임스탬프 가사 조회에 실패했습니다'); } }; /* ── 스타일 부스트 핸들러 ── */ const handleStyleBoost = async () => { if (!genre || styleBoostLoading) return; setStyleBoostLoading(true); try { const content = [ GENRES.find(g => g.id === genre)?.label, ...moods.map(id => MOODS.find(m => m.id === id)?.label).filter(Boolean), ].join(', '); const result = await generateStyleBoost(content); if (result?.result) { setPrompt(result.result); } } catch {} finally { setStyleBoostLoading(false); } }; /* ── 뮤직비디오 핸들러 ── */ const handleVideoGenerate = async (track) => { if (!track.task_id || !track.suno_id || isGenerating) return; setTab('create'); setIsGenerating(true); setTrack(null); setGenProgress(0); setGenStep('뮤직비디오 생성 요청 중…'); setGenError(null); try { const res = await generateVideo({ suno_task_id: track.task_id, suno_id: track.suno_id, track_id: track.id, }); if (res?.task_id) { taskIdRef.current = res.task_id; startPolling(res.task_id, `${track.title} (Video)`); } } catch { setIsGenerating(false); setGenError('뮤직비디오 생성에 실패했습니다'); } }; const handleVideoProject = (track) => { setInitialTrackId(track.id); setTab('youtube'); }; const handleVideoPipeline = (track) => { setOpenPipelineFor(track.id); setTab('youtube'); }; const handleNewTrack = () => { setTrack(null); setGenProgress(0); setGenError(null); setCustomTitle(''); clearInterval(pollRef.current); }; const canGenerate = !!genre && !isGenerating; return (
{/* ═══ HEADER ═══ */}

AI · MUSIC · FORGE

Sonic
Forge

AI가 당신의 아이디어를 완성된 음악으로 변환합니다.
장르를 선택하고 감정을 담아 세상에 하나뿐인 트랙을 만드세요.

{/* ═══ TAB NAV ═══ */} {/* ═══ LIBRARY TAB ═══ */} {tab === 'library' && ( )} {/* ═══ LYRICS TAB ═══ */} {tab === 'lyrics' && ( { setLyrics(text); setInstrumental(false); setProvider('suno'); setTab('create'); }} /> )} {/* ═══ REMIX TAB ═══ */} {tab === 'remix' && ( { setTab('create'); setIsGenerating(true); setTrack(null); setGenProgress(0); setGenStep(`${title} 처리 중…`); setGenError(null); taskIdRef.current = taskId; startPolling(taskId, title); }} model={model} isGenerating={isGenerating} /> )} {/* ═══ YOUTUBE TAB ═══ */} {tab === 'youtube' && ( setInitialTrackId(null)} openPipelineFor={openPipelineFor} /> )} {/* ═══ CREATE TAB ═══ */} {tab === 'create' && (
{/* ─── LEFT: Controls ─── */}
{/* Provider Error */} {providerError && (
⚠ 음악 서비스 연결 실패
)} {/* Provider Selector */} {providers.length > 0 && (
{providers.map((p) => ( ))}
)} {/* Model Selector (Suno only) */} {provider === 'suno' && models.length > 0 && (
Model
{models.map((m) => ( ))}
)} {/* Batch Generation Section */}
setBatchOpen(e.currentTarget.open)}> 🎲 배치 생성 (장르 → 1-10트랙 + 자동 영상)

예상: 약 {Math.ceil(batchCount * 1.5)}–{batchCount * 2}분 · {' '}비용 ~${(batchCount * 0.005 + (batchAutoPipe ? 0.05 : 0)).toFixed(2)}

{currentBatch && }
{/* Step 1: Genre */}
01

Genre

장르를 선택하세요 {provider === 'suno' && ( )}

음악의 뼈대와 전체 사운드 방향을 결정합니다.
Lo-Fi — 따뜻한 바이닐 질감과 노이즈가 섞인 느슨한 비트
Electronic — 선명한 신스 레이어와 정밀한 시퀀스
Jazz — 즉흥적 화성 진행과 스윙 리듬
Classical — 현악·피아노 중심의 오케스트레이션
Ambient — 공간감 넘치는 드론과 패드 레이어
Hip-Hop — 샘플 기반의 펀치감 있는 비트
Rock — 기타 드라이브와 강한 다이나믹
Cinematic — 영화 스코어 스타일의 웅장한 편곡

{GENRES.map((g) => ( ))}
{/* Step 2: Mood */}
02

Mood

분위기 최대 3개

음악의 감정 곡선과 에너지 밀도를 조절합니다. 최대 3개 조합 시 복합적인 감정 레이어가 만들어집니다.
Energetic — 강한 어택과 긴장감 있는 리듬, 빠른 전개
Chill — 여백이 많은 느슨한 편곡, 안정적인 흐름
Dark — 단조·불협화음 중심의 긴장감과 무게감
Uplifting — 상승하는 멜로디 라인과 밝은 화성 전개
Romantic — 부드러운 다이나믹과 풍부하고 따뜻한 화성
Epic — 광대한 오케스트레이션과 극적인 다이나믹 전개
Melancholic — 느린 전개의 감성적이고 내성적인 구성

{MOODS.map((m) => ( ))}
{/* Step 3: Instruments */}
03

Instruments

원하는 악기 선택

선택한 악기가 실제 편곡 레이어를 구성합니다. 많을수록 풍성·밀도 있는 사운드, 적을수록 미니멀하고 깔끔한 결과물이 만들어집니다. 선택하지 않으면 장르 최적화 기본 구성으로 생성됩니다.
Piano — 화성 중심의 따뜻한 풍성함 (261Hz 중음역대)
Guitar — 리듬 질감과 어택감 (82Hz 저~중음역대)
Drums — 그루브와 타이밍의 뼈대 (60Hz 타격음)
Synth — 분위기를 감싸는 패드 레이어 (440Hz 넓은 스펙트럼)
Bass — 저음의 그라운드, 리듬감 강화 (41Hz 저음역)
Strings — 감성적 현악 레이어와 서정성 (196Hz)
Brass — 펀치감과 힘, 금관 질감 (146Hz)
Flute · Violin — 섬세하고 표현적인 멜로디 라인
Choir — 공간감과 웅장함, 인간적인 온기

{INSTRUMENTS.map((inst) => ( ))}
{/* Step 4: Parameters */}
04

Parameters

음악 파라미터 설정
{/* Duration */}

30초~1분은 인트로·짧은 BGM, 2~3분은 유튜브 단독 콘텐츠, 5분은 장시간 재생용 환경음악에 최적입니다.

{DURATIONS.map((d) => ( ))}
{/* BPM */}
{bpm}

Slow(60–80)는 명상·환경음악, Mid(90–110)는 카페·집중 BGM, Fast(120–140)는 운동·에너제틱 무드, EDM(150+)는 댄스플로어 에너지에 적합합니다. 슬라이더로 1 BPM 단위 정밀 조정이 가능합니다.

{BPM_PRESETS.map((p) => ( ))}
setBpm(Number(e.target.value))} className="ms-bpm-slider" aria-label="BPM" />
{/* Key + Scale */}

C·G는 밝고 자연스러운 울림, D·A는 따뜻하고 공명하는 음색, F#·B♭는 재즈적 긴장감을 만들어냅니다.

Major는 밝고 긍정적, Minor는 감성적·우울, Dorian은 재즈풍, Phrygian은 어둡고 이국적, Lydian은 몽환적·부유감, Mixolydian은 블루지·록 감성입니다.

{/* Vocal Gender (Suno only) */} {provider === 'suno' && (
{[ { value: null, label: 'Auto', icon: '🎵' }, { value: 'm', label: 'Male', icon: '♂' }, { value: 'f', label: 'Female', icon: '♀' }, ].map((opt) => ( ))}
)} {/* Negative Tags (Suno only) */} {provider === 'suno' && (
{['screaming', 'autotune', 'distortion', 'whisper', 'falsetto', 'rap'].map((tag) => ( ))}
setNegativeTags(e.target.value)} />
)} {/* Style Weight / Audio Weight (Suno only) */} {provider === 'suno' && (
{styleWeight}%

Prompt ↔ Style 밸런스

setStyleWeight(Number(e.target.value))} className="ms-bpm-slider" aria-label="Style Weight" />
{audioWeight}%

Original ↔ AI 밸런스

setAudioWeight(Number(e.target.value))} className="ms-bpm-slider" aria-label="Audio Weight" />
)}
{/* Step 5: Prompt */}
05

Creative Brief

선택사항

AI가 생성할 음악의 방향을 자유롭게 묘사하는 텍스트 프롬프트입니다. 위 파라미터와 결합되어 작동하며, 상충할 경우 파라미터 설정이 우선합니다.
구체적인 장면·감정·레퍼런스 아티스트·분위기 키워드를 포함할수록 더 타겟팅된 결과물이 만들어집니다.
"새벽 4시 도시의 텅 빈 도로, 빗소리와 함께 흐르는 서정적인 피아노"
"Nils Frahm 스타일의 미니멀 피아노, 전자음과 어쿠스틱의 경계"