Skip to content
jettoaiPublic

About

Keeps macOS audio devices where you declared them

Topics

Resources

Stars

1 star

Watchers

0 watching

Forks

Repository files navigation

English · 繁體中文 · 简体中文 · 日本語 · 한국어

Cleat app icon

Cleat

by Jetto

macOS keeps moving your audio devices. Cleat moves them back.
AirPods back from the phone, your microphone at your level, your volume where you left it.
Written in Rust, built to cost almost nothing: it reacts to Core Audio events instead of polling, and on the author's Mac it holds about 29 MB of memory and a 3.9 MB binary.

Latest release macOS 14 or later MIT license

Download Cleat for macOS

brew tap jettoai/tap && brew trust jettoai/tap && brew install --cask cleat

Read Install before copying the example config: it is the author's own setup.

Illustrative: Cleat's menu bar menu (Output: AirPods Pro, Input: USB Microphone) over its settings window, with AirPods Pro in use
Illustrative composite with neutral device names. The real settings window is under The window and the menu bar.

What it fixes

macOS and the apps on it change your audio setup without asking. These are the three that happen most, with what Cleat does about each.

The phone keeps your AirPods. An iPhone borrows them and does not give them back, so the Mac plays through its speakers.

Illustrative: AirPods linked to an iPhone, then back to the Mac

Solved: the AirPods are back on the Mac. Cleat asks for them while the Mac is playing; a phone that is playing or on a call keeps them.

A call app turns your microphone down. Apps that "automatically adjust" the input gain leave it at a level you never chose.

Illustrative: a microphone level pulled down by a call app, then held back at its set level

Solved: the level goes back to yours. One level for every microphone, with per-device overrides.

A VM changes your volume. Parallels Desktop, or any app you name, moves the output volume while you work.

Illustrative: Parallels Desktop drops the output volume, then the volume is back

Solved: the volume is back where you left it. Your own changes stay.

It also holds

  • Output and microphone priority lists, with an Exclude list that keeps AirPods Max or Zoom's virtual microphone out of the input slot.
  • Bluetooth headphones take the output when they connect, the way wired ones already do.
  • Balance held at centre, or wherever you set it.
  • Silent receivers count as gone: a wireless mic whose transmitter is off hands over to the next one on your list.
  • Display arrangement remembered and put back after a reconnect (rememberDisplays, off by default).
  • HEIC photos turned into JPEG as they arrive in a folder, such as AirDrop into Downloads (convertHeic, off by default).

A microphone you picked yourself, on neither list, is left alone. All eight rules and their config keys are in What it holds.

Built in Rust

Cleat runs all day in the background, so it is built to use as little of your Mac as it can. The daemon, the menu bar item and the settings window are one Rust program that talks to Core Audio and AppKit directly through the objc2 bindings: no Electron and no web view. It waits for Core Audio events instead of polling for changes.

Measured on the author's Mac
Memory 29 MB (the figure Activity Monitor shows as Memory)
CPU 0.0% when idle; about 0.06% of one core averaged over two days of uptime
Binary 3.9 MB (Cleat.app/Contents/MacOS/Cleat)
Reaction median 36 ms from a change to the put-back (cleat status, last 20)

Cleat 0.4.3 on an Apple M4 Max, macOS 27.0.1, 2026-10-22, with the author's own config. Your numbers move with your config and whether the settings window is open.

A cleat is the fitting a rope gets tied to so the boat stops drifting.

Install

Download the zip from Releases, unzip it into /Applications, and open Cleat once from Finder. That first launch asks for the microphone and registers the launchd agent that ships inside the bundle, so from then on Cleat starts at login and starts again if it is ever killed or crashes. The switch on the settings window's General page turns that off. Opening the app again while the agent is running opens the settings window, so there is never a second daemon.

Or install it with Homebrew, which also puts cleat on your path:

brew tap jettoai/tap
# Homebrew requires third-party taps to be trusted before it will load their casks.
brew trust jettoai/tap
brew install --cask cleat

Then write a config and start it:

Read the example before you copy it. It is the author's own setup, not a neutral starting point: it holds every input device's gain at 100 percent ("inputVolume": {"*": 100, ...}) and it turns headphone takeover on, so any Bluetooth headset becomes the output as soon as it connects. The device names in it are the author's, so the rules that name devices do nothing on your machine until you put your own there. Two settings in it name no device and take effect at once: balance pulls whatever output you are on back to centre, and launchAtLogin registers Cleat as a login item. Edit it for your devices first, or start from {} and add one rule at a time; the settings window can do the editing for you.

mkdir -p ~/.config/cleat
cp /Applications/Cleat.app/Contents/Resources/config.example.json ~/.config/cleat/config.json
cleat restart

cleat restart registers the launchd agent that ships inside the bundle and starts the daemon through it; from then on launchd starts Cleat at login and starts it again if it is ever killed or crashes. A clean quit (the menu's Quit, or brew upgrade replacing the old copy) is left alone on purpose; Cleat comes back at the next login, when you open the app again, or with cleat restart. cleat status says which agent state you are in. Setting launchAtLogin to false unregisters the agent, and Cleat exits with it.

The window and the menu bar

The settings window and the menu follow the system language: Traditional Chinese on a Traditional Chinese system, English otherwise. The config file and the cleat commands are in English.

The daemon puts an item in the menu bar. Its menu shows the output and input in use right now, opens the settings window, shows About, and quits Cleat.

The settings window has five pages: Output, Input, Headphones, Photos and General. Across the top of each page is a status card: whether Cleat is running, its CPU (one core is 100 percent, averaged over the last minute, the same figure Activity Monitor shows), its memory (the same as Activity Monitor's Memory column), and its reclaim speed (how long Cleat takes to put a setting back after something changed it, the median of the recent ones).

  • Output: the priority list, every other output device with an "Add to order" button and an "Exclude" box, the programs whose volume changes are undone (with the time of the last undo), and the balance with a live reading.
  • Input: the microphone priority list, every other input device, and the gain: one slider for every microphone plus a slider for each device you add.
  • Headphones: the takeover switch, the reclaim switch, and a box for each paired Bluetooth headset. Bluetooth devices the system does not identify as audio sit in a collapsed group, for speakers or headphones that do not announce themselves.
  • Photos: the HEIC to JPEG switch (convertHeic), the folder watched, the folder the originals move to, and the long edge of the JPEG (1200, 1600, 2400, 3200 or the original size). Described under Config.
  • General: whether Cleat starts at login and restarts when it dies (launchAtLogin).

Right-click a device in a priority list to move it up or down. Every change is written to the config file a moment later, and the daemon picks it up from there; keys the window does not show are left exactly as they were. If the file changes on disk while the window is open, the window stops writing and offers to reload.

Open it from the menu bar item, with cleat settings, or by opening Cleat.app from Finder, Spotlight or Raycast while the daemon is running. Only one settings window is open at a time.

The four pages, as they look on the author's Mac

Output: the priority list, the Exclude marks, and the volume hold against Parallels Desktop. It follows your light or dark mode.
Cleat's Output page: a priority list with the headphones In use, two other outputs marked Excluded, and an output volume and balance card

Input: the microphone priority list, the blocked virtual devices, and the gain held at 100 percent.
Cleat's Input page: a priority list with a wireless microphone In use, three devices marked Excluded or Offline, and an input volume card

Headphones: the takeover and reclaim switches, and a box for each paired Bluetooth headset.
Cleat's Headphones page: the takeover and reclaim switches both on, two headsets ticked, and a collapsed group of other Bluetooth devices

These are real captures, so the device names are the ones on the author's Mac.

What it holds

Six rules are the same shape: subscribe to a Core Audio property, compare against the config, write back if they differ. The output volume rule also has to know which program made a change, which Core Audio does not say, so it reads that from the system log. The reclaim rule asks another daemon for a headset back, because a headset a phone has taken is not a Core Audio property to write.

What Config
1 Input device priority list, with a blocklist input, blockedInput
2 Treat a device sending exact digital silence as absent liveness
3 Hold the output balance balance
4 Output device priority list, with a blocklist output, blockedOutput
5 Hold input gain per device inputVolume
6 Bluetooth headphones take over the output when they connect headphonesTakeOver
7 Ask a Bluetooth headset back when a phone has taken it reclaim, reclaimEnabled
8 Undo output volume changes made by the programs you list outputVolumeHoldAgainst, outputVolumeHoldEnabled

It never takes over a device you picked yourself. If the current default input is not on your priority list and not on the blocklist (a mic you chose in System Settings, Zoom's or Teams' virtual device), Cleat leaves it alone.

Config

~/.config/cleat/config.json, re-read within a second of being saved:

{
  "input": ["Wireless microphone", "Brio 100"],
  "blockedInput": ["AirPods Max"],
  "output": ["外接耳機", "Mac Studio的揚聲器"],
  "blockedOutput": ["Maono AI Microphone"],
  "headphonesTakeOver": true,
  "balance": 0.5,
  "inputVolume": { "*": 100, "Wireless microphone": 88, "Brio 100": 75 },
  "liveness": { "Wireless microphone": { "zeroSeconds": 3 } },
  "reclaim": ["AirPods Max"],
  "outputVolumeHoldAgainst": ["Parallels Desktop"],
  "launchAtLogin": true,
  "errorReports": false
}
Field Type Default Meaning
input array of strings [] Input priority, most preferred first. Empty turns the rule off
blockedInput array of strings [] Never the default input. See Not used for where Cleat moves it
output array of strings [] Output priority. Empty turns the rule off
blockedOutput array of strings [] Never the default output. See Not used for where Cleat moves it
headphonesTakeOver boolean false Bluetooth output devices take the output when they connect
balance number or null null 0.0 (left) to 1.0 (right); 0.5 is centred. null turns the rule off
inputVolume object {} Device name, or "*" for every input device, to percent, 0-100
liveness object {} Device name to { "zeroSeconds": N }, N at least 1
reclaim array of strings [] Bluetooth headsets to ask back when another device holds them, by name or address
reclaimEnabled boolean true Turns reclaim off without emptying reclaim
outputVolumeHoldAgainst array of strings [] Programs whose changes to the output volume are undone
outputVolumeHoldEnabled boolean true Turns the volume hold off without emptying its list
launchAtLogin boolean true Register the launchd agent that starts Cleat at login and restarts it if it dies
rememberDisplays boolean false Remember the arrangement you last set for each combination of displays and restore it when macOS rearranges it. Details below
convertHeic boolean false Convert HEIC pictures that arrive in heic.watch to JPEG. Details below
heic object see below watch (default "~/Downloads"), originals (default "HEIC原檔"), longEdge (default 1200, or 0 for the original size, else 800 to 8000) and quality (default 60, 50 to 100). Every key is optional
errorReports boolean false Send crash and error reports to Sentry. See Privacy
usagePing boolean true Send the anonymous daily usage ping. See Privacy

Remember displays. "rememberDisplays": true makes Cleat remember where every display sat the last time you arranged each combination of displays (kept by UUID in ~/Library/Application Support/Cleat/display-arrangements.json), and put the whole set back in one step when macOS rearranges it after a reconnect. A combination Cleat has not seen is remembered as it is. A difference that shows up within ten seconds of Cleat's own write or of the set of displays changing is taken for macOS and undone (restoreDisplays in the log, three tries at most per combination); one that shows up later is you dragging a display in System Settings, and becomes the new memory (rememberDisplays saved ... moved by hand). So after Cleat restores a layout, wait ten seconds before rearranging. Only positions are remembered, not resolutions. Displays that report no usable UUID, or the same UUID, are skipped.

Photos. "convertHeic": true, or the switch on the Photos page of the settings window, makes Cleat turn every HEIC picture that arrives in heic.watch (the first level only, ~/Downloads unless you choose another folder) into a JPEG next to it. It is off by default, because it moves your files.

  • Event driven. Cleat watches the folder with kqueue and never polls. While the folder is quiet nothing runs, and with the switch off there is no watch and no thread.
  • Only new pictures. A .heic or .heif file (any case) that was already in the folder when the watch started is left alone. A new one is converted once its size has held still for a second.
  • The JPEG is <name>.jpg in the same folder (<name> 2.jpg if that name is taken, never an overwrite), heic.longEdge pixels on the long side (1200 unless you choose 1600, 2400, 3200 or the original size), at heic.quality (60, set in the config file only). It is turned upright, keeps the Display P3 colour profile and carries no location.
  • The original moves to heic.originals: a name is a folder inside the watched one (HEIC原檔), a path is used as it is. Nothing is deleted. A Live Photo's movie is not touched.
  • Permission. The first time, macOS asks whether Cleat may use the Downloads folder; allow it. If you refuse, the Photos page shows an orange line and cleat status says no-access.

cleat status shows a photos: line, for example photos: watching, 3 converted, last IMG_5708.HEIC 187 ms, or photos: off. Each conversion is one line in the log (heic: IMG_5708.HEIC -> IMG_5708.jpg 1800x2400 187 ms, original -> HEIC原檔/).

Input gain. "*" sets the target for every input device present, and a named entry overrides it for that device: {"*": 100, "Brio 100": 75} holds everything at 100 percent except the Brio, which is held at 75. Without a "*" entry, a device the config does not name is left alone. The wildcard covers blocked devices too, so an AirPods Max kept out of the input slot by blockedInput still has its gain held, and devices whose gain cannot be read (some virtual devices) are left alone either way.

Headphones. When a Bluetooth output device appears, it becomes the output. Choosing another device by hand while it stays connected is respected: with headphonesTakeOver on, output never moves the sound off a connected Bluetooth device and never moves it onto one, unless that device is on blockedOutput. That list says "never this one", and it outranks "this one is a headset". So the priority list decides what plays when no headphones hold the output. macOS does this for wired headphones already and iOS does it for AirPods; over Bluetooth on a Mac, reconnecting a headset that was last paired to a phone leaves the sound coming out of the speakers, which is the gap this fills. Headphones already connected when Cleat starts are not treated as having just arrived, so restarting Cleat never moves the output.

blockedOutput is the other half of it. Some USB microphones carry a speaker end, and that is where macOS lands when the headphones leave. It is not on your priority list, so without the blocked list Cleat would read it as an output you picked yourself and leave it there.

Not used. A device on blockedInput or blockedOutput is never picked by Cleat, and when macOS makes it the default anyway, Cleat moves off it:

  • Input: to the first listed microphone with signal, else to the built-in microphone, else to a listed microphone that is silent or still being measured (it gives way once a listed one has signal). Apart from the built-in microphone, a microphone that is not on input is never used as the way out.
  • Output: to the first listed output, else to any physical output, built-in first, then by name. Virtual, aggregate, Continuity and AirPlay devices, and a headset that headphonesTakeOver owns, are never used as the way out.

When there is nowhere to go, the device stays, status.json names it under stuck, and the settings window says so. A blocked device that is not physical (a meeting app's virtual microphone, an iPhone over Continuity) is only never picked: if an app switches to it itself, Cleat leaves it there. If macOS keeps putting a blocked device back, Cleat gives up after three moves in a minute and tries again once a device is added or removed, the config reloads, or a microphone's signal changes.

Reclaim. AirPods paired to both a Mac and a phone belong to whichever one last asked for them. The phone asks by playing something; when it stops, nothing on the Mac asks again, so the headset stays with the phone (still connected over Bluetooth, gone from the audio device list) and the Mac's sound comes out of the speakers for the rest of the day. macOS only reclaims the headset when an app on this side starts playing, and by then the sound has already gone somewhere else.

Listing a headset under reclaim asks for it back. Cleat sends the same routing request macOS itself sends, and the accessory decides between the two devices by what each one is doing: the request says this Mac has a playback session, which outranks a phone sitting idle and is outranked by a phone playing media or on a call. So an idle phone gives the headset up and a phone actually using it keeps it. Five things have to be true before a request goes out: the headset is listed, it is connected to this Mac, it has no audio device here, something is playing through whatever holds the output, and someone is at the Mac (a key or mouse touched in the last 30 seconds, or the frontmost app playing a video). A display kept awake by a meeting or caffeinate does not count as someone. Moving the output off the headset by hand is respected for the rest of that playback. A headset that refuses because nobody is wearing it yet is asked again every 8 seconds, for up to 3 minutes, while the Mac keeps playing. A headset the phone keeps is left alone for a minute; after that Cleat asks again at the next audio change it reacts to, such as playback starting or a device connecting. The log says it once rather than once a beat. When a request is accepted, the log says how long the headset took to become the output, or that it did not come back.

A headset may be named by its Bluetooth address ("70:F9:4A:B6:0C:C9", dashes and lower case accepted) as well as by name, which is how two headsets called the same thing are told apart. cleat reclaim sends one request by hand and prints the answer, which is the way to see what the arbitration is saying. This rule uses a private system interface: on a macOS that does not have it, the rule turns itself off, says so once in the log, and cleat status reports it as unavailable rather than on.

Output volume. Core Audio says that the volume changed, not who changed it, so Cleat reads the writer from the system's audio log. A change by a program on outputVolumeHoldAgainst is put back to the value before it; a change by you or by any program not on the list becomes the new value to hold. An entry is an executable's name or an app's name: Parallels Desktop matches anything inside Parallels Desktop.app.

Naming a device. Use the name shown in System Settings, or its Core Audio UID if two devices share a name. Names are compared after Unicode normalisation, because some devices carry a no-break space in their name that you cannot type (Maono's, for one). Case is not normalised.

A malformed or out-of-range config never replaces a working one: the previous settings stay in force and cleat status says why. Before any config has loaded, every rule is off. Removing the file also turns every rule off, which is the way to switch Cleat off without quitting it.

Silence detection (liveness) is the one feature that opens the microphone. Cleat runs a HAL IOProc on the listed device and checks whether every sample in the buffer is exactly zero: a real microphone always has some noise floor, a receiver with its transmitter off sends nothing. After zeroSeconds of that, the device counts as absent and the next device on the priority list takes over. Because the input is open, macOS shows the orange microphone dot while Cleat is watching.

Commands

The app bundle is the CLI. The Homebrew cask links it as cleat.

cleat status      # what it is holding right now, and why
cleat log -n 50   # recent events
cleat restart     # start the daemon, or replace the running one, through its launchd agent
cleat reclaim     # ask for the headsets under "reclaim", once, and print the answer
cleat settings    # open the settings window
cleat version

cleat restart is the one to reach for when Cleat is not running: it registers the agent if that has not happened yet, then has launchd replace the process. Nothing else has to be started by hand.

cleat status reads ~/Library/Application Support/Cleat/status.json; cleat log reads ~/Library/Logs/Cleat/cleat.log. The commands never talk to the daemon, they read the files it writes. Only actions that changed something are logged, so a quiet log means a quiet day, not a broken daemon; status is what tells you it is alive.

Microphone permission

Only liveness needs it. macOS asks the first time Cleat runs; if you decline, every other rule keeps working and cleat status shows microphone: denied. To change your mind: System Settings

Privacy & Security > Microphone, then cleat restart. Cleat reads the permission at launch and does not watch that switch.

This is also why Cleat is an .app rather than a bare binary on a LaunchAgent: a command-line tool started by launchd is often never asked, and the request fails silently instead. The agent that supervises the daemon starts the app bundle's own binary (BundleProgram), so the process launchd brings back is the same app the microphone was granted to, not a loose executable.

Privacy

Cleat sends two things off your Mac. The first is an anonymous usage ping, on by default: when the daemon starts and about once a day while it runs, it sends a random device ID, the Cleat version and the product name to jetto.ai so we can count installs and active users. It never sends your config, device names or file contents. Like any web request it reaches the server with your IP address, which is not stored with the ping. Set "usagePing": false in the config and nothing more is sent. The second is opt-in: set "errorReports": true in the config and the daemon sends crash and error reports to Sentry, so crashes reach the author without anyone filing an issue. A report carries the stack trace or the error message, the Cleat and macOS versions, and the time. It carries no IP address, no user, not your config and not the contents of any file, and your home folder in file paths is replaced with ~. Sentry, like any server, sees the connection the report arrives on. Apart from that daily ping there is no usage tracking: no events, no session tracking and no screen recording.

Set it back to false, or delete the line, and reporting stops as soon as the config is re-read; no restart needed. Reports still waiting to go are deleted, and one already on its way is cut off. cleat status shows which way it is set. The cleat commands and the settings window never send anything.

Build from source

cd rs
cargo build --release
cargo test
bash scripts/bundle.sh    # target/bundle.noindex/Cleat.app

scripts/bundle.sh builds an ad-hoc signed bundle with the development identity ai.jetto.cleat.rs, which never registers a login agent; rs/README.md explains how to run it under launchd for testing.

Uninstall

brew uninstall --cask --zap cleat

Or, for a manual install: unload the agent with launchctl bootout gui/$UID/ai.jetto.cleat, delete /Applications/Cleat.app, and remove ~/Library/Application Support/Cleat, ~/Library/Logs/Cleat and ~/.config/cleat.

License

MIT

About

Keeps macOS audio devices where you declared them

Topics

Resources

Stars

1 star

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages