diff --git a/src/api.js b/src/api.js
index 9de8f3f..6bedae5 100644
--- a/src/api.js
+++ b/src/api.js
@@ -334,6 +334,62 @@ export function deleteLyrics(id) {
return apiDelete(`/api/music/lyrics/library/${id}`);
}
+// ── Phase 1: 커버 이미지 ────────────────────────────────────────────────────
+
+// POST /api/music/cover-image body: { suno_task_id, track_id }
+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 });
+}
+
+// ── Phase 3 API ─────────────────────────────────────────────────────────────
+
+// POST /api/music/upload-cover
+export function uploadAndCover(payload) {
+ return apiPost('/api/music/upload-cover', payload);
+}
+
+// POST /api/music/upload-extend
+export function uploadAndExtend(payload) {
+ return apiPost('/api/music/upload-extend', payload);
+}
+
+// POST /api/music/add-vocals
+export function addVocals(payload) {
+ return apiPost('/api/music/add-vocals', payload);
+}
+
+// POST /api/music/add-instrumental
+export function addInstrumental(payload) {
+ return apiPost('/api/music/add-instrumental', payload);
+}
+
+// POST /api/music/video
+export function generateVideo(payload) {
+ return apiPost('/api/music/video', payload);
+}
+
// ── 로또 고도화 API ────────────────────────────────────────────────────────────
// GET /api/lotto/stats/performance
diff --git a/src/pages/music/MusicStudio.css b/src/pages/music/MusicStudio.css
index 89df6ed..1f2f59a 100644
--- a/src/pages/music/MusicStudio.css
+++ b/src/pages/music/MusicStudio.css
@@ -2426,3 +2426,173 @@
animation: none !important;
}
}
+
+/* ── Phase 1: Credits Badge ─────────────────────────────── */
+.ms-credits-badge {
+ display: inline-flex; align-items: center; gap: 6px;
+ padding: 6px 14px; border-radius: 20px;
+ background: rgba(245, 166, 35, 0.1);
+ border: 1px solid rgba(245, 166, 35, 0.25);
+ font-family: 'Courier Prime', monospace;
+ font-size: 0.85rem; color: var(--ms-accent);
+}
+.ms-credits-badge__icon { font-size: 1rem; }
+.ms-credits-badge__value { font-weight: 700; font-size: 1.1rem; }
+.ms-credits-badge__label { color: var(--ms-muted); font-size: 0.75rem; text-transform: uppercase; }
+.ms-credits-badge.is-low {
+ background: rgba(231, 76, 60, 0.15);
+ border-color: rgba(231, 76, 60, 0.4);
+ color: #e74c3c;
+ animation: pulse-badge 1.5s ease-in-out infinite;
+}
+@keyframes pulse-badge {
+ 0%, 100% { opacity: 1; }
+ 50% { opacity: 0.6; }
+}
+
+/* ── Phase 1: Vocal Gender Toggle ───────────────────────── */
+.ms-gender-toggle { display: flex; gap: 6px; }
+.ms-gender-btn {
+ flex: 1; padding: 8px 12px; border-radius: 8px;
+ background: var(--ms-surface); border: 1px solid var(--ms-line);
+ color: var(--ms-muted); font-family: 'Syne', sans-serif;
+ font-size: 0.82rem; cursor: pointer; transition: all 0.2s;
+ display: flex; align-items: center; gap: 6px; justify-content: center;
+}
+.ms-gender-btn:hover { border-color: var(--ms-accent); color: var(--ms-text); }
+.ms-gender-btn.is-active { background: rgba(245, 166, 35, 0.12); border-color: var(--ms-accent); color: var(--ms-text); }
+.ms-gender-btn.is-active.is-male { background: rgba(74, 158, 255, 0.12); border-color: #4a9eff; color: #4a9eff; }
+.ms-gender-btn.is-active.is-female { background: rgba(255, 107, 157, 0.12); border-color: #ff6b9d; color: #ff6b9d; }
+.ms-gender-btn__icon { font-size: 1.1rem; }
+
+/* ── Phase 1: Negative Tags ─────────────────────────────── */
+.ms-negative-tags { display: flex; flex-direction: column; gap: 8px; }
+.ms-negative-tags__presets { display: flex; flex-wrap: wrap; gap: 6px; }
+.ms-neg-chip {
+ padding: 4px 12px; border-radius: 14px;
+ background: var(--ms-surface); border: 1px solid var(--ms-line);
+ color: var(--ms-muted); font-size: 0.78rem; cursor: pointer;
+ font-family: 'Syne', sans-serif; transition: all 0.2s;
+}
+.ms-neg-chip:hover { border-color: #e74c3c; color: var(--ms-text); }
+.ms-neg-chip.is-active {
+ background: rgba(231, 76, 60, 0.12); border-color: #e74c3c; color: #e74c3c;
+ text-decoration: line-through;
+}
+.ms-negative-tags__input {
+ padding: 8px 12px; border-radius: 8px;
+ background: var(--ms-surface); border: 1px solid var(--ms-line);
+ color: var(--ms-text); font-family: 'Syne', sans-serif; font-size: 0.82rem;
+}
+.ms-negative-tags__input::placeholder { color: var(--ms-dim); }
+.ms-param-hint--inline {
+ font-size: 0.72rem; color: var(--ms-dim); margin: 0 0 4px;
+ font-family: 'Courier Prime', monospace;
+}
+
+/* ── More Menu ──────────────────────────────────────────── */
+.ms-more-menu { position: relative; }
+.ms-more-menu__dropdown {
+ position: absolute; bottom: 100%; right: 0;
+ background: var(--ms-surface2); border: 1px solid var(--ms-line);
+ border-radius: 8px; padding: 4px; min-width: 160px; z-index: 20;
+ box-shadow: 0 8px 24px rgba(0,0,0,0.4);
+}
+.ms-more-menu__dropdown button {
+ display: block; width: 100%; padding: 8px 12px; border: none;
+ background: none; color: var(--ms-text); font-size: 0.82rem;
+ font-family: 'Syne', sans-serif; cursor: pointer; text-align: left;
+ border-radius: 6px;
+}
+.ms-more-menu__dropdown button:hover { background: rgba(245,166,35,0.1); }
+.ms-more-menu__dropdown button:disabled { opacity: 0.4; cursor: not-allowed; }
+
+/* ── Modal ──────────────────────────────────────────────── */
+.ms-modal-overlay {
+ position: fixed; inset: 0; background: rgba(0,0,0,0.7);
+ display: flex; align-items: center; justify-content: center; z-index: 100;
+}
+.ms-modal {
+ background: var(--ms-surface); border: 1px solid var(--ms-line);
+ border-radius: 16px; padding: 24px; max-width: 520px; width: 90%;
+ max-height: 90vh; overflow-y: auto;
+}
+.ms-modal__header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 16px; }
+.ms-modal__title { font-family: 'Bebas Neue', sans-serif; font-size: 1.3rem; color: var(--ms-text); }
+.ms-modal__close { background: none; border: none; color: var(--ms-muted); font-size: 1.2rem; cursor: pointer; }
+.ms-modal__actions { display: flex; gap: 8px; margin-top: 16px; justify-content: flex-end; }
+
+/* ── Cover Art Grid ─────────────────────────────────────── */
+.ms-cover-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 12px; }
+.ms-cover-option {
+ border: 2px solid var(--ms-line); border-radius: 12px; overflow: hidden;
+ cursor: pointer; background: none; padding: 0; transition: border-color 0.2s;
+}
+.ms-cover-option:hover { border-color: var(--ms-accent); }
+.ms-cover-option.is-selected { border-color: var(--ms-accent); box-shadow: 0 0 12px rgba(245,166,35,0.3); }
+.ms-cover-option__img { width: 100%; aspect-ratio: 1; object-fit: cover; display: block; }
+.ms-cover-option__label {
+ 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; }
+
+/* ── Remix Tab ──────────────────────────────────────────── */
+.ms-remix-tab { display: flex; flex-direction: column; gap: 20px; }
+.ms-remix-tab__header { }
+.ms-remix-tab__title { font-family: 'Bebas Neue', sans-serif; font-size: 1.8rem; color: var(--ms-text); }
+.ms-remix-tab__desc { font-size: 0.85rem; color: var(--ms-muted); }
+
+.ms-remix-actions { display: grid; grid-template-columns: 1fr 1fr; gap: 10px; }
+.ms-remix-card {
+ display: flex; flex-direction: column; align-items: center; gap: 6px;
+ padding: 20px 12px; border-radius: 12px; cursor: pointer;
+ background: var(--ms-surface); border: 1px solid var(--ms-line);
+ transition: all 0.2s; text-align: center;
+}
+.ms-remix-card:hover { border-color: var(--ms-accent); background: var(--ms-surface2); }
+.ms-remix-card.is-active { border-color: var(--ms-accent); background: rgba(245,166,35,0.08); }
+.ms-remix-card__icon { font-size: 2rem; }
+.ms-remix-card__label { font-family: 'Bebas Neue', sans-serif; font-size: 1.1rem; color: var(--ms-text); }
+.ms-remix-card__desc { font-size: 0.72rem; color: var(--ms-muted); font-family: 'Courier Prime', monospace; }
+
+.ms-remix-params {
+ display: flex; flex-direction: column; gap: 12px;
+ padding: 16px; border-radius: 12px;
+ background: var(--ms-surface); border: 1px solid var(--ms-line);
+}
+.ms-remix-submit { align-self: flex-start; margin-top: 8px; }
diff --git a/src/pages/music/MusicStudio.jsx b/src/pages/music/MusicStudio.jsx
index fb0d61d..8521672 100644
--- a/src/pages/music/MusicStudio.jsx
+++ b/src/pages/music/MusicStudio.jsx
@@ -7,15 +7,24 @@ import {
getMusicProviders,
getMusicStatus,
getMusicModels,
- getMusicCredits,
extendMusicTrack,
removeVocals,
- getSavedLyrics,
- saveLyrics,
- updateLyrics,
- deleteLyrics,
+ generateCoverImage,
+ convertToWav,
+ splitStems,
+ getTimestampedLyrics,
+ generateStyleBoost,
+ generateVideo,
} from '../../api';
import './MusicStudio.css';
+import AudioPlayer from './components/AudioPlayer';
+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';
+import RemixTab from './components/RemixTab';
/* ─────────────────────────────────────────────
데이터 상수
@@ -83,12 +92,6 @@ const SIM_STEPS = [
{ 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
───────────────────────────────────────────── */
@@ -261,125 +264,6 @@ const GenerationProgress = ({ progress, stepMsg }) => (
);
-/* ─────────────────────────────────────────────
- Audio Player (실제
- {credits && (
-
- Credits
-
- {credits.credits_left ?? credits.remaining ?? '—'}
-
-
- )}
+
@@ -1226,6 +1112,13 @@ export default function MusicStudio() {
{library.length}
)}
+
{/* ═══ LIBRARY TAB ═══ */}
@@ -1237,6 +1130,11 @@ export default function MusicStudio() {
onRefresh={loadLibrary}
onExtend={handleExtend}
onVocalRemoval={handleVocalRemoval}
+ onCoverArt={handleCoverArt}
+ onWavConvert={handleWavConvert}
+ onStemSplit={handleStemSplit}
+ onSyncedLyrics={handleSyncedLyrics}
+ onVideoGenerate={handleVideoGenerate}
isGenerating={isGenerating}
/>
)}
@@ -1246,6 +1144,24 @@ export default function MusicStudio() {
{ setLyrics(text); setInstrumental(false); setProvider('suno'); setTab('create'); }} />
)}
+ {/* ═══ REMIX TAB ═══ */}
+ {tab === 'remix' && (
+ {
+ setTab('create');
+ setIsGenerating(true);
+ setTrack(null);
+ setGenProgress(0);
+ setGenStep(`${title} 처리 중…`);
+ setGenError(null);
+ taskIdRef.current = taskId;
+ startPolling(taskId, title);
+ }}
+ model={model}
+ isGenerating={isGenerating}
+ />
+ )}
+
{/* ═══ CREATE TAB ═══ */}
{tab === 'create' && (
@@ -1316,6 +1232,17 @@ export default function MusicStudio() {
01
Genre
장르를 선택하세요
+ {provider === 'suno' && (
+
+ )}
@@ -1529,6 +1456,96 @@ export default function MusicStudio() {
+
+ {/* Vocal Gender (Suno only) */}
+ {provider === 'suno' && (
+
+
+
+ {[
+ { value: null, label: 'Auto', icon: '🎵' },
+ { value: 'm', label: 'Male', icon: '♂' },
+ { value: 'f', label: 'Female', icon: '♀' },
+ ].map((opt) => (
+
+ ))}
+
+
+ )}
+
+ {/* Negative Tags (Suno only) */}
+ {provider === 'suno' && (
+
+
+
+
+ {['screaming', 'autotune', 'distortion', 'whisper', 'falsetto', 'rap'].map((tag) => (
+
+ ))}
+
+
setNegativeTags(e.target.value)}
+ />
+
+
+ )}
+
+ {/* Style Weight / Audio Weight (Suno only) */}
+ {provider === 'suno' && (
+
+
+
+
+ {styleWeight}%
+
+
Prompt ↔ Style 밸런스
+
setStyleWeight(Number(e.target.value))}
+ className="ms-bpm-slider"
+ aria-label="Style Weight"
+ />
+
+
+
+
+ {audioWeight}%
+
+
Original ↔ AI 밸런스
+
setAudioWeight(Number(e.target.value))}
+ className="ms-bpm-slider"
+ aria-label="Audio Weight"
+ />
+
+
+ )}
{/* Step 5: Prompt */}
@@ -1720,6 +1737,29 @@ export default function MusicStudio() {
)}
+
+ {coverArtModal && (
+ setCoverArtModal(null)}
+ />
+ )}
+
+ {/* ═══ Stem Modal ═══ */}
+ {stemModal && (
+ setStemModal(null)} />
+ )}
+
+ {/* ═══ Synced Lyrics Player ═══ */}
+ {syncedLyrics && (
+ setSyncedLyrics(null)}
+ accentColor={accentColor}
+ />
+ )}
);
}
diff --git a/src/pages/music/components/AudioPlayer.jsx b/src/pages/music/components/AudioPlayer.jsx
new file mode 100644
index 0000000..4c16dc6
--- /dev/null
+++ b/src/pages/music/components/AudioPlayer.jsx
@@ -0,0 +1,128 @@
+import React, { useRef, useState, useEffect } from 'react';
+
+/* ─────────────────────────────────────────────
+ 유틸
+───────────────────────────────────────────── */
+const pad = (n) => String(Math.floor(n)).padStart(2, '0');
+export const fmtTime = (s) => `${pad(s / 60)}:${pad(s % 60)}`;
+
+/* ─────────────────────────────────────────────
+ Audio Player (실제 기반)
+───────────────────────────────────────────── */
+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 (
+
+ {!isFake && (
+
setDuration(e.target.duration)}
+ onTimeUpdate={(e) => setElapsed(e.target.currentTime)}
+ onPlay={() => setPlaying(true)}
+ onPause={() => setPlaying(false)}
+ onEnded={() => { setPlaying(false); setElapsed(0); }}
+ />
+ )}
+
+
+
+
+
+ {fmtTime(elapsed)}
+ {fmtTime(total)}
+
+
+
+
+
+ );
+};
+
+export default AudioPlayer;
diff --git a/src/pages/music/components/CoverArtModal.jsx b/src/pages/music/components/CoverArtModal.jsx
new file mode 100644
index 0000000..5d9cab3
--- /dev/null
+++ b/src/pages/music/components/CoverArtModal.jsx
@@ -0,0 +1,40 @@
+import React, { useState } from 'react';
+
+const CoverArtModal = ({ images, onSelect, onClose }) => {
+ const [selected, setSelected] = useState(null);
+
+ if (!images || images.length === 0) return null;
+
+ return (
+
+
e.stopPropagation()}>
+
+
Cover Art 선택
+
+
+
+ {images.map((url, idx) => (
+
+ ))}
+
+
+
+
+
+
+
+ );
+};
+
+export default CoverArtModal;
diff --git a/src/pages/music/components/CreditsBadge.jsx b/src/pages/music/components/CreditsBadge.jsx
new file mode 100644
index 0000000..d83e9ee
--- /dev/null
+++ b/src/pages/music/components/CreditsBadge.jsx
@@ -0,0 +1,36 @@
+import React, { useEffect, useState, useCallback } from 'react';
+import { getMusicCredits } from '../../../api';
+
+const CreditsBadge = () => {
+ const [credits, setCredits] = useState(null);
+
+ const fetchCredits = useCallback(async () => {
+ try {
+ const data = await getMusicCredits();
+ setCredits(data);
+ } catch {}
+ }, []);
+
+ useEffect(() => {
+ fetchCredits();
+ const interval = setInterval(fetchCredits, 30000);
+ return () => clearInterval(interval);
+ }, [fetchCredits]);
+
+ if (!credits) return null;
+
+ const remaining = credits.credits_left ?? credits.remaining ?? credits.data ?? null;
+ if (remaining == null) return null;
+
+ const isLow = remaining <= 10;
+
+ return (
+
+ ⚡
+ {remaining}
+ credits
+
+ );
+};
+
+export default CreditsBadge;
diff --git a/src/pages/music/components/LyricsTab.jsx b/src/pages/music/components/LyricsTab.jsx
new file mode 100644
index 0000000..e6b224c
--- /dev/null
+++ b/src/pages/music/components/LyricsTab.jsx
@@ -0,0 +1,245 @@
+import React, { useEffect, useState } from 'react';
+import {
+ generateMusicLyrics,
+ getSavedLyrics,
+ saveLyrics,
+ updateLyrics,
+ deleteLyrics,
+} from '../../../api';
+
+/* ─────────────────────────────────────────────
+ Lyrics Tab
+───────────────────────────────────────────── */
+const LyricsTab = ({ onUseInCreate }) => {
+ const [lyrPrompt, setLyrPrompt] = useState('');
+ const [lyrLoading, setLyrLoading] = useState(false);
+ const [lyrError, setLyrError] = useState(null);
+ const [copied, setCopied] = useState(null); // id
+ const [saved, setSaved] = useState([]); // DB에 저장된 가사
+ const [loadingSaved, setLoadingSaved] = useState(true);
+ const [editingId, setEditingId] = useState(null);
+ const [editTitle, setEditTitle] = useState('');
+ const [editText, setEditText] = useState('');
+
+ /* ── 저장된 가사 로드 ── */
+ useEffect(() => {
+ setLoadingSaved(true);
+ getSavedLyrics()
+ .then((data) => setSaved(data.lyrics ?? []))
+ .catch(() => {})
+ .finally(() => setLoadingSaved(false));
+ }, []);
+
+ /* ── AI 생성 → 즉시 저장 ── */
+ const handleGenerate = async () => {
+ if (!lyrPrompt.trim() || lyrLoading) return;
+ setLyrLoading(true);
+ setLyrError(null);
+ try {
+ const res = await generateMusicLyrics(lyrPrompt.trim());
+ if (res?.text) {
+ const record = await saveLyrics({
+ title: res.title || '',
+ text: res.text,
+ prompt: lyrPrompt.trim(),
+ });
+ setSaved((prev) => [record, ...prev]);
+ } else {
+ setLyrError('가사 생성 결과가 없습니다');
+ }
+ } catch (e) {
+ setLyrError(e.message || '가사 생성에 실패했습니다');
+ } finally {
+ setLyrLoading(false);
+ }
+ };
+
+ /* ── 복사 ── */
+ const handleCopy = (text, id) => {
+ navigator.clipboard.writeText(text).then(() => {
+ setCopied(id);
+ setTimeout(() => setCopied(null), 2000);
+ });
+ };
+
+ /* ── 삭제 ── */
+ const handleDelete = async (id) => {
+ try {
+ await deleteLyrics(id);
+ setSaved((prev) => prev.filter((l) => l.id !== id));
+ } catch {}
+ };
+
+ /* ── 수정 시작 ── */
+ const startEdit = (item) => {
+ setEditingId(item.id);
+ setEditTitle(item.title);
+ setEditText(item.text);
+ };
+
+ /* ── 수정 저장 ── */
+ const handleSaveEdit = async () => {
+ if (editingId == null) return;
+ try {
+ const updated = await updateLyrics(editingId, { title: editTitle, text: editText });
+ setSaved((prev) => prev.map((l) => l.id === editingId ? updated : l));
+ setEditingId(null);
+ } catch {}
+ };
+
+ /* ── 수정 취소 ── */
+ const cancelEdit = () => setEditingId(null);
+
+ return (
+
+
+
+
AI Lyrics Generator
+
+ 원하는 분위기, 주제, 스타일을 설명하면 AI가 가사를 작성합니다.
+
+
+
+
+
+ {lyrError && (
+
+ ⚠ {lyrError}
+
+ )}
+
+
+ {lyrLoading && (
+
+
+
AI가 가사를 작성하고 있습니다...
+
+ )}
+
+ {/* 저장된 가사 목록 */}
+ {loadingSaved && (
+
+ )}
+
+ {!loadingSaved && saved.length === 0 && !lyrLoading && (
+
+
🎤
+
저장된 가사가 없습니다
+
+ 프롬프트를 입력하면 AI가 [Verse], [Chorus] 등 섹션이 포함된 가사를 작성합니다
+
+
+ )}
+
+
+ {saved.map((item) => (
+
+
+ {editingId === item.id ? (
+ setEditTitle(e.target.value)}
+ placeholder="제목"
+ />
+ ) : (
+ <>
+ {item.title &&
{item.title}
}
+ {item.prompt}
+ >
+ )}
+
+ {item.created_at ? new Date(item.created_at).toLocaleDateString('ko-KR') : ''}
+
+
+
+ {editingId === item.id ? (
+
+ ))}
+
+
+ );
+};
+
+export default LyricsTab;
diff --git a/src/pages/music/components/RemixTab.jsx b/src/pages/music/components/RemixTab.jsx
new file mode 100644
index 0000000..3e7537f
--- /dev/null
+++ b/src/pages/music/components/RemixTab.jsx
@@ -0,0 +1,193 @@
+import React, { useState } from 'react';
+import { uploadAndCover, uploadAndExtend, addVocals, addInstrumental } from '../../../api';
+
+const REMIX_ACTIONS = [
+ { id: 'cover', label: 'AI Cover', icon: '🎨', desc: '외부 음원을 Suno AI 스타일로 리메이크' },
+ { id: 'extend', label: 'Extend', icon: '⏩', desc: '외부 음원을 이어서 확장' },
+ { id: 'add-vocals', label: 'Add Vocals', icon: '🎤', desc: '인스트루멘탈에 AI 보컬 입히기' },
+ { id: 'add-instrumental', label: 'Add Instrumental', icon: '🎹', desc: '보컬에 AI 반주 입히기' },
+];
+
+const RemixTab = ({ onTaskStarted, model, isGenerating }) => {
+ const [uploadUrl, setUploadUrl] = useState('');
+ const [activeAction, setActiveAction] = useState(null);
+
+ // 각 액션별 파라미터
+ const [title, setTitle] = useState('');
+ const [style, setStyle] = useState('');
+ const [prompt, setPrompt] = useState('');
+ const [tags, setTags] = useState('');
+ const [negativeTags, setNegativeTags] = useState('');
+ const [vocalGender, setVocalGender] = useState(null);
+ const [continueAt, setContinueAt] = useState(0);
+ const [instrumental, setInstrumental] = useState(false);
+
+ const handleSubmit = async () => {
+ if (!uploadUrl || !activeAction || isGenerating) return;
+
+ let apiCall;
+ let payload = {};
+
+ switch (activeAction) {
+ case 'cover':
+ apiCall = uploadAndCover;
+ payload = {
+ upload_url: uploadUrl, model, custom_mode: true,
+ instrumental, prompt, style, title,
+ vocal_gender: vocalGender || undefined,
+ negative_tags: negativeTags || undefined,
+ };
+ break;
+ case 'extend':
+ apiCall = uploadAndExtend;
+ payload = {
+ upload_url: uploadUrl, model,
+ default_param_flag: !prompt,
+ continue_at: continueAt || undefined,
+ prompt, style, title, instrumental,
+ vocal_gender: vocalGender || undefined,
+ negative_tags: negativeTags || undefined,
+ };
+ break;
+ case 'add-vocals':
+ apiCall = addVocals;
+ payload = {
+ upload_url: uploadUrl, prompt, title, style,
+ negative_tags: negativeTags,
+ vocal_gender: vocalGender || undefined,
+ model: 'V4_5PLUS',
+ };
+ break;
+ case 'add-instrumental':
+ apiCall = addInstrumental;
+ payload = {
+ upload_url: uploadUrl, title, tags,
+ negative_tags: negativeTags,
+ vocal_gender: vocalGender || undefined,
+ model: 'V4_5PLUS',
+ };
+ break;
+ default:
+ return;
+ }
+
+ try {
+ const res = await apiCall(payload);
+ if (res?.task_id) {
+ onTaskStarted(res.task_id, `Remix: ${REMIX_ACTIONS.find(a => a.id === activeAction)?.label}`);
+ }
+ } catch (e) {
+ // 에러는 부모 컴포넌트에서 처리
+ }
+ };
+
+ return (
+
+
+
Remix Studio
+
외부 음원을 AI로 리메이크, 확장, 보컬/반주 추가
+
+
+
+
+ setUploadUrl(e.target.value)}
+ style={{ width: '100%' }}
+ />
+
+
+
+ {REMIX_ACTIONS.map((action) => (
+
+ ))}
+
+
+ {activeAction && (
+
+ {/* 공통 파라미터 */}
+
+
+ setTitle(e.target.value)} placeholder="곡 제목" style={{ width: '100%' }} />
+
+
+ {(activeAction === 'cover' || activeAction === 'extend' || activeAction === 'add-vocals') && (
+
+
+
+ )}
+
+ {(activeAction === 'cover' || activeAction === 'extend' || activeAction === 'add-vocals') && (
+
+
+ setStyle(e.target.value)} placeholder="예: Pop, Energetic, Piano" style={{ width: '100%' }} />
+
+ )}
+
+ {activeAction === 'add-instrumental' && (
+
+
+ setTags(e.target.value)} placeholder="예: acoustic, warm, dreamy" style={{ width: '100%' }} />
+
+ )}
+
+ {activeAction === 'extend' && (
+
+
+ setContinueAt(Number(e.target.value))} min={0} style={{ width: '120px' }} />
+
+ )}
+
+
+
+ setNegativeTags(e.target.value)} placeholder="제외할 스타일" style={{ width: '100%' }} />
+
+
+
+
+
+ {[{ value: null, label: 'Auto' }, { value: 'm', label: 'Male' }, { value: 'f', label: 'Female' }].map((opt) => (
+
+ ))}
+
+
+
+
+
+ )}
+
+ );
+};
+
+export default RemixTab;
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 && (
+
setPlayingStem(null)} />
+ )}
+
+ );
+ })}
+
+
+
+
+
+
+ );
+};
+
+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
+
+
+
setPlaying(true)}
+ onPause={() => setPlaying(false)}
+ onEnded={() => setPlaying(false)}
+ controls
+ className="ms-synced-player__audio"
+ />
+
+ {alignedWords.map((word, idx) => {
+ const isActive = currentTime >= word.startS && currentTime < word.endS;
+ const isPast = currentTime >= word.endS;
+ return (
+
+ {word.word}{' '}
+
+ );
+ })}
+
+
+ );
+};
+
+export default SyncedLyricsPlayer;