Local-first meeting recorder for macOS: records both sides of any call, transcribes on-device, and drops an AI summary into your Obsidian vault. A DIY replacement for Granola that you fully own.
Everything runs locally except the summary step, which uses your own claude CLI subscription. No accounts, no cloud storage, no telemetry. Your meetings never leave your machine.
While recording, a pill floats above every app and every Space - full-screen Zoom included - so a forgotten session can't quietly run for hours:
system audio ──(Core Audio process tap)──┐
├─→ two CAF tracks ─→ ffmpeg ─→ 16k WAV
microphone ──(AVAudioEngine)─────────────┘
─→ mlx-whisper (large-v3-turbo, on-device)
─→ sherpa-onnx speaker diarization
─→ transcript.md ("Me" / "Speaker N")
─→ claude CLI summary ─→ note.md ─→ Obsidian
Two separate tracks mean speaker separation comes for free: the mic track is you, the system track is everyone else (further split by diarization).
The app also watches the default input device (Core Audio HAL listener, no polling): when any process grabs the microphone - Zoom, Meet in a browser tab, FaceTime, Telegram - a small floating panel offers to start recording. Granola-style, but local.
- macOS 14.4+ on Apple Silicon (the process-tap API and mlx-whisper are both hard requirements)
ffmpeg(brew install ffmpeg)- Python 3.11+ with
mlx_whisper,sherpa_onnx,soundfile,certifi - A logged-in
claudeCLI for summaries (subscription; the pipeline works without it - you just get a transcript instead of a note) - Diarization models (see below)
git clone <this repo> ~/echo && cd ~/echo
pip3 install mlx_whisper sherpa_onnx soundfile certifi
bash build.sh # builds Echo.app with swiftc, no Xcode project
open Echo.appGrant Microphone and Screen & System Audio Recording in System Settings → Privacy & Security, then relaunch the app (TCC applies on next launch).
Diarization models go into models/:
sherpa-onnx-pyannote-segmentation-3-0/- from sherpa-onnx releases (note: the release tag is literallyspeaker-recongition-models, typo included)nemo_en_titanet_small.onnx- same place
Optional: copy config.env.example to config.env to get notes copied into an Obsidian vault and clickable deep links.
macOS ties audio permissions to the app's code signature. With the default ad-hoc signature every rebuild changes the app's identity, and your granted permissions silently stop applying: the tap "works" but writes pure silence (-91 dB). If you plan to rebuild, create a self-signed code-signing certificate once (Keychain, 10 years) and build with SIGN_ID="My Cert Name" bash build.sh - permissions then survive rebuilds.
Also: launch the app from Finder, not from a terminal - otherwise TCC attributes the audio capture to your terminal.
Type a meeting title, hit record. Stop when done - transcription and summary run automatically (a 1-hour meeting takes ~6 minutes on an M-series Mac). Click a finished meeting to open its note.
While recording, a pill floats above everything - including full-screen Zoom and every Space - showing the elapsed time and a Stop button; the menu bar carries the same timer. If you forget anyway, Echo stops itself after 15 minutes of silence in the system track (AUTOSTOP_SILENCE_MIN in config.env, 0 to disable). This listens to the system track only, so anything playing audio - music, a video - keeps the recording alive.
Raw CAFs are deleted once the transcript is written: they cost ~1 GB per hour per track and the text is what you keep. The 16 kHz WAVs stick around for a couple of days (KEEP_WAV_DAYS), because re-running the pipeline on real audio is the only way to tune diarization and the filters. If the transcript came out empty - whisper crashed, or the recording held no speech - all the audio stays put, because then it's your only way to retry.
CLI alternative without the app: ./record_meeting.sh "Title" (Enter stops; uses the compiled rec_system helper - build it with swiftc -O legacy/rec_system.swift -o rec_system).
| File | Role |
|---|---|
EchoApp.swift |
The whole app: UI, recorder, call detection, popup |
finish_session.sh |
Post-stop pipeline: caf→wav→transcript→note |
transcribe_merge.py |
mlx-whisper both tracks + merge with speaker labels |
diarize.py |
sherpa-onnx segmentation + speaker embeddings |
process_meeting.py |
claude CLI summary → note.md → Obsidian |
pick_mic.sh, record_meeting.sh |
CLI recording path |
legacy/ |
Minimal reference implementations (menu-bar app, standalone tap) |
- Speaker count auto-detection degrades on long recordings (a 2-hour workshop produced "Speaker 132"), and diarization is the slowest step by far
- Whisper hallucinates on non-speech audio - hold music makes it emit "Thank you." every 30 seconds with
no_speech_prob = 0.000, so its own thresholds never fire. Filtered by text density (speech runs 5-20 chars/sec, filler 0.2-0.7) and bycompression_ratiofor repetition loops. The cost: a lone "yeah" inside a long noisy stretch gets dropped with the noise - Browser-tab calls are detected (mic busy), but the meeting title can't be prefilled from a tab
- Russian-first: UI strings and summary prompt are in Russian - fork and translate, it's all in two files
MIT

