diff --git a/src/pages/music/MusicStudio.css b/src/pages/music/MusicStudio.css
index 89df6ed..3c7adff 100644
--- a/src/pages/music/MusicStudio.css
+++ b/src/pages/music/MusicStudio.css
@@ -2426,3 +2426,26 @@
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; }
+}
diff --git a/src/pages/music/MusicStudio.jsx b/src/pages/music/MusicStudio.jsx
index fb0d61d..57ef7eb 100644
--- a/src/pages/music/MusicStudio.jsx
+++ b/src/pages/music/MusicStudio.jsx
@@ -7,15 +7,14 @@ import {
getMusicProviders,
getMusicStatus,
getMusicModels,
- getMusicCredits,
extendMusicTrack,
removeVocals,
- getSavedLyrics,
- saveLyrics,
- updateLyrics,
- deleteLyrics,
} from '../../api';
import './MusicStudio.css';
+import AudioPlayer from './components/AudioPlayer';
+import { fmtTime } from './components/AudioPlayer';
+import CreditsBadge from './components/CreditsBadge';
+import LyricsTab from './components/LyricsTab';
/* ─────────────────────────────────────────────
데이터 상수
@@ -83,12 +82,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 +254,6 @@ const GenerationProgress = ({ progress, stepMsg }) => (
);
-/* ─────────────────────────────────────────────
- Audio Player (실제
- {credits && (
-
- Credits
-
- {credits.credits_left ?? credits.remaining ?? '—'}
-
-
- )}
+
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/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;