1937 lines
96 KiB
JavaScript
1937 lines
96 KiB
JavaScript
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(60–80)는 명상·환경음악, Mid(90–110)는 카페·집중 BGM, Fast(120–140)는 운동·에너제틱 무드, 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>
|
||
);
|
||
}
|