Skip to content

Our own product · AI in production

Meeting transcription that holds up outside the demo

Mavio transcribes meetings in real time across desktop, mobile and a browser extension. The hard parts were never the model — they were system audio capture on two operating systems, speaker attribution over a noisy channel, and retention rules that differ per artefact.

Context

Mavio is our own product, which is the only reason this case study can be this specific. It joins a meeting — or records one locally — transcribes it live, and produces notes and action items afterwards. It runs on macOS and Windows as an Electron desktop app, on iOS and Android through Expo, and as a Chrome MV3 extension, all against one Python backend.

We build it, we run it, and we are the ones woken up when it breaks. Everything below is a decision we had to live with rather than one we recommended and walked away from.

What was hard

Not the transcription. A managed speech API handles the acoustic problem well. The work was everywhere else.

System audio capture is two separate problems. Capturing what the meeting participants say — not just what the microphone hears — means loopback recording, and loopback recording is platform-specific: CoreAudio on macOS, WASAPI on Windows. Both then have to be mixed with the microphone, resampled to 16 kHz mono, and survive the user changing their audio device mid-recording, which people do constantly.

Speaker attribution degrades exactly where it matters. Diarisation is reliable on clean two-person audio and unreliable on a six-person call with overlapping speech and one participant on a phone. The interesting failure is not "who said this" being unknown — it is being confidently wrong, and attributing a commitment to the wrong person.

Notes have to exist before the meeting ends. Batch processing after the call is the easy architecture and the wrong product. If notes arrive twenty minutes later, the meeting has already ended without them.

What we built

Audio arrives over a WebSocket as a stream and is transcribed incrementally against Deepgram's Nova-2 model with diarisation enabled. Interim results render immediately and are replaced by finals as they settle, so the transcript is visible while the meeting is still running rather than assembled afterwards.

Segments, speakers, action items and share permissions are separate tables in Supabase Postgres with row-level security policies, so a meeting is isolated by construction rather than by remembering to add a WHERE clause. The schema has moved through 261 append-only migrations; none of them rewrites history.

The backend runs on ECS Fargate behind an ALB, with ElastiCache Redis for queues, Secrets Manager for credentials, ECR for images, and CloudWatch alarms on the paths that actually page someone. All of it is Terraform, and deploys authenticate through GitHub OIDC rather than long-lived AWS keys.

Technical decisions and why

Streaming over batch, accepting the harder failure model. Batch is simpler: one file, one request, one result. Streaming means partial state, reconnection, and deciding what to do with a half-transcribed meeting when a laptop lid closes. We took streaming because the product is worthless if the notes are late, and then paid for it with reconnection and resumption logic.

A managed model provider, behind our own interface. Deepgram does the acoustic work better than we would. But the call sits behind our own service boundary, with the provider selectable in configuration — because a speech model is exactly the kind of dependency that changes its behaviour under you, and we wanted the ability to switch or run a second provider in parallel without touching the product code. That interface has since let us evaluate a self-hosted alternative against live traffic.

Separate retention per artefact. The recording, the transcript, and the derived notes are three different things with three different sensitivities. Customers routinely want the audio gone and the notes kept. Modelling them as one object would have been simpler and would have made that request impossible to satisfy without deleting everything.

Confidently wrong is tracked separately from wrong. Attribution that the system declines to make is a manageable product state — the transcript shows an unidentified speaker. Attribution it makes incorrectly is a trust failure. The two are counted as different metrics because they need different responses.

Outcome

The honest version: it runs in production, we carry its uptime and its support load, and its architecture has survived a provider evaluation and a rebrand without a rewrite.

We are not publishing accuracy or latency figures here. The numbers we have are measured against our own evaluation set on our own traffic, and quoting them as though they generalise to your meetings would be exactly the kind of claim this site does not make.

Stack

  • Runtime — Python 3.11 / FastAPI backend, TypeScript across desktop, mobile and extension, Turborepo and pnpm workspace
  • Clients — Electron with a Vite web build, Expo for iOS and Android, Chrome MV3 extension
  • Data — Supabase Postgres with row-level security, Supabase Storage for audio, Realtime for live updates
  • Speech — Deepgram Nova-2 streaming with diarisation, behind a swappable provider interface
  • Infrastructure — AWS ECS Fargate, ALB, ElastiCache Redis, Secrets Manager, ECR, CloudWatch alarms, all in Terraform; GitHub OIDC for deploy credentials
  • Observability — Prometheus, Grafana, Loki and Promtail