feat: Drama Studio 프로젝트 초기 구조 설정
- FastAPI 백엔드 (audio-studio-api) - Next.js 프론트엔드 (audio-studio-ui) - Qwen3-TTS 엔진 (audio-studio-tts) - MusicGen 서비스 (audio-studio-musicgen) - Docker Compose 개발/운영 환경 Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
43
audio-studio-ui/Dockerfile
Normal file
43
audio-studio-ui/Dockerfile
Normal file
@ -0,0 +1,43 @@
|
||||
# Audio Studio UI - Dockerfile
|
||||
|
||||
FROM node:22-alpine AS base
|
||||
|
||||
# 의존성 설치
|
||||
FROM base AS deps
|
||||
WORKDIR /app
|
||||
|
||||
COPY package.json package-lock.json* ./
|
||||
RUN npm ci
|
||||
|
||||
# 빌드
|
||||
FROM base AS builder
|
||||
WORKDIR /app
|
||||
|
||||
COPY --from=deps /app/node_modules ./node_modules
|
||||
COPY . .
|
||||
|
||||
RUN npm run build
|
||||
|
||||
# 프로덕션
|
||||
FROM base AS runner
|
||||
WORKDIR /app
|
||||
|
||||
ENV NODE_ENV=production
|
||||
|
||||
RUN addgroup --system --gid 1001 nodejs
|
||||
RUN adduser --system --uid 1001 nextjs
|
||||
|
||||
COPY --from=builder /app/public ./public
|
||||
|
||||
# standalone 모드 사용
|
||||
COPY --from=builder --chown=nextjs:nodejs /app/.next/standalone ./
|
||||
COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static
|
||||
|
||||
USER nextjs
|
||||
|
||||
EXPOSE 3000
|
||||
|
||||
ENV PORT=3000
|
||||
ENV HOSTNAME="0.0.0.0"
|
||||
|
||||
CMD ["node", "server.js"]
|
||||
6
audio-studio-ui/next-env.d.ts
vendored
Normal file
6
audio-studio-ui/next-env.d.ts
vendored
Normal file
@ -0,0 +1,6 @@
|
||||
/// <reference types="next" />
|
||||
/// <reference types="next/image-types/global" />
|
||||
/// <reference path="./.next/types/routes.d.ts" />
|
||||
|
||||
// NOTE: This file should not be edited
|
||||
// see https://nextjs.org/docs/app/api-reference/config/typescript for more information.
|
||||
10
audio-studio-ui/next.config.ts
Normal file
10
audio-studio-ui/next.config.ts
Normal file
@ -0,0 +1,10 @@
|
||||
import type { NextConfig } from "next";
|
||||
|
||||
const nextConfig: NextConfig = {
|
||||
output: "standalone",
|
||||
env: {
|
||||
NEXT_PUBLIC_API_URL: process.env.NEXT_PUBLIC_API_URL || "http://localhost:8000",
|
||||
},
|
||||
};
|
||||
|
||||
export default nextConfig;
|
||||
7238
audio-studio-ui/package-lock.json
generated
Normal file
7238
audio-studio-ui/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
42
audio-studio-ui/package.json
Normal file
42
audio-studio-ui/package.json
Normal file
@ -0,0 +1,42 @@
|
||||
{
|
||||
"name": "drama-studio-ui",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "next dev",
|
||||
"build": "next build",
|
||||
"start": "next start",
|
||||
"lint": "next lint"
|
||||
},
|
||||
"dependencies": {
|
||||
"next": "^15.1.0",
|
||||
"react": "^19.0.0",
|
||||
"react-dom": "^19.0.0",
|
||||
"@radix-ui/react-dialog": "^1.1.4",
|
||||
"@radix-ui/react-dropdown-menu": "^2.1.4",
|
||||
"@radix-ui/react-icons": "^1.3.2",
|
||||
"@radix-ui/react-label": "^2.1.1",
|
||||
"@radix-ui/react-select": "^2.1.4",
|
||||
"@radix-ui/react-slider": "^1.2.2",
|
||||
"@radix-ui/react-slot": "^1.1.1",
|
||||
"@radix-ui/react-tabs": "^1.1.2",
|
||||
"@radix-ui/react-toast": "^1.2.4",
|
||||
"@radix-ui/react-tooltip": "^1.1.6",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
"lucide-react": "^0.469.0",
|
||||
"tailwind-merge": "^2.6.0",
|
||||
"tailwindcss-animate": "^1.0.7"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@tailwindcss/postcss": "^4.0.0",
|
||||
"@types/node": "^22.10.0",
|
||||
"@types/react": "^19.0.0",
|
||||
"@types/react-dom": "^19.0.0",
|
||||
"typescript": "^5.7.0",
|
||||
"tailwindcss": "^4.0.0",
|
||||
"postcss": "^8.4.49",
|
||||
"eslint": "^9.17.0",
|
||||
"eslint-config-next": "^15.1.0"
|
||||
}
|
||||
}
|
||||
5
audio-studio-ui/postcss.config.mjs
Normal file
5
audio-studio-ui/postcss.config.mjs
Normal file
@ -0,0 +1,5 @@
|
||||
export default {
|
||||
plugins: {
|
||||
"@tailwindcss/postcss": {},
|
||||
},
|
||||
};
|
||||
0
audio-studio-ui/public/.gitkeep
Normal file
0
audio-studio-ui/public/.gitkeep
Normal file
432
audio-studio-ui/src/app/drama/page.tsx
Normal file
432
audio-studio-ui/src/app/drama/page.tsx
Normal file
@ -0,0 +1,432 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect } from "react";
|
||||
import {
|
||||
parseScript,
|
||||
createDramaProject,
|
||||
getDramaProjects,
|
||||
getDramaProject,
|
||||
renderDrama,
|
||||
getDramaDownloadUrl,
|
||||
getVoices,
|
||||
updateVoiceMapping,
|
||||
DramaProject,
|
||||
ParsedScript,
|
||||
Voice,
|
||||
} from "@/lib/api";
|
||||
import { formatDuration } from "@/lib/utils";
|
||||
|
||||
// 예시 스크립트
|
||||
const EXAMPLE_SCRIPT = `# 아침의 카페
|
||||
|
||||
[장소: 조용한 카페, 아침 햇살이 들어오는 창가]
|
||||
|
||||
[음악: 잔잔한 재즈 피아노]
|
||||
|
||||
민수(30대 남성, 차분): 오랜만이야. 잘 지냈어?
|
||||
|
||||
수진(20대 여성, 밝음): 어! 민수 오빠! 완전 오랜만이다!
|
||||
|
||||
[효과음: 커피잔 내려놓는 소리]
|
||||
|
||||
민수: 커피 시켰어. 아메리카노 맞지?
|
||||
|
||||
수진(감사하며): 고마워. 오빠는 여전하네.
|
||||
|
||||
[쉼: 2초]
|
||||
|
||||
민수(조용히): 그동안... 많이 보고 싶었어.
|
||||
|
||||
[음악 페이드아웃: 여운]`;
|
||||
|
||||
export default function DramaPage() {
|
||||
// 상태
|
||||
const [script, setScript] = useState(EXAMPLE_SCRIPT);
|
||||
const [title, setTitle] = useState("새 드라마");
|
||||
const [parsedScript, setParsedScript] = useState<ParsedScript | null>(null);
|
||||
const [parseError, setParseError] = useState<string | null>(null);
|
||||
const [projects, setProjects] = useState<DramaProject[]>([]);
|
||||
const [selectedProject, setSelectedProject] = useState<DramaProject | null>(null);
|
||||
const [voices, setVoices] = useState<Voice[]>([]);
|
||||
const [voiceMapping, setVoiceMapping] = useState<Record<string, string>>({});
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [renderStatus, setRenderStatus] = useState<string | null>(null);
|
||||
|
||||
// 초기 로드
|
||||
useEffect(() => {
|
||||
loadProjects();
|
||||
loadVoices();
|
||||
}, []);
|
||||
|
||||
async function loadProjects() {
|
||||
try {
|
||||
const data = await getDramaProjects();
|
||||
setProjects(data);
|
||||
} catch (e) {
|
||||
console.error("프로젝트 로드 실패:", e);
|
||||
}
|
||||
}
|
||||
|
||||
async function loadVoices() {
|
||||
try {
|
||||
const response = await getVoices({ page_size: 100 });
|
||||
setVoices(response.voices);
|
||||
} catch (e) {
|
||||
console.error("보이스 로드 실패:", e);
|
||||
}
|
||||
}
|
||||
|
||||
// 스크립트 미리보기 파싱
|
||||
async function handleParsePreview() {
|
||||
setParseError(null);
|
||||
try {
|
||||
const parsed = await parseScript(script);
|
||||
setParsedScript(parsed);
|
||||
|
||||
// 캐릭터별 기본 보이스 매핑
|
||||
const mapping: Record<string, string> = {};
|
||||
parsed.characters.forEach((char) => {
|
||||
if (!voiceMapping[char.name] && voices.length > 0) {
|
||||
mapping[char.name] = voices[0].voice_id;
|
||||
} else {
|
||||
mapping[char.name] = voiceMapping[char.name] || "";
|
||||
}
|
||||
});
|
||||
setVoiceMapping(mapping);
|
||||
} catch (e) {
|
||||
setParseError(e instanceof Error ? e.message : "파싱 실패");
|
||||
}
|
||||
}
|
||||
|
||||
// 프로젝트 생성
|
||||
async function handleCreateProject() {
|
||||
setLoading(true);
|
||||
try {
|
||||
const project = await createDramaProject({
|
||||
title,
|
||||
script,
|
||||
voice_mapping: voiceMapping,
|
||||
auto_generate_assets: true,
|
||||
});
|
||||
setSelectedProject(project);
|
||||
await loadProjects();
|
||||
} catch (e) {
|
||||
alert(e instanceof Error ? e.message : "프로젝트 생성 실패");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
// 렌더링 시작
|
||||
async function handleRender() {
|
||||
if (!selectedProject) return;
|
||||
|
||||
setLoading(true);
|
||||
setRenderStatus("렌더링 시작 중...");
|
||||
|
||||
try {
|
||||
// 보이스 매핑 업데이트
|
||||
await updateVoiceMapping(selectedProject.project_id, voiceMapping);
|
||||
|
||||
// 렌더링 시작
|
||||
await renderDrama(selectedProject.project_id);
|
||||
setRenderStatus("렌더링 중... (TTS, 효과음 생성 및 믹싱)");
|
||||
|
||||
// 상태 폴링
|
||||
const pollStatus = async () => {
|
||||
const project = await getDramaProject(selectedProject.project_id);
|
||||
setSelectedProject(project);
|
||||
|
||||
if (project.status === "completed") {
|
||||
setRenderStatus("완료!");
|
||||
setLoading(false);
|
||||
} else if (project.status === "error") {
|
||||
setRenderStatus(`오류: ${project.error_message}`);
|
||||
setLoading(false);
|
||||
} else {
|
||||
setTimeout(pollStatus, 2000);
|
||||
}
|
||||
};
|
||||
|
||||
setTimeout(pollStatus, 2000);
|
||||
} catch (e) {
|
||||
setRenderStatus(`오류: ${e instanceof Error ? e.message : "렌더링 실패"}`);
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
// 프로젝트 선택
|
||||
async function handleSelectProject(project: DramaProject) {
|
||||
setSelectedProject(project);
|
||||
try {
|
||||
const fullProject = await getDramaProject(project.project_id);
|
||||
setSelectedProject(fullProject);
|
||||
|
||||
// 캐릭터 보이스 매핑
|
||||
const mapping: Record<string, string> = {};
|
||||
fullProject.characters.forEach((char) => {
|
||||
mapping[char.name] = char.voice_id || "";
|
||||
});
|
||||
setVoiceMapping(mapping);
|
||||
} catch (e) {
|
||||
console.error("프로젝트 상세 로드 실패:", e);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* 헤더 */}
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold">드라마 스튜디오</h1>
|
||||
<p className="text-muted-foreground">
|
||||
스크립트를 입력하면 AI가 라디오 드라마를 생성합니다
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-6 lg:grid-cols-3">
|
||||
{/* 스크립트 에디터 */}
|
||||
<div className="lg:col-span-2 space-y-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<input
|
||||
type="text"
|
||||
value={title}
|
||||
onChange={(e) => setTitle(e.target.value)}
|
||||
placeholder="드라마 제목"
|
||||
className="flex-1 rounded-lg border bg-background px-3 py-2 text-sm font-medium focus:outline-none focus:ring-2 focus:ring-ring"
|
||||
/>
|
||||
<button
|
||||
onClick={handleParsePreview}
|
||||
className="rounded-lg border px-4 py-2 text-sm font-medium hover:bg-accent"
|
||||
>
|
||||
미리보기
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="text-sm font-medium">스크립트</label>
|
||||
<textarea
|
||||
value={script}
|
||||
onChange={(e) => setScript(e.target.value)}
|
||||
placeholder="스크립트를 입력하세요..."
|
||||
className="mt-1 w-full h-80 rounded-lg border bg-background px-3 py-2 text-sm font-mono resize-none focus:outline-none focus:ring-2 focus:ring-ring"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 문법 가이드 */}
|
||||
<div className="rounded-lg bg-secondary p-4 text-sm">
|
||||
<h3 className="font-medium mb-2">스크립트 문법</h3>
|
||||
<ul className="space-y-1 text-muted-foreground">
|
||||
<li><code className="bg-background px-1 rounded"># 제목</code> - 드라마 제목</li>
|
||||
<li><code className="bg-background px-1 rounded">[장소: 설명]</code> - 장면 지문</li>
|
||||
<li><code className="bg-background px-1 rounded">[효과음: 설명]</code> - 효과음 삽입</li>
|
||||
<li><code className="bg-background px-1 rounded">[음악: 설명]</code> - 배경음악</li>
|
||||
<li><code className="bg-background px-1 rounded">[쉼: 2초]</code> - 간격</li>
|
||||
<li><code className="bg-background px-1 rounded">캐릭터(감정): 대사</code> - 대사</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
{/* 파싱 에러 */}
|
||||
{parseError && (
|
||||
<div className="rounded-lg border border-destructive/50 bg-destructive/10 p-3 text-sm text-destructive">
|
||||
{parseError}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 파싱 결과 */}
|
||||
{parsedScript && (
|
||||
<div className="rounded-lg border p-4 space-y-4">
|
||||
<h3 className="font-medium">파싱 결과</h3>
|
||||
|
||||
{/* 캐릭터 목록 & 보이스 매핑 */}
|
||||
<div>
|
||||
<h4 className="text-sm font-medium mb-2">등장인물 ({parsedScript.characters.length}명)</h4>
|
||||
<div className="space-y-2">
|
||||
{parsedScript.characters.map((char) => (
|
||||
<div key={char.name} className="flex items-center gap-3">
|
||||
<span className="w-24 text-sm truncate">{char.name}</span>
|
||||
<span className="text-xs text-muted-foreground w-32 truncate">
|
||||
{char.description || "-"}
|
||||
</span>
|
||||
<select
|
||||
value={voiceMapping[char.name] || ""}
|
||||
onChange={(e) =>
|
||||
setVoiceMapping({ ...voiceMapping, [char.name]: e.target.value })
|
||||
}
|
||||
className="flex-1 rounded-lg border bg-background px-2 py-1 text-sm"
|
||||
>
|
||||
<option value="">보이스 선택...</option>
|
||||
{voices.map((voice) => (
|
||||
<option key={voice.voice_id} value={voice.voice_id}>
|
||||
{voice.name} ({voice.type})
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 요소 수 */}
|
||||
<div className="text-sm text-muted-foreground">
|
||||
총 {parsedScript.elements.length}개 요소
|
||||
(대사 {parsedScript.elements.filter((e) => e.type === "dialogue").length}개,
|
||||
효과음 {parsedScript.elements.filter((e) => e.type === "sfx").length}개,
|
||||
음악 {parsedScript.elements.filter((e) => e.type === "music").length}개)
|
||||
</div>
|
||||
|
||||
{/* 프로젝트 생성 버튼 */}
|
||||
<button
|
||||
onClick={handleCreateProject}
|
||||
disabled={loading}
|
||||
className="w-full inline-flex items-center justify-center gap-2 rounded-lg bg-primary px-4 py-2 text-sm font-medium text-primary-foreground hover:bg-primary/90 disabled:opacity-50"
|
||||
>
|
||||
{loading ? (
|
||||
<>
|
||||
<div className="h-4 w-4 animate-spin rounded-full border-2 border-current border-t-transparent" />
|
||||
생성 중...
|
||||
</>
|
||||
) : (
|
||||
"프로젝트 생성"
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 프로젝트 목록 & 상세 */}
|
||||
<div className="space-y-4">
|
||||
<h2 className="font-semibold">프로젝트</h2>
|
||||
|
||||
{/* 프로젝트 목록 */}
|
||||
<div className="space-y-2 max-h-60 overflow-y-auto">
|
||||
{projects.map((project) => (
|
||||
<button
|
||||
key={project.project_id}
|
||||
onClick={() => handleSelectProject(project)}
|
||||
className={`w-full text-left rounded-lg border p-3 transition-colors ${
|
||||
selectedProject?.project_id === project.project_id
|
||||
? "border-primary bg-primary/5"
|
||||
: "hover:bg-accent"
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="font-medium text-sm truncate">{project.title}</span>
|
||||
<span
|
||||
className={`text-xs px-1.5 py-0.5 rounded ${
|
||||
project.status === "completed"
|
||||
? "bg-green-100 text-green-700"
|
||||
: project.status === "processing"
|
||||
? "bg-yellow-100 text-yellow-700"
|
||||
: project.status === "error"
|
||||
? "bg-red-100 text-red-700"
|
||||
: "bg-secondary"
|
||||
}`}
|
||||
>
|
||||
{project.status === "completed"
|
||||
? "완료"
|
||||
: project.status === "processing"
|
||||
? "처리 중"
|
||||
: project.status === "error"
|
||||
? "오류"
|
||||
: "초안"}
|
||||
</span>
|
||||
</div>
|
||||
<div className="text-xs text-muted-foreground mt-1">
|
||||
{project.characters.length}명 · {project.element_count}개 요소
|
||||
{project.estimated_duration && (
|
||||
<> · {formatDuration(project.estimated_duration)}</>
|
||||
)}
|
||||
</div>
|
||||
</button>
|
||||
))}
|
||||
|
||||
{projects.length === 0 && (
|
||||
<p className="text-sm text-muted-foreground text-center py-4">
|
||||
프로젝트가 없습니다
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 선택된 프로젝트 상세 */}
|
||||
{selectedProject && (
|
||||
<div className="rounded-lg border p-4 space-y-4">
|
||||
<h3 className="font-medium">{selectedProject.title}</h3>
|
||||
|
||||
<div className="text-sm space-y-1">
|
||||
<p>상태: {selectedProject.status}</p>
|
||||
<p>등장인물: {selectedProject.characters.length}명</p>
|
||||
<p>요소: {selectedProject.element_count}개</p>
|
||||
{selectedProject.estimated_duration && (
|
||||
<p>예상 길이: {formatDuration(selectedProject.estimated_duration)}</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 렌더링 상태 */}
|
||||
{renderStatus && (
|
||||
<div className="text-sm text-muted-foreground">{renderStatus}</div>
|
||||
)}
|
||||
|
||||
{/* 액션 버튼 */}
|
||||
<div className="flex gap-2">
|
||||
{selectedProject.status === "draft" && (
|
||||
<button
|
||||
onClick={handleRender}
|
||||
disabled={loading}
|
||||
className="flex-1 inline-flex items-center justify-center gap-2 rounded-lg bg-primary px-3 py-2 text-sm font-medium text-primary-foreground hover:bg-primary/90 disabled:opacity-50"
|
||||
>
|
||||
{loading ? (
|
||||
<>
|
||||
<div className="h-4 w-4 animate-spin rounded-full border-2 border-current border-t-transparent" />
|
||||
처리 중...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
style={{ width: 16, height: 16 }}
|
||||
viewBox="0 0 24 24"
|
||||
fill="currentColor"
|
||||
>
|
||||
<polygon points="5 3 19 12 5 21 5 3" />
|
||||
</svg>
|
||||
렌더링
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
)}
|
||||
|
||||
{selectedProject.status === "completed" && (
|
||||
<a
|
||||
href={getDramaDownloadUrl(selectedProject.project_id)}
|
||||
download
|
||||
className="flex-1 inline-flex items-center justify-center gap-2 rounded-lg bg-primary px-3 py-2 text-sm font-medium text-primary-foreground hover:bg-primary/90"
|
||||
>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
style={{ width: 16, height: 16 }}
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
>
|
||||
<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4" />
|
||||
<polyline points="7 10 12 15 17 10" />
|
||||
<line x1="12" y1="15" x2="12" y2="3" />
|
||||
</svg>
|
||||
다운로드
|
||||
</a>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{selectedProject.error_message && (
|
||||
<div className="rounded-lg border border-destructive/50 bg-destructive/10 p-3 text-sm text-destructive">
|
||||
{selectedProject.error_message}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
59
audio-studio-ui/src/app/globals.css
Normal file
59
audio-studio-ui/src/app/globals.css
Normal file
@ -0,0 +1,59 @@
|
||||
@import "tailwindcss";
|
||||
|
||||
@theme {
|
||||
--color-background: oklch(1 0 0);
|
||||
--color-foreground: oklch(0.145 0 0);
|
||||
--color-card: oklch(1 0 0);
|
||||
--color-card-foreground: oklch(0.145 0 0);
|
||||
--color-popover: oklch(1 0 0);
|
||||
--color-popover-foreground: oklch(0.145 0 0);
|
||||
--color-primary: oklch(0.205 0 0);
|
||||
--color-primary-foreground: oklch(0.985 0 0);
|
||||
--color-secondary: oklch(0.97 0 0);
|
||||
--color-secondary-foreground: oklch(0.205 0 0);
|
||||
--color-muted: oklch(0.97 0 0);
|
||||
--color-muted-foreground: oklch(0.556 0 0);
|
||||
--color-accent: oklch(0.97 0 0);
|
||||
--color-accent-foreground: oklch(0.205 0 0);
|
||||
--color-destructive: oklch(0.577 0.245 27.325);
|
||||
--color-destructive-foreground: oklch(0.577 0.245 27.325);
|
||||
--color-border: oklch(0.922 0 0);
|
||||
--color-input: oklch(0.922 0 0);
|
||||
--color-ring: oklch(0.708 0.165 254.624);
|
||||
--radius: 0.625rem;
|
||||
}
|
||||
|
||||
@layer base {
|
||||
* {
|
||||
@apply border-border;
|
||||
}
|
||||
body {
|
||||
@apply bg-background text-foreground;
|
||||
}
|
||||
}
|
||||
|
||||
/* 오디오 파형 스타일 */
|
||||
.waveform-container {
|
||||
@apply w-full h-16 bg-muted rounded-lg overflow-hidden;
|
||||
}
|
||||
|
||||
/* 오디오 플레이어 스타일 */
|
||||
.audio-player {
|
||||
@apply flex items-center gap-4 p-4 bg-card rounded-lg border;
|
||||
}
|
||||
|
||||
/* 녹음 버튼 애니메이션 */
|
||||
@keyframes pulse-recording {
|
||||
0%, 100% {
|
||||
opacity: 1;
|
||||
transform: scale(1);
|
||||
}
|
||||
50% {
|
||||
opacity: 0.8;
|
||||
transform: scale(1.05);
|
||||
}
|
||||
}
|
||||
|
||||
.recording-pulse {
|
||||
animation: pulse-recording 1.5s ease-in-out infinite;
|
||||
}
|
||||
85
audio-studio-ui/src/app/layout.tsx
Normal file
85
audio-studio-ui/src/app/layout.tsx
Normal file
@ -0,0 +1,85 @@
|
||||
import type { Metadata } from "next";
|
||||
import "./globals.css";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "Drama Studio",
|
||||
description: "AI 라디오 드라마 제작 시스템",
|
||||
};
|
||||
|
||||
export default function RootLayout({
|
||||
children,
|
||||
}: Readonly<{
|
||||
children: React.ReactNode;
|
||||
}>) {
|
||||
return (
|
||||
<html lang="ko">
|
||||
<body className="min-h-screen bg-background antialiased">
|
||||
<div className="flex min-h-screen">
|
||||
{/* 사이드바 */}
|
||||
<aside className="w-48 border-r bg-card px-3 py-4">
|
||||
<div className="mb-5 px-2">
|
||||
<h1 className="text-base font-bold">Drama Studio</h1>
|
||||
<p className="text-xs text-muted-foreground">AI 드라마 제작</p>
|
||||
</div>
|
||||
<nav className="space-y-0.5">
|
||||
<a href="/" className="flex items-center gap-2 rounded-md px-2 py-1.5 text-sm hover:bg-accent">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" style={{width: 16, height: 16, flexShrink: 0}} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||||
<path d="M3 9l9-7 9 7v11a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z" />
|
||||
<polyline points="9,22 9,12 15,12 15,22" />
|
||||
</svg>
|
||||
대시보드
|
||||
</a>
|
||||
<a href="/drama" className="flex items-center gap-2 rounded-md px-2 py-1.5 text-sm hover:bg-accent">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" style={{width: 16, height: 16, flexShrink: 0}} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||||
<path d="M4 19.5A2.5 2.5 0 0 1 6.5 17H20" />
|
||||
<path d="M6.5 2H20v20H6.5A2.5 2.5 0 0 1 4 19.5v-15A2.5 2.5 0 0 1 6.5 2z" />
|
||||
</svg>
|
||||
드라마
|
||||
</a>
|
||||
<a href="/voices" className="flex items-center gap-2 rounded-md px-2 py-1.5 text-sm hover:bg-accent">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" style={{width: 16, height: 16, flexShrink: 0}} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||||
<path d="M12 1a3 3 0 0 0-3 3v8a3 3 0 0 0 6 0V4a3 3 0 0 0-3-3z" />
|
||||
<path d="M19 10v2a7 7 0 0 1-14 0v-2" />
|
||||
<line x1="12" y1="19" x2="12" y2="23" />
|
||||
<line x1="8" y1="23" x2="16" y2="23" />
|
||||
</svg>
|
||||
보이스
|
||||
</a>
|
||||
<a href="/tts" className="flex items-center gap-2 rounded-md px-2 py-1.5 text-sm hover:bg-accent">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" style={{width: 16, height: 16, flexShrink: 0}} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||||
<polygon points="11 5 6 9 2 9 2 15 6 15 11 19 11 5" />
|
||||
<path d="M19.07 4.93a10 10 0 0 1 0 14.14M15.54 8.46a5 5 0 0 1 0 7.07" />
|
||||
</svg>
|
||||
TTS
|
||||
</a>
|
||||
<a href="/recordings" className="flex items-center gap-2 rounded-md px-2 py-1.5 text-sm hover:bg-accent">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" style={{width: 16, height: 16, flexShrink: 0}} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||||
<circle cx="12" cy="12" r="10" />
|
||||
<circle cx="12" cy="12" r="3" fill="currentColor" />
|
||||
</svg>
|
||||
녹음
|
||||
</a>
|
||||
<a href="/sound-effects" className="flex items-center gap-2 rounded-md px-2 py-1.5 text-sm hover:bg-accent">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" style={{width: 16, height: 16, flexShrink: 0}} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||||
<polygon points="12 2 15.09 8.26 22 9.27 17 14.14 18.18 21.02 12 17.77 5.82 21.02 7 14.14 2 9.27 8.91 8.26 12 2" />
|
||||
</svg>
|
||||
효과음
|
||||
</a>
|
||||
<a href="/music" className="flex items-center gap-2 rounded-md px-2 py-1.5 text-sm hover:bg-accent">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" style={{width: 16, height: 16, flexShrink: 0}} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||||
<path d="M9 18V5l12-2v13" />
|
||||
<circle cx="6" cy="18" r="3" />
|
||||
<circle cx="18" cy="16" r="3" />
|
||||
</svg>
|
||||
음악
|
||||
</a>
|
||||
</nav>
|
||||
</aside>
|
||||
|
||||
{/* 메인 콘텐츠 */}
|
||||
<main className="flex-1 p-6">{children}</main>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
);
|
||||
}
|
||||
251
audio-studio-ui/src/app/music/page.tsx
Normal file
251
audio-studio-ui/src/app/music/page.tsx
Normal file
@ -0,0 +1,251 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useRef } from "react";
|
||||
import { formatDuration } from "@/lib/utils";
|
||||
|
||||
const API_BASE_URL = process.env.NEXT_PUBLIC_API_URL || "http://localhost:8000";
|
||||
|
||||
// 예시 프롬프트
|
||||
const EXAMPLE_PROMPTS = [
|
||||
{ category: "Ambient", prompts: ["calm piano music, peaceful, ambient", "lo-fi hip hop beats, relaxing, study music", "meditation music, calm, zen"] },
|
||||
{ category: "Electronic", prompts: ["upbeat electronic dance music", "retro synthwave 80s style", "chill electronic ambient"] },
|
||||
{ category: "Cinematic", prompts: ["epic orchestral cinematic music", "tense suspenseful thriller music", "cheerful happy video game background"] },
|
||||
];
|
||||
|
||||
export default function MusicPage() {
|
||||
const [prompt, setPrompt] = useState("");
|
||||
const [duration, setDuration] = useState(30);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [audioUrl, setAudioUrl] = useState<string | null>(null);
|
||||
const audioRef = useRef<HTMLAudioElement>(null);
|
||||
|
||||
async function handleGenerate() {
|
||||
if (!prompt.trim()) {
|
||||
setError("프롬프트를 입력하세요");
|
||||
return;
|
||||
}
|
||||
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
const response = await fetch(`${API_BASE_URL}/api/v1/music/generate`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
prompt: prompt.trim(),
|
||||
duration,
|
||||
save_to_library: true,
|
||||
}),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const errorData = await response.json().catch(() => ({ detail: "Unknown error" }));
|
||||
throw new Error(errorData.detail || `HTTP ${response.status}`);
|
||||
}
|
||||
|
||||
const blob = await response.blob();
|
||||
|
||||
// 기존 URL 해제
|
||||
if (audioUrl) {
|
||||
URL.revokeObjectURL(audioUrl);
|
||||
}
|
||||
|
||||
const url = URL.createObjectURL(blob);
|
||||
setAudioUrl(url);
|
||||
|
||||
// 자동 재생
|
||||
setTimeout(() => {
|
||||
audioRef.current?.play();
|
||||
}, 100);
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : "음악 생성 실패");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
function handleDownload() {
|
||||
if (!audioUrl) return;
|
||||
|
||||
const a = document.createElement("a");
|
||||
a.href = audioUrl;
|
||||
a.download = `music_${Date.now()}.wav`;
|
||||
a.click();
|
||||
}
|
||||
|
||||
function selectPrompt(p: string) {
|
||||
setPrompt(p);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* 헤더 */}
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold">배경음악 생성</h1>
|
||||
<p className="text-muted-foreground">
|
||||
AI로 원하는 분위기의 배경음악을 생성하세요 (MusicGen)
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-6 lg:grid-cols-3">
|
||||
{/* 프롬프트 예시 */}
|
||||
<div className="space-y-4">
|
||||
<h2 className="font-semibold">프롬프트 예시</h2>
|
||||
<div className="space-y-4">
|
||||
{EXAMPLE_PROMPTS.map((category) => (
|
||||
<div key={category.category}>
|
||||
<h3 className="text-sm font-medium text-muted-foreground mb-2">
|
||||
{category.category}
|
||||
</h3>
|
||||
<div className="space-y-1">
|
||||
{category.prompts.map((p) => (
|
||||
<button
|
||||
key={p}
|
||||
onClick={() => selectPrompt(p)}
|
||||
className="w-full text-left rounded-lg border px-3 py-2 text-sm hover:bg-accent transition-colors"
|
||||
>
|
||||
{p}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 생성 영역 */}
|
||||
<div className="lg:col-span-2 space-y-4">
|
||||
<div>
|
||||
<label className="text-sm font-medium">프롬프트 (영어 권장)</label>
|
||||
<textarea
|
||||
value={prompt}
|
||||
onChange={(e) => setPrompt(e.target.value)}
|
||||
placeholder="원하는 음악을 설명하세요... (예: calm piano music, peaceful, ambient)"
|
||||
className="mt-1 w-full h-32 rounded-lg border bg-background px-3 py-2 text-sm resize-none focus:outline-none focus:ring-2 focus:ring-ring"
|
||||
maxLength={500}
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground text-right">
|
||||
{prompt.length}/500
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="text-sm font-medium">길이: {duration}초</label>
|
||||
<input
|
||||
type="range"
|
||||
min={5}
|
||||
max={30}
|
||||
value={duration}
|
||||
onChange={(e) => setDuration(Number(e.target.value))}
|
||||
className="mt-1 w-full"
|
||||
/>
|
||||
<div className="flex justify-between text-xs text-muted-foreground">
|
||||
<span>5초</span>
|
||||
<span>30초</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 에러 */}
|
||||
{error && (
|
||||
<div className="rounded-lg border border-destructive/50 bg-destructive/10 p-3 text-sm text-destructive">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 버튼 */}
|
||||
<button
|
||||
onClick={handleGenerate}
|
||||
disabled={loading || !prompt.trim()}
|
||||
className="w-full inline-flex items-center justify-center gap-2 rounded-lg bg-primary px-4 py-3 text-sm font-medium text-primary-foreground hover:bg-primary/90 disabled:opacity-50"
|
||||
>
|
||||
{loading ? (
|
||||
<>
|
||||
<div className="h-4 w-4 animate-spin rounded-full border-2 border-current border-t-transparent" />
|
||||
생성 중... (최대 2분 소요)
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
className="h-4 w-4"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
>
|
||||
<path d="M9 18V5l12-2v13" />
|
||||
<circle cx="6" cy="18" r="3" />
|
||||
<circle cx="18" cy="16" r="3" />
|
||||
</svg>
|
||||
음악 생성
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
|
||||
{/* 오디오 플레이어 */}
|
||||
{audioUrl && (
|
||||
<div className="rounded-lg border bg-card p-4 space-y-3">
|
||||
<h3 className="font-medium">생성된 음악</h3>
|
||||
<audio
|
||||
ref={audioRef}
|
||||
src={audioUrl}
|
||||
controls
|
||||
className="w-full"
|
||||
/>
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
onClick={() => audioRef.current?.play()}
|
||||
className="inline-flex items-center gap-2 rounded-lg border px-3 py-2 text-sm font-medium hover:bg-accent"
|
||||
>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
className="h-4 w-4"
|
||||
viewBox="0 0 24 24"
|
||||
fill="currentColor"
|
||||
>
|
||||
<polygon points="5 3 19 12 5 21 5 3" />
|
||||
</svg>
|
||||
재생
|
||||
</button>
|
||||
<button
|
||||
onClick={handleDownload}
|
||||
className="inline-flex items-center gap-2 rounded-lg border px-3 py-2 text-sm font-medium hover:bg-accent"
|
||||
>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
className="h-4 w-4"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
>
|
||||
<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4" />
|
||||
<polyline points="7 10 12 15 17 10" />
|
||||
<line x1="12" y1="15" x2="12" y2="3" />
|
||||
</svg>
|
||||
다운로드
|
||||
</button>
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
* MusicGen으로 생성된 음악은 CC-BY-NC 라이센스입니다. 비상업적 용도로 사용 가능합니다.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 정보 */}
|
||||
<div className="rounded-lg bg-secondary p-4">
|
||||
<h3 className="font-medium mb-2">MusicGen 정보</h3>
|
||||
<ul className="text-sm text-muted-foreground space-y-1">
|
||||
<li>• Meta AI의 오픈소스 AI 음악 생성 모델</li>
|
||||
<li>• 텍스트 프롬프트로 다양한 스타일의 음악 생성</li>
|
||||
<li>• 최대 30초 길이의 음악 생성 가능</li>
|
||||
<li>• 영어 프롬프트 사용 권장</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
235
audio-studio-ui/src/app/page.tsx
Normal file
235
audio-studio-ui/src/app/page.tsx
Normal file
@ -0,0 +1,235 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { healthCheck, getDramaProjects, DramaProject } from "@/lib/api";
|
||||
|
||||
export default function DashboardPage() {
|
||||
const [health, setHealth] = useState<{
|
||||
status: string;
|
||||
services: { mongodb: string; redis: string };
|
||||
} | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [projects, setProjects] = useState<DramaProject[]>([]);
|
||||
|
||||
useEffect(() => {
|
||||
healthCheck()
|
||||
.then(setHealth)
|
||||
.catch((e) => setError(e.message));
|
||||
|
||||
getDramaProjects()
|
||||
.then(setProjects)
|
||||
.catch(() => {});
|
||||
}, []);
|
||||
|
||||
// 프로젝트 통계
|
||||
const stats = {
|
||||
total: projects.length,
|
||||
draft: projects.filter(p => p.status === "draft").length,
|
||||
processing: projects.filter(p => p.status === "processing").length,
|
||||
completed: projects.filter(p => p.status === "completed").length,
|
||||
error: projects.filter(p => p.status === "error").length,
|
||||
};
|
||||
|
||||
const statusLabel: Record<string, { text: string; color: string }> = {
|
||||
draft: { text: "편집 중", color: "text-gray-600 bg-gray-100" },
|
||||
processing: { text: "렌더링", color: "text-blue-600 bg-blue-100" },
|
||||
completed: { text: "완료", color: "text-green-600 bg-green-100" },
|
||||
error: { text: "오류", color: "text-red-600 bg-red-100" },
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-6 max-w-5xl">
|
||||
{/* 헤더 */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold">Drama Studio</h1>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
AI 라디오 드라마 제작 시스템
|
||||
</p>
|
||||
</div>
|
||||
<a
|
||||
href="/drama"
|
||||
className="inline-flex items-center gap-2 rounded-md bg-primary px-4 py-2 text-sm font-medium text-primary-foreground hover:bg-primary/90"
|
||||
>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" style={{width: 16, height: 16}} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||||
<line x1="12" y1="5" x2="12" y2="19" />
|
||||
<line x1="5" y1="12" x2="19" y2="12" />
|
||||
</svg>
|
||||
새 드라마
|
||||
</a>
|
||||
</div>
|
||||
|
||||
{/* 프로젝트 현황 */}
|
||||
<div>
|
||||
<h2 className="text-lg font-semibold mb-3">프로젝트 현황</h2>
|
||||
<div className="grid gap-3 grid-cols-2 lg:grid-cols-5">
|
||||
<div className="rounded-lg border bg-card p-4">
|
||||
<h3 className="text-xs font-medium text-muted-foreground">전체</h3>
|
||||
<p className="mt-1 text-2xl font-bold">{stats.total}</p>
|
||||
</div>
|
||||
<div className="rounded-lg border bg-card p-4">
|
||||
<h3 className="text-xs font-medium text-muted-foreground">편집 중</h3>
|
||||
<p className="mt-1 text-2xl font-bold text-gray-600">{stats.draft}</p>
|
||||
</div>
|
||||
<div className="rounded-lg border bg-card p-4">
|
||||
<h3 className="text-xs font-medium text-muted-foreground">렌더링</h3>
|
||||
<p className="mt-1 text-2xl font-bold text-blue-600">{stats.processing}</p>
|
||||
</div>
|
||||
<div className="rounded-lg border bg-card p-4">
|
||||
<h3 className="text-xs font-medium text-muted-foreground">완료</h3>
|
||||
<p className="mt-1 text-2xl font-bold text-green-600">{stats.completed}</p>
|
||||
</div>
|
||||
<div className="rounded-lg border bg-card p-4">
|
||||
<h3 className="text-xs font-medium text-muted-foreground">오류</h3>
|
||||
<p className="mt-1 text-2xl font-bold text-red-600">{stats.error}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 최근 프로젝트 */}
|
||||
<div>
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<h2 className="text-lg font-semibold">최근 프로젝트</h2>
|
||||
{projects.length > 0 && (
|
||||
<a href="/drama" className="text-sm text-primary hover:underline">
|
||||
전체 보기
|
||||
</a>
|
||||
)}
|
||||
</div>
|
||||
{projects.length === 0 ? (
|
||||
<div className="rounded-lg border border-dashed p-8 text-center">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" style={{width: 40, height: 40, margin: "0 auto"}} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" className="text-muted-foreground">
|
||||
<path d="M4 19.5A2.5 2.5 0 0 1 6.5 17H20" />
|
||||
<path d="M6.5 2H20v20H6.5A2.5 2.5 0 0 1 4 19.5v-15A2.5 2.5 0 0 1 6.5 2z" />
|
||||
</svg>
|
||||
<p className="mt-3 text-sm text-muted-foreground">
|
||||
아직 생성된 드라마가 없습니다
|
||||
</p>
|
||||
<a
|
||||
href="/drama"
|
||||
className="mt-3 inline-flex items-center gap-1 text-sm text-primary hover:underline"
|
||||
>
|
||||
첫 드라마 만들기
|
||||
<svg xmlns="http://www.w3.org/2000/svg" style={{width: 14, height: 14}} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||||
<line x1="5" y1="12" x2="19" y2="12" />
|
||||
<polyline points="12 5 19 12 12 19" />
|
||||
</svg>
|
||||
</a>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
{projects.slice(0, 5).map((project) => (
|
||||
<a
|
||||
key={project.project_id}
|
||||
href={`/drama?id=${project.project_id}`}
|
||||
className="flex items-center justify-between rounded-lg border bg-card p-4 hover:bg-accent transition-colors"
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" style={{width: 20, height: 20, flexShrink: 0}} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" className="text-muted-foreground">
|
||||
<path d="M4 19.5A2.5 2.5 0 0 1 6.5 17H20" />
|
||||
<path d="M6.5 2H20v20H6.5A2.5 2.5 0 0 1 4 19.5v-15A2.5 2.5 0 0 1 6.5 2z" />
|
||||
</svg>
|
||||
<div>
|
||||
<h3 className="text-sm font-medium">{project.title}</h3>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{project.characters.length}명 캐릭터 · {project.element_count}개 요소
|
||||
{project.estimated_duration && ` · ${Math.round(project.estimated_duration / 60)}분`}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<span className={`rounded-full px-2 py-0.5 text-xs font-medium ${statusLabel[project.status]?.color || ""}`}>
|
||||
{statusLabel[project.status]?.text || project.status}
|
||||
</span>
|
||||
</a>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 시스템 상태 */}
|
||||
<div>
|
||||
<h2 className="text-lg font-semibold mb-3">시스템 상태</h2>
|
||||
<div className="grid gap-3 grid-cols-2 lg:grid-cols-4">
|
||||
<div className="rounded-lg border bg-card p-4">
|
||||
<h3 className="text-xs font-medium text-muted-foreground">API</h3>
|
||||
{error ? (
|
||||
<p className="mt-1 text-lg font-semibold text-destructive">오프라인</p>
|
||||
) : health ? (
|
||||
<p className={`mt-1 text-lg font-semibold ${health.status === "healthy" ? "text-green-600" : "text-yellow-600"}`}>
|
||||
{health.status === "healthy" ? "정상" : "점검 중"}
|
||||
</p>
|
||||
) : (
|
||||
<p className="mt-1 text-lg font-semibold text-muted-foreground">...</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="rounded-lg border bg-card p-4">
|
||||
<h3 className="text-xs font-medium text-muted-foreground">MongoDB</h3>
|
||||
<p className={`mt-1 text-lg font-semibold ${health?.services.mongodb === "healthy" ? "text-green-600" : "text-muted-foreground"}`}>
|
||||
{health?.services.mongodb === "healthy" ? "연결됨" : "-"}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="rounded-lg border bg-card p-4">
|
||||
<h3 className="text-xs font-medium text-muted-foreground">Redis</h3>
|
||||
<p className={`mt-1 text-lg font-semibold ${health?.services.redis === "healthy" ? "text-green-600" : "text-muted-foreground"}`}>
|
||||
{health?.services.redis === "healthy" ? "연결됨" : "-"}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="rounded-lg border bg-card p-4">
|
||||
<h3 className="text-xs font-medium text-muted-foreground">TTS 엔진</h3>
|
||||
<p className="mt-1 text-lg font-semibold text-muted-foreground">-</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 빠른 시작 */}
|
||||
<div>
|
||||
<h2 className="text-lg font-semibold mb-3">빠른 시작</h2>
|
||||
<div className="grid gap-3 grid-cols-1 md:grid-cols-3">
|
||||
<a href="/drama" className="flex items-center gap-3 rounded-lg border bg-card p-4 hover:bg-accent transition-colors">
|
||||
<div className="rounded-lg bg-orange-500/10 p-2">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" style={{width: 16, height: 16}} viewBox="0 0 24 24" fill="none" stroke="#ea580c" strokeWidth="2">
|
||||
<path d="M4 19.5A2.5 2.5 0 0 1 6.5 17H20" />
|
||||
<path d="M6.5 2H20v20H6.5A2.5 2.5 0 0 1 4 19.5v-15A2.5 2.5 0 0 1 6.5 2z" />
|
||||
</svg>
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="text-sm font-medium">드라마 제작</h3>
|
||||
<p className="text-xs text-muted-foreground">대본으로 라디오 드라마 생성</p>
|
||||
</div>
|
||||
</a>
|
||||
|
||||
<a href="/tts" className="flex items-center gap-3 rounded-lg border bg-card p-4 hover:bg-accent transition-colors">
|
||||
<div className="rounded-lg bg-blue-500/10 p-2">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" style={{width: 16, height: 16}} viewBox="0 0 24 24" fill="none" stroke="#2563eb" strokeWidth="2">
|
||||
<polygon points="11 5 6 9 2 9 2 15 6 15 11 19 11 5" />
|
||||
<path d="M19.07 4.93a10 10 0 0 1 0 14.14M15.54 8.46a5 5 0 0 1 0 7.07" />
|
||||
</svg>
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="text-sm font-medium">TTS 스튜디오</h3>
|
||||
<p className="text-xs text-muted-foreground">텍스트를 음성으로 변환</p>
|
||||
</div>
|
||||
</a>
|
||||
|
||||
<a href="/voices" className="flex items-center gap-3 rounded-lg border bg-card p-4 hover:bg-accent transition-colors">
|
||||
<div className="rounded-lg bg-green-500/10 p-2">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" style={{width: 16, height: 16}} viewBox="0 0 24 24" fill="none" stroke="#16a34a" strokeWidth="2">
|
||||
<path d="M12 1a3 3 0 0 0-3 3v8a3 3 0 0 0 6 0V4a3 3 0 0 0-3-3z" />
|
||||
<path d="M19 10v2a7 7 0 0 1-14 0v-2" />
|
||||
<line x1="12" y1="19" x2="12" y2="23" />
|
||||
<line x1="8" y1="23" x2="16" y2="23" />
|
||||
</svg>
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="text-sm font-medium">보이스 클론</h3>
|
||||
<p className="text-xs text-muted-foreground">3초 녹음으로 복제</p>
|
||||
</div>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
323
audio-studio-ui/src/app/recordings/page.tsx
Normal file
323
audio-studio-ui/src/app/recordings/page.tsx
Normal file
@ -0,0 +1,323 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useRef, useEffect } from "react";
|
||||
import { validateRecording, uploadRecording } from "@/lib/api";
|
||||
import { formatDuration } from "@/lib/utils";
|
||||
|
||||
export default function RecordingsPage() {
|
||||
const [isRecording, setIsRecording] = useState(false);
|
||||
const [audioUrl, setAudioUrl] = useState<string | null>(null);
|
||||
const [audioBlob, setAudioBlob] = useState<Blob | null>(null);
|
||||
const [duration, setDuration] = useState(0);
|
||||
const [validation, setValidation] = useState<{
|
||||
valid: boolean;
|
||||
quality_score: number;
|
||||
issues: string[];
|
||||
} | null>(null);
|
||||
const [transcript, setTranscript] = useState("");
|
||||
const [uploading, setUploading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [uploadResult, setUploadResult] = useState<{ file_id: string } | null>(null);
|
||||
|
||||
const mediaRecorderRef = useRef<MediaRecorder | null>(null);
|
||||
const chunksRef = useRef<Blob[]>([]);
|
||||
const timerRef = useRef<NodeJS.Timeout | null>(null);
|
||||
const audioRef = useRef<HTMLAudioElement>(null);
|
||||
|
||||
// 녹음 시간 업데이트
|
||||
useEffect(() => {
|
||||
if (isRecording) {
|
||||
timerRef.current = setInterval(() => {
|
||||
setDuration((d) => d + 1);
|
||||
}, 1000);
|
||||
} else {
|
||||
if (timerRef.current) {
|
||||
clearInterval(timerRef.current);
|
||||
}
|
||||
}
|
||||
|
||||
return () => {
|
||||
if (timerRef.current) {
|
||||
clearInterval(timerRef.current);
|
||||
}
|
||||
};
|
||||
}, [isRecording]);
|
||||
|
||||
async function startRecording() {
|
||||
try {
|
||||
const stream = await navigator.mediaDevices.getUserMedia({ audio: true });
|
||||
|
||||
const mediaRecorder = new MediaRecorder(stream, {
|
||||
mimeType: "audio/webm;codecs=opus",
|
||||
});
|
||||
|
||||
mediaRecorderRef.current = mediaRecorder;
|
||||
chunksRef.current = [];
|
||||
|
||||
mediaRecorder.ondataavailable = (e) => {
|
||||
if (e.data.size > 0) {
|
||||
chunksRef.current.push(e.data);
|
||||
}
|
||||
};
|
||||
|
||||
mediaRecorder.onstop = async () => {
|
||||
const blob = new Blob(chunksRef.current, { type: "audio/webm" });
|
||||
const url = URL.createObjectURL(blob);
|
||||
|
||||
setAudioBlob(blob);
|
||||
setAudioUrl(url);
|
||||
|
||||
// 스트림 정리
|
||||
stream.getTracks().forEach((track) => track.stop());
|
||||
|
||||
// 품질 검증
|
||||
await validateAudio(blob);
|
||||
};
|
||||
|
||||
mediaRecorder.start();
|
||||
setIsRecording(true);
|
||||
setDuration(0);
|
||||
setAudioUrl(null);
|
||||
setValidation(null);
|
||||
setUploadResult(null);
|
||||
setError(null);
|
||||
} catch (e) {
|
||||
setError("마이크 접근 권한이 필요합니다");
|
||||
}
|
||||
}
|
||||
|
||||
function stopRecording() {
|
||||
if (mediaRecorderRef.current && isRecording) {
|
||||
mediaRecorderRef.current.stop();
|
||||
setIsRecording(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function validateAudio(blob: Blob) {
|
||||
try {
|
||||
const file = new File([blob], "recording.webm", { type: "audio/webm" });
|
||||
const result = await validateRecording(file);
|
||||
setValidation({
|
||||
valid: result.valid,
|
||||
quality_score: result.quality_score,
|
||||
issues: result.issues,
|
||||
});
|
||||
} catch (e) {
|
||||
console.error("검증 실패:", e);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleUpload() {
|
||||
if (!audioBlob) return;
|
||||
|
||||
setUploading(true);
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
const file = new File([audioBlob], "recording.webm", { type: "audio/webm" });
|
||||
const result = await uploadRecording({
|
||||
audio: file,
|
||||
transcript: transcript.trim() || undefined,
|
||||
});
|
||||
setUploadResult(result);
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : "업로드 실패");
|
||||
} finally {
|
||||
setUploading(false);
|
||||
}
|
||||
}
|
||||
|
||||
function handleReset() {
|
||||
if (audioUrl) {
|
||||
URL.revokeObjectURL(audioUrl);
|
||||
}
|
||||
setAudioUrl(null);
|
||||
setAudioBlob(null);
|
||||
setValidation(null);
|
||||
setUploadResult(null);
|
||||
setDuration(0);
|
||||
setTranscript("");
|
||||
setError(null);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* 헤더 */}
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold">녹음</h1>
|
||||
<p className="text-muted-foreground">
|
||||
Voice Clone에 사용할 음성을 녹음하세요 (3초 이상 권장)
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* 녹음 카드 */}
|
||||
<div className="rounded-lg border bg-card p-6 max-w-xl mx-auto">
|
||||
{/* 녹음 버튼 */}
|
||||
<div className="flex flex-col items-center">
|
||||
<button
|
||||
onClick={isRecording ? stopRecording : startRecording}
|
||||
className={`relative w-24 h-24 rounded-full flex items-center justify-center transition-colors ${
|
||||
isRecording
|
||||
? "bg-destructive text-destructive-foreground recording-pulse"
|
||||
: "bg-primary text-primary-foreground hover:bg-primary/90"
|
||||
}`}
|
||||
>
|
||||
{isRecording ? (
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
className="h-10 w-10"
|
||||
viewBox="0 0 24 24"
|
||||
fill="currentColor"
|
||||
>
|
||||
<rect x="6" y="6" width="12" height="12" rx="2" />
|
||||
</svg>
|
||||
) : (
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
className="h-10 w-10"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
>
|
||||
<path d="M12 1a3 3 0 0 0-3 3v8a3 3 0 0 0 6 0V4a3 3 0 0 0-3-3z" />
|
||||
<path d="M19 10v2a7 7 0 0 1-14 0v-2" />
|
||||
<line x1="12" y1="19" x2="12" y2="23" />
|
||||
<line x1="8" y1="23" x2="16" y2="23" />
|
||||
</svg>
|
||||
)}
|
||||
</button>
|
||||
|
||||
<p className="mt-4 text-lg font-mono">
|
||||
{formatDuration(duration)}
|
||||
</p>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{isRecording ? "녹음 중... 클릭하여 중지" : "클릭하여 녹음 시작"}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* 스크립트 가이드 */}
|
||||
{!audioUrl && (
|
||||
<div className="mt-6 rounded-lg bg-secondary p-4">
|
||||
<h3 className="font-medium mb-2">녹음 가이드</h3>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
다음 문장을 또박또박 읽어주세요:
|
||||
</p>
|
||||
<p className="mt-2 text-sm font-medium">
|
||||
"안녕하세요, 저는 인공지능 음성 합성 테스트를 위해 녹음하고
|
||||
있습니다. 오늘 날씨가 정말 좋네요."
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 에러 */}
|
||||
{error && (
|
||||
<div className="mt-4 rounded-lg border border-destructive/50 bg-destructive/10 p-3 text-sm text-destructive">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 녹음 결과 */}
|
||||
{audioUrl && (
|
||||
<div className="mt-6 space-y-4">
|
||||
<audio
|
||||
ref={audioRef}
|
||||
src={audioUrl}
|
||||
controls
|
||||
className="w-full"
|
||||
/>
|
||||
|
||||
{/* 검증 결과 */}
|
||||
{validation && (
|
||||
<div
|
||||
className={`rounded-lg p-4 ${
|
||||
validation.valid
|
||||
? "bg-green-50 border border-green-200"
|
||||
: "bg-yellow-50 border border-yellow-200"
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<span className="font-medium">
|
||||
{validation.valid ? "녹음 품질 양호" : "품질 개선 필요"}
|
||||
</span>
|
||||
<span className="text-sm">
|
||||
점수: {Math.round(validation.quality_score * 100)}%
|
||||
</span>
|
||||
</div>
|
||||
{validation.issues.length > 0 && (
|
||||
<ul className="text-sm space-y-1">
|
||||
{validation.issues.map((issue, i) => (
|
||||
<li key={i} className="text-muted-foreground">
|
||||
• {issue}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 트랜스크립트 입력 */}
|
||||
<div>
|
||||
<label className="text-sm font-medium">
|
||||
녹음 내용 (선택)
|
||||
</label>
|
||||
<textarea
|
||||
value={transcript}
|
||||
onChange={(e) => setTranscript(e.target.value)}
|
||||
placeholder="녹음에서 말한 내용을 입력하세요..."
|
||||
className="mt-1 w-full h-20 rounded-lg border bg-background px-3 py-2 text-sm resize-none focus:outline-none focus:ring-2 focus:ring-ring"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 업로드 결과 */}
|
||||
{uploadResult && (
|
||||
<div className="rounded-lg bg-green-50 border border-green-200 p-4">
|
||||
<p className="font-medium text-green-700">업로드 완료!</p>
|
||||
<p className="text-sm text-green-600">
|
||||
File ID: {uploadResult.file_id}
|
||||
</p>
|
||||
<p className="text-sm text-muted-foreground mt-2">
|
||||
이제 보이스 클론 페이지에서 이 녹음을 사용할 수 있습니다.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 버튼 */}
|
||||
<div className="flex gap-3">
|
||||
<button
|
||||
onClick={handleReset}
|
||||
className="flex-1 inline-flex items-center justify-center rounded-lg border px-4 py-2 text-sm font-medium hover:bg-accent"
|
||||
>
|
||||
다시 녹음
|
||||
</button>
|
||||
{!uploadResult && (
|
||||
<button
|
||||
onClick={handleUpload}
|
||||
disabled={uploading}
|
||||
className="flex-1 inline-flex items-center justify-center gap-2 rounded-lg bg-primary px-4 py-2 text-sm font-medium text-primary-foreground hover:bg-primary/90 disabled:opacity-50"
|
||||
>
|
||||
{uploading ? (
|
||||
<>
|
||||
<div className="h-4 w-4 animate-spin rounded-full border-2 border-current border-t-transparent" />
|
||||
업로드 중...
|
||||
</>
|
||||
) : (
|
||||
"업로드"
|
||||
)}
|
||||
</button>
|
||||
)}
|
||||
{uploadResult && (
|
||||
<a
|
||||
href="/voices"
|
||||
className="flex-1 inline-flex items-center justify-center rounded-lg bg-primary px-4 py-2 text-sm font-medium text-primary-foreground hover:bg-primary/90"
|
||||
>
|
||||
보이스 클론으로 이동
|
||||
</a>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
255
audio-studio-ui/src/app/sound-effects/page.tsx
Normal file
255
audio-studio-ui/src/app/sound-effects/page.tsx
Normal file
@ -0,0 +1,255 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { searchSoundEffects, getSoundEffectAudioUrl, SoundEffect } from "@/lib/api";
|
||||
import { formatDuration } from "@/lib/utils";
|
||||
|
||||
export default function SoundEffectsPage() {
|
||||
const [query, setQuery] = useState("");
|
||||
const [results, setResults] = useState<SoundEffect[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [playingId, setPlayingId] = useState<string | null>(null);
|
||||
const [totalCount, setTotalCount] = useState(0);
|
||||
|
||||
async function handleSearch(e?: React.FormEvent) {
|
||||
e?.preventDefault();
|
||||
if (!query.trim()) return;
|
||||
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
const response = await searchSoundEffects({
|
||||
query: query.trim(),
|
||||
page_size: 20,
|
||||
});
|
||||
setResults(response.results);
|
||||
setTotalCount(response.count);
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : "검색 실패");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
function playSound(soundId: string, previewUrl?: string) {
|
||||
const url = previewUrl || getSoundEffectAudioUrl(soundId);
|
||||
const audio = new Audio(url);
|
||||
audio.onplay = () => setPlayingId(soundId);
|
||||
audio.onended = () => setPlayingId(null);
|
||||
audio.onerror = () => {
|
||||
setPlayingId(null);
|
||||
alert("재생 실패");
|
||||
};
|
||||
audio.play();
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* 헤더 */}
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold">효과음 라이브러리</h1>
|
||||
<p className="text-muted-foreground">
|
||||
Freesound에서 무료 효과음을 검색하고 다운로드하세요
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* 검색 */}
|
||||
<form onSubmit={handleSearch} className="flex gap-3">
|
||||
<input
|
||||
type="text"
|
||||
value={query}
|
||||
onChange={(e) => setQuery(e.target.value)}
|
||||
placeholder="효과음 검색 (예: door, explosion, rain...)"
|
||||
className="flex-1 rounded-lg border bg-background px-4 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-ring"
|
||||
/>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={loading || !query.trim()}
|
||||
className="inline-flex items-center gap-2 rounded-lg bg-primary px-4 py-2 text-sm font-medium text-primary-foreground hover:bg-primary/90 disabled:opacity-50"
|
||||
>
|
||||
{loading ? (
|
||||
<div className="h-4 w-4 animate-spin rounded-full border-2 border-current border-t-transparent" />
|
||||
) : (
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
className="h-4 w-4"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
>
|
||||
<circle cx="11" cy="11" r="8" />
|
||||
<line x1="21" y1="21" x2="16.65" y2="16.65" />
|
||||
</svg>
|
||||
)}
|
||||
검색
|
||||
</button>
|
||||
</form>
|
||||
|
||||
{/* 에러 */}
|
||||
{error && (
|
||||
<div className="rounded-lg border border-destructive/50 bg-destructive/10 p-4 text-destructive">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 결과 수 */}
|
||||
{totalCount > 0 && (
|
||||
<p className="text-sm text-muted-foreground">
|
||||
총 {totalCount.toLocaleString()}개의 결과
|
||||
</p>
|
||||
)}
|
||||
|
||||
{/* 결과 그리드 */}
|
||||
{results.length > 0 && (
|
||||
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
|
||||
{results.map((sound) => (
|
||||
<div
|
||||
key={sound.id}
|
||||
className="rounded-lg border bg-card p-4 space-y-3"
|
||||
>
|
||||
<div>
|
||||
<h3 className="font-medium truncate" title={sound.name}>
|
||||
{sound.name}
|
||||
</h3>
|
||||
<p className="text-sm text-muted-foreground line-clamp-2">
|
||||
{sound.description || "설명 없음"}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2 text-sm text-muted-foreground">
|
||||
<span>{formatDuration(sound.duration)}</span>
|
||||
<span>·</span>
|
||||
<span className="truncate">{sound.license || "Unknown"}</span>
|
||||
</div>
|
||||
|
||||
{/* 태그 */}
|
||||
{sound.tags && sound.tags.length > 0 && (
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{sound.tags.slice(0, 5).map((tag) => (
|
||||
<span
|
||||
key={tag}
|
||||
className="rounded bg-secondary px-1.5 py-0.5 text-xs"
|
||||
>
|
||||
{tag}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 버튼 */}
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
onClick={() => playSound(sound.id, sound.preview_url)}
|
||||
disabled={playingId === sound.id}
|
||||
className="flex-1 inline-flex items-center justify-center gap-2 rounded-lg border px-3 py-2 text-sm font-medium hover:bg-accent disabled:opacity-50"
|
||||
>
|
||||
{playingId === sound.id ? (
|
||||
<>
|
||||
<div className="h-4 w-4 animate-spin rounded-full border-2 border-current border-t-transparent" />
|
||||
재생 중
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
className="h-4 w-4"
|
||||
viewBox="0 0 24 24"
|
||||
fill="currentColor"
|
||||
>
|
||||
<polygon points="5 3 19 12 5 21 5 3" />
|
||||
</svg>
|
||||
재생
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
{sound.preview_url && (
|
||||
<a
|
||||
href={sound.preview_url}
|
||||
download
|
||||
className="inline-flex items-center justify-center rounded-lg border px-3 py-2 text-sm font-medium hover:bg-accent"
|
||||
title="다운로드"
|
||||
>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
className="h-4 w-4"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
>
|
||||
<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4" />
|
||||
<polyline points="7 10 12 15 17 10" />
|
||||
<line x1="12" y1="15" x2="12" y2="3" />
|
||||
</svg>
|
||||
</a>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 빈 상태 */}
|
||||
{!loading && results.length === 0 && query && (
|
||||
<div className="flex flex-col items-center justify-center py-12 text-center">
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
className="h-12 w-12 text-muted-foreground"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
>
|
||||
<circle cx="11" cy="11" r="8" />
|
||||
<line x1="21" y1="21" x2="16.65" y2="16.65" />
|
||||
</svg>
|
||||
<h3 className="mt-4 font-semibold">검색 결과 없음</h3>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
다른 키워드로 검색해보세요
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 초기 상태 */}
|
||||
{!loading && results.length === 0 && !query && (
|
||||
<div className="flex flex-col items-center justify-center py-12 text-center">
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
className="h-12 w-12 text-muted-foreground"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
>
|
||||
<path d="M9 18V5l12-2v13" />
|
||||
<circle cx="6" cy="18" r="3" />
|
||||
<circle cx="18" cy="16" r="3" />
|
||||
</svg>
|
||||
<h3 className="mt-4 font-semibold">효과음 검색</h3>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
검색어를 입력하여 Freesound에서 효과음을 찾아보세요
|
||||
</p>
|
||||
<div className="mt-4 flex flex-wrap justify-center gap-2">
|
||||
{["door", "footsteps", "rain", "thunder", "explosion", "whoosh"].map(
|
||||
(term) => (
|
||||
<button
|
||||
key={term}
|
||||
onClick={() => {
|
||||
setQuery(term);
|
||||
setTimeout(() => handleSearch(), 0);
|
||||
}}
|
||||
className="rounded-full border px-3 py-1 text-sm hover:bg-accent"
|
||||
>
|
||||
{term}
|
||||
</button>
|
||||
)
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
298
audio-studio-ui/src/app/tts/page.tsx
Normal file
298
audio-studio-ui/src/app/tts/page.tsx
Normal file
@ -0,0 +1,298 @@
|
||||
"use client";
|
||||
|
||||
import { Suspense, useEffect, useState, useRef } from "react";
|
||||
import { useSearchParams } from "next/navigation";
|
||||
import { getVoices, synthesize, Voice } from "@/lib/api";
|
||||
|
||||
function TTSContent() {
|
||||
const searchParams = useSearchParams();
|
||||
const initialVoiceId = searchParams.get("voice");
|
||||
|
||||
const [voices, setVoices] = useState<Voice[]>([]);
|
||||
const [selectedVoiceId, setSelectedVoiceId] = useState<string>(
|
||||
initialVoiceId || ""
|
||||
);
|
||||
const [text, setText] = useState("");
|
||||
const [instruct, setInstruct] = useState("");
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [audioUrl, setAudioUrl] = useState<string | null>(null);
|
||||
const audioRef = useRef<HTMLAudioElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
loadVoices();
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (initialVoiceId && !selectedVoiceId) {
|
||||
setSelectedVoiceId(initialVoiceId);
|
||||
}
|
||||
}, [initialVoiceId, selectedVoiceId]);
|
||||
|
||||
async function loadVoices() {
|
||||
try {
|
||||
const response = await getVoices({ page_size: 100 });
|
||||
setVoices(response.voices);
|
||||
|
||||
// 기본 선택
|
||||
if (!selectedVoiceId && response.voices.length > 0) {
|
||||
setSelectedVoiceId(response.voices[0].voice_id);
|
||||
}
|
||||
} catch (e) {
|
||||
console.error("보이스 로드 실패:", e);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleSynthesize() {
|
||||
if (!selectedVoiceId || !text.trim()) {
|
||||
setError("보이스와 텍스트를 입력하세요");
|
||||
return;
|
||||
}
|
||||
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
const blob = await synthesize({
|
||||
voice_id: selectedVoiceId,
|
||||
text: text.trim(),
|
||||
instruct: instruct.trim() || undefined,
|
||||
});
|
||||
|
||||
// 기존 URL 해제
|
||||
if (audioUrl) {
|
||||
URL.revokeObjectURL(audioUrl);
|
||||
}
|
||||
|
||||
const url = URL.createObjectURL(blob);
|
||||
setAudioUrl(url);
|
||||
|
||||
// 자동 재생
|
||||
setTimeout(() => {
|
||||
audioRef.current?.play();
|
||||
}, 100);
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : "TTS 합성 실패");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
function handleDownload() {
|
||||
if (!audioUrl) return;
|
||||
|
||||
const a = document.createElement("a");
|
||||
a.href = audioUrl;
|
||||
a.download = `tts_${Date.now()}.wav`;
|
||||
a.click();
|
||||
}
|
||||
|
||||
const selectedVoice = voices.find((v) => v.voice_id === selectedVoiceId);
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* 헤더 */}
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold">TTS 스튜디오</h1>
|
||||
<p className="text-muted-foreground">
|
||||
텍스트를 자연스러운 음성으로 변환하세요
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-6 lg:grid-cols-3">
|
||||
{/* 보이스 선택 */}
|
||||
<div className="space-y-4">
|
||||
<h2 className="font-semibold">보이스 선택</h2>
|
||||
|
||||
<div className="space-y-2 max-h-96 overflow-y-auto">
|
||||
{voices.map((voice) => (
|
||||
<button
|
||||
key={voice.voice_id}
|
||||
onClick={() => setSelectedVoiceId(voice.voice_id)}
|
||||
className={`w-full text-left rounded-lg border p-3 transition-colors ${
|
||||
selectedVoiceId === voice.voice_id
|
||||
? "border-primary bg-primary/5"
|
||||
: "hover:bg-accent"
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="font-medium">{voice.name}</span>
|
||||
<span
|
||||
className={`text-xs px-1.5 py-0.5 rounded ${
|
||||
voice.type === "preset"
|
||||
? "bg-blue-100 text-blue-700"
|
||||
: voice.type === "cloned"
|
||||
? "bg-green-100 text-green-700"
|
||||
: "bg-purple-100 text-purple-700"
|
||||
}`}
|
||||
>
|
||||
{voice.type === "preset"
|
||||
? "프리셋"
|
||||
: voice.type === "cloned"
|
||||
? "클론"
|
||||
: "디자인"}
|
||||
</span>
|
||||
</div>
|
||||
{voice.description && (
|
||||
<p className="text-sm text-muted-foreground truncate">
|
||||
{voice.description}
|
||||
</p>
|
||||
)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{selectedVoice && (
|
||||
<div className="rounded-lg border bg-card p-4">
|
||||
<h3 className="font-medium mb-2">선택된 보이스</h3>
|
||||
<p className="text-sm">{selectedVoice.name}</p>
|
||||
{selectedVoice.style_tags && selectedVoice.style_tags.length > 0 && (
|
||||
<div className="mt-2 flex flex-wrap gap-1">
|
||||
{selectedVoice.style_tags.map((tag) => (
|
||||
<span
|
||||
key={tag}
|
||||
className="rounded bg-secondary px-1.5 py-0.5 text-xs"
|
||||
>
|
||||
{tag}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 입력 영역 */}
|
||||
<div className="lg:col-span-2 space-y-4">
|
||||
<div>
|
||||
<label className="text-sm font-medium">텍스트 입력</label>
|
||||
<textarea
|
||||
value={text}
|
||||
onChange={(e) => setText(e.target.value)}
|
||||
placeholder="변환할 텍스트를 입력하세요..."
|
||||
className="mt-1 w-full h-40 rounded-lg border bg-background px-3 py-2 text-sm resize-none focus:outline-none focus:ring-2 focus:ring-ring"
|
||||
maxLength={5000}
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground text-right">
|
||||
{text.length}/5000
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="text-sm font-medium">
|
||||
감정/스타일 지시 (선택)
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={instruct}
|
||||
onChange={(e) => setInstruct(e.target.value)}
|
||||
placeholder="예: 밝고 활기차게, 슬프게, 화난 목소리로..."
|
||||
className="mt-1 w-full rounded-lg border bg-background px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-ring"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 에러 */}
|
||||
{error && (
|
||||
<div className="rounded-lg border border-destructive/50 bg-destructive/10 p-3 text-sm text-destructive">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 버튼 */}
|
||||
<div className="flex gap-3">
|
||||
<button
|
||||
onClick={handleSynthesize}
|
||||
disabled={loading || !text.trim() || !selectedVoiceId}
|
||||
className="flex-1 inline-flex items-center justify-center gap-2 rounded-lg bg-primary px-4 py-3 text-sm font-medium text-primary-foreground hover:bg-primary/90 disabled:opacity-50"
|
||||
>
|
||||
{loading ? (
|
||||
<>
|
||||
<div className="h-4 w-4 animate-spin rounded-full border-2 border-current border-t-transparent" />
|
||||
생성 중...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
className="h-4 w-4"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
>
|
||||
<polygon points="11 5 6 9 2 9 2 15 6 15 11 19 11 5" />
|
||||
<path d="M19.07 4.93a10 10 0 0 1 0 14.14M15.54 8.46a5 5 0 0 1 0 7.07" />
|
||||
</svg>
|
||||
음성 생성
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* 오디오 플레이어 */}
|
||||
{audioUrl && (
|
||||
<div className="rounded-lg border bg-card p-4 space-y-3">
|
||||
<h3 className="font-medium">생성된 음성</h3>
|
||||
<audio
|
||||
ref={audioRef}
|
||||
src={audioUrl}
|
||||
controls
|
||||
className="w-full"
|
||||
/>
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
onClick={() => audioRef.current?.play()}
|
||||
className="inline-flex items-center gap-2 rounded-lg border px-3 py-2 text-sm font-medium hover:bg-accent"
|
||||
>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
className="h-4 w-4"
|
||||
viewBox="0 0 24 24"
|
||||
fill="currentColor"
|
||||
>
|
||||
<polygon points="5 3 19 12 5 21 5 3" />
|
||||
</svg>
|
||||
재생
|
||||
</button>
|
||||
<button
|
||||
onClick={handleDownload}
|
||||
className="inline-flex items-center gap-2 rounded-lg border px-3 py-2 text-sm font-medium hover:bg-accent"
|
||||
>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
className="h-4 w-4"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
>
|
||||
<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4" />
|
||||
<polyline points="7 10 12 15 17 10" />
|
||||
<line x1="12" y1="15" x2="12" y2="3" />
|
||||
</svg>
|
||||
다운로드
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function LoadingFallback() {
|
||||
return (
|
||||
<div className="flex items-center justify-center min-h-[400px]">
|
||||
<div className="h-8 w-8 animate-spin rounded-full border-4 border-primary border-t-transparent" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function TTSPage() {
|
||||
return (
|
||||
<Suspense fallback={<LoadingFallback />}>
|
||||
<TTSContent />
|
||||
</Suspense>
|
||||
);
|
||||
}
|
||||
235
audio-studio-ui/src/app/voices/page.tsx
Normal file
235
audio-studio-ui/src/app/voices/page.tsx
Normal file
@ -0,0 +1,235 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { getVoices, getVoiceSampleUrl, Voice, VoiceListResponse } from "@/lib/api";
|
||||
|
||||
export default function VoicesPage() {
|
||||
const [voices, setVoices] = useState<Voice[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [filter, setFilter] = useState<"all" | "preset" | "cloned" | "designed">("all");
|
||||
const [playingId, setPlayingId] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
loadVoices();
|
||||
}, [filter]);
|
||||
|
||||
async function loadVoices() {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
const params: { type?: string } = {};
|
||||
if (filter !== "all") {
|
||||
params.type = filter;
|
||||
}
|
||||
const response = await getVoices(params);
|
||||
setVoices(response.voices);
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : "보이스 로드 실패");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
function playVoiceSample(voiceId: string) {
|
||||
const audio = new Audio(getVoiceSampleUrl(voiceId));
|
||||
audio.onplay = () => setPlayingId(voiceId);
|
||||
audio.onended = () => setPlayingId(null);
|
||||
audio.onerror = () => {
|
||||
setPlayingId(null);
|
||||
alert("샘플 재생 실패");
|
||||
};
|
||||
audio.play();
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* 헤더 */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold">보이스 라이브러리</h1>
|
||||
<p className="text-muted-foreground">
|
||||
다양한 AI 음성을 선택하거나 새로운 보이스를 만들어보세요
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<a
|
||||
href="/voices/clone"
|
||||
className="inline-flex items-center gap-2 rounded-lg bg-primary px-4 py-2 text-sm font-medium text-primary-foreground hover:bg-primary/90"
|
||||
>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
className="h-4 w-4"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
>
|
||||
<path d="M12 1a3 3 0 0 0-3 3v8a3 3 0 0 0 6 0V4a3 3 0 0 0-3-3z" />
|
||||
<path d="M19 10v2a7 7 0 0 1-14 0v-2" />
|
||||
</svg>
|
||||
보이스 클론
|
||||
</a>
|
||||
<a
|
||||
href="/voices/design"
|
||||
className="inline-flex items-center gap-2 rounded-lg border px-4 py-2 text-sm font-medium hover:bg-accent"
|
||||
>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
className="h-4 w-4"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
>
|
||||
<path d="M12 20h9" />
|
||||
<path d="M16.5 3.5a2.121 2.121 0 0 1 3 3L7 19l-4 1 1-4L16.5 3.5z" />
|
||||
</svg>
|
||||
보이스 디자인
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 필터 */}
|
||||
<div className="flex gap-2">
|
||||
{[
|
||||
{ value: "all", label: "전체" },
|
||||
{ value: "preset", label: "프리셋" },
|
||||
{ value: "cloned", label: "클론" },
|
||||
{ value: "designed", label: "디자인" },
|
||||
].map((item) => (
|
||||
<button
|
||||
key={item.value}
|
||||
onClick={() => setFilter(item.value as typeof filter)}
|
||||
className={`rounded-full px-4 py-1.5 text-sm font-medium transition-colors ${
|
||||
filter === item.value
|
||||
? "bg-primary text-primary-foreground"
|
||||
: "bg-secondary hover:bg-secondary/80"
|
||||
}`}
|
||||
>
|
||||
{item.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* 에러 */}
|
||||
{error && (
|
||||
<div className="rounded-lg border border-destructive/50 bg-destructive/10 p-4 text-destructive">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 로딩 */}
|
||||
{loading && (
|
||||
<div className="flex items-center justify-center py-12">
|
||||
<div className="h-8 w-8 animate-spin rounded-full border-4 border-primary border-t-transparent" />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 보이스 그리드 */}
|
||||
{!loading && voices.length > 0 && (
|
||||
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
|
||||
{voices.map((voice) => (
|
||||
<div
|
||||
key={voice.voice_id}
|
||||
className="flex flex-col rounded-lg border bg-card p-4 hover:shadow-md transition-shadow"
|
||||
>
|
||||
<div className="flex items-start justify-between">
|
||||
<div>
|
||||
<h3 className="font-semibold">{voice.name}</h3>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{voice.description || "설명 없음"}
|
||||
</p>
|
||||
</div>
|
||||
<span
|
||||
className={`rounded-full px-2 py-0.5 text-xs font-medium ${
|
||||
voice.type === "preset"
|
||||
? "bg-blue-100 text-blue-700"
|
||||
: voice.type === "cloned"
|
||||
? "bg-green-100 text-green-700"
|
||||
: "bg-purple-100 text-purple-700"
|
||||
}`}
|
||||
>
|
||||
{voice.type === "preset"
|
||||
? "프리셋"
|
||||
: voice.type === "cloned"
|
||||
? "클론"
|
||||
: "디자인"}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* 스타일 태그 */}
|
||||
{voice.style_tags && voice.style_tags.length > 0 && (
|
||||
<div className="mt-2 flex flex-wrap gap-1">
|
||||
{voice.style_tags.slice(0, 3).map((tag) => (
|
||||
<span
|
||||
key={tag}
|
||||
className="rounded bg-secondary px-1.5 py-0.5 text-xs"
|
||||
>
|
||||
{tag}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 액션 버튼 */}
|
||||
<div className="mt-4 flex gap-2">
|
||||
<button
|
||||
onClick={() => playVoiceSample(voice.voice_id)}
|
||||
disabled={playingId === voice.voice_id}
|
||||
className="flex-1 inline-flex items-center justify-center gap-2 rounded-lg border px-3 py-2 text-sm font-medium hover:bg-accent disabled:opacity-50"
|
||||
>
|
||||
{playingId === voice.voice_id ? (
|
||||
<>
|
||||
<div className="h-4 w-4 animate-spin rounded-full border-2 border-current border-t-transparent" />
|
||||
재생 중
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
className="h-4 w-4"
|
||||
viewBox="0 0 24 24"
|
||||
fill="currentColor"
|
||||
>
|
||||
<polygon points="5 3 19 12 5 21 5 3" />
|
||||
</svg>
|
||||
미리듣기
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
<a
|
||||
href={`/tts?voice=${voice.voice_id}`}
|
||||
className="inline-flex items-center justify-center rounded-lg bg-primary px-3 py-2 text-sm font-medium text-primary-foreground hover:bg-primary/90"
|
||||
>
|
||||
사용
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 빈 상태 */}
|
||||
{!loading && voices.length === 0 && (
|
||||
<div className="flex flex-col items-center justify-center py-12 text-center">
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
className="h-12 w-12 text-muted-foreground"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
>
|
||||
<path d="M12 1a3 3 0 0 0-3 3v8a3 3 0 0 0 6 0V4a3 3 0 0 0-3-3z" />
|
||||
<path d="M19 10v2a7 7 0 0 1-14 0v-2" />
|
||||
</svg>
|
||||
<h3 className="mt-4 font-semibold">보이스가 없습니다</h3>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
새로운 보이스를 클론하거나 디자인해보세요
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
371
audio-studio-ui/src/lib/api.ts
Normal file
371
audio-studio-ui/src/lib/api.ts
Normal file
@ -0,0 +1,371 @@
|
||||
/**
|
||||
* Audio Studio API 클라이언트
|
||||
*/
|
||||
|
||||
const API_BASE_URL = process.env.NEXT_PUBLIC_API_URL || "http://localhost:8010";
|
||||
|
||||
// ========================================
|
||||
// 타입 정의
|
||||
// ========================================
|
||||
|
||||
export interface Voice {
|
||||
voice_id: string;
|
||||
name: string;
|
||||
description?: string;
|
||||
type: "preset" | "cloned" | "designed";
|
||||
language: string;
|
||||
preset_voice_id?: string;
|
||||
design_prompt?: string;
|
||||
reference_transcript?: string;
|
||||
gender?: string;
|
||||
style_tags?: string[];
|
||||
is_public: boolean;
|
||||
sample_audio_id?: string;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
}
|
||||
|
||||
export interface VoiceListResponse {
|
||||
voices: Voice[];
|
||||
total: number;
|
||||
page: number;
|
||||
page_size: number;
|
||||
}
|
||||
|
||||
export interface TTSGenerationResponse {
|
||||
generation_id: string;
|
||||
voice_id: string;
|
||||
text: string;
|
||||
status: string;
|
||||
audio_file_id?: string;
|
||||
duration_seconds?: number;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
export interface RecordingValidateResponse {
|
||||
valid: boolean;
|
||||
duration: number;
|
||||
sample_rate: number;
|
||||
quality_score: number;
|
||||
issues: string[];
|
||||
}
|
||||
|
||||
export interface SoundEffect {
|
||||
id: string;
|
||||
freesound_id?: number;
|
||||
name: string;
|
||||
description: string;
|
||||
duration: number;
|
||||
tags: string[];
|
||||
preview_url?: string;
|
||||
license: string;
|
||||
source: string;
|
||||
}
|
||||
|
||||
export interface SoundEffectSearchResponse {
|
||||
count: number;
|
||||
page: number;
|
||||
page_size: number;
|
||||
results: SoundEffect[];
|
||||
}
|
||||
|
||||
// ========================================
|
||||
// API 함수
|
||||
// ========================================
|
||||
|
||||
async function fetchAPI<T>(
|
||||
endpoint: string,
|
||||
options?: RequestInit
|
||||
): Promise<T> {
|
||||
const response = await fetch(`${API_BASE_URL}${endpoint}`, {
|
||||
...options,
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
...options?.headers,
|
||||
},
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const error = await response.json().catch(() => ({ detail: "Unknown error" }));
|
||||
throw new Error(error.detail || `HTTP ${response.status}`);
|
||||
}
|
||||
|
||||
return response.json();
|
||||
}
|
||||
|
||||
// ========================================
|
||||
// Voice API
|
||||
// ========================================
|
||||
|
||||
export async function getVoices(params?: {
|
||||
type?: string;
|
||||
language?: string;
|
||||
page?: number;
|
||||
page_size?: number;
|
||||
}): Promise<VoiceListResponse> {
|
||||
const searchParams = new URLSearchParams();
|
||||
if (params?.type) searchParams.set("type", params.type);
|
||||
if (params?.language) searchParams.set("language", params.language);
|
||||
if (params?.page) searchParams.set("page", String(params.page));
|
||||
if (params?.page_size) searchParams.set("page_size", String(params.page_size));
|
||||
|
||||
return fetchAPI(`/api/v1/voices?${searchParams}`);
|
||||
}
|
||||
|
||||
export async function getVoice(voiceId: string): Promise<Voice> {
|
||||
return fetchAPI(`/api/v1/voices/${voiceId}`);
|
||||
}
|
||||
|
||||
export function getVoiceSampleUrl(voiceId: string): string {
|
||||
return `${API_BASE_URL}/api/v1/voices/${voiceId}/sample`;
|
||||
}
|
||||
|
||||
export async function createVoiceClone(data: {
|
||||
name: string;
|
||||
description?: string;
|
||||
reference_transcript: string;
|
||||
language?: string;
|
||||
is_public?: boolean;
|
||||
reference_audio: File;
|
||||
}): Promise<Voice> {
|
||||
const formData = new FormData();
|
||||
formData.append("name", data.name);
|
||||
if (data.description) formData.append("description", data.description);
|
||||
formData.append("reference_transcript", data.reference_transcript);
|
||||
formData.append("language", data.language || "ko");
|
||||
formData.append("is_public", String(data.is_public || false));
|
||||
formData.append("reference_audio", data.reference_audio);
|
||||
|
||||
const response = await fetch(`${API_BASE_URL}/api/v1/voices/clone`, {
|
||||
method: "POST",
|
||||
body: formData,
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const error = await response.json().catch(() => ({ detail: "Unknown error" }));
|
||||
throw new Error(error.detail || `HTTP ${response.status}`);
|
||||
}
|
||||
|
||||
return response.json();
|
||||
}
|
||||
|
||||
export async function createVoiceDesign(data: {
|
||||
name: string;
|
||||
description?: string;
|
||||
design_prompt: string;
|
||||
language?: string;
|
||||
is_public?: boolean;
|
||||
}): Promise<Voice> {
|
||||
return fetchAPI("/api/v1/voices/design", {
|
||||
method: "POST",
|
||||
body: JSON.stringify(data),
|
||||
});
|
||||
}
|
||||
|
||||
export async function deleteVoice(voiceId: string): Promise<void> {
|
||||
await fetchAPI(`/api/v1/voices/${voiceId}`, { method: "DELETE" });
|
||||
}
|
||||
|
||||
// ========================================
|
||||
// TTS API
|
||||
// ========================================
|
||||
|
||||
export async function synthesize(data: {
|
||||
voice_id: string;
|
||||
text: string;
|
||||
instruct?: string;
|
||||
}): Promise<Blob> {
|
||||
const response = await fetch(`${API_BASE_URL}/api/v1/tts/synthesize`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(data),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const error = await response.json().catch(() => ({ detail: "Unknown error" }));
|
||||
throw new Error(error.detail || `HTTP ${response.status}`);
|
||||
}
|
||||
|
||||
return response.blob();
|
||||
}
|
||||
|
||||
export async function getGeneration(generationId: string): Promise<TTSGenerationResponse> {
|
||||
return fetchAPI(`/api/v1/tts/generations/${generationId}`);
|
||||
}
|
||||
|
||||
export function getGenerationAudioUrl(generationId: string): string {
|
||||
return `${API_BASE_URL}/api/v1/tts/generations/${generationId}/audio`;
|
||||
}
|
||||
|
||||
// ========================================
|
||||
// Recording API
|
||||
// ========================================
|
||||
|
||||
export async function validateRecording(audio: File): Promise<RecordingValidateResponse> {
|
||||
const formData = new FormData();
|
||||
formData.append("audio", audio);
|
||||
|
||||
const response = await fetch(`${API_BASE_URL}/api/v1/recordings/validate`, {
|
||||
method: "POST",
|
||||
body: formData,
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const error = await response.json().catch(() => ({ detail: "Unknown error" }));
|
||||
throw new Error(error.detail || `HTTP ${response.status}`);
|
||||
}
|
||||
|
||||
return response.json();
|
||||
}
|
||||
|
||||
export async function uploadRecording(data: {
|
||||
audio: File;
|
||||
transcript?: string;
|
||||
}): Promise<{ file_id: string; filename: string; duration: number }> {
|
||||
const formData = new FormData();
|
||||
formData.append("audio", data.audio);
|
||||
if (data.transcript) formData.append("transcript", data.transcript);
|
||||
|
||||
const response = await fetch(`${API_BASE_URL}/api/v1/recordings/upload`, {
|
||||
method: "POST",
|
||||
body: formData,
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const error = await response.json().catch(() => ({ detail: "Unknown error" }));
|
||||
throw new Error(error.detail || `HTTP ${response.status}`);
|
||||
}
|
||||
|
||||
return response.json();
|
||||
}
|
||||
|
||||
// ========================================
|
||||
// Sound Effects API
|
||||
// ========================================
|
||||
|
||||
export async function searchSoundEffects(params: {
|
||||
query: string;
|
||||
page?: number;
|
||||
page_size?: number;
|
||||
min_duration?: number;
|
||||
max_duration?: number;
|
||||
}): Promise<SoundEffectSearchResponse> {
|
||||
const searchParams = new URLSearchParams();
|
||||
searchParams.set("query", params.query);
|
||||
if (params.page) searchParams.set("page", String(params.page));
|
||||
if (params.page_size) searchParams.set("page_size", String(params.page_size));
|
||||
if (params.min_duration) searchParams.set("min_duration", String(params.min_duration));
|
||||
if (params.max_duration) searchParams.set("max_duration", String(params.max_duration));
|
||||
|
||||
return fetchAPI(`/api/v1/sound-effects/search?${searchParams}`);
|
||||
}
|
||||
|
||||
export function getSoundEffectAudioUrl(soundId: string): string {
|
||||
return `${API_BASE_URL}/api/v1/sound-effects/${soundId}/audio`;
|
||||
}
|
||||
|
||||
// ========================================
|
||||
// Drama API
|
||||
// ========================================
|
||||
|
||||
export interface DramaCharacter {
|
||||
name: string;
|
||||
description?: string;
|
||||
voice_id?: string;
|
||||
}
|
||||
|
||||
export interface DramaProject {
|
||||
project_id: string;
|
||||
title: string;
|
||||
status: "draft" | "processing" | "completed" | "error";
|
||||
characters: DramaCharacter[];
|
||||
element_count: number;
|
||||
estimated_duration?: number;
|
||||
output_file_id?: string;
|
||||
error_message?: string;
|
||||
}
|
||||
|
||||
export interface ParsedScript {
|
||||
title?: string;
|
||||
characters: DramaCharacter[];
|
||||
elements: Array<{
|
||||
type: "dialogue" | "direction" | "sfx" | "music" | "pause";
|
||||
character?: string;
|
||||
text?: string;
|
||||
description?: string;
|
||||
emotion?: string;
|
||||
}>;
|
||||
}
|
||||
|
||||
export async function parseScript(script: string): Promise<ParsedScript> {
|
||||
const response = await fetch(`${API_BASE_URL}/api/v1/drama/parse`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(script),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const error = await response.json().catch(() => ({ detail: "Unknown error" }));
|
||||
throw new Error(error.detail || `HTTP ${response.status}`);
|
||||
}
|
||||
|
||||
return response.json();
|
||||
}
|
||||
|
||||
export async function createDramaProject(data: {
|
||||
title: string;
|
||||
script: string;
|
||||
voice_mapping?: Record<string, string>;
|
||||
auto_generate_assets?: boolean;
|
||||
}): Promise<DramaProject> {
|
||||
return fetchAPI("/api/v1/drama/projects", {
|
||||
method: "POST",
|
||||
body: JSON.stringify(data),
|
||||
});
|
||||
}
|
||||
|
||||
export async function getDramaProjects(): Promise<DramaProject[]> {
|
||||
return fetchAPI("/api/v1/drama/projects");
|
||||
}
|
||||
|
||||
export async function getDramaProject(projectId: string): Promise<DramaProject> {
|
||||
return fetchAPI(`/api/v1/drama/projects/${projectId}`);
|
||||
}
|
||||
|
||||
export async function renderDrama(
|
||||
projectId: string,
|
||||
outputFormat: string = "wav"
|
||||
): Promise<{ project_id: string; status: string; message: string }> {
|
||||
return fetchAPI(`/api/v1/drama/projects/${projectId}/render?output_format=${outputFormat}`, {
|
||||
method: "POST",
|
||||
});
|
||||
}
|
||||
|
||||
export async function updateVoiceMapping(
|
||||
projectId: string,
|
||||
voiceMapping: Record<string, string>
|
||||
): Promise<void> {
|
||||
await fetchAPI(`/api/v1/drama/projects/${projectId}/voices`, {
|
||||
method: "PUT",
|
||||
body: JSON.stringify(voiceMapping),
|
||||
});
|
||||
}
|
||||
|
||||
export function getDramaDownloadUrl(projectId: string): string {
|
||||
return `${API_BASE_URL}/api/v1/drama/projects/${projectId}/download`;
|
||||
}
|
||||
|
||||
export async function deleteDramaProject(projectId: string): Promise<void> {
|
||||
await fetchAPI(`/api/v1/drama/projects/${projectId}`, { method: "DELETE" });
|
||||
}
|
||||
|
||||
// ========================================
|
||||
// Health Check
|
||||
// ========================================
|
||||
|
||||
export async function healthCheck(): Promise<{
|
||||
status: string;
|
||||
services: { mongodb: string; redis: string };
|
||||
}> {
|
||||
return fetchAPI("/health");
|
||||
}
|
||||
18
audio-studio-ui/src/lib/utils.ts
Normal file
18
audio-studio-ui/src/lib/utils.ts
Normal file
@ -0,0 +1,18 @@
|
||||
import { type ClassValue, clsx } from "clsx";
|
||||
import { twMerge } from "tailwind-merge";
|
||||
|
||||
export function cn(...inputs: ClassValue[]) {
|
||||
return twMerge(clsx(inputs));
|
||||
}
|
||||
|
||||
export function formatDuration(seconds: number): string {
|
||||
const mins = Math.floor(seconds / 60);
|
||||
const secs = Math.floor(seconds % 60);
|
||||
return `${mins}:${secs.toString().padStart(2, "0")}`;
|
||||
}
|
||||
|
||||
export function formatFileSize(bytes: number): string {
|
||||
if (bytes < 1024) return `${bytes} B`;
|
||||
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
|
||||
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
|
||||
}
|
||||
27
audio-studio-ui/tsconfig.json
Normal file
27
audio-studio-ui/tsconfig.json
Normal file
@ -0,0 +1,27 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2017",
|
||||
"lib": ["dom", "dom.iterable", "esnext"],
|
||||
"allowJs": true,
|
||||
"skipLibCheck": true,
|
||||
"strict": true,
|
||||
"noEmit": true,
|
||||
"esModuleInterop": true,
|
||||
"module": "esnext",
|
||||
"moduleResolution": "bundler",
|
||||
"resolveJsonModule": true,
|
||||
"isolatedModules": true,
|
||||
"jsx": "preserve",
|
||||
"incremental": true,
|
||||
"plugins": [
|
||||
{
|
||||
"name": "next"
|
||||
}
|
||||
],
|
||||
"paths": {
|
||||
"@/*": ["./src/*"]
|
||||
}
|
||||
},
|
||||
"include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"],
|
||||
"exclude": ["node_modules"]
|
||||
}
|
||||
Reference in New Issue
Block a user