Created
December 11, 2018 12:34
-
-
Save maxious/581ac7213497fa76bd71bd7b1b893448 to your computer and use it in GitHub Desktop.
Display-o-tron HAT for Raspberry Pi Music Visualiser https://shop.pimoroni.com/products/display-o-tron-hat https://gist.github.com/transilluminate/d309b7f04b11230a3f6406a9cf70139c
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
#!/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 ['dothat','dothat.lcd','dothat.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 | |
# ------------------------------------------------------- # | |
DOTHAT_LEDS = 5 | |
DOTHAT_ROWS = 3 # number of rows for the do3k display | |
DOTHAT_COLUMNS = 16 # number of columns, should be the same as the cava config 'bars' setting | |
DOTHAT_ROW_LEVEL = 8 # LCD pixels per character (8 on my old dothat) | |
DOTHAT_SLEEP_TIMER = 30 # number of seconds of no output when the screen goes to sleep | |
MAX_CAVA_LEVEL = 96 # a nice multiple of DOTHAT_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 dothat-specific setup | |
self.command = '/usr/local/bin/cava -p /root/.config/cava/config' | |
self.lock = threading.Lock() | |
self.fifo = deque([ [[0]*DOTHAT_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;{DOTHAT_COLUMNS}',output): continue # skip further processing if matches 'empty' string | |
matched = re.findall('(\d+)',output) # matches all digits | |
if matched and len(matched) == DOTHAT_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 dothat 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_dothat(): | |
# 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 dothat memory (enough room for 8x characters) | |
# http://docs.pimoroni.com/displayotron/_modules/dothat/lcd.html#create_char | |
dothat.lcd.create_char(0,level1) | |
dothat.lcd.create_char(1,level2) | |
dothat.lcd.create_char(2,level3) | |
dothat.lcd.create_char(3,level4) | |
dothat.lcd.create_char(4,level5) | |
dothat.lcd.create_char(5,level6) | |
dothat.lcd.create_char(6,level7) | |
dothat.lcd.create_char(7,level8) | |
# set a decent level of contrast | |
# http://docs.pimoroni.com/displayotron/_modules/dothat/lcd.html#set_contrast | |
dothat.lcd.set_contrast(50) | |
# ------------------------------------------------------- # | |
def lcd_colour(f, index = None): | |
# there's a good colour change between these hues on a dothat *RBG* display | |
# I have an early dothat 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 | |
col_rgb = dothat.backlight.hue_to_rgb(hue) | |
if index: | |
# http://docs.pimoroni.com/displayotron/_modules/dothat/backlight.html#hue | |
dothat.backlight.single_rgb(index, col_rgb[0], col_rgb[1], col_rgb[2], False) | |
else: | |
dothat.backlight.rgb(col_rgb[0], col_rgb[1], col_rgb[2]) | |
# ------------------------------------------------------- # | |
def lcd_off(): | |
time.sleep(0.5) # get's called a lot when sleeping, add a delay to save CPU cycles | |
dothat.lcd.clear() # http://docs.pimoroni.com/displayotron/_modules/dothat/lcd.html#clear | |
dothat.backlight.off() # http://docs.pimoroni.com/displayotron/_modules/dothat/backlight.html#off | |
dothat.backlight.set_graph(0) # http://docs.pimoroni.com/displayotron/_modules/dothat/backlight.html#set_graph | |
# ------------------------------------------------------- # | |
def main(): | |
initialise_dothat() # do the initial LCD setup | |
lcd_timer = Countdown(DOTHAT_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 | |
levels = [0,0,0,0,0,0] #{'total':0, 'low':0, 'mid':0, 'high':0} | |
# process the output from CAVA | |
for column in range(DOTHAT_COLUMNS): | |
levels[math.floor(column/3)] += visualiser[column] /300 | |
# 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 * DOTHAT_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 dothat backlight | |
for column in range(DOTHAT_LEDS): | |
lcd_colour(levels[column],column) | |
dothat.backlight.update() | |
# update the bar graph (still a bit *too* bright for my taste!) | |
# http://docs.pimoroni.com/displayotron/_modules/dothat/backlight.html#set_graph | |
# dothat.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 | |
dothat.lcd.set_cursor_position(0,0) # http://docs.pimoroni.com/displayotron/_modules/dothat/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(DOTHAT_ROWS)): | |
for column in range(DOTHAT_COLUMNS): # columns of data from 0 .. 15 | |
divisor = MAX_CAVA_LEVEL / (DOTHAT_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) < DOTHAT_ROWS: # pad to the length of the screen | |
display_string += ' ' * (DOTHAT_ROWS - (DOTHAT_ROWS - len(display_string))) | |
# http://docs.pimoroni.com/displayotron/_modules/dothat/lcd.html#write | |
# this *will* overspill onto the next line, so we need to cap to the width of the display | |
dothat.lcd.write( display_string[column] ) # slice to the column index | |
elif len(display_string) >= DOTHAT_ROWS: # 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 | |
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 | |
dothat.lcd.write( scroll_string[column] ) # slice to the column index | |
else: # full screen, or 2nd and 3rd lines (we scale the visualiser down with the divisor_rows variable) | |
if level <= (row * DOTHAT_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 | |
dothat.lcd.write(character) | |
elif level >= (DOTHAT_ROW_LEVEL + (row * DOTHAT_ROW_LEVEL)): # if the level is above 8, 16, or 24 | |
character = DOTHAT_ROW_LEVEL # display the maximum 'on' value | |
dothat.lcd.write( chr( character - 1 )) # shift to the left as zero (0) is represented with space | |
else: | |
character = level - (DOTHAT_ROW_LEVEL * row) # otherwise we display something in between... | |
dothat.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() |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment