프로젝트 · · 8 min read
SpeechToText: 회의 녹음을 화자 실명이 붙은 회의록으로 바꾸는 로컬 STT 파이프라인
저장소: github.com/LaMelD/SpeechToText
회의 녹음 파일 하나를 넣으면 [HH:MM:SS] 이름: 텍스트 형식의 회의록이 나온다. 누가 말했는지는 로컬에서 알아내고(화자 분리 + 성문 매칭), 무슨 말을 했는지만 OpenAI gpt-transcribe에 묻는다. 클로바노트의 음성기록을 대체하려고 만들었고, 혼자 CLI로 쓰는 도구라 웹 UI도 LLM 요약도 없다.
[00:00:09] 홍길동: 지난주에 얘기한 마이그레이션 일정부터 볼까요.
[00:00:14] 김철수: 스키마 변경은 끝났고 트래픽 전환만 남았습니다.
[00:00:21] Unknown-1: 게이트웨이 쪽은 제가 확인하겠습니다.
Unknown-1은 성문 DB에 없는 사람이다. 회의록을 열어 speakers.json에서 이름을 적어 주고 같은 명령을 다시 실행하면 API 호출 없이 회의록만 다시 만들어진다.
왜 전사만 API에 보내나
gpt-transcribe는 prompt, keywords, languages를 받지만 타임스탬프와 화자 정보를 주지 않는다. 화자까지 주는 gpt-4o-transcribe-diarize는 미리 아는 화자가 4명까지라 회의에 못 쓴다. 그래서 diarization은 로컬에서 돌리고, 얻은 구간대로 오디오를 잘라 구간마다 전사를 요청한다. 타임스탬프는 diarization에서 나오므로 구간별 슬라이싱이 유일한 경로다.
부수 효과가 둘 있다. 성문(voice print)은 로컬에만 남고 밖으로 나가는 것은 잘린 오디오뿐이다. 비용은 분당 $0.0045라 50분 회의가 $0.22 정도다.
파이프라인 다섯 단계
| 단계 | 하는 일 | 산출물 |
|---|---|---|
| diarize | 화자 구간과 화자별 centroid 임베딩 | diarization.json, centroids.npy |
| match | 클러스터마다 성문 DB에서 가장 가까운 사람. 임계값 미만은 Unknown-n | speakers.json |
| merge | 같은 화자 인접 구간 병합, 추임새 건너뛰기 | 턴 목록 |
| transcribe | 턴별 WAV를 잘라 gpt-transcribe 호출. 8개 구간 병렬 | turns.jsonl |
| render | 같은 이름 인접 턴을 한 줄로 | transcript.md, transcript.json |
1~3과 5는 로컬이고 4만 API다. 성문 등록은 별도 명령이다. 회의 하나를 diarization해서 클러스터마다 가장 긴 발화 6개를 WAV로 저장해 주면, 사람이 듣고 폴더명을 실명으로 바꾼 뒤 --commit으로 DB에 넣는다. DB는 voiceprints/<이름>.npy에 세션별 centroid를 행으로 쌓는 구조라, 회의실과 온라인이 섞인 녹음 조건별 벡터가 누적된다.
화자 식별: 헝가리안 대신 독립 argmax
구현 전에 실제 회의 5건(5180분, 47명)과 클로바노트 출력을 정답으로 두고 임베딩 분포부터 쟀다. 순수한 클러스터끼리는 같은 사람이 세션 간 코사인 유사도 0.70 이상, 다른 사람은 0.60 이하로 겹치지 않았다. 문제는 클러스터가 순수하지 않을 때다. 43분을 말한 사람이 3개 클러스터로 갈라지고, 발화가 8블록뿐인 사람은 남의 클러스터에 흡수됐다.
처음 생각한 헝가리안 1:1 배정은 이 갈라진 클러스터를 엉뚱한 사람에게 밀어냈다. 사람 하나에 클러스터 하나라는 제약이 틀린 가정이었다. 그래서 클러스터마다 독립적으로 가장 가까운 사람을 고르고(argmax) 여러 클러스터가 같은 사람에게 붙는 것을 허용했다. render가 같은 이름을 합치므로 결과는 자연히 하나가 된다.
| 방식 | 정답 | 오배정 | Unknown |
|---|---|---|---|
| 헝가리안 1:1 (임계값 0.6) | 23 | 1 | 3 |
| 독립 argmax, 다대일 허용 | 26 | 1 | 0 |
임계값 0.65는 같은 사람 최소 0.74와 다른 사람 최대 0.58의 중간이다. 미등록자를 거르려면 0.5 이상이어야 한다는 것도 실측으로 확인했다. 최종 검증(회의마다 그 회의를 뺀 DB로 leave-one-out)에서 25/26을 맞혔고, Unknown 2건은 둘 다 옳은 판단이었다. 하나는 0.2분짜리 조각, 하나는 그 회의에만 나와 DB에 없던 사람이다.
화자 수는 강제하지 않는다. 지정하면 발화가 적은 사람의 자리를 지배적 화자의 조각이 채운다. 자동 추정(2~10명)이 두 파일 모두에서 더 정확했다.
턴 병합과 문맥
diarization이 준 구간은 중앙값 2~3초로 짧다. 그대로 보내면 gpt-transcribe가 받는 문맥이 적다. 병합 규칙은 셋이다.
- 같은 화자의 인접 구간이 1초 이내면 합치고, 0.3초 미만은 버린다. 버려지는 턴이 36%지만 합쳐도 1~2분이라 내용 손실은 없다.
- 다른 화자의 1초 이하 추임새는 건너뛰어 같은 화자를 2.5초 간격까지 묶는다. 추임새 오디오는 주 화자 구간 안에 남는다.
- 병합 결과가 10분을 넘게 되는 병합은 하지 않는다. 25MB 한도 대비 안전장치인데 실측 최장 턴이 1.3분이라 실제로는 발동하지 않는다.
여기에 직전 12턴에서 뽑은 마지막 300자를 prompt로 넘긴다. 턴을 8개 연속 구간으로 나눠 구간 안에서는 순차(앞 턴의 텍스트가 다음 턴의 prompt), 구간끼리는 병렬로 돌린다. 이 둘을 넣고 5건을 다시 돌리자 클로바노트 대비 불일치율(CER*)이 평균 0.166에서 0.148로 11% 내려갔고, 턴이 절반으로 줄어 호출 수도 절반이 됐다. 어려운 회의에서 폭이 컸다.
구간의 첫 턴은 앞 구간이 덜 끝나 prompt가 짧다. 완성된 문맥으로 그 턴만 다시 전사하는 2패스를 구현해 봤는데 5건 모두 ±0.002로 잡음 범위였고 비용만 10% 늘었다. 되돌렸다. 턴이 7초 안팎이 되면 prompt가 주는 추가 정보가 거의 없다.
캐시는 속도가 아니라 정합성을 위한 것
diarization 결과를 캐시하는 이유는 빠르게 하려는 게 아니다. 재실행 때 클러스터 라벨이 바뀌면 사용자가 고친 speakers.json이 어긋나기 때문이다. 캐시에 오디오 샘플 수를 함께 적어 다른 오디오의 캐시를 잘못 읽는 것도 막는다.
turns.jsonl은 턴이 끝날 때마다 한 줄씩 붙인다. 중간에 끊겨도 재실행하면 이어서 하고, 실패한 턴은 text: null로 남겨 다음 실행에서 다시 시도한다. 5건 2,972턴에서 실패는 0이었지만, 있어야 하는 장치다.
두 백엔드: pyannote와 sherpa-onnx
처음은 pyannote.audio 4로 만들었다. 정확하지만 torch를 끌고 오고, HF 토큰과 모델 약관 동의가 필요하고, ffmpeg도 shared 빌드여야 한다. 같은 파이프라인을 sherpa-onnx로 다시 만들었다. onnxruntime만 있으면 되고 모델 파일 34MB를 직접 내려받는다. 출력 스키마를 똑같이 유지해서 두 결과를 그대로 비교할 수 있다.
| pyannote | sherpa-onnx | |
|---|---|---|
| diarization | pyannote/speaker-diarization-community-1 | pyannote segmentation-3.0 ONNX + 3D-Speaker CAM++ |
| 런타임 | torch | onnxruntime |
| 성문 임베딩 | WeSpeaker 256차원 | CAM++ 192차원 |
| 매칭 임계값 | 0.65 | 0.60 |
| 인증 | HF 토큰 | 없음 |
임베딩 모델이 다르니 성문 DB는 호환되지 않는다. voiceprints/model.txt에 모델 식별자를 적어 두고 다르면 실행을 거부한다.
sherpa-onnx 판은 실제로 쓰면서 필요해진 것들이 더 붙었다.
- 미등록 화자 묶기. 과분할된 클러스터 수십 개가 미등록 1명을
Unknown-17/18/20/21로 흩어 놓는다. DB에 없는 클러스터끼리 같은 임계값으로 묶어 같은 번호를 준다. 그룹 대표는 소속 벡터의 평균이다. 단일 연결로 하면 사슬처럼 이어붙어 다른 사람이 한 그룹이 된다. --participants. 참석자 실명을 주면 DB 후보를 그 사람들로 제한한다. 회의에 없는 사람이 임계값을 넘겨 끼어드는 오탐이 3개 회의에서 1분 있었다.keywords.txt. 매번--keywords로 수십 개 용어를 넘기는 대신 파일로 관리한다.- 클립 삭제 후 성문 재계산. 클러스터 수를 늘려도 안 갈라지는 두 사람이 있었다. 등록 폴더에서 남의 목소리 클립을 지우고 commit하면 남은 클립으로 centroid를 다시 계산한다.
- 3초 미만 Unknown은
불명. Unknown 턴 57개 중 68%가 3초 미만 파편이었다. 2초짜리 추임새마다 번호가 붙으면 회의록이 지저분하다.
실측
pyannote (M3 Pro MPS, 회의 5건 331분, leave-one-out)
| 항목 | 값 |
|---|---|
| diarization RTF | 0.074 |
| 화자 매칭 | 25/26 |
| 전사 실패 | 0 / 2,972턴 |
| CER* | 0.115~0.201 (평균 0.148) |
| STT 비용 | 5건 합계 $1.43 |
CER은 클로바노트 출력과의 불일치율이지 정답과의 거리가 아니다. 어느 쪽이 맞는지 보려고 불일치 블록을 whisper-1에 다시 넣어 심판으로 삼았다. 쉬운 회의는 클로바와 대등했고 어려운 회의는 열세였다. 클로바 자체 오류(예: “패넌트"로 적힌 “테넌트”)가 CER의 하한을 만든다.
sherpa-onnx (i7-9750H, 회의 3건 200분, 성문 DB 7명)
| 항목 | 값 |
|---|---|
| diarization RTF | 0.13 (6스레드) |
| 등록 화자 정확도 | 93.2% |
| 임계값 민감도 | 0.60 → 93.0%, 0.65 → 90.1%, 0.70 → 75.3% |
min_duration_on(이보다 짧은 발화 구간은 버림)이 이 파이프라인의 사실상 유일한 노브다. 1.0/1.5/2.0 모두 정확도 차이가 1% 안이었다. 올릴수록 정확도가 오르는 건 어려운 짧은 구간을 버린 효과라 1.5를 유지했다. 회의록에는 놓친 질문이 더 아프다.
사용
# 성문 등록: 클립을 듣고 폴더명을 실명으로 바꾼 뒤 commit
python -m stt_local.enroll 회의.m4a --out enroll_out/
python -m stt_local.enroll --commit enroll_out/
# 전사
python -m stt_local.transcribe 회의.m4a --out out/0902회의/ --participants "홍길동,김철수"
.env에 OPENAI_API_KEY가 있으면 된다. 실명 성문 DB, 음성 클립, 회의 전사 결과는 개인정보라 .gitignore로 막아 두었고, 저장소의 설계 문서에서도 참석자 실명은 익명 라벨로 바꿨다.
만들면서 배운 것
- 비교 지표를 믿기 전에 정렬을 확인해야 한다. 심판 비교의 첫 결과는 “클로바가 3:1로 이긴다"였는데,
turns.jsonl을 시간순이 아닌 완료순으로 읽어 블록 안 문장 순서가 섞인 탓이었다. 정렬을 고치자 쉬운 회의는 대등이었다. 이 버그 하나가 블록 단위 통계 전부를 부풀리고 있었다. - 화자 수를 알아도 강제하지 않는 게 낫다. 사람 수를 맞추면 발화가 적은 사람 자리를 지배적 화자의 조각이 채운다. 한 사람이 여러 클러스터로 나오는 것을 정상으로 두고 매칭에서 다대일을 허용하는 쪽이 맞다.
- int8 세그멘테이션 모델은 12% 빨라지는 대신 검출 발화가 22% 줄었다. 속도 이득은 int8 연산이 아니라 놓친 구간의 임베딩을 건너뛴 데서 왔다. 빨라진 이유를 확인하지 않으면 손실을 속도로 착각한다.
- 스레드는 물리 코어 수만큼만. 6스레드 RTF 0.13이 하이퍼스레딩까지 12스레드로 올리면 0.39로 퇴화한다.
- 개선안은 합쳐도 더해지지 않는다. prompt 문맥과 추임새 병합이 각각 비슷한 효과를 냈고 합치면 그중 큰 쪽 정도였다. 그 위에 얹은 2패스는 효과가 0이었다. 턴이 길어지면 문맥의 한계 효용이 빠르게 사라진다.