diff --git a/.gitignore b/.gitignore index 758552b..50297e8 100644 --- a/.gitignore +++ b/.gitignore @@ -49,3 +49,6 @@ next-env.d.ts .env !.env.example + +# 화자 분리 모델 (npm run setup:diarization 으로 내려받음) +models/ diff --git a/README.md b/README.md index a0e41e5..d6e8f2b 100644 --- a/README.md +++ b/README.md @@ -24,6 +24,7 @@ docker compose up -d - [✨ 주요 기능](#-주요-기능) - [🎤 실시간 녹음 & 전사](#-실시간-녹음--전사) + - [🗣️ 화자 분리](#️-화자-분리) - [🤖 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가 그동안의 발화를 분석해 중간 회의록을 자동 갱신합니다. @@ -335,7 +368,8 @@ npm run test:coverage # 커버리지 리포트 | DB | PostgreSQL 16 + Prisma 7 (driver adapter `@prisma/adapter-pg`) | | Markdown | `marked` + `isomorphic-dompurify` | | 스타일 | 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 빌드 | --- diff --git a/SECURITY.md b/SECURITY.md index 7ae724e..ebe1e45 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -52,6 +52,7 @@ | 액션 아이템 | PostgreSQL (`action_items`) | 외부 송출 없음 | | API 키 | 브라우저 LocalStorage 또는 `.env` | 요청 시 선택한 프로바이더에만 전송 | | 음성 데이터 | 메모리 (실시간), `/app/uploads` (파일 업로드) | **Chrome Web Speech API → Google 서버** ⚠️ | +| 화자 분리용 PCM | 서버 프로세스 메모리 (녹음 중에만) | **외부 송출 없음** — 로컬에서 분석하고 분석 후 즉시 폐기 | ### ⚠️ 주의: 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 주의) OpenAI 호환 프리셋의 base URL은 브라우저가 아니라 **Next.js Route Handler(서버)** 가 fetch 합니다. diff --git a/next.config.ts b/next.config.ts index 68db12b..ef9a988 100644 --- a/next.config.ts +++ b/next.config.ts @@ -12,6 +12,8 @@ const securityHeaders = [ const nextConfig: NextConfig = { output: "standalone", + // sherpa-onnx-node는 네이티브 애드온이라 번들링하면 안 된다. + serverExternalPackages: ["sherpa-onnx-node"], async headers() { return [ { diff --git a/package-lock.json b/package-lock.json index a197a76..43c4be1 100644 --- a/package-lock.json +++ b/package-lock.json @@ -16,7 +16,8 @@ "pg": "^8.13.1", "prisma": "^7.7.0", "react": "19.2.4", - "react-dom": "19.2.4" + "react-dom": "19.2.4", + "sherpa-onnx-node": "^1.13.7" }, "devDependencies": { "@tailwindcss/postcss": "^4", @@ -8422,6 +8423,98 @@ "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": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.0.tgz", diff --git a/package.json b/package.json index 67b96f8..3802301 100644 --- a/package.json +++ b/package.json @@ -9,7 +9,8 @@ "lint": "eslint", "test": "vitest run", "test:watch": "vitest", - "test:coverage": "vitest run --coverage" + "test:coverage": "vitest run --coverage", + "setup:diarization": "node scripts/setup-diarization.mjs" }, "dependencies": { "@prisma/adapter-pg": "^7.7.0", @@ -20,7 +21,8 @@ "pg": "^8.13.1", "prisma": "^7.7.0", "react": "19.2.4", - "react-dom": "19.2.4" + "react-dom": "19.2.4", + "sherpa-onnx-node": "^1.13.7" }, "devDependencies": { "@tailwindcss/postcss": "^4", diff --git a/public/pcm-worklet.js b/public/pcm-worklet.js new file mode 100644 index 0000000..7e705fb --- /dev/null +++ b/public/pcm-worklet.js @@ -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) diff --git a/scripts/setup-diarization.mjs b/scripts/setup-diarization.mjs new file mode 100644 index 0000000..5e8522f --- /dev/null +++ b/scripts/setup-diarization.mjs @@ -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) +}) diff --git a/src/__tests__/speakers.test.ts b/src/__tests__/speakers.test.ts index cfc132d..b3fcfd5 100644 --- a/src/__tests__/speakers.test.ts +++ b/src/__tests__/speakers.test.ts @@ -2,6 +2,7 @@ import { describe, it, expect } from 'vitest' import { LOCAL_SPEAKER, REMOTE_SPEAKER, + diarizedSpeakerId, listAttendees, resolveSpeakerName, } from '@/lib/speakers' @@ -22,8 +23,17 @@ describe('resolveSpeakerName', () => { expect(resolveSpeakerName(LOCAL_SPEAKER, { [LOCAL_SPEAKER]: ' ' })).toBe('나') }) - it('모르는 식별자는 그대로 돌려준다', () => { - expect(resolveSpeakerName('spk_3')).toBe('spk_3') + 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') }) }) @@ -43,3 +53,10 @@ describe('listAttendees', () => { ).toEqual(['배철승', '김지훈']) }) }) + +describe('diarizedSpeakerId', () => { + it('0-based 번호를 1-based 식별자로 바꾼다', () => { + expect(diarizedSpeakerId(0)).toBe('spk_1') + expect(diarizedSpeakerId(3)).toBe('spk_4') + }) +}) diff --git a/src/__tests__/transcript-formatter.test.ts b/src/__tests__/transcript-formatter.test.ts index 681fd4a..517cb34 100644 --- a/src/__tests__/transcript-formatter.test.ts +++ b/src/__tests__/transcript-formatter.test.ts @@ -3,6 +3,7 @@ import { formatTranscriptChunks, mergeAdjacentChunks, mergeSpeakerChunks, + assignSpeakers, type TranscriptChunk, } from '@/lib/transcript-formatter' @@ -136,3 +137,61 @@ describe('mergeAdjacentChunks — 화자 경계', () => { 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') + }) +}) diff --git a/src/app/api/diarize/chunk/route.ts b/src/app/api/diarize/chunk/route.ts new file mode 100644 index 0000000..c886ac2 --- /dev/null +++ b/src/app/api/diarize/chunk/route.ts @@ -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) }) +} diff --git a/src/app/api/diarize/route.ts b/src/app/api/diarize/route.ts new file mode 100644 index 0000000..ac19ec7 --- /dev/null +++ b/src/app/api/diarize/route.ts @@ -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 }) + } +} diff --git a/src/components/recorder/LiveRecorder.tsx b/src/components/recorder/LiveRecorder.tsx index b57a247..b14c021 100644 --- a/src/components/recorder/LiveRecorder.tsx +++ b/src/components/recorder/LiveRecorder.tsx @@ -3,18 +3,24 @@ import { useEffect, useMemo, useRef, useState } from 'react' import { useSpeechRecognition } from '@/hooks/useSpeechRecognition' import { useDualStreamCapture } from '@/hooks/useDualStreamCapture' +import { useDiarization } from '@/hooks/useDiarization' import { useLiveSummary } from '@/hooks/useLiveSummary' 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' +type SeparationMode = 'off' | 'remote' | 'in-person' + interface LiveRecorderProps { onTranscriptReady: (transcript: string) => void liveSummaryEnabled: boolean @@ -30,12 +36,17 @@ export function LiveRecorder({ depth, customPrompt, }: LiveRecorderProps) { - const [speakerSeparation, setSpeakerSeparation] = useState(false) + const [mode, setMode] = useState('off') + const [attendeeCount, setAttendeeCount] = useState(2) const [pendingStart, setPendingStart] = useState(false) const [speakerNames, setSpeakerNames] = useState>({}) + const [segments, setSegments] = useState(null) const timeOriginRef = useRef(0) const capture = useDualStreamCapture() + const diarization = useDiarization() + + const speakerSeparation = mode !== 'off' // 화자 분리를 켜면 트랙마다 인식기를 붙인다. 트랙이 곧 화자가 되므로 // 추론 없이 화자가 갈린다. @@ -63,13 +74,11 @@ export function LiveRecorder({ startListening, } = local - const chunks = useMemo( - () => - isSeparating - ? mergeSpeakerChunks(local.chunks, remote.chunks) - : local.chunks, - [isSeparating, local.chunks, remote.chunks], - ) + 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(() => { @@ -79,8 +88,11 @@ export function LiveRecorder({ 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]) + }, [pendingStart, capture.mode, capture.systemTrack, capture.micTrack]) const { summary, @@ -130,22 +142,34 @@ export function LiveRecorder({ async function handleStart() { timeOriginRef.current = Date.now() + setSegments(null) - if (!speakerSeparation) { + if (mode === 'off') { startListening() return } setPendingStart(true) - await capture.start({ withSystemAudio: true }) + await capture.start({ withSystemAudio: mode === 'remote' }) } - function handleStop() { + 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(chunks, speakerNames) + const transcript = formatTranscriptChunks(finalChunks, speakerNames) if (transcript.length > 0) { onTranscriptReady(transcript) } @@ -154,6 +178,8 @@ export function LiveRecorder({ function handleReset() { local.resetChunks() remote.resetChunks() + diarization.reset() + setSegments(null) } const lastUpdatedLabel = lastUpdatedAt @@ -240,64 +266,123 @@ export function LiveRecorder({ {!isListening && chunks.length === 0 && (
- +

화자 분리

- {speakerSeparation && ( -
- - -
+
+ {( + [ + { id: 'off', label: '사용 안 함' }, + { id: 'remote', label: '원격 회의' }, + { id: 'in-person', label: '대면 회의' }, + ] as const + ).map((option) => ( + + ))} +
+ + {mode === 'remote' && ( + <> +

+ 내 마이크와 상대 목소리를 별도 트랙으로 받아 구분합니다. 추론이 없어 + 정확합니다. 시작 시{' '} + 화면 공유 대화상자에서 “탭 오디오 공유”를 반드시 켜주세요. +

+

+ 노트북 한 대를 두고 마주 앉은 대면 회의에서는 동작하지 않습니다. + 그 경우 “대면 회의”를 선택하세요. +

+
+ {( + [ + { id: LOCAL_SPEAKER, label: '내 이름', placeholder: '나' }, + { id: REMOTE_SPEAKER, label: '상대 이름', placeholder: '상대' }, + ] as const + ).map((field) => ( + + ))} +
+ )} + + {mode === 'in-person' && ( + <> +

+ 마이크 하나에 섞여 들어온 목소리를 내 PC에서 분석해 화자를 나눕니다. + 외부로 오디오를 보내지 않습니다.{' '} + 녹음이 끝난 뒤 한 번에 분석하므로 실시간 화자 표시는 없습니다. +

+ + + )} + + {diarization.available === false && ( +

+ 대면 회의 화자 분리를 쓰려면 모델이 필요합니다 —{' '} + npm run setup:diarization{' '} + 을 실행한 뒤 서버를 다시 시작하세요. +

+ )} +
+ )} + + {diarization.status.state === 'analyzing' && ( +
+ 화자를 분석하는 중입니다... 회의가 길수록 시간이 걸립니다. +
+ )} + + {diarization.status.state === 'done' && ( +
+ 화자 {diarization.status.speakers}명을 구분했습니다. +
+ )} + + {diarization.status.state === 'error' && ( +
+ 화자 분리 오류: {diarization.status.message}
)} diff --git a/src/hooks/useDiarization.ts b/src/hooks/useDiarization.ts new file mode 100644 index 0000000..55e289d --- /dev/null +++ b/src/hooks/useDiarization.ts @@ -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 + /** 수집을 멈추고 전체 오디오에 화자 분리를 돌린다. */ + finish: (numSpeakers?: number) => Promise + 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(null) + const [status, setStatus] = useState({ state: 'idle' }) + + const sessionRef = useRef('') + const contextRef = useRef(null) + const nodeRef = useRef(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 => { + 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 } +} diff --git a/src/lib/audio-session.ts b/src/lib/audio-session.ts new file mode 100644 index 0000000..203ff56 --- /dev/null +++ b/src/lib/audio-session.ts @@ -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() + +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 +} diff --git a/src/lib/diarization.ts b/src/lib/diarization.ts new file mode 100644 index 0000000..ffc59d6 --- /dev/null +++ b/src/lib/diarization.ts @@ -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 { + 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 { + 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}` } + } +} diff --git a/src/lib/speakers.ts b/src/lib/speakers.ts index 061966d..8a32f3b 100644 --- a/src/lib/speakers.ts +++ b/src/lib/speakers.ts @@ -9,6 +9,11 @@ export const REMOTE_SPEAKER = 'remote' export type SpeakerNames = Readonly> +/** diarization이 매긴 화자 번호를 식별자로 바꾼다. 0-based → 1-based. */ +export function diarizedSpeakerId(index: number): string { + return `spk_${index + 1}` +} + const DEFAULT_NAMES: SpeakerNames = { [LOCAL_SPEAKER]: '나', [REMOTE_SPEAKER]: '상대', @@ -25,6 +30,10 @@ export function resolveSpeakerName( 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 } diff --git a/src/lib/transcript-formatter.ts b/src/lib/transcript-formatter.ts index 4f5bc38..b8a47c5 100644 --- a/src/lib/transcript-formatter.ts +++ b/src/lib/transcript-formatter.ts @@ -89,3 +89,64 @@ export function mergeAdjacentChunks( 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) } + }) +} diff --git a/src/types/sherpa-onnx-node.d.ts b/src/types/sherpa-onnx-node.d.ts new file mode 100644 index 0000000..750b8cc --- /dev/null +++ b/src/types/sherpa-onnx-node.d.ts @@ -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 + } +}