Skip to content

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_typeint8로, batch_size를 축소한다.

④ 화자 이름 매핑 (SPEAKER_00 → 실명)

노션과 차이가 나는 부분이다. Diarization은 "화자 A/B/C"까지만 구분하며 실명은 자동으로 맞추지 못한다. 실명을 붙이는 방법은 다음 세 가지다.

  1. 수동 매핑 (가장 확실) — 결과 앞부분을 듣고 SPEAKER_00이 누구인지 파악해 딕셔너리로 치환
  2. 화자 등록(voice enrollment) — 참석자 목소리 샘플을 미리 등록하고 임베딩 매칭 (pyannote/SpeechBrain의 speaker embedding 활용)
  3. 자기소개 활용 — "저는 홍길동입니다" 같은 발화를 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를 사용한다. 같은 트랜스크립트로 양쪽을 동시에 돌려 나란히 비교하는 방식을 권장한다.

설치

pip install whisperx anthropic openai

pyannote 모델은 HuggingFace에서 1회 사용 동의가 필요하다.

  • huggingface.co/pyannote/speaker-diarization-3.1
  • huggingface.co/pyannote/segmentation-3.0

사용 흐름

2단계로 실행하는 것을 권장한다 (화자 4명 이상은 실명 매핑이 핵심).

  1. 1차 실행 → 트랜스크립트에 SPEAKER_00~03 라벨로 출력. 앞부분을 훑어 화자 파악
  2. 매핑 딕셔너리에 실명을 채우고 재실행 → 실명 반영 + 로컬/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()

See also