mirror of
https://github.com/nad4-su/meeting-minutes.git
synced 2026-10-11 15:50:13 +09:00
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>
This commit is contained in:
1 parent
a8cfab8a4a
commit
4af0a03de5
19 files changed
+1110
-75
No files matched your search
@@ -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) }
|
||||
})
|
||||
}
|
||||
Reference in new issue
Block a user