Skip to content

Instantly share code, notes, and snippets.

@nwithan8
Last active April 4, 2023 17:58
Show Gist options
  • Star 2 You must be signed in to star a gist
  • Fork 2 You must be signed in to fork a gist
  • Save nwithan8/01a88e7a1853c506e524432993401c18 to your computer and use it in GitHub Desktop.
Save nwithan8/01a88e7a1853c506e524432993401c18 to your computer and use it in GitHub Desktop.
Make a random song jukebox (using Google AIY Voice Kit)

Earlier this year, I was lucky enough to get my hands on one of the Google AIY Voice Kits for the Raspberry Pi 3. With the kit and some walk-through coding, the kit is a developer example of accessing the Google Assistant and turning a Raspberry Pi into a makeshift Google Home. The project was cool, but I already had a Google Home, so I decided to mess around with the hardware instead.

Eventually, I decided to turn the device into a small, offline jukebox as a gift for my mom, who is a big fan of 80s music. When you hit the button on the top, a random 80s song starts playing. A knob on the side allows for volume adjustment. (Unfortunately, once the song starts playing, it can't easily be stopped. The volume knob can also click in like a button, which I intended to use to stop the music by stopping the music script and immediately restarting it. However, when I was messing with the knob, the button got jammed in, and so I abandoned that idea. Instead, the user can just turn the volume down to zero.)

The hardware for this project is relatively simple. Most is from the Google AIY Voice Kit, available here, combined with a Raspberry Pi 3 running the Voice Kit operating system. I'm not sure if you could skip the voice kit and simply wire a speaker and a button to the Pi; though I would think the scripts could run on a regular Raspbian install rather than the Google AIY Voice Kit-specific one. Both OSes are free, and the kit came with the speaker, button and a cardboard case, so I just went with this setup. If you decide to go a different route, you may need to adjust your wiring and GPIO pins in your scripts accordingly.

This tutorial starts after you have already followed the tutorial to assemble the Google AIY Voice Kit, found here. All my commands are run as superuser (force of habit). If you do not have root access for some reason, ignore the "sudo" part of each command.

The music button script comes from combining two scripts I found online, one for music playback and another for selecting a random file. The script below waits for a signal from GPIO pin 23 (the default pin when using the Google AIY Voice Kit; see here for pin layout). When it receives a signal, it chooses a random file from a specified directory (in this case, the standard /home/pi/Music folder on a normal Raspbain install). If the file ends in an mp3 extension, it stops the previous song by killing mpg123 and then plays the new file using mpg123. If the extension is anything other than mp3, it kills aplay and plays the new file using aplay. The script then loops forever, waiting for the button to be pushed.

If your musical files are stored anywhere other than /home/pi/Music, you'll need to edit the path in randomfile and file.

This script calls for python, mpg123 and aplay to be installed on your system. Run sudo apt-get install python to get Python. Run sudo apt-get install mpg123 to get mgp123. Aplay should be a part of alsa-utils, which you can get by running sudo apt-get install alsa-utils

You can download this script by running sudo wget [RAW FILE URL] and placing the file in your /home/pi directory

Make the file executable by running sudo chmod +x /home/pi/button.py

The volume script mostly comes from Andrew Dupont, aka savetheclocktower, and his well-written tutorial for using a rotary encoder for volume control (Thanks man). For my volume knob, I used this rotary encoder from Adafruit (a few dollars, comes with a black knob cover and a washer and bolt), which can trasmit turns in both directions, as well as a button push. For my setup, I wired the five pins on the encoder (see a layout here) to specific GPIO inputs on the Voice HAT. I used GPIO 26, GPIO 6 and the black ground on GPIO 26 for the volume twist, and GPIO 13 and the black ground on GPIO 13 for the button (see a pinout of the Voice HAT here. GPIO 26, 6 and 13 are all in the Servo section on the left side of the Voice HAT). If you decide to use different GPIO pins, make sure to change them in your volume-control script.

The script calls for amixer to be installed, which comes from alsa-utils. If you installed aplay from the button script setup, you should be good to go. The script also required python3, which can be downloaded by running sudo apt-get install python3 python3-rpi.gpio

You can download this script by running sudo wget [RAW FILE URL] and placing the file in your /home/pi/bin directory (if you do not have a bin folder yet, make one by running sudo mkdir bin while in your /home/pi directory).

Make sure /home/pi/bin is in your path. You can check by running echo $PATH If you don't see it listed, add it using echo "export PATH=$HOME/bin:$PATH" >> ~/.bashrc Restart your Pi to put it into effect.

Make the file executable by running sudo chmod +x /home/pi/bin/volume-control

In the script, turning the knob up or down with adjust the volume up or down by 5 percent, between 0 (mute) and 90 percent. After 90 percent, the sounds gets really distorted. These values and the GPIO pins are adjustable in the SETTINGS of the script. Since my knob's button is jammed, I set GPIO_BUTTON to None. If you set it to a GPIO pin (such as 13, if you follow my wiring), the button will mute and unmute the audio, according to the script. (Again, thanks Andrew).

To have the two scripts automatically run when the Pi boots, edit your rc.local file. Navigate to /etc/rc.local by typing: sudo nano /etc/rc.local

At the end of the file, after "fi", add python /home/pi/button.py & for the music playback control. Then, on a new line, add sudo /home/pi/bin/volume-control & for the volume control. Make sure to have exit 0 on a new line below the two commands to complete the rc.local file. Press Ctrl-X, and save your changes to the file.

Finally, I make a cutout for my volume knob and put the whole setup into the Google AIY Voice Kit box. Plug the thing in, add some music files to your Music directory, hit the button and rock out.

#!/usr/bin/env python
import os
from time import sleep
import random
import RPi.GPIO as GPIO
GPIO.setmode(GPIO.BCM)
GPIO.setup(23, GPIO.IN)
def rndmp3():
randomfile = random.choice(os.listdir("/home/pi/Music/"))
file = ' /home/pi/Music/' + randomfile
ext = os.path.splitext(randomfile)[-1].lower()
if ext == ".mp3":
os.system('pkill mpg123')
os.system('mpg123' + file + ' &');
else:
os.system('pkill aplay')
os.system('aplay' + file + ' &');
while True:
if (GPIO.input(23) == False):
rndmp3()
sleep(0.1);
#!/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 = 6
# The pin that the knob's button is hooked up to. If you have no button, set
# this to None.
GPIO_BUTTON = None
# 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 = 0
VOLUME_MAX = 90
# 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 = 5
# (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 'Master' 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 'Master' 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 'Master' 0;")
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 'Master'")
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(600)
consume_queue()
EVENT.clear()
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment