How AirPods Pro 2 listening modes (ANC, Transparency, Off, Adaptive) are actually controlled on macOS: documented from live packet captures and binary analysis, with runnable code and the traces behind each claim.
Apple documents none of this. The on-air protocol (AAP) is proprietary, and every framework call that touches a listening mode is private. This repo is the teardown, plus a CLI built on what it found.
As of 2026-08-31, code in this repo changes the listening mode on real hardware, over its own L2CAP channel, speaking AAP. The mode change was heard, the firmware echoed the command back on the channel, and a packet capture shows that channel being opened and carrying traffic. (PacketLogger does not decode AAP payloads, so the capture corroborates the channel, not the individual bytes.) See Findings and
docs/aap-protocol-notes.md("Resolution part 5") for the bytes and the trace.The shipped CLI does not use that path yet:
setandtogglestill only update local state, and warn you when they do. Wiring the working transport intoairpods-ctlis the next piece of work, and it carries one real design problem: opening the channel currently costs a ~1.7 s Bluetooth reconnection, so a naivetoggleinterrupts your audio.
The itch was small: switch ANC and Transparency from a keyboard shortcut, silently, while music is playing.
The answer was not small, because there is no public API for it. Reading or setting a listening mode goes through a protocol Apple does not document, reached by framework calls Apple does not support. The available options each stop somewhere:
- macOS Shortcuts offers no action for selecting a noise control mode at all (checked on Tahoe 26, 2026-09-01), so there is nothing to automate against, let alone a way to read the current mode back and express a toggle.
- AppleScript and UI scripting against the Sound menu do work, but flash the popover on screen for 300 to 500 ms every time, which defeats the point.
- NoiseBuddy is abandoned and no
longer works on recent macOS. The teardown below shows why: the API it was
built on now returns
nilwithout a privileged entitlement.
So the question stopped being "which tool do I install" and became "how does this actually work". That is what this repo answers.
This started as a macOS port of the AAP-over-L2CAP work from
LibrePods (Android and Linux) and
turned into a tour of every path macOS offers. Full detail in
docs/aap-protocol-notes.md; the short version:
1. macOS blocks your L2CAP connect locally, but only while the AirPods are
connected, and the firmware itself is willing.
The AAP PSM 0x1001 is real, confirmed via SDP ("AAP Server" record). With the
AirPods connected, openL2CAPChannelSync(withPSM: 0x1001) returns
kIOReturnError after ~3 s, and a packet capture of the attempt shows no
Connection Request ever leaving the Mac: IOBluetooth refuses locally,
without asking the device. Run the same call with the AirPods not connected
and the picture flips. The request goes out, the AirPods answer
Connection Successful, and the channel configures and holds, even though
bluetoothd is bringing up its own AAP session on the same link at that moment.
So the firmware accepts a second AAP client; macOS is the one saying no, and
only in the connected steady state. Separately, Apple negotiates its own
channels through a proprietary Fast Connect L2CAP-echo handshake, which is why
the AAP connection never appears as a standard PSM connect between two Apple
devices.
2. Commands written on that channel do reach the firmware, and the mode
changes. Racing the reconnect (closeConnection(), then retry the open while
macOS re-pages the device) yields a working channel. On it, an AAP handshake
followed by the 11-byte set-mode packet
(04 00 04 00 09 00 0D <mode> 00 00 00) switches ANC and Transparency audibly.
The AirPods echo the command back, which is how the write is confirmed
programmatically. Provenance differs between those two frames: the set-mode
bytes are from our own capture, the handshake is from the LibrePods document
(see THIRD_PARTY_NOTICES.md). After a mode change
the device also emits a settings burst that carries the current mode, which
looks like a usable read path, though it arrives unsolicited and we have not
yet shown it can be requested on demand. The retry loop is mandatory, since a
single open attempt is a coin flip, and each command costs the ~1.7 s
reconnect. Bytes, trace and traps in
docs/aap-protocol-notes.md ("Resolution
part 5").
3. The private listeningMode properties are state mirrors, not command
channels. IOBluetoothDevice.setListeningMode:, CBDevice (an XPC data
object) and AVOutputDevice.setCurrentBluetoothListeningMode: all look like
setters, but in an unprivileged short-lived process they write a local field and
nothing reaches the hardware. AVOutputContext.sharedSystemAudioContext, the
path NoiseBuddy used, returns nil without a privileged entitlement, which is
the likely reason it stopped working.
4. That private setter appears reachable by third-party code, which keeps it
alive as a fallback. A shipping third-party app that controls listening modes
on the same macOS version ships a resident helper whose Bluetooth service
exports the very same setListeningMode: selector, holds no special Apple
entitlement (only an app group), and contains no L2CAP or PSM code at all.
The likely reading is that the barrier is neither an entitlement we lack nor
the choice of API, but whatever runtime setup a persistent process has and a
short-lived one does not. Caveat worth keeping in view: this rests on static
inspection, which proves the code exists rather than that it is the path taken
at runtime. It matters because the direct AAP route above costs an audio
interruption, so a route with no reconnect is worth chasing.
Every claim here came from one of three places, and each one is reproducible.
Live packet capture. PacketLogger, from Additional Tools for Xcode, with
Apple's Bluetooth logging profile installed, recording HCI and L2CAP traffic
between macOS and the AirPods: connection setup, Apple's Fast Connect echo
handshake, and per-PSM signaling. Traces are in docs/captures/, and the notes
quote timestamps against them. Two limits worth knowing: PacketLogger reports
AAP frames as "No Decoder Specified", so payload bytes have to be searched in
the raw .pklg rather than read off the text export, and captures that carry
device serial numbers or a lot of ambient Bluetooth traffic from nearby
strangers are kept out of the repo, with the notes saying so where they are
cited.
Binary and runtime inspection. otool -L,
codesign -d --entitlements, strings, dyld_info -exports and Objective-C
runtime introspection (property_getAttributes, objc_getClass) against the
private frameworks, and against a shipping third-party app as a working
existence proof. That is how listeningMode was found to be an ivar-backed
unsigned char rather than the Int it appears to be from Swift, and how the
entitlement question in finding 4 was settled.
Probes on real hardware. Small single-purpose Swift programs that make one
call and report exactly what happened, run against AirPods Pro 2, with results
confirmed audibly and by the firmware's own command echo.
Tools/aap-set-mode-probe.swift is the
published one: it finds the AirPods itself rather than hardcoding an address,
and it reproduces the mode change on a Mac with paired AirPods Pro 2. Expect to
lose Bluetooth audio for a couple of seconds while it races the reconnect, and
expect it to need more than one attempt.
Findings were published as they were reached, including one that turned out to be wrong. This repo previously stated that the AirPods firmware refuses a second AAP client. A capture taken on 2026-08-31 showed the opposite: the refusal comes from the macOS Bluetooth stack, which does not even put the request on the air, while the firmware accepts the connection when it is actually asked. The claim was corrected in place, with the trace that refutes it, in "Resolution part 4". Anything still unverified is labelled as such.
| Command | State |
|---|---|
airpods-ctl list |
works, enumerates connected AirPods |
airpods-ctl set <mode> |
runs and persists state, does not change the device yet |
airpods-ctl get |
prints the last mode this tool set, or unknown |
airpods-ctl toggle |
same caveat as set |
The working AAP path is not in the CLI yet, but it is in the repo as a runnable
reproducer, Tools/aap-set-mode-probe.swift:
swiftc Tools/aap-set-mode-probe.swift -o /tmp/aap-probe -framework IOBluetooth
/tmp/aap-probe # wear the AirPods: it sets Transparency, then ANCIt finds your AirPods itself, opens the channel, and prints every frame it sends and receives, flagging the firmware's echo. It drops your Bluetooth audio for a couple of seconds while it races the reconnect, which is the unsolved part, and it leaves the AirPods in ANC when it finishes.
The transport is abstracted behind a ListeningModeTransport protocol, which
keeps the CLI, the per-device mode store and the tests independent of how a mode
is actually set. The protocol as written is synchronous and per-call, so the
working AAP path does not drop in unchanged: it needs a retry loop, a live
RunLoop for the channel delegate, and probably a resident process to amortise
the reconnect. Expect the protocol to grow an async, session-shaped variant.
Swift 5.10 and SwiftPM. The .app bundle exists to give the binary a bundle
identity that Bluetooth TCC can key on across rebuilds; note the signature is
ad hoc (codesign --sign -), so this is the intent rather than a tested
guarantee.
make build # release binary for the host architecture
make build-universal # universal binary (arm64 + x86_64), needs full Xcode
make app # assemble AirPodsToggle.app under .build/
make install # copy to ~/Applications and symlink CLI to /opt/homebrew/bin/
make test # run unit tests (no hardware needed)
make lint / format # swiftformat, same file set CI checks
make clean # remove build artifactsExit codes: 0 success, 2 no AirPods connected or Bluetooth unavailable,
3 Bluetooth permission denied, 1 anything else.
Tests use Swift Testing. On a
machine with a full Xcode install, make test just works. On a Command Line
Tools-only install, the test bundle compiles but dyld may fail to locate
Testing.framework at runtime, so install Xcode for local testing. CI uses
GitHub's macos-15 runners, which have Xcode pre-installed.
- macOS 15 (Sequoia) or newer. Developed against Tahoe 26.
- Swift 5.10+ (ships with Xcode 15.3+ or Command Line Tools).
- AirPods Pro 2 paired to this Mac.
Two things would help most. First, the audio interruption: opening the AAP
channel currently requires dropping the Bluetooth link, and any way to reach
that channel without doing so would turn this into a usable toggle. Second, the
fallback route: if you know what runtime setup makes
IOBluetoothDevice.setListeningMode: reach the firmware from a resident
process, that solves the same problem from the other side, with no reconnect.
Captures and probes that reproduce, or refute, anything documented here are especially welcome. Findings in this repo have been wrong before, and were corrected by exactly that kind of evidence.
MIT. See LICENSE.
See THIRD_PARTY_NOTICES.md. The AAP protocol layout that seeded this
investigation comes from the
LibrePods reverse-engineering
effort.