Noise-cancellation control for your earbuds on macOS. AirPods menu bar experience, for buds that never got one.
MacOS gives AirPods a proper noise-mode control in the menu bar. Everyone else gets the touch gesture on the bud: tap and hold, guess which mode you landed on, try again. BudsCtl puts Off · Noise Cancellation · Transparency in your menu bar, in Control Center, in Shortcuts, and on a global hotkey with battery for each bud.
Apple Silicon · macOS 26+ · lives in the menu bar
| Verified | Should work | How | |
|---|---|---|---|
| SoundPEATS | Air4 Pro | other SoundPEATS models | Qualcomm GAIA V2 over Bluetooth LE |
| Samsung Galaxy Buds | none | Buds FE, Buds Pro, Buds2, Buds2 Pro, Buds3, Buds3 Pro, Buds3 FE, Buds Core, Buds4, Buds4 Pro | Samsung SPP over Bluetooth Classic RFCOMM |
Verified means confirmed against real hardware — one device, the Air4 Pro. Everything in the "should work" column shares a protocol with a verified device and is expected to work, but nothing else has been tested, and that includes every Galaxy Buds model: the Samsung side of this app has never been run against a real earbud. It is implemented from a reverse-engineered protocol reference and covered by unit tests over synthetic bytes, nothing more. If you try one, please open an issue either way.
Not supported: the original Galaxy Buds (2019) use different framing, and Buds+ and Buds Live use an older ambient-sound model rather than the three-way noise control this app is built around.
- The three modes, one click: Off, Noise Cancellation and Transparency as a segmented picker in the menu bar.
- Global hotkey:
⌥ + ⌘ + Ncycles modes from anywhere, without taking your hands off the keyboard or touching the buds. - Control Center tile: add "Earbuds Noise Mode" to Control Center and tap to cycle.
- Shortcuts and automations: Set Noise Mode, Cycle Noise Mode, Get Noise Mode and Get Earbud Battery as App Intents. Switch to ANC when a meeting starts, or to Transparency when you unlock the Mac.
- Per-bud battery: It indicates left and right levels in the panel.
- Private, and quiet: One Bluetooth connection to your own earbuds. No network access, no telemetry, no account. Menu bar only: no Dock icon, no window.
- Download
BudsCtl-1.1.dmgfrom the latest release. - Open it and drag BudsCtl into Applications.
- Launch BudsCtl, an ear icon appears in your menu bar.
⚠️ Not yet notarized. Releases are signed, but with a development certificate, so Gatekeeper will refuse to open the app on a Mac that isn't mine. Until that changes, either build from source (below) or clear the quarantine flag yourself:xattr -dr com.apple.quarantine /Applications/BudsCtl.appOnly do that for software you're willing to trust, which is a good argument for building it yourself.
- Pair your earbuds with the Mac in System Settings ▸ Bluetooth, as usual. BudsCtl controls buds macOS is already connected to; it doesn't replace pairing.
- Click the menu bar icon, open Settings at the bottom of the panel, and pick your earbuds from the list. Connected devices appear without any scan. If yours doesn't, take a bud out of the case and hit Scan for More.
- Grant Bluetooth access when macOS asks. Without it the app can't see any earbuds.
- Turn on Launch at login so the menu bar control is simply always there.
| How | |
|---|---|
| Menu bar | Click the icon, click a mode. Shows current mode and battery. |
| Hotkey | ⌥⌘N cycles (Off, ANC, Transparency). Rebind it in Settings. |
| Control Center | Edit Control Center, add Earbuds Noise Mode, tap to cycle. |
| Shortcuts | Four intents, usable in Shortcuts, Focus filters and automations. |
Control Center and Shortcuts work even when the app isn't running, the request is queued and BudsCtl launches to apply it.
There is no public API for this. The earbuds speak GAIA V2 Qualcomm's control protocol over Bluetooth LE, so the protocol here was reverse engineered from the traffic to a device I own: read the mode, set the mode, read each battery, read the firmware version. BudsCtl holds one BLE connection, keeps a small snapshot of what the buds reported, and hands that snapshot to Control Center and Shortcuts so they never have to open a connection of their own.
The awkward part isn't sending commands, it's trusting what comes back. The buds don't answer reliably in the first moments after connecting, and they don't announce it when they later settle into their saved mode. So BudsCtl re-reads a few times over the first minute and says "Reading mode…" rather than showing you a guess.
Galaxy Buds work differently enough to be worth a sentence. They speak a Samsung protocol over Bluetooth Classic RFCOMM rather than Bluetooth LE, so they go through IOBluetooth instead of CoreBluetooth — and unlike the SoundPEATS buds, they volunteer their full state the moment the connection opens and announce every mode change, including ones you make by touch or from your phone. So there is no "Reading mode…" guesswork for them: the protocol reverse-engineered by GalaxyBudsClient does the work, and this app just listens.
- Protocol: GAIA V2 framed as
<vendor 00 0A> <command:2> <payload…>, replies with the command's high bit set plus a status byte (GaiaFrame). Only the0x03xxcommand group is even named in this codebase —0x01xx/0x02xxcontain device reset and power off, and this app has no reason to know them. Verified against firmwareAIR4PRO-BS588R2E_20241112_v0.2.1. The status byte is advisory: this firmware answers0x01for merely unsupported commands, so state is always confirmed by reading, never inferred from an ack. - Transport: CoreBluetooth against the GAIA service
00001100-D102-11E1-…, write to the command characteristic, notifications on the response one (GaiaClient,GaiaTransport). - Mode settling: The reading taken as the link comes up is the least trustworthy one there is: it times out, or reports Off whatever the buds are really doing, and the buds then say nothing when they settle (measured at 33 s of silence after a case-exit connect). So
DeviceControllerre-reads at 2, 5, 10, 20 and 45 s after connecting and stops the instant you set a mode yourself. Five reads per connection bounded, not polling. - Optimistic UI, honest UI: A set shows immediately as
pendingModeduring the device's ~1.4 s apply window, butmodeonly ever holds what the device confirmed (DeviceState). While the mode is unknown the panel spins instead of presenting a guess as a selection. The Control Center button repaints on the optimistic value too, so a tap is not silent for 1.4 s; the snapshot carries apendingflag soSetModeIntentstill only reports success on a confirmed mode. - One connection, three front ends: The app is the only process that touches CoreBluetooth. The Control Center extension and the Shortcuts intents talk to it through
StateBridge: a snapshot the agent writes and they read, a request they write and it drains, both in App Group defaults, nudged by a payload-free Darwin notification. Requests carry a monotonicseqin the same stored value as the payload — two keys meant a second process could read a fresh payload beside a stale seq and apply one Control Center tap twice. - Cold start: If nothing picks a request up within 2 s the agent isn't running, so the intent calls
continueInForeground()to launch it. The agent drains pending requests on start, so the original request still lands and is never re-posted (Intents). - Only one Control Center control: macOS 26.5 has no
ControlWidgetBundle, andControlWidgetConfigurationBuildertakes a single configuration, so per-mode direct controls can't be built in one extension. Hence one cycle button (Controls.swift).
brew install xcodegen
xcodegen generate
xcodebuild -project BudsCtl.xcodeproj -scheme BudsCtl -configuration Debug \
-derivedDataPath .build/xcode build
cp -R .build/xcode/Build/Products/Debug/BudsCtl.app /Applications/
open /Applications/BudsCtl.appproject.yml is the source of truth; BudsCtl.xcodeproj, App/Info.plist and Controls/Info.plist are build artifacts and are not committed.
The build is tied to an Apple Team ID JCXZ7458UT here and a fork has to change it. Not for signing's sake, the menu bar agent and the Control Center extension are two sandboxed processes, and the only way they can share state is an App Group, whose ID macOS requires to be <TeamID>.<something> and checks against the signature at launch. If you add wrong prefix, the two processes quietly read different stores. App says "Shared storage unavailable" in Settings and Control Center shows nothing while the menu bar keeps working.
Two lines to change:
| Where | What |
|---|---|
project.yml |
DEVELOPMENT_TEAM: your 10-character Team ID |
Identifiers.swift |
teamID the same value, for the Swift side |
The entitlements files name no team at all: they derive the group from $(DEVELOPMENT_TEAM), expanded at build time. (Deliberately not the more familiar
$(TeamIdentifierPrefix) that one is populated from a provisioning profile, and this app embeds none, so it would expand to empty and break the App Group silently.) A free
Apple ID is enough to build and run it yourself; see Install for what it is not enough for.
swift test # 132 tests, no hardware needed
swift run budsctl-cli status # against real hardware
swift run budsctl-cli samsung <mac> # verify the Galaxy Buds SPP protocol against real hardwareThe BLE layer was proven with the CLI before any UI existed, and it's still the fastest way to see what the buds are doing:
budsctl-cli discover scan for earbuds and save the chosen one
budsctl-cli status connect and print mode, battery, firmware
budsctl-cli set <mode> normal | anc | passthrough
budsctl-cli watch print every frame the device sends, until Ctrl-C
./Tools/makedmg.sh # Release build → BudsCtl-<version>.dmghdiutil ships with macOS, so there's nothing to install. The script signs nothing of
its own — the app carries whatever identity built it, which is why current releases are
development-signed rather than notarized.
- One device, verified: Only SoundPEATS Air4 Pro on firmware v0.2.1 has been tested against real hardware. Other GAIA devices may work; expect nothing. The Galaxy Buds side has not been run against any real earbud at all — see Supported earbuds.
- Not notarized: Gatekeeper blocks the released DMG. See the install note.
- macOS 26+ and Apple Silicon: The project targets macOS 26 with Swift 6 strict concurrency. There's no back-deployment.
- One Control Center control: For the SDK reason above cycle, not three buttons.
- Reverse engineered, not documented: GAIA V2 is Qualcomm's, undocumented publicly, and derived here from traffic to my own device. A firmware update could change it. The Samsung SPP side is reverse engineered too, but from a third-party protocol reference rather than a packet capture of a device I own — see Supported earbuds.
Global hotkey handling uses KeyboardShortcuts by Sindre Sorhus (MIT).
The Samsung SPP protocol implementation was written against the reference kept by GalaxyBudsClient.
BudsCtl is not affiliated with, endorsed by, or supported by SoundPEATS, Qualcomm, or Samsung. GAIA is Qualcomm's protocol and SPP is Samsung's; the implementations here were written for interoperability, the GAIA one with a device the author owns and the Samsung one from a reverse-engineered protocol reference only.