Skip to content
Search

Meeting minutes pipeline.py

lastmod 2026-07-02created 2026-07-022 edits

Summarizes how to create meeting minutes labeled with each speaker's name, like Notion AI, from a single recording file. The core is a three-stage pipeline of speaker diarization + Korean: 음성 인식(STT/ASR) + summarization(LLM), and high-quality results can be obtained using only local open source.

Full pipeline

 녹음 파일 (.wav/.m4a)
     ↓
 ① STT (음성 → 텍스트 + 타임스탬프)   → WhisperX / faster-whisper
     ↓
 ② Diarization (누가 언제 말했나)      → pyannote.audio
     ↓
 ③ 정렬 (텍스트 ↔ 화자 매칭)          → WhisperX가 통합 처리
     ↓
 ④ 화자 이름 매핑 (SPEAKER_00 → 홍길동)
     ↓
 ⑤ 요약/액션아이템 추출 (LLM)         → 로컬 vLLM/SGLang or Claude API
     ↓
 회의록 (Markdown / MediaWiki)

The combination that takes the least effort while giving good quality is WhisperX alone. It handles Whisper STT + pyannote diarization + word-level alignment in one go.

Tool

Role

Notes

WhisperX

All-in-one STT + diarization + alignment

Most recommended

pyannote.audio 3.1

Speaker diarization only

Requires a HuggingFace token (free)

faster-whisper

STT only (fast)

Based on CTranslate2, excellent GPU efficiency

whisper.cpp

STT (lightweight)

For CPU/lightweight environments

For Korean-language meetings, using the Whisper large-v3 model is better for accuracy.

Pipeline stages

①~③ STT · alignment · speaker diarization

WhisperX handles these together. For Korean, fix language="ko"; the alignment stage automatically uses the kresnik/wav2vec2-large-xlsr-korean model. If GPU memory runs short, set compute_type to int8 and reduce batch_size.

④ Speaker name mapping (SPEAKER_00 → real name)

This is where it differs from Notion. Diarization only distinguishes "speaker A/B/C" and cannot match real names automatically. There are three ways to attach real names.

  1. Manual mapping (most reliable) — listen to the beginning of the result, identify who SPEAKER_00 is, and substitute using a dictionary
  2. Speaker registration(voice enrollment) — register voice samples of attendees in advance and match embeddings (using speaker embeddings from pyannote/SpeechBrain)
  3. Using self-introductions — parse utterances like "I am Hong Gil-dong" with an LLM to map automatically

⑤ LLM summarization

Feed the transcript into an LLM to extract the summary, decisions, and action items. Use local vLLM/SGLang infrastructure, or the Claude API if you want quality.

Local LLM vs Claude API comparison

Item

Local vLLM/SGLang

Claude API

Cost

Electricity only (free)

Per-token billing

Data

No external leak (safe for internal/partner meetings)

Sent externally

Korean summary quality

Depends on model choice (Qwen2.5-72B, EXAONE-3.5, etc.)

Top tier, stable

Long meetings (1~2 hours)

Watch out for model context limits

Plenty of room with 200K context

Suitability for DGX Spark (128GB)

Runs 70B-class int4/int8 comfortably

Not applicable

Use local for sensitive content (partner meetings, etc.), and Claude when you need a quality benchmark. Running both on the same transcript at the same time and comparing them side by side is recommended.

Installation

pip install whisperx anthropic openai

The pyannote models require a one-time usage agreement on HuggingFace.

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

Usage flow

Running in two stages is recommended (with 4 or more speakers, real-name mapping is key).

  1. First run → outputs the transcript with SPEAKER_00~03 labels. Skim the beginning to identify speakers
  2. Fill in real names in the mapping dictionary and rerun → real names applied + two summaries generated (local/Claude)

Accuracy tips for 4 or more speakers

  • If you know the exact number of speakers, specify num_speakers. Diarization errors drop significantly compared to a min/max range.
  • Sections with a lot of overlapping speech mix up speakers. Mapping becomes easier if everyone introduces themselves once at the start of the meeting.
  • Local summarization models may exceed the context when meetings are long (1~2 hours). With a DGX Spark 128GB, Qwen2.5-72B int4/int8 works fine, and the EXAONE-3.5 family sometimes produces more natural Korean summaries, so swap models and compare.

Fully integrated alternatives

If setting up the pipeline is a hassle, there is also open source that comes with a web UI.

  • Whishper — self-hosted web UI, transcription + translation, Docker deployment
  • Meetily / meeting-minutes family — specialized for meeting minutes (includes summarization)

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

author Zer0 sitemap