Files
web-page/src/pages/music/MusicStudio.jsx
gahusb a727bbf153 MusicStudio: Suno/MusicGen 듀얼 프로바이더 UI 추가
- Provider 선택 바 (Suno 🎙️ / MusicGen 🤖)
- Suno 전용: 보컬/인스트루멘탈 토글, 가사 입력, AI 가사 생성
- 라이브러리·결과 카드에 provider 뱃지 표시
- TrackResult에 가사 접기/펼치기 추가
- api.js: getMusicProviders, generateMusicLyrics 함수 추가

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-03 08:26:49 +09:00

1276 lines
64 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,
} 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)}`;
/* ─────────────────────────────────────────────
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 }) => {
const genre = GENRES.find((g) => g.id === track.genre);
const totalSec = track.duration_sec ?? DURATIONS.find((d) => d.id === track.duration)?.sec ?? 60;
return (
<div
className={`ms-lib-card ${isPlaying ? 'is-playing' : ''}`}
style={{ '--lib-accent': genre?.color ?? '#f5a623' }}
>
<div className="ms-lib-card__top">
<span className="ms-lib-card__icon">{genre?.icon ?? '🎵'}</span>
<div className="ms-lib-card__info">
<p className="ms-lib-card__title">{track.title}</p>
<p className="ms-lib-card__meta">
{fmtTime(totalSec)} · {track.bpm} BPM · {track.key} {track.scale}
</p>
</div>
<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>
{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>
<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 }) => {
const [playingId, setPlayingId] = useState(null);
const handlePlay = (track) => {
setPlayingId((prev) => (prev === track.id ? null : track.id));
};
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}
/>
))}
</div>
</div>
);
};
/* ─────────────────────────────────────────────
Main Page
───────────────────────────────────────────── */
export default function MusicStudio() {
/* ── 탭 ── */
const [tab, setTab] = useState('create');
/* ── Provider 상태 ── */
const [providers, setProviders] = useState([]);
const [provider, setProvider] = useState('suno');
/* ── 컨트롤 상태 ── */
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 [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);
if (list.length > 0 && !list.find((p) => p.id === provider)) {
setProvider(list[0].id);
}
})
.catch(() => {});
}, []); // eslint-disable-line react-hooks/exhaustive-deps
/* ── 가사 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,
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 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">
<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 === '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}
onDelete={handleDeleteFromLibrary}
onRefresh={loadLibrary}
/>
)}
{/* ═══ CREATE TAB ═══ */}
{tab === 'create' && (
<div className="ms-layout">
{/* ─── LEFT: Controls ─── */}
<div className="ms-controls">
{/* 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>
)}
{/* 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>
);
}