feat(music-lab): Phase 2 UI — StemModal, SyncedLyricsPlayer, Style Boost, WAV 변환

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-04-08 09:05:07 +09:00
parent 7a591bb0f1
commit 0849c70644
5 changed files with 349 additions and 2 deletions

View File

@@ -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

View File

@@ -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; }

View File

@@ -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
<div className="ms-more-menu__dropdown">
<button type="button" onClick={() => { onCoverArt(track); setMenuOpen(false); }}
disabled={isGenerating}>🖼 Cover Art</button>
<button type="button" onClick={() => { onWavConvert(track); setMenuOpen(false); }}
disabled={isGenerating}>📀 WAV Download</button>
<button type="button" onClick={() => { onStemSplit(track); setMenuOpen(false); }}
disabled={isGenerating}>🎛 12 Stems (50cr)</button>
<button type="button" onClick={() => { onSyncedLyrics(track); setMenuOpen(false); }}
disabled={isGenerating || !track.lyrics}>📝 Synced Lyrics</button>
</div>
)}
</div>
@@ -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() {
<span className="ms-section__step">01</span>
<h2 className="ms-section__title">Genre</h2>
<span className="ms-section__hint">장르를 선택하세요</span>
{provider === 'suno' && (
<button
type="button"
className={`ms-btn ms-btn--ghost ms-btn--sm ms-style-boost-btn ${styleBoostLoading ? 'is-loading' : ''}`}
onClick={handleStyleBoost}
disabled={styleBoostLoading || !genre}
title="현재 설정으로 최적 스타일 프롬프트 생성"
>
{styleBoostLoading ? '생성 중...' : '✨ Style Boost'}
</button>
)}
<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' : ''}`}>
@@ -1520,6 +1689,21 @@ export default function MusicStudio() {
onClose={() => setCoverArtModal(null)}
/>
)}
{/* ═══ Stem Modal ═══ */}
{stemModal && (
<StemModal stems={stemModal.stems} onClose={() => setStemModal(null)} />
)}
{/* ═══ Synced Lyrics Player ═══ */}
{syncedLyrics && (
<SyncedLyricsPlayer
audioUrl={syncedLyrics.audioUrl}
alignedWords={syncedLyrics.words}
onClose={() => setSyncedLyrics(null)}
accentColor={accentColor}
/>
)}
</div>
);
}

View File

@@ -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 (
<div className="ms-modal-overlay" onClick={onClose}>
<div className="ms-modal ms-modal--wide" onClick={(e) => e.stopPropagation()}>
<div className="ms-modal__header">
<h3 className="ms-modal__title">12 Stems</h3>
<span className="ms-modal__subtitle"> 스템을 개별 재생 다운로드할 있습니다</span>
<button type="button" className="ms-modal__close" onClick={onClose}></button>
</div>
<div className="ms-stem-grid">
{Object.entries(stems).map(([name, url]) => {
if (!url) return null;
const isPlaying = playingStem === name;
return (
<div key={name} className={`ms-stem-card ${isPlaying ? 'is-playing' : ''}`}>
<span className="ms-stem-card__icon">{STEM_ICONS[name] || '🎵'}</span>
<span className="ms-stem-card__name">{name.replace(/_/g, ' ')}</span>
<div className="ms-stem-card__actions">
<button
type="button"
className="ms-btn--icon"
onClick={() => setPlayingStem(isPlaying ? null : name)}
>
{isPlaying ? '■' : '▶'}
</button>
<a href={url} download className="ms-btn--icon" aria-label="다운로드"></a>
</div>
{isPlaying && (
<audio src={url} autoPlay onEnded={() => setPlayingStem(null)} />
)}
</div>
);
})}
</div>
<div className="ms-modal__actions">
<button type="button" className="ms-btn ms-btn--ghost" onClick={onClose}>닫기</button>
</div>
</div>
</div>
);
};
export default StemModal;

View File

@@ -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 (
<div className="ms-synced-player" style={{ '--synced-accent': accentColor }}>
<div className="ms-synced-player__header">
<h4 className="ms-synced-player__title">Synced Lyrics</h4>
<button type="button" className="ms-modal__close" onClick={onClose}></button>
</div>
<audio
ref={audioRef}
src={audioUrl}
onPlay={() => setPlaying(true)}
onPause={() => setPlaying(false)}
onEnded={() => setPlaying(false)}
controls
className="ms-synced-player__audio"
/>
<div className="ms-synced-player__lyrics">
{alignedWords.map((word, idx) => {
const isActive = currentTime >= word.startS && currentTime < word.endS;
const isPast = currentTime >= word.endS;
return (
<span
key={idx}
className={`ms-synced-word ${isActive ? 'is-active' : ''} ${isPast ? 'is-past' : ''}`}
>
{word.word}{' '}
</span>
);
})}
</div>
</div>
);
};
export default SyncedLyricsPlayer;