Skip to content

Instantly share code, notes, and snippets.

@brucesdad13
Last active June 1, 2024 13:25
Show Gist options
  • Save brucesdad13/4ac8ceeb904903b9346fc1fa4d579c73 to your computer and use it in GitHub Desktop.
Save brucesdad13/4ac8ceeb904903b9346fc1fa4d579c73 to your computer and use it in GitHub Desktop.
Raspberry Pi volume knob with mute using rotary encoder and Python

Raspberry Pi volume knob with mute using rotary encoder and Python

Adjust the volume or mute the active audio device on your Raspberry Pi using a rotary encoder and a few GPIOs. This project was inspired by the Orthopi mechanical keyboard and savetheclocktower rotary encoder volume control. Holding down the knob launches PulseAudio Volume Control. Tested on Raspberry Pi models 3B+, 4, and 400 with HDMI audio output, analog out (on the 3B+), and with a wireless headset via USB dongle. It works with Raspbian Bullseye 32-bit and 64-bit and Retropi--possibly older releases but I haven't tested.

Rotary encoder WikiCommons

Hardware Requirements

Hardware requirements for this project are a Raspberry Pi, a cheap rotary encoder, jumper wires, and 3 free GPIO pins.

Software Requirements

This is a simple high-level implementation using the well documented gpiozero library that I learned to use in The Official Raspberry Pi Beginner's Guide. Specifically, this project uses gpiozero RotaryEncoder and Button. Instead of alsa, as the OS level command for muting and setting volume, I'm using the pulse audio utility pactl which seems to be installed out of the box on my Raspberry Pi 3, 4, and 400 all running Raspbian. Currently, this script is less than 100 lines and doesn't require admin privileges or creating a systemd service. To install I created a symlink to the script in $HOME/.local/bin. I use BASH as my shell, so I appended the script name to my .bashrc. You can also simply run the script.

Download gpiozero_volknob.py

Download the Python script below from Github into your .local/bin/ folder

cd $HOME/.local/bin
wget https://gist.githubusercontent.com/brucesdad13/4ac8ceeb904903b9346fc1fa4d579c73/raw/6bfa66ce287caae835b9b871855425d328e8fdab/gpiozero_volknob.py
chmod 755 gpiozero_volknob.py

Installing Debian packages

sudo apt update
sudo apt install pulseaudio pulseaudio-utils pavucontrol python3 python3-gpiozero python3-pigpio python3-rpi.gpio

Note: on RetroPi I had to reboot after installing the above packages which seemed to fire up the pulseaudio server. I also futzed with some of the audio settings on the RetroPi and TBH am not sure what I changed compared with the stock install. I'll have to do a diff compare someday...

Updating .bashrc

If you want the knob to automatically work as soon as you login, append this snippet to your .bashrc or equivalent shell rc file. This will launch gpiozero_volknob.py in the background. If you need this to work for multiple users see the systemd example here and adapt:

# set PATH so it includes user's private bin if it exists
if [ -d "$HOME/.local/bin" ] ; then
    PATH="$HOME/.local/bin:$PATH"
fi

GPIOZERO_VOLKNOB_PID="$HOME/.gpiozero_volknob.pid"
if ! ps up `cat $GPIOZERO_VOLKNOB_PID 2>/dev/null` &> /dev/null; then
	nohup gpiozero_volknob.py &> /dev/null &
	echo $! > $GPIOZERO_VOLKNOB_PID
fi

Prechecks

Running these commands will give you an idea of whether or not everything is in place for the script to function. First run pactl list sinks and make a note of which sink # is "RUNNING". With some audio playing, try muting it by running pactl set-sink-mute 0 toggle (substituing your active sink # for 0). Run the command again to unmute. If that worked you should be ready. You can also play with setting your volume level pactl set-sink-volume 0 60% (substituting your active sink # for 0).

Basic Troubleshooting

  1. Nothing happens when the knob is turned or button pushed:
    • Ensure that gpiozero_volknob.py is running (ps -ef|grep volknob)
    • Check that your wiring matches the pins in the code and or edit the code
      • GPIO BCM pin 22 goes to the encoder push button switch pin
      • GPIO BCM pin 18 goes to rotary encoder CLK pin
      • GPIO BCM pin 17 goes to rotary encoder DT pin.
  2. Volume goes up when you want it to go down or vice versa
    • Switch the CLK and DT wires or the pin numbers in RotaryEncoder()
#!/usr/bin/env python3
r"""__ ___ (_)__ ___ ___ _______ _ _____ / / / /__ ___ ___ / /
/ _ `/ _ \/ / _ \/_ // -_) __/ _ \ | |/ / _ \/ / / '_// _ \/ _ \/ _ \
\_, / .__/_/\___//__/\__/_/ \___/ |___/\___/_/ /_/\_\/_//_/\___/_.__/
/___/_/
Volume control using rotary encoder by Charles Stevenson <brucesdad13>
Inspired by:
https://www.40percent.club/2020/12/orthopi-rotary-encoder.html
https://gist.github.com/savetheclocktower/9b5f67c20f6c04e65ed88f2e594d43c1
References:
https://gpiozero.readthedocs.io/en/stable/api_input.html#rotaryencoder
Requirements:
pulseaudio-utils: /usr/bin/pactl (or somewhere in $PATH)
pavucontrol: /usr/bin/pavucontrol (optional opens mixer when button held)"""
from signal import pause
from subprocess import check_output
from time import sleep
from gpiozero import RotaryEncoder, Button
# GPIO pins 4 and 27 for rotary encoder and GPIO pin 22 for push button switch
encoder = RotaryEncoder(4,27,bounce_time=0.1)
BUTTONBOUNCE=0.25 # seconds
button = Button(22,bounce_time=BUTTONBOUNCE,hold_time=0.5) # 1/2s long press
# set up command lists for subprocess.check_output() arguments
CMDGETSINKVOL = ["pactl","list","sinks"]
CMDTOGGLEMUTE = ["pactl","set-sink-mute","0","toggle"]
CMDSETSINKVOL = ["pactl","set-sink-volume", "0", "50%"]
CMDOPENMIXER = ["pavucontrol"]
SINKPOS=2 # position of the sink number in the subprocess argument array
VOLPOS=3 # position of the volume percentile in the subprocess argument array
MAXVOL=100 # integer maximum volume
MINVOL=0 # integer minimum volume
VOLSTEP=5 # if 1 is too fine try setting to 5 or 10 for example
CURVOL=MINVOL # integer representation of current volume percentile
def get_sink_vol():
"""Get active sink and volume level (from left chan) via stdout parsing"""
global CURVOL
output = check_output(CMDGETSINKVOL)
output = output.decode('utf-8') # convert from bytes to UTF-8 string
sinks = output.split("Sink #")[1:] # Assumes many sinks but only 1 active
for sink in sinks: # find active sink
if sink.find("RUNNING") != -1:
sink_id = sink.split("\n")[0].strip() # Get the sink number
CMDTOGGLEMUTE[SINKPOS] = CMDSETSINKVOL[SINKPOS] = sink_id #@ update cmds
volume = sink.split("Volume: front-left: ")
volume = volume[1].split("/")[1].strip()
CURVOL = int(volume[:-1])
return CURVOL
def increase_volume(): # knob turned clockwise
"""Increase the volume by VOLSTEP %"""
global CURVOL
if get_sink_vol() < MAXVOL:
CURVOL += VOLSTEP
set_volume()
def decrease_volume(): # knob turned counter-clockwise
"""Decrease the volume by VOLSTEP %"""
global CURVOL
if get_sink_vol() > MINVOL:
CURVOL -= VOLSTEP
set_volume()
def set_volume():
"""Set the volume by executing OS command"""
CMDSETSINKVOL[VOLPOS] = str(CURVOL) + "%" # set subprocess volume e.g. 65%
check_output(CMDSETSINKVOL)
def toggle_mute(): # knob pushed down
"""Toggle mute by executing OS command"""
sleep(BUTTONBOUNCE) # HACK: delay a bit to infer if the button is held
if button.is_pressed is not True:
get_sink_vol()
check_output(CMDTOGGLEMUTE)
def open_mixer(): # knob held down
"""Open volume control mixer by executing OS command"""
check_output(CMDOPENMIXER)
def init():
"""Initialize GPIO callbacks"""
encoder.when_rotated_clockwise = increase_volume
encoder.when_rotated_counter_clockwise = decrease_volume
button.when_pressed = toggle_mute
button.when_held = open_mixer
def main():
"""When run standalone, initialize GPIO callbacks and pause for input"""
try:
init() # set up callbacks
pause() # idle forever
except KeyboardInterrupt:
pass
if __name__ == '__main__':
main()
@brucesdad13
Copy link
Author

I suppose the other issue that needs to be addressed is whether to add additional logic or a configuration parameter to allow control of sinks that are not RUNNING (IDLE/SUSPENDED). For my use case I am typically listening to music when I attempt to change the volume or mute/unmute :) Feel free to submit a pull request if you figure it out before I do!

@brucesdad13
Copy link
Author

Consider reinstalling pulseaudio. Is this a vanilla Raspberry Pi image?

@Ferromort
Copy link

Hello again! Right did a full reinstall of RPi image using latest build ect. So good news and bad news! But there is progress.
Essentially its working but only when not playing sound. With no sound playing the rotary encoder works goes up and down and mutes just fine. When music is playing then it gives an error shown below. Sink info is also below without sound and with sound.

Thanks again for all your help, Its so closes!

Failed to get sink information: No such entity
Exception in thread Thread-1:
Traceback (most recent call last):
  File "/usr/lib/python3.11/threading.py", line 1038, in _bootstrap_inner
    self.run()
  File "/usr/lib/python3/dist-packages/lgpio.py", line 554, in run
    cb.func(chip, gpio, level, tick)
  File "/usr/lib/python3/dist-packages/gpiozero/pins/lgpio.py", line 248, in _call_when_changed
    super()._call_when_changed(ticks / 1000000000, level)
  File "/usr/lib/python3/dist-packages/gpiozero/pins/local.py", line 111, in _call_when_changed
    super()._call_when_changed(
  File "/usr/lib/python3/dist-packages/gpiozero/pins/pi.py", line 615, in _call_when_changed
    method(ticks, state)
  File "/usr/lib/python3/dist-packages/gpiozero/input_devices.py", line 1174, in _b_changed
    self._change_state(ticks, edge)
  File "/usr/lib/python3/dist-packages/gpiozero/input_devices.py", line 1186, in _change_state
    self._fire_rotated_cw()
  File "/usr/lib/python3/dist-packages/gpiozero/input_devices.py", line 1308, in _fire_rotated_cw
    self.when_rotated_clockwise()
  File "/home/warlord/gpiozero_volknob.py", line 59, in increase_volume
    set_volume()
  File "/home/warlord/gpiozero_volknob.py", line 69, in set_volume
    output = check_output(cmdSetSinkVol) # TODO: handle errors
             ^^^^^^^^^^^^^^^^^^^^^^^^^^^
  File "/usr/lib/python3.11/subprocess.py", line 466, in check_output
    return run(*popenargs, stdout=PIPE, timeout=timeout, check=True,
           ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
  File "/usr/lib/python3.11/subprocess.py", line 571, in run
    raise CalledProcessError(retcode, process.args,
subprocess.CalledProcessError: Command '['pactl', 'set-sink-volume', '7', '35%']' returned non-zero exit status 1.

Sink info without playing

pactl list sinks
Sink #71
	State: SUSPENDED
	Name: bluez_output.66_B8_89_0C_A8_B0.1
	Description: XFW-BT
	Driver: PipeWire
	Sample Specification: s16le 2ch 48000Hz
	Channel Map: front-left,front-right
	Owner Module: 4294967295
	Mute: no
	Volume: front-left: 19660 /  30% / -31.37 dB,   front-right: 19660 /  30% / -31.37 dB
	        balance 0.00
	Base Volume: 65536 / 100% / 0.00 dB
	Monitor Source: bluez_output.66_B8_89_0C_A8_B0.1.monitor
	Latency: 0 usec, configured 0 usec
	Flags: HARDWARE DECIBEL_VOLUME LATENCY 
	Properties:
		api.bluez5.address = "66:B8:89:0C:A8:B0"
		api.bluez5.codec = "sbc"
		api.bluez5.profile = "a2dp-sink"
		api.bluez5.transport = ""
		card.profile.device = "1"
		device.id = "69"
		device.routes = "1"
		factory.name = "api.bluez5.a2dp.sink"
		device.description = "XFW-BT"
		node.name = "bluez_output.66_B8_89_0C_A8_B0.1"
		node.pause-on-idle = "false"
		priority.driver = "1010"
		priority.session = "1010"
		factory.id = "8"
		clock.quantum-limit = "8192"
		device.api = "bluez5"
		media.class = "Audio/Sink"
		media.name = "XFW-BT"
		node.driver = "true"
		factory.mode = "merge"
		audio.adapt.follower = ""
		library.name = "audioconvert/libspa-audioconvert"
		object.id = "70"
		object.serial = "71"
		client.id = "34"
		api.bluez5.class = "0x240404"
		api.bluez5.connection = "disconnected"
		api.bluez5.device = ""
		api.bluez5.icon = "audio-headset"
		api.bluez5.path = "/org/bluez/hci0/dev_66_B8_89_0C_A8_B0"
		bluez5.auto-connect = "[ hfp_hf hsp_hs a2dp_sink ]"
		bluez5.profile = "off"
		device.alias = "XFW-BT"
		device.bus = "bluetooth"
		device.form_factor = "headset"
		device.icon_name = "audio-headset-bluetooth"
		device.name = "bluez_card.66_B8_89_0C_A8_B0"
		device.product.id = "0x000a"
		device.string = "66:B8:89:0C:A8:B0"
		device.vendor.id = "bluetooth:05d6"
	Ports:
		headset-output: Headset (type: Headset, priority: 0, available)
	Active Port: headset-output
	Formats:
		pcm

Sink info while playing sound

Sink #71
	State: RUNNING
	Name: bluez_output.66_B8_89_0C_A8_B0.1
	Description: XFW-BT
	Driver: PipeWire
	Sample Specification: s16le 2ch 48000Hz
	Channel Map: front-left,front-right
	Owner Module: 4294967295
	Mute: no
	Volume: front-left: 19660 /  30% / -31.37 dB,   front-right: 19660 /  30% / -31.37 dB
	        balance 0.00
	Base Volume: 65536 / 100% / 0.00 dB
	Monitor Source: bluez_output.66_B8_89_0C_A8_B0.1.monitor
	Latency: 0 usec, configured 0 usec
	Flags: HARDWARE DECIBEL_VOLUME LATENCY 
	Properties:
		api.bluez5.address = "66:B8:89:0C:A8:B0"
		api.bluez5.codec = "sbc"
		api.bluez5.profile = "a2dp-sink"
		api.bluez5.transport = ""
		card.profile.device = "1"
		device.id = "69"
		device.routes = "1"
		factory.name = "api.bluez5.a2dp.sink"
		device.description = "XFW-BT"
		node.name = "bluez_output.66_B8_89_0C_A8_B0.1"
		node.pause-on-idle = "false"
		priority.driver = "1010"
		priority.session = "1010"
		factory.id = "8"
		clock.quantum-limit = "8192"
		device.api = "bluez5"
		media.class = "Audio/Sink"
		media.name = "XFW-BT"
		node.driver = "true"
		factory.mode = "merge"
		audio.adapt.follower = ""
		library.name = "audioconvert/libspa-audioconvert"
		object.id = "70"
		object.serial = "71"
		client.id = "34"
		api.bluez5.class = "0x240404"
		api.bluez5.connection = "disconnected"
		api.bluez5.device = ""
		api.bluez5.icon = "audio-headset"
		api.bluez5.path = "/org/bluez/hci0/dev_66_B8_89_0C_A8_B0"
		bluez5.auto-connect = "[ hfp_hf hsp_hs a2dp_sink ]"
		bluez5.profile = "off"
		device.alias = "XFW-BT"
		device.bus = "bluetooth"
		device.form_factor = "headset"
		device.icon_name = "audio-headset-bluetooth"
		device.name = "bluez_card.66_B8_89_0C_A8_B0"
		device.product.id = "0x000a"
		device.string = "66:B8:89:0C:A8:B0"
		device.vendor.id = "bluetooth:05d6"
	Ports:
		headset-output: Headset (type: Headset, priority: 0, available)
	Active Port: headset-output
	Formats:
		pcm

@Ferromort
Copy link

Found the issue, the origianl code only accounts for single digit sink numbers for some reason my one is 71 and the code just reads 7 and uses 7 to function.

sink_id = sink.split("\n")[0].strip() seemed to work

Here is the code that I updated to support double digit sinks. Hope it helps someone

#!/usr/bin/env python3
#             _    ____             _   __     _______          __           __
#  ___ ____  (_)__/_  / ___ _______| | / /__  / / ___/__  ___  / /________  / /
# / _ `/ _ \/ / _ \/ /_/ -_) __/ _ \ |/ / _ \/ / /__/ _ \/ _ \/ __/ __/ _ \/ / 
# \_, / .__/_/\___/___/\__/_/  \___/___/\___/_/\___/\___/_//_/\__/_/  \___/_/  
#/___/_/                                                                       
#
# Volume control using rotary encoder by Charles Stevenson <brucesdad13@gmail.com>
#
# Inspired by:
#   https://www.40percent.club/2020/12/orthopi-rotary-encoder.html
#   https://gist.github.com/savetheclocktower/9b5f67c20f6c04e65ed88f2e594d43c1
# References:
#   https://gpiozero.readthedocs.io/en/stable/api_input.html#rotaryencoder
# Requirements:
#   pulseaudio-utils: /usr/bin/pactl (or somewhere in $PATH)
# TODO: do something cool with button.when_held
from gpiozero import RotaryEncoder, Button
from signal import pause
from subprocess import check_output

# GPIOs 27 and 17 for rotary encoder knob and GPIO 22 for the button
encoder = RotaryEncoder(27, 17, bounce_time=0.1)
button = Button(22, hold_time=0.5)  # hold time for a long press

# set up command lists for subprocess.check_output() e.g.:
# pactl set-sink-volume 0 70%  # set Master volume to 70%
cmdGetSinkVol = ["pactl", "list", "sinks"]
cmdToggleMute = ["pactl", "set-sink-mute", "0", "toggle"]
cmdSetSinkVol = ["pactl", "set-sink-volume", "0", "50%"]
SINKPOS = 2  # position of the sink number in the subprocess argument array
VOLPOS = 3  # position of the volume percentile in the subprocess argument array

# track the volume at present vs min and max
MAXVOL = 100  # integer maximum volume
MINVOL = 0  # integer minimum volume
VOLSTEP = 4  # if 1 is too fine try setting to 5 or 10 for example
curVol = MINVOL  # integer representation of current volume percentile

# get the currently active sink and its volume level (assumes right channel set
# the same as the left). string operations are probably not optimal :D
def getSinkVol():
    global curVol
    output = check_output(cmdGetSinkVol)
    output = output.decode('utf-8')  # convert from bytes to UTF-8 string
    sinks = output.split("Sink #")[1:]  # Assumes can be many sinks but only 1 active
    for sink in sinks:  # find active sink
        if "RUNNING" in sink:
            sink_id = sink.split("\n")[0].strip()  # Get the sink number
            cmdToggleMute[SINKPOS] = cmdSetSinkVol[SINKPOS] = sink_id  # update cmds
            volume = sink.split("Volume: front-left: ")[1].split("/")[1].strip()
            curVol = int(volume[:-1])
    return curVol

def increase_volume():  # knob turned clockwise
    global curVol
    if getSinkVol() < MAXVOL:
        curVol = min(curVol + VOLSTEP, MAXVOL)
        set_volume()
    
def decrease_volume():  # knob turned counter-clockwise
    global curVol
    if getSinkVol() > MINVOL:
        curVol = max(curVol - VOLSTEP, MINVOL)
        set_volume()

def set_volume():
    cmdSetSinkVol[VOLPOS] = str(curVol) + "%"  # set subprocess volume e.g. 65%
    check_output(cmdSetSinkVol)  # TODO: handle errors

def mute_pressed():  # knob pushed down
    getSinkVol()
    check_output(cmdToggleMute)  # TODO: handle errors

def init():
    encoder.when_rotated_clockwise = increase_volume
    encoder.when_rotated_counter_clockwise = decrease_volume
    button.when_pressed = mute_pressed
    #button.when_held = #TODO#

def main():
    try:
        init()  # set up callbacks
        pause()  # idle forever
 
    except KeyboardInterrupt:
        exit()
 
if __name__ == '__main__':
    main()

@brucesdad13
Copy link
Author

Awesome! Glad to hear it worked out. I will look at your code changes and see if it's possible to merge them into the gist.

@brucesdad13
Copy link
Author

I merged your fix adding support for sinks that are more than a single digit. :)

Found the issue, the [original] code only accounts for single digit sink numbers for some reason my one is 71 and the code just reads 7 and uses 7 to function.

sink_id = sink.split("\n")[0].strip() seemed to work

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment