Meeting minutes pipeline.py
녹음 파일 하나로 노션 AI처럼 화자별로 이름을 붙인 회의록을 만드는 방법을 정리한다. 핵심은 화자 분리(speaker diarization) + 음성 인식(STT/ASR) + 요약(LLM)의 3단계 파이프라인이며, 로컬 오픈소스만으로 고품질 결과를 얻을 수 있다.
전체 파이프라인
녹음 파일 (.wav/.m4a)
↓
① STT (음성 → 텍스트 + 타임스탬프) → WhisperX / faster-whisper
↓
② Diarization (누가 언제 말했나) → pyannote.audio
↓
③ 정렬 (텍스트 ↔ 화자 매칭) → WhisperX가 통합 처리
↓
④ 화자 이름 매핑 (SPEAKER_00 → 홍길동)
↓
⑤ 요약/액션아이템 추출 (LLM) → 로컬 vLLM/SGLang or Claude API
↓
회의록 (Markdown / MediaWiki)
추천 오픈소스
가장 손이 덜 가면서 품질이 좋은 조합은 WhisperX 하나다. Whisper STT + pyannote diarization + 단어 단위 정렬을 한 번에 처리한다.
| 도구 | 역할 | 비고 |
| WhisperX | STT + diarization + 정렬 올인원 | 가장 추천 |
| pyannote.audio 3.1 | 화자 분리 단독 | HuggingFace 토큰 필요(무료) |
| faster-whisper | STT 전용 (고속) | CTranslate2 기반, GPU 효율 우수 |
| whisper.cpp | STT (경량) | CPU/경량 환경용 |
한국어 회의라면 Whisper large-v3 모델을 사용하는 것이 정확도 면에서 유리하다.
파이프라인 단계 설명
①~③ STT · 정렬 · 화자 분리
WhisperX가 통합 처리한다. 한국어는 language="ko"로 고정하고, 정렬 단계는 kresnik/wav2vec2-large-xlsr-korean 모델이 자동 사용된다. GPU 메모리 부족 시 compute_type을 int8로, batch_size를 축소한다.
④ 화자 이름 매핑 (SPEAKER_00 → 실명)
노션과 차이가 나는 부분이다. Diarization은 "화자 A/B/C"까지만 구분하며 실명은 자동으로 맞추지 못한다. 실명을 붙이는 방법은 다음 세 가지다.
- 수동 매핑 (가장 확실) — 결과 앞부분을 듣고 SPEAKER_00이 누구인지 파악해 딕셔너리로 치환
- 화자 등록(voice enrollment) — 참석자 목소리 샘플을 미리 등록하고 임베딩 매칭 (pyannote/SpeechBrain의 speaker embedding 활용)
- 자기소개 활용 — "저는 홍길동입니다" 같은 발화를 LLM으로 파싱해 자동 매핑
⑤ LLM 요약
트랜스크립트를 LLM에 넣어 요약·결정사항·액션아이템을 추출한다. 로컬 vLLM/SGLang 인프라를 쓰거나, 품질을 원하면 Claude API를 사용한다.
로컬 LLM vs Claude API 비교
| 항목 | 로컬 vLLM/SGLang | Claude API |
| 비용 | 전기세만 (무료) | 토큰 과금 |
| 데이터 | 외부 유출 없음 (사내/파트너사 회의 안전) | 외부 전송 |
| 한국어 요약 품질 | 모델 선택에 좌우 (Qwen2.5-72B, EXAONE-3.5 등) | 최상위권, 안정적 |
| 긴 회의(1~2시간) | 모델 컨텍스트 한계 주의 | 200K 컨텍스트 여유 |
| DGX Spark(128GB) 적합성 | 70B급 int4/int8 충분히 구동 | 해당 없음 |
민감한 내용(파트너사 회의 등)이면 로컬, 품질 벤치마크가 필요하면 Claude를 사용한다. 같은 트랜스크립트로 양쪽을 동시에 돌려 나란히 비교하는 방식을 권장한다.
설치
pyannote 모델은 HuggingFace에서 1회 사용 동의가 필요하다.
- huggingface.co/pyannote/speaker-diarization-3.1
- huggingface.co/pyannote/segmentation-3.0
사용 흐름
2단계로 실행하는 것을 권장한다 (화자 4명 이상은 실명 매핑이 핵심).
- 1차 실행 → 트랜스크립트에
SPEAKER_00~03라벨로 출력. 앞부분을 훑어 화자 파악 - 매핑 딕셔너리에 실명을 채우고 재실행 → 실명 반영 + 로컬/Claude 요약본 2종 생성
화자 4명 이상일 때 정확도 팁
- 화자 수를 정확히 알면
num_speakers를 지정한다.min/max범위보다 diarization 오류가 크게 준다. - 발화가 겹치는(overlapping speech) 구간이 많으면 화자가 섞인다. 회의 시작에 각자 한 번씩 자기소개가 있으면 매핑이 쉬워진다.
- 로컬 요약 모델은 회의가 길면(1~2시간) 컨텍스트 초과가 날 수 있다. DGX Spark 128GB면 Qwen2.5-72B int4/int8이 무난하며, 한국어 요약은 EXAONE-3.5 계열이 더 자연스러울 때도 있으므로 모델을 바꿔 비교한다.
완전 통합형 대안
파이프라인 구성이 번거로우면 웹UI까지 갖춘 오픈소스도 있다.
- Whishper — 셀프호스팅 웹UI, 전사+번역, Docker 배포
- Meetily / meeting-minutes 계열 — 회의록 특화(요약 포함)
Source Code
#!/usr/bin/env python3
"""
회의록 자동 생성 파이프라인 / Meeting minutes pipeline
=====================================================
녹음 파일 1개 → 화자별 트랜스크립트 → LLM 요약(로컬 & Claude 동시 비교) → Markdown
Pipeline:
① STT + 화자분리 + 정렬 : WhisperX (Whisper large-v3 + pyannote 3.1)
② 화자 실명 매핑 : SPEAKER_00 → 실명 dict
③ Markdown 트랜스크립트 : 화자별 발화 묶기
④ LLM 요약 (2종 비교) : 로컬 vLLM/SGLang + Claude API
설치 / install:
pip install whisperx anthropic openai
# pyannote 모델 사용 동의 (HuggingFace에서 1회):
# huggingface.co/pyannote/speaker-diarization-3.1
# huggingface.co/pyannote/segmentation-3.0
실행 / run:
export HF_TOKEN=hf_xxxxx
export ANTHROPIC_API_KEY=sk-ant-xxxxx # Claude 요약 쓸 때만
export LOCAL_LLM_BASE_URL=http://localhost:8000/v1 # vLLM/SGLang 엔드포인트
python meeting_minutes_pipeline.py meeting.m4a
"""
import os
import gc
import sys
import argparse
import torch
# =====================================================================
# 설정 / CONFIG (여기만 상황에 맞게 수정) / edit here
# =====================================================================
class Config:
# --- STT / diarization ---
device = "cuda" # RTX PRO 2000 Blackwell Laptop (8GB VRAM)
compute_type = "int8" # 8GB GPU: int8 필수 / int8 required for 8GB
batch_size = 4 # 8GB GPU: 4로 축소 / reduced for 8GB
whisper_model = "large-v3" # 한국어 권장 / recommended for Korean
language = "ko"
# 화자 수 / speaker count — 4명 이상은 정확히 아는 게 정확도에 크게 유리
# 정확히 알면 num_speakers 지정, 모르면 None + min/max 사용
num_speakers = None # 예: 5 (정확히 알면) / exact if known
min_speakers = 4
max_speakers = 8
hf_token = os.environ.get("HF_TOKEN")
# --- 화자 실명 매핑 / speaker name mapping ---
# 1차 결과의 앞부분을 듣고 SPEAKER_XX ↔ 실명을 채워 재실행
# First run produces SPEAKER_00~; listen to the head, then fill & rerun
speaker_names = {
"SPEAKER_00": "SPEAKER_00",
"SPEAKER_01": "SPEAKER_01",
"SPEAKER_02": "SPEAKER_02",
"SPEAKER_03": "SPEAKER_03",
# "SPEAKER_00": "김철수 (PM)",
# "SPEAKER_01": "이영희 (개발)",
# "SPEAKER_02": "박민수 (파트너사)",
}
# --- LLM 요약 / summarization ---
run_local_llm = True # 로컬 vLLM/SGLang 요약 실행
run_claude = True # Claude API 요약 실행
local_llm_base_url = os.environ.get("LOCAL_LLM_BASE_URL",
"http://localhost:8000/v1")
local_llm_model = "Qwen/Qwen2.5-72B-Instruct" # vLLM에 로드된 모델명
claude_model = "claude-opus-4-8"
# =====================================================================
# ① STT + 화자분리 + 정렬 / transcribe + diarize + align
# =====================================================================
def transcribe_and_diarize(audio_file: str, cfg: Config):
import whisperx
# --- STT ---
print("[1/3] STT (Whisper) ...")
model = whisperx.load_model(
cfg.whisper_model, cfg.device,
compute_type=cfg.compute_type, language=cfg.language,
)
audio = whisperx.load_audio(audio_file)
result = model.transcribe(audio, batch_size=cfg.batch_size)
del model
gc.collect()
torch.cuda.empty_cache()
# --- 단어 단위 정렬 / word-level alignment ---
# 한국어는 kresnik/wav2vec2-large-xlsr-korean 자동 사용
print("[2/3] 정렬 / alignment ...")
try:
model_a, metadata = whisperx.load_align_model(
language_code=cfg.language, device=cfg.device)
result = whisperx.align(
result["segments"], model_a, metadata, audio, cfg.device)
del model_a
gc.collect()
torch.cuda.empty_cache()
except Exception as e:
# 정렬이 튀면 segment 단위로 진행 / fall back to segment-level
print(f" ! 정렬 스킵 (segment 단위 진행): {e}")
# --- 화자 분리 / diarization ---
print("[3/3] 화자 분리 / diarization ...")
diarize_model = whisperx.DiarizationPipeline(
use_auth_token=cfg.hf_token, device=cfg.device)
if cfg.num_speakers: # 화자 수를 알면 지정 = 정확도 상승
diarize_segments = diarize_model(audio, num_speakers=cfg.num_speakers)
else:
diarize_segments = diarize_model(
audio, min_speakers=cfg.min_speakers, max_speakers=cfg.max_speakers)
result = whisperx.assign_word_speakers(diarize_segments, result)
return result
# =====================================================================
# ②③ Markdown 트랜스크립트 / build markdown transcript
# =====================================================================
def format_timestamp(seconds: float) -> str:
"""초 → MM:SS / seconds to MM:SS"""
m, s = divmod(int(seconds), 60)
return f"{m:02d}:{s:02d}"
def build_transcript(result, cfg: Config) -> str:
names = cfg.speaker_names
lines, current, buffer, start_ts = [], None, [], None
for seg in result["segments"]:
spk_id = seg.get("speaker", "UNKNOWN")
spk = names.get(spk_id, spk_id)
text = seg["text"].strip()
if not text:
continue
# 같은 화자 연속 발화 묶기 / group consecutive turns
if spk != current:
if buffer:
lines.append(f"**[{start_ts}] {current}**: {' '.join(buffer)}\n")
current, buffer = spk, [text]
start_ts = format_timestamp(seg["start"])
else:
buffer.append(text)
if buffer:
lines.append(f"**[{start_ts}] {current}**: {' '.join(buffer)}\n")
return "\n".join(lines)
# =====================================================================
# ④ LLM 요약 / summarization
# =====================================================================
SUMMARY_PROMPT = """다음은 회의 녹취록입니다. 아래 형식의 Markdown 회의록으로 정리해줘.
## 회의 요약
(핵심을 3~5줄로)
## 주요 논의 사항
- (항목별 bullet)
## 결정 사항
- (확정된 것만)
## 액션 아이템
| 담당자 | 할 일 | 기한 |
|---|---|---|
## 미해결 / 후속 논의
- (있으면)
---
녹취록:
{transcript}
"""
def summarize_local(transcript: str, cfg: Config) -> str:
"""로컬 vLLM/SGLang (OpenAI 호환 API) / local OpenAI-compatible endpoint"""
from openai import OpenAI
client = OpenAI(base_url=cfg.local_llm_base_url, api_key="not-needed")
resp = client.chat.completions.create(
model=cfg.local_llm_model,
messages=[{"role": "user",
"content": SUMMARY_PROMPT.format(transcript=transcript)}],
temperature=0.3,
max_tokens=4000,
)
return resp.choices[0].message.content
def summarize_claude(transcript: str, cfg: Config) -> str:
"""Claude API / Anthropic SDK"""
import anthropic
client = anthropic.Anthropic() # ANTHROPIC_API_KEY 환경변수 사용
resp = client.messages.create(
model=cfg.claude_model,
max_tokens=4000,
messages=[{"role": "user",
"content": SUMMARY_PROMPT.format(transcript=transcript)}],
)
return resp.content[0].text
# =====================================================================
# main
# =====================================================================
def main():
parser = argparse.ArgumentParser(
description="회의록 자동 생성 파이프라인",
formatter_class=argparse.ArgumentDefaultsHelpFormatter)
parser.add_argument("audio", help="녹음 파일 경로 / audio file path")
parser.add_argument("--out", default="meeting", help="출력 파일 prefix")
# --- STT / diarization 튜닝 (OOM 시 조절) ---
parser.add_argument("--device", default=Config.device,
choices=["cuda", "cpu"], help="연산 장치 / device")
parser.add_argument("--compute-type", default=Config.compute_type,
choices=["float16", "float32", "int8", "int8_float16"],
help="양자화 정밀도. OOM 시 int8 / lower for OOM")
parser.add_argument("--batch-size", type=int, default=Config.batch_size,
help="배치 크기. OOM 시 축소 / reduce for OOM")
parser.add_argument("--whisper-model", default=Config.whisper_model,
help="Whisper 모델. OOM 시 medium / smaller for OOM")
parser.add_argument("--language", default=Config.language, help="언어 코드")
# --- 화자 수 / speaker count ---
parser.add_argument("--num-speakers", type=int, default=Config.num_speakers,
help="화자 수(정확히 알 때). 지정 시 min/max 무시")
parser.add_argument("--min-speakers", type=int, default=Config.min_speakers,
help="최소 화자 수")
parser.add_argument("--max-speakers", type=int, default=Config.max_speakers,
help="최대 화자 수")
# --- LLM 요약 on/off ---
parser.add_argument("--no-local-llm", action="store_true",
help="로컬 vLLM/SGLang 요약 비활성화")
parser.add_argument("--no-claude", action="store_true",
help="Claude API 요약 비활성화")
args = parser.parse_args()
# 명령행 값으로 Config 오버라이드 / CLI overrides Config
cfg = Config()
cfg.device = args.device
cfg.compute_type = args.compute_type
cfg.batch_size = args.batch_size
cfg.whisper_model = args.whisper_model
cfg.language = args.language
cfg.num_speakers = args.num_speakers
cfg.min_speakers = args.min_speakers
cfg.max_speakers = args.max_speakers
cfg.run_local_llm = Config.run_local_llm and not args.no_local_llm
cfg.run_claude = Config.run_claude and not args.no_claude
if not cfg.hf_token:
print("! HF_TOKEN 환경변수가 필요합니다 (pyannote 모델용).")
sys.exit(1)
# ① STT + diarization
result = transcribe_and_diarize(args.audio, cfg)
# ②③ 트랜스크립트 / transcript
transcript = build_transcript(result, cfg)
transcript_path = f"{args.out}_transcript.md"
with open(transcript_path, "w", encoding="utf-8") as f:
f.write("# 회의 녹취록 (화자별)\n\n" + transcript)
print(f"\n✓ 트랜스크립트 저장: {transcript_path}")
# 화자 라벨이 아직 SPEAKER_XX면 여기서 멈추고 매핑 후 재요약 권장
if all(v.startswith("SPEAKER_") for v in cfg.speaker_names.values()):
print("\n※ 앞부분을 듣고 Config.speaker_names에 실명을 채운 뒤 "
"재실행하면 회의록에 실명이 반영됩니다.")
# ④ LLM 요약 (2종 비교) / dual summarization
if cfg.run_local_llm:
try:
print("\n[요약] 로컬 vLLM/SGLang ...")
local_md = summarize_local(transcript, cfg)
path = f"{args.out}_summary_local.md"
with open(path, "w", encoding="utf-8") as f:
f.write(local_md)
print(f"✓ 로컬 요약 저장: {path}")
except Exception as e:
print(f"! 로컬 요약 실패: {e}")
if cfg.run_claude:
try:
print("\n[요약] Claude API ...")
claude_md = summarize_claude(transcript, cfg)
path = f"{args.out}_summary_claude.md"
with open(path, "w", encoding="utf-8") as f:
f.write(claude_md)
print(f"✓ Claude 요약 저장: {path}")
except Exception as e:
print(f"! Claude 요약 실패: {e}")
print("\n완료. 두 요약본을 나란히 비교해 보세요.")
if __name__ == "__main__":
main()