Fork this with Git

Oscilloscope Music Player

Properly playing sound files for visualization on an oscilloscope
Project started on February 11, 2024.
Last updated on March 08, 2024.

Soon our local hackerspace Toolbox Bodensee has its 10-year anniversary, and it will also be present at the IBO fair 2024 in Friedrichshafen. Some five or six years ago I already prepared a small setup at one of the Toolbox open-door-days with an oscilloscope and a laptop playing Jerobeam Fendersons Oscilloscope Music. But it never looked quite right. So I decided to tackle the topic again this year.

Front of Osci Music Player
© 2024 Falko.
© 2024 Falko.
'Reconstruct' with the Osci Music Player

Table Of Contents

Want to build your own player? Skip to the interesting part then!

Introduction

An oscilloscope usually displays one or more waveforms of an electrical signal. To achieve this a single dot is moved across the screen, with the voltage or amplitude of the signal controlling the vertical deflection (Y axis), and time controlling the horizontal deflection (X axis), meaning the dot automatically moves from left to right with a pre-configured speed. But most oscilloscopes can also be configured in something called XY mode, where the horizontal deflection is no longer controlled by time, but instead by another voltage. This turnes the oscilloscope into a vector display, where a single dot can be moved across the screen. Note that the brightness can not be controlled, it is fixed. Some oscilloscopes have a third input that can be used for that, but this is not needed here.

A common way to achieve digital to analog conversion (DAC) of a signal, with hardware usually available in every household, is using the soundcard of a PC. The stereo line output channels are basically connected directly to a DAC chip, with only some filtering hardware in between.

Now combining these concepts, one can connect the two audio output channels of a PC or similar device to the two inputs of an oscilloscope in XY mode. This enables a kind of visualization of music, and was also used in the past in something called "audio vectorscope" to check the balance of the channels. If you have a mono signal, with both channels having the exact same contents, the scope will display a 45-degree angled line. If only one channel has contents, and the other is silent, you will see either a horizontal or a vertical line, depending on which channel is active. When one channel outputs a sine, and the other a cosine, with the same frequency and phase, you will see a circle.

Of course with normal music this doesn't look that interesting. But what if you were to specifically compose music to look good on such a device?

That's what Oscilloscope Music is.

Artists

To generate the proper sounds you usually need some kind of software or self-written code. Many people have already experimented with this, with small scripts running on a PC, or directly on a microcontroller.

In 2016 Jerobeam Fenderson released an album based on this concept. He also collaborated with Hansi Raber and they released some software to play and generate this music. You can also find high-quality recordings on his YouTube channel.

Reconstruct - Jerobeam Fenderson
Function - Jerobeam Fenderson

I also found Chris Allen on YouTube, another artist that offers a download of the original files.

Moon Patrol De-Rastered - C. Allen
72 Pantera - C. Allen

Playback

So there are some technical challenges involved in playing these properly on an oscilloscope.

First, it is not enough to play these from YouTube or small MP3 files, as the compression algorithms remove too much of the visual contents of the music. So original WAV file downloads are required.

Second, most audio outputs are AC coupled instead of DC coupled. What does that mean? Audio is normally an AC signal without any DC bias. The spekaer membrane is more or less constantly moving around it's resting position. If there were a DC offset, this would mean the membrane has a constant offset to one side, but would then vibrate there as usual. So the sound waves generated are the same. That's why normally theres a capacitor in line with the audio signal, blocking the DC contents. But when you consider our oscilloscope, if you want to draw a straight line with an offset from the center of the screen, this looks like a DC bias on the signal, which would be filtered out by the capacitor, causing the line to appear in the middle of the screen instead. So the impact of this depends on the image shown. If it is symmetrical around the center of the screen, not much will be distorted, but in other cases the image can be completely unrecognizable.

Third, the audio files have a sample rate of 192kHz. It's usually assumed that average humans don't hear sounds above 20kHz, so according to Shannon, a sample rate of 40kHz is enough to capture all sounds interesting for humans. But to draw many interesting shapes in a short time, higher frequencies are needed. So you need a fast 192kHz DAC and the proper settings in your OS or player software to actually output this without downsampling happening somewhere in the chain.

So how can you achieve these in practice? Interestingly, most Apple devices have a DC coupled output. So when using these, you already get pretty close to the intended output.

We also tried a "proper" Behringer UMC404HD USB audio interface from the Toolbox Sound Studio, but the output was the worst of all devices tried. Even using random cheap AC coupled phone or laptop DACs looked way better.

But the best solution came in the form of the Hifiberry DACs. These are DC coupled, support 192kHz and are cheap and easy to get.

Now the image finally looks like in the YouTube videos. The only problem is, this is supposed to be used and operated at Toolbox events, so it needs to be easy to use and kind of sturdy.

Oscilloscope Settings

To get the proper picture out of your oscilloscope you need to set it up correctly. Every oscilloscope has its own kind of front panel buttons, but in general they all have more or less the same kind of settings.

You need to set the voltage range of both inputs you're using, usually CH1 and CH2, to the same value, something like 0.5V or 0.2V. This changes the size of the image on the screen, just like changing the volume on the Raspberry Pi does, too. My script has an option on top for the volume percentage. You need to adjust these values so the image properly fills your screen, without drawing outside of its borders. The output of the Pi should be put as high as possible, to reduce the influence of electrical noise. Then set the proper range of the oscilloscope, which usually has pretty big steps, so do the final fine-adjustment with the percentage in the script.

Both input channels should be set to DC coupling.

And you need to put the scope in XY mode.

Put the intensity as high as needed, but as low as possible, to maximize the lifetime of the phosphor coating of the tube.

Adjust the focus so you have the sharpest possible image.

Use the horizontal and vertical position controls to center the image on the screen.

In the case of my Tektronix 2215A, the XY mode hides as one of the steps of the right-most rotational input, the timebase or A and B SEC/DIV. In the picture below I marked all settings you may need to adjust.

My Tektronix 2215A set up for Oscilloscope Music

Hardware

We need to consider the environments this device will be used in. Both at the Toolbox anniversary, as well as the IBO fair, it will be displayed as part of a public show, with many people walking by and maybe stopping for a short time. And I will not be there all the time, so it will be operated by other Toolbox members (setting it up, turning it on or off), or even the visitors (switching tracks).

With the part selection outlined above, the solution was kind of obvious. Have a script on the Pi, automatically starting playback of the music files, with a small OLED and some buttons to control playback, and ideally with some kind of UPS for ease of powering the device and properly shutting down to not corrupt the SD card.

Top of Osci Music Player
Insides of Osci Music Player

Here are the parts I used and the prices I paid at the time of building this.

Part Description Cost
Pi Raspberry Pi Zero 2 W 24.49€
DAC HiFiBerry DAC+ Zero 30.99€
Batt PiSugar 2 35.99€
OLED SSD1306 I2C 128x64 4.40€
Btns 2x 12mm momentary switch 1.60€
Conn 2x Cinch panel mount 1.60€
Amp Stereo amplifier module 1.40€
Spkr Stereo speakers 5.00€
Ext Micro-USB panel mount 7.69€
BNC Cinch to BNC adapter 0.50€
Pot Volume knob 0.85€
Cable Cinch stereo cable ~2.00€
Case Lunchbox from supermarket ~5.00€
Sum 85.52€

Of course you also need some bits of copper wire for the internal connections inside the device. I also used a bunch of male and female 2.54mm pin headers to be able to disconnect stuff. Also M2.5 spacers to properly mount the PCBs around the Pi Zero, M2 spacers for the OLED, as well as a couple of cable-ties.

Audio output of Osci Music Player
Power input of Osci Music Player

For this project I decided to use a cheap plastic lunchbox as a case. This worked relatively well. Just take care with the lid, it was quite hard in my case, causing it to crack when I tried to drill a hole that was too big. But some superglue saved the day.

Software

I've been using the Raspberry Pi OS (Legacy, 32bit) Lite image. Install it as usual, set up a user account, wireless network connection and SSH login.

Prepare the environment by installing all required dependencies:

sudo sh -c 'echo "dtoverlay=hifiberry-dac" >> /boot/config.txt'
sudo apt-get update
sudo apt-get upgrade
sudo apt-get install python3 python3-pip python3-pil libjpeg-dev zlib1g-dev libfreetype6-dev liblcms2-dev libopenjp2-7 libtiff5 ffmpeg
curl "http://cdn.pisugar.com/release/pisugar-power-manager.sh" | sudo bash
pip install pisugar luma.oled psutil
sudo usermod -a -G spi,gpio,i2c $USER

Reboot after the last command so the new settings take effect and the power manager can do its thing. If in doubt, also take a look at the PiSugar 2 manual.

Next put the script that controls playback on the device. Here is ~/osci-pi.py.

#!/usr/bin/env python3

# IP address code taken from:
# https://github.com/rm-hull/luma.examples/blob/master/examples/sys_info_extended.py
#
# ----------------------------------------------------------------------------
# Copyright (c) 2024 Thomas Buck (thomas@xythobuz.de)
# Copyright (c) 2024 Philipp Schönberger (mail@phschoen.de)
#
# This program is free software: you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
# GNU General Public License for more details.
#
# See .
# ----------------------------------------------------------------------------

import sys
import os
import random
import subprocess
import signal
import glob
import socket
from collections import OrderedDict
import time

import pisugar
from luma.core.interface.serial import i2c
from luma.core.render import canvas
from luma.oled.device import ssd1306
from luma.core.error import DeviceNotFoundError
import psutil
import RPi.GPIO as GPIO
from PIL import ImageFont

basevol = "70"
debouncems = 100

#fontfile = "/usr/share/fonts/truetype/dejavu/DejaVuSansMono.ttf"
fontfile = "/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf"
fontsize = 11

LCD_REFRESH = 5.0

BTN_ARTIST = 16
BTN_NEXT = 26

currentplaying = None
lcd = None
font = None
bat = None
songlist = None
currentfile = None
lasttime = None
currvol = basevol

vol_list = [
    ("Harley_Matthews", "100"),
    ("xythobuz", "50"),
]

basedir = sys.argv[1]
if basedir.endswith("/"):
    basedir = basedir.removesuffix("/")

def get_artist(fn):
    global currvol
    parts = fn.replace(basedir + "/", "").split(os.sep)
    artist = parts[0].replace("_", " ")
    currvol = basevol
    for i in vol_list:
        if i[0] in fn:
            currvol = i[1]
            break
    return artist

originalsongs = []
originalartists = []
for fn in glob.iglob(os.path.join(basedir, '**', '*.wav'), recursive=True):
    originalsongs.append(fn)

    artist = get_artist(fn)
    if artist not in originalartists:
        originalartists.append(artist)

artists = originalartists.copy()
random.shuffle(artists)
currentartist = artists[0]

def find_single_ipv4_address(addrs):
    for addr in addrs:
        if addr.family == socket.AddressFamily.AF_INET:  # IPv4
            return addr.address

def get_ipv4_address(interface_name=None):
    if_addrs = psutil.net_if_addrs()
    if isinstance(interface_name, str) and interface_name in if_addrs:
        addrs = if_addrs.get(interface_name)
        address = find_single_ipv4_address(addrs)
        return address if isinstance(address, str) else ""
    else:
        if_stats = psutil.net_if_stats()
        if_stats_filtered = {key: if_stats[key] for key, stat in if_stats.items() if "loopback" not in stat.flags}
        if_names_sorted = [stat[0] for stat in sorted(if_stats_filtered.items(), key=lambda x: (x[1].isup, x[1].duplex), reverse=True)]
        if_addrs_sorted = OrderedDict((key, if_addrs[key]) for key in if_names_sorted if key in if_addrs)
        for _, addrs in if_addrs_sorted.items():
            address = find_single_ipv4_address(addrs)
            if isinstance(address, str):
                return address
        return ""

def status(filename):
    try:
        with canvas(lcd) as draw:
            f = filename.replace(".wav", "")
            f = f.replace(basedir + "/", "")
            f = f.replace("/", "\n")
            f = f.replace("_", " ")

            f += "\n\n"
            f += "Bat: {:.0f}% {:.2f}V {:.2f}A".format(bat.get_battery_level(), bat.get_battery_voltage(), bat.get_battery_current())

            ip = get_ipv4_address()
            if len(ip) > 0:
                f += "\n"
                f += "IP: %s" % (ip)

            with open("/proc/asound/card0/pcm0p/sub0/hw_params", "r") as rf:
                for line in rf:
                    if line.startswith("rate:"):
                        rate = int(line.split(" ")[1])

                        f += "\n"
                        f += "Rate: {:.0f}kHz".format(rate / 1000)

            draw.multiline_text((0, 0), f, font=font, fill="white", spacing=-1)
    except Exception as e:
        pass

def stop():
    global currentplaying

    if running():
        try:
            print("Stopping running player")
            os.kill(currentplaying.pid, signal.SIGINT)
            if not currentplaying.poll():
                currentplaying = None
            else:
                print("Error stopping player")
        except ProcessLookupError as e:
            currentplaying = None
    else:
        currentplaying = None

def play(filename):
    global currentplaying
    global lcd
    global basedir
    global bat
    global currentfile

    stop()

    print('Now playing "' + filename + '"')
    currentfile = filename
    status(currentfile)

    print("volume %s " %  currvol)
    currentplaying = subprocess.Popen(["ffplay", "-hide_banner", "-nostats", "-nodisp", "-autoexit", "-volume", currvol, filename])

def running():
    global currentplaying

    if currentplaying != None:
        if currentplaying.poll() == None:
            return True
    return False

def playlist():
    global songlist
    global lasttime

    if not running():
        while True:
            if (songlist == None) or (len(songlist) <= 0):
                switch_artist()
                songlist = originalsongs.copy()
                random.shuffle(songlist)

            song = songlist.pop()
            artist = get_artist(song)
            if artist == currentartist:
                play(song)
                lasttime = time.time()
                break
    else:
        if (time.time() - lasttime) >= LCD_REFRESH:
            status(currentfile)
            lasttime = time.time()

def switch_artist():
    global artists
    global currentartist

    if len(artists) <= 0:
        artists = originalartists.copy()
        random.shuffle(artists)

    currentartist = artists.pop()

    switch_track()

def switch_track():
    stop()

def button(ch):
    val = not GPIO.input(ch)

    #name = "Unknown"
    #if ch == BTN_ARTIST:
    #    name = "BTN_ARTIST"
    #elif ch == BTN_NEXT:
    #    name = "BTN_NEXT"
    #print(name + " is now " + str(val))

    if val:
        if ch == BTN_ARTIST:
            switch_artist()
        elif ch == BTN_NEXT:
            switch_track()

def main():
    global lcd
    global font
    global bat
    global currentfile

    if len(sys.argv) <= 1:
        print("Usage:")
        print("\t" + sys.argv[0] + " PATH")
        sys.exit(1)

    os.system("killall ffplay")

    GPIO.setmode(GPIO.BCM)
    for b in [ BTN_ARTIST, BTN_NEXT ]:
        GPIO.setup(b, GPIO.IN, pull_up_down=GPIO.PUD_UP)
        GPIO.add_event_detect(b, GPIO.BOTH, callback=button, bouncetime=debouncems)

    try:
        bus = i2c(port=1, address=0x3C)
        lcd = ssd1306(bus)
        font = ImageFont.truetype(fontfile, fontsize)
    except DeviceNotFoundError as E:
        print("No LCD connected")
        lcd = None

    conn, event_conn = pisugar.connect_tcp()
    bat = pisugar.PiSugarServer(conn, event_conn)
    print(bat.get_model() + " " + bat.get_version())
    print(str(bat.get_battery_level()) + "% " + str(bat.get_battery_voltage()) + "V " + str(bat.get_battery_current()) + "A")
    print("Plug=" + str(bat.get_battery_power_plugged()) + " Charge=" + str(bat.get_battery_charging()))

    try:
        while True:
            playlist()
            time.sleep(0.05)
    except KeyboardInterrupt:
        print("Bye")
        GPIO.cleanup()
        sys.exit(0)

if __name__ == "__main__":
    main()

And you'll also need /etc/systemd/system/osci.service to start the script automatically. Adjust the username and path accordingly.

[Unit]
Description=Oscilloscope Music Player
After=multi-user.target

[Service]
Type=idle
User=thomas
ExecStart=/home/thomas/osci-music-player/osci-pi.py /home/thomas/music
Restart=always
RestartSec=5

[Install]
WantedBy=multi-user.target

The ExecStart line has the parameters that are passed to the Python script. This is the path of the directory that contains all the .wav files for the songs. In this top-level folder, I called it ~/music, put another level of directories named after the artist, then place the files in there.

Finally enable and start the new unit.

sudo systemctl daemon-reload
sudo systemctl enable --now osci.service

The device should now start playing the music as soon as the power is turned on. Before switching the device off, use the on-board button of the PiSugar to properly shutdown the OS, and only then move the power switch to the Off position.

You can also check out the code in its Git repository.