mirror of
https://github.com/nad4-su/meeting-minutes.git
synced 2026-10-11 15:50:13 +09:00
Compare commits
5
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
874cf11a7a | ||
|
|
73d5790dcc | ||
|
|
4af0a03de5 | ||
|
|
a8cfab8a4a | ||
|
|
d2cb5b0009 |
No files matched your search
@@ -49,3 +49,9 @@ next-env.d.ts
|
|||||||
.env
|
.env
|
||||||
!.env.example
|
!.env.example
|
||||||
|
|
||||||
|
|
||||||
|
# 화자 분리 모델 (npm run setup:diarization 으로 내려받음)
|
||||||
|
models/
|
||||||
|
|
||||||
|
# 로컬 개발용 compose 오버라이드
|
||||||
|
docker-compose.override.yml
|
||||||
+8
-3
@@ -1,4 +1,6 @@
|
|||||||
FROM node:22-alpine AS base
|
# sherpa-onnx 사전 빌드 바이너리가 glibc에 링크되어 있어 Alpine(musl)에서는
|
||||||
|
# ld-linux-aarch64.so.1 을 찾지 못한다. glibc 계열인 slim 을 쓴다.
|
||||||
|
FROM node:22-slim AS base
|
||||||
|
|
||||||
FROM base AS deps
|
FROM base AS deps
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
@@ -15,8 +17,8 @@ RUN npm run build
|
|||||||
FROM base AS runner
|
FROM base AS runner
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
ENV NODE_ENV=production
|
ENV NODE_ENV=production
|
||||||
RUN addgroup --system --gid 1001 nodejs
|
RUN groupadd --system --gid 1001 nodejs \
|
||||||
RUN adduser --system --uid 1001 nextjs
|
&& useradd --system --uid 1001 --gid nodejs nextjs
|
||||||
COPY --from=builder /app/public ./public
|
COPY --from=builder /app/public ./public
|
||||||
COPY --from=builder /app/.next/standalone ./
|
COPY --from=builder /app/.next/standalone ./
|
||||||
COPY --from=builder /app/.next/static ./.next/static
|
COPY --from=builder /app/.next/static ./.next/static
|
||||||
@@ -24,6 +26,9 @@ COPY --from=builder /app/prisma ./prisma
|
|||||||
|
|
||||||
RUN mkdir -p uploads && chown nextjs:nodejs uploads
|
RUN mkdir -p uploads && chown nextjs:nodejs uploads
|
||||||
|
|
||||||
|
# sherpa-onnx의 .so는 표준 검색 경로에 없으므로 명시해준다.
|
||||||
|
ENV LD_LIBRARY_PATH=/app/node_modules/sherpa-onnx-linux-arm64:/app/node_modules/sherpa-onnx-linux-x64
|
||||||
|
|
||||||
USER nextjs
|
USER nextjs
|
||||||
EXPOSE 3000
|
EXPOSE 3000
|
||||||
ENV PORT=3000
|
ENV PORT=3000
|
||||||
|
|||||||
@@ -24,6 +24,7 @@ docker compose up -d
|
|||||||
|
|
||||||
- [✨ 주요 기능](#-주요-기능)
|
- [✨ 주요 기능](#-주요-기능)
|
||||||
- [🎤 실시간 녹음 & 전사](#-실시간-녹음--전사)
|
- [🎤 실시간 녹음 & 전사](#-실시간-녹음--전사)
|
||||||
|
- [🗣️ 화자 분리](#️-화자-분리)
|
||||||
- [🤖 AI 회의록 (6 템플릿 × 3 강도)](#-ai-회의록-6-템플릿--3-강도)
|
- [🤖 AI 회의록 (6 템플릿 × 3 강도)](#-ai-회의록-6-템플릿--3-강도)
|
||||||
- [⚡ 실시간 롤링 요약](#-실시간-롤링-요약)
|
- [⚡ 실시간 롤링 요약](#-실시간-롤링-요약)
|
||||||
- [💾 회의록 저장/조회/검색](#-회의록-저장조회검색)
|
- [💾 회의록 저장/조회/검색](#-회의록-저장조회검색)
|
||||||
@@ -101,6 +102,42 @@ docker compose up -d
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
### 🗣️ 화자 분리
|
||||||
|
|
||||||
|
누가 무슨 말을 했는지 구분해 기록합니다. 회의 환경에 따라 두 가지 방식을 씁니다.
|
||||||
|
|
||||||
|
| 모드 | 방식 | 정확도 | 준비물 |
|
||||||
|
|---|---|---|---|
|
||||||
|
| **원격 회의** | 내 마이크와 시스템(탭) 오디오를 **별도 트랙**으로 받아 트랙별로 전사 | **100%** — 추론 없음 | 없음 |
|
||||||
|
| **대면 회의** | 마이크 한 트랙을 **내 PC에서** 분석해 화자를 나눔 | DER 12~15% | 모델 1회 설치 |
|
||||||
|
|
||||||
|
**원격 회의**는 트랙이 곧 화자라 틀릴 수가 없습니다. 시작할 때 화면 공유 대화상자에서
|
||||||
|
**"탭 오디오 공유"를 반드시 켜야** 합니다. 켜지 않으면 마이크 단독 녹음으로 넘어가고
|
||||||
|
화자 라벨이 붙지 않습니다 (틀린 라벨을 붙이지 않습니다).
|
||||||
|
|
||||||
|
**대면 회의**는 `pyannote-segmentation-3.0` + `3D-Speaker CAM++` ONNX 모델을 로컬
|
||||||
|
Next.js 프로세스에서 실행합니다. **오디오가 외부로 나가지 않습니다.** GPU도 파이썬
|
||||||
|
런타임도 필요 없고, 노트북 CPU에서 실시간의 수십 배 속도로 처리됩니다.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 최초 1회 — 모델 약 34MB 내려받기
|
||||||
|
npm run setup:diarization
|
||||||
|
```
|
||||||
|
|
||||||
|
> Docker로 실행하실 때도 **호스트에서** 위 명령을 먼저 실행하세요. `models/` 디렉터리가
|
||||||
|
> 컨테이너에 읽기 전용으로 마운트됩니다. 받지 않으면 대면 모드만 비활성화되고 나머지는
|
||||||
|
> 정상 동작합니다.
|
||||||
|
|
||||||
|
> 💡 **참석자 수를 정확히 입력하세요.** 자동 추정은 화자 수를 잘못 세는 것이 주 실패
|
||||||
|
> 모드입니다. 실측에서 4인 오디오가 자동 모드로는 5명, 참석자 수를 알려주면 4명으로
|
||||||
|
> 정확히 나왔습니다.
|
||||||
|
|
||||||
|
대면 모드는 녹음이 끝난 뒤 **전체 오디오에 한 번** 분석을 돌립니다. 구간을 잘라
|
||||||
|
반복 실행하면 실행마다 화자 번호가 달라져 이어 붙일 수 없기 때문입니다. 따라서
|
||||||
|
녹음 중 실시간 화자 표시는 없고, 종료 시 원문 전체에 화자가 붙습니다.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
### ⚡ 실시간 롤링 요약
|
### ⚡ 실시간 롤링 요약
|
||||||
|
|
||||||
녹음 중 **30초마다** Gemini가 그동안의 발화를 분석해 중간 회의록을 자동 갱신합니다.
|
녹음 중 **30초마다** Gemini가 그동안의 발화를 분석해 중간 회의록을 자동 갱신합니다.
|
||||||
@@ -335,7 +372,8 @@ npm run test:coverage # 커버리지 리포트
|
|||||||
| DB | PostgreSQL 16 + Prisma 7 (driver adapter `@prisma/adapter-pg`) |
|
| DB | PostgreSQL 16 + Prisma 7 (driver adapter `@prisma/adapter-pg`) |
|
||||||
| Markdown | `marked` + `isomorphic-dompurify` |
|
| Markdown | `marked` + `isomorphic-dompurify` |
|
||||||
| 스타일 | Tailwind CSS v4 |
|
| 스타일 | Tailwind CSS v4 |
|
||||||
| 테스트 | Vitest 4 (jsdom, 141 tests) |
|
| 화자 분리 | pyannote-segmentation-3.0 + CAM++ (ONNX Runtime, CPU) |
|
||||||
|
| 테스트 | Vitest 4 (jsdom, 184 tests) |
|
||||||
| 배포 | Docker Compose + standalone Next.js 빌드 |
|
| 배포 | Docker Compose + standalone Next.js 빌드 |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|||||||
+16
@@ -52,6 +52,7 @@
|
|||||||
| 액션 아이템 | PostgreSQL (`action_items`) | 외부 송출 없음 |
|
| 액션 아이템 | PostgreSQL (`action_items`) | 외부 송출 없음 |
|
||||||
| API 키 | 브라우저 LocalStorage 또는 `.env` | 요청 시 선택한 프로바이더에만 전송 |
|
| API 키 | 브라우저 LocalStorage 또는 `.env` | 요청 시 선택한 프로바이더에만 전송 |
|
||||||
| 음성 데이터 | 메모리 (실시간), `/app/uploads` (파일 업로드) | **Chrome Web Speech API → Google 서버** ⚠️ |
|
| 음성 데이터 | 메모리 (실시간), `/app/uploads` (파일 업로드) | **Chrome Web Speech API → Google 서버** ⚠️ |
|
||||||
|
| 화자 분리용 PCM | 서버 프로세스 메모리 (녹음 중에만) | **외부 송출 없음** — 로컬에서 분석하고 분석 후 즉시 폐기 |
|
||||||
|
|
||||||
### ⚠️ 주의: Web Speech API의 음성 외부 전송
|
### ⚠️ 주의: Web Speech API의 음성 외부 전송
|
||||||
|
|
||||||
@@ -77,6 +78,21 @@ Chrome의 `SpeechRecognition` API는 **음성 데이터를 Google 서버로 전
|
|||||||
- 중계 라우터는 요청을 대신 전달하는 구조상 **평문 프롬프트를 볼 수 있는 주체가 한 곳 늘어납니다.** 로깅·보관 정책은 각 서비스 약관을 직접 확인하세요.
|
- 중계 라우터는 요청을 대신 전달하는 구조상 **평문 프롬프트를 볼 수 있는 주체가 한 곳 늘어납니다.** 로깅·보관 정책은 각 서비스 약관을 직접 확인하세요.
|
||||||
- 사내 컴플라이언스가 외부 전송을 제한한다면 **로컬 모델** 프리셋을 사용하세요.
|
- 사내 컴플라이언스가 외부 전송을 제한한다면 **로컬 모델** 프리셋을 사용하세요.
|
||||||
|
|
||||||
|
### 화자 분리 오디오는 로컬에만 머뭅니다
|
||||||
|
|
||||||
|
대면 회의 화자 분리는 `pyannote-segmentation-3.0` + `CAM++` ONNX 모델을 로컬
|
||||||
|
Next.js 프로세스에서 실행합니다. 외부 API를 호출하지 않으므로 **오디오가 네트워크로
|
||||||
|
나가지 않습니다.**
|
||||||
|
|
||||||
|
- 녹음 중 PCM 조각이 서버 프로세스 **메모리**에 쌓입니다 (디스크에 쓰지 않음)
|
||||||
|
- 분석이 끝나면 즉시 폐기하며, 6시간 지난 세션은 자동 정리됩니다
|
||||||
|
- 세션 최대 길이는 3시간으로 제한됩니다
|
||||||
|
- 단일 사용자 로컬 실행 전제입니다. 여러 사용자가 붙는 환경이라면 세션 ID를
|
||||||
|
추측해 다른 사람의 오디오에 접근할 수 있으므로, 노출 배포 시 인증이 선행되어야 합니다
|
||||||
|
|
||||||
|
전사 텍스트는 여전히 Web Speech API를 거치므로 Google로 갑니다. 화자 분리만
|
||||||
|
로컬이라는 점에 유의하세요.
|
||||||
|
|
||||||
### base URL은 서버가 대신 호출합니다 (SSRF 주의)
|
### base URL은 서버가 대신 호출합니다 (SSRF 주의)
|
||||||
|
|
||||||
OpenAI 호환 프리셋의 base URL은 브라우저가 아니라 **Next.js Route Handler(서버)** 가 fetch 합니다.
|
OpenAI 호환 프리셋의 base URL은 브라우저가 아니라 **Next.js Route Handler(서버)** 가 fetch 합니다.
|
||||||
|
|||||||
@@ -43,8 +43,17 @@ services:
|
|||||||
environment:
|
environment:
|
||||||
DATABASE_URL: postgresql://${POSTGRES_USER:?required}:${POSTGRES_PASSWORD:?required}@db:5432/${POSTGRES_DB:?required}
|
DATABASE_URL: postgresql://${POSTGRES_USER:?required}:${POSTGRES_PASSWORD:?required}@db:5432/${POSTGRES_DB:?required}
|
||||||
GEMINI_API_KEY: ${GEMINI_API_KEY:-}
|
GEMINI_API_KEY: ${GEMINI_API_KEY:-}
|
||||||
|
# OpenAI 호환 프로바이더 (선택) — 없으면 브라우저에 저장된 설정을 쓴다
|
||||||
|
LLM_PROVIDER: ${LLM_PROVIDER:-}
|
||||||
|
LLM_BASE_URL: ${LLM_BASE_URL:-}
|
||||||
|
LLM_MODEL: ${LLM_MODEL:-}
|
||||||
|
LLM_API_KEY: ${LLM_API_KEY:-}
|
||||||
|
DIARIZATION_MODEL_DIR: /app/models/diarization
|
||||||
volumes:
|
volumes:
|
||||||
- uploads:/app/uploads
|
- uploads:/app/uploads
|
||||||
|
# 화자 분리 모델 — 호스트에서 npm run setup:diarization 으로 받은 것을 읽기 전용으로 마운트.
|
||||||
|
# 없으면 빈 디렉터리가 되고 대면 화자 분리만 비활성화된다 (앱은 정상 동작).
|
||||||
|
- ./models:/app/models:ro
|
||||||
depends_on:
|
depends_on:
|
||||||
db:
|
db:
|
||||||
condition: service_healthy
|
condition: service_healthy
|
||||||
|
|||||||
@@ -12,6 +12,13 @@ const securityHeaders = [
|
|||||||
|
|
||||||
const nextConfig: NextConfig = {
|
const nextConfig: NextConfig = {
|
||||||
output: "standalone",
|
output: "standalone",
|
||||||
|
// sherpa-onnx-node는 네이티브 애드온이라 번들링하면 안 된다.
|
||||||
|
serverExternalPackages: ["sherpa-onnx-node"],
|
||||||
|
// 트레이싱은 .node 파일만 잡고 동반 공유 라이브러리(.so/.dylib)를 놓친다.
|
||||||
|
// 플랫폼 패키지를 통째로 포함시켜야 standalone 빌드에서 로드된다.
|
||||||
|
outputFileTracingIncludes: {
|
||||||
|
"/api/diarize": ["./node_modules/sherpa-onnx-*/**"],
|
||||||
|
},
|
||||||
async headers() {
|
async headers() {
|
||||||
return [
|
return [
|
||||||
{
|
{
|
||||||
|
|||||||
Generated
+94
-1
@@ -16,7 +16,8 @@
|
|||||||
"pg": "^8.13.1",
|
"pg": "^8.13.1",
|
||||||
"prisma": "^7.7.0",
|
"prisma": "^7.7.0",
|
||||||
"react": "19.2.4",
|
"react": "19.2.4",
|
||||||
"react-dom": "19.2.4"
|
"react-dom": "19.2.4",
|
||||||
|
"sherpa-onnx-node": "^1.13.7"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@tailwindcss/postcss": "^4",
|
"@tailwindcss/postcss": "^4",
|
||||||
@@ -8422,6 +8423,98 @@
|
|||||||
"node": ">=8"
|
"node": ">=8"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/sherpa-onnx-darwin-arm64": {
|
||||||
|
"version": "1.13.7",
|
||||||
|
"resolved": "https://registry.npmjs.org/sherpa-onnx-darwin-arm64/-/sherpa-onnx-darwin-arm64-1.13.7.tgz",
|
||||||
|
"integrity": "sha512-5NCE50hAvr3n2pdett0SgfPBJXaFZE0bqHwbHyiq+IKZ8Ids0l4M0VrG+ImGYIafCwie+oC3uAJ+pKj9xg/k+w==",
|
||||||
|
"cpu": [
|
||||||
|
"arm64"
|
||||||
|
],
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"darwin"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"node_modules/sherpa-onnx-darwin-x64": {
|
||||||
|
"version": "1.13.7",
|
||||||
|
"resolved": "https://registry.npmjs.org/sherpa-onnx-darwin-x64/-/sherpa-onnx-darwin-x64-1.13.7.tgz",
|
||||||
|
"integrity": "sha512-N3o+T+wn9WaQmsKV5DD8bTHdo+WN2+sXwmZcGJZiDjtOMR2zFz7uVCZnYCmEAMgvChC+oHcF5RvEEKcRCAu6Pw==",
|
||||||
|
"cpu": [
|
||||||
|
"x64"
|
||||||
|
],
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"darwin"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"node_modules/sherpa-onnx-linux-arm64": {
|
||||||
|
"version": "1.13.7",
|
||||||
|
"resolved": "https://registry.npmjs.org/sherpa-onnx-linux-arm64/-/sherpa-onnx-linux-arm64-1.13.7.tgz",
|
||||||
|
"integrity": "sha512-TFCVpXyTh69buhOtTS8KIfkRXOVKY4Y1qjAktSItrKS4A0chnnrlXO5bKWoNAPeI6fMxTF/uvMYbYgcvjEMfNg==",
|
||||||
|
"cpu": [
|
||||||
|
"arm64"
|
||||||
|
],
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"node_modules/sherpa-onnx-linux-x64": {
|
||||||
|
"version": "1.13.7",
|
||||||
|
"resolved": "https://registry.npmjs.org/sherpa-onnx-linux-x64/-/sherpa-onnx-linux-x64-1.13.7.tgz",
|
||||||
|
"integrity": "sha512-npmxn5WwmAmlthgBhmbZ33t3i2j4mJwQt46dMEb3j7d41y1/uJrjrVAfa/DkvV+vn49ZWfcQ2UEWDipaZBVhuw==",
|
||||||
|
"cpu": [
|
||||||
|
"x64"
|
||||||
|
],
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"node_modules/sherpa-onnx-node": {
|
||||||
|
"version": "1.13.7",
|
||||||
|
"resolved": "https://registry.npmjs.org/sherpa-onnx-node/-/sherpa-onnx-node-1.13.7.tgz",
|
||||||
|
"integrity": "sha512-0XGV7arGngBCnol0m8OLyqlnaUm19Q1KmetVj1DDBdymXa1upmAHZDwNdN47gjsEhqE5hXUEyc1vRQoXrNhNVg==",
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"optionalDependencies": {
|
||||||
|
"sherpa-onnx-darwin-arm64": "^1.13.7",
|
||||||
|
"sherpa-onnx-darwin-x64": "^1.13.7",
|
||||||
|
"sherpa-onnx-linux-arm64": "^1.13.7",
|
||||||
|
"sherpa-onnx-linux-x64": "^1.13.7",
|
||||||
|
"sherpa-onnx-win-ia32": "^1.13.7",
|
||||||
|
"sherpa-onnx-win-x64": "^1.13.7"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/sherpa-onnx-win-ia32": {
|
||||||
|
"version": "1.13.7",
|
||||||
|
"resolved": "https://registry.npmjs.org/sherpa-onnx-win-ia32/-/sherpa-onnx-win-ia32-1.13.7.tgz",
|
||||||
|
"integrity": "sha512-sTwtpxPQ76XLn0giAbvknIDEDKD3XXi2mo2AVROEucf1pIK1DjQl+LjLkalTeFoQqbC4J3xGx/g+xgcHQD1dsw==",
|
||||||
|
"cpu": [
|
||||||
|
"ia32"
|
||||||
|
],
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"win32"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"node_modules/sherpa-onnx-win-x64": {
|
||||||
|
"version": "1.13.7",
|
||||||
|
"resolved": "https://registry.npmjs.org/sherpa-onnx-win-x64/-/sherpa-onnx-win-x64-1.13.7.tgz",
|
||||||
|
"integrity": "sha512-wBV1o+/zgsMrOjfCFIgGrH6S28xq6CqRCLSavCOjTZ6cqr80yGc07DUHxqsHFPZvfoJU+2JF5L2l3gyWFWoWdQ==",
|
||||||
|
"cpu": [
|
||||||
|
"x64"
|
||||||
|
],
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"win32"
|
||||||
|
]
|
||||||
|
},
|
||||||
"node_modules/side-channel": {
|
"node_modules/side-channel": {
|
||||||
"version": "1.1.0",
|
"version": "1.1.0",
|
||||||
"resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.0.tgz",
|
"resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.0.tgz",
|
||||||
|
|||||||
+4
-2
@@ -9,7 +9,8 @@
|
|||||||
"lint": "eslint",
|
"lint": "eslint",
|
||||||
"test": "vitest run",
|
"test": "vitest run",
|
||||||
"test:watch": "vitest",
|
"test:watch": "vitest",
|
||||||
"test:coverage": "vitest run --coverage"
|
"test:coverage": "vitest run --coverage",
|
||||||
|
"setup:diarization": "node scripts/setup-diarization.mjs"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@prisma/adapter-pg": "^7.7.0",
|
"@prisma/adapter-pg": "^7.7.0",
|
||||||
@@ -20,7 +21,8 @@
|
|||||||
"pg": "^8.13.1",
|
"pg": "^8.13.1",
|
||||||
"prisma": "^7.7.0",
|
"prisma": "^7.7.0",
|
||||||
"react": "19.2.4",
|
"react": "19.2.4",
|
||||||
"react-dom": "19.2.4"
|
"react-dom": "19.2.4",
|
||||||
|
"sherpa-onnx-node": "^1.13.7"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@tailwindcss/postcss": "^4",
|
"@tailwindcss/postcss": "^4",
|
||||||
|
|||||||
@@ -0,0 +1,38 @@
|
|||||||
|
/**
|
||||||
|
* 마이크 오디오를 Int16 PCM 조각으로 잘라 메인 스레드로 넘긴다.
|
||||||
|
*
|
||||||
|
* AudioContext를 16kHz로 열면 브라우저가 리샘플링해주므로 여기서는
|
||||||
|
* float32 → int16 변환과 버퍼링만 한다. 화자 분리 모델이 16kHz 모노를 받는다.
|
||||||
|
*/
|
||||||
|
const CHUNK_SAMPLES = 16000 * 4 // 4초
|
||||||
|
|
||||||
|
class PcmCollector extends AudioWorkletProcessor {
|
||||||
|
constructor() {
|
||||||
|
super()
|
||||||
|
this.buffer = new Int16Array(CHUNK_SAMPLES)
|
||||||
|
this.offset = 0
|
||||||
|
}
|
||||||
|
|
||||||
|
flush() {
|
||||||
|
if (this.offset === 0) return
|
||||||
|
const out = this.buffer.slice(0, this.offset)
|
||||||
|
this.port.postMessage(out, [out.buffer])
|
||||||
|
this.buffer = new Int16Array(CHUNK_SAMPLES)
|
||||||
|
this.offset = 0
|
||||||
|
}
|
||||||
|
|
||||||
|
process(inputs) {
|
||||||
|
const channel = inputs[0] && inputs[0][0]
|
||||||
|
if (!channel) return true
|
||||||
|
|
||||||
|
for (let i = 0; i < channel.length; i++) {
|
||||||
|
const clamped = Math.max(-1, Math.min(1, channel[i]))
|
||||||
|
this.buffer[this.offset++] = clamped * 32767
|
||||||
|
if (this.offset === CHUNK_SAMPLES) this.flush()
|
||||||
|
}
|
||||||
|
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
registerProcessor('pcm-collector', PcmCollector)
|
||||||
@@ -0,0 +1,98 @@
|
|||||||
|
#!/usr/bin/env node
|
||||||
|
/**
|
||||||
|
* 화자 분리 모델 내려받기.
|
||||||
|
*
|
||||||
|
* 두 모델 모두 CPU에서 도는 ONNX 파일이며 GPU나 파이썬 런타임이 필요 없다.
|
||||||
|
* 라이선스 문제로 저장소에 포함하지 않고 최초 1회 내려받는다.
|
||||||
|
*
|
||||||
|
* npm run setup:diarization
|
||||||
|
*/
|
||||||
|
import { createWriteStream } from 'node:fs'
|
||||||
|
import { mkdir, rename, rm, stat } from 'node:fs/promises'
|
||||||
|
import { pipeline } from 'node:stream/promises'
|
||||||
|
import path from 'node:path'
|
||||||
|
import { spawn } from 'node:child_process'
|
||||||
|
|
||||||
|
const DEST = path.join(process.cwd(), 'models', 'diarization')
|
||||||
|
|
||||||
|
const MODELS = [
|
||||||
|
{
|
||||||
|
name: 'segmentation.onnx',
|
||||||
|
// pyannote/segmentation-3.0 (MIT) — 발화 구간 분할
|
||||||
|
url: 'https://github.com/k2-fsa/sherpa-onnx/releases/download/speaker-segmentation-models/sherpa-onnx-pyannote-segmentation-3-0.tar.bz2',
|
||||||
|
archiveEntry: 'sherpa-onnx-pyannote-segmentation-3-0/model.onnx',
|
||||||
|
approxMB: 6,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'embedding.onnx',
|
||||||
|
// 3D-Speaker CAM++ — 화자 임베딩
|
||||||
|
url: 'https://github.com/k2-fsa/sherpa-onnx/releases/download/speaker-recongition-models/3dspeaker_speech_campplus_sv_zh-cn_16k-common.onnx',
|
||||||
|
approxMB: 28,
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
async function exists(p) {
|
||||||
|
try {
|
||||||
|
await stat(p)
|
||||||
|
return true
|
||||||
|
} catch {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function download(url, dest) {
|
||||||
|
const res = await fetch(url, { redirect: 'follow' })
|
||||||
|
if (!res.ok) throw new Error(`${res.status} ${res.statusText} — ${url}`)
|
||||||
|
await pipeline(res.body, createWriteStream(dest))
|
||||||
|
}
|
||||||
|
|
||||||
|
function run(cmd, args, cwd) {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const child = spawn(cmd, args, { cwd, stdio: 'inherit' })
|
||||||
|
child.on('error', reject)
|
||||||
|
child.on('exit', (code) =>
|
||||||
|
code === 0 ? resolve() : reject(new Error(`${cmd} 실패 (exit ${code})`)),
|
||||||
|
)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
async function main() {
|
||||||
|
await mkdir(DEST, { recursive: true })
|
||||||
|
|
||||||
|
for (const model of MODELS) {
|
||||||
|
const target = path.join(DEST, model.name)
|
||||||
|
|
||||||
|
if (await exists(target)) {
|
||||||
|
console.log(`✓ ${model.name} — 이미 있음`)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(`↓ ${model.name} (약 ${model.approxMB}MB) 내려받는 중...`)
|
||||||
|
|
||||||
|
if (!model.archiveEntry) {
|
||||||
|
await download(model.url, target)
|
||||||
|
console.log(`✓ ${model.name}`)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
const archive = path.join(DEST, 'tmp.tar.bz2')
|
||||||
|
await download(model.url, archive)
|
||||||
|
await run('tar', ['xjf', archive], DEST)
|
||||||
|
await rename(path.join(DEST, model.archiveEntry), target)
|
||||||
|
await rm(archive)
|
||||||
|
await rm(path.join(DEST, path.dirname(model.archiveEntry)), {
|
||||||
|
recursive: true,
|
||||||
|
force: true,
|
||||||
|
})
|
||||||
|
console.log(`✓ ${model.name}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(`\n완료. 모델 위치: ${DEST}`)
|
||||||
|
console.log('이제 녹음 화면에서 "대면 회의 화자 분리"를 쓸 수 있습니다.')
|
||||||
|
}
|
||||||
|
|
||||||
|
main().catch((err) => {
|
||||||
|
console.error(`\n✗ 실패: ${err.message}`)
|
||||||
|
console.error('네트워크를 확인하고 다시 실행해주세요.')
|
||||||
|
process.exit(1)
|
||||||
|
})
|
||||||
@@ -0,0 +1,62 @@
|
|||||||
|
import { describe, it, expect } from 'vitest'
|
||||||
|
import {
|
||||||
|
LOCAL_SPEAKER,
|
||||||
|
REMOTE_SPEAKER,
|
||||||
|
diarizedSpeakerId,
|
||||||
|
listAttendees,
|
||||||
|
resolveSpeakerName,
|
||||||
|
} from '@/lib/speakers'
|
||||||
|
|
||||||
|
describe('resolveSpeakerName', () => {
|
||||||
|
it('기본 화자에 한국어 표기를 붙인다', () => {
|
||||||
|
expect(resolveSpeakerName(LOCAL_SPEAKER)).toBe('나')
|
||||||
|
expect(resolveSpeakerName(REMOTE_SPEAKER)).toBe('상대')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('사용자가 지정한 이름이 우선한다', () => {
|
||||||
|
const names = { [REMOTE_SPEAKER]: '김지훈' }
|
||||||
|
expect(resolveSpeakerName(REMOTE_SPEAKER, names)).toBe('김지훈')
|
||||||
|
expect(resolveSpeakerName(LOCAL_SPEAKER, names)).toBe('나')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('공백뿐인 이름은 무시한다', () => {
|
||||||
|
expect(resolveSpeakerName(LOCAL_SPEAKER, { [LOCAL_SPEAKER]: ' ' })).toBe('나')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('diarization 화자 번호를 한국어 표기로 바꾼다', () => {
|
||||||
|
expect(resolveSpeakerName('spk_1')).toBe('화자 1')
|
||||||
|
expect(resolveSpeakerName('spk_3')).toBe('화자 3')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('diarization 화자에도 지정 이름이 우선한다', () => {
|
||||||
|
expect(resolveSpeakerName('spk_2', { spk_2: '박지연' })).toBe('박지연')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('그 밖의 식별자는 그대로 돌려준다', () => {
|
||||||
|
expect(resolveSpeakerName('unknown')).toBe('unknown')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('listAttendees', () => {
|
||||||
|
it('중복을 제거하고 순서를 유지한다', () => {
|
||||||
|
expect(
|
||||||
|
listAttendees([REMOTE_SPEAKER, LOCAL_SPEAKER, REMOTE_SPEAKER]),
|
||||||
|
).toEqual(['상대', '나'])
|
||||||
|
})
|
||||||
|
|
||||||
|
it('지정된 이름을 반영한다', () => {
|
||||||
|
expect(
|
||||||
|
listAttendees([LOCAL_SPEAKER, REMOTE_SPEAKER], {
|
||||||
|
[LOCAL_SPEAKER]: '배철승',
|
||||||
|
[REMOTE_SPEAKER]: '김지훈',
|
||||||
|
}),
|
||||||
|
).toEqual(['배철승', '김지훈'])
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('diarizedSpeakerId', () => {
|
||||||
|
it('0-based 번호를 1-based 식별자로 바꾼다', () => {
|
||||||
|
expect(diarizedSpeakerId(0)).toBe('spk_1')
|
||||||
|
expect(diarizedSpeakerId(3)).toBe('spk_4')
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -2,6 +2,8 @@ import { describe, it, expect } from 'vitest'
|
|||||||
import {
|
import {
|
||||||
formatTranscriptChunks,
|
formatTranscriptChunks,
|
||||||
mergeAdjacentChunks,
|
mergeAdjacentChunks,
|
||||||
|
mergeSpeakerChunks,
|
||||||
|
assignSpeakers,
|
||||||
type TranscriptChunk,
|
type TranscriptChunk,
|
||||||
} from '@/lib/transcript-formatter'
|
} from '@/lib/transcript-formatter'
|
||||||
|
|
||||||
@@ -55,3 +57,141 @@ describe('mergeAdjacentChunks', () => {
|
|||||||
expect(result).toEqual([])
|
expect(result).toEqual([])
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
describe('화자 표기', () => {
|
||||||
|
it('speaker가 있으면 이름을 붙여 출력한다', () => {
|
||||||
|
const out = formatTranscriptChunks([
|
||||||
|
{ text: '안녕하세요', startTime: 0, endTime: 2, isFinal: true, speaker: 'local' },
|
||||||
|
{ text: '네 반갑습니다', startTime: 3, endTime: 5, isFinal: true, speaker: 'remote' },
|
||||||
|
])
|
||||||
|
expect(out).toBe('[00:00] 나: 안녕하세요\n[00:03] 상대: 네 반갑습니다')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('지정된 화자 이름을 사용한다', () => {
|
||||||
|
const out = formatTranscriptChunks(
|
||||||
|
[{ text: '안녕하세요', startTime: 0, endTime: 2, isFinal: true, speaker: 'remote' }],
|
||||||
|
{ remote: '김지훈' },
|
||||||
|
)
|
||||||
|
expect(out).toBe('[00:00] 김지훈: 안녕하세요')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('speaker가 없으면 기존 형식을 유지한다', () => {
|
||||||
|
const out = formatTranscriptChunks([
|
||||||
|
{ text: '안녕하세요', startTime: 0, endTime: 2, isFinal: true },
|
||||||
|
])
|
||||||
|
expect(out).toBe('[00:00] 안녕하세요')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('mergeSpeakerChunks', () => {
|
||||||
|
const local = [
|
||||||
|
{ text: '먼저 말함', startTime: 1, endTime: 2, isFinal: true, speaker: 'local' },
|
||||||
|
{ text: '나중에 말함', startTime: 9, endTime: 10, isFinal: true, speaker: 'local' },
|
||||||
|
]
|
||||||
|
const remote = [
|
||||||
|
{ text: '중간에 답함', startTime: 4, endTime: 6, isFinal: true, speaker: 'remote' },
|
||||||
|
]
|
||||||
|
|
||||||
|
it('여러 트랙을 시간순으로 합친다', () => {
|
||||||
|
expect(mergeSpeakerChunks(local, remote).map((c) => c.text)).toEqual([
|
||||||
|
'먼저 말함',
|
||||||
|
'중간에 답함',
|
||||||
|
'나중에 말함',
|
||||||
|
])
|
||||||
|
})
|
||||||
|
|
||||||
|
it('같은 시각이면 넘긴 순서를 유지한다', () => {
|
||||||
|
const a = [{ text: 'A', startTime: 5, endTime: 6, isFinal: true, speaker: 'local' }]
|
||||||
|
const b = [{ text: 'B', startTime: 5, endTime: 6, isFinal: true, speaker: 'remote' }]
|
||||||
|
expect(mergeSpeakerChunks(a, b).map((c) => c.text)).toEqual(['A', 'B'])
|
||||||
|
})
|
||||||
|
|
||||||
|
it('빈 트랙을 섞어도 동작한다', () => {
|
||||||
|
expect(mergeSpeakerChunks([], remote, [])).toEqual(remote)
|
||||||
|
expect(mergeSpeakerChunks()).toEqual([])
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('mergeAdjacentChunks — 화자 경계', () => {
|
||||||
|
it('화자가 다르면 간격이 좁아도 합치지 않는다', () => {
|
||||||
|
const merged = mergeAdjacentChunks(
|
||||||
|
[
|
||||||
|
{ text: '그건', startTime: 0, endTime: 1, isFinal: true, speaker: 'local' },
|
||||||
|
{ text: '어렵습니다', startTime: 1, endTime: 2, isFinal: true, speaker: 'remote' },
|
||||||
|
],
|
||||||
|
5,
|
||||||
|
)
|
||||||
|
expect(merged).toHaveLength(2)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('같은 화자면 기존대로 합친다', () => {
|
||||||
|
const merged = mergeAdjacentChunks(
|
||||||
|
[
|
||||||
|
{ text: '그건', startTime: 0, endTime: 1, isFinal: true, speaker: 'local' },
|
||||||
|
{ text: '가능합니다', startTime: 1, endTime: 2, isFinal: true, speaker: 'local' },
|
||||||
|
],
|
||||||
|
5,
|
||||||
|
)
|
||||||
|
expect(merged).toHaveLength(1)
|
||||||
|
expect(merged[0].text).toBe('그건 가능합니다')
|
||||||
|
expect(merged[0].speaker).toBe('local')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('assignSpeakers', () => {
|
||||||
|
const id = (n: number) => `spk_${n + 1}`
|
||||||
|
|
||||||
|
const chunks: TranscriptChunk[] = [
|
||||||
|
{ text: '안녕하세요', startTime: 0, endTime: 3, isFinal: true },
|
||||||
|
{ text: '네 반갑습니다', startTime: 5, endTime: 8, isFinal: true },
|
||||||
|
{ text: '그럼 시작하죠', startTime: 10, endTime: 12, isFinal: true },
|
||||||
|
]
|
||||||
|
|
||||||
|
it('시간이 가장 많이 겹치는 화자를 붙인다', () => {
|
||||||
|
const out = assignSpeakers(
|
||||||
|
chunks,
|
||||||
|
[
|
||||||
|
{ start: 0, end: 4, speaker: 0 },
|
||||||
|
{ start: 4.5, end: 9, speaker: 1 },
|
||||||
|
{ start: 9.5, end: 13, speaker: 0 },
|
||||||
|
],
|
||||||
|
id,
|
||||||
|
)
|
||||||
|
expect(out.map((c) => c.speaker)).toEqual(['spk_1', 'spk_2', 'spk_1'])
|
||||||
|
})
|
||||||
|
|
||||||
|
it('겹치는 구간이 없으면 화자를 비워 둔다', () => {
|
||||||
|
const out = assignSpeakers(
|
||||||
|
chunks,
|
||||||
|
[{ start: 100, end: 110, speaker: 0 }],
|
||||||
|
id,
|
||||||
|
)
|
||||||
|
expect(out.every((c) => c.speaker === undefined)).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('부분적으로만 겹치면 더 많이 겹치는 쪽을 고른다', () => {
|
||||||
|
const out = assignSpeakers(
|
||||||
|
[{ text: '경계에 걸침', startTime: 0, endTime: 10, isFinal: true }],
|
||||||
|
[
|
||||||
|
{ start: 0, end: 3, speaker: 0 },
|
||||||
|
{ start: 3, end: 10, speaker: 1 },
|
||||||
|
],
|
||||||
|
id,
|
||||||
|
)
|
||||||
|
expect(out[0].speaker).toBe('spk_2')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('segments가 비면 원본을 그대로 돌려준다', () => {
|
||||||
|
const out = assignSpeakers(chunks, [], id)
|
||||||
|
expect(out).toEqual(chunks)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('기존 화자 라벨은 새 결과로 덮어쓴다', () => {
|
||||||
|
const out = assignSpeakers(
|
||||||
|
[{ text: 'x', startTime: 0, endTime: 2, isFinal: true, speaker: 'spk_9' }],
|
||||||
|
[{ start: 0, end: 2, speaker: 0 }],
|
||||||
|
id,
|
||||||
|
)
|
||||||
|
expect(out[0].speaker).toBe('spk_1')
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
import { NextRequest } from 'next/server'
|
||||||
|
import { appendAudio } from '@/lib/audio-session'
|
||||||
|
|
||||||
|
export const dynamic = 'force-dynamic'
|
||||||
|
|
||||||
|
/** 녹음 중 몇 초짜리 PCM 조각을 이어 붙인다. 본문은 16kHz 모노 Int16 raw PCM. */
|
||||||
|
export async function POST(request: NextRequest) {
|
||||||
|
const sessionId = request.nextUrl.searchParams.get('session')?.trim()
|
||||||
|
if (!sessionId) {
|
||||||
|
return Response.json({ error: 'session 파라미터가 필요합니다.' }, { status: 400 })
|
||||||
|
}
|
||||||
|
|
||||||
|
const buffer = await request.arrayBuffer()
|
||||||
|
if (buffer.byteLength === 0) {
|
||||||
|
return Response.json({ error: '오디오가 비어 있습니다.' }, { status: 400 })
|
||||||
|
}
|
||||||
|
if (buffer.byteLength % 2 !== 0) {
|
||||||
|
return Response.json(
|
||||||
|
{ error: 'Int16 PCM이 아닙니다 (홀수 바이트).' },
|
||||||
|
{ status: 400 },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = appendAudio(sessionId, new Int16Array(buffer))
|
||||||
|
if (!result.ok) {
|
||||||
|
return Response.json({ error: result.error }, { status: 413 })
|
||||||
|
}
|
||||||
|
|
||||||
|
return Response.json({ seconds: Math.round(result.seconds) })
|
||||||
|
}
|
||||||
@@ -0,0 +1,55 @@
|
|||||||
|
import { NextRequest } from 'next/server'
|
||||||
|
import { clearSession, collectSamples, sessionSeconds } from '@/lib/audio-session'
|
||||||
|
import { diarize, modelsInstalled } from '@/lib/diarization'
|
||||||
|
|
||||||
|
export const dynamic = 'force-dynamic'
|
||||||
|
|
||||||
|
export async function GET() {
|
||||||
|
return Response.json({ available: modelsInstalled() })
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 누적된 오디오 전체에 화자 분리를 돌린다.
|
||||||
|
*
|
||||||
|
* 창을 잘라 반복 실행하면 실행마다 화자 번호가 달라져 이어 붙일 수 없다.
|
||||||
|
* 전역 클러스터링을 위해 녹음 종료 시 한 번만 돌린다.
|
||||||
|
*/
|
||||||
|
export async function POST(request: NextRequest) {
|
||||||
|
try {
|
||||||
|
const body = await request.json().catch(() => ({}))
|
||||||
|
const sessionId = typeof body.session === 'string' ? body.session.trim() : ''
|
||||||
|
|
||||||
|
if (!sessionId) {
|
||||||
|
return Response.json({ error: 'session이 필요합니다.' }, { status: 400 })
|
||||||
|
}
|
||||||
|
|
||||||
|
const samples = collectSamples(sessionId)
|
||||||
|
if (!samples) {
|
||||||
|
return Response.json({ error: '누적된 오디오가 없습니다.' }, { status: 400 })
|
||||||
|
}
|
||||||
|
|
||||||
|
const numSpeakers =
|
||||||
|
typeof body.numSpeakers === 'number' && body.numSpeakers > 1
|
||||||
|
? Math.floor(body.numSpeakers)
|
||||||
|
: undefined
|
||||||
|
|
||||||
|
const seconds = sessionSeconds(sessionId)
|
||||||
|
const result = await diarize(samples, { numSpeakers })
|
||||||
|
|
||||||
|
if (!result.success) {
|
||||||
|
const status = result.reason === 'models-missing' ? 503 : 500
|
||||||
|
return Response.json({ error: result.error, reason: result.reason }, { status })
|
||||||
|
}
|
||||||
|
|
||||||
|
clearSession(sessionId)
|
||||||
|
|
||||||
|
return Response.json({
|
||||||
|
segments: result.segments,
|
||||||
|
seconds: Math.round(seconds),
|
||||||
|
speakers: new Set(result.segments.map((s) => s.speaker)).size,
|
||||||
|
})
|
||||||
|
} catch (err) {
|
||||||
|
const message = err instanceof Error ? err.message : '알 수 없는 오류'
|
||||||
|
return Response.json({ error: `화자 분리 실패: ${message}` }, { status: 500 })
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,11 +1,26 @@
|
|||||||
'use client'
|
'use client'
|
||||||
|
|
||||||
import { useEffect, useRef } from 'react'
|
import { useEffect, useMemo, useRef, useState } from 'react'
|
||||||
import { useSpeechRecognition } from '@/hooks/useSpeechRecognition'
|
import { useSpeechRecognition } from '@/hooks/useSpeechRecognition'
|
||||||
|
import { useDualStreamCapture } from '@/hooks/useDualStreamCapture'
|
||||||
|
import { useDiarization } from '@/hooks/useDiarization'
|
||||||
import { useLiveSummary } from '@/hooks/useLiveSummary'
|
import { useLiveSummary } from '@/hooks/useLiveSummary'
|
||||||
import { formatTranscriptChunks } from '@/lib/transcript-formatter'
|
import {
|
||||||
|
assignSpeakers,
|
||||||
|
formatTranscriptChunks,
|
||||||
|
mergeSpeakerChunks,
|
||||||
|
type TimeSpan,
|
||||||
|
} from '@/lib/transcript-formatter'
|
||||||
|
import {
|
||||||
|
LOCAL_SPEAKER,
|
||||||
|
REMOTE_SPEAKER,
|
||||||
|
diarizedSpeakerId,
|
||||||
|
resolveSpeakerName,
|
||||||
|
} from '@/lib/speakers'
|
||||||
import type { SummaryDepth, TemplateId } from '@/lib/templates'
|
import type { SummaryDepth, TemplateId } from '@/lib/templates'
|
||||||
|
|
||||||
|
type SeparationMode = 'off' | 'remote' | 'in-person'
|
||||||
|
|
||||||
interface LiveRecorderProps {
|
interface LiveRecorderProps {
|
||||||
onTranscriptReady: (transcript: string) => void
|
onTranscriptReady: (transcript: string) => void
|
||||||
liveSummaryEnabled: boolean
|
liveSummaryEnabled: boolean
|
||||||
@@ -21,16 +36,63 @@ export function LiveRecorder({
|
|||||||
depth,
|
depth,
|
||||||
customPrompt,
|
customPrompt,
|
||||||
}: LiveRecorderProps) {
|
}: LiveRecorderProps) {
|
||||||
|
const [mode, setMode] = useState<SeparationMode>('off')
|
||||||
|
const [attendeeCount, setAttendeeCount] = useState(2)
|
||||||
|
const [pendingStart, setPendingStart] = useState(false)
|
||||||
|
const [speakerNames, setSpeakerNames] = useState<Record<string, string>>({})
|
||||||
|
const [segments, setSegments] = useState<TimeSpan[] | null>(null)
|
||||||
|
const timeOriginRef = useRef(0)
|
||||||
|
|
||||||
|
const capture = useDualStreamCapture()
|
||||||
|
const diarization = useDiarization()
|
||||||
|
|
||||||
|
const speakerSeparation = mode !== 'off'
|
||||||
|
|
||||||
|
// 화자 분리를 켜면 트랙마다 인식기를 붙인다. 트랙이 곧 화자가 되므로
|
||||||
|
// 추론 없이 화자가 갈린다.
|
||||||
|
// 두 트랙을 실제로 확보했을 때만 화자를 라벨링한다.
|
||||||
|
// 마이크만 잡힌 상태에서 라벨을 붙이면 상대 발언이 내 것으로 기록된다.
|
||||||
|
const isSeparating = capture.mode === 'dual-stream'
|
||||||
|
|
||||||
|
const local = useSpeechRecognition({
|
||||||
|
audioTrack: speakerSeparation ? capture.micTrack : null,
|
||||||
|
speaker: isSeparating ? LOCAL_SPEAKER : undefined,
|
||||||
|
timeOrigin: timeOriginRef.current || undefined,
|
||||||
|
})
|
||||||
|
|
||||||
|
const remote = useSpeechRecognition({
|
||||||
|
audioTrack: capture.systemTrack,
|
||||||
|
speaker: REMOTE_SPEAKER,
|
||||||
|
timeOrigin: timeOriginRef.current || undefined,
|
||||||
|
})
|
||||||
|
|
||||||
const {
|
const {
|
||||||
isListening,
|
isListening,
|
||||||
isSupported,
|
isSupported,
|
||||||
chunks,
|
|
||||||
interimText,
|
interimText,
|
||||||
error: recognitionError,
|
error: recognitionError,
|
||||||
startListening,
|
startListening,
|
||||||
stopListening,
|
} = local
|
||||||
resetChunks,
|
|
||||||
} = useSpeechRecognition()
|
const chunks = useMemo(() => {
|
||||||
|
if (isSeparating) return mergeSpeakerChunks(local.chunks, remote.chunks)
|
||||||
|
if (segments) return assignSpeakers(local.chunks, segments, diarizedSpeakerId)
|
||||||
|
return local.chunks
|
||||||
|
}, [isSeparating, segments, local.chunks, remote.chunks])
|
||||||
|
|
||||||
|
// 캡처가 끝나 트랙이 준비되면 그때 인식기를 시작한다.
|
||||||
|
useEffect(() => {
|
||||||
|
if (!pendingStart) return
|
||||||
|
if (capture.mode === 'idle') return
|
||||||
|
|
||||||
|
setPendingStart(false)
|
||||||
|
local.startListening()
|
||||||
|
if (capture.systemTrack) remote.startListening()
|
||||||
|
if (mode === 'in-person' && capture.micTrack) {
|
||||||
|
diarization.start(capture.micTrack)
|
||||||
|
}
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [pendingStart, capture.mode, capture.systemTrack, capture.micTrack])
|
||||||
|
|
||||||
const {
|
const {
|
||||||
summary,
|
summary,
|
||||||
@@ -78,14 +140,48 @@ export function LiveRecorder({
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleStop() {
|
async function handleStart() {
|
||||||
stopListening()
|
timeOriginRef.current = Date.now()
|
||||||
const transcript = formatTranscriptChunks(chunks)
|
setSegments(null)
|
||||||
|
|
||||||
|
if (mode === 'off') {
|
||||||
|
startListening()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
setPendingStart(true)
|
||||||
|
await capture.start({ withSystemAudio: mode === 'remote' })
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleStop() {
|
||||||
|
local.stopListening()
|
||||||
|
remote.stopListening()
|
||||||
|
|
||||||
|
// 대면 모드는 녹음이 끝난 뒤 전체 오디오에 한 번 화자 분리를 돌린다.
|
||||||
|
let finalChunks = chunks
|
||||||
|
if (mode === 'in-person') {
|
||||||
|
const result = await diarization.finish(attendeeCount)
|
||||||
|
if (result.length > 0) {
|
||||||
|
setSegments(result)
|
||||||
|
finalChunks = assignSpeakers(local.chunks, result, diarizedSpeakerId)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
capture.stop()
|
||||||
|
|
||||||
|
const transcript = formatTranscriptChunks(finalChunks, speakerNames)
|
||||||
if (transcript.length > 0) {
|
if (transcript.length > 0) {
|
||||||
onTranscriptReady(transcript)
|
onTranscriptReady(transcript)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function handleReset() {
|
||||||
|
local.resetChunks()
|
||||||
|
remote.resetChunks()
|
||||||
|
diarization.reset()
|
||||||
|
setSegments(null)
|
||||||
|
}
|
||||||
|
|
||||||
const lastUpdatedLabel = lastUpdatedAt
|
const lastUpdatedLabel = lastUpdatedAt
|
||||||
? new Date(lastUpdatedAt).toLocaleTimeString('ko-KR', {
|
? new Date(lastUpdatedAt).toLocaleTimeString('ko-KR', {
|
||||||
hour: '2-digit',
|
hour: '2-digit',
|
||||||
@@ -136,7 +232,7 @@ export function LiveRecorder({
|
|||||||
</button>
|
</button>
|
||||||
) : (
|
) : (
|
||||||
<button
|
<button
|
||||||
onClick={startListening}
|
onClick={handleStart}
|
||||||
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"
|
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>
|
<span className="text-lg">🎤</span>
|
||||||
@@ -146,7 +242,7 @@ export function LiveRecorder({
|
|||||||
|
|
||||||
{chunks.length > 0 && !isListening && (
|
{chunks.length > 0 && !isListening && (
|
||||||
<button
|
<button
|
||||||
onClick={resetChunks}
|
onClick={handleReset}
|
||||||
className="rounded-full border border-neutral-300 px-4 py-2 text-sm text-neutral-600 hover:bg-neutral-50 transition-colors"
|
className="rounded-full border border-neutral-300 px-4 py-2 text-sm text-neutral-600 hover:bg-neutral-50 transition-colors"
|
||||||
>
|
>
|
||||||
초기화
|
초기화
|
||||||
@@ -159,8 +255,149 @@ export function LiveRecorder({
|
|||||||
녹음 중 · {wordCount}단어 · {chunks.length}개 구간
|
녹음 중 · {wordCount}단어 · {chunks.length}개 구간
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{isListening && capture.mode === 'dual-stream' && (
|
||||||
|
<div className="flex items-center gap-1.5 rounded-full bg-purple-50 px-4 py-1.5 text-xs text-purple-700">
|
||||||
|
<span className="h-2 w-2 rounded-full bg-purple-500" />
|
||||||
|
화자 분리 중
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{!isListening && chunks.length === 0 && (
|
||||||
|
<div className="rounded-xl border border-neutral-200 bg-white p-4">
|
||||||
|
<p className="mb-3 text-sm font-medium text-neutral-800">화자 분리</p>
|
||||||
|
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
|
{(
|
||||||
|
[
|
||||||
|
{ id: 'off', label: '사용 안 함' },
|
||||||
|
{ id: 'remote', label: '원격 회의' },
|
||||||
|
{ id: 'in-person', label: '대면 회의' },
|
||||||
|
] as const
|
||||||
|
).map((option) => (
|
||||||
|
<button
|
||||||
|
key={option.id}
|
||||||
|
onClick={() => setMode(option.id)}
|
||||||
|
disabled={option.id === 'in-person' && diarization.available === false}
|
||||||
|
className={`rounded-lg border px-3 py-2 text-sm font-medium transition-all disabled:opacity-40 ${
|
||||||
|
mode === option.id
|
||||||
|
? 'border-purple-400 bg-purple-50 text-purple-700'
|
||||||
|
: 'border-neutral-200 bg-white text-neutral-600 hover:border-neutral-300'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{option.label}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{mode === 'remote' && (
|
||||||
|
<>
|
||||||
|
<p className="mt-3 text-xs text-neutral-500">
|
||||||
|
내 마이크와 상대 목소리를 별도 트랙으로 받아 구분합니다. 추론이 없어
|
||||||
|
정확합니다. 시작 시{' '}
|
||||||
|
<strong>화면 공유 대화상자에서 “탭 오디오 공유”를 반드시 켜주세요.</strong>
|
||||||
|
</p>
|
||||||
|
<p className="mt-1.5 text-xs text-amber-700">
|
||||||
|
노트북 한 대를 두고 마주 앉은 대면 회의에서는 동작하지 않습니다.
|
||||||
|
그 경우 “대면 회의”를 선택하세요.
|
||||||
|
</p>
|
||||||
|
<div className="mt-4 grid gap-3 sm:grid-cols-2">
|
||||||
|
{(
|
||||||
|
[
|
||||||
|
{ id: LOCAL_SPEAKER, label: '내 이름', placeholder: '나' },
|
||||||
|
{ id: REMOTE_SPEAKER, label: '상대 이름', placeholder: '상대' },
|
||||||
|
] as const
|
||||||
|
).map((field) => (
|
||||||
|
<label key={field.id} className="block">
|
||||||
|
<span className="mb-1 block text-xs text-neutral-500">
|
||||||
|
{field.label}
|
||||||
|
</span>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={speakerNames[field.id] ?? ''}
|
||||||
|
onChange={(e) =>
|
||||||
|
setSpeakerNames((prev) => ({
|
||||||
|
...prev,
|
||||||
|
[field.id]: e.target.value,
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
placeholder={field.placeholder}
|
||||||
|
className="w-full rounded-lg border border-neutral-300 px-3 py-2 text-sm focus:border-purple-400 focus:outline-none"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{mode === 'in-person' && (
|
||||||
|
<>
|
||||||
|
<p className="mt-3 text-xs text-neutral-500">
|
||||||
|
마이크 하나에 섞여 들어온 목소리를 내 PC에서 분석해 화자를 나눕니다.
|
||||||
|
외부로 오디오를 보내지 않습니다.{' '}
|
||||||
|
<strong>녹음이 끝난 뒤 한 번에 분석</strong>하므로 실시간 화자 표시는 없습니다.
|
||||||
|
</p>
|
||||||
|
<label className="mt-4 block max-w-40">
|
||||||
|
<span className="mb-1 block text-xs text-neutral-500">참석자 수</span>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
min={2}
|
||||||
|
max={8}
|
||||||
|
value={attendeeCount}
|
||||||
|
onChange={(e) =>
|
||||||
|
setAttendeeCount(Math.max(2, Math.min(8, Number(e.target.value) || 2)))
|
||||||
|
}
|
||||||
|
className="w-full rounded-lg border border-neutral-300 px-3 py-2 text-sm focus:border-purple-400 focus:outline-none"
|
||||||
|
/>
|
||||||
|
<span className="mt-1 block text-xs text-neutral-500">
|
||||||
|
정확한 수를 넣을수록 결과가 좋아집니다. 자동 추정은 화자 수를 잘못 세는
|
||||||
|
경우가 많습니다.
|
||||||
|
</span>
|
||||||
|
</label>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{diarization.available === false && (
|
||||||
|
<p className="mt-3 text-xs text-amber-700">
|
||||||
|
대면 회의 화자 분리를 쓰려면 모델이 필요합니다 —{' '}
|
||||||
|
<code className="rounded bg-amber-50 px-1">npm run setup:diarization</code>{' '}
|
||||||
|
을 실행한 뒤 서버를 다시 시작하세요.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{diarization.status.state === 'analyzing' && (
|
||||||
|
<div className="rounded-xl border border-purple-200 bg-purple-50 p-4 text-sm text-purple-700">
|
||||||
|
화자를 분석하는 중입니다... 회의가 길수록 시간이 걸립니다.
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{diarization.status.state === 'done' && (
|
||||||
|
<div className="rounded-xl border border-green-200 bg-green-50 p-4 text-sm text-green-700">
|
||||||
|
화자 {diarization.status.speakers}명을 구분했습니다.
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{diarization.status.state === 'error' && (
|
||||||
|
<div className="rounded-xl border border-red-200 bg-red-50 p-4 text-sm text-red-700">
|
||||||
|
<strong>화자 분리 오류:</strong> {diarization.status.message}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{capture.error && (
|
||||||
|
<div className="rounded-xl border border-red-200 bg-red-50 p-4 text-sm text-red-700">
|
||||||
|
<strong>오디오 캡처 오류:</strong> {capture.error}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{capture.warning && (
|
||||||
|
<div className="rounded-xl border border-amber-200 bg-amber-50 p-4 text-sm text-amber-800">
|
||||||
|
⚠️ {capture.warning}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{recognitionError && (
|
{recognitionError && (
|
||||||
<div className="rounded-xl border border-red-200 bg-red-50 p-4 text-sm text-red-700">
|
<div className="rounded-xl border border-red-200 bg-red-50 p-4 text-sm text-red-700">
|
||||||
<strong>음성 인식 오류:</strong> {recognitionError}
|
<strong>음성 인식 오류:</strong> {recognitionError}
|
||||||
@@ -204,7 +441,20 @@ export function LiveRecorder({
|
|||||||
{hasTranscript ? (
|
{hasTranscript ? (
|
||||||
<div className="space-y-1 text-sm font-mono leading-relaxed text-neutral-700">
|
<div className="space-y-1 text-sm font-mono leading-relaxed text-neutral-700">
|
||||||
{chunks.map((chunk, i) => (
|
{chunks.map((chunk, i) => (
|
||||||
<p key={i}>{chunk.text}</p>
|
<p key={i}>
|
||||||
|
{chunk.speaker && (
|
||||||
|
<span
|
||||||
|
className={`mr-1.5 font-semibold ${
|
||||||
|
chunk.speaker === LOCAL_SPEAKER
|
||||||
|
? 'text-purple-600'
|
||||||
|
: 'text-teal-600'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{resolveSpeakerName(chunk.speaker, speakerNames)}:
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{chunk.text}
|
||||||
|
</p>
|
||||||
))}
|
))}
|
||||||
{interimText && (
|
{interimText && (
|
||||||
<p className="text-neutral-400 italic">
|
<p className="text-neutral-400 italic">
|
||||||
|
|||||||
@@ -0,0 +1,169 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import { useCallback, useEffect, useRef, useState } from 'react'
|
||||||
|
import type { TimeSpan } from '@/lib/transcript-formatter'
|
||||||
|
|
||||||
|
export type DiarizationStatus =
|
||||||
|
| { state: 'idle' }
|
||||||
|
| { state: 'unavailable'; message: string }
|
||||||
|
| { state: 'recording'; seconds: number }
|
||||||
|
| { state: 'analyzing' }
|
||||||
|
| { state: 'done'; speakers: number; segments: TimeSpan[] }
|
||||||
|
| { state: 'error'; message: string }
|
||||||
|
|
||||||
|
interface UseDiarizationResult {
|
||||||
|
/** 모델 설치 여부. null이면 확인 중. */
|
||||||
|
available: boolean | null
|
||||||
|
status: DiarizationStatus
|
||||||
|
/** 오디오 수집 시작. 마이크 트랙을 넘긴다. */
|
||||||
|
start: (track: MediaStreamTrack) => Promise<void>
|
||||||
|
/** 수집을 멈추고 전체 오디오에 화자 분리를 돌린다. */
|
||||||
|
finish: (numSpeakers?: number) => Promise<TimeSpan[]>
|
||||||
|
reset: () => void
|
||||||
|
}
|
||||||
|
|
||||||
|
function newSessionId(): string {
|
||||||
|
return `s_${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 8)}`
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 대면 회의용 화자 분리.
|
||||||
|
*
|
||||||
|
* 녹음 중에는 4초짜리 PCM 조각을 서버에 쌓아두기만 하고, 녹음이 끝나면
|
||||||
|
* 전체 오디오에 한 번 분리를 돌린다. 창을 잘라 반복 실행하면 실행마다
|
||||||
|
* 화자 번호가 달라져 이어 붙일 수 없기 때문이다.
|
||||||
|
*/
|
||||||
|
export function useDiarization(): UseDiarizationResult {
|
||||||
|
const [available, setAvailable] = useState<boolean | null>(null)
|
||||||
|
const [status, setStatus] = useState<DiarizationStatus>({ state: 'idle' })
|
||||||
|
|
||||||
|
const sessionRef = useRef<string>('')
|
||||||
|
const contextRef = useRef<AudioContext | null>(null)
|
||||||
|
const nodeRef = useRef<AudioWorkletNode | null>(null)
|
||||||
|
const secondsRef = useRef(0)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let cancelled = false
|
||||||
|
fetch('/api/diarize')
|
||||||
|
.then((r) => r.json())
|
||||||
|
.then((d) => {
|
||||||
|
if (!cancelled) setAvailable(!!d.available)
|
||||||
|
})
|
||||||
|
.catch(() => {
|
||||||
|
if (!cancelled) setAvailable(false)
|
||||||
|
})
|
||||||
|
return () => {
|
||||||
|
cancelled = true
|
||||||
|
}
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const teardown = useCallback(() => {
|
||||||
|
nodeRef.current?.port.close()
|
||||||
|
nodeRef.current?.disconnect()
|
||||||
|
nodeRef.current = null
|
||||||
|
contextRef.current?.close().catch(() => {})
|
||||||
|
contextRef.current = null
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const start = useCallback(
|
||||||
|
async (track: MediaStreamTrack) => {
|
||||||
|
sessionRef.current = newSessionId()
|
||||||
|
secondsRef.current = 0
|
||||||
|
|
||||||
|
try {
|
||||||
|
// 16kHz로 열면 브라우저가 리샘플링해준다 — 모델이 받는 샘플레이트.
|
||||||
|
const context = new AudioContext({ sampleRate: 16_000 })
|
||||||
|
contextRef.current = context
|
||||||
|
|
||||||
|
await context.audioWorklet.addModule('/pcm-worklet.js')
|
||||||
|
|
||||||
|
const source = context.createMediaStreamSource(new MediaStream([track]))
|
||||||
|
const node = new AudioWorkletNode(context, 'pcm-collector')
|
||||||
|
nodeRef.current = node
|
||||||
|
|
||||||
|
node.port.onmessage = (event) => {
|
||||||
|
const pcm = event.data as Int16Array
|
||||||
|
secondsRef.current += pcm.length / 16_000
|
||||||
|
setStatus({
|
||||||
|
state: 'recording',
|
||||||
|
seconds: Math.round(secondsRef.current),
|
||||||
|
})
|
||||||
|
|
||||||
|
// 실패해도 녹음을 막지 않는다. 그 구간만 화자 분리에서 빠진다.
|
||||||
|
fetch(`/api/diarize/chunk?session=${sessionRef.current}`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/octet-stream' },
|
||||||
|
// 워크릿이 매번 새 버퍼를 transfer 하므로 buffer 전체가 곧 이 조각이다.
|
||||||
|
body: pcm.buffer as ArrayBuffer,
|
||||||
|
}).catch(() => {})
|
||||||
|
}
|
||||||
|
|
||||||
|
source.connect(node)
|
||||||
|
setStatus({ state: 'recording', seconds: 0 })
|
||||||
|
} catch (err) {
|
||||||
|
teardown()
|
||||||
|
setStatus({
|
||||||
|
state: 'error',
|
||||||
|
message:
|
||||||
|
err instanceof Error
|
||||||
|
? `오디오 수집 실패: ${err.message}`
|
||||||
|
: '오디오 수집에 실패했습니다.',
|
||||||
|
})
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[teardown],
|
||||||
|
)
|
||||||
|
|
||||||
|
const finish = useCallback(
|
||||||
|
async (numSpeakers?: number): Promise<TimeSpan[]> => {
|
||||||
|
teardown()
|
||||||
|
|
||||||
|
if (!sessionRef.current || secondsRef.current < 1) {
|
||||||
|
setStatus({ state: 'idle' })
|
||||||
|
return []
|
||||||
|
}
|
||||||
|
|
||||||
|
setStatus({ state: 'analyzing' })
|
||||||
|
|
||||||
|
try {
|
||||||
|
const res = await fetch('/api/diarize', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ session: sessionRef.current, numSpeakers }),
|
||||||
|
})
|
||||||
|
const data = await res.json()
|
||||||
|
|
||||||
|
if (!res.ok) {
|
||||||
|
setStatus({
|
||||||
|
state: 'error',
|
||||||
|
message: data.error ?? '화자 분리에 실패했습니다.',
|
||||||
|
})
|
||||||
|
return []
|
||||||
|
}
|
||||||
|
|
||||||
|
const segments: TimeSpan[] = data.segments ?? []
|
||||||
|
setStatus({ state: 'done', speakers: data.speakers ?? 0, segments })
|
||||||
|
return segments
|
||||||
|
} catch (err) {
|
||||||
|
setStatus({
|
||||||
|
state: 'error',
|
||||||
|
message:
|
||||||
|
err instanceof Error ? err.message : '화자 분리 요청에 실패했습니다.',
|
||||||
|
})
|
||||||
|
return []
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[teardown],
|
||||||
|
)
|
||||||
|
|
||||||
|
const reset = useCallback(() => {
|
||||||
|
teardown()
|
||||||
|
sessionRef.current = ''
|
||||||
|
secondsRef.current = 0
|
||||||
|
setStatus({ state: 'idle' })
|
||||||
|
}, [teardown])
|
||||||
|
|
||||||
|
useEffect(() => teardown, [teardown])
|
||||||
|
|
||||||
|
return { available, status, start, finish, reset }
|
||||||
|
}
|
||||||
@@ -0,0 +1,121 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import { useCallback, useEffect, useRef, useState } from 'react'
|
||||||
|
|
||||||
|
export type CaptureMode = 'idle' | 'mic-only' | 'dual-stream'
|
||||||
|
|
||||||
|
interface DualStreamCapture {
|
||||||
|
mode: CaptureMode
|
||||||
|
/** 내 목소리 트랙 (마이크) */
|
||||||
|
micTrack: MediaStreamTrack | null
|
||||||
|
/** 상대 목소리 트랙 (시스템·탭 오디오). 화면 공유를 거부하거나 오디오를 안 켜면 null */
|
||||||
|
systemTrack: MediaStreamTrack | null
|
||||||
|
error: string | null
|
||||||
|
/** 시스템 오디오 없이 마이크만 잡힌 경우의 안내 */
|
||||||
|
warning: string | null
|
||||||
|
start: (options?: { withSystemAudio?: boolean }) => Promise<void>
|
||||||
|
stop: () => void
|
||||||
|
}
|
||||||
|
|
||||||
|
function describeCaptureError(err: unknown): string {
|
||||||
|
if (!(err instanceof Error)) return '오디오 캡처에 실패했습니다.'
|
||||||
|
|
||||||
|
switch (err.name) {
|
||||||
|
case 'NotAllowedError':
|
||||||
|
return '오디오 캡처 권한이 거부되었습니다. 마이크와 화면 공유를 모두 허용해주세요.'
|
||||||
|
case 'NotFoundError':
|
||||||
|
return '사용할 수 있는 마이크를 찾을 수 없습니다.'
|
||||||
|
case 'NotReadableError':
|
||||||
|
return '다른 앱이 마이크를 사용 중입니다. 해당 앱을 종료하고 다시 시도해주세요.'
|
||||||
|
default:
|
||||||
|
return `오디오 캡처 실패: ${err.message}`
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 화자 분리를 위한 2트랙 캡처.
|
||||||
|
*
|
||||||
|
* 내 마이크와 시스템(탭) 오디오를 **별도 트랙으로** 잡는다. 트랙이 곧 화자이므로
|
||||||
|
* 추론 없이 정확도 100%로 화자가 갈린다. 원격 1:1 회의를 겨냥한 경로다.
|
||||||
|
*
|
||||||
|
* 시스템 오디오는 Chrome/Windows와 Chrome 141+/macOS 14.2+ 에서만 캡처된다.
|
||||||
|
* 실패하면 마이크 단독 모드로 떨어지며, 그 경우 화자 분리는 되지 않는다.
|
||||||
|
*/
|
||||||
|
export function useDualStreamCapture(): DualStreamCapture {
|
||||||
|
const [mode, setMode] = useState<CaptureMode>('idle')
|
||||||
|
const [micTrack, setMicTrack] = useState<MediaStreamTrack | null>(null)
|
||||||
|
const [systemTrack, setSystemTrack] = useState<MediaStreamTrack | null>(null)
|
||||||
|
const [error, setError] = useState<string | null>(null)
|
||||||
|
const [warning, setWarning] = useState<string | null>(null)
|
||||||
|
|
||||||
|
const streamsRef = useRef<MediaStream[]>([])
|
||||||
|
|
||||||
|
const stop = useCallback(() => {
|
||||||
|
for (const stream of streamsRef.current) {
|
||||||
|
for (const track of stream.getTracks()) track.stop()
|
||||||
|
}
|
||||||
|
streamsRef.current = []
|
||||||
|
setMicTrack(null)
|
||||||
|
setSystemTrack(null)
|
||||||
|
setMode('idle')
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const start = useCallback(
|
||||||
|
async (options?: { withSystemAudio?: boolean }) => {
|
||||||
|
const withSystemAudio = options?.withSystemAudio ?? true
|
||||||
|
|
||||||
|
setError(null)
|
||||||
|
setWarning(null)
|
||||||
|
|
||||||
|
let micStream: MediaStream
|
||||||
|
try {
|
||||||
|
micStream = await navigator.mediaDevices.getUserMedia({ audio: true })
|
||||||
|
} catch (err) {
|
||||||
|
setError(describeCaptureError(err))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
streamsRef.current.push(micStream)
|
||||||
|
const mic = micStream.getAudioTracks()[0] ?? null
|
||||||
|
setMicTrack(mic)
|
||||||
|
|
||||||
|
if (!withSystemAudio) {
|
||||||
|
setMode('mic-only')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
// 오디오만 필요해도 video:true를 함께 요청해야 한다 (스펙 요구사항).
|
||||||
|
const displayStream = await navigator.mediaDevices.getDisplayMedia({
|
||||||
|
video: true,
|
||||||
|
audio: true,
|
||||||
|
})
|
||||||
|
streamsRef.current.push(displayStream)
|
||||||
|
|
||||||
|
// 영상은 쓰지 않으므로 즉시 끊어 자원과 프라이버시 노출을 줄인다.
|
||||||
|
for (const track of displayStream.getVideoTracks()) track.stop()
|
||||||
|
|
||||||
|
const system = displayStream.getAudioTracks()[0] ?? null
|
||||||
|
if (!system) {
|
||||||
|
setWarning(
|
||||||
|
'시스템 오디오가 공유되지 않아 화자 분리가 비활성화됩니다. 공유 대화상자에서 "탭 오디오 공유"를 켜주세요.',
|
||||||
|
)
|
||||||
|
setMode('mic-only')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
setSystemTrack(system)
|
||||||
|
setMode('dual-stream')
|
||||||
|
} catch (err) {
|
||||||
|
setWarning(
|
||||||
|
`${describeCaptureError(err)} 마이크만으로 계속 녹음합니다 (화자 분리 없음).`,
|
||||||
|
)
|
||||||
|
setMode('mic-only')
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[],
|
||||||
|
)
|
||||||
|
|
||||||
|
useEffect(() => stop, [stop])
|
||||||
|
|
||||||
|
return { mode, micTrack, systemTrack, error, warning, start, stop }
|
||||||
|
}
|
||||||
@@ -3,6 +3,22 @@
|
|||||||
import { useState, useRef, useCallback, useEffect } from 'react'
|
import { useState, useRef, useCallback, useEffect } from 'react'
|
||||||
import type { TranscriptChunk } from '@/lib/transcript-formatter'
|
import type { TranscriptChunk } from '@/lib/transcript-formatter'
|
||||||
|
|
||||||
|
export interface UseSpeechRecognitionOptions {
|
||||||
|
/**
|
||||||
|
* 전사할 오디오 트랙. 지정하면 기본 마이크 대신 이 트랙을 인식한다.
|
||||||
|
* 트랙마다 인식기를 붙이면 화자가 트랙으로 확정된다.
|
||||||
|
*/
|
||||||
|
audioTrack?: MediaStreamTrack | null
|
||||||
|
/** 이 인식기가 만든 청크에 붙일 화자 식별자. */
|
||||||
|
speaker?: string
|
||||||
|
/**
|
||||||
|
* 타임스탬프 기준 시각(ms). 여러 인식기를 병합하려면 같은 값을 넘겨야
|
||||||
|
* 한 시간축 위에 놓인다. 생략하면 startListening 호출 시각을 쓴다.
|
||||||
|
*/
|
||||||
|
timeOrigin?: number
|
||||||
|
lang?: string
|
||||||
|
}
|
||||||
|
|
||||||
interface SpeechRecognitionHook {
|
interface SpeechRecognitionHook {
|
||||||
isListening: boolean
|
isListening: boolean
|
||||||
isSupported: boolean | null
|
isSupported: boolean | null
|
||||||
@@ -30,7 +46,10 @@ function describeError(code: string): string {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export function useSpeechRecognition(): SpeechRecognitionHook {
|
export function useSpeechRecognition(
|
||||||
|
options: UseSpeechRecognitionOptions = {},
|
||||||
|
): SpeechRecognitionHook {
|
||||||
|
const { audioTrack, speaker, timeOrigin, lang = 'ko-KR' } = options
|
||||||
const [isListening, setIsListening] = useState(false)
|
const [isListening, setIsListening] = useState(false)
|
||||||
const [chunks, setChunks] = useState<TranscriptChunk[]>([])
|
const [chunks, setChunks] = useState<TranscriptChunk[]>([])
|
||||||
const [interimText, setInterimText] = useState('')
|
const [interimText, setInterimText] = useState('')
|
||||||
@@ -40,6 +59,12 @@ export function useSpeechRecognition(): SpeechRecognitionHook {
|
|||||||
const startTimeRef = useRef<number>(0)
|
const startTimeRef = useRef<number>(0)
|
||||||
const networkRetryRef = useRef<number>(0)
|
const networkRetryRef = useRef<number>(0)
|
||||||
|
|
||||||
|
// start()/onend 콜백이 항상 최신 값을 보도록 ref로 들고 있는다.
|
||||||
|
const configRef = useRef({ audioTrack, speaker, timeOrigin, lang })
|
||||||
|
useEffect(() => {
|
||||||
|
configRef.current = { audioTrack, speaker, timeOrigin, lang }
|
||||||
|
}, [audioTrack, speaker, timeOrigin, lang])
|
||||||
|
|
||||||
const MAX_NETWORK_RETRIES = 8
|
const MAX_NETWORK_RETRIES = 8
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -59,12 +84,15 @@ export function useSpeechRecognition(): SpeechRecognitionHook {
|
|||||||
const SpeechRecognitionAPI =
|
const SpeechRecognitionAPI =
|
||||||
window.SpeechRecognition || window.webkitSpeechRecognition
|
window.SpeechRecognition || window.webkitSpeechRecognition
|
||||||
|
|
||||||
|
const { audioTrack: track, speaker: speakerId, timeOrigin: origin, lang: language } =
|
||||||
|
configRef.current
|
||||||
|
|
||||||
const recognition = new SpeechRecognitionAPI()
|
const recognition = new SpeechRecognitionAPI()
|
||||||
recognition.lang = 'ko-KR'
|
recognition.lang = language
|
||||||
recognition.continuous = true
|
recognition.continuous = true
|
||||||
recognition.interimResults = true
|
recognition.interimResults = true
|
||||||
|
|
||||||
startTimeRef.current = Date.now()
|
startTimeRef.current = origin ?? Date.now()
|
||||||
networkRetryRef.current = 0
|
networkRetryRef.current = 0
|
||||||
setError(null)
|
setError(null)
|
||||||
|
|
||||||
@@ -88,6 +116,7 @@ export function useSpeechRecognition(): SpeechRecognitionHook {
|
|||||||
startTime: Math.max(0, now - 2),
|
startTime: Math.max(0, now - 2),
|
||||||
endTime: now,
|
endTime: now,
|
||||||
isFinal: true,
|
isFinal: true,
|
||||||
|
...(speakerId ? { speaker: speakerId } : {}),
|
||||||
},
|
},
|
||||||
])
|
])
|
||||||
setInterimText('')
|
setInterimText('')
|
||||||
@@ -103,8 +132,14 @@ export function useSpeechRecognition(): SpeechRecognitionHook {
|
|||||||
const delay = networkRetryRef.current > 0 ? 1500 : 0
|
const delay = networkRetryRef.current > 0 ? 1500 : 0
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
if (recognitionRef.current !== recognition) return
|
if (recognitionRef.current !== recognition) return
|
||||||
|
// 트랙이 끝났으면(화면 공유 중단 등) 재시작하지 않는다.
|
||||||
|
if (track && track.readyState !== 'live') {
|
||||||
|
setIsListening(false)
|
||||||
|
recognitionRef.current = null
|
||||||
|
return
|
||||||
|
}
|
||||||
try {
|
try {
|
||||||
recognition.start()
|
recognition.start(track ?? undefined)
|
||||||
} catch {
|
} catch {
|
||||||
setIsListening(false)
|
setIsListening(false)
|
||||||
recognitionRef.current = null
|
recognitionRef.current = null
|
||||||
@@ -141,7 +176,7 @@ export function useSpeechRecognition(): SpeechRecognitionHook {
|
|||||||
|
|
||||||
recognitionRef.current = recognition
|
recognitionRef.current = recognition
|
||||||
try {
|
try {
|
||||||
recognition.start()
|
recognition.start(track ?? undefined)
|
||||||
setIsListening(true)
|
setIsListening(true)
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setError(
|
setError(
|
||||||
|
|||||||
@@ -0,0 +1,93 @@
|
|||||||
|
/**
|
||||||
|
* 녹음 중 들어오는 PCM 조각을 세션별로 모아 둔다.
|
||||||
|
*
|
||||||
|
* 화자 분리는 녹음이 끝난 뒤 전체 오디오에 한 번 돌린다. 누적분을 매번
|
||||||
|
* 다시 보내면 1시간 회의에서 100MB가 넘는 요청이 되므로, 녹음 중에는
|
||||||
|
* 몇 초짜리 조각만 올리고 서버가 이어 붙인다.
|
||||||
|
*
|
||||||
|
* 단일 사용자 로컬 실행을 전제로 한 메모리 저장소다. 여러 사용자가 붙는
|
||||||
|
* 환경이라면 디스크나 외부 저장소로 옮겨야 한다.
|
||||||
|
*/
|
||||||
|
|
||||||
|
export const SAMPLE_RATE = 16_000
|
||||||
|
|
||||||
|
/** 세션당 최대 녹음 길이. 초과분은 거부한다. */
|
||||||
|
export const MAX_SESSION_SECONDS = 3 * 60 * 60
|
||||||
|
|
||||||
|
/** 마지막 갱신 이후 이 시간이 지난 세션은 정리한다. */
|
||||||
|
const SESSION_TTL_MS = 6 * 60 * 60 * 1000
|
||||||
|
|
||||||
|
interface Session {
|
||||||
|
chunks: Int16Array[]
|
||||||
|
totalSamples: number
|
||||||
|
updatedAt: number
|
||||||
|
}
|
||||||
|
|
||||||
|
const sessions = new Map<string, Session>()
|
||||||
|
|
||||||
|
function sweep(): void {
|
||||||
|
const cutoff = Date.now() - SESSION_TTL_MS
|
||||||
|
for (const [id, session] of sessions) {
|
||||||
|
if (session.updatedAt < cutoff) sessions.delete(id)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export type AppendResult =
|
||||||
|
| { ok: true; totalSamples: number; seconds: number }
|
||||||
|
| { ok: false; error: string }
|
||||||
|
|
||||||
|
export function appendAudio(sessionId: string, pcm: Int16Array): AppendResult {
|
||||||
|
sweep()
|
||||||
|
|
||||||
|
const session = sessions.get(sessionId) ?? {
|
||||||
|
chunks: [],
|
||||||
|
totalSamples: 0,
|
||||||
|
updatedAt: Date.now(),
|
||||||
|
}
|
||||||
|
|
||||||
|
const nextTotal = session.totalSamples + pcm.length
|
||||||
|
if (nextTotal > MAX_SESSION_SECONDS * SAMPLE_RATE) {
|
||||||
|
return {
|
||||||
|
ok: false,
|
||||||
|
error: `녹음이 최대 길이(${MAX_SESSION_SECONDS / 3600}시간)를 초과했습니다.`,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
session.chunks.push(pcm)
|
||||||
|
session.totalSamples = nextTotal
|
||||||
|
session.updatedAt = Date.now()
|
||||||
|
sessions.set(sessionId, session)
|
||||||
|
|
||||||
|
return {
|
||||||
|
ok: true,
|
||||||
|
totalSamples: nextTotal,
|
||||||
|
seconds: nextTotal / SAMPLE_RATE,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 누적된 PCM을 모델이 받는 [-1, 1] float32로 펼친다. */
|
||||||
|
export function collectSamples(sessionId: string): Float32Array | null {
|
||||||
|
const session = sessions.get(sessionId)
|
||||||
|
if (!session || session.totalSamples === 0) return null
|
||||||
|
|
||||||
|
const out = new Float32Array(session.totalSamples)
|
||||||
|
let offset = 0
|
||||||
|
|
||||||
|
for (const chunk of session.chunks) {
|
||||||
|
for (let i = 0; i < chunk.length; i++) {
|
||||||
|
out[offset + i] = chunk[i] / 32768
|
||||||
|
}
|
||||||
|
offset += chunk.length
|
||||||
|
}
|
||||||
|
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
export function clearSession(sessionId: string): void {
|
||||||
|
sessions.delete(sessionId)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function sessionSeconds(sessionId: string): number {
|
||||||
|
const session = sessions.get(sessionId)
|
||||||
|
return session ? session.totalSamples / SAMPLE_RATE : 0
|
||||||
|
}
|
||||||
@@ -0,0 +1,135 @@
|
|||||||
|
import path from 'node:path'
|
||||||
|
import { existsSync } from 'node:fs'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 로컬 화자 분리 (speaker diarization).
|
||||||
|
*
|
||||||
|
* pyannote-segmentation-3.0 + 3D-Speaker 임베딩을 ONNX Runtime으로 돌린다.
|
||||||
|
* GPU도 파이썬 런타임도 필요 없고, 노트북 CPU에서 실시간의 수십 배 속도로 처리된다.
|
||||||
|
*
|
||||||
|
* 마이크 하나에 여러 사람이 섞여 들어오는 대면 회의용 경로다. 원격 회의처럼
|
||||||
|
* 트랙이 이미 나뉘어 있으면 이 모듈을 쓰지 않는 편이 정확하다.
|
||||||
|
*/
|
||||||
|
|
||||||
|
export interface SpeakerSegment {
|
||||||
|
/** 발화 시작 (초) */
|
||||||
|
start: number
|
||||||
|
/** 발화 종료 (초) */
|
||||||
|
end: number
|
||||||
|
/** 0부터 시작하는 화자 번호 */
|
||||||
|
speaker: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export type DiarizationResult =
|
||||||
|
| { success: true; segments: SpeakerSegment[] }
|
||||||
|
| { success: false; error: string; reason: 'models-missing' | 'failed' }
|
||||||
|
|
||||||
|
export interface DiarizeOptions {
|
||||||
|
/**
|
||||||
|
* 참석자 수. 알면 반드시 넘길 것.
|
||||||
|
*
|
||||||
|
* 자동 추정은 화자 수를 과다·과소 계수하는 것이 주 실패 모드다.
|
||||||
|
* 실측에서 4인 오디오가 자동 모드로는 5명, 힌트를 주면 4명으로 나왔다.
|
||||||
|
*/
|
||||||
|
numSpeakers?: number
|
||||||
|
/** 자동 추정 시 클러스터 분리 임계값. 낮을수록 화자를 많이 잡는다. */
|
||||||
|
threshold?: number
|
||||||
|
}
|
||||||
|
|
||||||
|
const MODEL_DIR = process.env.DIARIZATION_MODEL_DIR ?? 'models/diarization'
|
||||||
|
const SEGMENTATION_MODEL = 'segmentation.onnx'
|
||||||
|
const EMBEDDING_MODEL = 'embedding.onnx'
|
||||||
|
|
||||||
|
export function resolveModelPaths(): {
|
||||||
|
segmentation: string
|
||||||
|
embedding: string
|
||||||
|
} {
|
||||||
|
const root = path.isAbsolute(MODEL_DIR)
|
||||||
|
? MODEL_DIR
|
||||||
|
: path.join(process.cwd(), MODEL_DIR)
|
||||||
|
|
||||||
|
return {
|
||||||
|
segmentation: path.join(root, SEGMENTATION_MODEL),
|
||||||
|
embedding: path.join(root, EMBEDDING_MODEL),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function modelsInstalled(): boolean {
|
||||||
|
const { segmentation, embedding } = resolveModelPaths()
|
||||||
|
return existsSync(segmentation) && existsSync(embedding)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 모델 로딩이 수백 ms 걸리므로 프로세스당 한 번만 만들고 재사용한다.
|
||||||
|
* 클러스터링 설정은 요청마다 바뀔 수 있어 setConfig로 갈아끼운다.
|
||||||
|
*/
|
||||||
|
type Diarizer = {
|
||||||
|
process: (samples: Float32Array) => SpeakerSegment[]
|
||||||
|
setConfig: (config: {
|
||||||
|
clustering: { numClusters?: number; threshold?: number }
|
||||||
|
}) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
let cached: Diarizer | null = null
|
||||||
|
|
||||||
|
async function getDiarizer(): Promise<Diarizer> {
|
||||||
|
if (cached) return cached
|
||||||
|
|
||||||
|
const { segmentation, embedding } = resolveModelPaths()
|
||||||
|
const { OfflineSpeakerDiarization } = await import('sherpa-onnx-node')
|
||||||
|
|
||||||
|
cached = new OfflineSpeakerDiarization({
|
||||||
|
segmentation: {
|
||||||
|
pyannote: { model: segmentation },
|
||||||
|
numThreads: 2,
|
||||||
|
},
|
||||||
|
embedding: { model: embedding, numThreads: 2 },
|
||||||
|
clustering: { threshold: 0.5 },
|
||||||
|
}) as unknown as Diarizer
|
||||||
|
|
||||||
|
return cached
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function diarize(
|
||||||
|
samples: Float32Array,
|
||||||
|
options: DiarizeOptions = {},
|
||||||
|
): Promise<DiarizationResult> {
|
||||||
|
if (samples.length === 0) {
|
||||||
|
return { success: true, segments: [] }
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!modelsInstalled()) {
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
reason: 'models-missing',
|
||||||
|
error:
|
||||||
|
'화자 분리 모델이 설치되지 않았습니다. `npm run setup:diarization`을 실행해주세요.',
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const diarizer = await getDiarizer()
|
||||||
|
|
||||||
|
// 참석자 수를 알면 클러스터 개수를 고정한다. 정확도가 크게 오른다.
|
||||||
|
diarizer.setConfig({
|
||||||
|
clustering:
|
||||||
|
options.numSpeakers && options.numSpeakers > 0
|
||||||
|
? { numClusters: options.numSpeakers }
|
||||||
|
: { threshold: options.threshold ?? 0.5 },
|
||||||
|
})
|
||||||
|
|
||||||
|
const segments = diarizer.process(samples)
|
||||||
|
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
segments: segments.map((s) => ({
|
||||||
|
start: s.start,
|
||||||
|
end: s.end,
|
||||||
|
speaker: s.speaker,
|
||||||
|
})),
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
const message = err instanceof Error ? err.message : '알 수 없는 오류'
|
||||||
|
return { success: false, reason: 'failed', error: `화자 분리 실패: ${message}` }
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,55 @@
|
|||||||
|
/**
|
||||||
|
* 화자 식별자.
|
||||||
|
*
|
||||||
|
* dual-stream 캡처에서는 트랙이 곧 화자이므로 추론이 개입하지 않는다.
|
||||||
|
* 단일 트랙에서 화자를 추정하는 경로가 생기면 'spk_1' 같은 식별자가 추가된다.
|
||||||
|
*/
|
||||||
|
export const LOCAL_SPEAKER = 'local'
|
||||||
|
export const REMOTE_SPEAKER = 'remote'
|
||||||
|
|
||||||
|
export type SpeakerNames = Readonly<Record<string, string>>
|
||||||
|
|
||||||
|
/** diarization이 매긴 화자 번호를 식별자로 바꾼다. 0-based → 1-based. */
|
||||||
|
export function diarizedSpeakerId(index: number): string {
|
||||||
|
return `spk_${index + 1}`
|
||||||
|
}
|
||||||
|
|
||||||
|
const DEFAULT_NAMES: SpeakerNames = {
|
||||||
|
[LOCAL_SPEAKER]: '나',
|
||||||
|
[REMOTE_SPEAKER]: '상대',
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 화자 식별자를 표시용 이름으로 바꾼다. 사용자가 지정한 이름이 우선한다. */
|
||||||
|
export function resolveSpeakerName(
|
||||||
|
speaker: string,
|
||||||
|
names?: SpeakerNames,
|
||||||
|
): string {
|
||||||
|
const custom = names?.[speaker]?.trim()
|
||||||
|
if (custom) return custom
|
||||||
|
|
||||||
|
const preset = DEFAULT_NAMES[speaker]
|
||||||
|
if (preset) return preset
|
||||||
|
|
||||||
|
// spk_1 → "화자 1"
|
||||||
|
const diarized = /^spk_(\d+)$/.exec(speaker)
|
||||||
|
if (diarized) return `화자 ${diarized[1]}`
|
||||||
|
|
||||||
|
return speaker
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 회의록에 저장할 참석자 목록. 지정된 이름이 없으면 기본 표기를 쓴다. */
|
||||||
|
export function listAttendees(
|
||||||
|
speakers: readonly string[],
|
||||||
|
names?: SpeakerNames,
|
||||||
|
): string[] {
|
||||||
|
const seen = new Set<string>()
|
||||||
|
const result: string[] = []
|
||||||
|
|
||||||
|
for (const speaker of speakers) {
|
||||||
|
if (seen.has(speaker)) continue
|
||||||
|
seen.add(speaker)
|
||||||
|
result.push(resolveSpeakerName(speaker, names))
|
||||||
|
}
|
||||||
|
|
||||||
|
return result
|
||||||
|
}
|
||||||
@@ -1,8 +1,12 @@
|
|||||||
|
import { resolveSpeakerName, type SpeakerNames } from './speakers'
|
||||||
|
|
||||||
export interface TranscriptChunk {
|
export interface TranscriptChunk {
|
||||||
text: string
|
text: string
|
||||||
startTime: number
|
startTime: number
|
||||||
endTime: number
|
endTime: number
|
||||||
isFinal: boolean
|
isFinal: boolean
|
||||||
|
/** 화자 식별자. 단일 트랙 녹음에서는 없다. */
|
||||||
|
speaker?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
function formatTimestamp(seconds: number): string {
|
function formatTimestamp(seconds: number): string {
|
||||||
@@ -16,14 +20,42 @@ function formatTimestamp(seconds: number): string {
|
|||||||
return `[${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 {
|
export function formatTranscriptChunks(
|
||||||
|
chunks: readonly TranscriptChunk[],
|
||||||
|
names?: SpeakerNames,
|
||||||
|
): string {
|
||||||
if (chunks.length === 0) return ''
|
if (chunks.length === 0) return ''
|
||||||
|
|
||||||
return chunks
|
return chunks
|
||||||
.map((chunk) => `${formatTimestamp(chunk.startTime)} ${chunk.text}`)
|
.map((chunk) => {
|
||||||
|
const stamp = formatTimestamp(chunk.startTime)
|
||||||
|
if (!chunk.speaker) return `${stamp} ${chunk.text}`
|
||||||
|
return `${stamp} ${resolveSpeakerName(chunk.speaker, names)}: ${chunk.text}`
|
||||||
|
})
|
||||||
.join('\n')
|
.join('\n')
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 여러 트랙에서 나온 청크를 하나의 시간축으로 합친다.
|
||||||
|
*
|
||||||
|
* 각 인식기가 독립적으로 동작하므로 도착 순서가 발화 순서와 다를 수 있다.
|
||||||
|
* 모든 트랙이 같은 timeOrigin을 공유한다는 전제 아래 startTime으로 정렬한다.
|
||||||
|
*/
|
||||||
|
export function mergeSpeakerChunks(
|
||||||
|
...streams: readonly (readonly TranscriptChunk[])[]
|
||||||
|
): TranscriptChunk[] {
|
||||||
|
const merged = streams.flat()
|
||||||
|
|
||||||
|
return merged
|
||||||
|
.map((chunk, index) => ({ chunk, index }))
|
||||||
|
.sort((a, b) => {
|
||||||
|
const diff = a.chunk.startTime - b.chunk.startTime
|
||||||
|
// 같은 시각이면 원래 순서를 유지해 결과가 흔들리지 않게 한다.
|
||||||
|
return diff !== 0 ? diff : a.index - b.index
|
||||||
|
})
|
||||||
|
.map(({ chunk }) => chunk)
|
||||||
|
}
|
||||||
|
|
||||||
export function mergeAdjacentChunks(
|
export function mergeAdjacentChunks(
|
||||||
chunks: readonly TranscriptChunk[],
|
chunks: readonly TranscriptChunk[],
|
||||||
gapThreshold: number,
|
gapThreshold: number,
|
||||||
@@ -36,12 +68,19 @@ export function mergeAdjacentChunks(
|
|||||||
const current = chunks[i]
|
const current = chunks[i]
|
||||||
const last = result[result.length - 1]
|
const last = result[result.length - 1]
|
||||||
|
|
||||||
|
// 화자가 다르면 붙이지 않는다 — 서로 다른 사람의 발화가 한 덩어리가 되면 안 된다.
|
||||||
|
if (current.speaker !== last.speaker) {
|
||||||
|
result.push({ ...current })
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
if (current.startTime - last.endTime <= gapThreshold) {
|
if (current.startTime - last.endTime <= gapThreshold) {
|
||||||
result[result.length - 1] = {
|
result[result.length - 1] = {
|
||||||
text: `${last.text} ${current.text}`,
|
text: `${last.text} ${current.text}`,
|
||||||
startTime: last.startTime,
|
startTime: last.startTime,
|
||||||
endTime: current.endTime,
|
endTime: current.endTime,
|
||||||
isFinal: current.isFinal,
|
isFinal: current.isFinal,
|
||||||
|
...(last.speaker ? { speaker: last.speaker } : {}),
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
result.push({ ...current })
|
result.push({ ...current })
|
||||||
@@ -50,3 +89,64 @@ export function mergeAdjacentChunks(
|
|||||||
|
|
||||||
return result
|
return result
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface TimeSpan {
|
||||||
|
start: number
|
||||||
|
end: number
|
||||||
|
speaker: number
|
||||||
|
}
|
||||||
|
|
||||||
|
function overlap(
|
||||||
|
aStart: number,
|
||||||
|
aEnd: number,
|
||||||
|
bStart: number,
|
||||||
|
bEnd: number,
|
||||||
|
): number {
|
||||||
|
return Math.max(0, Math.min(aEnd, bEnd) - Math.max(aStart, bStart))
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* diarization 결과를 전사 청크에 붙인다.
|
||||||
|
*
|
||||||
|
* 청크마다 시간이 가장 많이 겹치는 화자 구간을 고른다. 겹치는 구간이 없으면
|
||||||
|
* 화자를 비워 둔다 — 틀린 화자를 붙이는 것보다 없는 편이 낫다.
|
||||||
|
*
|
||||||
|
* Web Speech API가 주는 청크 타임스탬프는 근사값이므로 이 매칭도 근사다.
|
||||||
|
*/
|
||||||
|
export function assignSpeakers(
|
||||||
|
chunks: readonly TranscriptChunk[],
|
||||||
|
segments: readonly TimeSpan[],
|
||||||
|
speakerId: (index: number) => string,
|
||||||
|
): TranscriptChunk[] {
|
||||||
|
if (segments.length === 0) return chunks.map((chunk) => ({ ...chunk }))
|
||||||
|
|
||||||
|
return chunks.map((chunk) => {
|
||||||
|
let best: TimeSpan | null = null
|
||||||
|
let bestOverlap = 0
|
||||||
|
|
||||||
|
for (const segment of segments) {
|
||||||
|
const value = overlap(
|
||||||
|
chunk.startTime,
|
||||||
|
chunk.endTime,
|
||||||
|
segment.start,
|
||||||
|
segment.end,
|
||||||
|
)
|
||||||
|
if (value > bestOverlap) {
|
||||||
|
bestOverlap = value
|
||||||
|
best = segment
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!best) {
|
||||||
|
// 겹치는 구간이 없으면 이전 라벨도 남기지 않는다.
|
||||||
|
return {
|
||||||
|
text: chunk.text,
|
||||||
|
startTime: chunk.startTime,
|
||||||
|
endTime: chunk.endTime,
|
||||||
|
isFinal: chunk.isFinal,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return { ...chunk, speaker: speakerId(best.speaker) }
|
||||||
|
})
|
||||||
|
}
|
||||||
Vendored
+36
@@ -0,0 +1,36 @@
|
|||||||
|
/**
|
||||||
|
* sherpa-onnx-node는 JSDoc만 제공하고 .d.ts를 배포하지 않는다.
|
||||||
|
* 이 프로젝트가 실제로 쓰는 화자 분리 API만 선언한다.
|
||||||
|
*/
|
||||||
|
declare module 'sherpa-onnx-node' {
|
||||||
|
export interface SherpaSpeakerSegment {
|
||||||
|
start: number
|
||||||
|
end: number
|
||||||
|
speaker: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface FastClusteringConfig {
|
||||||
|
numClusters?: number
|
||||||
|
threshold?: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface OfflineSpeakerDiarizationConfig {
|
||||||
|
segmentation?: {
|
||||||
|
pyannote?: { model?: string; windowShiftRatio?: number }
|
||||||
|
numThreads?: number
|
||||||
|
provider?: string
|
||||||
|
debug?: boolean | number
|
||||||
|
}
|
||||||
|
embedding?: { model?: string; numThreads?: number; provider?: string }
|
||||||
|
clustering?: FastClusteringConfig
|
||||||
|
minDurationOn?: number
|
||||||
|
minDurationOff?: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export class OfflineSpeakerDiarization {
|
||||||
|
constructor(config: OfflineSpeakerDiarizationConfig)
|
||||||
|
readonly sampleRate: number
|
||||||
|
process(samples: Float32Array): SherpaSpeakerSegment[]
|
||||||
|
setConfig(config: { clustering: FastClusteringConfig }): void
|
||||||
|
}
|
||||||
|
}
|
||||||
Vendored
+5
-1
@@ -5,7 +5,11 @@ interface SpeechRecognition extends EventTarget {
|
|||||||
onresult: ((event: SpeechRecognitionEvent) => void) | null
|
onresult: ((event: SpeechRecognitionEvent) => void) | null
|
||||||
onend: (() => void) | null
|
onend: (() => void) | null
|
||||||
onerror: ((event: SpeechRecognitionErrorEvent) => void) | null
|
onerror: ((event: SpeechRecognitionErrorEvent) => void) | null
|
||||||
start(): void
|
/**
|
||||||
|
* audioTrack을 넘기면 기본 마이크 대신 그 트랙을 전사한다 (Chrome 한정).
|
||||||
|
* 트랙별로 인식기를 붙이면 화자가 트랙 번호로 확정된다.
|
||||||
|
*/
|
||||||
|
start(audioTrack?: MediaStreamTrack): void
|
||||||
stop(): void
|
stop(): void
|
||||||
abort(): void
|
abort(): void
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in new issue
Block a user