Multi-track voice memos for macOS. Record two people wearing two lapel mics and get back one audio file and one transcript per speaker.
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.
CoreAudio hotplug detection, no polling. It also catches the mode button being pressed, which changes the channel count without changing the device list.
Not how many the receiver could take — how many are switched on and transmitting right now.
Per-transmitter levels with proper ballistics, held peaks and clip detection.
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.
Each track transcribed separately, so attribution is settled before any model runs. Nothing leaves your Mac.
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.
In M mode the receiver mixes both lapels before your Mac sees them. Lapel says so, and names the fix on the hardware.
Drag the ends of the combined waveform, scrub, and play exactly the stretch you selected. Non-destructive — the per-speaker files are never touched.
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.
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
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
| ✅ | 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 |
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.