Skip to content

Instantly share code, notes, and snippets.

@wojas
Created November 13, 2025 13:46
Show Gist options
  • Select an option

  • Save wojas/c9f79ffd7eb68aaff6d892de4d52c733 to your computer and use it in GitHub Desktop.

Select an option

Save wojas/c9f79ffd7eb68aaff6d892de4d52c733 to your computer and use it in GitHub Desktop.
macOS sound volume limiter for Hammerspoon

(The following description and code is generated by ChatGPT)

Here’s a drop-in volume limiter for Hammerspoon (open source macOS automation app using Lua) that enforces per-output-device max volume, and automatically follows the active output device.

Paste this into ~/.hammerspoon/init.lua (or a separate file and require it), then edit the config table at the top.

(see file below)

How to use

  1. Paste the code into your Hammerspoon config.

  2. Reload Hammerspoon (hs.reload() or from the menu bar icon).

  3. Open the Hammerspoon console and run:

    volumeLimiter.dumpOutputDevices()

    Copy the UID/Name strings you care about.

  4. Fill in volumeLimiter.limitsByUID and/or volumeLimiter.limitsByName with your desired caps.

  5. From now on, whenever:

    • the default output device changes, or
    • you change volume on the current output device,

    Hammerspoon will snap the volume back down if it goes above the configured limit for that device.

If you want, I can help you tune the config once you paste the output of dumpOutputDevices().

-- Volume limiter for Hammerspoon
-- Limits system output volume depending on the *active* output device.
--
-- Configuration ----------------------------------------------------------------
local volumeLimiter = {}
-- Max volume per device UID (0–100).
-- To discover UIDs, call `volumeLimiter.dumpOutputDevices()` from the Hammerspoon console.
volumeLimiter.limitsByUID = {
-- ["AppleHDAEngineOutput:1B,0,1,1:0"] = 25, -- Example: built-in speakers
-- ["Some-USB-DAC-UID"] = 80,
}
-- Optional: max volume per device *name* (used if UID is not in the table).
volumeLimiter.limitsByName = {
-- ["MacBook Pro Speakers"] = 30,
-- ["LG HDR 4K"] = 40,
}
-- Fallback if no UID/name match is found:
volumeLimiter.defaultLimit = 100 -- set to e.g. 50 if you want a global hard cap
-- Internal state ----------------------------------------------------------------
local currentOutputDevice = nil
-- Helpers -----------------------------------------------------------------------
-- Get the limit for a given device
function volumeLimiter.limitForDevice(dev)
if not dev then return volumeLimiter.defaultLimit end
local uid = dev:uid()
local name = dev:name()
if uid and volumeLimiter.limitsByUID[uid] then
return volumeLimiter.limitsByUID[uid]
end
if name and volumeLimiter.limitsByName[name] then
return volumeLimiter.limitsByName[name]
end
return volumeLimiter.defaultLimit
end
-- Enforce the limit on a specific device (or on the current default output)
function volumeLimiter.enforce(dev)
dev = dev or hs.audiodevice.defaultOutputDevice()
if not dev then return end
local limit = volumeLimiter.limitForDevice(dev)
if not limit then return end
-- Prefer outputVolume() for output devices
local vol = dev:outputVolume() or dev:volume()
if not vol then return end
if vol > limit then
dev:setOutputVolume(limit)
-- Optional: visual feedback
hs.alert.show(string.format(
"Volume limited to %d%% on %s",
limit,
dev:name() or dev:uid() or "output"
), 1)
end
end
-- Attach a per-device watcher to the current default output device
local function attachOutputDeviceWatcher()
local dev = hs.audiodevice.defaultOutputDevice()
if not dev then
currentOutputDevice = nil
return
end
-- Stop watcher on previous device (if any)
if currentOutputDevice and currentOutputDevice:watcherIsRunning() then
currentOutputDevice:watcherStop()
end
currentOutputDevice = dev
dev:watcherCallback(function(uid, event, scope, element)
-- We only care about output volume changes
if scope == "outp" and event == "vmvc" then
volumeLimiter.enforce(dev)
end
end)
dev:watcherStart()
-- Immediately clamp in case current volume is already too high
volumeLimiter.enforce(dev)
end
-- Global watcher: re-attach when default output device changes
hs.audiodevice.watcher.setCallback(function(event)
-- dOut = default output changed, sOut = system output changed
if event == "dOut" or event == "sOut" then
attachOutputDeviceWatcher()
end
end)
hs.audiodevice.watcher.start()
-- Public helper: dump output devices with name + UID (for filling the config)
function volumeLimiter.dumpOutputDevices()
local devs = hs.audiodevice.allOutputDevices()
for _, dev in ipairs(devs) do
print(string.format("Name: %-30s | UID: %s",
dev:name() or "<no name>",
dev:uid() or "<no uid>"
))
end
end
-- Initialise on load
attachOutputDeviceWatcher()
-- Export if you want to require this from another file
return volumeLimiter
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment