MAR — macOS 메뉴바 회의 오디오 레코더
ScreenCaptureKit으로 회의 오디오를 녹음하고 로컬 Whisper로 회의록까지 만드는 macOS 메뉴바 앱. 전사 품질은 원하던 만큼 올라갔는데, 녹음과 전사와 요약을 직접 이어 붙여야 해서 요즘은 다시 Notion을 씁니다.
개요
MAR(Mac Audio Recorder)은 Zoom, Google Meet, Microsoft Teams 같은 회의 앱의 시스템 오디오를 녹음하고, 로컬 Whisper 추론으로 회의록까지 만드는 macOS 메뉴바 유틸리티입니다. Swift 5.9와 SwiftUI로 작성했고 macOS 14.0(Sonoma) 이상을 지원합니다. 라이선스는 MIT입니다.
Notion의 전사 기능을 쓰다가 결과가 아쉬워서, Whisper를 직접 돌리는 도구를 만들어보기로 한 것이 출발점이었습니다. 그런데 막상 시작하니 Whisper에 넣을 오디오 파일부터가 없었습니다. 회의는 Zoom과 Meet 안에서 소리로만 지나가고 아무것도 남기지 않습니다. 그래서 전사보다 녹음을 먼저 만들게 됐습니다.
회의 중에 창을 하나 더 띄우고 싶지 않아서 메뉴바에 상주하는 형태로 정했고, 그러려니 Swift와 SwiftUI로 가게 됐습니다.
주요 기능
- 시스템 오디오 녹음 — ScreenCaptureKit으로 전체 시스템 또는 특정 앱의 오디오만 선택 캡처
- 메뉴바 상주 —
LSUIElement=YES로 Dock에 표시되지 않으며 팝오버 UI로 조작 - 실시간 피드백 — 녹음 중 메뉴바 아이콘에 빨간 점, RMS 기반 레벨 미터
- 글로벌 단축키 — 기본
⌃⌥R로 앱 포커스 없이 녹음 토글, 설정에서 변경 가능 - 최근 녹음 접근 — 팝오버에서 최근 3개 확인, Finder에서 열기
- 자동 안전장치 — 타겟 앱 종료, 시스템 슬립, 디스크 여유 500MB 미만, 8시간 상한에서 자동 저장
- 출력 설정 — M4A/WAV 포맷과 48/44.1/16 kHz 품질 선택
- 로컬 Whisper 전사 — 인터넷 없이 Apple Silicon 가속으로
.md회의록 생성 - 화자분리 — pyannote 모델로 발화 구간을 화자별로 라벨링 (
develop브랜치)
기술 스택
| 영역 | 선택 |
|---|---|
| 언어 / UI | Swift 5.9+, SwiftUI |
| 최소 OS | macOS 14.0 (Sonoma) |
| 오디오 캡처 | ScreenCaptureKit (SCStream, SCShareableContent) |
| 파일 기록 | AVFoundation (AVAssetWriter) |
| 전사 / 화자분리 | argmax-oss-swift v1.0.0 (WhisperKit, SpeakerKit) |
| 글로벌 단축키 | sindresorhus/KeyboardShortcuts |
| 동시성 | Swift Concurrency (async/await, AsyncStream, Task.detached) |
| 테스트 | XCTest, 유닛 192 + 통합 3 |
아키텍처
MVVM에 프로토콜 기반 의존성 주입을 결합한 4계층 구조입니다.
View Layer (SwiftUI)
↓ @Published binding
ViewModel Layer (@MainActor)
↓ protocol (async throws)
Service Protocol Layer (12개 인터페이스)
↓
Real Implementation | Test Double (Fake*)
MARApp.init()이 컴포지션 루트로 실제 서비스를 생성해 ViewModel에 주입하고, 테스트는 같은 자리에
MARTests/Fakes/의 Fake를 주입합니다. 데이터 흐름은 단방향이며, 서비스에서 ViewModel로 올라오는
이벤트는 델리게이트 콜백 대신 AsyncStream으로 전달합니다. 녹음 상태는
idle → preparing → recording → finalizing → completed 상태 머신으로 관리합니다.
View와 ViewModel만 @MainActor이고 서비스 구현체는 각자의 직렬 큐에서 돌기 때문에
nonisolated final class로 선언합니다. 이 선언을 빠뜨리면 컴파일은 되는데 런타임에 isolation
assertion으로 죽습니다.
화면 녹화 권한과 코드 서명
macOS에서 다른 앱이 내는 소리를 받아 오는 공개 경로는 ScreenCaptureKit입니다. 이름 그대로 화면 캡처용 프레임워크라, 오디오만 가져와도 사용자에게 요구하는 권한은 화면 및 시스템 오디오 녹음입니다. 소리만 녹음하는 앱이 화면 녹화를 허용해 달라고 물어보는 셈이라, 첫 실행 온보딩에서 그 권한이 왜 필요한지 설명하고 시스템 설정을 바로 열어 주는 화면부터 만들어야 했습니다.
개발 중에는 더 성가신 일이 반복됐습니다. 권한을 허용해 두고 코드를 고쳐 다시 빌드하면 앱이 또 권한이 없다고 했습니다. TCC는 경로와 코드 서명으로 앱을 식별하는데, 서명 없는 ad-hoc 빌드는 재빌드마다 CDHash가 바뀝니다. macOS 입장에서는 매번 다른 앱이 같은 자리에 와서 같은 권한을 달라고 하는 것이니 정상 동작이었습니다.
그래서 /Applications에 설치되는 앱 타겟의 Release 구성에만 Apple Development 자동 서명을
걸었습니다. 여기서 한 번 더 걸렸는데, 같은 설정을 Debug나 테스트 타겟에 넣으면 번들에 임베드된
XCTest 프레임워크까지 재서명하려다 errSecInternalComponent로 실패합니다. 빌드가 아니라
xcodebuild test 자체가 죽습니다. 게다가 CI는 서명을 아예 끄고 돌기 때문에 이 회귀를 잡아 주지
못합니다. 초록 불을 믿고 있으면 로컬에서만 터지는 종류라 저장소의 CLAUDE.md에 하지 말아야 할
일로 적어 두었습니다.
전사 성능 최적화
녹음 파일을 만들어서 다른 도구에 넘기는 것까지가 원래 계획이었는데, 파일을 옮기고 업로드하는
단계가 남아 있으면 결국 Notion을 쓰던 때와 손이 비슷해집니다. 그래서
WhisperKit으로 전사까지 앱 안에 넣었습니다. 기본 모델은
large-v3-turbo이고 처음 전사할 때 1.5GB를 내려받습니다. 클라우드 Whisper API는 후속 과제가 아니라
명시적인 비목표로 적었습니다. 회의 오디오는 제 것만 담긴 파일이 아니라서, 어느 쪽이 편한지를
따지기 전에 기본값을 정해 두는 편이 낫다고 봤습니다.
초기 구현은 1시간 녹음에 30분 가까이 걸렸습니다. 병렬로 안 돌아가서라고 생각했는데, 라이브러리를
열어 보니 워커는 이미 macOS 기본값으로 16개였습니다. 문제는 DecodingOptions의
chunkingStrategy가 설정되지 않아 오디오가 단일 배열로 전달되고, 그래서 나눠 줄 조각이 없어 워커
16개가 놀고 있었다는 쪽이었습니다. 발표자가 5분을 침묵해도 그 5분이 그대로 추론에 들어가고
있었습니다.
chunkingStrategy: .vad를 켜자 Silero VAD가 발화 구간만 잘라 내고 기존 워커들이 알아서 붙었습니다.
그러자 문제가 속도에서 메모리로 옮겨 갔습니다. 16개가 동시에 활성화되면 16GB 맥에서 스왑이
시작됩니다. 결국 이 작업에서 제가 정한 값은 가속 배수가 아니라 상한이었습니다.
| 항목 | 변경 전 | 변경 후 |
|---|---|---|
chunkingStrategy | nil (30초 고정 윈도우) | .vad |
concurrentWorkerCount | 16 (기본값) | 1~4 (가용 메모리로 자동 결정) |
noSpeechThreshold | 0.6 | 0.7 |
| 1시간 녹음 소요 시간 | 약 30분 | 약 10~15분 (목표) |
워커 수는 전사를 시작할 때마다 가용 메모리를 다시 재서 결정하고, 설정에서 사용자가 4를 지정해도 그건 천장이지 보장이 아닙니다. 이런 안전장치는 조용히 깨지기 쉬워서, 1워커와 4워커가 같은 트랜스크립트를 내놓는지 검증하는 통합 테스트를 같이 넣었습니다. 빨라지는 대신 결과가 달라지면 켤 이유가 없습니다.
화자분리, 그리고 접은 기능
전사 결과에 화자 구분을 붙이는 작업은 그다음이었습니다. pyannote 모델이 11MB뿐이고 46분짜리 녹음을
8초에 훑어서 비용 대비로는 고민할 것도 없었습니다. 회의록은 [00:00:03] 화자 1: ... 형태가 됩니다.
원래는 그다음에 화자 1을 김팀장으로 바꾸는 화면을 만들 계획이었고, 스펙까지 써 두었다가 구현
직전에 지웠습니다. 자동으로 이름을 알아내는 쪽은 신뢰할 수가 없습니다. 회의 중에 나오는 이름은 대개
부르는 말이라 말한 사람이 아니라 듣는 사람을 가리키고, Whisper가 가장 약한 것이 고유명사이며, 정기
회의에는 자기소개가 없습니다. 그렇다고 손으로 바꾸는 화면을 만들면 정확하긴 한데 결국 .md 찾아
바꾸기를 위해 창 하나와 이름 저장소를 얹는 일이었습니다.
어느 쪽도 "화자 1이 누구인가"를 알아내는 일 자체는 줄여 주지 못한다는 게 접은 이유였습니다. 그건 회의록 앞부분을 몇 줄 읽으면 대개 풀립니다. 다시 볼 조건도 같이 적어 두었습니다. 회의록을 밖으로 공유하는 일이 잦아져서 매번 손으로 이름을 고치는 게 실제로 부담이 되면, 그때는 자동 추정이 아니라 수동 입력으로 갑니다.
테스트
전 영역에 TDD를 적용해 현재 195개를 유지하고 있습니다. 서비스가 모두 프로토콜로 추상화되어 있어 ViewModel 테스트에서는 Fake를 주입합니다. 실제 동작 검증이 필요한 두 영역만 따로 다룹니다.
AVAudioFileWritingServiceTests— 10초 사인파를 실제 AAC로 인코딩해 유효한 M4A인지 확인WhisperKitTranscribingIntegrationTests— tiny 모델(75MB)로 실제 추론, CI는actions/cache@v4로 모델 캐싱
화자분리는 실제 모델이 필요해 자동화 대신 docs/manual-test-checklist.md의 수동 항목으로
검증합니다. 46분 4인 회의 녹음으로 한 번 훑으면서 피크 메모리 1.3GB를 확인했고, 실제 참가자 4명에
화자 5명이 잡혔습니다. 튜닝하지 않고 그대로 두기로 했습니다.
배포와 현재 한계
GitHub Actions로 Release 빌드를 만들어 Releases 페이지에 올리며, 공개된 최신 버전은
v0.2.2입니다. 화자분리와 argmax-oss-swift v1.0.0
마이그레이션은 아직 develop에만 있고 릴리스로 내리지 않았습니다. 소스에서 빌드하면
scripts/install.sh가 Release 빌드 후 /Applications/MAR.app(7.8MB)에 설치합니다.
Developer ID 서명과 공증은 안 했습니다. 그래서 릴리스를 받아서 실행하면 macOS가 손상된 앱이라며
막고, xattr -dr com.apple.quarantine을 치거나 시스템 설정에서 한 번 열어 줘야 합니다. 릴리스
노트에 안내를 적어 두긴 했지만 남에게 권하기는 어려운 상태라는 것도 압니다.
마이크 믹싱도 아직 없습니다. 시스템이 스피커로 내보내는 소리만 담기 때문에 제 목소리는 녹음에 들어가지 않습니다. 상대방의 말은 다 남고 제 말만 빠지는 회의록이 됩니다.
다시 Notion을 쓰게 된 이유
전사 품질은 실제로 좋아졌습니다. 애초에 그게 만든 이유였고 그 부분은 됐습니다. 그런데 요즘은 다시
Notion을 많이 씁니다. MAR로 회의록을 만들려면 회의가 시작될 때 녹음을 켜야 하고, 끝나면 전사가
끝나기를 기다려야 하고, 요약은 .md를 열어서 따로 붙여야 합니다. Notion은 켜 두면 그 세 개가
이어져 있습니다. 회의가 끝난 직후에 제가 실제로 고르는 기준은 결과의 품질이 아니라 지금 손이 덜
가는 쪽이었습니다.
이게 이 프로젝트에서 제일 오래 남은 감각입니다. 아쉬운 결과를 고치려고 만들기 시작했는데, 정작 제가 손댈 수 있었던 건 결과의 품질뿐이었습니다. 남이 만든 도구의 진짜 강점은 품질이 아니라 단계가 이어져 있다는 것이었고, 그건 부품을 잘 만든다고 따라잡히지 않습니다. 다음에 무언가를 직접 만들 때는 무엇을 더 잘할지보다 어디까지 이어 붙일 건지를 먼저 정하려고 합니다.