|
-- 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 |