#!/usr/bin/env python3 # Oscilloscope Music Player for Raspberry Pi # https://oscilloscopemusic.com # # Tested with a Pi Zero 2 W: # https://www.raspberrypi.com/products/raspberry-pi-zero-2-w/ # # Connected to a HiFiBerry DAC+ Zero: # https://www.hifiberry.com/shop/boards/hifiberry-dac-zero/ # # Tested with Raspberry Pi OS (Legacy, 32bit) Lite: # https://downloads.raspberrypi.com/raspios_oldstable_lite_armhf/images/raspios_oldstable_lite_armhf-2023-12-06/2023-12-05-raspios-bullseye-armhf-lite.img.xz # # Add "dtoverlay=hifiberry-dac" to "/boot/config.txt" # # Install ffmpeg for the ffplay dependency # # Powered by PiSugar 2: # https://github.com/PiSugar/pisugar-power-manager-rs # https://github.com/PiSugar/pisugar-server-py # # sudo sh -c 'echo "dtoverlay=hifiberry-dac" >> /boot/config.txt' # sudo apt-get update # 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 # pip install luma.oled # pip install psutil # sudo usermod -a -G spi,gpio,i2c $USER # # 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) # # 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 = 200 #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 basedir = sys.argv[1] if basedir.endswith("/"): basedir = basedir.removesuffix("/") def get_artist(fn): parts = fn.replace(basedir + "/", "").split(os.sep) artist = parts[0].replace("_", " ") return artist #originalsongs = os.listdir(basedir) originalsongs = [] artists = [] for fn in glob.iglob(os.path.join(basedir, '**', '*.wav'), recursive=True): originalsongs.append(fn) artist = get_artist(fn) if artist not in artists: artists.append(artist) 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: raise e 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) currentplaying = subprocess.Popen(["ffplay", "-hide_banner", "-nostats", "-nodisp", "-autoexit", "-volume", basevol, 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 ca = currentartist while currentartist == ca: random.shuffle(artists) currentartist = artists[0] 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()