본문으로 건너뛰기

© 2026 Molayo

Qiita헤드라인2026. 06. 08. 12:51

Mac 로컬 환경에서 완결되는 「녹음 → 받아쓰기 → 화자 분리 → 요약 → Notion 자동 게시」 시스템 구축

요약

보안을 위해 외부 클라우드 API를 사용하지 않고, Mac 로컬 환경에서만 작동하는 음성 녹음-받아쓰기-화자 분리-요약-Notion 자동화 파이프라인 구축 방법을 소개합니다.

핵심 포인트

  • Apple Silicon 최적화를 위해 mlx-whisper와 faster-whisper 이중 구조 채택
  • pyannote.audio를 활용한 로컬 화자 분리(Diarization) 구현
  • Ollama를 이용해 텍ext 데이터 유출 없는 로컬 LLM 요약 수행
  • ffmpeg와 Python을 활용한 데이터 정규화 및 자동화 워크플로우

회의 받아쓰기는 클라우드 받아쓰기 서비스를 사용하면 순식간에 끝납니다. 하지만 업무 회의에는 외부로 유출하고 싶지 않은 정보가 보통 포함되어 있습니다. "편리하긴 하지만, 이 음성을 어떤 API로 보내는 것은 좀..."이라는 이유로 도입이 중단되는 경우는 흔한 일이라고 생각합니다.

그래서, 음성을 일절 외부로 보내지 않고, Mac 상에서만 완결되는 받아쓰기 파이프라인을 구축했습니다. 최종적으로는 다음과 같은 단계까지 자동화되어 있습니다.

  • 회의 녹음 파일 (mp3 / m4a / wav / ogg 등)을
  • 로컬에서 일본어 받아쓰기 수행 (faster-whisper / mlx-whisper)
  • 「누가 말했는지」를 추가 (pyannote.audio를 통한 화자 분리 (Diarization))
  • 로컬 LLM (Ollama)으로 요약
  • 녹음을 집약하고 있는 Notion 페이지에 요약과 전문을 자동으로 추가

받아쓰기, 화자 분리, 요약의 모든 과정이 로컬 실행이며, 외부 통신은 모델의 최초 다운로드를 제외하면 발생하지 않습니다 (요약도 자신의 Mac에 있는 LLM으로 돌립니다).

  • macOS (Apple Silicon. 이번에는 Mac mini M4 Pro / 64GB로 운용)
  • Python 3.12 (후술하겠지만, GUI의 드래그 & 드롭 편의성을 위해 3.13보다 3.12를 권장)
  • ffmpeg
  • 받아쓰기: faster-whisper (CPU) 또는 mlx-whisper (Apple GPU)
  • 화자 분리: pyannote.audio
  • 요약: Ollama + 로컬 LLM
[녹음 파일]
│ ffmpeg로 16kHz/모노럴/wav로 정규화
▼
...

CLI, GUI (드래그 & 드롭), Notion 자동 감시 (launchd로 1시간 간격)의 세 가지 입구를 마련했습니다.

OpenAI의 Whisper를 그대로 사용하는 것보다, 추론 (Inference)을 최적화한 구현을 사용하는 것이 더 빠릅니다. 대표적인 선택지는 두 가지가 있습니다.

faster-whisper (CTranslate2 백엔드): CPU에서 동작하며, 어떤 Mac에서도 작동합니다. 단, CTranslate2는 Apple GPU (Metal)를 지원하지 않아 Apple Silicon에서도 CPU로 실행됩니다.

mlx-whisper (Apple의 MLX 프ramework): Apple Silicon의 GPU를 사용할 수 있으므로, M 시리즈 칩에서는 확실히 빠릅니다.

동일한 large-v3를 사용하는 한 정밀도는 동등하므로 (같은 가중치를 구동할 뿐), 차이는 속도와 대응 환경뿐입니다. 그래서 "모든 Mac에 대응하는 faster-whisper를 기본으로 하되, Apple Silicon이라면 mlx로 전환할 수 있는" 이중 구조로 만들었습니다.

「누가 말했는지」를 추가하지 않으면, 여러 명이 참여하는 회의의 의사록으로는 사용하기 어렵습니다. pyannote.audio는 로컬에서 화자 분리 (Diarization)를 할 수 있는 대표적인 라이브러리입니다. 추론은 로컬에서 이루어지지만, 모델이 게이트(Gate) 방식으로 되어 있어 최초 1회 Hugging Face 인증이 필요합니다 (후술).

요약만 클라우드 API로 보내버리면 "음성은 지켰지만 텍스트는 외부로 나갔다"가 되어 본말전도입니다. 요약도 로컬에서 완결시키기 위해, Ollama로 로컬 모델을 사용합니다. 64GB 사양의 머신이라면, MoE 계열의 30B 클래스 (Active 3B)가 속도와 일본어 품질의 밸런스 측면에서 실용적이었습니다.

# Homebrew
brew install ffmpeg
brew install python@3.12 python-tk@3.12 # python-tk는 GUI (Tkinter)용
...

requirements.txt:

faster-whisper>=1.0.0
pyannote.audio>=3.1
tkinterdnd2>=0.4.0
...
pip install -r requirements.txt
pip install mlx-whisper # Apple Silicon인 경우

pyannote 모델은 게이트 방식입니다. 추론 자체는 로컬에서 수행되지만, 최초 다운로드 시 Hugging Face 토큰과 이용 조건 동의가 필요합니다. 아래 페이지에서 「Agree and access repository」를 누릅니다.

pyannote/speaker-diarization-3.1

pyannote/segmentation-3.0

pyannote/speaker-diarization-community-1

(pyannote.audio 4.x 계열에서 필요)

토큰은 hf auth login 명령어로 저장합니다.

hf auth login --token "your_hf_token"

토큰은 ~/.cache/huggingface/에 저장되며, 이후에는 라이브러리가 자동으로 불러옵니다 (환경 변수 영속화는 불필요).

보충: 오래된 huggingface-cli login은 권장되지 않으며 (deprecated), huggingface_hub v1.0에서 삭제되었습니다. 현재 명령어는 hf auth login입니다. 한 번 다운로드되면 이후에는 오프라인으로 작동합니다.

brew install ollama
brew services start ollama
ollama pull qwen3:30b-a3b-instruct-2507-q4_K_M # 예시. 환경에 맞는 모델을 선택하세요

요약 용도로는 「사고 토큰(thought token)을 내보내지 않는 instruct 계열」이 레이턴시 (latency)가 작아 다루기 쉽습니다.

Whisper 계열은 16kHz · 모노럴 (monaural) · PCM wav를 입력으로 사용하는 것이 무난합니다. 어떤 입력이든 우선 이것에 맞춥니다.

import subprocess
from pathlib import Path
def convert_to_wav(src: Path, dst: Path) -> None:
...
def load_model(model_name: str, device: str, compute_type: str):
from faster_whisper import WhisperModel
return WhisperModel(model_name, device=device, compute_type=compute_type)
...

Apple Silicon에서의 CPU 설정은 device="cpu", compute_type="int8"가 현실적인 최적해입니다. int8 양자화 (quantization)를 통해 메모리와 속도가 개선되며, 정확도 저하는 매우 미미합니다.

MLX 버전은 동일한 형식의 세그먼트 (segment)를 반환하도록 래핑 (wrapping)해 두면, 후속 단계 (출력 · 화자 분리)를 공통화할 수 있습니다.

MLX_MODEL_REPOS = {
"small": "mlx-community/whisper-small-mlx",
"medium": "mlx-community/whisper-medium-mlx",
...

주의: mlx-whisper는 결과를 일괄적으로 반환하기 때문에, faster-whisper와 같은 순차적인 진행률(%)은 표시할 수 없습니다.

verbose=True로 설정하면 인식 결과가 순차적으로 표시되므로, 이를 진행률 대신 사용하고 있습니다.

이 부분은 버전 차이로 인해 문제(ハマり)가 발생하기 쉬우므로, 후술할 「주의할 점」과 함께 읽어주세요. 요점만 먼저 말씀드립니다.

def load_diarization_pipeline(hf_token):
from pyannote.audio import Pipeline
model_id = "pyannote/speaker-diarization-3.1"
...

텍스트 변환(transcription)의 각 세그먼트에 시간적으로 가장 많이 겹치는 화자를 할당합니다.

def assign_speakers(segments, turns):
for seg in segments:
start, end = seg["start"], seg["end"]
...

회사명 · 인명 · 제품명 · 전문 용어는 오변환되기 쉽습니다. Whisper의 initial_prompt에 이것들을 전달하면 개선됩니다. 매번 수동으로 입력하는 것은 번거로우므로, 고유명사.txt (한 줄에 한 단어)를 두어 자동으로 읽어오도록 했습니다.

from pathlib import Path
GLOSSARY_FILE = Path(__file__).resolve().parent / "고유명사.txt"
def load_glossary():
...

이것만으로도 「전문 용어가 가타카나식 표기로 잘못 변환되는」 계열의 실수가 상당히 줄어듭니다.

용도에 맞춰 4가지 형식을 출력합니다. 화자 라벨이 있으면 각 행의 맨 앞에 붙입니다.

def speaker_prefix(seg):
return f"[{seg['speaker']}] " if seg.get("speaker") else ""
def write_srt(segments, path):
...

제가 처음으로 고생했던 부분이 바로 여기입니다. **"받아쓰기(Transcription)는 성공했는데, 다음 단계인 화자 분리(Speaker Diarization)에서 에러 발생 → 전부 처음부터 다시"**를 한 번 겪게 되면, 25분짜리 음성이라도 은근히 진이 빠집니다.

대책으로 "받아쓰기가 끝난 시점에 우선 저장 → 화자 분리 → 성공하면 화자 정보가 포함된 내용으로 덮어쓰기"라는 순서로 구성했습니다. 화자 분리에서 실패하더라도 받아쓰기 결과는 남아 있으며, 저장된 JSON을 재사용하여 화자 분리 단계만 다시 수행할 수 있습니다.

def try_load_existing_segments(json_path, src):
    """기존 JSON이 존재하고, 동일한 음성 파일에서 유래했다면 받아쓰기 결과를 재사용한다."""
    if not json_path.exists():
        ...

장시간 처리 프로세스를 설계할 때는 "무거운 작업 직후에 중간 저장"을 넣어두면 나중에 매우 도움이 됩니다.

CLI(Command Line Interface)만 사용하면 일상적으로 쓰기에 번거롭기 때문에, Tkinter를 사용하여 간단한 GUI도 준비했습니다. 파일을 드롭하면 자동으로 처리가 시작되며, 완료된 항목을 클릭하면 전체 내용이 클립보드에 복사됩니다.

Tkinter는 스레드 세이프(Thread-safe)하지 않으므로, 무거운 작업은 별도의 스레드로 넘기되, GUI 위젯의 값(모델 선택 등)은 메인 스레드에서 읽은 후 워커(Worker)에게 전달하는 것이 포인트입니다. 이를 소홀히 하면 main thread is not in main loop 에러와 함께 프로그램이 종료됩니다.

# ❌ 워커 스레드 내에서 self.model_var.get()을 호출하면 에러 발생
# ⭕ 메인 스레드에서 읽어서 dict로 전달
opts = {
    ...
}

마지막으로 자동화입니다. 녹음 장치의 컴패니언 앱이 녹음 파일을 Notion의 특정 페이지 하위에 서브 페이지로 자동 업로드해 주므로, 이를 모니터링합니다. 1시간마다 새로운 서브 페이지를 찾아 미처리 상태라면 다운로드하여 처리하고, 그 결과를 해당 페이지에 추가합니다.

요약은 Ollama의 로컬 API를 호출합니다.

import requests

def summarize(transcript, model, ollama_url="http://localhost:11434"):
    prompt = f"""당신은 우수한 회의록 작성자입니다. 다음 받아쓰기 내용을 일본어로 요약해 주세요.
    ..."""

중복 처리를 방지하기 위해 처리 완료된 페이지 ID를 상태 파일에 기록하는 동시에, Notion 페이지 측에도 "요약(자동 생성)" 헤딩이 있는지 체크합니다(상태 파일을 삭제하더라도 중복 게시되지 않도록 하기 위함).

Notion API에는 "1회 요청당 자식 블록 최대 100개", "1개 텍스트 최대 2000자"라는 제한이 있으므로, 긴 문장은 분할해서 보냅니다.

def chunk_text(text, size=1800):
    chunks, buf = [], ""
    for line in text.splitlines(keepends=True):
        ...

cron을 사용해도 좋지만, macOS이므로 launchd를 사용했습니다. ~/Library/LaunchAgents/에 plist 파일을 두고 StartInterval을 3600으로 설정하기만 하면 됩니다.

<key>StartInterval</key>
<integer>3600</integer>
<key>RunAtLoad</key>
...
cp com.example.notion-watch.plist ~/Library/LaunchAgents/
launchctl load ~/Library/LaunchAgents/com.example.notion-watch.plist

주의 사항:

launchd는 Mac이 슬립(Sleep) 모드일 때는 실행되지 않습니다. 상시 구동하려면 "시스템 설정 → 에너지"에서 슬립하지 않도록 설정해 두어야 합니다.

faster-whisper의 백엔드인 CTranslate2는 Metal을 지원하지 않습니다. "Apple Silicon이니까 빠를 것이다"라고 생각할 수 있지만, faster-whisper에서는 CPU로 실행됩니다. GPU를 사용하고 싶다면 mlx-whisper(또는 whisper.cpp)로 전환해야 합니다. 정밀도는 동일한 가중치(Weight)를 사용하므로 차이가 없습니다.

Python 3.13 계열은 Tcl/Tk 9.0에 링크되어 있어, tkinterdnd2에 포함된 tkdnd(Tcl 8.6용 바이너리)가

RuntimeError: Unable to load tkdnd library
(interpreter uses an incompatible stubs mechanism)

로 불러올 수 없습니다. 회피 방법은 다음과 같습니다.

  • 드래그 앤 드롭 (D&D)을 포기하고 파일 선택 버튼으로 폴백 (Fallback) 처리하기 (코드 측에서 예외 처리)
  • 또는
    Tcl/Tk 8.6 계열의 Python (3.12 등)으로 환경을 다시 구축하기

GUI를 사용한다면 솔직히 3.12를 선택하는 것이 더 편했습니다.

3.x 계열의 기사를 참고하며 작성하면, 4.x 계열에서 두 군데 정도 막히게 됩니다.

Pipeline.from_pretrained(..., use_auth_token=...)

→ 4.x는 token=... 로 변경되었습니다.

pipeline(wav)

의 반환값이 Annotation이 아니라 DiarizeOutput이 되며, .itertracks() 메서드가 없습니다. 실제 데이터는 .speaker_diarization 속성에 들어 있습니다.

  • 4.x 계열은 추가로 pyannote/speaker-diarization-community-1에 대한 게이트 동의 (Gate agreement)도 필요합니다.

앞서 언급한 코드처럼 두 버전 모두에 대응하는 폴백 (Fallback) 로직을 작성해 두면 안정적입니다.

large-v3 모델은 int8 양자화(Quantization)를 적용해도 수 GB를 차지하며, 화자 분리 (Speaker Diarization)를 동시에 실행하면 용량이 더 늘어납니다. 메모리가 부족한 머신에서는 받아쓰기 (Transcription)와 화자 분리를 나누어 실행하거나, 모델을 medium/small로 낮추는 등의 조정이 필요합니다. 요약용 로컬 LLM (Large Language Model)을 포함하여 동시에 무엇을 올리느냐에 따라 메모리 설계가 달라집니다.

가감 없이 말씀드리자면, 받아쓰기 정확도는 녹음 음질에 따라 크게 달라집니다. 동일한 회의를 서로 다른 기기로 녹음하여 비교해 본 결과, 음질이 불분명한 녹음에서는 동일한 문장을 반복하는 현상(소위 할루시네이션 (Hallucination))이 눈에 띄게 증가했습니다. 마이크를 화자 가까이에 두거나 충분한 음량을 확보하는 등의 기본 원칙이 모델 선정보다 더 효과적일 때가 많습니다.

  • 받아쓰기, 화자 분리, 요약을 모두 로컬에서 완결하면, 기밀 회의 음성을 외부로 유출하지 않고도 회의록을 작성할 수 있습니다.
  • Apple Silicon에서 GPU를 활용하려면 faster-whisper 대신 mlx-whisper를 사용하세요. 정확도는 동일하지만 속도만 달라집니다.
  • pyannote.audio는 버전 차이(특히 4.x)에 따라 API가 변경되므로, 폴백 (Fallback) 로직을 작성해 두면 안정적입니다.
  • 장시간 처리 시에는 "무거운 작업 직후 중간 저장" 기능을 넣어두면, 후반 작업 실패로 인해 처음부터 다시 해야 하는 비극을 방지할 수 있습니다.
  • 자동화는 Notion API + 로컬 LLM + launchd를 조합하여, 녹음 업로드부터 회의록 게시까지 사람의 손길 없이 처리할 수 있습니다.

로컬에서 모든 과정이 완결되므로, 외부로 보낼 수 없는 음성이라도 안심하고 받아쓰기를 할 수 있다는 점이 가장 큰 장점이었습니다. 같은 고민을 가진 분들에게 도움이 되기를 바랍니다.

AI 자동 생성 콘텐츠

본 콘텐츠는 Qiita AI의 원문을 AI가 자동으로 요약·번역·분석한 것입니다. 원 저작권은 원저작자에게 있으며, 정확한 내용은 반드시 원문을 확인해 주세요.

원문 바로가기
0

댓글

0