|
#!/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() |
This is so cooooooooooooool !