Files
web-page/src/pages/music/MusicStudio.jsx

1937 lines
96 KiB
JavaScript
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, { 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 = () => (
<div className="ms-lib-card ms-lib-card--skeleton" aria-hidden>
<div className="ms-lib-card__header">
<span className="ms-skel ms-skel--icon" />
<span className="ms-skel ms-skel--title" />
<span className="ms-skel ms-skel--btn" />
</div>
<div className="ms-lib-card__sub">
<span className="ms-skel ms-skel--filename" />
<span className="ms-skel ms-skel--meta" />
</div>
<div className="ms-lib-card__tags">
<span className="ms-skel ms-skel--tag" />
<span className="ms-skel ms-skel--tag" />
</div>
</div>
);
/* ─────────────────────────────────────────────
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 <canvas ref={canvasRef} className="ms-waveform-canvas" />;
};
/* ─────────────────────────────────────────────
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 }) => (
<div
className={`ms-radar ${isGenerating ? 'is-active' : ''}`}
style={{ '--radar-accent': accentColor }}
aria-hidden
>
{/* SVG — 링·크로스헤어·스윕 */}
<svg className="ms-radar__svg" viewBox="0 0 160 160">
{/* 가이드 링 3개 */}
<circle cx="80" cy="80" r="70" className="ms-radar__ring ms-radar__ring--outer" />
<circle cx="80" cy="80" r="52" className="ms-radar__ring ms-radar__ring--mid" />
<circle cx="80" cy="80" r="26" className="ms-radar__ring ms-radar__ring--inner" />
{/* 크로스헤어 틱 마크 */}
<line x1="80" y1="6" x2="80" y2="20" className="ms-radar__tick" />
<line x1="80" y1="140" x2="80" y2="154" className="ms-radar__tick" />
<line x1="6" y1="80" x2="20" y2="80" className="ms-radar__tick" />
<line x1="140" y1="80" x2="154" y2="80" className="ms-radar__tick" />
{/* 대각선 코너 틱 */}
<line x1="19" y1="19" x2="27" y2="27" className="ms-radar__tick ms-radar__tick--dim" />
<line x1="141" y1="19" x2="133" y2="27" className="ms-radar__tick ms-radar__tick--dim" />
<line x1="19" y1="141" x2="27" y2="133" className="ms-radar__tick ms-radar__tick--dim" />
<line x1="141" y1="141" x2="133" y2="133" className="ms-radar__tick ms-radar__tick--dim" />
{/* 레이더 스윕 라인 (generating 시 회전) */}
<line x1="80" y1="80" x2="80" y2="10" className="ms-radar__sweep" />
{/* 센터 글로우 링 */}
<circle cx="80" cy="80" r="14" className="ms-radar__center-ring" />
</svg>
{/* CSS 방사형 바 (HTML div) */}
{RADAR_DATA.map((bar, i) => (
<div
key={i}
className="ms-radar__pivot"
style={{ '--angle': bar.angle }}
>
<div
className="ms-radar__bar"
style={{ '--delay': bar.delay, '--rnd': bar.rnd }}
/>
</div>
))}
{/* 센터 도트 */}
<div className="ms-radar__center" />
{/* 상태 레이블 */}
<div className="ms-radar__status">
<span className="ms-radar__status-dot" />
{isGenerating ? 'GENERATING' : 'STANDBY'}
</div>
</div>
);
/* ─────────────────────────────────────────────
Progress Bar
───────────────────────────────────────────── */
const GenerationProgress = ({ progress, stepMsg }) => (
<div className="ms-progress">
<div className="ms-progress__bar">
<div className="ms-progress__fill" style={{ width: `${progress}%` }} />
</div>
<div className="ms-progress__meta">
<span className="ms-progress__msg">{stepMsg}</span>
<span className="ms-progress__pct">{progress}%</span>
</div>
</div>
);
/* ─────────────────────────────────────────────
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 (
<div className="ms-result" style={{ '--result-accent': genre?.color }}>
<div className="ms-result__header">
<span className="ms-result__badge"> Generated</span>
<span className="ms-result__time">{track.created_at ?? track.createdAt}</span>
</div>
<div className="ms-result__title-row">
<span className="ms-result__icon">{genre?.icon}</span>
<div>
<h3 className="ms-result__title">{track.title}</h3>
<p className="ms-result__meta">
{fmtTime(totalSec)} · {track.bpm} BPM · {track.key} {track.scale}
</p>
</div>
</div>
<AudioPlayer
audioUrl={track.audio_url}
totalSec={totalSec}
accentColor={genre?.color ?? '#f5a623'}
/>
<div className="ms-result__tags">
{track.provider && (
<span className={`ms-result__tag ms-result__tag--provider ${track.provider === 'suno' ? 'is-suno' : 'is-local'}`}>
{track.provider === 'suno' ? '🎙️ Suno' : '🤖 MusicGen'}
</span>
)}
{(track.instruments ?? []).slice(0, 4).map((inst) => (
<span key={inst} className="ms-result__tag">{inst}</span>
))}
{track.moods?.map?.((m) => (
<span key={m} className="ms-result__tag">{m}</span>
))}
</div>
{track.lyrics && (
<details className="ms-result__lyrics">
<summary>🎤 가사 보기</summary>
<pre className="ms-result__lyrics-text">{track.lyrics}</pre>
</details>
)}
<div className="ms-result__actions">
<button type="button" className="ms-btn ms-btn--ghost" onClick={onNew}>
+ New Track
</button>
{track.audio_url && (
<a href={track.audio_url} download className="ms-btn ms-btn--outline">
Download
</a>
)}
</div>
<p className="ms-result__yt-hint">
생성 완료 Library에 자동 저장되었습니다
</p>
</div>
);
};
/* ─────────────────────────────────────────────
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 (
<div
className={`ms-lib-card ${isPlaying ? 'is-playing' : ''}`}
style={{ '--lib-accent': genre?.color ?? '#f5a623' }}
>
<div className="ms-lib-card__header">
<span className="ms-lib-card__icon">{genre?.icon ?? '🎵'}</span>
<p className="ms-lib-card__title">{track.title}</p>
<div className="ms-lib-card__controls">
<button
type="button"
className={`ms-btn--icon ${isPlaying ? 'is-active' : ''}`}
onClick={() => onPlay(track)}
aria-label={isPlaying ? '정지' : '재생'}
>
{isPlaying ? '■' : '▶'}
</button>
<button
type="button"
className="ms-btn--icon ms-btn--danger"
onClick={() => onDelete(track.id)}
aria-label="삭제"
>
</button>
</div>
</div>
<div className="ms-lib-card__sub">
<p className="ms-lib-card__filename">{filename}</p>
<p className="ms-lib-card__meta">
{totalSec != null ? fmtTime(totalSec) : '--:--'} · {track.bpm ? `${track.bpm} BPM` : ''} {track.key} {track.scale}
</p>
</div>
{isPlaying && (
<AudioPlayer
audioUrl={track.audio_url}
totalSec={totalSec}
accentColor={genre?.color ?? '#f5a623'}
/>
)}
<div className="ms-lib-card__tags">
{track.provider && (
<span className={`ms-result__tag ms-result__tag--provider ${track.provider === 'suno' ? 'is-suno' : 'is-local'}`}>
{track.provider === 'suno' ? '🎙️ Suno' : '🤖 MusicGen'}
</span>
)}
{(track.instruments ?? []).slice(0, 3).map((i) => (
<span key={i} className="ms-result__tag">{i}</span>
))}
{(track.moods ?? []).slice(0, 2).map((m) => (
<span key={m} className="ms-result__tag">{m}</span>
))}
</div>
{hasSunoId && (
<div className="ms-lib-card__actions">
<button type="button" className="ms-btn ms-btn--ghost ms-btn--sm"
onClick={() => onExtend(track)} disabled={isGenerating}>
Extend
</button>
<button type="button" className="ms-btn ms-btn--ghost ms-btn--sm"
onClick={() => onVocalRemoval(track)} disabled={isGenerating}>
🎤 Vocal Split
</button>
{track.audio_url && (
<a href={track.audio_url} download className="ms-btn ms-btn--ghost ms-btn--sm"> Download</a>
)}
<div className="ms-more-menu">
<button type="button" className="ms-btn ms-btn--ghost ms-btn--sm"
onClick={() => setMenuOpen(!menuOpen)}></button>
{menuOpen && (
<div className="ms-more-menu__dropdown">
<button type="button" onClick={() => { onCoverArt(track); setMenuOpen(false); }}
disabled={isGenerating}>🖼 Cover Art</button>
<button type="button" onClick={() => { onWavConvert(track); setMenuOpen(false); }}
disabled={isGenerating}>📀 WAV Download</button>
<button type="button" onClick={() => { onStemSplit(track); setMenuOpen(false); }}
disabled={isGenerating}>🎛 12 Stems (50cr)</button>
<button type="button" onClick={() => { onSyncedLyrics(track); setMenuOpen(false); }}
disabled={isGenerating || !track.lyrics}>📝 Synced Lyrics</button>
<button type="button" onClick={() => { onVideoGenerate(track); setMenuOpen(false); }}
disabled={isGenerating}>🎬 Music Video</button>
<button type="button" onClick={() => { onVideoProject(track); setMenuOpen(false); }}>
🎯 YouTube 프로젝트
</button>
<button type="button" onClick={() => { onVideoPipeline(track); setMenuOpen(false); }}>
🎬 영상 파이프라인
</button>
</div>
)}
</div>
</div>
)}
{!hasSunoId && track.audio_url && (
<div className="ms-lib-card__actions">
<a href={track.audio_url} download className="ms-btn ms-btn--ghost ms-btn--sm">
Download
</a>
<button type="button" className="ms-btn ms-btn--ghost ms-btn--sm"
onClick={() => onVideoPipeline(track)}>
🎬 영상 파이프라인
</button>
</div>
)}
<p className="ms-lib-card__date">
{track.created_at ? new Date(track.created_at).toLocaleDateString('ko-KR') : ''}
</p>
</div>
);
};
/* ─────────────────────────────────────────────
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 (
<div className="ms-library">
<div className="ms-library__header">
<h2 className="ms-library__title">My Library</h2>
</div>
<div className="ms-library__grid">
{Array.from({ length: 4 }, (_, i) => <SkeletonCard key={i} />)}
</div>
</div>
);
}
if (tracks.length === 0) {
return (
<div className="ms-library ms-library--empty">
<div className="ms-library__empty-icon">🎵</div>
<p className="ms-library__empty-text">라이브러리가 비어있습니다</p>
<p className="ms-library__empty-hint">트랙을 생성하고 Library에 저장하면 여기서 확인할 있습니다</p>
</div>
);
}
return (
<div className="ms-library">
<div className="ms-library__header">
<h2 className="ms-library__title">My Library</h2>
<span className="ms-library__count">{tracks.length} tracks</span>
<button type="button" className="ms-btn ms-btn--ghost ms-library__refresh" onClick={onRefresh}>
Refresh
</button>
</div>
<div className="ms-library__grid">
{tracks.map((track) => (
<LibraryCard
key={track.id}
track={track}
onDelete={onDelete}
onPlay={handlePlay}
isPlaying={playingId === track.id}
onExtend={onExtend}
onVocalRemoval={onVocalRemoval}
onCoverArt={onCoverArt}
onWavConvert={onWavConvert}
onStemSplit={onStemSplit}
onSyncedLyrics={onSyncedLyrics}
onVideoGenerate={onVideoGenerate}
onVideoProject={onVideoProject}
onVideoPipeline={onVideoPipeline}
isGenerating={isGenerating}
/>
))}
</div>
</div>
);
};
/* ─────────────────────────────────────────────
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 (
<div className="ms" style={{ '--ms-accent': accentColor }}>
{/* ═══ HEADER ═══ */}
<header className="ms-header">
<div className="ms-header__left">
<p className="ms-header__kicker">AI · MUSIC · FORGE</p>
<h1 className="ms-header__title">
Sonic<br /><em>Forge</em>
</h1>
<p className="ms-header__desc">
AI가 당신의 아이디어를 완성된 음악으로 변환합니다.<br />
장르를 선택하고 감정을 담아 세상에 하나뿐인 트랙을 만드세요.
</p>
</div>
<div className="ms-header__right">
<CreditsBadge />
<SonicRadar isGenerating={isGenerating} accentColor={accentColor} />
</div>
</header>
{/* ═══ TAB NAV ═══ */}
<nav className="ms-tabs">
<button
type="button"
className={`ms-tab ${tab === 'create' ? 'is-active' : ''}`}
onClick={() => setTab('create')}
>
<span className="ms-tab__icon"></span> Create
</button>
<button
type="button"
className={`ms-tab ${tab === 'lyrics' ? 'is-active' : ''}`}
onClick={() => setTab('lyrics')}
>
<span className="ms-tab__icon">🎤</span> Lyrics
</button>
<button
type="button"
className={`ms-tab ${tab === 'library' ? 'is-active' : ''}`}
onClick={() => setTab('library')}
>
<span className="ms-tab__icon">📚</span> Library
{library.length > 0 && (
<span className="ms-tab__badge">{library.length}</span>
)}
</button>
<button
type="button"
className={`ms-tab ${tab === 'remix' ? 'is-active' : ''}`}
onClick={() => setTab('remix')}
>
<span className="ms-tab__icon">🔄</span> Remix
</button>
<button
type="button"
className={`ms-tab ms-tab--youtube ${tab === 'youtube' ? 'is-active' : ''}`}
onClick={() => setTab('youtube')}
>
<span className="ms-tab__icon">🎯</span> YouTube
</button>
</nav>
{/* ═══ LIBRARY TAB ═══ */}
{tab === 'library' && (
<PullToRefresh onRefresh={loadLibrary}>
<Library
tracks={library}
loading={libLoading}
onDelete={handleDeleteFromLibrary}
onRefresh={loadLibrary}
onExtend={handleExtend}
onVocalRemoval={handleVocalRemoval}
onCoverArt={handleCoverArt}
onWavConvert={handleWavConvert}
onStemSplit={handleStemSplit}
onSyncedLyrics={handleSyncedLyrics}
onVideoGenerate={handleVideoGenerate}
onVideoProject={handleVideoProject}
onVideoPipeline={handleVideoPipeline}
isGenerating={isGenerating}
/>
</PullToRefresh>
)}
{/* ═══ LYRICS TAB ═══ */}
{tab === 'lyrics' && (
<LyricsTab onUseInCreate={(text) => { setLyrics(text); setInstrumental(false); setProvider('suno'); setTab('create'); }} />
)}
{/* ═══ REMIX TAB ═══ */}
{tab === 'remix' && (
<RemixTab
onTaskStarted={(taskId, title) => {
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' && (
<YoutubeTab
library={library}
initialTrackId={initialTrackId}
onClearInitialTrack={() => setInitialTrackId(null)}
openPipelineFor={openPipelineFor}
/>
)}
{/* ═══ CREATE TAB ═══ */}
{tab === 'create' && (
<div className="ms-layout">
{/* ─── LEFT: Controls ─── */}
<div className="ms-controls">
{/* Provider Error */}
{providerError && (
<div className="ms-error-banner">
<span> 음악 서비스 연결 실패</span>
<button type="button" className="ms-btn ms-btn--ghost ms-btn--sm"
onClick={() => {
setProviderError(false);
getMusicProviders()
.then((data) => { setProviders(data.providers ?? []); setProviderError(false); })
.catch(() => setProviderError(true));
}}
>
재시도
</button>
</div>
)}
{/* Provider Selector */}
{providers.length > 0 && (
<div className="ms-provider-bar">
{providers.map((p) => (
<button
key={p.id}
type="button"
className={`ms-provider-btn ${provider === p.id ? 'is-active' : ''}`}
onClick={() => setProvider(p.id)}
>
<span className="ms-provider-btn__icon">
{p.id === 'suno' ? '🎙️' : '🤖'}
</span>
<span className="ms-provider-btn__name">{p.name}</span>
<span className="ms-provider-btn__desc">{p.description}</span>
</button>
))}
</div>
)}
{/* Model Selector (Suno only) */}
{provider === 'suno' && models.length > 0 && (
<div className="ms-model-bar">
<span className="ms-model-bar__label">Model</span>
<div className="ms-model-bar__options">
{models.map((m) => (
<button
key={m.id}
type="button"
className={`ms-model-btn ${model === m.id ? 'is-active' : ''}`}
onClick={() => setModel(m.id)}
title={m.description || m.name}
>
{m.name}
</button>
))}
</div>
</div>
)}
{/* Batch Generation Section */}
<details className="ms-batch-section" open={batchOpen} onToggle={(e) => setBatchOpen(e.currentTarget.open)}>
<summary>🎲 배치 생성 (장르 1-10트랙 + 자동 영상)</summary>
<div className="ms-batch-form">
<label>장르
<select value={batchGenre} onChange={e => setBatchGenre(e.target.value)}>
{batchGenresList.map(g => (
<option key={g} value={g}>
{g.split('-').map(s => s.charAt(0).toUpperCase() + s.slice(1)).join('-')}
</option>
))}
</select>
</label>
<label>트랙 : <strong>{batchCount}</strong>
<input type="range" min={1} max={10} value={batchCount}
onChange={e => setBatchCount(parseInt(e.target.value))} />
</label>
<label>트랙당 길이: <strong>{batchDuration}</strong>
<input type="range" min={60} max={300} step={10} value={batchDuration}
onChange={e => setBatchDuration(parseInt(e.target.value))} />
</label>
<label className="ms-batch-checkbox">
<input type="checkbox" checked={batchAutoPipe}
onChange={e => setBatchAutoPipe(e.target.checked)} />
모든 트랙 생성 자동 영상 파이프라인 시작
</label>
<p className="ms-batch-estimate">
예상: {Math.ceil(batchCount * 1.5)}{batchCount * 2} ·
{' '}비용 ~${(batchCount * 0.005 + (batchAutoPipe ? 0.05 : 0)).toFixed(2)}
</p>
<button className="button primary" onClick={startBatch}
disabled={batchPolling}>
🎵 배치 생성 시작
</button>
</div>
{currentBatch && <BatchProgress batch={currentBatch} />}
</details>
{/* Step 1: Genre */}
<section className="ms-section">
<div className="ms-section__head">
<span className="ms-section__step">01</span>
<h2 className="ms-section__title">Genre</h2>
<span className="ms-section__hint">장르를 선택하세요</span>
{provider === 'suno' && (
<button
type="button"
className={`ms-btn ms-btn--ghost ms-btn--sm ms-style-boost-btn ${styleBoostLoading ? 'is-loading' : ''}`}
onClick={handleStyleBoost}
disabled={styleBoostLoading || !genre}
title="현재 설정으로 최적 스타일 프롬프트 생성"
>
{styleBoostLoading ? '생성 중...' : '✨ Style Boost'}
</button>
)}
<button type="button" className={`ms-desc-toggle ${isOpen('genre') ? 'is-open' : ''}`} onClick={() => toggleDesc('genre')} aria-label="설명 펼치기"></button>
</div>
<div className={`ms-desc-wrap ${isOpen('genre') ? 'is-open' : ''}`}>
<p className="ms-section__desc">
음악의 뼈대와 전체 사운드 방향을 결정합니다.<br />
<strong>Lo-Fi</strong> — 따뜻한 바이닐 질감과 노이즈가 섞인 느슨한 비트<br />
<strong>Electronic</strong> — 선명한 신스 레이어와 정밀한 시퀀스<br />
<strong>Jazz</strong> — 즉흥적 화성 진행과 스윙 리듬<br />
<strong>Classical</strong> — 현악·피아노 중심의 오케스트레이션<br />
<strong>Ambient</strong> — 공간감 넘치는 드론과 패드 레이어<br />
<strong>Hip-Hop</strong> — 샘플 기반의 펀치감 있는 비트<br />
<strong>Rock</strong> — 기타 드라이브와 강한 다이나믹<br />
<strong>Cinematic</strong>
</p>
</div>
<div className="ms-genre-grid">
{GENRES.map((g) => (
<button
key={g.id}
type="button"
className={`ms-genre-card ${genre === g.id ? 'is-active' : ''}`}
style={{ '--g-color': g.color }}
onClick={() => setGenre(g.id)}
aria-pressed={genre === g.id}
>
<span className="ms-genre-card__icon">{g.icon}</span>
<span className="ms-genre-card__label">{g.label}</span>
<span className="ms-genre-card__desc">{g.desc}</span>
</button>
))}
</div>
</section>
{/* Step 2: Mood */}
<section className="ms-section">
<div className="ms-section__head">
<span className="ms-section__step">02</span>
<h2 className="ms-section__title">Mood</h2>
<span className="ms-section__hint">분위기 최대 3</span>
<button type="button" className={`ms-desc-toggle ${isOpen('mood') ? 'is-open' : ''}`} onClick={() => toggleDesc('mood')} aria-label="설명 펼치기"></button>
</div>
<div className={`ms-desc-wrap ${isOpen('mood') ? 'is-open' : ''}`}>
<p className="ms-section__desc">
음악의 감정 곡선과 에너지 밀도를 조절합니다. 최대 3 조합 복합적인 감정 레이어가 만들어집니다.<br />
<strong>Energetic</strong> — 강한 어택과 긴장감 있는 리듬, 빠른 전개<br />
<strong>Chill</strong> — 여백이 많은 느슨한 편곡, 안정적인 흐름<br />
<strong>Dark</strong> — 단조·불협화음 중심의 긴장감과 무게감<br />
<strong>Uplifting</strong> — 상승하는 멜로디 라인과 밝은 화성 전개<br />
<strong>Romantic</strong> — 부드러운 다이나믹과 풍부하고 따뜻한 화성<br />
<strong>Epic</strong> — 광대한 오케스트레이션과 극적인 다이나믹 전개<br />
<strong>Melancholic</strong>
</p>
</div>
<div className="ms-mood-rack">
{MOODS.map((m) => (
<button
key={m.id}
type="button"
className={`ms-mood-chip ${moods.includes(m.id) ? 'is-active' : ''}`}
style={{ '--m-color': m.color }}
onClick={() => toggleMood(m.id)}
aria-pressed={moods.includes(m.id)}
>
{m.label}
</button>
))}
</div>
</section>
{/* Step 3: Instruments */}
<section className="ms-section">
<div className="ms-section__head">
<span className="ms-section__step">03</span>
<h2 className="ms-section__title">Instruments</h2>
<span className="ms-section__hint">원하는 악기 선택</span>
<button type="button" className={`ms-desc-toggle ${isOpen('instruments') ? 'is-open' : ''}`} onClick={() => toggleDesc('instruments')} aria-label="설명 펼치기"></button>
</div>
<div className={`ms-desc-wrap ${isOpen('instruments') ? 'is-open' : ''}`}>
<p className="ms-section__desc">
선택한 악기가 실제 편곡 레이어를 구성합니다. 많을수록 풍성·밀도 있는 사운드, 적을수록 미니멀하고 깔끔한 결과물이 만들어집니다. 선택하지 않으면 장르 최적화 기본 구성으로 생성됩니다.<br />
<strong>Piano</strong> — 화성 중심의 따뜻한 풍성함 (261Hz 중음역대)<br />
<strong>Guitar</strong> — 리듬 질감과 어택감 (82Hz 저~중음역대)<br />
<strong>Drums</strong> — 그루브와 타이밍의 뼈대 (60Hz 타격음)<br />
<strong>Synth</strong> — 분위기를 감싸는 패드 레이어 (440Hz 넓은 스펙트럼)<br />
<strong>Bass</strong> — 저음의 그라운드, 리듬감 강화 (41Hz 저음역)<br />
<strong>Strings</strong> — 감성적 현악 레이어와 서정성 (196Hz)<br />
<strong>Brass</strong> — 펀치감과 힘, 금관 질감 (146Hz)<br />
<strong>Flute · Violin</strong> — 섬세하고 표현적인 멜로디 라인<br />
<strong>Choir</strong> ,
</p>
</div>
<div className="ms-instrument-rack">
{INSTRUMENTS.map((inst) => (
<button
key={inst.id}
type="button"
className={`ms-instrument-chip ${instruments.includes(inst.id) ? 'is-active' : ''}`}
onClick={() => toggleInstrument(inst.id)}
aria-pressed={instruments.includes(inst.id)}
>
<span className="ms-instrument-chip__label">{inst.label}</span>
<span className="ms-instrument-chip__freq">{inst.freq}</span>
</button>
))}
</div>
</section>
{/* Step 4: Parameters */}
<section className="ms-section">
<div className="ms-section__head">
<span className="ms-section__step">04</span>
<h2 className="ms-section__title">Parameters</h2>
<span className="ms-section__hint">음악 파라미터 설정</span>
</div>
{/* Duration */}
<div className="ms-param-group">
<div className="ms-param-label-row">
<label className="ms-param-label">Duration</label>
<button type="button" className={`ms-desc-toggle ms-desc-toggle--sm ${isOpen('duration') ? 'is-open' : ''}`} onClick={() => toggleDesc('duration')} aria-label="설명 펼치기">?</button>
</div>
<div className={`ms-desc-wrap ${isOpen('duration') ? 'is-open' : ''}`}>
<p className="ms-param-hint">30~1분은 인트로·짧은 BGM, 2~3분은 유튜브 단독 콘텐츠, 5분은 장시간 재생용 환경음악에 최적입니다.</p>
</div>
<div className="ms-duration-rail">
{DURATIONS.map((d) => (
<button
key={d.id}
type="button"
className={`ms-duration-btn ${duration === d.id ? 'is-active' : ''}`}
onClick={() => setDuration(d.id)}
>
{d.label}
</button>
))}
</div>
</div>
{/* BPM */}
<div className="ms-param-group">
<div className="ms-param-row">
<div className="ms-param-label-row">
<label className="ms-param-label">BPM</label>
<button type="button" className={`ms-desc-toggle ms-desc-toggle--sm ${isOpen('bpm') ? 'is-open' : ''}`} onClick={() => toggleDesc('bpm')} aria-label="설명 펼치기">?</button>
</div>
<span className="ms-param-value">{bpm}</span>
</div>
<div className={`ms-desc-wrap ${isOpen('bpm') ? 'is-open' : ''}`}>
<p className="ms-param-hint">Slow(6080) 명상·환경음악, Mid(90110) 카페·집중 BGM, Fast(120140) 운동·에너제틱 무드, EDM(150+) 댄스플로어 에너지에 적합합니다. 슬라이더로 1 BPM 단위 정밀 조정이 가능합니다.</p>
</div>
<div className="ms-bpm-presets">
{BPM_PRESETS.map((p) => (
<button
key={p.label}
type="button"
className={`ms-bpm-preset ${bpm === p.bpm ? 'is-active' : ''}`}
onClick={() => setBpm(p.bpm)}
>
{p.label} <span>{p.bpm}</span>
</button>
))}
</div>
<input
type="range" min={40} max={200} value={bpm}
onChange={(e) => setBpm(Number(e.target.value))}
className="ms-bpm-slider"
aria-label="BPM"
/>
</div>
{/* Key + Scale */}
<div className="ms-param-grid">
<div className="ms-param-group">
<div className="ms-param-label-row">
<label className="ms-param-label">Key</label>
<button type="button" className={`ms-desc-toggle ms-desc-toggle--sm ${isOpen('key') ? 'is-open' : ''}`} onClick={() => toggleDesc('key')} aria-label="설명 펼치기">?</button>
</div>
<div className={`ms-desc-wrap ${isOpen('key') ? 'is-open' : ''}`}>
<p className="ms-param-hint">C·G는 밝고 자연스러운 울림, D·A는 따뜻하고 공명하는 음색, F#·B 재즈적 긴장감을 만들어냅니다.</p>
</div>
<div className="ms-select-wrap">
<select
className="ms-select"
value={musicalKey}
onChange={(e) => setMusicalKey(e.target.value)}
>
{KEYS.map((k) => (
<option key={k} value={k}>{k}</option>
))}
</select>
</div>
</div>
<div className="ms-param-group">
<div className="ms-param-label-row">
<label className="ms-param-label">Scale</label>
<button type="button" className={`ms-desc-toggle ms-desc-toggle--sm ${isOpen('scale') ? 'is-open' : ''}`} onClick={() => toggleDesc('scale')} aria-label="설명 펼치기">?</button>
</div>
<div className={`ms-desc-wrap ${isOpen('scale') ? 'is-open' : ''}`}>
<p className="ms-param-hint">Major는 밝고 긍정적, Minor는 감성적·우울, Dorian은 재즈풍, Phrygian은 어둡고 이국적, Lydian은 몽환적·부유감, Mixolydian은 블루지· 감성입니다.</p>
</div>
<div className="ms-select-wrap">
<select
className="ms-select"
value={scale}
onChange={(e) => setScale(e.target.value)}
>
{SCALES.map((s) => (
<option key={s} value={s}>{s}</option>
))}
</select>
</div>
</div>
</div>
{/* Vocal Gender (Suno only) */}
{provider === 'suno' && (
<div className="ms-param-group">
<label className="ms-param-label">Vocal Gender</label>
<div className="ms-gender-toggle">
{[
{ value: null, label: 'Auto', icon: '🎵' },
{ value: 'm', label: 'Male', icon: '♂' },
{ value: 'f', label: 'Female', icon: '♀' },
].map((opt) => (
<button
key={opt.label}
type="button"
className={`ms-gender-btn ${vocalGender === opt.value ? 'is-active' : ''} ${opt.value === 'm' ? 'is-male' : opt.value === 'f' ? 'is-female' : ''}`}
onClick={() => setVocalGender(opt.value)}
>
<span className="ms-gender-btn__icon">{opt.icon}</span>
{opt.label}
</button>
))}
</div>
</div>
)}
{/* Negative Tags (Suno only) */}
{provider === 'suno' && (
<div className="ms-param-group">
<label className="ms-param-label">Exclude Styles</label>
<div className="ms-negative-tags">
<div className="ms-negative-tags__presets">
{['screaming', 'autotune', 'distortion', 'whisper', 'falsetto', 'rap'].map((tag) => (
<button
key={tag}
type="button"
className={`ms-neg-chip ${negativeTags.includes(tag) ? 'is-active' : ''}`}
onClick={() => {
setNegativeTags((prev) => {
const tags = prev.split(',').map(t => t.trim()).filter(Boolean);
if (tags.includes(tag)) return tags.filter(t => t !== tag).join(', ');
return [...tags, tag].join(', ');
});
}}
>
{tag}
</button>
))}
</div>
<input
type="text"
className="ms-negative-tags__input"
placeholder="추가로 제외할 스타일을 입력..."
value={negativeTags}
onChange={(e) => setNegativeTags(e.target.value)}
/>
</div>
</div>
)}
{/* Style Weight / Audio Weight (Suno only) */}
{provider === 'suno' && (
<div className="ms-param-grid">
<div className="ms-param-group">
<div className="ms-param-row">
<label className="ms-param-label">Style Weight</label>
<span className="ms-param-value">{styleWeight}%</span>
</div>
<p className="ms-param-hint ms-param-hint--inline">Prompt Style 밸런스</p>
<input
type="range" min={0} max={100} value={styleWeight}
onChange={(e) => setStyleWeight(Number(e.target.value))}
className="ms-bpm-slider"
aria-label="Style Weight"
/>
</div>
<div className="ms-param-group">
<div className="ms-param-row">
<label className="ms-param-label">Audio Weight</label>
<span className="ms-param-value">{audioWeight}%</span>
</div>
<p className="ms-param-hint ms-param-hint--inline">Original AI 밸런스</p>
<input
type="range" min={0} max={100} value={audioWeight}
onChange={(e) => setAudioWeight(Number(e.target.value))}
className="ms-bpm-slider"
aria-label="Audio Weight"
/>
</div>
</div>
)}
</section>
{/* Step 5: Prompt */}
<section className="ms-section">
<div className="ms-section__head">
<span className="ms-section__step">05</span>
<h2 className="ms-section__title">Creative Brief</h2>
<span className="ms-section__hint">선택사항</span>
<button type="button" className={`ms-desc-toggle ${isOpen('prompt') ? 'is-open' : ''}`} onClick={() => toggleDesc('prompt')} aria-label="설명 펼치기"></button>
</div>
<div className={`ms-desc-wrap ${isOpen('prompt') ? 'is-open' : ''}`}>
<p className="ms-section__desc">
AI가 생성할 음악의 방향을 자유롭게 묘사하는 텍스트 프롬프트입니다. 파라미터와 결합되어 작동하며, 상충할 경우 파라미터 설정이 우선합니다.<br />
구체적인 장면·감정·레퍼런스 아티스트·분위기 키워드를 포함할수록 타겟팅된 결과물이 만들어집니다.<br />
<em>"새벽 4시 도시의 텅 빈 도로, 빗소리와 함께 흐르는 서정적인 피아노"</em><br />
<em>"Nils Frahm 스타일의 미니멀 피아노, 전자음과 어쿠스틱의 경계"</em>
</p>
</div>
<div className="ms-prompt-wrap">
<textarea
className="ms-prompt"
placeholder="원하는 음악을 자유롭게 묘사하세요. 예: 새벽 카페에서 커피를 마시며 읽는 책의 분위기, 빗소리가 배경으로 깔리는 재즈…"
value={prompt}
onChange={(e) => setPrompt(e.target.value)}
rows={4}
maxLength={500}
/>
<span className="ms-prompt__count">{prompt.length}/500</span>
</div>
</section>
{/* Step 6: Vocals & Lyrics (Suno only) */}
{provider === 'suno' && (
<section className="ms-section">
<div className="ms-section__head">
<span className="ms-section__step">06</span>
<h2 className="ms-section__title">Vocals & Lyrics</h2>
<span className="ms-section__hint">Suno 전용</span>
</div>
<div className="ms-vocal-toggle">
<button
type="button"
className={`ms-vocal-btn ${!instrumental ? 'is-active' : ''}`}
onClick={() => setInstrumental(false)}
>
🎤 보컬 포함
</button>
<button
type="button"
className={`ms-vocal-btn ${instrumental ? 'is-active' : ''}`}
onClick={() => setInstrumental(true)}
>
🎹 인스트루멘탈
</button>
</div>
{!instrumental && (
<>
<div className="ms-lyrics-wrap">
<div className="ms-lyrics-header">
<label className="ms-param-label">가사</label>
<button
type="button"
className="ms-btn ms-btn--ghost ms-btn--sm"
onClick={handleGenerateLyrics}
disabled={lyricsLoading || (!prompt && !genre)}
>
{lyricsLoading ? '생성 중...' : '✨ AI 가사 생성'}
</button>
</div>
<textarea
className="ms-lyrics"
placeholder={"[Verse]\n여기에 가사를 입력하세요...\n\n[Chorus]\n후렴구 가사...\n\n비워두면 AI가 자동으로 가사를 작성합니다."}
value={lyrics}
onChange={(e) => setLyrics(e.target.value)}
rows={8}
/>
<p className="ms-lyrics-hint">
섹션 태그: [Verse], [Chorus], [Bridge], [Outro], [Instrumental]
</p>
</div>
</>
)}
</section>
)}
</div>
{/* ─── RIGHT: Stage ─── */}
<div className="ms-stage">
{/* Stage waveform */}
<div className="ms-stage__viz">
<WaveformCanvas isGenerating={isGenerating} accentColor={accentColor} />
<div className="ms-stage__overlay">
{!genre && !isGenerating && !track && (
<p className="ms-stage__idle">장르를 선택하면<br />여기서 음악이 깨어납니다</p>
)}
{genre && !isGenerating && !track && (
<div className="ms-stage__ready">
<span className="ms-stage__ready-icon">{activeGenre?.icon}</span>
<p className="ms-stage__ready-label">{activeGenre?.label}</p>
{moods.length > 0 && (
<p className="ms-stage__ready-moods">
{moods.map((id) => MOODS.find((m) => m.id === id)?.label).join(' · ')}
</p>
)}
</div>
)}
</div>
</div>
{/* Track title input */}
{!track && (
<div className="ms-title-input-wrap">
<input
type="text"
className="ms-title-input"
placeholder="트랙 제목 (비워두면 자동 생성)"
value={customTitle}
onChange={(e) => setCustomTitle(e.target.value)}
maxLength={80}
/>
</div>
)}
{/* Generate button */}
{!track && (
<button
type="button"
className={`ms-generate-btn ${canGenerate ? 'is-ready' : ''} ${isGenerating ? 'is-generating' : ''}`}
onClick={handleGenerate}
disabled={!canGenerate}
>
<span className="ms-generate-btn__ring" aria-hidden />
<span className="ms-generate-btn__core">
{isGenerating ? (
<span className="ms-generate-btn__spinner" aria-hidden />
) : (
<svg width="28" height="28" viewBox="0 0 28 28" fill="none" aria-hidden>
<path d="M14 4v20M4 14h20" stroke="currentColor" strokeWidth="2" strokeLinecap="round"/>
<circle cx="14" cy="14" r="12" stroke="currentColor" strokeWidth="1.5" opacity="0.4"/>
</svg>
)}
</span>
<span className="ms-generate-btn__label">
{isGenerating ? 'Generating…' : `Generate Track${provider === 'suno' ? ' (Suno)' : ''}`}
</span>
</button>
)}
{/* Progress */}
{isGenerating && (
<GenerationProgress progress={genProgress} stepMsg={genStep} />
)}
{/* Error */}
{genError && !isGenerating && (
<div className="ms-gen-error">
<span> {genError}</span>
<button type="button" className="ms-btn ms-btn--ghost" onClick={handleNewTrack}>
재시도
</button>
</div>
)}
{/* Result */}
{track && !isGenerating && (
<TrackResult
track={track}
onNew={handleNewTrack}
/>
)}
{/* Spec chips */}
{genre && !track && !isGenerating && (
<div className="ms-stage__spec">
<div className="ms-spec-chip">
<span className="ms-spec-chip__label">Genre</span>
<span className="ms-spec-chip__val">{activeGenre?.label}</span>
</div>
<div className="ms-spec-chip">
<span className="ms-spec-chip__label">Duration</span>
<span className="ms-spec-chip__val">
{DURATIONS.find((d) => d.id === duration)?.label}
</span>
</div>
<div className="ms-spec-chip">
<span className="ms-spec-chip__label">BPM</span>
<span className="ms-spec-chip__val">{bpm}</span>
</div>
<div className="ms-spec-chip">
<span className="ms-spec-chip__label">Key</span>
<span className="ms-spec-chip__val">{musicalKey} {scale}</span>
</div>
{instruments.length > 0 && (
<div className="ms-spec-chip">
<span className="ms-spec-chip__label">Instruments</span>
<span className="ms-spec-chip__val">{instruments.length}</span>
</div>
)}
</div>
)}
</div>
</div>
)}
{coverArtModal && (
<CoverArtModal
images={coverArtModal.images}
onSelect={handleCoverSelect}
onClose={() => setCoverArtModal(null)}
/>
)}
{/* ═══ Stem Modal ═══ */}
{stemModal && (
<StemModal stems={stemModal.stems} onClose={() => setStemModal(null)} />
)}
{/* ═══ Synced Lyrics Player ═══ */}
{syncedLyrics && (
<SyncedLyricsPlayer
audioUrl={syncedLyrics.audioUrl}
alignedWords={syncedLyrics.words}
onClose={() => setSyncedLyrics(null)}
accentColor={accentColor}
/>
)}
{tab === 'library' && (
<FAB onClick={() => setTab('create')} label="음악 생성" />
)}
</div>
);
}