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:
jungwoo choi
2026-01-26 11:39:38 +09:00
commit cc547372c0
70 changed files with 18399 additions and 0 deletions

View 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
View 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.

View 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

File diff suppressed because it is too large Load Diff

View 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"
}
}

View File

@ -0,0 +1,5 @@
export default {
plugins: {
"@tailwindcss/postcss": {},
},
};

View File

View 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>
);
}

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

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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">
&quot;,
. .&quot;
</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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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");
}

View 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`;
}

View 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"]
}