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
19 changed files with 1092 additions and 57 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)
})
+19 -2
View File
@@ -2,6 +2,7 @@ import { describe, it, expect } from 'vitest'
import { import {
LOCAL_SPEAKER, LOCAL_SPEAKER,
REMOTE_SPEAKER, REMOTE_SPEAKER,
diarizedSpeakerId,
listAttendees, listAttendees,
resolveSpeakerName, resolveSpeakerName,
} from '@/lib/speakers' } from '@/lib/speakers'
@@ -22,8 +23,17 @@ describe('resolveSpeakerName', () => {
expect(resolveSpeakerName(LOCAL_SPEAKER, { [LOCAL_SPEAKER]: ' ' })).toBe('나') expect(resolveSpeakerName(LOCAL_SPEAKER, { [LOCAL_SPEAKER]: ' ' })).toBe('나')
}) })
it('모르는 식별자는 그대로 돌려준다', () => { it('diarization 화자 번호를 한국어 표기로 바꾼다', () => {
expect(resolveSpeakerName('spk_3')).toBe('spk_3') 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')
}) })
}) })
@@ -43,3 +53,10 @@ describe('listAttendees', () => {
).toEqual(['배철승', '김지훈']) ).toEqual(['배철승', '김지훈'])
}) })
}) })
describe('diarizedSpeakerId', () => {
it('0-based 번호를 1-based 식별자로 바꾼다', () => {
expect(diarizedSpeakerId(0)).toBe('spk_1')
expect(diarizedSpeakerId(3)).toBe('spk_4')
})
})
@@ -3,6 +3,7 @@ import {
formatTranscriptChunks, formatTranscriptChunks,
mergeAdjacentChunks, mergeAdjacentChunks,
mergeSpeakerChunks, mergeSpeakerChunks,
assignSpeakers,
type TranscriptChunk, type TranscriptChunk,
} from '@/lib/transcript-formatter' } from '@/lib/transcript-formatter'
@@ -136,3 +137,61 @@ describe('mergeAdjacentChunks — 화자 경계', () => {
expect(merged[0].speaker).toBe('local') 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 })
}
}
+136 -51
View File
@@ -3,18 +3,24 @@
import { useEffect, useMemo, useRef, useState } 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 { useDualStreamCapture } from '@/hooks/useDualStreamCapture'
import { useDiarization } from '@/hooks/useDiarization'
import { useLiveSummary } from '@/hooks/useLiveSummary' import { useLiveSummary } from '@/hooks/useLiveSummary'
import { import {
assignSpeakers,
formatTranscriptChunks, formatTranscriptChunks,
mergeSpeakerChunks, mergeSpeakerChunks,
type TimeSpan,
} from '@/lib/transcript-formatter' } from '@/lib/transcript-formatter'
import { import {
LOCAL_SPEAKER, LOCAL_SPEAKER,
REMOTE_SPEAKER, REMOTE_SPEAKER,
diarizedSpeakerId,
resolveSpeakerName, resolveSpeakerName,
} from '@/lib/speakers' } 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
@@ -30,12 +36,17 @@ export function LiveRecorder({
depth, depth,
customPrompt, customPrompt,
}: LiveRecorderProps) { }: LiveRecorderProps) {
const [speakerSeparation, setSpeakerSeparation] = useState(false) const [mode, setMode] = useState<SeparationMode>('off')
const [attendeeCount, setAttendeeCount] = useState(2)
const [pendingStart, setPendingStart] = useState(false) const [pendingStart, setPendingStart] = useState(false)
const [speakerNames, setSpeakerNames] = useState<Record<string, string>>({}) const [speakerNames, setSpeakerNames] = useState<Record<string, string>>({})
const [segments, setSegments] = useState<TimeSpan[] | null>(null)
const timeOriginRef = useRef(0) const timeOriginRef = useRef(0)
const capture = useDualStreamCapture() const capture = useDualStreamCapture()
const diarization = useDiarization()
const speakerSeparation = mode !== 'off'
// 화자 분리를 켜면 트랙마다 인식기를 붙인다. 트랙이 곧 화자가 되므로 // 화자 분리를 켜면 트랙마다 인식기를 붙인다. 트랙이 곧 화자가 되므로
// 추론 없이 화자가 갈린다. // 추론 없이 화자가 갈린다.
@@ -63,13 +74,11 @@ export function LiveRecorder({
startListening, startListening,
} = local } = local
const chunks = useMemo( const chunks = useMemo(() => {
() => if (isSeparating) return mergeSpeakerChunks(local.chunks, remote.chunks)
isSeparating if (segments) return assignSpeakers(local.chunks, segments, diarizedSpeakerId)
? mergeSpeakerChunks(local.chunks, remote.chunks) return local.chunks
: local.chunks, }, [isSeparating, segments, local.chunks, remote.chunks])
[isSeparating, local.chunks, remote.chunks],
)
// 캡처가 끝나 트랙이 준비되면 그때 인식기를 시작한다. // 캡처가 끝나 트랙이 준비되면 그때 인식기를 시작한다.
useEffect(() => { useEffect(() => {
@@ -79,8 +88,11 @@ export function LiveRecorder({
setPendingStart(false) setPendingStart(false)
local.startListening() local.startListening()
if (capture.systemTrack) remote.startListening() if (capture.systemTrack) remote.startListening()
if (mode === 'in-person' && capture.micTrack) {
diarization.start(capture.micTrack)
}
// eslint-disable-next-line react-hooks/exhaustive-deps // eslint-disable-next-line react-hooks/exhaustive-deps
}, [pendingStart, capture.mode, capture.systemTrack]) }, [pendingStart, capture.mode, capture.systemTrack, capture.micTrack])
const { const {
summary, summary,
@@ -130,22 +142,34 @@ export function LiveRecorder({
async function handleStart() { async function handleStart() {
timeOriginRef.current = Date.now() timeOriginRef.current = Date.now()
setSegments(null)
if (!speakerSeparation) { if (mode === 'off') {
startListening() startListening()
return return
} }
setPendingStart(true) setPendingStart(true)
await capture.start({ withSystemAudio: true }) await capture.start({ withSystemAudio: mode === 'remote' })
} }
function handleStop() { async function handleStop() {
local.stopListening() local.stopListening()
remote.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() capture.stop()
const transcript = formatTranscriptChunks(chunks, speakerNames) const transcript = formatTranscriptChunks(finalChunks, speakerNames)
if (transcript.length > 0) { if (transcript.length > 0) {
onTranscriptReady(transcript) onTranscriptReady(transcript)
} }
@@ -154,6 +178,8 @@ export function LiveRecorder({
function handleReset() { function handleReset() {
local.resetChunks() local.resetChunks()
remote.resetChunks() remote.resetChunks()
diarization.reset()
setSegments(null)
} }
const lastUpdatedLabel = lastUpdatedAt const lastUpdatedLabel = lastUpdatedAt
@@ -240,64 +266,123 @@ export function LiveRecorder({
{!isListening && chunks.length === 0 && ( {!isListening && chunks.length === 0 && (
<div className="rounded-xl border border-neutral-200 bg-white p-4"> <div className="rounded-xl border border-neutral-200 bg-white p-4">
<label className="flex items-start gap-3 cursor-pointer"> <p className="mb-3 text-sm font-medium text-neutral-800">화자 분리</p>
<input
type="checkbox"
checked={speakerSeparation}
onChange={(e) => setSpeakerSeparation(e.target.checked)}
className="mt-0.5 h-4 w-4 accent-purple-600"
/>
<span>
<span className="text-sm font-medium text-neutral-800">
화자 분리 (원격 회의)
</span>
<span className="mt-0.5 block text-xs text-neutral-500">
내 마이크와 상대 목소리를 별도 트랙으로 받아 구분합니다. 추론이 없어
정확합니다. 시작 시 <strong>화면 공유 대화상자에서 &ldquo;탭 오디오 공유&rdquo;를
반드시 켜주세요.</strong>
</span>
<span className="mt-1.5 block text-xs text-amber-700">
한 대의 노트북을 앞에 두고 마주 앉은 <strong>대면 회의에서는 동작하지
않습니다.</strong> 마이크 하나에 두 사람 목소리가 섞여 들어오기 때문입니다.
이 경우 화자 라벨 없이 녹음되며, 대면 화자 분리는 준비 중입니다.
</span>
</span>
</label>
{speakerSeparation && ( <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"> <div className="mt-4 grid gap-3 sm:grid-cols-2">
<label className="block"> {(
<span className="mb-1 block text-xs text-neutral-500">내 이름</span> [
{ 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 <input
type="text" type="text"
value={speakerNames[LOCAL_SPEAKER] ?? ''} value={speakerNames[field.id] ?? ''}
onChange={(e) => onChange={(e) =>
setSpeakerNames((prev) => ({ setSpeakerNames((prev) => ({
...prev, ...prev,
[LOCAL_SPEAKER]: e.target.value, [field.id]: e.target.value,
})) }))
} }
placeholder="나" 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" className="w-full rounded-lg border border-neutral-300 px-3 py-2 text-sm focus:border-purple-400 focus:outline-none"
/> />
</label> </label>
<label className="block"> ))}
<span className="mb-1 block text-xs text-neutral-500">상대 이름</span> </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 <input
type="text" type="number"
value={speakerNames[REMOTE_SPEAKER] ?? ''} min={2}
max={8}
value={attendeeCount}
onChange={(e) => onChange={(e) =>
setSpeakerNames((prev) => ({ setAttendeeCount(Math.max(2, Math.min(8, Number(e.target.value) || 2)))
...prev,
[REMOTE_SPEAKER]: e.target.value,
}))
} }
placeholder="상대"
className="w-full rounded-lg border border-neutral-300 px-3 py-2 text-sm focus:border-purple-400 focus:outline-none" 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> </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> </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> </div>
)} )}
+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 }
}
+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}` }
}
}
+9
View File
@@ -9,6 +9,11 @@ export const REMOTE_SPEAKER = 'remote'
export type SpeakerNames = Readonly<Record<string, string>> 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 = { const DEFAULT_NAMES: SpeakerNames = {
[LOCAL_SPEAKER]: '나', [LOCAL_SPEAKER]: '나',
[REMOTE_SPEAKER]: '상대', [REMOTE_SPEAKER]: '상대',
@@ -25,6 +30,10 @@ export function resolveSpeakerName(
const preset = DEFAULT_NAMES[speaker] const preset = DEFAULT_NAMES[speaker]
if (preset) return preset if (preset) return preset
// spk_1 → "화자 1"
const diarized = /^spk_(\d+)$/.exec(speaker)
if (diarized) return `화자 ${diarized[1]}`
return speaker return speaker
} }
+61
View File
@@ -89,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
}
}