Skip to content

Voxa

A native macOS app that transcribes and diarizes meetings entirely on-device. Signed, distributed, and used every day for two hundred meetings.

production2026 – presentSwiftUI · Python · mlx-whisper · pyannote.audio · Ollama

v1.2.1 · 2.3 MB · macOS 14+ · Apple Silicon

The meeting report, written by a local model, above the transcript it was built from
The meeting report, written by a local model, above the transcript it was built from

200+ meetings recorded and summarised in daily use100% on-device, nothing is uploadedGPU-optimised for Apple Silicon via mlx-whisperSigned .dmg, released on GitHubRecognises a speaker by voice from one earlier confirmation

The problem

Every transcription service asks for the same thing: upload the recording. For a meeting that contains client names, salaries or medical details, that is not a trade-off. It is a disqualification.

Apple Silicon has the hardware to do this locally. What was missing was an application that treats local inference as the normal path rather than a degraded fallback.

What it does

Records system audio and microphone together, transcribes with speaker diarization, and produces a summary, all on the machine, with no network call at any stage.

Exports to TXT, JSON, SRT and Markdown. Follows the system language between English and French. Progress lives in the menu bar so it can run while you work.

Speakers are named once. The next recording recognises the same voices and fills the names in before you start reading.

Requires
macOS 14.0+ on Apple Silicon (M1–M4)
Transcription
mlx-whisper, GPU-optimised
Diarization
pyannote.audio 3.1
Summaries
Ollama, running locally

Architecture

  • Meeting audioSystem track and microphone

    Voxa

  • VoxaSwiftUI, menu bar

    Inference subprocess, Ollama, Transcripts and voice prints

  • Inference subprocessmlx-whisper, pyannote
  • OllamaModel already on the Mac
  • Transcripts and voice printsSwiftData, JSON

    Inference subprocess

visitorstorageno node is a server: the whole diagram is one Mac
01

SwiftUI application

Native interface, menu bar integration, audio player with segment navigation, drag and drop for m4a, wav, mp3 and mp4.

02

ScreenCaptureKit capture

System audio and microphone captured together, which is what makes a remote meeting usable rather than half-recorded.

03

Python inference subprocess

mlx-whisper for transcription and pyannote 3.1 for diarization, driven as a subprocess and kept off the UI thread.

04

Local summarisation

Ollama produces speaker summaries and meeting reports on the same machine. No API key exists to leak.

Built with

Client

SwiftUI and SwiftData
Window, menu bar item, and the store that holds transcripts and speaker names
ScreenCaptureKit
System audio capture, the half of a remote meeting a microphone cannot reach
AVFoundation
Microphone tap, the merge of the two recorded tracks, and segment playback
Sparkle
Updates from a signed appcast, so a fix reaches installs I will never see

Inference

mlx-whisper
Transcription on the Apple Silicon GPU, large-v3-turbo unless you pick otherwise
pyannote.audio
Speaker segmentation, and the voice centroids that let a name survive the meeting
FFmpeg
Audio decoding for mlx-whisper, needed for every format including wav
Ollama
Speaker summaries and meeting reports, from a model already installed locally

Tooling

XcodeGen
Project file generated from a short manifest instead of hand edited
codesign and notarytool
Developer ID signature, notarisation and stapling, inside the script that builds the dmg
JSON Lines
The entire Swift to Python contract: one object per line on stdout
CAPTUREMLX-WHISPERPYANNOTEOLLAMAEXPORT

Every stage runs on hardware I own

Under the hood

01A JSON Lines protocol between Swift and Python

Swift owns the interface, Python owns the models. They talk over JSON Lines on the subprocess pipe: one object per line, streamed. It means progress arrives continuously instead of at the end, and a crash on the Python side is a readable event rather than a hang.

02Shipping a Python runtime to people who do not have one

The hard part of an on-device ML app is not the inference, it is the first launch. The installer detects Python, creates a dedicated environment and installs the ML packages. Five to ten minutes, once. Everything after that is a normal Mac app.

03A speaker is named once, not once per meeting

Diarization returns anonymous labels: SPEAKER_00, SPEAKER_01. Renaming the same four colleagues after every meeting is the kind of chore that quietly ends the habit of using an app. When a name is confirmed, the voice centroid pyannote produced for that speaker is saved beside it, and the next transcription compares its own centroids against the saved ones. The threshold is a value I settled on from my own recordings, set high enough that no name is more likely than a wrong name.

transcribe_bridge.py
scores.sort(reverse=True)
matches = {}
used_names = set()
used_labels = set()

for sim, new_label, saved_name in scores:
    if new_label not in used_labels and saved_name not in used_names:
        matches[new_label] = saved_name
        used_labels.add(new_label)
        used_names.add(saved_name)

Greedy, best similarity first, so one voice cannot claim two names.

04On-device is a product decision, not a benchmark

Local inference is slower than a datacentre and that is fine. The recordings this app is built for are exactly the ones you would never upload, so the comparison that matters is against not transcribing them at all.

Gallery

Naming each voice, once, before the transcript is final. The interface follows the system language.
Naming each voice, once, before the transcript is final. The interface follows the system language.
Starting a recording without opening the window
Starting a recording without opening the window

Try it

Download it and run it.

A native macOS app cannot be embedded in a browser, so there is no demo to fake. There is a signed disk image, a first-run wizard that sets up the Python environment for you, and the source on GitHub if you would rather read it than install it.

The pyannote models require a free HuggingFace token on first run. FFmpeg is required and the wizard installs it. Ollama is optional, and only the summaries depend on it.

What broke

Would change

FFmpeg was shipped as optional, and never was

The setup wizard listed FFmpeg as a nice to have, on the assumption that it only mattered for exotic formats. mlx-whisper loads every file through it, wav included, so anyone who skipped that step finished the wizard with a working install that failed on their first file. It is now a blocking step, with a button that runs the Homebrew install. If the inference path shells out to a binary, that binary is a requirement, whatever the import list suggests.

Would change

A new app running an old Python script

The Python script lives outside the app bundle, copied out on first setup so it sits next to the virtual environment that runs it. The copy only ran during that first setup. Everyone who updated through Sparkle got the new interface driving the previous script, and the version number in the About window was correct, which made the reports impossible to read. Anything extracted from the bundle is now rewritten on every launch, and the stored path forced back to the managed one.

Would do again

Two files and a merge, rather than one live mix

System audio and microphone are written to separate files during a recording, and merged only when it stops. Mixing them live in the audio engine is fewer moving parts and was the obvious first design, but it replays the system track on the machine that is capturing it, which is how you record a meeting with an echo of itself inside. The merge costs a few seconds, once, at the end.

Where it stops

Local inference still needs an account before it starts

The diarization weights are gated: a HuggingFace token, and the terms of two models accepted on their website, before anything downloads. An application whose entire point is that nothing leaves the machine cannot reach its first transcription without an online account. Nothing in the app can change that. What it does is recognise the 403 and show the two pages to accept, instead of handing over a stack trace.

Next projectDocStack