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 }) => (
);
/* ─────────────────────────────────────────────
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}
)}
✓ 생성 완료 — 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}
onPlay(track)}
aria-label={isPlaying ? '정지' : '재생'}
>
{isPlaying ? '■' : '▶'}
onDelete(track.id)}
aria-label="삭제"
>
✕
{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 && (
onExtend(track)} disabled={isGenerating}>
⏩ Extend
onVocalRemoval(track)} disabled={isGenerating}>
🎤 Vocal Split
{track.audio_url && (
↓ Download
)}
setMenuOpen(!menuOpen)}>•••
{menuOpen && (
{ onCoverArt(track); setMenuOpen(false); }}
disabled={isGenerating}>🖼 Cover Art
{ onWavConvert(track); setMenuOpen(false); }}
disabled={isGenerating}>📀 WAV Download
{ onStemSplit(track); setMenuOpen(false); }}
disabled={isGenerating}>🎛 12 Stems (50cr)
{ onSyncedLyrics(track); setMenuOpen(false); }}
disabled={isGenerating || !track.lyrics}>📝 Synced Lyrics
{ onVideoGenerate(track); setMenuOpen(false); }}
disabled={isGenerating}>🎬 Music Video
{ onVideoProject(track); setMenuOpen(false); }}>
🎯 YouTube 프로젝트
{ onVideoPipeline(track); setMenuOpen(false); }}>
🎬 영상 파이프라인
)}
)}
{!hasSunoId && track.audio_url && (
)}
{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
↻ Refresh
{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 ═══ */}
{/* ═══ TAB NAV ═══ */}
setTab('create')}
>
⚗ Create
setTab('lyrics')}
>
🎤 Lyrics
setTab('library')}
>
📚 Library
{library.length > 0 && (
{library.length}
)}
setTab('remix')}
>
🔄 Remix
setTab('youtube')}
>
🎯 YouTube
{/* ═══ 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 && (
⚠ 음악 서비스 연결 실패
{
setProviderError(false);
getMusicProviders()
.then((data) => { setProviders(data.providers ?? []); setProviderError(false); })
.catch(() => setProviderError(true));
}}
>
재시도
)}
{/* Provider Selector */}
{providers.length > 0 && (
{providers.map((p) => (
setProvider(p.id)}
>
{p.id === 'suno' ? '🎙️' : '🤖'}
{p.name}
{p.description}
))}
)}
{/* Model Selector (Suno only) */}
{provider === 'suno' && models.length > 0 && (
Model
{models.map((m) => (
setModel(m.id)}
title={m.description || m.name}
>
{m.name}
))}
)}
{/* Batch Generation Section */}
setBatchOpen(e.currentTarget.open)}>
🎲 배치 생성 (장르 → 1-10트랙 + 자동 영상)
{currentBatch && }
{/* Step 1: Genre */}
01
Genre
장르를 선택하세요
{provider === 'suno' && (
{styleBoostLoading ? '생성 중...' : '✨ Style Boost'}
)}
toggleDesc('genre')} aria-label="설명 펼치기">ℹ
음악의 뼈대와 전체 사운드 방향을 결정합니다.
Lo-Fi — 따뜻한 바이닐 질감과 노이즈가 섞인 느슨한 비트
Electronic — 선명한 신스 레이어와 정밀한 시퀀스
Jazz — 즉흥적 화성 진행과 스윙 리듬
Classical — 현악·피아노 중심의 오케스트레이션
Ambient — 공간감 넘치는 드론과 패드 레이어
Hip-Hop — 샘플 기반의 펀치감 있는 비트
Rock — 기타 드라이브와 강한 다이나믹
Cinematic — 영화 스코어 스타일의 웅장한 편곡
{GENRES.map((g) => (
setGenre(g.id)}
aria-pressed={genre === g.id}
>
{g.icon}
{g.label}
{g.desc}
))}
{/* Step 2: Mood */}
02
Mood
분위기 최대 3개
toggleDesc('mood')} aria-label="설명 펼치기">ℹ
음악의 감정 곡선과 에너지 밀도를 조절합니다. 최대 3개 조합 시 복합적인 감정 레이어가 만들어집니다.
Energetic — 강한 어택과 긴장감 있는 리듬, 빠른 전개
Chill — 여백이 많은 느슨한 편곡, 안정적인 흐름
Dark — 단조·불협화음 중심의 긴장감과 무게감
Uplifting — 상승하는 멜로디 라인과 밝은 화성 전개
Romantic — 부드러운 다이나믹과 풍부하고 따뜻한 화성
Epic — 광대한 오케스트레이션과 극적인 다이나믹 전개
Melancholic — 느린 전개의 감성적이고 내성적인 구성
{MOODS.map((m) => (
toggleMood(m.id)}
aria-pressed={moods.includes(m.id)}
>
{m.label}
))}
{/* Step 3: Instruments */}
03
Instruments
원하는 악기 선택
toggleDesc('instruments')} aria-label="설명 펼치기">ℹ
선택한 악기가 실제 편곡 레이어를 구성합니다. 많을수록 풍성·밀도 있는 사운드, 적을수록 미니멀하고 깔끔한 결과물이 만들어집니다. 선택하지 않으면 장르 최적화 기본 구성으로 생성됩니다.
Piano — 화성 중심의 따뜻한 풍성함 (261Hz 중음역대)
Guitar — 리듬 질감과 어택감 (82Hz 저~중음역대)
Drums — 그루브와 타이밍의 뼈대 (60Hz 타격음)
Synth — 분위기를 감싸는 패드 레이어 (440Hz 넓은 스펙트럼)
Bass — 저음의 그라운드, 리듬감 강화 (41Hz 저음역)
Strings — 감성적 현악 레이어와 서정성 (196Hz)
Brass — 펀치감과 힘, 금관 질감 (146Hz)
Flute · Violin — 섬세하고 표현적인 멜로디 라인
Choir — 공간감과 웅장함, 인간적인 온기
{INSTRUMENTS.map((inst) => (
toggleInstrument(inst.id)}
aria-pressed={instruments.includes(inst.id)}
>
{inst.label}
{inst.freq}
))}
{/* Step 4: Parameters */}
04
Parameters
음악 파라미터 설정
{/* Duration */}
Duration
toggleDesc('duration')} aria-label="설명 펼치기">?
30초~1분은 인트로·짧은 BGM, 2~3분은 유튜브 단독 콘텐츠, 5분은 장시간 재생용 환경음악에 최적입니다.
{DURATIONS.map((d) => (
setDuration(d.id)}
>
{d.label}
))}
{/* BPM */}
BPM
toggleDesc('bpm')} aria-label="설명 펼치기">?
{bpm}
Slow(60–80)는 명상·환경음악, Mid(90–110)는 카페·집중 BGM, Fast(120–140)는 운동·에너제틱 무드, EDM(150+)는 댄스플로어 에너지에 적합합니다. 슬라이더로 1 BPM 단위 정밀 조정이 가능합니다.
{BPM_PRESETS.map((p) => (
setBpm(p.bpm)}
>
{p.label} {p.bpm}
))}
setBpm(Number(e.target.value))}
className="ms-bpm-slider"
aria-label="BPM"
/>
{/* Key + Scale */}
Key
toggleDesc('key')} aria-label="설명 펼치기">?
C·G는 밝고 자연스러운 울림, D·A는 따뜻하고 공명하는 음색, F#·B♭는 재즈적 긴장감을 만들어냅니다.
setMusicalKey(e.target.value)}
>
{KEYS.map((k) => (
{k}
))}
Scale
toggleDesc('scale')} aria-label="설명 펼치기">?
Major는 밝고 긍정적, Minor는 감성적·우울, Dorian은 재즈풍, Phrygian은 어둡고 이국적, Lydian은 몽환적·부유감, Mixolydian은 블루지·록 감성입니다.
setScale(e.target.value)}
>
{SCALES.map((s) => (
{s}
))}
{/* Vocal Gender (Suno only) */}
{provider === 'suno' && (
Vocal Gender
{[
{ value: null, label: 'Auto', icon: '🎵' },
{ value: 'm', label: 'Male', icon: '♂' },
{ value: 'f', label: 'Female', icon: '♀' },
].map((opt) => (
setVocalGender(opt.value)}
>
{opt.icon}
{opt.label}
))}
)}
{/* Negative Tags (Suno only) */}
{provider === 'suno' && (
)}
{/* Style Weight / Audio Weight (Suno only) */}
{provider === 'suno' && (
Style Weight
{styleWeight}%
Prompt ↔ Style 밸런스
setStyleWeight(Number(e.target.value))}
className="ms-bpm-slider"
aria-label="Style Weight"
/>
Audio Weight
{audioWeight}%
Original ↔ AI 밸런스
setAudioWeight(Number(e.target.value))}
className="ms-bpm-slider"
aria-label="Audio Weight"
/>
)}
{/* Step 5: Prompt */}
05
Creative Brief
선택사항
toggleDesc('prompt')} aria-label="설명 펼치기">ℹ
AI가 생성할 음악의 방향을 자유롭게 묘사하는 텍스트 프롬프트입니다. 위 파라미터와 결합되어 작동하며, 상충할 경우 파라미터 설정이 우선합니다.
구체적인 장면·감정·레퍼런스 아티스트·분위기 키워드를 포함할수록 더 타겟팅된 결과물이 만들어집니다.
"새벽 4시 도시의 텅 빈 도로, 빗소리와 함께 흐르는 서정적인 피아노"
"Nils Frahm 스타일의 미니멀 피아노, 전자음과 어쿠스틱의 경계"
{/* Step 6: Vocals & Lyrics (Suno only) */}
{provider === 'suno' && (
06
Vocals & Lyrics
Suno 전용
setInstrumental(false)}
>
🎤 보컬 포함
setInstrumental(true)}
>
🎹 인스트루멘탈
{!instrumental && (
<>
가사
{lyricsLoading ? '생성 중...' : '✨ AI 가사 생성'}
>
)}
)}
{/* ─── RIGHT: Stage ─── */}
{/* Stage waveform */}
{!genre && !isGenerating && !track && (
장르를 선택하면 여기서 음악이 깨어납니다
)}
{genre && !isGenerating && !track && (
{activeGenre?.icon}
{activeGenre?.label}
{moods.length > 0 && (
{moods.map((id) => MOODS.find((m) => m.id === id)?.label).join(' · ')}
)}
)}
{/* Track title input */}
{!track && (
setCustomTitle(e.target.value)}
maxLength={80}
/>
)}
{/* Generate button */}
{!track && (
{isGenerating ? (
) : (
)}
{isGenerating ? 'Generating…' : `Generate Track${provider === 'suno' ? ' (Suno)' : ''}`}
)}
{/* Progress */}
{isGenerating && (
)}
{/* Error */}
{genError && !isGenerating && (
⚠ {genError}
재시도
)}
{/* Result */}
{track && !isGenerating && (
)}
{/* Spec chips */}
{genre && !track && !isGenerating && (
Genre
{activeGenre?.label}
Duration
{DURATIONS.find((d) => d.id === duration)?.label}
BPM
{bpm}
Key
{musicalKey} {scale}
{instruments.length > 0 && (
Instruments
{instruments.length}개
)}
)}
)}
{coverArtModal && (
setCoverArtModal(null)}
/>
)}
{/* ═══ Stem Modal ═══ */}
{stemModal && (
setStemModal(null)} />
)}
{/* ═══ Synced Lyrics Player ═══ */}
{syncedLyrics && (
setSyncedLyrics(null)}
accentColor={accentColor}
/>
)}
{tab === 'library' && (
setTab('create')} label="음악 생성" />
)}
);
}