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