Keyboard volume control on Mac

Hi there!

First post, I’m actually still in my Roon trial, be gentle…

It bugged me to no end that the Mac app needed to be in focus in order to change the volume via keyboard simply because Roon is outputting directly to my DAC (and not the general system sound output).

I saw that others here had been looking for a workaround but I couldn’t find a solution that seemed to work, just the answer that Roon just need to be in focus. So I started looking for my own workaround. Here it is.

Caveat: this uses a macOS app called Hammerspoon that allows sending keystrokes to apps in the background (so, it will require giving accessibility privileges in macOS privacy settings).
It decided to keep the Roon shortcuts (command up and command down) and to change the volume in 5 increments. The script below can be edited to change the keyboard shortcut. I’m happy to post a different (and simpler) version of the script, if you want to use 1 increments.

BTW, this also has the potential to send other Roon keystrokes in the background. I’m guessing Mute would be another interesting one.

Step 1: Install Hammerspoon

  1. Download and install Hammerspoon from hammerspoon.org.
  2. Open Hammerspoon from your Applications folder.
  3. Click the Hammerspoon menu bar iconOpen Config.
  • This opens the init.lua configuration file in a text editor.
  • If the file doesn’t exist, create a new one.

Step 2: Paste the Script into init.lua

Copy and paste the following Lua script into Hammerspoon’s init.lua

--
-- Function to send Cmd + Up Arrow to Roon (increase volume by 5)
function sendCmdUpToRoon()
    local roonApp = hs.application.find("Roon")
    if roonApp then
        for i = 1, 5 do -- Loop 5 times for 5 volume increments
            hs.eventtap.keyStroke({"cmd"}, "up", 0, roonApp)
            hs.timer.usleep(50000) -- 50ms delay to prevent missed inputs
        end
    else
        hs.alert.show("Roon is not running")
    end
end

-- Function to send Cmd + Down Arrow to Roon (decrease volume by 5)
function sendCmdDownToRoon()
    local roonApp = hs.application.find("Roon")
    if roonApp then
        for i = 1, 5 do -- Loop 5 times for 5 volume decrements
            hs.eventtap.keyStroke({"cmd"}, "down", 0, roonApp)
            hs.timer.usleep(50000) -- 50ms delay
        end
    else
        hs.alert.show("Roon is not running")
    end
end

-- Bind Command + Up Arrow to send Cmd + Up Arrow 5 times (Volume Up)
hs.hotkey.bind({"cmd"}, "up", sendCmdUpToRoon)

-- Bind Command + Down Arrow to send Cmd + Down Arrow 5 times (Volume Down)
hs.hotkey.bind({"cmd"}, "down", sendCmdDownToRoon)

-- Show an alert when Hammerspoon config reloads
hs.alert.show("Hammerspoon Config Loaded!")

(Sorry, couldn’t find a proper code block to post here, copy/paste the above between horizontal lines should still work.)

Step 3: Reload Hammerspoon

  1. Click the Hammerspoon menu bar icon → Select Reload Config.
  2. Alternatively, press Cmd + Shift + R to reload the configuration.

That should do it. Hope it helps someone else!

1 Like

Hi @gphr37, I’ve added a code tag into your post above to help with the display.
This is how it was done.

[code]
Paste your code or log files exports here.
Paste your code or log files exports here.
Paste your code or log files exports here.
[/code]
1 Like
-- Global flag to track if the Roon window has been unminimized in this batch
local roonWindowShown = false
local scheduledMinimizeTimer = nil

-- Helper function to get Roon's window even if it's minimized
function getRoonWindow()
    local roonApp = hs.application.find("Roon")
    if not roonApp then return nil end
    local win = roonApp:mainWindow()
    if not win then
        local wins = roonApp:allWindows()
        if #wins > 0 then
            win = wins[1]
        end
    end
    return win
end

-- Ensure Roon window is shown (only once per batch)
function ensureRoonWindowIsShown()
    local roonApp = hs.application.find("Roon")
    if roonApp then
        local win = getRoonWindow()
        if win and win:isMinimized() and (roonWindowShown == false) then
            win:unminimize()
            win:raise()
            roonWindowShown = true
        end
    end
end

-- Schedule window minimize after a 2-second cooldown
function scheduleRoonWindowMinimize()
    if roonWindowShown then
        if scheduledMinimizeTimer then
            scheduledMinimizeTimer:stop()
        end
        scheduledMinimizeTimer = hs.timer.doAfter(2, function()
            local roonApp = hs.application.find("Roon")
            if roonApp then
                local win = getRoonWindow()
                if win then
                    win:minimize()
                end
            end
            roonWindowShown = false
            scheduledMinimizeTimer = nil
        end)
    end
end

-- Function to increase volume by sending Cmd+Up
function sendCmdUpToRoon()
    local roonApp = hs.application.find("Roon")
    if roonApp then
        ensureRoonWindowIsShown()
        hs.eventtap.keyStroke({"cmd"}, "up", 0, roonApp)
    else
        hs.alert.show("Roon is not running")
    end
end

-- Function to decrease volume by sending Cmd+Down
function sendCmdDownToRoon()
    local roonApp = hs.application.find("Roon")
    if roonApp then
        ensureRoonWindowIsShown()
        hs.eventtap.keyStroke({"cmd"}, "down", 0, roonApp)
    else
        hs.alert.show("Roon is not running")
    end
end

-- When the key is released, schedule the minimize after 2 seconds
function releaseVolumeKey()
    scheduleRoonWindowMinimize()
end

-- Bind Cmd+Up with repeat; on press/repeat, call sendCmdUpToRoon; on release, call releaseVolumeKey
hs.hotkey.bind({"cmd"}, "up", sendCmdUpToRoon, releaseVolumeKey, sendCmdUpToRoon)
-- Bind Cmd+Down similarly
hs.hotkey.bind({"cmd"}, "down", sendCmdDownToRoon, releaseVolumeKey, sendCmdDownToRoon)

hs.alert.show("Hammerspoon Config Loaded!")