Raspberry Pi Streaming Radio Player: OLED Display

With the OLED wired up to the Raspberry Pi, the LUMA.OLED driver makes it surprisingly easy to slap text on the screen, at least after some obligatory fumbling around:

RPi OLED Display - Plenitude
RPi OLED Display – Plenitude

Connect the hardware, install the driver, then the setup goes like this:

import textwrap

from luma.oled.device import sh1106
from luma.core.serial import spi
from luma.core.render import canvas
from PIL import ImageFont

… snippage …

font1 = ImageFont.truetype('/usr/share/fonts/truetype/dejavu/DejaVuSansMono.ttf',14)
font2 = ImageFont.truetype('/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf',11)

wrapper = textwrap.TextWrapper(width=128//font2.getsize('n')[0])

StatLine = 0
DataLine = 17           # allow for weird ascenders and accent marks
LineSpace = 16

Contrast = 255          # OLED brightness setting

serial = spi(device=0,port=0)
device = sh1106(serial)
device.contrast(Contrast)

The Python Imaging Library below the LUMA driver supports Truetype fonts that look much better than the default fonts. For these tiny displays, DejaVu Sans comes close enough to being a single-stroke (“stick”) font and, being proportional, packs more text into a fixed horizontal space.

The textwrap library chops a string into lines of a specified length, which works great with a fixed-width font and not so well with a proportional font. I set the line length based on the width of a mid-size lowercase letter and hope for the best. In round numbers, each 128 pixel line can hold 20-ish characters of the size-11 (which might be the height in pixels) font.

It also understands hyphens and similar line-ending punctuation:

Felix Mendelssohn-
Bartholdy - Piano
Concerto No.01 in

It turns out whatever library routine blits characters into the bitmap has an off-by-one error that overwrites the leftmost column with the pixel columns that should be just off-screen on the right; it may also overwrite the topmost row with the bottommost row+1. I poked around a bit, couldn’t find the actual code amid the layers of inherited classes and methods and suchlike, and gave up: each line starts in pixel column 1, not 0. With textwrap generally leaving the rightmost character in each line blank, the picket-fence error (almost) always overwrites the first column with dark pixels.

Display coordinates start at (0,0) in the upper left corner, but apparently the character origin corresponds to the box around an uppercase letter, with ascenders and diacritical marks extending (at least) one pixel above that. The blue area in these displays starts at (0,16), but having the ascenders poke into the yellow section is really, really conspicuous, so DataLine Finagles the text down by one pixel. The value of Linespace avoids collisions between descenders and ascenders in successive lines that you (well, I) wouldn’t expect with a spacing equal to the font height.

The display has a variable brightness setting, called “contrast” by the datasheet and driver, that determines the overall LED current (perhaps according to an exponential relationship, because an α appears in the tables). I tweak the value in Contrast based on where the streamer lives, with 1 being perfectly suitable for a dark room and 255 for ordinary lighting.

The LUMA package includes a scrolling terminal emulator. With maybe four lines, tops, on that display (in a reasonable font, anyhow), what’s the point?

Instead, I homebrewed a panel with manual positioning:

def ShowStatus(L1=None,L2=None,L3='None'):
  with canvas(device) as screen:
    screen.text((1,StatLine),Media[CurrentKC][0][0:11],
             font=font1,fill='white')
    screen.text((127-(4*font1.getsize('M')[0] + 2),StatLine),'Mute' if Muted else ' ',
             font=font1,fill='white')

    screen.text((1,DataLine),L1,
             font=font2,fill='white')
    screen.text((1,DataLine + 1*LineSpace),L2,
             font=font2,fill='white')
    screen.text((1,DataLine + 2*LineSpace),L3,
             font=font2,fill='white')

Yeah, those are global variables in the first line; feel free to object-orient it as you like.

The LUMA driver hands you a blank screen inside the with … as …: context, whereupon you may draw as you see fit and the driver squirts the bitmap to the display at the end of the context. There’s apparently a way to set up a permanent canvas and update it at will, but this works well enough for now.

That means you (well, I) must mange those three lines by hand:

ShowStatus('Startup in ' + Location,
           'Mixer: ' + MixerChannel + ' = ' + MixerVol,
           'Contrast: ' + str(Contrast))

Chopping the track info string into lines goes like this:

if TrackName:
  info = wrapper.wrap(TrackName)
  ShowStatus(info[0],
             info[1] if len(info) > 1 else '',
             info[2] if len(info) > 2 else '')
else:
  ShowStatus('No track info','','')

Something along the way ruins Unicode characters from the track info, converting them into unrelated (and generally accented) characters. They work fine when shipped through the logging interface, so it may be due to a font incompatibility or, more likely, my not bothering to work around Python 2’s string vs. byte stream weirdness. Using Python 3 would be a Good Idea, but I’m unsure all the various & sundry libraries are compatible and unwilling to find out using programming as an experimental science.

The Python source code as a GitHub Gist:

from evdev import InputDevice,ecodes,KeyEvent
import subprocess32 as subp
import select
import re
import sys
import time
import logging
import logging.handlers
import os.path
import argparse as args
import textwrap
from luma.oled.device import sh1106
from luma.core.serial import spi
from luma.core.render import canvas
from PIL import ImageFont
# URL must be last entry in command line list
Media = {'KEY_KP7' : ['Classical',False,['mplayer','-playlist','http://stream2137.init7.net/listen.pls']],
'KEY_KP8' : ['Jazz',False,['mplayer','-playlist','http://stream2138.init7.net/listen.pls']],
'KEY_KP9' : ['WMHT',False,['mplayer','http://wmht.streamguys1.com/wmht1']],
'KEY_KP4' : ['Classic 1k',True,['mplayer','-playlist','http://listen.radionomy.com/1000classicalhits.m3u']],
'KEY_KP5' : ['Love',True,['mplayer','-playlist','/home/pi/Playlists/LoveRadio.m3u']],
'KEY_KP6' : ['WAMC',False,['mplayer','-playlist','http://playerservices.streamtheworld.com/pls/WAMCFM.pls']],
'KEY_KP1' : ['60s',True,['mplayer','-playlist','http://listen.radionomy.com/all60sallthetime-keepfreemusiccom.m3u']],
'KEY_KP2' : ['50-70s',True,['mplayer','-playlist','http://listen.radionomy.com/golden-50-70s-hits.m3u']],
'KEY_KP3' : ['Soft Rock',True,['mplayer','-playlist','http://listen.radionomy.com/softrockradio.m3u']],
'KEY_KP0' : ['Zen',False,['mplayer','http://iradio.iceca.st:80/zenradio']],
'KEY_KPDOT' : ['Ambient',False,['mplayer','http://185.32.125.42:7331/maschinengeist.org.mp3']],
'KEY_KPMINUS' : ['Relaxation',True,['mplayer','-playlist','/home/pi/Playlists/Frequences-relaxation.m3u']],
'KEY_KPPLUS' : ['Plenitude',True,['mplayer','-playlist','/home/pi/Playlists/Radio-PLENITUDE.m3u']]
}
# these keycode will be fed directly into mplayer
Controls = {'KEY_KPSLASH' : '//////',
'KEY_KPASTERISK' : '******',
'KEY_VOLUMEUP' : '*',
'KEY_VOLUMEDOWN' : '/'
}
# stream title keywords that trigger muting
MuteStrings = ['TargetSpot', # common Radionomy insert
'Intro of','Jingle','*bumper*', # Radio-PLENITUDE
'[Unknown]','Advert:','+++','---','SRR','Srr', # softrockradio
'PEACE LK1','PEACE J1'] # Frequences-relaxation
# Set up default configuration
CurrentKC = 'KEY_KP7' # default stream source
MuteDelay = 6.5 # delay before non-music track; varies with buffering
UnMuteDelay = 9.0 # delay after non-music track
MixerChannel = 'PCM' # default amixer output control
MixerVol = '30' # mixer gain
RestartDelay = 10 # delay after stream failure
Contrast = 255 # OLED brightness setting
# Set up command line parsing
cmdline = args.ArgumentParser(description='Streaming Radio Player',epilog='KE4ZNU - http://softsolder.com')
cmdline.add_argument('Loc',help='Location: BR1 BR2 ...',default='any',nargs='?')
args = cmdline.parse_args()
# Set up logging
LogFN = '/home/pi/Streamer.log'
LogFmt = logging.Formatter('%(asctime)s %(levelname)s: %(message)s')
LogHandler = logging.handlers.RotatingFileHandler(LogFN,backupCount=9)
LogHandler.setFormatter(LogFmt)
logger = logging.getLogger('StreamLog')
logger.addHandler(LogHandler)
logger.setLevel(logging.INFO)
# Tweak config based on where we are
Location = vars(args)['Loc'].upper()
logger.info('Player setup for: ' + Location)
if Location == 'BR1':
CurrentKC = 'KEY_KPDOT'
MixerVol = '5'
Contrast = 1
elif Location == 'BR2':
MuteDelay = 4.5
UnMuteDelay = 8.5
MixerVol = '5'
Contrast = 1
elif Location == 'LR':
MixerVol = '40'
CurrentKC = 'KEY_KPPLUS'
# set up event inputs and polling objects
# This requires some udev magic to create the symlinks
k = InputDevice('/dev/input/keypad')
k.grab()
kp = select.poll()
kp.register(k.fileno(),select.POLLIN + select.POLLPRI + select.POLLERR)
# if volume control knob exists, then set up its events
VolumeDevice = '/dev/input/volume'
vp = select.poll()
if os.path.exists(VolumeDevice):
logger.info('Volume control device: %s',VolumeDevice)
v = InputDevice(VolumeDevice)
v.grab()
vp.register(v.fileno(),select.POLLIN + select.POLLPRI + select.POLLERR)
# set up file for mplayer output tracing
lw = open('/home/pi/mp.log','w') # mplayer piped output
# set the mixer output low enough that the initial audio won't wake the dead
subp.call(['amixer','-q','sset',MixerChannel,MixerVol])
if Media[CurrentKC][1]:
subp.call(['amixer','-q','sset',MixerChannel,'mute'])
Muted = True # squelch anything before valid track name
logger.info('Audio muted at startup')
else:
subp.call(['amixer','-q','sset',MixerChannel,'unmute'])
Muted = False # allow early audio
logger.info('Audio unmuted at startup')
# Set up OLED display
font1 = ImageFont.truetype('/usr/share/fonts/truetype/dejavu/DejaVuSansMono.ttf',14)
font2 = ImageFont.truetype('/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf',11)
wrapper = textwrap.TextWrapper(width=128//font2.getsize('n')[0])
StatLine = 0
DataLine = 17 # allow one line for weird ascenders and accent marks
LineSpace = 16
serial = spi(device=0,port=0)
device = sh1106(serial)
device.contrast(Contrast)
def ShowStatus(L1=None,L2=None,L3='None'):
with canvas(device) as screen:
screen.text((1,StatLine),Media[CurrentKC][0][0:11],
font=font1,fill='white')
screen.text((127-(4*font1.getsize('M')[0] + 2),StatLine),'Mute' if Muted else ' ',
font=font1,fill='white')
screen.text((1,DataLine),L1,
font=font2,fill='white')
screen.text((1,DataLine + 1*LineSpace),L2,
font=font2,fill='white')
screen.text((1,DataLine + 2*LineSpace),L3,
font=font2,fill='white')
ShowStatus('Startup in ' + Location,
'Mixer: ' + MixerChannel + ' = ' + MixerVol,
'Contrast: ' + str(Contrast))
# Start the player with the default stream, set up for polling
logger.info('Starting mplayer on %s -> %s',Media[CurrentKC][0],Media[CurrentKC][-1][-1])
p = subp.Popen(Media[CurrentKC][-1],
stdin=subp.PIPE,stdout=subp.PIPE,stderr=subp.STDOUT)
pp = select.poll() # this may be valid for other invocations, but is not pretty
pp.register(p.stdout.fileno(),select.POLLIN + select.POLLPRI + select.POLLERR)
#--------------------
#--- Play the streams
while True:
# pluck next line from mplayer and decode it
if [] != pp.poll(10):
text = p.stdout.readline()
if 'Error: ' in text: # something horrible went wrong
lw.write(text)
lw.flush()
logger.info('Unsolvable problem! ' + text)
logger.info('Exiting')
LogHandler.doRollover()
logging.shutdown()
sys.exit('Exit streamer on mplayer error --' + text)
if 'ICY Info: ' in text: # these lines may contain track names
lw.write(text)
lw.flush()
trkinfo = text.split(';') # also splits at semicolon embedded in track name
# logger.info('Raw split line: %s', trkinfo)
for ln in trkinfo:
if 'StreamTitle' in ln: # this part probably contains the track name
NeedMute = False # assume a listenable track
trkhit = re.search(r"StreamTitle='(.*)'",ln) # extract title if possible
if trkhit: # regex returned valid result?
TrackName = trkhit.group(1) # get string between two quotes
else:
logger.info('Regex failed for line: [' + ln + ']')
TrackName = 'Invalid StreamTitle format!'
logger.info('Track name: [%s]', TrackName)
if Media[CurrentKC][1] and ( (len(TrackName) == 0) or any(m in TrackName for m in MuteStrings) ) :
NeedMute = True
if NeedMute:
if Media[CurrentKC][1] and not Muted:
time.sleep(MuteDelay) # brute-force assumption about buffer leadtime
subp.call(['amixer','-q','sset',MixerChannel,'mute'])
Muted = True
logger.info('Track muted')
else:
if Muted:
if Media[CurrentKC][1]:
time.sleep(UnMuteDelay) # another brute-force timing assumption
subp.call(['amixer','-q','sset',MixerChannel,'unmute'])
Muted = False
logger.info('Track unmuted')
if TrackName:
info = wrapper.wrap(TrackName)
ShowStatus(info[0],
info[1] if len(info) > 1 else '',
info[2] if len(info) > 2 else '')
else:
ShowStatus('No track info','','')
elif 'Exiting.' in text: # mplayer just imploded
lw.write(text)
lw.flush()
logger.info('EOF or stream cutoff: [' + text + ']')
ShowStatus('Killing dead Mplayer','','')
pp.unregister(p.stdout.fileno())
p.terminate() # p.kill()
p.wait()
logger.info('Discarding keys')
while [] != kp.poll(0):
kev = k.read
time.sleep(RestartDelay)
logger.info('Restarting mplayer')
ShowStatus('Restarting Mplayer','','')
p = subp.Popen(Media[CurrentKC][-1],
stdin=subp.PIPE,stdout=subp.PIPE,stderr=subp.STDOUT)
pp.register(p.stdout.fileno(),select.POLLIN + select.POLLPRI + select.POLLERR)
logger.info(' ... running')
ShowStatus('Mplayer running','','')
# accept pending events from volume control knob
if [] != vp.poll(10):
vev = v.read()
lw.write('Volume')
lw.flush()
for e in vev:
if e.type == ecodes.EV_KEY:
kc = KeyEvent(e).keycode
if kc in Controls:
try:
p.stdin.write(Controls[kc])
except Exception as e:
logger.info('Error sending volume, restarting player: ' + str(e))
try:
pp.unregister(p.stdout.fileno())
except Exception as e:
logger.info('Cannot unregister stdout: ' + str(e))
ShowStatus('Vol error','Restarting',' Mplayer')
time.sleep(RestartDelay)
p = subp.Popen(Media[CurrentKC][-1],
stdin=subp.PIPE,stdout=subp.PIPE,stderr=subp.STDOUT)
pp.register(p.stdout.fileno(),select.POLLIN + select.POLLPRI + select.POLLERR)
logger.info(' ... running')
# accept pending events from keypad
if [] != kp.poll(10):
kev = k.read()
lw.write('Keypad')
lw.flush()
for e in kev:
if e.type == ecodes.EV_KEY:
kc = KeyEvent(e).keycode
if kc == 'KEY_NUMLOCK': # discard these, as we don't care
continue
if (kc == 'KEY_BACKSPACE') and (KeyEvent(e).keystate == KeyEvent.key_hold):
if True:
logger.info('Shutting down')
LogHandler.doRollover()
logging.shutdown()
p.kill()
q = subp.call(['sudo','shutdown','-P','now'])
q.wait()
time.sleep(5)
else:
logger.info('Exiting from main')
LogHandler.doRollover()
logging.shutdown()
sys.exit('Exit on command')
break
if KeyEvent(e).keystate != KeyEvent.key_down: # now OK to discard key up & hold
continue
if kc == 'KEY_KPENTER': # toggle muted state
if Muted:
logger.info('Forcing unmute')
subp.call(['amixer','-q','sset',MixerChannel,'unmute'])
Muted = False
else:
logger.info('Forcing mute')
subp.call(['amixer','-q','sset',MixerChannel,'mute'])
Muted = True
continue
if kc in Controls:
logger.info('Control: ' + kc)
try:
p.stdin.write(Controls[kc])
except Exception as e:
logger.info('Error sending controls, restarting player: ' + str(e))
ShowStatus('Ctl error','Restarting',' Mplayer')
try:
pp.unregister(p.stdout.fileno())
except Exception as e:
logger.info('Cannot unregister stdout: ' + str(e))
p.terminate() # p.kill()
p.wait()
time.sleep(RestartDelay)
p = subp.Popen(Media[CurrentKC][-1],
stdin=subp.PIPE,stdout=subp.PIPE,stderr=subp.STDOUT)
pp.register(p.stdout.fileno(),select.POLLIN + select.POLLPRI + select.POLLERR)
logger.info(' ... running')
ShowStatus('Mplayer',' running','')
if kc in Media:
logger.info('Switching stream: ' + Media[kc][0] + ' -> ' + Media[kc][-1][-1])
oldname = Media[CurrentKC][0]
CurrentKC = kc
ShowStatus('Switching from',oldname,'Halt Mplayer')
try:
pp.unregister(p.stdout.fileno())
except Exception as e:
logger.info('Cannot unregister stdout: ' + str(e))
try:
p.communicate(input='q')
except Exception as e:
logger.info('Mplayer already dead? ' + str(e))
try:
p.terminate() # p.kill()
p.wait()
except Exception as e:
logger.info('Trouble with terminate or wait: ' + str(e))
if Media[CurrentKC][1]:
subp.call(['amixer','-q','sset',MixerChannel,'mute'])
Muted = True
logger.info('Audio muted for restart')
else:
subp.call(['amixer','-q','sset',MixerChannel,'unmute'])
Muted = False
logger.info('Audio unmuted for restart')
time.sleep(RestartDelay)
logger.info('Restarting Mplayer')
p = subp.Popen(Media[CurrentKC][-1],
stdin=subp.PIPE,stdout=subp.PIPE,stderr=subp.STDOUT)
pp.register(p.stdout.fileno(),select.POLLIN + select.POLLPRI + select.POLLERR)
logger.info(' ... running')
ShowStatus('Started Mplayer','','')

3 thoughts on “Raspberry Pi Streaming Radio Player: OLED Display

  1. layers of inherited classes and methods and suchlike

    Yeah, my C programming brain had a fun time wrapping itself around the python code for the Elitech RC-5 datalogger. (The Fahrenheit code is incomplete; the datalogger knows it’s in F and outputs a C/F flag, but the dumped data is in Celsius. Conversion is left to the datareader code. I’ll see about fixing it Real Soon Now, and submit a patch.)

    1. AFAICT, the inheritances all say something like: “Yes, this is me, I’m over there!”

Comments are closed.