- Create ↔ Library 사이에 Lyrics 탭 신설 - 프롬프트 입력 (200자) → Suno AI 가사 생성 - 결과 카드: 제목, 가사 텍스트, 프롬프트 표시 - 클립보드 복사 / "Create에서 사용" 버튼 (가사 자동 세팅 후 Create 탭 전환) - 로딩 shimmer, 에러 배너, 빈 상태 UI Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1614 lines
79 KiB
JavaScript
1614 lines
79 KiB
JavaScript
import React, { useCallback, useEffect, useRef, useState } from 'react';
|
||
import {
|
||
deleteMusicTrack,
|
||
generateMusic,
|
||
generateMusicLyrics,
|
||
getMusicLibrary,
|
||
getMusicProviders,
|
||
getMusicStatus,
|
||
getMusicModels,
|
||
getMusicCredits,
|
||
extendMusicTrack,
|
||
removeVocals,
|
||
} from '../../api';
|
||
import './MusicStudio.css';
|
||
|
||
/* ─────────────────────────────────────────────
|
||
데이터 상수
|
||
───────────────────────────────────────────── */
|
||
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 },
|
||
];
|
||
|
||
/* ─────────────────────────────────────────────
|
||
유틸
|
||
───────────────────────────────────────────── */
|
||
const pad = (n) => String(Math.floor(n)).padStart(2, '0');
|
||
const fmtTime = (s) => `${pad(s / 60)}:${pad(s % 60)}`;
|
||
|
||
/* ─────────────────────────────────────────────
|
||
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>
|
||
);
|
||
|
||
/* ─────────────────────────────────────────────
|
||
Audio Player (실제 <audio> 기반)
|
||
───────────────────────────────────────────── */
|
||
const AudioPlayer = ({ audioUrl, totalSec, accentColor }) => {
|
||
const audioRef = useRef(null);
|
||
const [playing, setPlaying] = useState(false);
|
||
const [elapsed, setElapsed] = useState(0);
|
||
const [duration, setDuration] = useState(totalSec ?? 0);
|
||
const [volume, setVolume] = useState(1);
|
||
|
||
/* 실제 오디오가 없으면 가짜 타이머로 폴백 */
|
||
const isFake = !audioUrl;
|
||
const timerRef = useRef(null);
|
||
|
||
const total = duration || totalSec || 60;
|
||
|
||
const togglePlay = () => {
|
||
if (isFake) {
|
||
if (playing) {
|
||
clearInterval(timerRef.current);
|
||
setPlaying(false);
|
||
} else {
|
||
setPlaying(true);
|
||
timerRef.current = setInterval(() => {
|
||
setElapsed((e) => {
|
||
if (e >= total - 1) {
|
||
clearInterval(timerRef.current);
|
||
setPlaying(false);
|
||
return 0;
|
||
}
|
||
return e + 1;
|
||
});
|
||
}, 1000);
|
||
}
|
||
return;
|
||
}
|
||
const el = audioRef.current;
|
||
if (!el) return;
|
||
playing ? el.pause() : el.play();
|
||
};
|
||
|
||
const handleSeek = (e) => {
|
||
const rect = e.currentTarget.getBoundingClientRect();
|
||
const ratio = (e.clientX - rect.left) / rect.width;
|
||
const newTime = ratio * total;
|
||
if (!isFake && audioRef.current) {
|
||
audioRef.current.currentTime = newTime;
|
||
}
|
||
setElapsed(newTime);
|
||
};
|
||
|
||
const handleVolumeChange = (e) => {
|
||
const v = Number(e.target.value);
|
||
setVolume(v);
|
||
if (!isFake && audioRef.current) audioRef.current.volume = v;
|
||
};
|
||
|
||
useEffect(() => () => clearInterval(timerRef.current), []);
|
||
|
||
const progress = (elapsed / total) * 100;
|
||
|
||
return (
|
||
<div className="ms-audio-player" style={{ '--player-accent': accentColor }}>
|
||
{!isFake && (
|
||
<audio
|
||
ref={audioRef}
|
||
src={audioUrl}
|
||
onLoadedMetadata={(e) => setDuration(e.target.duration)}
|
||
onTimeUpdate={(e) => setElapsed(e.target.currentTime)}
|
||
onPlay={() => setPlaying(true)}
|
||
onPause={() => setPlaying(false)}
|
||
onEnded={() => { setPlaying(false); setElapsed(0); }}
|
||
/>
|
||
)}
|
||
<button
|
||
type="button"
|
||
className={`ms-player__play ${playing ? 'is-playing' : ''}`}
|
||
onClick={togglePlay}
|
||
aria-label={playing ? '일시정지' : '재생'}
|
||
>
|
||
{playing ? (
|
||
<svg width="16" height="16" viewBox="0 0 16 16" fill="currentColor">
|
||
<rect x="3" y="2" width="4" height="12" rx="1" />
|
||
<rect x="9" y="2" width="4" height="12" rx="1" />
|
||
</svg>
|
||
) : (
|
||
<svg width="16" height="16" viewBox="0 0 16 16" fill="currentColor">
|
||
<path d="M4 2l10 6-10 6V2z" />
|
||
</svg>
|
||
)}
|
||
</button>
|
||
|
||
<div className="ms-player__timeline">
|
||
<div className="ms-player__bar" onClick={handleSeek} role="slider"
|
||
aria-label="재생 위치" aria-valuenow={Math.round(elapsed)} aria-valuemin={0} aria-valuemax={Math.round(total)}>
|
||
<div className="ms-player__fill" style={{ width: `${progress}%` }} />
|
||
<div className="ms-player__thumb" style={{ left: `${progress}%` }} />
|
||
</div>
|
||
<div className="ms-player__times">
|
||
<span>{fmtTime(elapsed)}</span>
|
||
<span>{fmtTime(total)}</span>
|
||
</div>
|
||
</div>
|
||
|
||
<div className="ms-volume">
|
||
<svg width="14" height="14" viewBox="0 0 14 14" fill="currentColor" aria-hidden>
|
||
<path d="M2 5h2.5l3-3v10l-3-3H2V5zm8.5-1.5a4.5 4.5 0 010 7" stroke="currentColor" strokeWidth="1.2" fill="none" strokeLinecap="round"/>
|
||
</svg>
|
||
<input
|
||
type="range" min={0} max={1} step={0.02} value={volume}
|
||
onChange={handleVolumeChange}
|
||
className="ms-volume__slider"
|
||
aria-label="볼륨"
|
||
/>
|
||
</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, isGenerating }) => {
|
||
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}
|
||
title="이 곡을 이어서 연장합니다"
|
||
>
|
||
⏩ Extend
|
||
</button>
|
||
<button
|
||
type="button"
|
||
className="ms-btn ms-btn--ghost ms-btn--sm"
|
||
onClick={() => onVocalRemoval(track)}
|
||
disabled={isGenerating}
|
||
title="보컬과 인스트루멘탈을 분리합니다"
|
||
>
|
||
🎤 Vocal Split
|
||
</button>
|
||
{track.audio_url && (
|
||
<a href={track.audio_url} download className="ms-btn ms-btn--ghost ms-btn--sm">
|
||
↓ Download
|
||
</a>
|
||
)}
|
||
</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>
|
||
</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, 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}
|
||
isGenerating={isGenerating}
|
||
/>
|
||
))}
|
||
</div>
|
||
</div>
|
||
);
|
||
};
|
||
|
||
/* ─────────────────────────────────────────────
|
||
Lyrics Tab
|
||
───────────────────────────────────────────── */
|
||
const LyricsTab = ({ onUseInCreate }) => {
|
||
const [lyrPrompt, setLyrPrompt] = useState('');
|
||
const [lyrLoading, setLyrLoading] = useState(false);
|
||
const [lyrResults, setLyrResults] = useState([]); // [{text, title}]
|
||
const [lyrError, setLyrError] = useState(null);
|
||
const [copied, setCopied] = useState(null); // index
|
||
|
||
const handleGenerate = async () => {
|
||
if (!lyrPrompt.trim() || lyrLoading) return;
|
||
setLyrLoading(true);
|
||
setLyrError(null);
|
||
try {
|
||
const res = await generateMusicLyrics(lyrPrompt.trim());
|
||
if (res?.text) {
|
||
setLyrResults((prev) => [{ text: res.text, title: res.title || '', prompt: lyrPrompt.trim() }, ...prev]);
|
||
} else {
|
||
setLyrError('가사 생성 결과가 없습니다');
|
||
}
|
||
} catch (e) {
|
||
setLyrError(e.message || '가사 생성에 실패했습니다');
|
||
} finally {
|
||
setLyrLoading(false);
|
||
}
|
||
};
|
||
|
||
const handleCopy = (text, idx) => {
|
||
navigator.clipboard.writeText(text).then(() => {
|
||
setCopied(idx);
|
||
setTimeout(() => setCopied(null), 2000);
|
||
});
|
||
};
|
||
|
||
return (
|
||
<div className="ms-lyrics-tab">
|
||
<div className="ms-lyrics-tab__form">
|
||
<div className="ms-lyrics-tab__head">
|
||
<h2 className="ms-lyrics-tab__title">AI Lyrics Generator</h2>
|
||
<p className="ms-lyrics-tab__desc">
|
||
원하는 분위기, 주제, 스타일을 설명하면 AI가 가사를 작성합니다.
|
||
</p>
|
||
</div>
|
||
|
||
<div className="ms-lyrics-tab__input-wrap">
|
||
<textarea
|
||
className="ms-lyrics-tab__input"
|
||
placeholder="예: 비 오는 밤, 혼자 걷는 도시의 거리를 배경으로 한 감성적인 발라드 가사"
|
||
value={lyrPrompt}
|
||
onChange={(e) => setLyrPrompt(e.target.value)}
|
||
rows={3}
|
||
maxLength={200}
|
||
onKeyDown={(e) => { if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); handleGenerate(); } }}
|
||
/>
|
||
<div className="ms-lyrics-tab__input-footer">
|
||
<span className="ms-lyrics-tab__count">{lyrPrompt.length}/200</span>
|
||
<button
|
||
type="button"
|
||
className={`ms-btn ms-btn--accent ${lyrLoading ? 'is-loading' : ''}`}
|
||
onClick={handleGenerate}
|
||
disabled={!lyrPrompt.trim() || lyrLoading}
|
||
>
|
||
{lyrLoading ? (
|
||
<><span className="ms-btn__spinner" /> 생성 중...</>
|
||
) : (
|
||
'✨ 가사 생성'
|
||
)}
|
||
</button>
|
||
</div>
|
||
</div>
|
||
|
||
{lyrError && (
|
||
<div className="ms-error-banner">
|
||
<span>⚠ {lyrError}</span>
|
||
</div>
|
||
)}
|
||
</div>
|
||
|
||
{lyrResults.length === 0 && !lyrLoading && (
|
||
<div className="ms-lyrics-tab__empty">
|
||
<span className="ms-lyrics-tab__empty-icon">🎤</span>
|
||
<p>프롬프트를 입력하고 가사를 생성해보세요</p>
|
||
<p className="ms-lyrics-tab__empty-hint">
|
||
생성된 가사는 [Verse], [Chorus] 등 섹션 태그가 포함됩니다
|
||
</p>
|
||
</div>
|
||
)}
|
||
|
||
{lyrLoading && (
|
||
<div className="ms-lyrics-tab__loading">
|
||
<div className="ms-lyrics-tab__loading-bar" />
|
||
<p>AI가 가사를 작성하고 있습니다...</p>
|
||
</div>
|
||
)}
|
||
|
||
<div className="ms-lyrics-tab__results">
|
||
{lyrResults.map((item, idx) => (
|
||
<div key={idx} className="ms-lyrics-card">
|
||
<div className="ms-lyrics-card__header">
|
||
{item.title && <h3 className="ms-lyrics-card__title">{item.title}</h3>}
|
||
<span className="ms-lyrics-card__prompt">{item.prompt}</span>
|
||
</div>
|
||
<pre className="ms-lyrics-card__text">{item.text}</pre>
|
||
<div className="ms-lyrics-card__actions">
|
||
<button
|
||
type="button"
|
||
className="ms-btn ms-btn--ghost ms-btn--sm"
|
||
onClick={() => handleCopy(item.text, idx)}
|
||
>
|
||
{copied === idx ? '✓ 복사됨' : '📋 복사'}
|
||
</button>
|
||
<button
|
||
type="button"
|
||
className="ms-btn ms-btn--ghost ms-btn--sm"
|
||
onClick={() => onUseInCreate(item.text)}
|
||
>
|
||
🎵 Create에서 사용
|
||
</button>
|
||
</div>
|
||
</div>
|
||
))}
|
||
</div>
|
||
</div>
|
||
);
|
||
};
|
||
|
||
/* ─────────────────────────────────────────────
|
||
Main Page
|
||
───────────────────────────────────────────── */
|
||
export default function MusicStudio() {
|
||
/* ── 탭 ── */
|
||
const [tab, setTab] = useState('create');
|
||
|
||
/* ── 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('');
|
||
|
||
/* ── Suno 전용 상태 ── */
|
||
const [lyrics, setLyrics] = useState('');
|
||
const [instrumental, setInstrumental] = useState(false);
|
||
const [lyricsLoading, setLyricsLoading] = useState(false);
|
||
const [model, setModel] = useState('V4');
|
||
const [models, setModels] = useState([]);
|
||
const [credits, setCredits] = useState(null);
|
||
|
||
/* ── 생성 상태 ── */
|
||
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 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(() => {});
|
||
getMusicCredits()
|
||
.then((data) => setCredits(data))
|
||
.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), []);
|
||
|
||
/* ── 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 = `${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,
|
||
} : {}),
|
||
};
|
||
|
||
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 handleNewTrack = () => {
|
||
setTrack(null);
|
||
setGenProgress(0);
|
||
setGenError(null);
|
||
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">
|
||
{credits && (
|
||
<div className="ms-credits">
|
||
<span className="ms-credits__label">Credits</span>
|
||
<span className="ms-credits__value">
|
||
{credits.credits_left ?? credits.remaining ?? '—'}
|
||
</span>
|
||
</div>
|
||
)}
|
||
<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>
|
||
</nav>
|
||
|
||
{/* ═══ LIBRARY TAB ═══ */}
|
||
{tab === 'library' && (
|
||
<Library
|
||
tracks={library}
|
||
loading={libLoading}
|
||
onDelete={handleDeleteFromLibrary}
|
||
onRefresh={loadLibrary}
|
||
onExtend={handleExtend}
|
||
onVocalRemoval={handleVocalRemoval}
|
||
isGenerating={isGenerating}
|
||
/>
|
||
)}
|
||
|
||
{/* ═══ LYRICS TAB ═══ */}
|
||
{tab === 'lyrics' && (
|
||
<LyricsTab onUseInCreate={(text) => { setLyrics(text); setInstrumental(false); setProvider('suno'); setTab('create'); }} />
|
||
)}
|
||
|
||
{/* ═══ 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>
|
||
)}
|
||
|
||
{/* 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>
|
||
<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>
|
||
</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>
|
||
|
||
{/* 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>
|
||
)}
|
||
</div>
|
||
);
|
||
}
|