Open source · macOS · MIT

Lapel

Multi-track voice memos for macOS. Record two people wearing two lapel mics and get back one audio file and one transcript per speaker.

The problem

Voice Memos records one track. A two-person conversation flattened into one mixed file is a transcript full of guesses about who said what.

But if you clip a DJI Mic Mini transmitter to each person, the hardware has already kept them apart — TX1 on the left channel, TX2 on the right. The separation is physical: perfect, deterministic, free. Speaker-diarization models exist to reconstruct what this hardware never lost.

Lapel is the app that stops throwing it away.

What it does

Sees the receiver arrive

CoreAudio hotplug detection, no polling. It also catches the mode button being pressed, which changes the channel count without changing the device list.

Counts live lapels

Not how many the receiver could take — how many are switched on and transmitting right now.

Meters each speaker

Per-transmitter levels with proper ballistics, held peaks and clip detection.

Records per speaker

One file per person, named for whoever wore that mic. Names are remembered for next time, and a recording made before anyone was named can be attributed afterwards — the transcript follows.

Transcribes on device

Each track transcribed separately, so attribution is settled before any model runs. Nothing leaves your Mac.

Mutes mic bleed

Two lapels at one table each hear both people. Lapel gates each track on which mic was loudest, so a line lands only on whoever said it — one transcript, no duplicates.

Warns you about Mono

In M mode the receiver mixes both lapels before your Mac sees them. Lapel says so, and names the fix on the hardware.

Trims before export

Drag the ends of the combined waveform, scrub, and play exactly the stretch you selected. Non-destructive — the per-speaker files are never touched.

Exports a shareable pair

Every speaker mixed to one audio file, and one attributed transcript — plain text, Markdown or SRT — alongside the per-speaker files, not instead of them.

Try the hardware layer now

No app needed. The probe verifies everything below the UI against your own gear:

git clone https://github.com/ben4mn/lapel
cd lapel && swift run lapel-probe
Input devices ──────────────────────────────────────────────────────────── ▸ DJI MIC MINI 2 channels 48000 Hz usb MacBook Air Microphone 1 channel 48000 Hz builtIn ──────────────────────────────────────────────────────────── DJI MIC MINI — 2 of 2 microphones live TX1 [███████████████|·················] -18.4 dBFS ● speaking TX2 [████|···························] -46.1 dBFS ○ live

Plug and unplug the receiver, or press its mode button, while it runs. No receiver to hand? swift run lapel-probe --demo-session <folder> writes a two-speaker fixture so you can see the app without buying a microphone.

Exporting

The per-speaker files are the point, but they are awkward to send to someone. Export produces the shareable pair alongside them: every track mixed into one audio file, and one transcript with each line attributed.

Ben: Right — so the whole point is that the hardware already keeps you apart. Dana: Because we're each wearing our own transmitter. Ben: Exactly. TX1 goes to the left channel, TX2 goes to the right. Nothing has to guess. Dana: And that's why the transcript can say who said what without a diarization model.

The mixdown is a sum, not an average

Averaging is the textbook answer and it is wrong here. Two people taking turns is the normal case, so only one track is loud at any instant — dividing by the track count would halve the volume of a recording that never came close to clipping. The sum is taken at full weight, and a single gain applied across the whole mix only if its peak would exceed −1 dBFS.

The transcript is re-timed with the audio

Trim the mix and the transcript follows: turns outside the selection are dropped, and the rest are rebased so the two exported files still line up. A turn straddling a cut is clipped rather than dropped — someone mid-sentence at the boundary still said something inside the clip.

Mixing discards the separation, so the transcript carries it

Names you typed are used as written. An unnamed track becomes Speaker 1 rather than TX1 — a hardware label has no business in a document meant to be read. A numbered mode overrides names entirely, for sharing a transcript without naming anyone.

Two mics in one room hear each other

Separate tracks settle whose microphone, not whose voice. Two people wearing lapels at the same table are each picked up by both transmitters, and a transcriber given the raw tracks dutifully returns every sentence twice — once from the mic worn by whoever said it, and once, quieter and worse, from the other.

before TX1: If anything, we should be able to improve our mic situation… TX2: If anything, we should be able to improve our mic situation… TX1: And then also I want to figure out who the dominant speaker is… TX2: And then also I want to figure out who the dominant speaker is… after TX1: If anything, we should be able to improve our mic situation… And then also I want to figure out who the dominant speaker is, because I can tell I'm getting picked up in both mics.

Quieter is the whole answer

A lapel mic is a hand's breadth from its own speaker's mouth and a table's width from the other's. Measured on a real 45-minute conversation, the speaker's own mic wins by 6–15 dB, and fewer than 4% of speech windows are within 3 dB of a tie. So before transcribing, Lapel compares the tracks in 20 ms windows, decides who owned each one, and mutes every other track there — with a short ramp either side of each cut, a hold so a breath doesn't hand the floor over, and a blip filter so a click on the wrong mic doesn't count as a turn.

The engine then only ever hears a person through their own microphone. Any line it still produces where the other mic owned the room is thrown out afterwards. The recordings on disk are never touched, genuine overlap is kept on both tracks, and it can be switched off for mics that could not hear each other.

Two things worth knowing

Counting live microphones is inference, not a query

CoreAudio cannot tell you how many transmitters are linked — the receiver enumerates as a fixed two-channel USB device whether one lapel is on or both.

The signal can. A channel with no transmitter carries true digital zero. A linked transmitter sitting silent still sends its own self-noise, around −60 dBFS and never exactly zero. That gap is the entire discriminator.

The timing is deliberately asymmetric: presence is believed on the first buffer so the UI lights up the instant a lapel is switched on, while absence must persist for a confirmation window, so an RF dropout can't make the count flicker.

Reading device names needs no microphone permission

The CoreAudio HAL will name every attached device before you've granted anything, so Lapel shows “DJI MIC MINI — 2 of 2 microphones live” on first launch and only prompts when you actually press record.

A browser-based equivalent can't do this: getUserMedia hides device labels until permission is granted. It's one of several reasons this is Swift and not Electron.

Design

The rule the codebase is built around: hardware types stop at the bridge.

Metering, receiver detection, transmitter presence, the recording state machine and the session store contain no CoreAudio or AVFoundation types whatsoever. Audio reaches them as [[Float]] — one array per channel. Everything above the bridge is therefore tested against synthetic signals with exact arithmetic, and the whole suite runs on a CI machine with no audio interface attached.

Exactly three files talk to hardware: CoreAudioDeviceEnumerator reads the device list, AudioDeviceMonitor carries the hotplug and channel-mode listeners, and AudioCapture owns the AVAudioEngine tap that deinterleaves into [[Float]].

Built red/green: every test written and confirmed failing before the code that satisfies it. Each commit message records what was red.

Status

Device enumeration, hotplug and channel-mode detection
Level metering, live transmitter count, speaking indicator
Recording state machine, per-channel files, session store
SwiftUI app — live meters, transport, session library
lapel-probe terminal harness, with a --demo-session fixture
Combined audio and transcript export (txt, Markdown, SRT)
On-device transcription with SpeechAnalyzer
Crosstalk gate — one attributed transcript from two mics in one room

Requirements

macOS 14 or later. A DJI Mic Mini, DJI Mic 2, or any multi-channel USB audio input. On-device transcription needs macOS 26 and a build made with Xcode 26 — on older toolchains the engine compiles out and the app says so rather than failing quietly.

The receiver must be in S (Stereo) mode. In M (Mono) it mixes both lapels into a single channel before your Mac ever sees them. Lapel will tell you if it is.