M2 Mac Arch Linux RTX 3080 Iced Tailscale

Granola

Private, self-hosted AI meeting notes. Record on any machine, transcribe on your 3080 desktop, view transcript & AI summaries in a native Iced app. Zero cloud dependency.

System architecture

flowchart LR subgraph Client["Iced app (each laptop)"] direction TB UI["Native GUI
Rust · Iced · cpal"] STATE[("🎀 capture + encode
Opus .ogg β†’ memory")] VIEW["πŸ“– transcript + summary
in-app tabs"] end subgraph Discovery["Auto-discovery"] MDNS["mDNS (LAN)
granola-server.local"] TS["Tailscale (remote)
granola-server:9800"] end subgraph Server["Arch + RTX 3080 (Axum API)"] direction TB API["Axum HTTP API
POST /record Β· GET /status Β· GET /result"] WHISPER["whisper.cpp
large-v3-turbo (CUDA)"] LLM["LLM summariser
Ollama / llama.cpp"] PUSH["Notion pusher
background task"] end subgraph Output["Output"] NT["πŸ“„ Notion tingies DB
summary + transcript + actions"] end UI -->|capture| STATE UI -->|display| VIEW STATE -->|POST /api/record| API VIEW -->|poll /api/status| API Client -.->|discover| Discovery Discovery -.->|resolve| Server API -->|spawn| WHISPER WHISPER -->|transcript| LLM LLM -->|structured notes| PUSH PUSH -->|API call| NT style Client fill:#e0e7ff,stroke:#a5b4fc,stroke-width:1 style Discovery fill:#fef3c7,stroke:#fcd34d,stroke-width:1 style Server fill:#ccfbf1,stroke:#5eead4,stroke-width:1 style Output fill:#e0e7ff,stroke:#a5b4fc,stroke-width:1

πŸ”² App window (Iced native)

Granola granola-server.local Β· connected
REC 0:12:34
Sprint Planning Β· 12 Jun
Transcript Summary History

Alex (0:02) So for the API migration, we need to decide whether we're doing a phased rollout or a big bang cutover.

You (0:15) Phased is safer. We can route a percentage of traffic to the new endpoints and monitor for issues.

Sam (0:28) I agree. But we need feature flags for every endpoint or the frontend team will be blocked.

… 14 minutes remaining

Uploaded Β· Processing Β· ~45s remaining

πŸ“‘ API protocol β€” client ↔ server

Client sends

POST /api/record
Content-Type: audio/ogg
X-Machine: m2-mac-work
X-Recorded: 2026-06-16T09:12:00Z
--- binary Opus data ---
β†’ 201 { "job_id": "abc123" }
GET /api/status/abc123
β†’ 200 { "status": "processing", "progress": "42%" }

Client receives

GET /api/result/abc123
β†’ 200
{
"title": "Sprint Planning - 12 Jun",
"duration_sec": 1842,
"transcript": [
{ "speaker": "Alex", "ts": 2, "text": "..." },
],
"summary": {
"key_points": ["..."],
"decisions": ["Phased rollout"],
"action_items": [
{"assignee": "You", "item": "Write feature flags"}
]
},
"notion_url": "https://notion.so/..."
}
πŸ’»

Iced recording app

Single Rust binary. One codebase runs on M2 Mac and Arch. Native, no WebKit, no Electron.

  • βœ“ Iced native GUI β€” cosy widgets, GPU-accelerated rendering (wgpu)
  • βœ“ cpal mic capture β€” cross-platform audio input
  • βœ“ Opus encoding β€” ~2MB for a 30-min meeting
  • βœ“ HTTP client β€” reqwest to POST audio + poll status
  • βœ“ In-app tabs β€” toggle between transcript, AI summary, history
  • βœ“ Auto-discovery β€” mDNS first, Tailscale fallback
  • βœ— No tray icon β€” just a window, like a proper app
πŸ–₯️

Axum API server

Your Arch + 3080 desktop. Runs an HTTP server instead of a file-watch daemon. Receives audio, returns results.

  • βœ“ Axum (Rust) β€” async, fast, type-safe API
  • βœ“ POST β†’ queue β†’ process β€” receives Opus, decodes to WAV, spawns pipeline
  • βœ“ whisper.cpp (CUDA) β€” 30-min meeting β†’ ~90 sec
  • βœ“ Ollama API β€” summarises transcript to structured notes
  • βœ“ Notion push β€” background task, writes to tingies DBs
  • βœ“ Job queue β€” concurrent meetings don't collide

πŸ“ Auto-discovery

On local LAN

1

mDNS broadcast

Server advertises granola-server._http._tcp.local via Avahi (Arch) / Bonjour (macOS)

2

Client resolves

Uses libmdns (Rust crate) to discover β€” http://granola-server.local:9800 in ~200ms

Over Tailscale (anywhere)

1

Fallback trigger

mDNS timed out (2s) β†’ client tries Tailscale MagicDNS

2

Tailscale DNS

Resolves granola-server.tailnet-name.ts.net:9800 β€” encrypted WireGuard transport

πŸ’‘ Both machines on your Tailscale tailnet? The Tailscale path handles local + remote seamlessly. mDNS is a nice LAN-only optimisation for the ~2s it saves on startup.

⏩ End-to-end flow

1

Record

Click record in the Iced app, or hit a hotkey. cpal captures mic audio, encoded to Opus in-memory. Timer counts up.

2

Upload

Stop recording β†’ app POSTs Opus bytes to /api/record. Server returns a job_id. App shows "uploaded, processing…"

3

Transcribe

Axum server spawns a background task. Decodes Opus to WAV, runs whisper.cpp --model large-v3-turbo. 30-min meeting β†’ ~90 seconds on CUDA.

4

Summarise

Transcript goes to Ollama API with a prompt: extract action items, decisions, key discussion points, and open questions. Returns structured JSON.

5

Display + push

App polls GET /api/status/{job_id} until status = "done", then GET /api/result/{job_id}. Transcript and summary render in-app tabs. In background, server pushes the same data to Notion tingies DB.

6

Done

App shows "Ready" with a link to the Notion page. All past recordings accessible in the History tab.

🧱 Tech stack

Component Runs on Lang Key deps
App GUI macOS Arch Rust iced + wgpu
Audio capture β€” Rust cpal + symphonia (Opus)
HTTP client β€” Rust reqwest
API server Arch Rust axum + tokio + serde
Transcription Arch C++ whisper.cpp + CUDA
Summarisation Arch β€” Ollama API (local model)
Notion push Arch Rust reqwest + notion API
mDNS discovery β€” Rust libmdns / mdns-sd

Why all Rust? Shared types between client and server (same crate for API structs, job IDs, audio encoding). One build system. No language boundary headaches.

πŸ“¦ Crate layout

granola/
β”œβ”€β”€ Cargo.toml              # workspace root
β”œβ”€β”€ crates/
β”‚   β”œβ”€β”€ granola-core/          # shared types: JobId, JobStatus, Transcript, Summary
β”‚   β”‚   β”œβ”€β”€ Cargo.toml
β”‚   β”‚   └── src/lib.rs
β”‚   β”œβ”€β”€ granola-client/        # Iced app: mic capture + encode + HTTP + GUI
β”‚   β”‚   β”œβ”€β”€ Cargo.toml
β”‚   β”‚   └── src/
β”‚   β”‚       β”œβ”€β”€ main.rs          # entry + iced runtime
β”‚   β”‚       β”œβ”€β”€ ui/              # widget tree (record/stop, tabs, transcript view)
β”‚   β”‚       β”œβ”€β”€ audio/           # cpal capture β†’ Opus encoder
β”‚   β”‚       β”œβ”€β”€ client.rs        # reqwest API client
β”‚   β”‚       └── discovery.rs     # mDNS + Tailscale resolver
β”‚   └── granola-server/        # Axum API + whisper + Ollama + Notion push
β”‚       β”œβ”€β”€ Cargo.toml
β”‚       └── src/
β”‚           β”œβ”€β”€ main.rs          # axum server bootstrap
β”‚           β”œβ”€β”€ routes/          # POST /record, GET /status, GET /result
β”‚           β”œβ”€β”€ pipeline/        # whisper runner, Ollama summariser
β”‚           β”œβ”€β”€ notion.rs        # Notion API client
β”‚           └── queue.rs         # in-memory job queue (tokio tasks)
└── scripts/
    └── setup.sh                # install whisper.cpp model, configure avahi

πŸ—ΊοΈ Build phases

Phase 1 Β· Core pipeline

Server-side: receive audio β†’ transcribe β†’ summarise β†’ push

  • β†’ granola-core shared types
  • β†’ granola-server Axum API scaffold
  • β†’ whisper.cpp integration (subprocess)
  • β†’ Ollama summariser
  • β†’ Notion push
  • β†’ Test with curl

Working API, testable with curl

Phase 2 Β· Iced client

Native GUI: record β†’ upload β†’ display results

  • β†’ granola-client app scaffold
  • β†’ Iced window + tab layout
  • β†’ Mic capture with cpal + Opus
  • β†’ HTTP client + status polling
  • β†’ Auto-discovery (mDNS + Tailscale)

Record on laptop β†’ view in app

Phase 3 Β· Polish

Hotkeys, history, reliability

  • β†’ Global hotkey toggle
  • β†’ History tab (past recordings)
  • β†’ Offline queue (record while server's away)
  • β†’ Speaker diarisation
  • β†’ LLM model swapping in settings

Daily driver ready

πŸ€” Key decisions made

Decision Choice Why
GUI framework Iced Native Rust. No WebKit. Proper Wayland support. One codebase for Mac + Arch.
App model Window (no tray) No Wayland tray headaches. Iced window is native and clean.
Communication HTTP API (Axum) Lets the app fetch results back. Polling is simple and reliable. Shared Rust types between client + server.
Process location Desktop (3080) 20-30Γ— realtime whisper. Desktop is always-on. Single server to maintain.
Audio format Opus β†’ WAV Opus for POST (tiny), server decodes to WAV for Whisper
Discovery mDNS primary + Tailscale fallback Sub-second LAN discovery. Tailscale handles everything else.
LLM Local Ollama Private. 3080 runs local models easily. No API costs.
Output Notion + in-app display Both. App shows instant results. Notion is the durable archive.

❓ Still to decide

Granola architecture v2 Β· updated 16 Jun 2026 Published with flareduct