mirror of
https://github.com/nad4-su/meeting-minutes.git
synced 2026-08-12 22:33:25 +09:00
feat: 회의록 자동 작성 웹 서비스 구현
- 오디오 파일 업로드 + 유효성 검사 - 실시간 음성 인식 (Web Speech API) - 회의록 생성 (단순 STT→MD 변환 / Gemini AI 요약) - 마크다운/HTML 내보내기 - PostgreSQL + Prisma ORM - Docker Compose 배포 지원 - TDD: 31개 테스트, 96% 커버리지
This commit is contained in:
@@ -0,0 +1,8 @@
|
|||||||
|
# PostgreSQL
|
||||||
|
DATABASE_URL="postgresql://meetinguser:meetingpass@localhost:5432/meetingminutes"
|
||||||
|
POSTGRES_USER=meetinguser
|
||||||
|
POSTGRES_PASSWORD=meetingpass
|
||||||
|
POSTGRES_DB=meetingminutes
|
||||||
|
|
||||||
|
# Gemini API (선택사항 - AI 요약 기능에 필요)
|
||||||
|
GEMINI_API_KEY=
|
||||||
+12
@@ -39,3 +39,15 @@ yarn-error.log*
|
|||||||
# typescript
|
# typescript
|
||||||
*.tsbuildinfo
|
*.tsbuildinfo
|
||||||
next-env.d.ts
|
next-env.d.ts
|
||||||
|
|
||||||
|
/src/generated/prisma
|
||||||
|
|
||||||
|
# Uploads
|
||||||
|
/uploads
|
||||||
|
|
||||||
|
|
||||||
|
# Environment
|
||||||
|
.env*.local
|
||||||
|
.env
|
||||||
|
!.env.example
|
||||||
|
|
||||||
|
|||||||
+32
@@ -0,0 +1,32 @@
|
|||||||
|
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 npx prisma generate
|
||||||
|
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
|
||||||
|
COPY --from=builder /app/.next/standalone ./
|
||||||
|
COPY --from=builder /app/.next/static ./.next/static
|
||||||
|
COPY --from=builder /app/prisma ./prisma
|
||||||
|
COPY --from=builder /app/src/generated ./src/generated
|
||||||
|
|
||||||
|
RUN mkdir -p uploads && chown nextjs:nodejs uploads
|
||||||
|
|
||||||
|
USER nextjs
|
||||||
|
EXPOSE 3000
|
||||||
|
ENV PORT=3000
|
||||||
|
ENV HOSTNAME="0.0.0.0"
|
||||||
|
CMD ["node", "server.js"]
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
services:
|
||||||
|
db:
|
||||||
|
image: postgres:16-alpine
|
||||||
|
restart: unless-stopped
|
||||||
|
environment:
|
||||||
|
POSTGRES_USER: ${POSTGRES_USER:-meetinguser}
|
||||||
|
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-meetingpass}
|
||||||
|
POSTGRES_DB: ${POSTGRES_DB:-meetingminutes}
|
||||||
|
volumes:
|
||||||
|
- pgdata:/var/lib/postgresql/data
|
||||||
|
ports:
|
||||||
|
- "5432:5432"
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER:-meetinguser}"]
|
||||||
|
interval: 5s
|
||||||
|
timeout: 5s
|
||||||
|
retries: 5
|
||||||
|
|
||||||
|
app:
|
||||||
|
build:
|
||||||
|
context: .
|
||||||
|
dockerfile: Dockerfile
|
||||||
|
restart: unless-stopped
|
||||||
|
ports:
|
||||||
|
- "3000:3000"
|
||||||
|
environment:
|
||||||
|
DATABASE_URL: postgresql://${POSTGRES_USER:-meetinguser}:${POSTGRES_PASSWORD:-meetingpass}@db:5432/${POSTGRES_DB:-meetingminutes}
|
||||||
|
GEMINI_API_KEY: ${GEMINI_API_KEY:-}
|
||||||
|
volumes:
|
||||||
|
- uploads:/app/uploads
|
||||||
|
depends_on:
|
||||||
|
db:
|
||||||
|
condition: service_healthy
|
||||||
|
|
||||||
|
volumes:
|
||||||
|
pgdata:
|
||||||
|
uploads:
|
||||||
+1
-1
@@ -1,7 +1,7 @@
|
|||||||
import type { NextConfig } from "next";
|
import type { NextConfig } from "next";
|
||||||
|
|
||||||
const nextConfig: NextConfig = {
|
const nextConfig: NextConfig = {
|
||||||
/* config options here */
|
output: "standalone",
|
||||||
};
|
};
|
||||||
|
|
||||||
export default nextConfig;
|
export default nextConfig;
|
||||||
|
|||||||
Generated
+2741
-18
File diff suppressed because it is too large
Load Diff
+13
-2
@@ -6,21 +6,32 @@
|
|||||||
"dev": "next dev",
|
"dev": "next dev",
|
||||||
"build": "next build",
|
"build": "next build",
|
||||||
"start": "next start",
|
"start": "next start",
|
||||||
"lint": "eslint"
|
"lint": "eslint",
|
||||||
|
"test": "vitest run",
|
||||||
|
"test:watch": "vitest",
|
||||||
|
"test:coverage": "vitest run --coverage"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@prisma/client": "^7.7.0",
|
||||||
"next": "16.2.3",
|
"next": "16.2.3",
|
||||||
|
"prisma": "^7.7.0",
|
||||||
"react": "19.2.4",
|
"react": "19.2.4",
|
||||||
"react-dom": "19.2.4"
|
"react-dom": "19.2.4"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@tailwindcss/postcss": "^4",
|
"@tailwindcss/postcss": "^4",
|
||||||
|
"@testing-library/jest-dom": "^6.9.1",
|
||||||
|
"@testing-library/react": "^16.3.2",
|
||||||
"@types/node": "^20",
|
"@types/node": "^20",
|
||||||
"@types/react": "^19",
|
"@types/react": "^19",
|
||||||
"@types/react-dom": "^19",
|
"@types/react-dom": "^19",
|
||||||
|
"@vitejs/plugin-react": "^6.0.1",
|
||||||
|
"@vitest/coverage-v8": "^4.1.4",
|
||||||
"eslint": "^9",
|
"eslint": "^9",
|
||||||
"eslint-config-next": "16.2.3",
|
"eslint-config-next": "16.2.3",
|
||||||
|
"jsdom": "^29.0.2",
|
||||||
"tailwindcss": "^4",
|
"tailwindcss": "^4",
|
||||||
"typescript": "^5"
|
"typescript": "^5",
|
||||||
|
"vitest": "^4.1.4"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,14 @@
|
|||||||
|
// This file was generated by Prisma, and assumes you have installed the following:
|
||||||
|
// npm install --save-dev prisma dotenv
|
||||||
|
import "dotenv/config";
|
||||||
|
import { defineConfig } from "prisma/config";
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
schema: "prisma/schema.prisma",
|
||||||
|
migrations: {
|
||||||
|
path: "prisma/migrations",
|
||||||
|
},
|
||||||
|
datasource: {
|
||||||
|
url: process.env["DATABASE_URL"],
|
||||||
|
},
|
||||||
|
});
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
generator client {
|
||||||
|
provider = "prisma-client"
|
||||||
|
output = "../src/generated/prisma"
|
||||||
|
}
|
||||||
|
|
||||||
|
datasource db {
|
||||||
|
provider = "postgresql"
|
||||||
|
url = env("DATABASE_URL")
|
||||||
|
}
|
||||||
|
|
||||||
|
model Meeting {
|
||||||
|
id String @id @default(cuid())
|
||||||
|
title String
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
updatedAt DateTime @updatedAt
|
||||||
|
|
||||||
|
audioFileName String?
|
||||||
|
audioMimeType String?
|
||||||
|
audioDuration Int?
|
||||||
|
|
||||||
|
rawTranscript String?
|
||||||
|
markdownMinutes String?
|
||||||
|
geminiSummary String?
|
||||||
|
|
||||||
|
status MeetingStatus @default(CREATED)
|
||||||
|
|
||||||
|
@@map("meetings")
|
||||||
|
}
|
||||||
|
|
||||||
|
enum MeetingStatus {
|
||||||
|
CREATED
|
||||||
|
UPLOADING
|
||||||
|
TRANSCRIBING
|
||||||
|
SUMMARIZING
|
||||||
|
COMPLETED
|
||||||
|
FAILED
|
||||||
|
}
|
||||||
@@ -0,0 +1,75 @@
|
|||||||
|
import { describe, it, expect } from 'vitest'
|
||||||
|
import {
|
||||||
|
validateAudioFile,
|
||||||
|
ALLOWED_MIME_TYPES,
|
||||||
|
MAX_FILE_SIZE_BYTES,
|
||||||
|
} from '@/lib/audio-validation'
|
||||||
|
|
||||||
|
describe('validateAudioFile', () => {
|
||||||
|
it('허용된 오디오 파일을 통과시킨다', () => {
|
||||||
|
const file = new File(['audio-data'], 'meeting.mp3', { type: 'audio/mpeg' })
|
||||||
|
const result = validateAudioFile(file)
|
||||||
|
expect(result.valid).toBe(true)
|
||||||
|
expect(result.error).toBeUndefined()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('허용되지 않은 MIME 타입을 거부한다', () => {
|
||||||
|
const file = new File(['data'], 'meeting.exe', {
|
||||||
|
type: 'application/x-msdownload',
|
||||||
|
})
|
||||||
|
const result = validateAudioFile(file)
|
||||||
|
expect(result.valid).toBe(false)
|
||||||
|
expect(result.error).toContain('지원하지 않는 파일 형식')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('파일 크기 초과를 거부한다', () => {
|
||||||
|
const bigData = new Uint8Array(MAX_FILE_SIZE_BYTES + 1)
|
||||||
|
const file = new File([bigData], 'huge.mp3', { type: 'audio/mpeg' })
|
||||||
|
const result = validateAudioFile(file)
|
||||||
|
expect(result.valid).toBe(false)
|
||||||
|
expect(result.error).toContain('파일 크기')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('빈 파일을 거부한다', () => {
|
||||||
|
const file = new File([], 'empty.mp3', { type: 'audio/mpeg' })
|
||||||
|
const result = validateAudioFile(file)
|
||||||
|
expect(result.valid).toBe(false)
|
||||||
|
expect(result.error).toContain('비어 있습니다')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('WAV 파일을 허용한다', () => {
|
||||||
|
const file = new File(['wav-data'], 'meeting.wav', { type: 'audio/wav' })
|
||||||
|
const result = validateAudioFile(file)
|
||||||
|
expect(result.valid).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('WebM 오디오를 허용한다', () => {
|
||||||
|
const file = new File(['webm-data'], 'meeting.webm', {
|
||||||
|
type: 'audio/webm',
|
||||||
|
})
|
||||||
|
const result = validateAudioFile(file)
|
||||||
|
expect(result.valid).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('M4A 파일을 허용한다', () => {
|
||||||
|
const file = new File(['m4a-data'], 'meeting.m4a', { type: 'audio/mp4' })
|
||||||
|
const result = validateAudioFile(file)
|
||||||
|
expect(result.valid).toBe(true)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('ALLOWED_MIME_TYPES', () => {
|
||||||
|
it('주요 오디오 형식을 포함한다', () => {
|
||||||
|
expect(ALLOWED_MIME_TYPES).toContain('audio/mpeg')
|
||||||
|
expect(ALLOWED_MIME_TYPES).toContain('audio/wav')
|
||||||
|
expect(ALLOWED_MIME_TYPES).toContain('audio/webm')
|
||||||
|
expect(ALLOWED_MIME_TYPES).toContain('audio/mp4')
|
||||||
|
expect(ALLOWED_MIME_TYPES).toContain('audio/ogg')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('MAX_FILE_SIZE_BYTES', () => {
|
||||||
|
it('500MB로 설정되어 있다', () => {
|
||||||
|
expect(MAX_FILE_SIZE_BYTES).toBe(500 * 1024 * 1024)
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,59 @@
|
|||||||
|
import { describe, it, expect } from 'vitest'
|
||||||
|
import {
|
||||||
|
exportAsMarkdown,
|
||||||
|
exportAsHtml,
|
||||||
|
generateDownloadFilename,
|
||||||
|
} from '@/lib/export-minutes'
|
||||||
|
|
||||||
|
const sampleMarkdown = `# 주간 회의
|
||||||
|
|
||||||
|
**날짜**: 2026-04-11
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 회의 내용
|
||||||
|
|
||||||
|
[00:00] 안녕하세요
|
||||||
|
[00:05] 회의를 시작합니다
|
||||||
|
`
|
||||||
|
|
||||||
|
describe('exportAsMarkdown', () => {
|
||||||
|
it('마크다운 문자열을 Blob으로 변환한다', () => {
|
||||||
|
const blob = exportAsMarkdown(sampleMarkdown)
|
||||||
|
expect(blob).toBeInstanceOf(Blob)
|
||||||
|
expect(blob.type).toBe('text/markdown;charset=utf-8')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('빈 문자열도 유효한 Blob을 반환한다', () => {
|
||||||
|
const blob = exportAsMarkdown('')
|
||||||
|
expect(blob).toBeInstanceOf(Blob)
|
||||||
|
expect(blob.size).toBe(0)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('exportAsHtml', () => {
|
||||||
|
it('마크다운을 HTML 문서로 변환한다', () => {
|
||||||
|
const html = exportAsHtml(sampleMarkdown, '주간 회의')
|
||||||
|
expect(html).toContain('<!DOCTYPE html>')
|
||||||
|
expect(html).toContain('<title>주간 회의</title>')
|
||||||
|
expect(html).toContain('주간 회의')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('Google Docs 호환 스타일을 포함한다', () => {
|
||||||
|
const html = exportAsHtml(sampleMarkdown, '테스트')
|
||||||
|
expect(html).toContain('<style>')
|
||||||
|
expect(html).toContain('font-family')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('generateDownloadFilename', () => {
|
||||||
|
it('제목과 날짜로 파일명을 생성한다', () => {
|
||||||
|
const filename = generateDownloadFilename('주간 회의', new Date('2026-04-11'))
|
||||||
|
expect(filename).toBe('주간_회의_2026-04-11')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('특수문자를 언더스코어로 치환한다', () => {
|
||||||
|
const filename = generateDownloadFilename('회의/미팅 #1', new Date('2026-04-11'))
|
||||||
|
expect(filename).toBe('회의_미팅__1_2026-04-11')
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,107 @@
|
|||||||
|
import { describe, it, expect, vi } from 'vitest'
|
||||||
|
import {
|
||||||
|
generateSimpleMinutes,
|
||||||
|
generateGeminiMinutes,
|
||||||
|
type MinutesInput,
|
||||||
|
} from '@/lib/minutes-generator'
|
||||||
|
|
||||||
|
const sampleInput: MinutesInput = {
|
||||||
|
title: '주간 스프린트 회의',
|
||||||
|
date: new Date('2026-04-11T10:00:00'),
|
||||||
|
transcript: `[00:00] 안녕하세요 오늘 주간 회의를 시작하겠습니다
|
||||||
|
[00:05] 지난 주 작업 내역을 공유해주세요
|
||||||
|
[00:15] 프론트엔드 리팩토링을 완료했습니다
|
||||||
|
[00:30] 백엔드 API 성능 개선 작업 중입니다
|
||||||
|
[01:00] 다음 주 계획을 논의하겠습니다
|
||||||
|
[01:15] QA 테스트를 진행할 예정입니다`,
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('generateSimpleMinutes', () => {
|
||||||
|
it('마크다운 형식의 회의록을 생성한다', () => {
|
||||||
|
const result = generateSimpleMinutes(sampleInput)
|
||||||
|
|
||||||
|
expect(result).toContain('# 주간 스프린트 회의')
|
||||||
|
expect(result).toContain('2026-04-11')
|
||||||
|
expect(result).toContain('## 회의 내용')
|
||||||
|
expect(result).toContain('안녕하세요')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('타임스탬프를 유지한다', () => {
|
||||||
|
const result = generateSimpleMinutes(sampleInput)
|
||||||
|
expect(result).toContain('[00:00]')
|
||||||
|
expect(result).toContain('[01:15]')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('빈 transcript이면 해당 섹션에 안내 문구를 넣는다', () => {
|
||||||
|
const input: MinutesInput = {
|
||||||
|
...sampleInput,
|
||||||
|
transcript: '',
|
||||||
|
}
|
||||||
|
const result = generateSimpleMinutes(input)
|
||||||
|
expect(result).toContain('내용 없음')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('generateGeminiMinutes', () => {
|
||||||
|
it('Gemini API를 호출하여 요약된 회의록을 반환한다', async () => {
|
||||||
|
const mockFetch = vi.fn().mockResolvedValue({
|
||||||
|
ok: true,
|
||||||
|
json: () =>
|
||||||
|
Promise.resolve({
|
||||||
|
candidates: [
|
||||||
|
{
|
||||||
|
content: {
|
||||||
|
parts: [
|
||||||
|
{
|
||||||
|
text: '## 요약\n- 프론트엔드 리팩토링 완료\n- 백엔드 API 성능 개선 진행 중\n\n## 액션 아이템\n- QA 테스트 진행',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
})
|
||||||
|
|
||||||
|
const result = await generateGeminiMinutes(sampleInput, {
|
||||||
|
apiKey: 'test-key',
|
||||||
|
fetchFn: mockFetch,
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(result.success).toBe(true)
|
||||||
|
if (result.success) {
|
||||||
|
expect(result.markdown).toContain('# 주간 스프린트 회의')
|
||||||
|
expect(result.markdown).toContain('요약')
|
||||||
|
expect(result.markdown).toContain('액션 아이템')
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
it('API 호출 실패 시 에러를 반환한다', async () => {
|
||||||
|
const mockFetch = vi.fn().mockResolvedValue({
|
||||||
|
ok: false,
|
||||||
|
status: 429,
|
||||||
|
statusText: 'Too Many Requests',
|
||||||
|
})
|
||||||
|
|
||||||
|
const result = await generateGeminiMinutes(sampleInput, {
|
||||||
|
apiKey: 'test-key',
|
||||||
|
fetchFn: mockFetch,
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(result.success).toBe(false)
|
||||||
|
if (!result.success) {
|
||||||
|
expect(result.error).toContain('Gemini API')
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
it('API 키가 없으면 에러를 반환한다', async () => {
|
||||||
|
const result = await generateGeminiMinutes(sampleInput, {
|
||||||
|
apiKey: '',
|
||||||
|
fetchFn: vi.fn(),
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(result.success).toBe(false)
|
||||||
|
if (!result.success) {
|
||||||
|
expect(result.error).toContain('API 키')
|
||||||
|
}
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
import { describe, it, expect } from 'vitest'
|
||||||
|
|
||||||
|
describe('smoke test', () => {
|
||||||
|
it('vitest is configured correctly', () => {
|
||||||
|
expect(1 + 1).toBe(2)
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,57 @@
|
|||||||
|
import { describe, it, expect } from 'vitest'
|
||||||
|
import {
|
||||||
|
formatTranscriptChunks,
|
||||||
|
mergeAdjacentChunks,
|
||||||
|
type TranscriptChunk,
|
||||||
|
} from '@/lib/transcript-formatter'
|
||||||
|
|
||||||
|
describe('formatTranscriptChunks', () => {
|
||||||
|
it('타임스탬프가 있는 청크를 텍스트로 변환한다', () => {
|
||||||
|
const chunks: TranscriptChunk[] = [
|
||||||
|
{ text: '안녕하세요', startTime: 0, endTime: 2.5, isFinal: true },
|
||||||
|
{ text: '오늘 회의를 시작하겠습니다', startTime: 2.5, endTime: 5.0, isFinal: true },
|
||||||
|
]
|
||||||
|
|
||||||
|
const result = formatTranscriptChunks(chunks)
|
||||||
|
|
||||||
|
expect(result).toContain('[00:00]')
|
||||||
|
expect(result).toContain('안녕하세요')
|
||||||
|
expect(result).toContain('[00:02]')
|
||||||
|
expect(result).toContain('오늘 회의를 시작하겠습니다')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('빈 배열이면 빈 문자열을 반환한다', () => {
|
||||||
|
const result = formatTranscriptChunks([])
|
||||||
|
expect(result).toBe('')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('1시간 이상일 때 시:분:초 형식을 사용한다', () => {
|
||||||
|
const chunks: TranscriptChunk[] = [
|
||||||
|
{ text: '긴 회의', startTime: 3661, endTime: 3665, isFinal: true },
|
||||||
|
]
|
||||||
|
|
||||||
|
const result = formatTranscriptChunks(chunks)
|
||||||
|
expect(result).toContain('[01:01:01]')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('mergeAdjacentChunks', () => {
|
||||||
|
it('2초 이내의 인접 청크를 병합한다', () => {
|
||||||
|
const chunks: TranscriptChunk[] = [
|
||||||
|
{ text: '안녕', startTime: 0, endTime: 1, isFinal: true },
|
||||||
|
{ text: '하세요', startTime: 1.1, endTime: 2, isFinal: true },
|
||||||
|
{ text: '다음 주제입니다', startTime: 10, endTime: 12, isFinal: true },
|
||||||
|
]
|
||||||
|
|
||||||
|
const merged = mergeAdjacentChunks(chunks, 2)
|
||||||
|
|
||||||
|
expect(merged).toHaveLength(2)
|
||||||
|
expect(merged[0].text).toBe('안녕 하세요')
|
||||||
|
expect(merged[1].text).toBe('다음 주제입니다')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('빈 배열이면 빈 배열을 반환한다', () => {
|
||||||
|
const result = mergeAdjacentChunks([], 2)
|
||||||
|
expect(result).toEqual([])
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,57 @@
|
|||||||
|
import { describe, it, expect, vi } from 'vitest'
|
||||||
|
import { handleUpload } from '@/lib/upload-handler'
|
||||||
|
|
||||||
|
describe('handleUpload', () => {
|
||||||
|
it('유효한 오디오 파일을 받으면 성공 응답을 반환한다', async () => {
|
||||||
|
const file = new File(['audio-content'], 'meeting.mp3', {
|
||||||
|
type: 'audio/mpeg',
|
||||||
|
})
|
||||||
|
const formData = new FormData()
|
||||||
|
formData.append('audio', file)
|
||||||
|
formData.append('title', '주간 회의')
|
||||||
|
|
||||||
|
const result = await handleUpload(formData)
|
||||||
|
|
||||||
|
expect(result.success).toBe(true)
|
||||||
|
expect(result.data?.title).toBe('주간 회의')
|
||||||
|
expect(result.data?.fileName).toBe('meeting.mp3')
|
||||||
|
expect(result.data?.mimeType).toBe('audio/mpeg')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('오디오 파일이 없으면 에러를 반환한다', async () => {
|
||||||
|
const formData = new FormData()
|
||||||
|
formData.append('title', '빈 회의')
|
||||||
|
|
||||||
|
const result = await handleUpload(formData)
|
||||||
|
|
||||||
|
expect(result.success).toBe(false)
|
||||||
|
expect(result.error).toContain('오디오 파일')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('유효하지 않은 파일 형식이면 에러를 반환한다', async () => {
|
||||||
|
const file = new File(['not-audio'], 'doc.pdf', {
|
||||||
|
type: 'application/pdf',
|
||||||
|
})
|
||||||
|
const formData = new FormData()
|
||||||
|
formData.append('audio', file)
|
||||||
|
formData.append('title', '잘못된 파일')
|
||||||
|
|
||||||
|
const result = await handleUpload(formData)
|
||||||
|
|
||||||
|
expect(result.success).toBe(false)
|
||||||
|
expect(result.error).toContain('지원하지 않는 파일 형식')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('제목이 없으면 파일명을 기본 제목으로 사용한다', async () => {
|
||||||
|
const file = new File(['audio-content'], 'standup.webm', {
|
||||||
|
type: 'audio/webm',
|
||||||
|
})
|
||||||
|
const formData = new FormData()
|
||||||
|
formData.append('audio', file)
|
||||||
|
|
||||||
|
const result = await handleUpload(formData)
|
||||||
|
|
||||||
|
expect(result.success).toBe(true)
|
||||||
|
expect(result.data?.title).toBe('standup')
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
import { NextRequest } from 'next/server'
|
||||||
|
import { generateGeminiMinutes, generateSimpleMinutes } from '@/lib/minutes-generator'
|
||||||
|
|
||||||
|
export async function POST(request: NextRequest) {
|
||||||
|
try {
|
||||||
|
const body = await request.json()
|
||||||
|
const { title, transcript, mode, date } = body
|
||||||
|
|
||||||
|
if (!transcript || typeof transcript !== 'string') {
|
||||||
|
return Response.json({ error: '변환할 텍스트가 필요합니다.' }, { status: 400 })
|
||||||
|
}
|
||||||
|
|
||||||
|
const input = {
|
||||||
|
title: title || '무제 회의',
|
||||||
|
transcript,
|
||||||
|
date: date ? new Date(date) : new Date(),
|
||||||
|
}
|
||||||
|
|
||||||
|
if (mode === 'gemini') {
|
||||||
|
const apiKey = process.env.GEMINI_API_KEY ?? ''
|
||||||
|
const result = await generateGeminiMinutes(input, { apiKey })
|
||||||
|
|
||||||
|
if (!result.success) {
|
||||||
|
return Response.json({ error: result.error }, { status: 502 })
|
||||||
|
}
|
||||||
|
|
||||||
|
return Response.json({ markdown: result.markdown, mode: 'gemini' })
|
||||||
|
}
|
||||||
|
|
||||||
|
const markdown = generateSimpleMinutes(input)
|
||||||
|
return Response.json({ markdown, mode: 'simple' })
|
||||||
|
} catch (err) {
|
||||||
|
const message = err instanceof Error ? err.message : '알 수 없는 오류'
|
||||||
|
return Response.json({ error: `요약 처리 실패: ${message}` }, { status: 500 })
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
import { NextRequest } from 'next/server'
|
||||||
|
import { handleUpload } from '@/lib/upload-handler'
|
||||||
|
import { writeFile, mkdir } from 'fs/promises'
|
||||||
|
import path from 'path'
|
||||||
|
|
||||||
|
const UPLOAD_DIR = path.join(process.cwd(), 'uploads')
|
||||||
|
|
||||||
|
export async function POST(request: NextRequest) {
|
||||||
|
try {
|
||||||
|
const formData = await request.formData()
|
||||||
|
const result = await handleUpload(formData)
|
||||||
|
|
||||||
|
if (!result.success) {
|
||||||
|
return Response.json({ error: result.error }, { status: 400 })
|
||||||
|
}
|
||||||
|
|
||||||
|
const audioFile = formData.get('audio') as File
|
||||||
|
const buffer = Buffer.from(await audioFile.arrayBuffer())
|
||||||
|
|
||||||
|
await mkdir(UPLOAD_DIR, { recursive: true })
|
||||||
|
|
||||||
|
const timestamp = Date.now()
|
||||||
|
const safeFileName = `${timestamp}_${result.data.fileName.replace(/[^a-zA-Z0-9._-]/g, '_')}`
|
||||||
|
const filePath = path.join(UPLOAD_DIR, safeFileName)
|
||||||
|
|
||||||
|
await writeFile(filePath, buffer)
|
||||||
|
|
||||||
|
return Response.json({
|
||||||
|
...result.data,
|
||||||
|
savedAs: safeFileName,
|
||||||
|
})
|
||||||
|
} catch (err) {
|
||||||
|
const message = err instanceof Error ? err.message : '알 수 없는 오류'
|
||||||
|
return Response.json({ error: `업로드 처리 실패: ${message}` }, { status: 500 })
|
||||||
|
}
|
||||||
|
}
|
||||||
+3
-3
@@ -13,8 +13,8 @@ const geistMono = Geist_Mono({
|
|||||||
});
|
});
|
||||||
|
|
||||||
export const metadata: Metadata = {
|
export const metadata: Metadata = {
|
||||||
title: "Create Next App",
|
title: "Meeting Minutes - 회의록 자동 작성",
|
||||||
description: "Generated by create next app",
|
description: "음성 녹음을 회의록으로 자동 변환하는 서비스",
|
||||||
};
|
};
|
||||||
|
|
||||||
export default function RootLayout({
|
export default function RootLayout({
|
||||||
@@ -24,7 +24,7 @@ export default function RootLayout({
|
|||||||
}>) {
|
}>) {
|
||||||
return (
|
return (
|
||||||
<html
|
<html
|
||||||
lang="en"
|
lang="ko"
|
||||||
className={`${geistSans.variable} ${geistMono.variable} h-full antialiased`}
|
className={`${geistSans.variable} ${geistMono.variable} h-full antialiased`}
|
||||||
>
|
>
|
||||||
<body className="min-h-full flex flex-col">{children}</body>
|
<body className="min-h-full flex flex-col">{children}</body>
|
||||||
|
|||||||
+213
-58
@@ -1,65 +1,220 @@
|
|||||||
import Image from "next/image";
|
'use client'
|
||||||
|
|
||||||
export default function Home() {
|
import { useState } from 'react'
|
||||||
|
import { AudioUploader } from '@/components/upload/AudioUploader'
|
||||||
|
import { LiveRecorder } from '@/components/recorder/LiveRecorder'
|
||||||
|
import { MinutesViewer } from '@/components/minutes/MinutesViewer'
|
||||||
|
|
||||||
|
type Tab = 'upload' | 'record'
|
||||||
|
type SummaryMode = 'simple' | 'gemini'
|
||||||
|
|
||||||
|
interface MinutesResult {
|
||||||
|
markdown: string
|
||||||
|
mode: SummaryMode
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function HomePage() {
|
||||||
|
const [tab, setTab] = useState<Tab>('record')
|
||||||
|
const [title, setTitle] = useState('')
|
||||||
|
const [transcript, setTranscript] = useState('')
|
||||||
|
const [summaryMode, setSummaryMode] = useState<SummaryMode>('simple')
|
||||||
|
const [result, setResult] = useState<MinutesResult | null>(null)
|
||||||
|
const [loading, setLoading] = useState(false)
|
||||||
|
const [error, setError] = useState<string | null>(null)
|
||||||
|
const [uploadedFile, setUploadedFile] = useState<File | null>(null)
|
||||||
|
|
||||||
|
async function handleUpload(file: File) {
|
||||||
|
setUploadedFile(file)
|
||||||
|
setError(null)
|
||||||
|
|
||||||
|
const formData = new FormData()
|
||||||
|
formData.append('audio', file)
|
||||||
|
formData.append('title', title || file.name.replace(/\.[^.]+$/, ''))
|
||||||
|
|
||||||
|
try {
|
||||||
|
const res = await fetch('/api/upload', { method: 'POST', body: formData })
|
||||||
|
const data = await res.json()
|
||||||
|
|
||||||
|
if (!res.ok) {
|
||||||
|
setError(data.error)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!title) setTitle(data.title)
|
||||||
|
} catch {
|
||||||
|
setError('파일 업로드에 실패했습니다.')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleTranscriptReady(text: string) {
|
||||||
|
setTranscript(text)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function generateMinutes() {
|
||||||
|
if (!transcript.trim()) {
|
||||||
|
setError('변환할 텍스트가 없습니다. 녹음하거나 파일을 업로드해주세요.')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
setLoading(true)
|
||||||
|
setError(null)
|
||||||
|
|
||||||
|
try {
|
||||||
|
const res = await fetch('/api/summarize', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({
|
||||||
|
title: title || '무제 회의',
|
||||||
|
transcript,
|
||||||
|
mode: summaryMode,
|
||||||
|
}),
|
||||||
|
})
|
||||||
|
const data = await res.json()
|
||||||
|
|
||||||
|
if (!res.ok) {
|
||||||
|
setError(data.error)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
setResult({ markdown: data.markdown, mode: data.mode })
|
||||||
|
} catch {
|
||||||
|
setError('회의록 생성에 실패했습니다.')
|
||||||
|
} finally {
|
||||||
|
setLoading(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col flex-1 items-center justify-center bg-zinc-50 font-sans dark:bg-black">
|
<main className="flex-1 bg-gradient-to-b from-neutral-50 to-white">
|
||||||
<main className="flex flex-1 w-full max-w-3xl flex-col items-center justify-between py-32 px-16 bg-white dark:bg-black sm:items-start">
|
<div className="mx-auto max-w-3xl px-6 py-12">
|
||||||
<Image
|
<header className="mb-10 text-center">
|
||||||
className="dark:invert"
|
<h1 className="text-4xl font-bold tracking-tight text-neutral-900">
|
||||||
src="/next.svg"
|
Meeting Minutes
|
||||||
alt="Next.js logo"
|
|
||||||
width={100}
|
|
||||||
height={20}
|
|
||||||
priority
|
|
||||||
/>
|
|
||||||
<div className="flex flex-col items-center gap-6 text-center sm:items-start sm:text-left">
|
|
||||||
<h1 className="max-w-xs text-3xl font-semibold leading-10 tracking-tight text-black dark:text-zinc-50">
|
|
||||||
To get started, edit the page.tsx file.
|
|
||||||
</h1>
|
</h1>
|
||||||
<p className="max-w-md text-lg leading-8 text-zinc-600 dark:text-zinc-400">
|
<p className="mt-2 text-neutral-500">
|
||||||
Looking for a starting point or more instructions? Head over to{" "}
|
음성을 텍스트로, 텍스트를 회의록으로
|
||||||
<a
|
</p>
|
||||||
href="https://vercel.com/templates?framework=next.js&utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
|
</header>
|
||||||
className="font-medium text-zinc-950 dark:text-zinc-50"
|
|
||||||
|
<section className="mb-8">
|
||||||
|
<label className="block text-sm font-medium text-neutral-600 mb-2">
|
||||||
|
회의 제목
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={title}
|
||||||
|
onChange={(e) => setTitle(e.target.value)}
|
||||||
|
placeholder="예: 주간 스프린트 회의"
|
||||||
|
className="w-full rounded-xl border border-neutral-300 px-4 py-3 text-neutral-800 placeholder:text-neutral-400 focus:border-blue-400 focus:outline-none focus:ring-2 focus:ring-blue-100 transition-all"
|
||||||
|
/>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section className="mb-8">
|
||||||
|
<div className="flex gap-1 rounded-xl bg-neutral-100 p-1 mb-6">
|
||||||
|
<button
|
||||||
|
onClick={() => setTab('record')}
|
||||||
|
className={`flex-1 rounded-lg py-2.5 text-sm font-medium transition-all ${
|
||||||
|
tab === 'record'
|
||||||
|
? 'bg-white text-neutral-900 shadow-sm'
|
||||||
|
: 'text-neutral-500 hover:text-neutral-700'
|
||||||
|
}`}
|
||||||
>
|
>
|
||||||
Templates
|
실시간 녹음
|
||||||
</a>{" "}
|
</button>
|
||||||
or the{" "}
|
<button
|
||||||
<a
|
onClick={() => setTab('upload')}
|
||||||
href="https://nextjs.org/learn?utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
|
className={`flex-1 rounded-lg py-2.5 text-sm font-medium transition-all ${
|
||||||
className="font-medium text-zinc-950 dark:text-zinc-50"
|
tab === 'upload'
|
||||||
|
? 'bg-white text-neutral-900 shadow-sm'
|
||||||
|
: 'text-neutral-500 hover:text-neutral-700'
|
||||||
|
}`}
|
||||||
>
|
>
|
||||||
Learning
|
파일 업로드
|
||||||
</a>{" "}
|
</button>
|
||||||
center.
|
</div>
|
||||||
</p>
|
|
||||||
</div>
|
{tab === 'record' ? (
|
||||||
<div className="flex flex-col gap-4 text-base font-medium sm:flex-row">
|
<LiveRecorder onTranscriptReady={handleTranscriptReady} />
|
||||||
<a
|
) : (
|
||||||
className="flex h-12 w-full items-center justify-center gap-2 rounded-full bg-foreground px-5 text-background transition-colors hover:bg-[#383838] dark:hover:bg-[#ccc] md:w-[158px]"
|
<AudioUploader onFileSelected={handleUpload} />
|
||||||
href="https://vercel.com/new?utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
|
)}
|
||||||
target="_blank"
|
</section>
|
||||||
rel="noopener noreferrer"
|
|
||||||
>
|
{transcript && (
|
||||||
<Image
|
<section className="mb-8">
|
||||||
className="dark:invert"
|
<label className="block text-sm font-medium text-neutral-600 mb-2">
|
||||||
src="/vercel.svg"
|
인식된 텍스트
|
||||||
alt="Vercel logomark"
|
</label>
|
||||||
width={16}
|
<textarea
|
||||||
height={16}
|
value={transcript}
|
||||||
|
onChange={(e) => setTranscript(e.target.value)}
|
||||||
|
rows={6}
|
||||||
|
className="w-full rounded-xl border border-neutral-300 px-4 py-3 text-sm font-mono text-neutral-700 focus:border-blue-400 focus:outline-none focus:ring-2 focus:ring-blue-100 transition-all resize-y"
|
||||||
/>
|
/>
|
||||||
Deploy Now
|
</section>
|
||||||
</a>
|
)}
|
||||||
<a
|
|
||||||
className="flex h-12 w-full items-center justify-center rounded-full border border-solid border-black/[.08] px-5 transition-colors hover:border-transparent hover:bg-black/[.04] dark:border-white/[.145] dark:hover:bg-[#1a1a1a] md:w-[158px]"
|
{uploadedFile && !transcript && (
|
||||||
href="https://nextjs.org/docs?utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
|
<section className="mb-8">
|
||||||
target="_blank"
|
<div className="rounded-xl border border-blue-200 bg-blue-50 p-4 text-sm text-blue-700">
|
||||||
rel="noopener noreferrer"
|
<strong>{uploadedFile.name}</strong> 업로드 완료.
|
||||||
>
|
실시간 녹음 탭에서 음성 인식을 시작하거나, 텍스트를 직접 입력해주세요.
|
||||||
Documentation
|
</div>
|
||||||
</a>
|
</section>
|
||||||
</div>
|
)}
|
||||||
</main>
|
|
||||||
</div>
|
<section className="mb-8">
|
||||||
);
|
<div className="flex items-center gap-4">
|
||||||
|
<label className="text-sm font-medium text-neutral-600">
|
||||||
|
변환 모드:
|
||||||
|
</label>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<button
|
||||||
|
onClick={() => setSummaryMode('simple')}
|
||||||
|
className={`rounded-lg px-4 py-2 text-sm font-medium transition-all ${
|
||||||
|
summaryMode === 'simple'
|
||||||
|
? 'bg-green-100 text-green-700 ring-1 ring-green-300'
|
||||||
|
: 'bg-neutral-100 text-neutral-600 hover:bg-neutral-200'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
단순 변환
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => setSummaryMode('gemini')}
|
||||||
|
className={`rounded-lg px-4 py-2 text-sm font-medium transition-all ${
|
||||||
|
summaryMode === 'gemini'
|
||||||
|
? 'bg-purple-100 text-purple-700 ring-1 ring-purple-300'
|
||||||
|
: 'bg-neutral-100 text-neutral-600 hover:bg-neutral-200'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
Gemini AI 요약
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<button
|
||||||
|
onClick={generateMinutes}
|
||||||
|
disabled={loading || !transcript.trim()}
|
||||||
|
className="w-full rounded-xl bg-neutral-900 px-6 py-3.5 text-white font-medium shadow-lg shadow-neutral-900/10 hover:bg-neutral-800 disabled:opacity-40 disabled:cursor-not-allowed transition-all mb-8"
|
||||||
|
>
|
||||||
|
{loading ? '생성 중...' : '회의록 생성'}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{error && (
|
||||||
|
<div className="mb-8 rounded-xl border border-red-200 bg-red-50 p-4 text-sm text-red-700">
|
||||||
|
{error}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{result && (
|
||||||
|
<MinutesViewer
|
||||||
|
markdown={result.markdown}
|
||||||
|
title={title || '무제 회의'}
|
||||||
|
mode={result.mode}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,82 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import { exportAsMarkdown, exportAsHtml, generateDownloadFilename } from '@/lib/export-minutes'
|
||||||
|
|
||||||
|
interface MinutesViewerProps {
|
||||||
|
markdown: string
|
||||||
|
title: string
|
||||||
|
mode: 'simple' | 'gemini'
|
||||||
|
}
|
||||||
|
|
||||||
|
export function MinutesViewer({ markdown, title, mode }: MinutesViewerProps) {
|
||||||
|
function download(type: 'md' | 'html') {
|
||||||
|
const filename = generateDownloadFilename(title, new Date())
|
||||||
|
|
||||||
|
if (type === 'md') {
|
||||||
|
const blob = exportAsMarkdown(markdown)
|
||||||
|
triggerDownload(blob, `${filename}.md`)
|
||||||
|
} else {
|
||||||
|
const html = exportAsHtml(markdown, title)
|
||||||
|
const blob = new Blob([html], { type: 'text/html;charset=utf-8' })
|
||||||
|
triggerDownload(blob, `${filename}.html`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function triggerDownload(blob: Blob, filename: string) {
|
||||||
|
const url = URL.createObjectURL(blob)
|
||||||
|
const a = document.createElement('a')
|
||||||
|
a.href = url
|
||||||
|
a.download = filename
|
||||||
|
a.click()
|
||||||
|
URL.revokeObjectURL(url)
|
||||||
|
}
|
||||||
|
|
||||||
|
function copyToClipboard() {
|
||||||
|
navigator.clipboard.writeText(markdown)
|
||||||
|
}
|
||||||
|
|
||||||
|
const modeLabel = mode === 'gemini' ? 'Gemini AI 요약' : '단순 변환'
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<h2 className="text-lg font-semibold text-neutral-800">회의록</h2>
|
||||||
|
<span className={`text-xs px-2 py-0.5 rounded-full font-medium ${
|
||||||
|
mode === 'gemini'
|
||||||
|
? 'bg-purple-100 text-purple-700'
|
||||||
|
: 'bg-green-100 text-green-700'
|
||||||
|
}`}>
|
||||||
|
{modeLabel}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<button
|
||||||
|
onClick={copyToClipboard}
|
||||||
|
className="rounded-lg border border-neutral-300 px-3 py-1.5 text-sm hover:bg-neutral-50 transition-colors"
|
||||||
|
>
|
||||||
|
복사
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => download('md')}
|
||||||
|
className="rounded-lg border border-neutral-300 px-3 py-1.5 text-sm hover:bg-neutral-50 transition-colors"
|
||||||
|
>
|
||||||
|
.md 다운로드
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => download('html')}
|
||||||
|
className="rounded-lg border border-neutral-300 px-3 py-1.5 text-sm hover:bg-neutral-50 transition-colors"
|
||||||
|
>
|
||||||
|
.html 다운로드
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="rounded-2xl border border-neutral-200 bg-white p-6 max-h-[500px] overflow-y-auto">
|
||||||
|
<pre className="whitespace-pre-wrap text-sm text-neutral-700 font-mono leading-relaxed">
|
||||||
|
{markdown}
|
||||||
|
</pre>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,91 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import { useSpeechRecognition } from '@/hooks/useSpeechRecognition'
|
||||||
|
import { formatTranscriptChunks } from '@/lib/transcript-formatter'
|
||||||
|
|
||||||
|
interface LiveRecorderProps {
|
||||||
|
onTranscriptReady: (transcript: string) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
export function LiveRecorder({ onTranscriptReady }: LiveRecorderProps) {
|
||||||
|
const {
|
||||||
|
isListening,
|
||||||
|
isSupported,
|
||||||
|
chunks,
|
||||||
|
interimText,
|
||||||
|
startListening,
|
||||||
|
stopListening,
|
||||||
|
resetChunks,
|
||||||
|
} = useSpeechRecognition()
|
||||||
|
|
||||||
|
if (!isSupported) {
|
||||||
|
return (
|
||||||
|
<div className="rounded-2xl border border-amber-200 bg-amber-50 p-6 text-center">
|
||||||
|
<p className="text-amber-800">
|
||||||
|
이 브라우저는 음성 인식을 지원하지 않습니다.
|
||||||
|
Chrome 브라우저를 사용해주세요.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleStop() {
|
||||||
|
stopListening()
|
||||||
|
const transcript = formatTranscriptChunks(chunks)
|
||||||
|
if (transcript.length > 0) {
|
||||||
|
onTranscriptReady(transcript)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
{isListening ? (
|
||||||
|
<button
|
||||||
|
onClick={handleStop}
|
||||||
|
className="flex items-center gap-2 rounded-full bg-red-500 px-6 py-3 text-white font-medium shadow-lg shadow-red-500/25 hover:bg-red-600 transition-colors"
|
||||||
|
>
|
||||||
|
<span className="relative flex h-3 w-3">
|
||||||
|
<span className="absolute inline-flex h-full w-full animate-ping rounded-full bg-white opacity-75" />
|
||||||
|
<span className="relative inline-flex h-3 w-3 rounded-full bg-white" />
|
||||||
|
</span>
|
||||||
|
녹음 중지
|
||||||
|
</button>
|
||||||
|
) : (
|
||||||
|
<button
|
||||||
|
onClick={startListening}
|
||||||
|
className="flex items-center gap-2 rounded-full bg-blue-600 px-6 py-3 text-white font-medium shadow-lg shadow-blue-600/25 hover:bg-blue-700 transition-colors"
|
||||||
|
>
|
||||||
|
<span className="text-lg">🎤</span>
|
||||||
|
녹음 시작
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{chunks.length > 0 && !isListening && (
|
||||||
|
<button
|
||||||
|
onClick={resetChunks}
|
||||||
|
className="rounded-full border border-neutral-300 px-4 py-2 text-sm text-neutral-600 hover:bg-neutral-50 transition-colors"
|
||||||
|
>
|
||||||
|
초기화
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{(chunks.length > 0 || interimText) && (
|
||||||
|
<div className="rounded-2xl border border-neutral-200 bg-neutral-50 p-6 max-h-64 overflow-y-auto">
|
||||||
|
<h3 className="text-sm font-semibold text-neutral-500 mb-3 uppercase tracking-wider">
|
||||||
|
실시간 텍스트
|
||||||
|
</h3>
|
||||||
|
<div className="space-y-1 text-sm font-mono text-neutral-700">
|
||||||
|
{chunks.map((chunk, i) => (
|
||||||
|
<p key={i}>{chunk.text}</p>
|
||||||
|
))}
|
||||||
|
{interimText && (
|
||||||
|
<p className="text-neutral-400 italic">{interimText}...</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,82 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import { useState, useRef, type DragEvent } from 'react'
|
||||||
|
import { validateAudioFile } from '@/lib/audio-validation'
|
||||||
|
|
||||||
|
interface AudioUploaderProps {
|
||||||
|
onFileSelected: (file: File) => void
|
||||||
|
disabled?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
export function AudioUploader({ onFileSelected, disabled }: AudioUploaderProps) {
|
||||||
|
const [dragOver, setDragOver] = useState(false)
|
||||||
|
const [error, setError] = useState<string | null>(null)
|
||||||
|
const inputRef = useRef<HTMLInputElement>(null)
|
||||||
|
|
||||||
|
function handleFile(file: File) {
|
||||||
|
const result = validateAudioFile(file)
|
||||||
|
if (!result.valid) {
|
||||||
|
setError(result.error)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
setError(null)
|
||||||
|
onFileSelected(file)
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleDrop(e: DragEvent<HTMLDivElement>) {
|
||||||
|
e.preventDefault()
|
||||||
|
setDragOver(false)
|
||||||
|
const file = e.dataTransfer.files[0]
|
||||||
|
if (file) handleFile(file)
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleDragOver(e: DragEvent<HTMLDivElement>) {
|
||||||
|
e.preventDefault()
|
||||||
|
setDragOver(true)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-3">
|
||||||
|
<div
|
||||||
|
onDrop={handleDrop}
|
||||||
|
onDragOver={handleDragOver}
|
||||||
|
onDragLeave={() => setDragOver(false)}
|
||||||
|
onClick={() => inputRef.current?.click()}
|
||||||
|
className={`
|
||||||
|
relative cursor-pointer rounded-2xl border-2 border-dashed p-12
|
||||||
|
text-center transition-all duration-200
|
||||||
|
${dragOver
|
||||||
|
? 'border-blue-400 bg-blue-50/50 scale-[1.01]'
|
||||||
|
: 'border-neutral-300 hover:border-neutral-400 hover:bg-neutral-50/50'
|
||||||
|
}
|
||||||
|
${disabled ? 'pointer-events-none opacity-50' : ''}
|
||||||
|
`}
|
||||||
|
>
|
||||||
|
<div className="space-y-3">
|
||||||
|
<div className="text-4xl">🎙️</div>
|
||||||
|
<p className="text-lg font-medium text-neutral-700">
|
||||||
|
오디오 파일을 드래그하거나 클릭하여 업로드
|
||||||
|
</p>
|
||||||
|
<p className="text-sm text-neutral-500">
|
||||||
|
MP3, WAV, WebM, M4A, OGG, FLAC (최대 500MB)
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<input
|
||||||
|
ref={inputRef}
|
||||||
|
type="file"
|
||||||
|
accept="audio/*"
|
||||||
|
className="hidden"
|
||||||
|
onChange={(e) => {
|
||||||
|
const file = e.target.files?.[0]
|
||||||
|
if (file) handleFile(file)
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
{error && (
|
||||||
|
<p className="text-sm text-red-600 bg-red-50 rounded-lg px-4 py-2">
|
||||||
|
{error}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,107 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import { useState, useRef, useCallback } from 'react'
|
||||||
|
import type { TranscriptChunk } from '@/lib/transcript-formatter'
|
||||||
|
|
||||||
|
interface SpeechRecognitionHook {
|
||||||
|
isListening: boolean
|
||||||
|
isSupported: boolean
|
||||||
|
chunks: TranscriptChunk[]
|
||||||
|
interimText: string
|
||||||
|
startListening: () => void
|
||||||
|
stopListening: () => void
|
||||||
|
resetChunks: () => void
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useSpeechRecognition(): SpeechRecognitionHook {
|
||||||
|
const [isListening, setIsListening] = useState(false)
|
||||||
|
const [chunks, setChunks] = useState<TranscriptChunk[]>([])
|
||||||
|
const [interimText, setInterimText] = useState('')
|
||||||
|
const recognitionRef = useRef<SpeechRecognition | null>(null)
|
||||||
|
const startTimeRef = useRef<number>(0)
|
||||||
|
|
||||||
|
const isSupported =
|
||||||
|
typeof window !== 'undefined' &&
|
||||||
|
('SpeechRecognition' in window || 'webkitSpeechRecognition' in window)
|
||||||
|
|
||||||
|
const startListening = useCallback(() => {
|
||||||
|
if (!isSupported) return
|
||||||
|
|
||||||
|
const SpeechRecognitionAPI =
|
||||||
|
window.SpeechRecognition || window.webkitSpeechRecognition
|
||||||
|
|
||||||
|
const recognition = new SpeechRecognitionAPI()
|
||||||
|
recognition.lang = 'ko-KR'
|
||||||
|
recognition.continuous = true
|
||||||
|
recognition.interimResults = true
|
||||||
|
|
||||||
|
startTimeRef.current = Date.now()
|
||||||
|
|
||||||
|
recognition.onresult = (event: SpeechRecognitionEvent) => {
|
||||||
|
const now = (Date.now() - startTimeRef.current) / 1000
|
||||||
|
|
||||||
|
for (let i = event.resultIndex; i < event.results.length; i++) {
|
||||||
|
const result = event.results[i]
|
||||||
|
const text = result[0].transcript.trim()
|
||||||
|
|
||||||
|
if (result.isFinal) {
|
||||||
|
setChunks((prev) => [
|
||||||
|
...prev,
|
||||||
|
{
|
||||||
|
text,
|
||||||
|
startTime: Math.max(0, now - 2),
|
||||||
|
endTime: now,
|
||||||
|
isFinal: true,
|
||||||
|
},
|
||||||
|
])
|
||||||
|
setInterimText('')
|
||||||
|
} else {
|
||||||
|
setInterimText(text)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
recognition.onend = () => {
|
||||||
|
if (recognitionRef.current) {
|
||||||
|
recognition.start()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
recognition.onerror = (event: SpeechRecognitionErrorEvent) => {
|
||||||
|
if (event.error !== 'no-speech' && event.error !== 'aborted') {
|
||||||
|
console.error('Speech recognition error:', event.error)
|
||||||
|
setIsListening(false)
|
||||||
|
recognitionRef.current = null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
recognitionRef.current = recognition
|
||||||
|
recognition.start()
|
||||||
|
setIsListening(true)
|
||||||
|
}, [isSupported])
|
||||||
|
|
||||||
|
const stopListening = useCallback(() => {
|
||||||
|
if (recognitionRef.current) {
|
||||||
|
const recognition = recognitionRef.current
|
||||||
|
recognitionRef.current = null
|
||||||
|
recognition.stop()
|
||||||
|
setIsListening(false)
|
||||||
|
setInterimText('')
|
||||||
|
}
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const resetChunks = useCallback(() => {
|
||||||
|
setChunks([])
|
||||||
|
setInterimText('')
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
return {
|
||||||
|
isListening,
|
||||||
|
isSupported,
|
||||||
|
chunks,
|
||||||
|
interimText,
|
||||||
|
startListening,
|
||||||
|
stopListening,
|
||||||
|
resetChunks,
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
export const ALLOWED_MIME_TYPES = [
|
||||||
|
'audio/mpeg',
|
||||||
|
'audio/wav',
|
||||||
|
'audio/webm',
|
||||||
|
'audio/mp4',
|
||||||
|
'audio/ogg',
|
||||||
|
'audio/flac',
|
||||||
|
'audio/x-m4a',
|
||||||
|
] as const
|
||||||
|
|
||||||
|
export const MAX_FILE_SIZE_BYTES = 500 * 1024 * 1024 // 500MB
|
||||||
|
|
||||||
|
type ValidationResult =
|
||||||
|
| { valid: true; error?: undefined }
|
||||||
|
| { valid: false; error: string }
|
||||||
|
|
||||||
|
export function validateAudioFile(file: File): ValidationResult {
|
||||||
|
if (file.size === 0) {
|
||||||
|
return { valid: false, error: '파일이 비어 있습니다.' }
|
||||||
|
}
|
||||||
|
|
||||||
|
if (file.size > MAX_FILE_SIZE_BYTES) {
|
||||||
|
const maxMB = MAX_FILE_SIZE_BYTES / (1024 * 1024)
|
||||||
|
return {
|
||||||
|
valid: false,
|
||||||
|
error: `파일 크기가 ${maxMB}MB를 초과합니다.`,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!ALLOWED_MIME_TYPES.includes(file.type as (typeof ALLOWED_MIME_TYPES)[number])) {
|
||||||
|
return {
|
||||||
|
valid: false,
|
||||||
|
error: `지원하지 않는 파일 형식입니다: ${file.type}`,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return { valid: true }
|
||||||
|
}
|
||||||
@@ -0,0 +1,61 @@
|
|||||||
|
export function exportAsMarkdown(markdown: string): Blob {
|
||||||
|
return new Blob([markdown], { type: 'text/markdown;charset=utf-8' })
|
||||||
|
}
|
||||||
|
|
||||||
|
export function exportAsHtml(markdown: string, title: string): string {
|
||||||
|
const lines = markdown.split('\n')
|
||||||
|
const bodyHtml = lines
|
||||||
|
.map((line) => {
|
||||||
|
if (line.startsWith('# ')) return `<h1>${escapeHtml(line.slice(2))}</h1>`
|
||||||
|
if (line.startsWith('## ')) return `<h2>${escapeHtml(line.slice(3))}</h2>`
|
||||||
|
if (line.startsWith('### ')) return `<h3>${escapeHtml(line.slice(4))}</h3>`
|
||||||
|
if (line.startsWith('---')) return '<hr>'
|
||||||
|
if (line.startsWith('- ')) return `<li>${escapeHtml(line.slice(2))}</li>`
|
||||||
|
if (line.startsWith('**') && line.endsWith('**'))
|
||||||
|
return `<p><strong>${escapeHtml(line.slice(2, -2))}</strong></p>`
|
||||||
|
if (line.trim() === '') return ''
|
||||||
|
return `<p>${escapeHtml(line)}</p>`
|
||||||
|
})
|
||||||
|
.join('\n')
|
||||||
|
|
||||||
|
return `<!DOCTYPE html>
|
||||||
|
<html lang="ko">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>${escapeHtml(title)}</title>
|
||||||
|
<style>
|
||||||
|
body {
|
||||||
|
font-family: 'Noto Sans KR', Arial, sans-serif;
|
||||||
|
max-width: 800px;
|
||||||
|
margin: 0 auto;
|
||||||
|
padding: 2rem;
|
||||||
|
line-height: 1.6;
|
||||||
|
color: #333;
|
||||||
|
}
|
||||||
|
h1 { font-size: 1.8rem; border-bottom: 2px solid #333; padding-bottom: 0.5rem; }
|
||||||
|
h2 { font-size: 1.4rem; color: #555; margin-top: 1.5rem; }
|
||||||
|
hr { border: none; border-top: 1px solid #ddd; margin: 1.5rem 0; }
|
||||||
|
p { margin: 0.5rem 0; }
|
||||||
|
li { margin: 0.3rem 0; }
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
${bodyHtml}
|
||||||
|
</body>
|
||||||
|
</html>`
|
||||||
|
}
|
||||||
|
|
||||||
|
export function generateDownloadFilename(title: string, date: Date): string {
|
||||||
|
const dateStr = date.toISOString().split('T')[0]
|
||||||
|
const sanitized = title.replace(/[^a-zA-Z0-9가-힣\s]/g, '_').replace(/\s+/g, '_')
|
||||||
|
return `${sanitized}_${dateStr}`
|
||||||
|
}
|
||||||
|
|
||||||
|
function escapeHtml(text: string): string {
|
||||||
|
return text
|
||||||
|
.replace(/&/g, '&')
|
||||||
|
.replace(/</g, '<')
|
||||||
|
.replace(/>/g, '>')
|
||||||
|
.replace(/"/g, '"')
|
||||||
|
}
|
||||||
@@ -0,0 +1,114 @@
|
|||||||
|
export interface MinutesInput {
|
||||||
|
title: string
|
||||||
|
date: Date
|
||||||
|
transcript: string
|
||||||
|
}
|
||||||
|
|
||||||
|
type GeminiResult =
|
||||||
|
| { success: true; markdown: string }
|
||||||
|
| { success: false; error: string }
|
||||||
|
|
||||||
|
interface GeminiOptions {
|
||||||
|
apiKey: string
|
||||||
|
fetchFn?: typeof fetch
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatDate(date: Date): string {
|
||||||
|
return date.toISOString().split('T')[0]
|
||||||
|
}
|
||||||
|
|
||||||
|
export function generateSimpleMinutes(input: MinutesInput): string {
|
||||||
|
const { title, date, transcript } = input
|
||||||
|
const dateStr = formatDate(date)
|
||||||
|
|
||||||
|
const content = transcript.trim().length > 0 ? transcript : '*내용 없음*'
|
||||||
|
|
||||||
|
return `# ${title}
|
||||||
|
|
||||||
|
**날짜**: ${dateStr}
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 회의 내용
|
||||||
|
|
||||||
|
${content}
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
*이 회의록은 음성 인식 결과를 기반으로 자동 생성되었습니다.*
|
||||||
|
`
|
||||||
|
}
|
||||||
|
|
||||||
|
const GEMINI_PROMPT = `당신은 회의록 작성 전문가입니다. 아래 음성 인식 텍스트를 분석하여 구조화된 회의록을 마크다운 형식으로 작성해주세요.
|
||||||
|
|
||||||
|
포함할 섹션:
|
||||||
|
- ## 요약 (핵심 내용 3-5줄)
|
||||||
|
- ## 주요 논의 사항 (번호 매기기)
|
||||||
|
- ## 액션 아이템 (체크리스트 형식)
|
||||||
|
- ## 다음 단계
|
||||||
|
|
||||||
|
간결하고 명확하게 작성해주세요. 한국어로 작성합니다.
|
||||||
|
|
||||||
|
---
|
||||||
|
음성 인식 텍스트:
|
||||||
|
`
|
||||||
|
|
||||||
|
export async function generateGeminiMinutes(
|
||||||
|
input: MinutesInput,
|
||||||
|
options: GeminiOptions,
|
||||||
|
): Promise<GeminiResult> {
|
||||||
|
const { apiKey, fetchFn = fetch } = options
|
||||||
|
|
||||||
|
if (!apiKey || apiKey.trim().length === 0) {
|
||||||
|
return { success: false, error: 'Gemini API 키가 필요합니다.' }
|
||||||
|
}
|
||||||
|
|
||||||
|
const url = `https://generativelanguage.googleapis.com/v1beta/models/gemini-2.0-flash:generateContent?key=${apiKey}`
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetchFn(url, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({
|
||||||
|
contents: [
|
||||||
|
{
|
||||||
|
parts: [{ text: `${GEMINI_PROMPT}${input.transcript}` }],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
error: `Gemini API 호출 실패: ${response.status} ${response.statusText}`,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = await response.json()
|
||||||
|
const generatedText =
|
||||||
|
data?.candidates?.[0]?.content?.parts?.[0]?.text ?? ''
|
||||||
|
|
||||||
|
const dateStr = formatDate(input.date)
|
||||||
|
const markdown = `# ${input.title}
|
||||||
|
|
||||||
|
**날짜**: ${dateStr}
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
${generatedText}
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
*이 회의록은 Gemini AI를 활용하여 자동 생성되었습니다.*
|
||||||
|
`
|
||||||
|
|
||||||
|
return { success: true, markdown }
|
||||||
|
} catch (err) {
|
||||||
|
const message = err instanceof Error ? err.message : '알 수 없는 오류'
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
error: `Gemini API 호출 중 오류 발생: ${message}`,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,52 @@
|
|||||||
|
export interface TranscriptChunk {
|
||||||
|
text: string
|
||||||
|
startTime: number
|
||||||
|
endTime: number
|
||||||
|
isFinal: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatTimestamp(seconds: number): string {
|
||||||
|
const h = Math.floor(seconds / 3600)
|
||||||
|
const m = Math.floor((seconds % 3600) / 60)
|
||||||
|
const s = Math.floor(seconds % 60)
|
||||||
|
|
||||||
|
if (h > 0) {
|
||||||
|
return `[${String(h).padStart(2, '0')}:${String(m).padStart(2, '0')}:${String(s).padStart(2, '0')}]`
|
||||||
|
}
|
||||||
|
return `[${String(m).padStart(2, '0')}:${String(s).padStart(2, '0')}]`
|
||||||
|
}
|
||||||
|
|
||||||
|
export function formatTranscriptChunks(chunks: readonly TranscriptChunk[]): string {
|
||||||
|
if (chunks.length === 0) return ''
|
||||||
|
|
||||||
|
return chunks
|
||||||
|
.map((chunk) => `${formatTimestamp(chunk.startTime)} ${chunk.text}`)
|
||||||
|
.join('\n')
|
||||||
|
}
|
||||||
|
|
||||||
|
export function mergeAdjacentChunks(
|
||||||
|
chunks: readonly TranscriptChunk[],
|
||||||
|
gapThreshold: number,
|
||||||
|
): TranscriptChunk[] {
|
||||||
|
if (chunks.length === 0) return []
|
||||||
|
|
||||||
|
const result: TranscriptChunk[] = [{ ...chunks[0] }]
|
||||||
|
|
||||||
|
for (let i = 1; i < chunks.length; i++) {
|
||||||
|
const current = chunks[i]
|
||||||
|
const last = result[result.length - 1]
|
||||||
|
|
||||||
|
if (current.startTime - last.endTime <= gapThreshold) {
|
||||||
|
result[result.length - 1] = {
|
||||||
|
text: `${last.text} ${current.text}`,
|
||||||
|
startTime: last.startTime,
|
||||||
|
endTime: current.endTime,
|
||||||
|
isFinal: current.isFinal,
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
result.push({ ...current })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return result
|
||||||
|
}
|
||||||
@@ -0,0 +1,42 @@
|
|||||||
|
import { validateAudioFile } from './audio-validation'
|
||||||
|
|
||||||
|
type UploadResult =
|
||||||
|
| {
|
||||||
|
success: true
|
||||||
|
data: {
|
||||||
|
title: string
|
||||||
|
fileName: string
|
||||||
|
mimeType: string
|
||||||
|
fileSize: number
|
||||||
|
}
|
||||||
|
}
|
||||||
|
| { success: false; error: string }
|
||||||
|
|
||||||
|
export async function handleUpload(formData: FormData): Promise<UploadResult> {
|
||||||
|
const audioFile = formData.get('audio')
|
||||||
|
|
||||||
|
if (!audioFile || !(audioFile instanceof File)) {
|
||||||
|
return { success: false, error: '오디오 파일이 필요합니다.' }
|
||||||
|
}
|
||||||
|
|
||||||
|
const validation = validateAudioFile(audioFile)
|
||||||
|
if (!validation.valid) {
|
||||||
|
return { success: false, error: validation.error }
|
||||||
|
}
|
||||||
|
|
||||||
|
const titleInput = formData.get('title')
|
||||||
|
const title =
|
||||||
|
typeof titleInput === 'string' && titleInput.trim().length > 0
|
||||||
|
? titleInput.trim()
|
||||||
|
: audioFile.name.replace(/\.[^.]+$/, '')
|
||||||
|
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
data: {
|
||||||
|
title,
|
||||||
|
fileName: audioFile.name,
|
||||||
|
mimeType: audioFile.type,
|
||||||
|
fileSize: audioFile.size,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
import '@testing-library/jest-dom/vitest'
|
||||||
Vendored
+46
@@ -0,0 +1,46 @@
|
|||||||
|
interface SpeechRecognition extends EventTarget {
|
||||||
|
lang: string
|
||||||
|
continuous: boolean
|
||||||
|
interimResults: boolean
|
||||||
|
onresult: ((event: SpeechRecognitionEvent) => void) | null
|
||||||
|
onend: (() => void) | null
|
||||||
|
onerror: ((event: SpeechRecognitionErrorEvent) => void) | null
|
||||||
|
start(): void
|
||||||
|
stop(): void
|
||||||
|
abort(): void
|
||||||
|
}
|
||||||
|
|
||||||
|
interface SpeechRecognitionEvent extends Event {
|
||||||
|
resultIndex: number
|
||||||
|
results: SpeechRecognitionResultList
|
||||||
|
}
|
||||||
|
|
||||||
|
interface SpeechRecognitionResultList {
|
||||||
|
length: number
|
||||||
|
[index: number]: SpeechRecognitionResult
|
||||||
|
}
|
||||||
|
|
||||||
|
interface SpeechRecognitionResult {
|
||||||
|
isFinal: boolean
|
||||||
|
length: number
|
||||||
|
[index: number]: SpeechRecognitionAlternative
|
||||||
|
}
|
||||||
|
|
||||||
|
interface SpeechRecognitionAlternative {
|
||||||
|
transcript: string
|
||||||
|
confidence: number
|
||||||
|
}
|
||||||
|
|
||||||
|
interface SpeechRecognitionErrorEvent extends Event {
|
||||||
|
error: string
|
||||||
|
message: string
|
||||||
|
}
|
||||||
|
|
||||||
|
declare let SpeechRecognition: {
|
||||||
|
new (): SpeechRecognition
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Window {
|
||||||
|
SpeechRecognition: typeof SpeechRecognition
|
||||||
|
webkitSpeechRecognition: typeof SpeechRecognition
|
||||||
|
}
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
import { defineConfig } from 'vitest/config'
|
||||||
|
import react from '@vitejs/plugin-react'
|
||||||
|
import path from 'path'
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
plugins: [react()],
|
||||||
|
test: {
|
||||||
|
environment: 'jsdom',
|
||||||
|
globals: true,
|
||||||
|
setupFiles: ['./src/test/setup.ts'],
|
||||||
|
coverage: {
|
||||||
|
provider: 'v8',
|
||||||
|
reporter: ['text', 'json', 'html'],
|
||||||
|
exclude: [
|
||||||
|
'node_modules/',
|
||||||
|
'src/test/',
|
||||||
|
'*.config.*',
|
||||||
|
'.next/',
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
resolve: {
|
||||||
|
alias: {
|
||||||
|
'@': path.resolve(__dirname, './src'),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
Reference in New Issue
Block a user