From 0849c70644e91ce2233bb0626909ba91641c30b1 Mon Sep 17 00:00:00 2001 From: gahusb Date: Wed, 8 Apr 2026 09:05:07 +0900 Subject: [PATCH] =?UTF-8?q?feat(music-lab):=20Phase=202=20UI=20=E2=80=94?= =?UTF-8?q?=20StemModal,=20SyncedLyricsPlayer,=20Style=20Boost,=20WAV=20?= =?UTF-8?q?=EB=B3=80=ED=99=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.6 --- src/api.js | 22 ++ src/pages/music/MusicStudio.css | 35 ++++ src/pages/music/MusicStudio.jsx | 188 +++++++++++++++++- src/pages/music/components/StemModal.jsx | 55 +++++ .../music/components/SyncedLyricsPlayer.jsx | 51 +++++ 5 files changed, 349 insertions(+), 2 deletions(-) create mode 100644 src/pages/music/components/StemModal.jsx create mode 100644 src/pages/music/components/SyncedLyricsPlayer.jsx 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;