Search Results for: "python source code"

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)

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((127-(4*font1.getsize('M')[0] + 2),StatLine),'Mute' if Muted else ' ',

    screen.text((1,DataLine + 1*LineSpace),L2,
    screen.text((1,DataLine + 2*LineSpace),L3,

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)
             info[1] if len(info) > 1 else '',
             info[2] if len(info) > 2 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:




Raspberry Pi Streaming Radio Player: Command Line Parsing

Some experience suggested different default stations & volume settings for the streamers in various rooms, so the Python code now parses its command line to determine how to configure itself:

import argparse as args

cmdline = args.ArgumentParser(description='Streaming Radio Player',epilog='KE4ZNU -')
cmdline.add_argument('Loc',help='Location: BR1 BR2 ...',default='any',nargs='?')
args = cmdline.parse_args()

I should definitely pick a different variable name to avoid the obvious clash.

With that in hand, the customization takes very effort:

CurrentKC = 'KEY_KP7'
MuteDelay = 8.5         # delay before non-music track; varies with buffering
UnMuteDelay = 7.5       # delay after non-music track
MixerVol = '15'         # mixer gain

Location = vars(args)['Loc'].upper()
print 'Player location: ',Location'Player setup for: ' + Location)

if Location == 'BR1':
  CurrentKC = 'KEY_KPDOT'
  MixerVol = '10'
elif Location == 'BR2':
  MuteDelay = 6.0
  UnMuteDelay = 8.0
MixerVol = '5'

The Location = vars() idiom returns a dictionary of all the variables and their values, of which there’s only one at the moment. The rest of the line extracts the value and normalizes it to uppercase.

Now we can poke the button and get appropriate music without having to think very hard.

Life is good!

The Python source code, which remains in dire need of refactoring, as a GitHub Gist:


1 Comment

Raspberry Pi Streaming Radio Player: Improved Pipe Handling

My Raspberry Pi-based streaming radio player generally worked fine, except sometimes the keypad / volume control knob would stop responding after switching streams. This being an erratic thing, the error had to be a timing problem in otherwise correct code and, after spending Quality Time with the Python subprocess and select doc, I decided I was abusing mplayer’s stdin and stdout pipes.

This iteration registers mplayer’s stdout pipe as Yet Another select.poll() Polling Object, so that the main loop can respond whenever a complete line arrives. Starting mplayer in quiet mode reduces the tonnage of stdout text, at the cost of losing the streaming status that I really couldn’t do anything with, and eliminates the occasional stalls when mplayer (apparently) dies in the middle of a line.

The code kills and restarts mplayer whenever it detects an EOF or stream cutoff. That works most of the time, but a persistent server or network failure can still send the code into a sulk. Manually selecting a different stream (after we eventually notice the silence) generally sets things right, mainly by whacking mplayer upside the head; it’s good enough.

It seems I inadvertently invented streaming ad suppression by muting (most of) the tracks that produced weird audio effects. Given that the “radio stations” still get paid for sending ads to me, I’m not actually cheating anybody out of their revenue: I’ve just automated our trips to the volume control knob. The audio goes silent for a few seconds (or, sheesh, a few minutes) , blatting a second or two of ad noise around the gap to remind us of what we’re missing; given the prevalence of National Forest Service PSAs, the audio ad market must be a horrific wasteland.

The Python source code as a GitHub Gist:


1 Comment

HP 7475A Plotter: Coordinate Pruning

The original SuperFormula equation produces points in polar coordinates, which the Chiplotle library converts to the rectilinear format more useful with Cartesian plotters. I’ve been feeding the equation with 10001 angular values (10 passes around the paper, with 1000 points per pass, plus one more point to close the pattern), which means the angle changes by 3600°/10000 = 0.36° per point. Depending on the formula’s randomly chosen parameters, each successive point can move the plotter pen by almost nothing to several inches.

On the “almost nothing” end of the scale, the plotter slows to a crawl while the serial interface struggles to feed the commands. Given that you can’t see the result, why send the commands?

Computing point-to-point distances goes more easily in rectilinear coordinates, so I un-tweaked my polar-modified superformula function to return the points in rectangular coordinates. I’d originally thought a progressive scaling factor would be interesting, but it never happened.

The coordinate pruning occurs in the supershape function, which now contains a loop to scan through the incoming list of points from the  superformula function and add a point to the output path only when it differs by enough from the most recently output point:

    path = []
    path.append(Coordinate(width * points[0][0], height * points[0][1]))
    outi = 0
    xp, yp = points[outi][0], points[outi][1]
    for i in range(len(points))[1:]:
        x,y = width * points[i][0], height * points[i][1]
        dist = sqrt(pow(x - xp,2) + pow(y - yp,2))
        if dist > 60 :
          path.append(Coordinate(x, y))
          outi = i
          xp, yp = x, y

    path.append(Coordinate(width * points[-1][0], height * points[-1][1]))
    print "Pruned",len(points),"to",len(path),"points"

The first and last points always go into the output list; the latter might be duplicated, but that doesn’t matter.

Note that you can’t prune the list by comparing successive points, because then you’d jump directly from the start of a series of small motions to their end. The idea is to step through the small motions in larger units that, with a bit of luck, won’t be too ugly.

The width and height values scale the XY coordinates to fill either A or B paper sheets, with units of “Plotter Units” = 40.2 PU/mm = 1021 PU/inch. You can scale those in various ways to fit various output sizes within the sheets, but I use the defaults that fill the entire sheets with a reasonable margin. As a result, the magic number 60 specifies 60 Plotter Units; obviously, it should have a suitable name.

Pruning to 40 PU = 1.0 mm (clicky for more dots, festooned with over-compressed JPEG artifacts):

Plot pruned to 40 PU

Plot pruned to 40 PU

Pruning to 60 PU = 1.5 mm:

Plot pruned to 60 PU

Plot pruned to 60 PU

Pruning to 80 PU = 2.0 mm:

Plot pruned to 80 PU

Plot pruned to 80 PU

Pruning to 120 PU = 3.0 mm:

Plot pruned to 120 PU

Plot pruned to 120 PU

All four of those plots have the same pens in the same order, although I refilled a few of them in flight.

By and large, up through 80 PU there’s not much visual difference, although you can definitely see the 3 mm increments at 120 PU. However, the plotting time drops from just under an hour for each un-pruned plot to maybe 15 minutes with 120 PU pruning, with 60 PU producing very good results at half an hour.

Comparing the length of the input point lists to the pruned output path lists, including some pruning values not shown above:

Prune 20
1 - m: 5.3, n1: 0.15, n2=n3: 0.80
Pruned 10001 to 4856 points
2 - m: 5.3, n1: 0.23, n2=n3: 0.75
Pruned 10001 to 5545 points
3 - m: 5.3, n1: 1.15, n2=n3: 0.44
Pruned 10001 to 6218 points
4 - m: 5.3, n1: 0.41, n2=n3: 1.50
Pruned 10001 to 7669 points
5 - m: 5.3, n1: 0.29, n2=n3: 0.95
Pruned 10001 to 6636 points
6 - m: 5.3, n1: 0.95, n2=n3: 0.16
Pruned 10001 to 5076 points

Prune 40
1 - m: 3.1, n1: 0.23, n2=n3: 0.26
Pruned 10001 to 2125 points
2 - m: 3.1, n1: 1.05, n2=n3: 0.44
Pruned 10001 to 5725 points
3 - m: 3.1, n1: 0.25, n2=n3: 0.32
Pruned 10001 to 2678 points
4 - m: 3.1, n1: 0.43, n2=n3: 0.34
Pruned 10001 to 4040 points
5 - m: 3.1, n1: 0.80, n2=n3: 0.40
Pruned 10001 to 5380 points
6 - m: 3.1, n1: 0.55, n2=n3: 0.56
Pruned 10001 to 5424 points

Prune 60
1 - m: 1.1, n1: 0.45, n2=n3: 0.40
Pruned 10001 to 2663 points
2 - m: 1.1, n1: 0.41, n2=n3: 0.14
Pruned 10001 to 1706 points
3 - m: 1.1, n1: 1.20, n2=n3: 0.75
Pruned 10001 to 4446 points
4 - m: 1.1, n1: 0.33, n2=n3: 0.80
Pruned 10001 to 3036 points
5 - m: 1.1, n1: 0.90, n2=n3: 1.40
Pruned 10001 to 4723 points
6 - m: 1.1, n1: 0.61, n2=n3: 0.65
Pruned 10001 to 3601 points

Prune 80
1 - m: 3.7, n1: 0.95, n2=n3: 0.58
Pruned 10001 to 3688 points
2 - m: 3.7, n1: 0.49, n2=n3: 0.22
Pruned 10001 to 2258 points
3 - m: 3.7, n1: 0.57, n2=n3: 0.90
Pruned 10001 to 3823 points
4 - m: 3.7, n1: 0.25, n2=n3: 0.40
Pruned 10001 to 2161 points
5 - m: 3.7, n1: 0.47, n2=n3: 0.30
Pruned 10001 to 2532 points
6 - m: 3.7, n1: 0.45, n2=n3: 0.14
Pruned 10001 to 1782 points

Prune 120
1 - m: 1.9, n1: 0.33, n2=n3: 0.48
Pruned 10001 to 1561 points
2 - m: 1.9, n1: 0.51, n2=n3: 0.18
Pruned 10001 to 1328 points
3 - m: 1.9, n1: 1.80, n2=n3: 0.16
Pruned 10001 to 2328 points
4 - m: 1.9, n1: 0.21, n2=n3: 1.10
Pruned 10001 to 1981 points
5 - m: 1.9, n1: 0.63, n2=n3: 0.24
Pruned 10001 to 1664 points
6 - m: 1.9, n1: 0.45, n2=n3: 0.22
Pruned 10001 to 1290 points

Eyeballometrically, 60 PU pruning halves the number of plotted points, so the average data rate jumps from 9600 b/s to 19.2 kb/s. Zowie!

Most of the pruning occurs near the middle of the patterns, where the pen slows to a crawl. Out near the spiky rim, where the points are few & far between, there’s no pruning at all. Obviously, quantizing a generic plot to 1.5 mm would produce terrible results; in this situation, the SuperFormula produces smooth curves (apart from those spikes) that look just fine.

The Python source code as a GitHub Gist:


Raspberry Pi Streaming Radio Player: Mostly Viable Product

The latest version of my simpleminded streaming radio player includes:

  • More durable parsing for track titles with embedded quotes and semicolons
  • Muting during empty / non-music Radionomy tracks
  • The Dutchess County E911 service

Audionomy’s empty / non-music tracks include a remarkable number of mis-encoded MP3 sections triggering decoding problems; those problems don’t occur during music tracks. Some tracks come through as advertisements, which would be mostly OK apart from the garbled / high-volume gibberish, but on the whole they’re un-listenable:

ICY Info: StreamTitle='';
A:1271.0 (21:10.9) of 0.0 (00.0)  4.0% 44%
[mp3float @ 0x7623e080]overread, skip -7 enddists: -5 -5
[mp3float @ 0x7623e080]overread, skip -9 enddists: -6 -6
A:1271.2 (21:11.2) of 0.0 (00.0)  4.0% 45%
[mp3float @ 0x7623e080]overread, skip -7 enddists: -5 -5
A:1309.1 (21:49.1) of 0.0 (00.0)  4.0% 42%
ICY Info: StreamTitle='Targetspot - TargetSpot';
A:1316.4 (21:56.4) of 0.0 (00.0)  4.0% 40%
[mp3float @ 0x7623e080]overread, skip -5 enddists: -4 -4
[mp3float @ 0x7623e080]overread, skip -5 enddists: -2 -2

Muting happens in the mixer, because that seems easier than messing with mplayer in mid-flight. Rather than attempt to control the muted state with specific timeouts, I just un-mute after a new track title arrives; that has no effect if it’s already un-muted. The delays depend on the buffer fill level and avoid the worst of the gibberish.

The player still falls over dead / jams solid on occasion, generally because the incoming data has stopped streaming or delivered severe encoding problems. Other than that, it runs pretty much all day, every day, on at least one of the Raspberry Pi streamers.

Still no track display. Mostly, we still don’t miss it.

The Python source code as a GitHub Gist:



Raspberry Pi Streaming Radio Player: Minimum Viable Product

With the numeric keypad producing events, and the USB audio box producing sound, the next steps involve starting mplayer through Python’s subprocess interface and feeding keystrokes into it.

There’s not much to it:

As much hardware doc as you need:

RPi Streaming Player - first lashup

RPi Streaming Player – first lashup

The green plug leads off to a set of decent-quality PC speakers with far more bass drive than seems absolutely necessary in this context. The usual eBay vendor bungled an order for the adapter between the RCA line-out jacks and the 3.5 mm plug that will avoid driving the speakers from the UCA202’s headphone monitor output; I doubt that will make any audible difference. If you need an adapter with XLR female to 1/4 inch mono, let me know…

The keypad labels provide all the UI documentation there is:

Numeric Keypad - stream labels

Numeric Keypad – stream labels

The Python source code as a GitHub Gist:

The Media dictionary relates keycodes with the command line parameters required to fire mplayer at the streaming stations. With that running, the Controls dictionary turns keycodes into mplayer keyboard controls.

There’s no display: you have no idea what’s going on. I must start the program manually through an ssh session and can watch mplayer‘s console output.

Poking the Halt button forcibly halts the RPi, after which you squeeze the Reset button to reboot the thing. There’s no indication that it’s running, other than sound coming out of the speakers, and no way to tell it fell of the rails other than through the ssh session.

The loop blocks on events, so it can’t also extract stream titles from the (not yet implemented) mplayer stdout pipe / file and paste them on the (missing) display; that’s gotta go.

There’s a lot not to like about all that, of course, but it’s in the tradition of getting something working to discover how it fails and, in this case, how it sounds, which is even more important.


Poughkeepsie ACM Chapter Presentation: Plotting Like It’s 1989!

I’ll be giving an in-depth talk about my adventures restoring that old HP 7475A plotter for the Poughkeepsie ACM Chapter at Marist College this evening:

Superformula Plot - Composite D

Superformula Plot – Composite D

This being the Association for Computing Machinery, I will talk a bit about the Superformula that makes it all possible:

Gielis Superformula - parameters

Gielis Superformula – parameters

The presentation will look a lot like this: ACM – Plotting Like Its 1989. The PDF doesn’t include my patter, but perhaps the linky love on each screen can fill in the details.

If you’re following along, the Python source code running on the plotter as a GitHub Gist:


Leave a comment