Record, transcribe, diarize, summarize, and extract action items from any meeting — entirely on your Mac.
No cloud. No uploads. No API keys at inference time. No third-party recorder to install.
Note
Everything runs on your machine. Audio never leaves the device — capture, speech-to-text, speaker diarization, and the LLM summary all happen locally. You bring the models; MeetingNotes orchestrates the rest.
|
One click records any app's audio (Zoom, Teams, FaceTime, Slack…) mixed with your mic, via the macOS 14.2 CoreAudio Process Tap — no virtual audio device, no separate recorder. |
Local whisper.cpp transcription + pyannote diarization, then a local LLM writes a structured summary and pulls out committed action items with owners and due dates. |
Name voices once and they're recognized across meetings. A Weekly rollup stitches the week into a narrative with cross-meeting themes and your open action items. |
|
Auto-detect meetings in browsers and native apps, or fire |
Push action items to Apple Reminders, Google Tasks, or Google Docs; the summary to Markdown; or a JSON payload to any webhook (n8n, Zapier, Slack…). |
No SaaS, no telemetry, no API keys. Sandboxed Electron, zod-validated IPC, gated-model licenses cached offline. Your meetings stay yours. |
| Library | Recording in progress |
|---|---|
![]() |
![]() |
| Speaker-ID gate | Summary + action items |
![]() |
![]() |
MeetingNotes is an Electron app that orchestrates four local services, spawning each on demand and shutting them down when idle to keep RAM free.
flowchart LR
subgraph mac["🖥️ Your Mac · nothing leaves the device"]
direction LR
subgraph app["MeetingNotes.app · Electron"]
ui["Renderer · React<br/>library · detail · weekly · settings"]
main["Main process<br/>pipeline · storage · IPC · supervisors"]
end
swift["Swift helper<br/>audio capture"]
whisper["whisper.cpp<br/>STT :8080"]
sidecar["pyannote sidecar<br/>diarization :8765"]
llm["LM Studio / Ollama<br/>LLM :1234 / :11434"]
store[("SQLite + files<br/>~/Documents/MeetingNotes")]
end
ui <--> main
main -->|spawn on demand| swift
main -->|spawn on demand| whisper
main -->|spawn on demand| sidecar
main -->|managed lifecycle| llm
main <--> store
classDef svc fill:#1e293b,stroke:#475569,color:#e2e8f0
classDef data fill:#0f766e,stroke:#0d9488,color:#fff
class swift,whisper,sidecar,llm svc
class store data
Hit Record, and a Swift helper captures the chosen app + mic into a mixed M4A. Click Process, and each meeting flows through these stages. transcribe and diarize run in parallel; everything else is sequential.
flowchart TD
rec([⏺ Record]) --> tap["Swift helper<br/>CoreAudio Process Tap"]
tap -->|mixed M4A| watch[File watcher]
watch --> pending["Meeting row · pending"]
pending -->|▶ Process| prep["Decode → 16 kHz WAV<br/>ffmpeg, once · reused"]
prep --> stt["transcribe<br/>whisper.cpp :8080"]
prep --> dia["diarize<br/>pyannote sidecar :8765"]
stt --> merge[merge]
dia --> merge
merge --> id["identify<br/>voice embeddings vs roster"]
id --> gate{{"awaiting_speaker_id<br/>name unknown voices"}}
gate -->|Continue / Skip| sum["summarize<br/>LM Studio :1234 / Ollama :11434"]
sum --> ext["extract action items<br/>same LLM"]
ext --> done([✅ done])
classDef stt fill:#0d9488,stroke:#0f766e,color:#fff
classDef dia fill:#7c3aed,stroke:#6d28d9,color:#fff
classDef llm fill:#ea580c,stroke:#c2410c,color:#fff
classDef gate fill:#f59e0b,stroke:#d97706,color:#111827
class stt stt
class dia dia
class sum,ext llm
class gate gate
Each meeting is one row in SQLite (~/Documents/MeetingNotes/db.sqlite) and one folder under meetings/<slug>/:
meetings/<slug>/
├── audio.m4a symlink to the recording
├── transcript.raw.json whisper output
├── diarization.json pyannote speaker turns
├── transcript.md speaker-labeled, with real names after the gate
├── summary.md structured markdown (editable in-app)
└── action-items.json { text, owner, due_date, source_quote }
Tip
Crash-safe. If the app dies mid-pipeline, status='processing' meetings resume on next launch; status='failed' waits for an explicit Retry, which re-runs from the last safe checkpoint.
Important
Requirements: macOS 14.2 (Sonoma)+ on Apple Silicon · ~16 GB RAM · an LLM runtime (LM Studio or Ollama) with a chat model · a free Hugging Face account with three pyannote licenses accepted (one-time — see below).
git clone https://github.com/dbbaskette/MeetingNotes.git
cd MeetingNotes
brew install whisper-cpp ffmpeg
./scripts/setup.sh # one interactive setup: deps · sidecar venv · model · HF token · .app build
./scripts/start.sh # launch the packaged .app (use --dev for hot-reload)start.sh exports the HF token, health-checks the stack, and opens the app. whisper-server, the diarization sidecar, and the LLM runtime are spawned by the app itself on demand (first transcription wakes whisper, first summarize wakes the LLM and auto-loads the model) and shut down after 10 minutes idle.
First launch runs a five-step wizard — macOS permissions → Whisper model → Hugging Face token → LLM runtime → transcription server — each step skippable, each verified live (the LLM step even runs a quick loop-detection canary on your chosen model). macOS prompts for Microphone and Screen & System Audio Recording on first record; grant both.
- Click ⏺ Record — or fire
meetingnotes://record?source=zoom.usfrom a Shortcut /osascript/ Stream Deck — or let auto-detect catch it (an in-library banner appears when a known meeting URL opens in your browser, or when Zoom / Teams / Webex / FaceTime starts a call). - The source picker lists every app currently making sound; recognized meeting apps float to the top with a
MEETINGbadge. Pick one, or All system audio as a catch-all. - A live recording row appears with elapsed time, a VU meter, and Stop.
- Click ■ Stop — the new row lands in your library instantly.
- Click ▶ Process to run the pipeline.
Each recording writes three AAC files to ~/Music/MeetingNotes/ (mono, 128 kbps ≈ 60 MB/hour): the mixed file (used by the pipeline) plus .voice and .system stems reserved for future stem-aware processing.
Managing recordings
Every row and the detail-view header has a ⋯ menu with Rename… and Delete…. Delete is a hard delete — mixed m4a, both stems, the meeting folder, and the DB row.
MeetingNotes talks to any chat model in LM Studio or Ollama over an OpenAI-compatible API, and manages the runtime for you: with summaryProvider set to lm-studio/ollama it spawns the server, auto-loads llmModel, and idle-shuts-down after 10 min. The default is qwen/qwen3.5-9b — small enough to fit Apple Silicon VRAM on long transcripts.
Reasoning models are supported but need care. Models like Gemma, Qwen3, and DeepSeek-R1 "think" in a <think> channel before answering, which MeetingNotes handles at several layers:
- 🧠 Badges flag known reasoning models in the picker and onboarding.
- ✅ A health-check canary runs a cheap extraction on your chosen model and warns if it loops.
- 🔁 Automatic re-sampling — if a model spends its whole token budget thinking and returns nothing (an intermittent failure on some models), the stage re-samples at a higher temperature instead of hard-failing.
- ✂️
<think>blocks are stripped before rendering, and a Disable model thinking toggle sendsenable_thinking: falsewhere the model honors it.
Tip
A non-reasoning chat model is the simplest, fastest choice and sidesteps the thinking-loop failure mode entirely. Reach for a reasoning model only if you specifically want its output quality.
The speaker-ID gate — name unknown voices once
After diarize + identify, the pipeline pauses at awaiting_speaker_id; the library row turns amber with a NAME VOICES chip and (if the app isn't focused) a native notification. In the detail view each unknown voice has a ▶ Play sample (8-second clip) and a dropdown to link an existing roster entry or create a new one. Continue re-merges the transcript with real names and proceeds. Don't care for this meeting? Toggle Skip speaker ID and it runs straight through.
Summary + action items — editable, with provenance
Summaries are structured into Overview · Key Discussion Points · Decisions · Action Items · Follow-ups · Open Questions, skipping empty sections. Opening/closing small talk is moved (not duplicated) into an Off-topic Conversation section at the end. Verbosity is a one-time detail level (concise / standard / detailed) that pins the target length in the prompt so different models don't drift.
The Summary editor has Preview / Split / Edit modes — fix a hallucination or redact in place and Save (writes to summary.md).
Action items are extracted from the summary and carry provenance: click one to jump to the exact summary bullet it came from. Edited the summary? Hit ↻ Re-extract to regenerate the items in seconds without re-running the whole pipeline.
Weekly view — a Mon–Sun narrative
The Week tab rolls up every meeting in the week: an LLM narrative (past weeks only), a Themes section synthesizing 3–6 topic threads that run across meetings (with clickable chips back to sources), all open action items grouped by owner, and key decisions. It's cached in SQLite by content hash — re-opening is instant; editing any meeting in the week invalidates it. Export to Markdown ships the whole rollup.
Set Settings → "You are…" to pin your open action items to a "You" group at the top. The current week is intentionally narrative-free (it would go stale within hours); the structured rollup still updates live.
Progress, search & playback
- Learned ETAs — the app records how long each stage takes on your machine, bucketed by transcript size, and shows "elapsed · ~estimate" with a "running long" cue. A rough estimate appears after a single run.
- Permanent status bar at the bottom shows the in-flight run from any view (
Summarizing "…" — 17s · ~3m · 2 queued), orReadywhen idle. - ⌘K opens a global search across titles, summaries, and transcript text.
- Click-to-play transcript — timestamps seek the sticky audio player, which survives tab switches so you can listen while editing.
| Target | What it does |
|---|---|
| Apple Reminders | Push action items into a Reminders list. |
| Google Tasks / Docs | Send action items to Google Tasks, or the full summary to a Google Doc (BYO OAuth client — see docs/google-setup.md). |
| Markdown | Export the summary as a .md file, editor + live preview built in. |
| Webhook | POST a meeting.completed JSON payload to any HTTPS/localhost endpoint. Templates: compact JSON · full JSON · Slack blocks · Telegram markdown. Send test payload verifies the round-trip. |
| URL scheme | meetingnotes://record?source=zoom.us, …?source=ask, meetingnotes://stop — drive recording from Shortcuts, osascript, Stream Deck, or a calendar trigger. |
Settings live in SQLite (~/Documents/MeetingNotes/db.sqlite, table settings). Edit them in the app's Settings view or via setup.sh.
Full settings reference
| Key | Default | What it does |
|---|---|---|
summaryProvider |
external |
LLM runtime: lm-studio, ollama, or external. Managed modes spawn the runtime, auto-load llmModel, and idle-shut-down. external = you run the server at lmStudioUrl. A healthy externally-started server is adopted (never killed) in any mode. |
lmStudioUrl |
http://localhost:1234 |
Chat/LLM endpoint (used only in external mode; managed modes hardcode 1234 / 11434). |
llmModel |
qwen/qwen3.5-9b |
Model id for summarize/extract. Auto-loaded on first use. |
disableThinking |
true |
Sends enable_thinking: false so reasoning models skip chain-of-thought where they honor it. |
summaryDetail |
detailed |
Summary verbosity: concise / standard / detailed. |
sttUrl |
http://127.0.0.1:8080 |
whisper-server endpoint. |
sttModel |
whisper-1 |
Model file loaded when the app spawns whisper-server (ggml-<name>.bin); falls back to an auto-pick order if missing. |
sttLanguage |
en |
Passed to Whisper. |
libraryPath |
~/Documents/MeetingNotes |
Meetings, DB, embeddings. |
audioWatchPath |
~/Music/MeetingNotes |
Folder watched for new recordings. |
recordingBitrateKbps |
128 |
AAC bitrate (96 / 128 / 192). |
theme |
system |
UI appearance: system / light / dark. |
userName |
"" |
Your name, substituted into transcripts after speaker-ID. |
userSpeakerId |
null |
The roster speaker that represents you; pins your action items in Weekly. Set via Settings → "You are…". |
autoDetectMeetings |
{browserTabs:false, nativeApps:false, silenceMs:5000} |
browserTabs polls the frontmost browser for meeting URLs; nativeApps polls CoreAudio for Zoom/Teams/Webex/FaceTime; silenceMs debounces beeps. |
autoRecordZoom |
false |
When the native detector fires for Zoom, skip the banner and record immediately. |
exporterApple / exporterMarkdown / exporterWebhook |
true / true / false |
Enable each exporter. |
webhookUrl |
"" |
Destination (HTTPS, or localhost). |
webhookSecret |
"" |
Optional bearer token; redacted from logs. |
webhookTemplate |
compact |
compact / full (JSON), slack-blocks, telegram-markdown. |
webhookOwnerFilter |
mine |
Which action items to include: mine (by userSpeakerId) / all / none. |
googleClientId / googleClientSecret |
"" |
BYO Google OAuth desktop client for Tasks/Docs export. |
Hugging Face token (one-time, for diarization)
pyannote's models are gated. Accept the license on all three:
- https://huggingface.co/pyannote/speaker-diarization-3.1
- https://huggingface.co/pyannote/segmentation-3.0
- https://huggingface.co/pyannote/speaker-diarization-community-1
Create a fine-grained token with "Read access to contents of all public gated repos you can access", and paste it when setup.sh prompts. It's saved to ~/.cache/huggingface/token (chmod 600). After the first download the model is cached locally and inference needs neither the token nor the network.
npm run dev # vite + electron with HMR
npx vitest run # test suite (540 tests) · see note below
npm run lint
npm run build # tsc main + preload (CJS) + vite
npm run dist # full installer: audio-tap + sidecar + app + .dmg + .zipNote
Use npx vitest run rather than npm test — the repo's posttest hook rebuilds better-sqlite3 for Electron's ABI and can exit non-zero on newer Node even when every test passes.
Source layout
audio-tap/ Swift CLI helper — CoreAudio Process Tap recording (swiftc + codesign)
electron/main/ main process: pipeline, storage, IPC, watcher, services
recording/ RecordingManager, AppEnumerator, orphan-recovery
meeting-detector/ browser-tab URL polling + native-app detector
url-scheme/ meetingnotes:// handler
exporters/ apple-reminders · google-tasks · google-doc · markdown · webhook
llm/ managed LM Studio / Ollama lifecycle
lm-studio/ OpenAI-compatible client (thinking-strip, re-sample retries)
whisper/ whisper-server supervisor (lazy spawn, /health probe)
diarization/ pyannote sidecar supervisor + HTTP client
weekly/ Mon–Sun aggregator + narrative prompt
pipeline/stages/ transcribing · diarizing · merging · identifying · summarizing · extracting
storage/ SQLite repos + migrations (schema v14)
electron/preload/ CJS IPC bridge (with a parity test)
electron/renderer/ React UI (views/ · components/ · lib/ · store/)
sidecar/ Python pyannote diarization sidecar, FastAPI :8765
scripts/ setup.sh · start.sh · rebuild.sh · whisper-server.sh · doctor.sh
docs/ url-scheme.md · exporters.md · google-setup.md · smoke-test · specs
Packaging & the packaged-app PATH
./scripts/rebuild.sh (or npm run dist) compiles the Swift helper, bundles the Python sidecar with PyInstaller (so end users don't need Python), builds the Electron app, rebuilds better-sqlite3 against Electron's ABI, and produces release/MeetingNotes-1.8.4-arm64.dmg + .zip.
Electron apps launched from Finder inherit a minimal PATH that excludes Homebrew, so the app resolves ffmpeg, ffprobe, whisper-server, lms, and ollama by searching well-known Homebrew paths — the .dmg behaves exactly like npm run dev. If a binary is missing, the error names the exact brew install to run.
Runtime tools: ./scripts/doctor.sh (read-only health check) and ./scripts/start.sh --status (what's running). App logs: ~/Library/Logs/MeetingNotes/app.log, surfaced in-app under Settings → Diagnostics.
- Local-only inference. Audio, transcripts, and summaries never leave your Mac. No telemetry, no accounts, no API keys at inference time.
- Sandboxed renderer —
contextIsolation: true,nodeIntegration: false; the preload exposes a typed API surface only, and every IPC payload is zod-validated. - Scoped audio capture — the Swift helper is codesigned with the audio-input entitlement; TCC scopes your grant to MeetingNotes specifically, and the helper auto-stops if the app dies (no orphaned recorder).
- Parameterized SQLite (
better-sqlite3, FKs + WAL). The HF token is storedchmod 600and needed only for the one-time model download.
1.8.4 — stable on macOS 14.2+ / Apple Silicon. Full pipeline working end-to-end; the packaged .dmg runs the same path as npm run dev. Browser + native-app meeting detection ship enabled-but-off. The "All system audio" capture path remains experimental.
MIT — see LICENSE.
- whisper.cpp — fast local Whisper inference
- pyannote-audio — speaker diarization
- LM Studio & Ollama — local LLM runtimes
- AudioCap by @insidegui — the Process Tap reference that unblocked audio capture



