diff --git a/src/api.js b/src/api.js
index 4ec3b55..2fdca69 100644
--- a/src/api.js
+++ b/src/api.js
@@ -341,6 +341,28 @@ export function generateCoverImage(payload) {
return apiPost('/api/music/cover-image', payload);
}
+// ── Phase 2 API ─────────────────────────────────────────────────────────────
+
+// POST /api/music/wav body: { suno_task_id, suno_id, track_id }
+export function convertToWav(payload) {
+ return apiPost('/api/music/wav', payload);
+}
+
+// POST /api/music/stem-split body: { suno_task_id, suno_id, track_id }
+export function splitStems(payload) {
+ return apiPost('/api/music/stem-split', payload);
+}
+
+// GET /api/music/timestamped-lyrics?task_id=...&suno_id=...
+export function getTimestampedLyrics(taskId, sunoId) {
+ return apiGet(`/api/music/timestamped-lyrics?task_id=${encodeURIComponent(taskId)}&suno_id=${encodeURIComponent(sunoId)}`);
+}
+
+// POST /api/music/style-boost body: { content }
+export function generateStyleBoost(content) {
+ return apiPost('/api/music/style-boost', { content });
+}
+
// ── 로또 고도화 API ────────────────────────────────────────────────────────────
// GET /api/lotto/stats/performance
diff --git a/src/pages/music/MusicStudio.css b/src/pages/music/MusicStudio.css
index beb6f6d..8fe9bb3 100644
--- a/src/pages/music/MusicStudio.css
+++ b/src/pages/music/MusicStudio.css
@@ -2535,3 +2535,38 @@
display: block; padding: 8px; text-align: center;
font-family: 'Courier Prime', monospace; font-size: 0.78rem; color: var(--ms-muted);
}
+
+/* ── Stem Modal ─────────────────────────────────────────── */
+.ms-modal--wide { max-width: 680px; }
+.ms-modal__subtitle { font-size: 0.78rem; color: var(--ms-muted); font-family: 'Courier Prime', monospace; }
+.ms-stem-grid { display: grid; grid-template-columns: repeat(3, 1fr); gap: 8px; }
+.ms-stem-card {
+ display: flex; flex-direction: column; align-items: center; gap: 6px;
+ padding: 12px 8px; border-radius: 10px;
+ background: var(--ms-surface2); border: 1px solid var(--ms-line);
+ transition: border-color 0.2s;
+}
+.ms-stem-card.is-playing { border-color: var(--ms-accent); background: rgba(245,166,35,0.08); }
+.ms-stem-card__icon { font-size: 1.4rem; }
+.ms-stem-card__name {
+ font-family: 'Courier Prime', monospace; font-size: 0.72rem;
+ color: var(--ms-muted); text-transform: capitalize;
+}
+.ms-stem-card__actions { display: flex; gap: 6px; }
+
+/* ── Synced Lyrics Player ───────────────────────────────── */
+.ms-synced-player {
+ background: var(--ms-surface); border: 1px solid var(--ms-line);
+ border-radius: 12px; padding: 16px; margin-top: 12px;
+}
+.ms-synced-player__header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 12px; }
+.ms-synced-player__title { font-family: 'Bebas Neue', sans-serif; font-size: 1.1rem; color: var(--ms-text); }
+.ms-synced-player__audio { width: 100%; margin-bottom: 12px; }
+.ms-synced-player__lyrics { line-height: 1.8; font-family: 'Syne', sans-serif; font-size: 0.95rem; }
+.ms-synced-word { color: var(--ms-dim); transition: color 0.15s; }
+.ms-synced-word.is-active { color: var(--synced-accent, var(--ms-accent)); font-weight: 600; }
+.ms-synced-word.is-past { color: var(--ms-muted); }
+
+/* ── Style Boost Button ─────────────────────────────────── */
+.ms-style-boost-btn { margin-left: auto; }
+.ms-style-boost-btn.is-loading { opacity: 0.6; }
diff --git a/src/pages/music/MusicStudio.jsx b/src/pages/music/MusicStudio.jsx
index 0037ecf..7507ea9 100644
--- a/src/pages/music/MusicStudio.jsx
+++ b/src/pages/music/MusicStudio.jsx
@@ -10,6 +10,10 @@ import {
extendMusicTrack,
removeVocals,
generateCoverImage,
+ convertToWav,
+ splitStems,
+ getTimestampedLyrics,
+ generateStyleBoost,
} from '../../api';
import './MusicStudio.css';
import AudioPlayer from './components/AudioPlayer';
@@ -17,6 +21,8 @@ import { fmtTime } from './components/AudioPlayer';
import CreditsBadge from './components/CreditsBadge';
import CoverArtModal from './components/CoverArtModal';
import LyricsTab from './components/LyricsTab';
+import StemModal from './components/StemModal';
+import SyncedLyricsPlayer from './components/SyncedLyricsPlayer';
/* ─────────────────────────────────────────────
데이터 상수
@@ -327,7 +333,7 @@ const TrackResult = ({ track, onDownload, onNew }) => {
/* ─────────────────────────────────────────────
Library Card
───────────────────────────────────────────── */
-const LibraryCard = ({ track, onDelete, onPlay, isPlaying, onExtend, onVocalRemoval, onCoverArt, isGenerating }) => {
+const LibraryCard = ({ track, onDelete, onPlay, isPlaying, onExtend, onVocalRemoval, onCoverArt, onWavConvert, onStemSplit, onSyncedLyrics, isGenerating }) => {
const [menuOpen, setMenuOpen] = useState(false);
const genre = GENRES.find((g) => g.id === track.genre);
const totalSec = track.duration_sec ?? null;
@@ -407,6 +413,12 @@ const LibraryCard = ({ track, onDelete, onPlay, isPlaying, onExtend, onVocalRemo
+
+
+
)}
@@ -429,7 +441,7 @@ const LibraryCard = ({ track, onDelete, onPlay, isPlaying, onExtend, onVocalRemo
/* ─────────────────────────────────────────────
Library Section
───────────────────────────────────────────── */
-const Library = ({ tracks, onDelete, onRefresh, onExtend, onVocalRemoval, onCoverArt, isGenerating, loading }) => {
+const Library = ({ tracks, onDelete, onRefresh, onExtend, onVocalRemoval, onCoverArt, onWavConvert, onStemSplit, onSyncedLyrics, isGenerating, loading }) => {
const [playingId, setPlayingId] = useState(null);
const handlePlay = (track) => {
@@ -479,6 +491,9 @@ const Library = ({ tracks, onDelete, onRefresh, onExtend, onVocalRemoval, onCove
onExtend={onExtend}
onVocalRemoval={onVocalRemoval}
onCoverArt={onCoverArt}
+ onWavConvert={onWavConvert}
+ onStemSplit={onStemSplit}
+ onSyncedLyrics={onSyncedLyrics}
isGenerating={isGenerating}
/>
))}
@@ -525,6 +540,11 @@ export default function MusicStudio() {
/* ── CoverArt 상태 ── */
const [coverArtModal, setCoverArtModal] = useState(null); // { trackId, images }
+ /* ── Phase 2 상태 ── */
+ const [stemModal, setStemModal] = useState(null); // { stems: {} }
+ const [syncedLyrics, setSyncedLyrics] = useState(null); // { audioUrl, words }
+ const [styleBoostLoading, setStyleBoostLoading] = useState(false);
+
/* ── 생성 상태 ── */
const [isGenerating, setIsGenerating] = useState(false);
const [genProgress, setGenProgress] = useState(0);
@@ -871,6 +891,141 @@ export default function MusicStudio() {
setCoverArtModal(null);
};
+ /* ── WAV 변환 핸들러 ── */
+ const handleWavConvert = async (track) => {
+ if (!track.task_id || !track.suno_id || isGenerating) return;
+ setTab('create');
+ setIsGenerating(true);
+ setTrack(null);
+ setGenProgress(0);
+ setGenStep('WAV 변환 요청 중…');
+ setGenError(null);
+ try {
+ const res = await convertToWav({
+ suno_task_id: track.task_id,
+ suno_id: track.suno_id,
+ track_id: track.id,
+ });
+ if (res?.task_id) {
+ taskIdRef.current = res.task_id;
+ setGenStep('WAV 변환 처리 중…');
+ setGenProgress(5);
+ clearInterval(pollRef.current);
+ pollRef.current = setInterval(async () => {
+ try {
+ const status = await getMusicStatus(res.task_id);
+ setGenProgress(status.progress ?? 0);
+ setGenStep(status.message ?? '처리 중…');
+ if (status.status === 'succeeded') {
+ clearInterval(pollRef.current);
+ setIsGenerating(false);
+ const wavUrl = status.audio_url;
+ if (wavUrl) {
+ const a = document.createElement('a');
+ a.href = wavUrl;
+ a.download = `${track.title || 'track'}.wav`;
+ a.click();
+ }
+ setGenStep('WAV 변환 완료!');
+ } else if (status.status === 'failed') {
+ clearInterval(pollRef.current);
+ setIsGenerating(false);
+ setGenError(`WAV 변환 실패: ${status.error ?? '알 수 없는 오류'}`);
+ }
+ } catch {
+ clearInterval(pollRef.current);
+ setIsGenerating(false);
+ setGenError('WAV 변환 상태 조회 실패');
+ }
+ }, 3000);
+ }
+ } catch {
+ setIsGenerating(false);
+ setGenError('WAV 변환에 실패했습니다');
+ }
+ };
+
+ /* ── 12스템 분리 핸들러 ── */
+ const handleStemSplit = async (track) => {
+ if (!track.task_id || !track.suno_id || isGenerating) return;
+ setTab('create');
+ setIsGenerating(true);
+ setTrack(null);
+ setGenProgress(0);
+ setGenStep('12스템 분리 요청 중…');
+ setGenError(null);
+ try {
+ const res = await splitStems({
+ suno_task_id: track.task_id,
+ suno_id: track.suno_id,
+ track_id: track.id,
+ });
+ if (res?.task_id) {
+ taskIdRef.current = res.task_id;
+ setGenStep('12스템 분리 처리 중 (약 2~3분)…');
+ setGenProgress(5);
+ clearInterval(pollRef.current);
+ pollRef.current = setInterval(async () => {
+ try {
+ const status = await getMusicStatus(res.task_id);
+ setGenProgress(status.progress ?? 0);
+ setGenStep(status.message ?? '처리 중…');
+ if (status.status === 'succeeded') {
+ clearInterval(pollRef.current);
+ setIsGenerating(false);
+ const stems = JSON.parse(status.audio_url || '{}');
+ setStemModal({ stems });
+ } else if (status.status === 'failed') {
+ clearInterval(pollRef.current);
+ setIsGenerating(false);
+ setGenError(`스템 분리 실패: ${status.error ?? '알 수 없는 오류'}`);
+ }
+ } catch {
+ clearInterval(pollRef.current);
+ setIsGenerating(false);
+ setGenError('스템 분리 상태 조회 실패');
+ }
+ }, 3000);
+ }
+ } catch {
+ setIsGenerating(false);
+ setGenError('12스템 분리에 실패했습니다');
+ }
+ };
+
+ /* ── 타임스탬프 가사 핸들러 ── */
+ const handleSyncedLyrics = async (track) => {
+ if (!track.task_id || !track.suno_id) return;
+ try {
+ const result = await getTimestampedLyrics(track.task_id, track.suno_id);
+ if (result?.alignedWords || result?.aligned_words) {
+ setSyncedLyrics({
+ audioUrl: track.audio_url,
+ words: result.alignedWords || result.aligned_words,
+ });
+ }
+ } catch {
+ setGenError('타임스탬프 가사 조회에 실패했습니다');
+ }
+ };
+
+ /* ── 스타일 부스트 핸들러 ── */
+ const handleStyleBoost = async () => {
+ if (!genre || styleBoostLoading) return;
+ setStyleBoostLoading(true);
+ try {
+ const content = [
+ GENRES.find(g => g.id === genre)?.label,
+ ...moods.map(id => MOODS.find(m => m.id === id)?.label).filter(Boolean),
+ ].join(', ');
+ const result = await generateStyleBoost(content);
+ if (result?.result) {
+ setPrompt(result.result);
+ }
+ } catch {}
+ finally { setStyleBoostLoading(false); }
+ };
+
const handleNewTrack = () => {
setTrack(null);
setGenProgress(0);
@@ -939,6 +1094,9 @@ export default function MusicStudio() {
onExtend={handleExtend}
onVocalRemoval={handleVocalRemoval}
onCoverArt={handleCoverArt}
+ onWavConvert={handleWavConvert}
+ onStemSplit={handleStemSplit}
+ onSyncedLyrics={handleSyncedLyrics}
isGenerating={isGenerating}
/>
)}
@@ -1018,6 +1176,17 @@ export default function MusicStudio() {
01
Genre
장르를 선택하세요
+ {provider === 'suno' && (
+
+ )}
@@ -1520,6 +1689,21 @@ export default function MusicStudio() {
onClose={() => setCoverArtModal(null)}
/>
)}
+
+ {/* ═══ Stem Modal ═══ */}
+ {stemModal && (
+ setStemModal(null)} />
+ )}
+
+ {/* ═══ Synced Lyrics Player ═══ */}
+ {syncedLyrics && (
+ setSyncedLyrics(null)}
+ accentColor={accentColor}
+ />
+ )}
);
}
diff --git a/src/pages/music/components/StemModal.jsx b/src/pages/music/components/StemModal.jsx
new file mode 100644
index 0000000..c49c5b2
--- /dev/null
+++ b/src/pages/music/components/StemModal.jsx
@@ -0,0 +1,55 @@
+import React, { useState } from 'react';
+
+const STEM_ICONS = {
+ vocal: '🎤', backing_vocals: '🎶', drums: '🥁', bass: '🎸',
+ guitar: '🎸', keyboard: '🎹', strings: '🎻', brass: '🎺',
+ woodwinds: '🪈', percussion: '🪘', synth: '🎛', fx: '✨',
+};
+
+const StemModal = ({ stems, onClose }) => {
+ const [playingStem, setPlayingStem] = useState(null);
+
+ if (!stems || Object.keys(stems).length === 0) return null;
+
+ return (
+
+
e.stopPropagation()}>
+
+
12 Stems
+ 각 스템을 개별 재생 및 다운로드할 수 있습니다
+
+
+
+ {Object.entries(stems).map(([name, url]) => {
+ if (!url) return null;
+ const isPlaying = playingStem === name;
+ return (
+
+
{STEM_ICONS[name] || '🎵'}
+
{name.replace(/_/g, ' ')}
+
+
+
↓
+
+ {isPlaying && (
+
+ );
+ })}
+
+
+
+
+
+
+ );
+};
+
+export default StemModal;
diff --git a/src/pages/music/components/SyncedLyricsPlayer.jsx b/src/pages/music/components/SyncedLyricsPlayer.jsx
new file mode 100644
index 0000000..8c0da9e
--- /dev/null
+++ b/src/pages/music/components/SyncedLyricsPlayer.jsx
@@ -0,0 +1,51 @@
+import React, { useEffect, useRef, useState } from 'react';
+
+const SyncedLyricsPlayer = ({ audioUrl, alignedWords, onClose, accentColor }) => {
+ const audioRef = useRef(null);
+ const [playing, setPlaying] = useState(false);
+ const [currentTime, setCurrentTime] = useState(0);
+
+ useEffect(() => {
+ const el = audioRef.current;
+ if (!el) return;
+ const handler = () => setCurrentTime(el.currentTime);
+ el.addEventListener('timeupdate', handler);
+ return () => el.removeEventListener('timeupdate', handler);
+ }, []);
+
+ if (!alignedWords || alignedWords.length === 0) return null;
+
+ return (
+
+
+
Synced Lyrics
+
+
+
+ );
+};
+
+export default SyncedLyricsPlayer;