Files
web-page/src/pages/music/MusicStudio.jsx
gahusb c9e29bdad9 Music: AI 작사(Lyrics) 전용 탭 추가
- Create ↔ Library 사이에 Lyrics 탭 신설
- 프롬프트 입력 (200자) → Suno AI 가사 생성
- 결과 카드: 제목, 가사 텍스트, 프롬프트 표시
- 클립보드 복사 / "Create에서 사용" 버튼 (가사 자동 세팅 후 Create 탭 전환)
- 로딩 shimmer, 에러 배너, 빈 상태 UI

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-05 19:02:05 +09:00

1614 lines
79 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import React, { useCallback, useEffect, useRef, useState } from 'react';
import {
deleteMusicTrack,
generateMusic,
generateMusicLyrics,
getMusicLibrary,
getMusicProviders,
getMusicStatus,
getMusicModels,
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(6080) 명상·환경음악, Mid(90110) 카페·집중 BGM, Fast(120140) 운동·에너제틱 무드, EDM(150+) 댄스플로어 에너지에 적합합니다. 슬라이더로 1 BPM 단위 정밀 조정이 가능합니다.</p>
</div>
<div className="ms-bpm-presets">
{BPM_PRESETS.map((p) => (
<button
key={p.label}
type="button"
className={`ms-bpm-preset ${bpm === p.bpm ? 'is-active' : ''}`}
onClick={() => setBpm(p.bpm)}
>
{p.label} <span>{p.bpm}</span>
</button>
))}
</div>
<input
type="range" min={40} max={200} value={bpm}
onChange={(e) => setBpm(Number(e.target.value))}
className="ms-bpm-slider"
aria-label="BPM"
/>
</div>
{/* Key + Scale */}
<div className="ms-param-grid">
<div className="ms-param-group">
<div className="ms-param-label-row">
<label className="ms-param-label">Key</label>
<button type="button" className={`ms-desc-toggle ms-desc-toggle--sm ${isOpen('key') ? 'is-open' : ''}`} onClick={() => toggleDesc('key')} aria-label="설명 펼치기">?</button>
</div>
<div className={`ms-desc-wrap ${isOpen('key') ? 'is-open' : ''}`}>
<p className="ms-param-hint">C·G는 밝고 자연스러운 울림, D·A는 따뜻하고 공명하는 음색, F#·B 재즈적 긴장감을 만들어냅니다.</p>
</div>
<div className="ms-select-wrap">
<select
className="ms-select"
value={musicalKey}
onChange={(e) => setMusicalKey(e.target.value)}
>
{KEYS.map((k) => (
<option key={k} value={k}>{k}</option>
))}
</select>
</div>
</div>
<div className="ms-param-group">
<div className="ms-param-label-row">
<label className="ms-param-label">Scale</label>
<button type="button" className={`ms-desc-toggle ms-desc-toggle--sm ${isOpen('scale') ? 'is-open' : ''}`} onClick={() => toggleDesc('scale')} aria-label="설명 펼치기">?</button>
</div>
<div className={`ms-desc-wrap ${isOpen('scale') ? 'is-open' : ''}`}>
<p className="ms-param-hint">Major는 밝고 긍정적, Minor는 감성적·우울, Dorian은 재즈풍, Phrygian은 어둡고 이국적, Lydian은 몽환적·부유감, Mixolydian은 블루지· 감성입니다.</p>
</div>
<div className="ms-select-wrap">
<select
className="ms-select"
value={scale}
onChange={(e) => setScale(e.target.value)}
>
{SCALES.map((s) => (
<option key={s} value={s}>{s}</option>
))}
</select>
</div>
</div>
</div>
</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>
);
}