Skip to content

Instantly share code, notes, and snippets.

@savetheclocktower
Last active October 6, 2024 20:19
Show Gist options
  • Save savetheclocktower/9b5f67c20f6c04e65ed88f2e594d43c1 to your computer and use it in GitHub Desktop.
Save savetheclocktower/9b5f67c20f6c04e65ed88f2e594d43c1 to your computer and use it in GitHub Desktop.
Using a rotary encoder as a volume control for the Raspberry Pi

Using a rotary encoder as a volume control

On my RetroPie machine I wanted a hardware volume knob — the games I play use a handful of emulators, and there's no unified software interface for controlling the volume. The speakers I got for my cabinet are great, but don't have their own hardware volume knob. So with a bunch of googling and trial and error, I figured out what I need to pull this off: a rotary encoder and a daemon that listens for the signals it sends.

Rotary encoder

A rotary encoder is like the standard potentiometer (i.e., analog volume knob) we all know, except (a) you can keep turning it in either direction for as long as you want, and thus (b) it talks to the RPi differently than a potentiometer would.

I picked up this one from Adafruit, but there are plenty others available. This rotary encoder also lets you push the knob in and treats that like a button press, so I figured that would be useful for toggling mute on and off.

So we've got 5 wires to hook up: three for the knob part (A, B, and ground), and two for the button part (common and ground). Here's how I hooked them up (reference):

Description BCM # Board #
knob A GPIO 26 37
knob B GPIO 19 35
knob ground ground pin below GPIO 26 39
button common GPIO 13 33
button ground ground pin opposite GPIO 13 34

You can use whichever pins you want; just update the script if you change them.

Volume daemon

Since the GPIO pins are just for arbitrary signals, you need a script on the Pi that knows what to do with them. The builtin GPIO library for the Pi will do nicely for this.

You'll see the script below, but here's how it works: it listens on the specified pins, and when the knob is turned one way or another, it uses the states of the A and B pins to figure out whether the knob was turned to the left or to the right. That way it knows whether to increase or decrease the system volume in response, which it does with the command-line program amixer.

First, make sure amixer is present and install it if it isn’t.

which amixer || sudo apt-get install alsa-utils

(If you’re not using the default analog audio output, consult @thijstriemstra’s comment below for some additional steps that you may or may not need to do.)

Create a bin directory in your pi folder if it doesn't exist already, then drop the script below into it.

mkdir ~/bin

If it's not there yet, I'd also put /home/pi/bin somewhere in your PATH:

echo $PATH
# don't see "/home/pi/bin" there? then run...
echo "export PATH=$HOME/bin:$PATH" >> ~/.bashrc
# ...and restart your shell

Then drop the script into the /home/pi/bin folder:

nano ~/bin/monitor-volume # (or however you do it)
chmod +x ~/bin/monitor-volume

You can run this script in the foreground just to test it out. Edit the script and temporarily change DEBUG to True so that you can see what's going on, then simply run it with monitor-volume. When you turn or press the knob, the script should report what it's doing. (Make sure that the volume increases rather than decreases when you turn it to the right, and if it doesn't, swap your A and B pins, or just swap their numbers in the script.)

You should also play around with the constants defined at the top of the script. Naturally, if you picked other pins, you'll want to tell the script which GPIO pins to use. If your rotary encoder doesn't act like a button, or if you didn't hook up the button and don't care about it, you can set GPIO_BUTTON to None. But you may also want to change the minimum and maximum volumes (they're percentages, so they should be between 1 and 100) and the increment (how many percentage points the volume increases/decreases with each "click" of the knob).

If it's working the way you want, you can proceed to the next step: running monitor-volume automatically in the background whenever your Pi starts.

Creating a systemd service

NOTE: If you're on a version of Raspbian before Jessie, these instructions won't work for you. Hopefully someone can pipe up with a version of this for init.d in the comments.

systemd is the new way of managing startup daemons in Raspbian Jessie. Because it's new, there's not much RPi-specific documentation on it, and to find out how to use it you have to sift through a bunch of Google results from people who hate systemd and wish it didn't exist. After much trial and error, here's what worked for me:

nano ~/monitor-volume.service
# paste in the contents of monitor-volume.service, save/exit nano
chmod +x ~/monitor-volume.service
sudo mv ~/monitor-volume.service /etc/systemd/system
sudo systemctl enable monitor-volume
sudo systemctl start monitor-volume

If that worked right, then you just told Raspbian to start up that script in the background on every boot (enable), and also to start it right now (start). At this point, and on every boot after this, your volume knob should Just Work.

FAQ

This didn't work!

I got this working on an RPi3 running RetroPie 3.x, so all I can say is “works on my machine.” Some things to try:

  • You might not have Python 3; if which python3 turns up nothing, try this:

    sudo apt-get install python3 python3-rpi.gpio
    
  • I've heard that in earlier versions of Raspbian, the pi user isn't automatically allowed to access the GPIO pins, so you need to run scripts like this as root. If you're running into permissions errors when you try to run the script from your shell, then that's your problem, most likely. There's no particular reason why you shouldn't run this script as root, except on the general principle that you shouldn't really trust code that you didn't write. Make good choices and have backups.

  • I might have made a typo in the gist. Wouldn't be the first time.

If you run into trouble, leave a comment and the internet can help you figure it out.

#!/usr/bin/env python3
"""
The daemon responsible for changing the volume in response to a turn or press
of the volume knob.
The volume knob is a rotary encoder. It turns infinitely in either direction.
Turning it to the right will increase the volume; turning it to the left will
decrease the volume. The knob can also be pressed like a button in order to
turn muting on or off.
The knob uses two GPIO pins and we need some extra logic to decode it. The
button we can just treat like an ordinary button. Rather than poll
constantly, we use threads and interrupts to listen on all three pins in one
script.
"""
import os
import signal
import subprocess
import sys
import threading
from RPi import GPIO
from queue import Queue
DEBUG = False
# SETTINGS
# ========
# The two pins that the encoder uses (BCM numbering).
GPIO_A = 26
GPIO_B = 19
# The pin that the knob's button is hooked up to. If you have no button, set
# this to None.
GPIO_BUTTON = 13
# The minimum and maximum volumes, as percentages.
#
# The default max is less than 100 to prevent distortion. The default min is
# greater than zero because if your system is like mine, sound gets
# completely inaudible _long_ before 0%. If you've got a hardware amp or
# serious speakers or something, your results will vary.
VOLUME_MIN = 60
VOLUME_MAX = 96
# The amount you want one click of the knob to increase or decrease the
# volume. I don't think that non-integer values work here, but you're welcome
# to try.
VOLUME_INCREMENT = 1
# (END SETTINGS)
#
# When the knob is turned, the callback happens in a separate thread. If
# those turn callbacks fire erratically or out of order, we'll get confused
# about which direction the knob is being turned, so we'll use a queue to
# enforce FIFO. The callback will push onto a queue, and all the actual
# volume-changing will happen in the main thread.
QUEUE = Queue()
# When we put something in the queue, we'll use an event to signal to the
# main thread that there's something in there. Then the main thread will
# process the queue and reset the event. If the knob is turned very quickly,
# this event loop will fall behind, but that's OK because it consumes the
# queue completely each time through the loop, so it's guaranteed to catch up.
EVENT = threading.Event()
def debug(str):
if not DEBUG:
return
print(str)
class RotaryEncoder:
"""
A class to decode mechanical rotary encoder pulses.
Ported to RPi.GPIO from the pigpio sample here:
http://abyz.co.uk/rpi/pigpio/examples.html
"""
def __init__(self, gpioA, gpioB, callback=None, buttonPin=None, buttonCallback=None):
"""
Instantiate the class. Takes three arguments: the two pin numbers to
which the rotary encoder is connected, plus a callback to run when the
switch is turned.
The callback receives one argument: a `delta` that will be either 1 or -1.
One of them means that the dial is being turned to the right; the other
means that the dial is being turned to the left. I'll be damned if I know
yet which one is which.
"""
self.lastGpio = None
self.gpioA = gpioA
self.gpioB = gpioB
self.callback = callback
self.gpioButton = buttonPin
self.buttonCallback = buttonCallback
self.levA = 0
self.levB = 0
GPIO.setmode(GPIO.BCM)
GPIO.setup(self.gpioA, GPIO.IN, pull_up_down=GPIO.PUD_UP)
GPIO.setup(self.gpioB, GPIO.IN, pull_up_down=GPIO.PUD_UP)
GPIO.add_event_detect(self.gpioA, GPIO.BOTH, self._callback)
GPIO.add_event_detect(self.gpioB, GPIO.BOTH, self._callback)
if self.gpioButton:
GPIO.setup(self.gpioButton, GPIO.IN, pull_up_down=GPIO.PUD_UP)
GPIO.add_event_detect(self.gpioButton, GPIO.FALLING, self._buttonCallback, bouncetime=500)
def destroy(self):
GPIO.remove_event_detect(self.gpioA)
GPIO.remove_event_detect(self.gpioB)
GPIO.cleanup()
def _buttonCallback(self, channel):
self.buttonCallback(GPIO.input(channel))
def _callback(self, channel):
level = GPIO.input(channel)
if channel == self.gpioA:
self.levA = level
else:
self.levB = level
# Debounce.
if channel == self.lastGpio:
return
# When both inputs are at 1, we'll fire a callback. If A was the most
# recent pin set high, it'll be forward, and if B was the most recent pin
# set high, it'll be reverse.
self.lastGpio = channel
if channel == self.gpioA and level == 1:
if self.levB == 1:
self.callback(1)
elif channel == self.gpioB and level == 1:
if self.levA == 1:
self.callback(-1)
class VolumeError(Exception):
pass
class Volume:
"""
A wrapper API for interacting with the volume settings on the RPi.
"""
MIN = VOLUME_MIN
MAX = VOLUME_MAX
INCREMENT = VOLUME_INCREMENT
def __init__(self):
# Set an initial value for last_volume in case we're muted when we start.
self.last_volume = self.MIN
self._sync()
def up(self):
"""
Increases the volume by one increment.
"""
return self.change(self.INCREMENT)
def down(self):
"""
Decreases the volume by one increment.
"""
return self.change(-self.INCREMENT)
def change(self, delta):
v = self.volume + delta
v = self._constrain(v)
return self.set_volume(v)
def set_volume(self, v):
"""
Sets volume to a specific value.
"""
self.volume = self._constrain(v)
output = self.amixer("set 'PCM' unmute {}%".format(v))
self._sync(output)
return self.volume
def toggle(self):
"""
Toggles muting between on and off.
"""
if self.is_muted:
output = self.amixer("set 'PCM' unmute")
else:
# We're about to mute ourselves, so we should remember the last volume
# value we had because we'll want to restore it later.
self.last_volume = self.volume
output = self.amixer("set 'PCM' mute")
self._sync(output)
if not self.is_muted:
# If we just unmuted ourselves, we should restore whatever volume we
# had previously.
self.set_volume(self.last_volume)
return self.is_muted
def status(self):
if self.is_muted:
return "{}% (muted)".format(self.volume)
return "{}%".format(self.volume)
# Read the output of `amixer` to get the system volume and mute state.
#
# This is designed not to do much work because it'll get called with every
# click of the knob in either direction, which is why we're doing simple
# string scanning and not regular expressions.
def _sync(self, output=None):
if output is None:
output = self.amixer("get 'PCM'")
lines = output.readlines()
if DEBUG:
strings = [line.decode('utf8') for line in lines]
debug("OUTPUT:")
debug("".join(strings))
last = lines[-1].decode('utf-8')
# The last line of output will have two values in square brackets. The
# first will be the volume (e.g., "[95%]") and the second will be the
# mute state ("[off]" or "[on]").
i1 = last.rindex('[') + 1
i2 = last.rindex(']')
self.is_muted = last[i1:i2] == 'off'
i1 = last.index('[') + 1
i2 = last.index('%')
# In between these two will be the percentage value.
pct = last[i1:i2]
self.volume = int(pct)
# Ensures the volume value is between our minimum and maximum.
def _constrain(self, v):
if v < self.MIN:
return self.MIN
if v > self.MAX:
return self.MAX
return v
def amixer(self, cmd):
p = subprocess.Popen("amixer {}".format(cmd), shell=True, stdout=subprocess.PIPE)
code = p.wait()
if code != 0:
raise VolumeError("Unknown error")
sys.exit(0)
return p.stdout
if __name__ == "__main__":
gpioA = GPIO_A
gpioB = GPIO_B
gpioButton = GPIO_BUTTON
v = Volume()
def on_press(value):
v.toggle()
print("Toggled mute to: {}".format(v.is_muted))
EVENT.set()
# This callback runs in the background thread. All it does is put turn
# events into a queue and flag the main thread to process them. The
# queueing ensures that we won't miss anything if the knob is turned
# extremely quickly.
def on_turn(delta):
QUEUE.put(delta)
EVENT.set()
def consume_queue():
while not QUEUE.empty():
delta = QUEUE.get()
handle_delta(delta)
def handle_delta(delta):
if v.is_muted:
debug("Unmuting")
v.toggle()
if delta == 1:
vol = v.up()
else:
vol = v.down()
print("Set volume to: {}".format(vol))
def on_exit(a, b):
print("Exiting...")
encoder.destroy()
sys.exit(0)
debug("Volume knob using pins {} and {}".format(gpioA, gpioB))
if gpioButton != None:
debug("Volume button using pin {}".format(gpioButton))
debug("Initial volume: {}".format(v.volume))
encoder = RotaryEncoder(GPIO_A, GPIO_B, callback=on_turn, buttonPin=GPIO_BUTTON, buttonCallback=on_press)
signal.signal(signal.SIGINT, on_exit)
while True:
# This is the best way I could come up with to ensure that this script
# runs indefinitely without wasting CPU by polling. The main thread will
# block quietly while waiting for the event to get flagged. When the knob
# is turned we're able to respond immediately, but when it's not being
# turned we're not looping at all.
#
# The 1200-second (20 minute) timeout is a hack; for some reason, if I
# don't specify a timeout, I'm unable to get the SIGINT handler above to
# work properly. But if there is a timeout set, even if it's a very long
# timeout, then Ctrl-C works as intended. No idea why.
EVENT.wait(1200)
consume_queue()
EVENT.clear()
[Unit]
Description=Volume knob monitor
[Service]
User=pi
Group=pi
ExecStart=/home/pi/bin/monitor-volume
[Install]
WantedBy=multi-user.target
@sHooPmyWooP
Copy link

Hello @savetheclocktower,
thanks for this awesome tutorial. I'm very new to programming in general and python/raspberry in detail. Your code works fine for me except one problem. The volume will only vary between two values (e.g. 31 to 32). I'm not sure if this explains my problem well enough. I hope you have an idea how to fix this, since I've checked various sites and didn't find a trace to a solution.
Greetings and thanks in advance!

@JamesGKent
Copy link

for anyone interested i wrote a kernel driver to use a rotary encoder as a volume control by translating it's events into keyboard volume up and down keypresses. for anyone interested the source and instructions is here

@Onextw
Copy link

Onextw commented Apr 30, 2018

Hi all,
I'm more of a duct tape and cable ties kind of coder but I thought I would share in case it helps someone else.

Came across this project while building a standalone AV Reciever sort of thing, hit some of the same hiccups as others along the way but managed to get it working for now. Writing this up from memory from last night so I might do a clean install and make sure I've covered everything later on but I'm pretty sure this is everything I did.

As I was using it on OSMC (latest stable build) with a HifiBerry Amp2 there were a few tweaks that I needed to do to get it up and running.

First off, as suggested by @savetheclocktower OSMC doesnt have alsa-utils installed by default so

sudo apt-get install alsa-utils

I am also using an OLED Display and I don't believe OSMC has GPIO functionality off the bat so I had also used the following instructions installing various libraries taken from here

sudo apt-get install build-essential python-dev python-pip
sudo pip install RPi.GPIO

I have heard that using pip to install RPi.GPIO is the wrong way to go about it, but it did work, maybe someone can clear that up.

It became a bit of a mess using Python3 so following @michaeldrouin's suggestion I changed

line #1 from: #!/usr/bin/env python3 to: #!/usr/bin/env python2
line #25 from from queue import Queue to from multiprocessing import Queue

As the Berry Amp2's use pin 19 switched that up to pin 16 (HIFIBERRY DAC+, DIGI+ AND AMP+ also look to use the same pins)

line #34 from GPIO_B = 19 to GPIO_B = 16

I also had a similar problem as @jonnydcakes with the audio sink so after a quick google I came across this which flagged that the audio sink was 'Digital' so essentially replacing "PCM" within the following lines with "Digital"

Line 185: output = self.amixer("set 'Digital' unmute {}%".format(v))
Line 194: output = self.amixer("set 'Digital' unmute")
Line 199: output = self.amixer("set 'Digital' mute")
Line 220: output = self.amixer("get 'Digital'")

Lastly to get it running as a service, OSMC has a problem allowing r/w/x access to GPIO pins to the main user osmc, you could add the user to the GPIO group permissions but i chose to run it as root instead as it will be a core system function for my project. so changing the lines in the monitor-volume.service script

Line 5: User=root
Line 6: Group=root

That might be a security risk, maybe someone wants to correct me on that but for now it works. Again I'll probably be doing this on a clean install to see if I missed anything.

There are a few things that I might try and do (albeit with duct tape and cable ties)

  • The Hifiberry amp2 has a mute line on GPIO pin 4, so make use of that over the rotary encoders button
  • Use the button on the rotary encoder to facilitate a menu on the OLED
  • As this doesn't show volume changes on the GUI I might look into @JamesGKent's approach once the Kernel within the stable branch of OSMC reach the requirements (4.20 I think?) as I'm assuming that as it replicates (+/-) keystrokes it would.

Anyway awesome project, appreciate you making it easier for dumbasses like me to make things work. hopefully this helps other people

Snap of the Rpi im running atm

Copy link

ghost commented May 1, 2018

Hello,
Getting error noted below, help please!

pi@Pi0:~ $ DEBUG=1 monitor-volume
Knob using pins 26 and 19
amixer: Unable to find simple control 'PCM',0

Traceback (most recent call last):
File "/home/pi/bin/monitor-volume", line 276, in
v = Volume()
File "/home/pi/bin/monitor-volume", line 132, in init
self._sync()
File "/home/pi/bin/monitor-volume", line 193, in _sync
output = self._amixer("get 'PCM'")
File "/home/pi/bin/monitor-volume", line 224, in _amixer
raise VolumeError("Unknown error")
main.VolumeError: Unknown error

@savetheclocktower
Copy link
Author

@grigdon see this comment for some guidance.

@AEChadwick
Copy link

AEChadwick commented Jun 19, 2018

first off, thank you so much for this project--i appreciate all the work, it's been super helpful.

everything runs perfectly from the command line, but i cannot get it to work as a service. it claims to be running, but there is no effect--turning the encoder does nothing.

checking service monitor-volume status gets this

   Loaded: loaded (/etc/systemd/system/monitor-volume.service; enabled; vendor preset: enabled)
   Active: active (running) since Tue 2018-06-19 23:34:14 UTC; 41s ago
 Main PID: 528 (python3)
   CGroup: /system.slice/monitor-volume.service
           ├─528 python3 /home/pi/bin/monitor-volume
           └─657 /usr/bin/pulseaudio --start --log-target=syslog

Jun 19 23:34:14 raspberrypi systemd[1]: Started Volume knob monitor.
Jun 19 23:34:16 raspberrypi pulseaudio[657]: [pulseaudio] server-lookup.c: Unable to contact D-Bus: org.freedesktop.DBus.Error.NotSupported: Unable to autolaunch a dbus-daemon without a $DISPL
Jun 19 23:34:16 raspberrypi pulseaudio[657]: [pulseaudio] main.c: Unable to contact D-Bus: org.freedesktop.DBus.Error.NotSupported: Unable to autolaunch a dbus-daemon without a $DISPLAY for X1

RaspberryPi 3 with HifiBerry Amp2, and currently using a monitor via HDMI (but i hope to go headless)

i am just wondering if anyone else is encountering this. Thanks!

@klettervirus
Copy link

klettervirus commented Sep 26, 2018

Awesome project, thanks! I got it working so that the mute button works and I can change the volume. Only problem left: It only changes volume down to 74% (which funnily is the initial Volume) to 100%. I did change the maximal/minimal Volume in the script (ln 46/47), but that did nothing. Can somebody please point me in the right direction? Using a usb-soundcard and python 2 (2.7 I believe).

Set volume to: 74
OUTPUT:
Simple mixer control 'PCM',0
Capabilities: pvolume pswitch pswitch-joined
Playback channels: Front Left - Front Right
Limits: Playback 0 - 151
Mono:
Front Left: Playback 111 [74%] [-7.56dB] [on]
Front Right: Playback 111 [74%] [-7.56dB] [on]

@panzo19
Copy link

panzo19 commented Jan 31, 2019

I'm following all the steps unfortunately I take the following output but the knob seems not to be responded.
im also running it as root and already try to run it with python2

any help?

OUTPUT:
Simple mixer control 'PCM',0
Capabilities: pvolume pvolume-joined pswitch pswitch-joined
Playback channels: Mono
Limits: Playback -10239 - 400
Mono: Playback -1089 [86%] [-10.89dB] [on]

Volume knob using pins 26 and 19
Volume button using pin 13
Initial volume: 86

@panzo19
Copy link

panzo19 commented Feb 1, 2019

Problem solved as i found out my rotary encoder has different pin outs!! But another issu raised !!! When i turn the encoder in any way goes from 86% to 85% and stays in 85% which is the minimun. Randomly goes to 87% and then back to 85%. Push works like a chrarm as mute

@nettings
Copy link

Hi @savetheclocktower, thanks for this nice script. I linked to you from my own similar project, because I find yours easier. The reason I wrote my own is that I need it to also be able to create JACK MIDI messages, which means it has to run under realtime constraints. The Python JACK API is very nice, but it wouldn't give me the performance to run at low latency. If someone here has a similar itch to scratch, can I humbly suggest to check out https://github.com/nettings/gpioctl . Otherwise, by all means stick to Python!

@Joemoe134
Copy link

Thanks for the great tutorial, and thanks to all the good feeback here in the comment section.
I also wanted to use a rotary encoder to control volume of Mopidy output running on raspberry pi zero with the Hifiberry MiniAmp. Unfortunately I realized rather late in the project, that the MiniAmp does not have hardware volume control and I wont get any device into amixer scontrols. Any suggestions form anybody how i could still translate the inputs from the rotary encoder into signals that could be picked up by mopidy?
I know this is a far fetch, but wanted to ask for ideas anyways.

@Joemoe134
Copy link

Thanks for the great tutorial, and thanks to all the good feeback here in the comment section.
I also wanted to use a rotary encoder to control volume of Mopidy output running on raspberry pi zero with the Hifiberry MiniAmp. Unfortunately I realized rather late in the project, that the MiniAmp does not have hardware volume control and I wont get any device into amixer scontrols. Any suggestions form anybody how i could still translate the inputs from the rotary encoder into signals that could be picked up by mopidy?
I know this is a far fetch, but wanted to ask for ideas anyways.

solved

changed etc/asound.conf to

pcm.hifiberry {
    type softvol
    slave.pcm "plughw:0"
    control.name "Master"
    control.card 0
}
$ amixer scontrols
Simple mixer control 'Master',0

@hujiba
Copy link

hujiba commented Aug 5, 2020

The script works when running the debug, but nothing happens after enabling the service. This worked before, but I had to reflash my sd card and now it doesn’t. There are no errors, but turning the knob doesn’t change anything.

@elknubrac
Copy link

elknubrac commented Sep 3, 2020

The script was working for me fine if started from the command line, but would not work upon boot.
Changing this line in service file seems to have fixed it.
ExecStart=/home/pi/bin/monitor-volume &

@Shayner2005
Copy link

I have the script running and the service runs, but it adds alot of static, to the point where the audio is destroyed. Using a pi4 with an adafruit speaker bonnet and the same encoder as the guide. Any suggestions?

@virginiebjt
Copy link

virginiebjt commented Feb 2, 2021

Hello, I can't get this to work on boot, I've tried with systemd service, and adding a script in etc.local or crontab.
Here's what I get

`pi@raspberrypi:~ $ service monitor-volume status
● monitor-volume.service - Volume knob monitor
   Loaded: loaded (/etc/systemd/system/monitor-volume.service; enabled; vendor preset: enabled)
   Active: failed (Result: exit-code) since Mon 2021-02-01 14:48:48 CET; 1 day 5h ago
  Process: 330 ExecStart=/home/pi/bin/monitor-volume & (code=exited, status=1/FAILURE)
 Main PID: 330 (code=exited, status=1/FAILURE)

févr. 01 14:48:47 raspberrypi monitor-volume[330]:     v = Volume()
févr. 01 14:48:47 raspberrypi monitor-volume[330]:   File "/home/pi/bin/monitor-volume", line 164, in __init__
févr. 01 14:48:47 raspberrypi monitor-volume[330]:     self._sync()
févr. 01 14:48:47 raspberrypi monitor-volume[330]:   File "/home/pi/bin/monitor-volume", line 223, in _sync
févr. 01 14:48:47 raspberrypi monitor-volume[330]:     output = self.amixer("get 'Master'")
févr. 01 14:48:47 raspberrypi monitor-volume[330]:   File "/home/pi/bin/monitor-volume", line 259, in amixer
févr. 01 14:48:47 raspberrypi monitor-volume[330]:     raise VolumeError("Unknown error")
févr. 01 14:48:47 raspberrypi monitor-volume[330]: __main__.VolumeError: Unknown error
févr. 01 14:48:48 raspberrypi systemd[1]: monitor-volume.service: Main process exited, code=exited, status=1/FAILURE
févr. 01 14:48:48 raspberrypi systemd[1]: monitor-volume.service: Failed with result 'exit-code'`

But it works well when started from terminal.
I've tried changing Master to PCM but it doesn't change a thing.

Any help would be much appreciated!
I'm using a pi 3.

@savetheclocktower
Copy link
Author

@virginiebjt I think there's more insight in the log above the point that service monitor-volume status cuts off, but I wonder if it's a permissions issue.

Try running

sudo journalctl -u monitor-volume -n 100 --no-pager

and let me know what the output is.

@virginiebjt
Copy link

virginiebjt commented Feb 3, 2021

Hello, thanks a lot for your quick reply.
Here's what I get:

pi@raspberrypi:~ $ sudo journalctl -u monitor-volume -n 100 --no-pager
-- Logs begin at Thu 2019-02-14 11:11:59 CET, end at Wed 2021-02-03 09:41:53 CET. --
févr. 02 22:36:41 raspberrypi systemd[1]: Started Volume knob monitor.
févr. 02 22:36:44 raspberrypi monitor-volume[346]: amixer: Unable to find simple control 'Master',0
févr. 02 22:36:44 raspberrypi monitor-volume[346]: Traceback (most recent call last):
févr. 02 22:36:44 raspberrypi monitor-volume[346]:   File "/home/pi/bin/monitor-volume", line 271, in <module>
févr. 02 22:36:44 raspberrypi monitor-volume[346]:     v = Volume()
févr. 02 22:36:44 raspberrypi monitor-volume[346]:   File "/home/pi/bin/monitor-volume", line 164, in __init__
févr. 02 22:36:44 raspberrypi monitor-volume[346]:     self._sync()
févr. 02 22:36:44 raspberrypi monitor-volume[346]:   File "/home/pi/bin/monitor-volume", line 223, in _sync
févr. 02 22:36:44 raspberrypi monitor-volume[346]:     output = self.amixer("get 'Master'")
févr. 02 22:36:44 raspberrypi monitor-volume[346]:   File "/home/pi/bin/monitor-volume", line 259, in amixer
févr. 02 22:36:44 raspberrypi monitor-volume[346]:     raise VolumeError("Unknown error")
févr. 02 22:36:44 raspberrypi monitor-volume[346]: __main__.VolumeError: Unknown error
févr. 02 22:36:44 raspberrypi systemd[1]: monitor-volume.service: Main process exited, code=exited, status=1/FAILURE
févr. 02 22:36:44 raspberrypi systemd[1]: monitor-volume.service: Failed with result 'exit-code'.

I'm quite a newbie in programming, I've also tried to create a bash script called volume.sh:

#!/bin/bash
sudo ./bin/monitor-volume

and add this to rc local:
sudo /home/pi/volume.sh

but it didn't change anything.

@savetheclocktower
Copy link
Author

Hmm. And it works when you start it manually? That's strange. Are your speakers plugged in through the line-out port, or only via USB?

Also, can you run this and show me what you get?

amixer scontrols

@virginiebjt
Copy link

Hi,
I think I was in the wrong user group.
But anyway what I wanted to control was sound on mpc, so I changed the script to control only mpc's volume and not alsa, and that did the trick somehow. Thanks!

@jps17
Copy link

jps17 commented Feb 11, 2021

Hello savetheclocktower,

Thanks for contributing your code to other tinkerer.

I am making a wireless music player and will be using a rotary encoder. I am curious as to why the script is saved in pi/bin and not usr/bin . Is there any advantages/disadvantages to saving it in usr/bin. I am asking out of curiosity and ignorance.

Thanks,
jp

@savetheclocktower
Copy link
Author

@jps17, there's not much of a difference. I think most Linux users would suggest that you put stuff you build yourself into /usr/local/bin rather than /usr/bin, but it isn't hugely important.

At some point in working with Raspberry Pis, I developed a habit to put as much as possible in /home/pi for ease of portability, and because other users can definitely always put stuff there without any danger of permissions issues. System services don't really care where the binary file is, and if you add $HOME/bin to your path via .bashrc or the like, your shell won't care either.

@jps17
Copy link

jps17 commented Feb 12, 2021

Savetheclocktower,

Thanks for your answer.

Cheers,
jp

@sukohonig
Copy link

Newbi here.. I get this traceback. Not sure what to do at this point...

pi@raspberrypi:~/bin $ python3 monitor-volume
amixer: Unable to find simple control 'PCM',0

Traceback (most recent call last):
File "monitor-volume", line 271, in
v = Volume()
File "monitor-volume", line 164, in init
self._sync()
File "monitor-volume", line 223, in _sync
output = self.amixer("get 'PCM'")
File "monitor-volume", line 259, in amixer
raise VolumeError("Unknown error")
main.VolumeError: Unknown error

@theraa
Copy link

theraa commented Apr 28, 2021

Great code @savetheclocktower, and thank you @thijstriemstra for the pcm trick.

I have two troubles with setting this up.

  1. My encoder always increases volume :)
    If the volume is maxed, debug shows that the volume had been changed to 96, no matter what direction I rotate the knob. (Sometimes quick rotation can drop the volume to 95, for a fraction of a second.) If the volume is not maxed by the time I start the script, I can clearly see how both CW and CCW rotation increments volume up (approx. 1 unit per 2 clicks). Looks like the case similar to Jerry's and to @panzo19's.

  2. Systemd service doesn't work.
    None of the methods I've read here or on google had helped me.
    That's the Service monitor-volume status command output:

● monitor-volume.service - Volume knob monitor
   Loaded: loaded (/etc/systemd/system/monitor-volume.service; enabled; vendor preset: enabled)
   Active: failed (Result: exit-code) since Wed 2021-04-28 23:23:08 MSK; 1h 11min ago
  Process: 479 ExecStart=/home/ra/bin/monitor-volume (code=exited, status=216/GROUP)
 Main PID: 479 (code=exited, status=216/GROUP)

Apr 28 23:23:08 ra01 systemd[1]: Started Volume knob monitor.
Apr 28 23:23:08 ra01 systemd[479]: monitor-volume.service: Failed to determine group credentials: No s
Apr 28 23:23:08 ra01 systemd[479]: monitor-volume.service: Failed at step GROUP spawning /home/ra/bin/
Apr 28 23:23:08 ra01 systemd[1]: monitor-volume.service: Main process exited, code=exited, status=216/
Apr 28 23:23:08 ra01 systemd[1]: monitor-volume.service: Failed with result 'exit-code'.

I would highly appreciate any help on that!

PS. I'm using Pi4 and KY-040 encoder.
I've tested it with my multimeter and have figured out that I can plug it like this:

GND - 39
 SW - 33
 DT - 35
CLK - 37

@robert5974
Copy link

@theraa I can help with the service. I ended up not using it. I created a monitor-volume.desktop file and put it in /etc/xdg/autostart directory. Just Google the format for this file or copy from one in the directory that is already there. No need for the service stuff. Just need the .desktop file and make the script executable.

@MrRKernelPanic
Copy link

Awesome article, guide. Like others I was looking to use rotary encoder with the Pi as a volume control. I could get it working from the cli, but was having similar issues to @virginiebjt above. Finally I discovered that anyone who is using a recent build of Raspberry Pi OS or similar may be using PulseAudio, this sits on top of a layer of ALSA, this is evident when in Alsamixer for me it just shows 'Master'. Now here's the sneaky bit, the 'Master' mixer only exists for the user, not for 'root' usage. So when trying to create a service for this it won't work as this is 'run' as 'root' even if you tell it to use 'user=pi' or similar. I was having issues that it couldn't locate the 'Master' mixer, which after a lot of wrangling I realised it couldn't, I checked this by running my python script with and without 'sudo', the 'user' one worked fine, the 'sudo' couldn't find / see the 'Master' mixer.

The way round this is not to create a service in the usual way, but create one from the 'users' point of view.

So for me I created a service here - sudo nano /home/pi/.config/systemd/user/volumeknob.service

[Unit]
Description=A service to manage the volume knob

[Service]
RemainAfterExit=true
ExecStart=/home/pi/volumeknob &

[Install]
WantedBy=default.target

and then enabled it, and started it using
systemctl --user enable volumeknob
systemctl --user start volumeknob

Then check the status using
systemct --user status volumeknob

Also if you're having trouble with it being persistent after a reboot then check your WantedBy=default.target and not anything else. If you have to change this again remember to enable and start it again and all should be working.

@waninggibbous
Copy link

I was having a similar issue to some other commenters where the volume was hanging around 96 and could only be nudged one or two stops in each direction. I resolved it by connecting the VCC pin to a 3v3 pin on the Pi. Note that I had a slightly different model ofd encoder than the one shown in the tutorial.

@tobyweston
Copy link

tobyweston commented May 31, 2023

Where did @thijstriemstra `s comment go? for the "PCM trick"?

This may be repeating it, but when I got...

./monitor-volume 
amixer: Unable to find simple control 'PCM',0

Traceback (most recent call last):
  File "/home/pi/bin/./monitor-volume", line 271, in <module>
    v = Volume()
  File "/home/pi/bin/./monitor-volume", line 164, in __init__
    self._sync()
  File "/home/pi/bin/./monitor-volume", line 223, in _sync
    output = self.amixer("get 'PCM'")
  File "/home/pi/bin/./monitor-volume", line 259, in amixer
    raise VolumeError("Unknown error")
__main__.VolumeError: Unknown error

it was because amixer doesn't have a PCM sound card setup. When I run axmixer scontrols it lists these:

 $ amixer scontrols
Simple mixer control 'DSP Program',0
Simple mixer control 'Analogue',0
Simple mixer control 'Analogue Playback Boost',0
Simple mixer control 'Auto Mute',0
Simple mixer control 'Auto Mute Mono',0
Simple mixer control 'Auto Mute Time Left',0
Simple mixer control 'Auto Mute Time Right',0
Simple mixer control 'Clock Missing Period',0
Simple mixer control 'Deemphasis',0
Simple mixer control 'Digital',0
Simple mixer control 'Max Overclock DAC',0
Simple mixer control 'Max Overclock DSP',0
Simple mixer control 'Max Overclock PLL',0
Simple mixer control 'Volume Ramp Down Emergency Rate',0
Simple mixer control 'Volume Ramp Down Emergency Step',0
Simple mixer control 'Volume Ramp Down Rate',0
Simple mixer control 'Volume Ramp Down Step',0
Simple mixer control 'Volume Ramp Up Rate',0
Simple mixer control 'Volume Ramp Up Step',0

I have the IQAudio DigiAMP+ installed which is "Digital" above. I looked in alsamixer to confirm:

image

I search and replaced PCM with Digital and it all worked.

Should be these lines:

 $ cat monitor-volume | grep Digital
    output = self.amixer("set 'Digital' unmute {}%".format(v))
      output = self.amixer("set 'Digital' unmute")
      output = self.amixer("set 'Digital' mute")
      output = self.amixer("get 'Digital'")

@Ferromort
Copy link

Ferromort commented May 18, 2024

Hi all!
I got this running on my RPi4 using the normal AV jack but having trouble trying to get it to work with my bluetooth speaker when that's selected instead of the jack. I'm new to coding and could really do with some help. I know this is an old post but any help is more than welcome! Thanks!

Edit: I should say I think I'm using Pulseaudio and have read that it need setting directly by using something like this... can anyone help integrate this into the orignal code?
pactl set-sink-volume bluez_sink.66_B8_89_0C_A8_B0.a2dp_sink +3%

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