commit 363f0f864c7d907908c8c9f3c2a993cbf7c00732 Author: Thomas Buck Date: Fri Feb 9 21:08:37 2024 +0100 initial commit diff --git a/osci-pi.py b/osci-pi.py new file mode 100755 index 0000000..0bed2d1 --- /dev/null +++ b/osci-pi.py @@ -0,0 +1,273 @@ +#!/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 + +basevol = "70" + +LCD_REFRESH = 5.0 + +BTN_ARTIST = 16 +BTN_NEXT = 26 + +currentplaying = None +lcd = 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" + f += "Batt: {:.0f}% {:.2f}V {:.2f}A".format(bat.get_battery_level(), bat.get_battery_voltage(), bat.get_battery_current()) + + f += "\n" + f += "IP: %s" % (get_ipv4_address()) + + draw.multiline_text((0, 0), f, fill="white") + 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): + 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 bat + global currentfile + + if len(sys.argv) <= 1: + print("Usage:") + print("\t" + sys.argv[0] + " PATH") + sys.exit(1) + + 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=100) + + try: + bus = i2c(port=1, address=0x3C) + lcd = ssd1306(bus) + 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() diff --git a/osci.service b/osci.service new file mode 100644 index 0000000..6f59ba1 --- /dev/null +++ b/osci.service @@ -0,0 +1,13 @@ +[Unit] +Description=Oscilloscope Music Player +After=multi-user.target + +[Service] +Type=idle +User=thomas +ExecStart=/home/thomas/osci-pi.py /home/thomas/music +Restart=always +RestartSec=5 + +[Install] +WantedBy=multi-user.target