Skip to content

Instantly share code, notes, and snippets.

Embed
What would you like to do?
Using a potentiometer as a volume control for the Raspberry Pi

Using a rotary encoder as a volume control

This describes how to use a potentiometer with a MCP3008 chip.

Hardware

Potentiometer from Ebay: http://www.ebay.com/itm/10PCS-6mm-3pin-Knurled-Shaft-Single-Linear-B-Type-B10K-ohm-Rotary-Potentiometer-/222445944546

MCP3008 from Adafruit: https://www.adafruit.com/products/856

Connecting the MCP3008: https://learn.adafruit.com/raspberry-pi-analog-to-digital-converters/mcp3008

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.

We increase or decrease the system volume using 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

Also install Adafruit Python MCP3008:

pip install adafruit-mcp3008

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 the knob, the script should report what it's doing.

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. 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
    
  • 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 python
"""
The daemon responsible for changing the volume in response to a turn
of the volume knob.
"""
import logging
import signal
import subprocess
import sys
import time
import Adafruit_MCP3008
# LOGGING
# ========
logger = logging.getLogger(__name__)
# logging
logging.basicConfig(
level=logging.DEBUG,
format='%(asctime)-15s - %(message)s'
)
# SETTINGS
# ========
DEBUG = True
# Software SPI configuration.
GPIO_CLK = 18
GPIO_MISO = 23
GPIO_MOSI = 24
GPIO_CS = 25
# MCP3008 channel where potentiometer is connected
MCP3008_CHANNEL = 0
# 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 = 15
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
# Audio device name, e.g. 'PCM' or 'Master'. Find with: amixer scontrols
DEVICE_NAME = 'Master'
# (END SETTINGS)
#
def debug(str):
if not DEBUG:
return
logger.debug(str)
class RotaryEncoder(object):
"""
A class to decode mechanical rotary encoder pulses.
"""
def __init__(self, gpio_CLK, gpio_CS, gpio_MISO, gpio_MOSI, channel, tolerance=5):
# MCP3008 component
self.mcp = Adafruit_MCP3008.MCP3008(clk=gpio_CLK, cs=gpio_CS,
miso=gpio_MISO, mosi=gpio_MOSI)
self.volume = Volume()
self.channel = channel
# to keep from being jittery we'll only change
# volume when the pot has moved more than 5 'counts'
self.tolerance = tolerance
# this keeps track of the last potentiometer value
self.last_read = 0
def destroy(self):
debug('Destroying...')
def read(self):
# we'll assume that the pot didn't move
trim_pot_changed = False
# read the analog pin
trim_pot = self.mcp.read_adc(self.channel)
# how much has it changed since the last read?
pot_adjust = abs(trim_pot - self.last_read)
if pot_adjust > self.tolerance:
trim_pot_changed = True
if trim_pot_changed:
# convert 10bit adc0 (0-1024) trim pot read into 0-100 volume level
set_volume = int(round(trim_pot / 10.24))
self.volume.set_volume(set_volume)
# save the potentiometer reading for the next loop
self.last_read = trim_pot
class VolumeError(Exception):
pass
class Volume(object):
"""
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)
debug("set volume: {}".format(self.volume))
output = self.amixer("set '{}' unmute {}%".format(DEVICE_NAME, v))
self._sync(output)
return self.volume
def toggle(self):
"""
Toggles muting between on and off.
"""
if self.is_muted:
output = self.amixer("set '{}' unmute".format(DEVICE_NAME))
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 '{}' mute".format(DEVICE_NAME))
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 '{}'".format(DEVICE_NAME))
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: {}".format(code))
sys.exit(0)
return p.stdout
if __name__ == "__main__":
def on_exit(a, b):
debug("Exiting...")
encoder.destroy()
sys.exit(0)
debug("Volume knob using pins GPIO_CLK ({}), GPIO_CS ({}), GPIO_MISO ({}) and GPIO_MOSI ({})".format(
GPIO_CLK, GPIO_CS, GPIO_MISO, GPIO_MOSI))
encoder = RotaryEncoder(GPIO_CLK, GPIO_CS, GPIO_MISO, GPIO_MOSI, MCP3008_CHANNEL)
signal.signal(signal.SIGINT, on_exit)
debug("Initial volume: {}".format(encoder.volume.volume))
while True:
encoder.read()
# hang out and do nothing for a half second
time.sleep(0.5)
[Unit]
Description=Volume knob monitor
[Service]
User=pi
Group=pi
ExecStart=/home/pi/bin/monitor-volume
[Install]
WantedBy=multi-user.target
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
You can’t perform that action at this time.