Author SHA1 Message Date
csbaeandClaude Opus 5 4af0a03de5 feat: 대면 회의 화자 분리 (로컬 diarization)
마이크 한 트랙에 여러 사람이 섞여 들어오는 대면 회의에서 화자를 나눈다.
pyannote-segmentation-3.0 + 3D-Speaker CAM++ 를 ONNX Runtime으로 로컬
Next.js 프로세스에서 실행한다. GPU도 파이썬도 필요 없고 오디오가 외부로
나가지 않는다.

실측 (Apple Silicon, 2스레드, 4인 한국어 아닌 샘플 57초):
- 처리 1.5초 → RTF 0.03, 실시간의 33배
- 자동 추정은 화자 5명(과다 계수), 참석자 수를 주면 4명(정확)
  → 참석자 수 입력을 1급 기능으로 노출

구성:
- lib/diarization.ts — sherpa-onnx-node 래퍼, 모델 캐시, 참석자 수 힌트
- lib/audio-session.ts — 녹음 중 PCM 조각을 세션별로 누적 (메모리)
- api/diarize/chunk — 4초짜리 Int16 PCM 조각 업로드
- api/diarize — 누적 오디오 전체 분석, GET은 모델 설치 여부 확인
- public/pcm-worklet.js — 16kHz Int16 PCM 추출 AudioWorklet
- hooks/useDiarization.ts — 수집·분석 상태 관리
- transcript-formatter: assignSpeakers() — 시간 겹침으로 화자 배정.
  겹치는 구간이 없으면 라벨을 비운다 (틀린 라벨보다 없는 편이 낫다)
- LiveRecorder: 사용 안 함 / 원격 회의 / 대면 회의 3모드 + 참석자 수
- scripts/setup-diarization.mjs — 모델 34MB 내려받기 (npm run setup:diarization)

설계 변경: 롤링 재-diarization 대신 녹음 종료 시 1회 전체 분석.
구간을 잘라 반복하면 실행마다 화자 번호가 달라져 이어 붙일 수 없고,
누적분 재분석은 비용이 회의 길이에 비례해 커진다. 대신 실시간 화자 표시는 없다.

모델은 라이선스와 용량 때문에 저장소에 넣지 않고 gitignore 한다.

테스트 176 → 184.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-08 10:47:10 +09:00
csbaeandClaude Opus 5 a8cfab8a4a fix: 두 트랙을 확보했을 때만 화자를 라벨링
대면 회의처럼 시스템 오디오가 없는 상황에서 화자 분리를 켜면,
마이크에 섞여 들어온 상대 발언까지 "나"로 기록되는 문제가 있었다.
라벨이 틀리는 것은 라벨이 없는 것보다 나쁘다.

capture.mode가 'dual-stream'일 때만 speaker를 붙이고, 마이크 단독으로
폴백하면 라벨 없이 기존 형식으로 기록한다.

설정 UI에도 대면 회의에서는 동작하지 않는다는 점을 명시했다.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-07 18:29:58 +09:00
csbaeandClaude Opus 5 d2cb5b0009 feat: dual-stream 캡처로 원격 회의 화자 분리
내 마이크와 시스템(탭) 오디오를 별도 트랙으로 잡고, 트랙마다 인식기를
붙인다. 트랙이 곧 화자이므로 추론도 모델 다운로드도 없이 화자가 갈린다.

Chrome이 SpeechRecognition.start(audioTrack)를 지원하는 덕분에 가능하다.
트랙을 명시적으로 넘기므로 기본 마이크를 두고 경합할 일도 없다.

- speakers.ts 신설 — 화자 식별자와 표시 이름 해석
- TranscriptChunk.speaker 추가, formatTranscriptChunks가 "화자: 발화"로 출력
- mergeSpeakerChunks — 트랙별 청크를 하나의 시간축으로 병합.
  여러 인식기가 같은 timeOrigin을 공유해야 순서가 맞는다
- mergeAdjacentChunks가 화자 경계를 넘어 합치지 않도록 수정
- useSpeechRecognition: audioTrack / speaker / timeOrigin / lang 옵션.
  인자 없이 호출하면 기존 동작 그대로다
- useDualStreamCapture 신설 — getUserMedia + getDisplayMedia.
  video는 즉시 끊고 오디오만 쓴다. 시스템 오디오 확보에 실패하면
  마이크 단독으로 폴백하고 화자 분리가 꺼졌음을 안내한다
- LiveRecorder: 화자 분리 토글, 화자 이름 입력, 전사 목록 화자 색상 구분

원격 다자 회의와 대면 회의는 원격 트랙에 diarization이 필요하며 후속 작업이다.

테스트 162 → 176.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-07 18:27:00 +09:00
22 changed files with 1595 additions and 24 deletions

No files matched your search

+3
View File
@@ -49,3 +49,6 @@ next-env.d.ts
.env .env
!.env.example !.env.example
# 화자 분리 모델 (npm run setup:diarization 으로 내려받음)
models/
+35 -1
View File
@@ -24,6 +24,7 @@ docker compose up -d
- [✨ 주요 기능](#-주요-기능) - [✨ 주요 기능](#-주요-기능)
- [🎤 실시간 녹음 & 전사](#-실시간-녹음--전사) - [🎤 실시간 녹음 & 전사](#-실시간-녹음--전사)
- [🗣️ 화자 분리](#️-화자-분리)
- [🤖 AI 회의록 (6 템플릿 × 3 강도)](#-ai-회의록-6-템플릿--3-강도) - [🤖 AI 회의록 (6 템플릿 × 3 강도)](#-ai-회의록-6-템플릿--3-강도)
- [⚡ 실시간 롤링 요약](#-실시간-롤링-요약) - [⚡ 실시간 롤링 요약](#-실시간-롤링-요약)
- [💾 회의록 저장/조회/검색](#-회의록-저장조회검색) - [💾 회의록 저장/조회/검색](#-회의록-저장조회검색)
@@ -101,6 +102,38 @@ 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
```
> 💡 **참석자 수를 정확히 입력하세요.** 자동 추정은 화자 수를 잘못 세는 것이 주 실패
> 모드입니다. 실측에서 4인 오디오가 자동 모드로는 5명, 참석자 수를 알려주면 4명으로
> 정확히 나왔습니다.
대면 모드는 녹음이 끝난 뒤 **전체 오디오에 한 번** 분석을 돌립니다. 구간을 잘라
반복 실행하면 실행마다 화자 번호가 달라져 이어 붙일 수 없기 때문입니다. 따라서
녹음 중 실시간 화자 표시는 없고, 종료 시 원문 전체에 화자가 붙습니다.
---
### ⚡ 실시간 롤링 요약 ### ⚡ 실시간 롤링 요약
녹음 중 **30초마다** Gemini가 그동안의 발화를 분석해 중간 회의록을 자동 갱신합니다. 녹음 중 **30초마다** Gemini가 그동안의 발화를 분석해 중간 회의록을 자동 갱신합니다.
@@ -335,7 +368,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
View File
@@ -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 합니다.
+2
View File
@@ -12,6 +12,8 @@ const securityHeaders = [
const nextConfig: NextConfig = { const nextConfig: NextConfig = {
output: "standalone", output: "standalone",
// sherpa-onnx-node는 네이티브 애드온이라 번들링하면 안 된다.
serverExternalPackages: ["sherpa-onnx-node"],
async headers() { async headers() {
return [ return [
{ {
+94 -1
View File
@@ -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
View File
@@ -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",
+38
View File
@@ -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)
+98
View File
@@ -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)
})
+62
View File
@@ -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')
})
})
+140
View File
@@ -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')
})
})
+30
View File
@@ -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) })
}
+55
View File
@@ -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 })
}
}
+262 -12
View File
@@ -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,7 +255,148 @@ 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>화면 공유 대화상자에서 &ldquo;탭 오디오 공유&rdquo;를 반드시 켜주세요.</strong>
</p>
<p className="mt-1.5 text-xs text-amber-700">
노트북 한 대를 두고 마주 앉은 대면 회의에서는 동작하지 않습니다.
그 경우 &ldquo;대면 회의&rdquo;를 선택하세요.
</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">
@@ -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">
+169
View File
@@ -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 }
}
+121
View File
@@ -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 }
}
+40 -5
View File
@@ -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(
+93
View File
@@ -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
}
+135
View File
@@ -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}` }
}
}
+55
View File
@@ -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
}
+102 -2
View File
@@ -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) }
})
}
+36
View File
@@ -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
}
}
+5 -1
View File
@@ -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
} }