Automatically record and transcribe meetings on macOS using ffmpeg and local Whisper transcription. Integrates with MeetingBar for hands-free recording — starts when you join a meeting, transcribes when you leave.
- Automatic recording via ffmpeg from a virtual audio device, triggered by MeetingBar or Raycast
- Local transcription using whisper.cpp — no cloud services, fully private
- Speaker diarization (optional) — labels who said what using pyannote + a personal speaker library
- Intelligent model selection — uses the large model for important meetings, medium for standups/syncs
- Hallucination detection — automatically detects and retranscribes Whisper context-loop artifacts
- Calendar integration — names recordings from your calendar event, not the system clock
- Zoom chat capture — appends saved Zoom team chat to the meeting note
- Markdown output — transcripts saved as markdown with YAML frontmatter, ready for Obsidian
- Meeting intelligence (optional) — runs a Claude agent to generate AI summaries after transcription
- macOS (tested on Sonoma/Sequoia)
- Homebrew
- MeetingBar (optional, for automatic start/stop)
- Raycast (optional, for manual start/stop via launcher)
git clone https://github.com/willfanguy/meeting-recorder.git
cd meeting-recorder
./install.shThe install script will:
- Install ffmpeg (audio recording and format conversion)
- Install BlackHole 2ch (virtual audio device for capturing system audio)
- Install whisper-cpp (local transcription engine)
- Download Whisper models
- Create your config file
After installation, reboot your Mac for BlackHole to load.
The transcription script uses larger quantized models for better accuracy. Download them after install:
MODEL_DIR="$HOME/.local/share/whisper-models"
# Large model (recommended — used by default for most meetings)
curl -L -o "$MODEL_DIR/ggml-large-v3-q5_0.bin" \
"https://huggingface.co/ggerganov/whisper.cpp/resolve/main/ggml-large-v3-q5_0.bin"
# Medium model (used for routine meetings like standups/syncs)
curl -L -o "$MODEL_DIR/ggml-medium-q5_0.bin" \
"https://huggingface.co/ggerganov/whisper.cpp/resolve/main/ggml-medium-q5_0.bin"
# VAD model (optional — voice activity detection for silence filtering)
curl -L -o "$MODEL_DIR/ggml-silero-v6.2.0.bin" \
"https://huggingface.co/ggerganov/whisper.cpp/resolve/main/ggml-silero-v6.2.0.bin"At minimum, you need either the large or medium model. The base model installed by install.sh is a fallback only.
You need to create two virtual audio devices in Audio MIDI Setup (in /Applications/Utilities/) so that ffmpeg can capture both your microphone and meeting audio simultaneously.
This sends meeting audio to both your speakers AND BlackHole for recording.
- Open Audio MIDI Setup
- Click + at bottom left → "Create Multi-Output Device"
- Check your speakers/headphones AND "BlackHole 2ch"
- Right-click → Rename to "Meeting Output"
- Enable "Drift Correction" for BlackHole
This combines your microphone with BlackHole so ffmpeg captures both sides of the conversation.
- Click + → "Create Aggregate Device"
- Check your microphone AND "BlackHole 2ch"
- Right-click → Rename to "Meeting Recording Input" (exact name required)
- Enable "Drift Correction" for BlackHole
Set your meeting app's speaker output to "Meeting Output" (the Multi-Output Device):
- Zoom: Settings → Audio → Speaker → "Meeting Output"
- Google Meet: Set "Meeting Output" as your system default before joining
- Teams: Settings → Devices → Speaker → "Meeting Output"
You'll need to grant microphone and accessibility permissions:
- Microphone for Terminal (or whichever app triggers the recording scripts) — System Settings → Privacy & Security → Microphone
- Accessibility for the app triggering the scripts:
- If using MeetingBar: add MeetingBar
- If using Raycast: add Raycast
- If running from Terminal: add Terminal
macOS will prompt you the first time. If recording fails silently, check these permissions.
Scripts use $HOME-based paths with sensible defaults. To customize, you can either edit the variables at the top of each script or set environment variables:
MEETING_RECORDER_DIR— override the repo location (default:$HOME/Repos/meeting-recorder)CLAUDE_BIN— path to your Claude Code binary (default: auto-detected viawhich claude)
Paths that need personalizing (edit directly in the scripts):
dailyNotesFolderinquicktime-stop-recording.applescript— your Obsidian daily notes folderMEETING_NOTES_DIRintranscribe-and-process.sh— where meeting note markdown files are created
See config.example.sh for a reference of all configurable values.
MeetingBar triggers recording automatically when you join/leave calendar events.
Setup:
- Open MeetingBar preferences
- Go to the Advanced tab
- Under "Run AppleScript", set the event start script to:
~/path/to/meeting-recorder/scripts/eventStartScript.applescript
This script saves meeting metadata and starts ffmpeg recording.
To stop recording, use Raycast or run the stop script manually. MeetingBar's "leave" event is unreliable for triggering scripts.
Raycast: Import the scripts from the raycast/ folder. Then use:
- "Start Meeting Recording" — starts ffmpeg recording
- "Stop Meeting Recording" — stops recording and triggers transcription
Command line:
# Start recording
osascript scripts/quicktime-start-recording.applescript
# Stop recording and transcribe
osascript scripts/quicktime-stop-recording.applescriptIf something goes wrong:
# Kill ffmpeg and save what was captured
./raycast/force-close-recording.shEach meeting creates:
- Audio file:
~/Meeting Transcriptions/2025-03-04 1400 - Meeting Name.m4a - Transcript:
~/Meeting Transcriptions/2025-03-04 1400 - Meeting Name.txt - SRT subtitles:
~/Meeting Transcriptions/2025-03-04 1400 - Meeting Name.srt - Meeting note:
~/YOUR_NOTES_DIR/2025-03-04 1400 - Meeting Name.md
Files use the format YYYY-MM-DD HHmm - Title where:
- Date/time comes from the calendar event's start time (not when recording started)
- Title is sanitized (special characters removed, truncated to 80 chars)
This convention ensures filenames match wiki-links created by other tools that reference calendar events.
---
title: "Weekly Sync"
date: 2025-03-04
time: "14:00"
event_id: "recording-20250304140000"
status: transcribed
recording: "/path/to/recording.m4a"
srt: "/path/to/subtitles.srt"
attendees: []
tags:
- meeting
---
## Transcript
[transcript content here]The transcribe-and-process.sh script handles post-recording processing:
- Format conversion — converts the m4a recording to 16kHz mono WAV for Whisper
- Model selection — uses the medium model for routine meetings (standups, syncs, 1:1s) and the large model for everything else
- Long recording chunking — splits recordings over 30 minutes into 15-minute chunks to prevent Whisper context-loop hallucinations
- Transcription — runs whisper-cli with language and thread settings
- Hallucination detection — checks for repeated lines and retranscribes affected segments
- Domain corrections — applies a customizable dictionary of corrections for proper nouns and technical terms (see
scripts/domain-corrections.json) - Speaker diarization (optional) — labels speaker turns using pyannote; see Diarization below
- Meeting note creation — generates markdown with YAML frontmatter
- Zoom chat capture — searches for saved Zoom team chat files and appends them
- Meeting intelligence (optional) — spawns a Claude agent to add an AI summary
The pipeline can label who said what in transcripts using pyannote and a personal speaker library.
# Install diarization dependencies (one-time)
scripts/setup-diarization.sh
# Create config.sh with your HuggingFace token
echo 'HF_TOKEN=your-token-here' > config.shA HuggingFace account and access to pyannote/speaker-diarization-3.1 is required.
After any meeting, enroll speakers from the diarization output:
scripts/.venv/bin/python scripts/enroll-speakers.py \
--diarization-json "path/to/recording.diarization.json" \
--assign "Speaker A=Alice Smith" "Speaker B=Bob Jones"Once enrolled, future meetings automatically identify those voices.
If the venv, HuggingFace token, or model is missing, diarization is silently skipped — the unlabeled transcript continues through the pipeline without errors.
If you have Claude Code installed, the transcription script can automatically run a meeting intelligence agent that:
- Summarizes key decisions and action items
- Enriches the note with context from Slack, JIRA, and other sources
To enable: set RUN_MEETING_INTELLIGENCE="true" in the script (enabled by default). Requires:
- Claude Code CLI installed and available on
$PATH(or setCLAUDE_BIN) - A
meeting-intelligence-processoragent definition in~/.claude/agents/
To disable: set RUN_MEETING_INTELLIGENCE="false" or simply don't install Claude Code.
- Check Terminal (or your launcher) has Microphone permission in System Settings
- Check the triggering app has Accessibility permission
- Look at the log:
cat /tmp/meeting-recorder.log - Verify the aggregate device exists:
ffmpeg -f avfoundation -list_devices true -i "" 2>&1 | grep -i meeting
- Verify "Meeting Recording Input" exists in Audio MIDI Setup with the exact name
- Make sure your meeting app's speaker is set to "Meeting Output"
- Check BlackHole is included in the Aggregate Device
- Test:
ffmpeg -f avfoundation -list_devices true -i "" 2>&1
This is Whisper hallucinating on silence or very quiet audio. The script detects this automatically and retranscribes, but if it persists:
- Check the audio has speech:
ffmpeg -i file.m4a -af volumedetect -f null - - If max_volume is below -40dB, the recording captured silence
- Verify your meeting app's speaker output is "Meeting Output" (not your regular speakers)
Check Whisper models exist:
ls -la ~/.local/share/whisper-models/You need at least one of ggml-large-v3-q5_0.bin or ggml-medium-q5_0.bin. See Additional Whisper models above.
A previous recording wasn't stopped cleanly. Use the force-close script:
./raycast/force-close-recording.shscripts/
eventStartScript.applescript # MeetingBar handler — saves metadata, starts recording
quicktime-start-recording.applescript # Starts ffmpeg recording from aggregate device
quicktime-stop-recording.applescript # Stops recording, saves file, triggers transcription
transcribe-and-process.sh # Full post-processing pipeline
save-meeting-metadata.py # Saves MeetingBar event data as JSON
domain-corrections.json # Customizable post-transcription corrections dictionary
apply-domain-corrections.py # Applies domain corrections to transcripts
diarize-transcript.py # Speaker diarization (pyannote)
enroll-speakers.py # Enroll known speakers into the library
speaker_library.py # Speaker identification from embeddings
setup-diarization.sh # One-time diarization environment setup
start-live-transcript.sh # Start real-time transcript (yap)
stop-live-transcript.sh # Stop live transcript
raycast/
start-meeting-recording.sh # Raycast command — start recording
stop-meeting-recording.sh # Raycast command — stop recording
force-close-recording.sh # Raycast command — emergency stop
open-live-transcript.sh # Raycast command — open live transcript in Obsidian
extract-meeting-tasks.sh # Raycast command — extract action items via Claude
archive/ # Old QuickTime UI automation scripts (deprecated)
Apache 2.0 — see LICENSE