Skip to content

Instantly share code, notes, and snippets.

@transilluminate
Last active October 1, 2023 07:39
Show Gist options
  • Star 18 You must be signed in to star a gist
  • Fork 0 You must be signed in to fork a gist
  • Save transilluminate/d309b7f04b11230a3f6406a9cf70139c to your computer and use it in GitHub Desktop.
Save transilluminate/d309b7f04b11230a3f6406a9cf70139c to your computer and use it in GitHub Desktop.
Raspberry Pi Visualiser: Pimoroni Display-o-Tron (https://github.com/pimoroni/displayotron), CAVA (http://karlstav.github.io/cava/) and Shairport-Sync-Metadata-Reader (https://github.com/mikebrady/shairport-sync-metadata-reader).

Raspberry Pi Audio Visualiser

To play music at home, I use a Raspberry Pi and Shairport-sync to receive an Airplay stream and output via a pair of USB speakers.

I wanted to sample the audio stream, display an audio visualiser on the Display-o-Tron LCD display. Additionally I wanted to display the track info, which can be read from the metadata.

Using a bit of python magic, we can tie it all together...! Watch it all in action:

Raspberry Pi Visualiser

Instructions

Install the following pre-requisites:

Configure a loopback device

This is needed to capture the audio output to the speakers. In my case, I'm using USB speakers as the Pi's on-board autio output is awful.

Add 'snd-aloop' to /etc/modules so your file looks like this:

# /etc/modules: kernel modules to load at boot time.
#
# This file contains the names of kernel modules that should be loaded
# at boot time, one per line. Lines beginning with "#" are ignored.

i2c-dev
snd-aloop

By default, there's loads of loopback subdevices, we just need the one, so add this file to /etc/modprobe.d/:

# /etc/modprobe.d/snd-aloop.conf
options snd-aloop pcm_substreams=1 enable=1

Then configure the loopback device. We need to use the system /etc/asound.conf file as we run as 'root' to access the display-o-tron.

# example /etc/asound.conf

# Outputs to the Audioengine A2+ USB speakers
pcm.HardwareOutput {
	type hw;
	card A2;
};

# Sound in to this ...
pcm.LoopbackInput {
	type plug;
	slave.pcm "hw:Loopback,0,0";
};

# ... gets looped back into this device
pcm.LoopbackOutput {
	type plug;
	slave.pcm "hw:Loopback,1,0";
};

pcm.MultiMixer {
	type route;
	slave.pcm {
		type multi
		slaves.a.pcm "HardwareOutput";
		slaves.b.pcm "LoopbackInput";
		slaves.a.channels 2;
		slaves.b.channels 2;
		bindings.0.slave a;
		bindings.0.channel 0;
		bindings.1.slave a;
		bindings.1.channel 1;
		bindings.2.slave b;
		bindings.2.channel 0;
		bindings.3.slave b;
		bindings.3.channel 1;
	};
	ttable.0.0 1;
	ttable.1.1 1;
	ttable.0.2 1;
	ttable.1.3 1;
};

pcm.DuplexDevice {
	type asym;
	playback.pcm "MultiMixer";
	capture.pcm "LoopbackOutput";
};

pcm.!default {
	type plug;
	slave.pcm "DuplexDevice";
};

CAVA

Install with the instructions, then create the config file:

[general]
framerate = 30
bars = 16

[input]
method = alsa
source = hw:Loopback,1

[output]
method = raw
channels = mono
data_format = ascii
bit_format = 8bit
ascii_max_range = 96

This produces a stream of levels written to STDOUT which we capture with the main python script.

Metadata reader

Install shairport-sync-metadata-reader. No more config needed! Test on the console with shairport-sync-metadata-reader < /tmp/shairport-sync-metadata while playing music via shairport-sync.

cava_visualiser.py

Copy this to a locations on the Raspberry Pi, and make it executable... if you've got this far, I shouldn't have to give any instructions for this! Test that it works...

Load at startup

Once all is well and running nicely, edit /etc/rc.local to run the script at startup:

#!/bin/sh -e
#
# /etc/rc.local
#

# [...]

# Add this to the file to run the CAVA visualiser at boot:
/usr/bin/python3 /path/to/cava_visualiser.py &

exit 0
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
# ------------------------------------------------------- #
import os, threading, re, sys, time, math, unicodedata
from collections import deque
# import non-standard libraries
for libraryName in ['dot3k','dot3k.lcd','dot3k.backlight']:
try:
libraryObject = __import__(libraryName)
except ImportError:
print("Error: unable to load library: '%s', have you installed it?" % libraryName)
print(" try: sudo pip3 install %s" % libraryName)
sys.exit(1)
else:
globals()[libraryName] = libraryObject
# ------------------------------------------------------- #
DOT3K_ROWS = 3 # number of rows for the do3k display
DOT3K_COLUMNS = 16 # number of columns, should be the same as the cava config 'bars' setting
DOT3K_ROW_LEVEL = 8 # LCD pixels per character (8 on my old dot3k)
DOT3K_SLEEP_TIMER = 30 # number of seconds of no output when the screen goes to sleep
MAX_CAVA_LEVEL = 96 # a nice multiple of DOT3K_ROW_LEVEL (set at 96)
SLEEP = 0.01 # time to sleep the while True loops (0.01 is good)
METADATA_DISPLAY_TIME = 20 # time in seconds to display the metadata (20 is good)
METADATA_SCROLL_SPEED = 5 # frames/second that the metadata moves when scrolling (3-6 is good)
# ------------------------------------------------------- #
# display a warning if not root
if os.getuid() != 0:
print("Error: need to be root to access the Display-o-Tron!")
sys.exit(2)
# ------------------------------------------------------- #
class Visualiser(threading.Thread): # subclass of threading
def __init__(self):
threading.Thread.__init__(self)
self.daemon = True
# our cava command with dot3k-specific setup
self.command = '/usr/local/bin/cava -p /home/adrian/etc/cava.conf'
self.lock = threading.Lock()
self.fifo = deque([ [[0]*DOT3K_COLUMNS] ], maxlen = 1) # only one at a time, avoids catchup / lag
def get_output(self): # can return NoneType (!)
with self.lock:
try: return self.fifo.popleft()[0] # return the first and only entry in the deque ...
except: pass # ... or fail gracefully
def run(self):
try:
process = os.popen(self.command,mode='r')
while True:
time.sleep(SLEEP)
output = process.readline().rstrip()
if output:
if re.match('0;{DOT3K_COLUMNS}',output): continue # skip further processing if matches 'empty' string
matched = re.findall('(\d+)',output) # matches all digits
if matched and len(matched) == DOT3K_COLUMNS: # should be 16 'columns' of data
for i in range(len(matched)): matched[i] = int(matched[i]) # convert string to integers
with self.lock: self.fifo.append([matched]) # append to deque
except OSError as error:
print("Error:", error)
sys.exit(1)
# ------------------------------------------------------- #
class Metadata(threading.Thread):
def __init__(self):
threading.Thread.__init__(self)
self.daemon = True
# need to install https://github.com/mikebrady/shairport-sync-metadata-reader
self.command = "/usr/local/bin/shairport-sync-metadata-reader < /tmp/shairport-sync-metadata"
self.lock = threading.Lock()
self.metadata = {'Title': '', 'Artist': ''}
def get_metadata(self):
with self.lock: return self.metadata
def run(self):
try:
process = os.popen(self.command,mode='r')
while True:
time.sleep(SLEEP)
output = process.readline().rstrip()
if output:
for key in ["Artist","Title"]:
regex = key + ': "(.*)".'
match = re.match(regex,output)
if match:
# clean the special characters from the metadata as dot3k can't display "Með blóðnasir by Sigur Rós"!
clean_match = unicodedata.normalize('NFKD', match.group(1)).encode('ASCII', 'ignore').decode('UTF-8')
self.metadata[key] = clean_match
except OSError as error:
print("Error:", error)
sys.exit(1)
# ------------------------------------------------------- #
class Countdown():
def __init__(self,seconds):
self.timeout = seconds
self.timer = time.time() + self.timeout
def reset_countdown(self):
self.timer = time.time() + self.timeout
def is_elapsed(self):
if time.time() > self.timer:
return True
else:
return False
def remaining(self):
return math.floor(self.timer - time.time())
# ------------------------------------------------------- #
def initialise_dot3k():
# define our special characters (level0 is the ' ' character)
# (each character is a 5x8 led matrix)
level1 = [0b00000,0b00000,0b00000,0b00000,0b00000,0b00000,0b00000,0b11111]
level2 = [0b00000,0b00000,0b00000,0b00000,0b00000,0b00000,0b11111,0b11111]
level3 = [0b00000,0b00000,0b00000,0b00000,0b00000,0b11111,0b11111,0b11111]
level4 = [0b00000,0b00000,0b00000,0b00000,0b11111,0b11111,0b11111,0b11111]
level5 = [0b00000,0b00000,0b00000,0b11111,0b11111,0b11111,0b11111,0b11111]
level6 = [0b00000,0b00000,0b11111,0b11111,0b11111,0b11111,0b11111,0b11111]
level7 = [0b00000,0b11111,0b11111,0b11111,0b11111,0b11111,0b11111,0b11111]
level8 = [0b11111,0b11111,0b11111,0b11111,0b11111,0b11111,0b11111,0b11111]
# write the custom characters to the dot3k memory (enough room for 8x characters)
# http://docs.pimoroni.com/displayotron/_modules/dot3k/lcd.html#create_char
dot3k.lcd.create_char(0,level1)
dot3k.lcd.create_char(1,level2)
dot3k.lcd.create_char(2,level3)
dot3k.lcd.create_char(3,level4)
dot3k.lcd.create_char(4,level5)
dot3k.lcd.create_char(5,level6)
dot3k.lcd.create_char(6,level7)
dot3k.lcd.create_char(7,level8)
# set a decent level of contrast
# http://docs.pimoroni.com/displayotron/_modules/dot3k/lcd.html#set_contrast
dot3k.lcd.set_contrast(50)
# ------------------------------------------------------- #
def lcd_colour(f, index = None):
# there's a good colour change between these hues on a dot3k *RBG* display
# I have an early dot3k with reversed B/G channels, you may need to fiddle
# to get a nice range of colour
start = 160 # in the blue range
end = 350 # just touching the orange
step = end - start
hue = (start + (f * step)) / 360
if index == 0:
# http://docs.pimoroni.com/displayotron/_modules/dot3k/backlight.html#hue
dot3k.backlight.left_hue(hue)
elif index == 1:
dot3k.backlight.mid_hue(hue)
elif index == 2:
dot3k.backlight.right_hue(hue)
else:
dot3k.backlight.hue(hue)
# ------------------------------------------------------- #
def lcd_off():
time.sleep(0.5) # get's called a lot when sleeping, add a delay to save CPU cycles
dot3k.lcd.clear() # http://docs.pimoroni.com/displayotron/_modules/dot3k/lcd.html#clear
dot3k.backlight.off() # http://docs.pimoroni.com/displayotron/_modules/dot3k/backlight.html#off
dot3k.backlight.set_graph(0) # http://docs.pimoroni.com/displayotron/_modules/dot3k/backlight.html#set_graph
# ------------------------------------------------------- #
def main():
initialise_dot3k() # do the initial LCD setup
lcd_timer = Countdown(DOT3K_SLEEP_TIMER) # set a display timeout, in seconds
v = Visualiser() # setup cava visualiser process
v.start() # start the process
m = Metadata() # setup metadata reader process
m.start() # start the process
metadata_timer = Countdown(METADATA_DISPLAY_TIME) # time to display the track info for
display_text = {'Title': '','Artist': ''} # the metadata we are interested in
scroll_timer = Countdown(1 / METADATA_SCROLL_SPEED) # metadata scroll speed in seconds
scroll_string = '' # use this to store the original metadata string outside of the while loop
copy_scroll_text = False # flag used to copy the string only once
# main loop, catch Ctrl+C to exit gracefully
try:
while True:
time.sleep(SLEEP)
visualiser = v.get_output()
if visualiser: # we are capturing data through the visualiser
lcd_timer.reset_countdown() # reset the display timeout
# change the LCD backlight depending on the levels
levels = {'total':0, 'low':0, 'mid':0, 'high':0}
# process the output from CAVA
for column in range(DOT3K_COLUMNS):
# able to change the colour for the 3 LCDs and the bar graph
if column in range(0,5): levels['low'] += visualiser[column] # 5 columns
if column in range(5,11): levels['mid'] += visualiser[column] # 6 columns
if column in range(11,16): levels['high'] += visualiser[column] # 5 columns
# calculate the levels as a float between 0 and 1
levels['total'] = sum(visualiser) / (MAX_CAVA_LEVEL * DOT3K_COLUMNS)
levels['low'] = levels['low'] / (MAX_CAVA_LEVEL * 5) # as defined by the range, above
levels['mid'] = levels['mid'] / (MAX_CAVA_LEVEL * 6)
levels['high'] = levels['high'] / (MAX_CAVA_LEVEL * 5)
# update the dot3k backlight
lcd_colour(levels['low'],0) # '0' I defined as dot3k.backlight.left_hue,
lcd_colour(levels['mid'],1) # '1' as mid_hue
lcd_colour(levels['high'],2) # '2' as right_hue, representing the columns underneath them
# update the bar graph (still a bit *too* bright for my taste!)
# http://docs.pimoroni.com/displayotron/_modules/dot3k/backlight.html#set_graph
# dot3k.backlight.set_graph(levels['total'])
# position cursor in the top left of the LCD, the rows are named 2 for the top one, and 0 for the bottom
dot3k.lcd.set_cursor_position(0,0) # http://docs.pimoroni.com/displayotron/_modules/dot3k/lcd.html#set_cursor_position
metadata = m.get_metadata() # get the current track info
# check for new metadata
for key in display_text.keys():
if display_text[key] != metadata[key]:
display_text[key] = metadata[key] # copy the new metadata
metadata_timer.reset_countdown() # reset the countdown to display metadata
copy_scroll_text = True # new metadata, so flag this as true
# uncomment to test the track info
if display_text['Title'] != '' or display_text['Artist'] != '':
print("Now Playing: '" + display_text['Title'] + " - " + display_text['Artist'] + "'")
full_screen = None # will be set to false if displaying metadata, otherwise true to display over 3 lines
divisor_rows = None # this will be the number of rows to display over
# check to see if the timer has elapsed (or no metadata)
if metadata_timer.is_elapsed() or display_text['Title'] == '' or display_text['Artist']== '': # display full screen
full_screen = True
divisor_rows = 3
else:
full_screen = False
divisor_rows = 2
# create the LCD display output, reverse the order, i.e. 2,1,0 (2 = top line, 0 = bottom line)
for row in reversed(range(DOT3K_ROWS)):
for column in range(DOT3K_COLUMNS): # columns of data from 0 .. 15
divisor = MAX_CAVA_LEVEL / (DOT3K_ROW_LEVEL * divisor_rows)
level = math.floor(visualiser[column] / divisor)
if row == 2 and not full_screen: # n.b. row '2' is the top row
display_string = display_text['Title']
if display_text['Artist']: display_string += " - " + display_text['Artist']
if len(display_string) < DOT3K_COLUMNS: # pad to the length of the screen *FIXED!*
display_string += ' ' * (DOT3K_COLUMNS - (DOT3K_COLUMNS - len(display_string)))
# http://docs.pimoroni.com/displayotron/_modules/dot3k/lcd.html#write
# this *will* overspill onto the next line, so we need to cap to the width of the display
dot3k.lcd.write( display_string[column] ) # slice to the column index
elif len(display_string) >= DOT3K_COLUMNS: # scrolling display
display_string = (' ' * 3) + display_string # add blanks at the start
if copy_scroll_text == True: # flag to do this once (reset on new metadata)
scroll_string = display_string
copy_scroll_text = False
dot3k.lcd.write( scroll_string[column] ) # slice to the column index
if scroll_timer.is_elapsed(): # we need this as we're in a fast while loop
# slice from the 2nd character, then add the first character to the end
scroll_string = scroll_string[1:len(scroll_string)] + scroll_string[0]
scroll_timer.reset_countdown() # reset the countdown
else: # full screen, or 2nd and 3rd lines (we scale the visualiser down with the divisor_rows variable)
if level <= (row * DOT3K_ROW_LEVEL): # if the level is less than 0, 8 or 16 (depends on row)
character = ' ' # we don't display anything, i.e. a space character
dot3k.lcd.write(character)
elif level >= (DOT3K_ROW_LEVEL + (row * DOT3K_ROW_LEVEL)): # if the level is above 8, 16, or 24
character = DOT3K_ROW_LEVEL # display the maximum 'on' value
dot3k.lcd.write( chr( character - 1 )) # shift to the left as zero (0) is represented with space
else:
character = level - (DOT3K_ROW_LEVEL * row) # otherwise we display something in between...
dot3k.lcd.write( chr( character - 1 )) # again shifted left by 1
else: # end of 'if visualiser:' statement
if lcd_timer.is_elapsed(): lcd_off() # switch the display off after a timeout
except KeyboardInterrupt: # catch Ctrl+C
lcd_off()
sys.exit(0)
# ------------------------------------------------------- #
if __name__ == '__main__':
main()
@hehehe886
Copy link

This is so cooooooooooooool !

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