commit 3eb800ac7021c24cfbdcb0ed919fafc168f4fcdf Author: Benjamyn Love Date: Sat Sep 20 20:06:01 2025 +1000 initial diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..1c6141e --- /dev/null +++ b/.gitignore @@ -0,0 +1,9 @@ +.vscode/ +venv/ +tmp/ +sounds/*.mp3 +sounds/*.json +*.pyc +*.m4a +*.opus +staging/ \ No newline at end of file diff --git a/audio_setup.bash b/audio_setup.bash new file mode 100644 index 0000000..3460f41 --- /dev/null +++ b/audio_setup.bash @@ -0,0 +1,35 @@ +#!/bin/bash + +# Benjamyn Love +# Soundboard setup +# Based on thread https://superuser.com/questions/1675877/how-to-create-a-new-pipewire-virtual-device-that-to-combines-an-real-input-and-o + +# VARIABLES +VIRTUAL_SINK_NAME="soundboard" +VIRTUAL_MIC_NAME="mic-soundboard" +PHYS_MIC_L="alsa_input.usb-R__DE_Microphones_R__DE_NT-USB_Mini_4B14FBA6-00.mono-fallback:capture_MONO" +PHYS_MIC_R="alsa_input.usb-R__DE_Microphones_R__DE_NT-USB_Mini_4B14FBA6-00.mono-fallback:capture_MONO" + +# Create a combined audio sink and soundboard-specific sink +pactl load-module module-null-sink media.class=Audio/Sink sink_name=${VIRTUAL_SINK_NAME} channel_map=stereo + +# Create a virtual microphone +pactl load-module module-null-sink media.class=Audio/Source/Virtual sink_name=${VIRTUAL_MIC_NAME} channel_map=front-left,front-right + +# Create loopback +#pactl load-module module-loopback + +# Link the loopback +# +#input.loopback-3441-13:input_FL +# |<- mic-soundboard:capture_FL +#input.loopback-3441-13:input_FR +# |<- mic-soundboard:capture_FR + +# Link the microphone to the virtual sink +pw-link ${PHYS_MIC_L} ${VIRTUAL_SINK_NAME}:playback_FL +pw-link ${PHYS_MIC_R} ${VIRTUAL_SINK_NAME}:playback_FR + +# Link the combined sink to the virtual microphone +pw-link ${VIRTUAL_SINK_NAME}:monitor_FL ${VIRTUAL_MIC_NAME}:input_FL +pw-link ${VIRTUAL_SINK_NAME}:monitor_FR ${VIRTUAL_MIC_NAME}:input_FR diff --git a/config.py b/config.py new file mode 100644 index 0000000..41bf872 --- /dev/null +++ b/config.py @@ -0,0 +1,35 @@ +import configparser +from pathlib import Path +from os import path + + +class Config: + def __init__(self, application_basedir, devicename, port, host): + self.application_basedir = application_basedir + self.devicename = devicename + self.port = port + self.host = host + self.current_volume = 0.6 + + @staticmethod + def load(filepath): + c = configparser.ConfigParser() + try: + c.read(path.expanduser(filepath)) + application_basedir = path.expanduser( + c.get("General", "application_basedir") + ) + host = c.get("Network", "host") + port = c.get("Network", "port") + devicename = c.get("Audio", "device_name") + + # Create directory if it doesn't exist + # along with the sounds directory + Path(application_basedir).mkdir(parents=True, exist_ok=True) + Path(f"{application_basedir}/sounds").mkdir(parents=True, exist_ok=True) + + config = Config(application_basedir, devicename, port, host) + return config + except Exception as e: + print(e) + return None diff --git a/create_sound.py b/create_sound.py new file mode 100644 index 0000000..f6d4760 --- /dev/null +++ b/create_sound.py @@ -0,0 +1,16 @@ +from lookup_sounds import Sound +from sys import argv + +try: + filename = argv[1] + name = argv[2] + route = argv[3] +except IndexError: + print(f"Invalid args, {argv[0]} filename name route") + exit(2) + +# Create new sound object +sound = Sound(filename, name, route) + +print(sound.json()) +sound.save() diff --git a/example_request.json b/example_request.json new file mode 100644 index 0000000..e69de29 diff --git a/init.bash b/init.bash new file mode 100644 index 0000000..a76f06d --- /dev/null +++ b/init.bash @@ -0,0 +1,11 @@ +#!/bin/bash + +# Setup the environment +cd ~/Documents/Projects/soundboard +source venv/bin/activate + +# Init the audio devices +bash audio_setup.bash + +# Start the soundboard application +python soundboard.py diff --git a/lookup_sounds.py b/lookup_sounds.py new file mode 100644 index 0000000..20127c0 --- /dev/null +++ b/lookup_sounds.py @@ -0,0 +1,104 @@ +import glob +import json +import magic +import tempfile +import shutil +from pathlib import Path +from config import Config +from base64 import b64decode + + +class Sounds: + def __init__(self): + self.sounds = list() + self.config = Config.load("~/.config/soundboard.ini") + + def load_sounds(self): + self.sounds = list() + files = glob.glob(f"{self.config.application_basedir}/sounds/*.json") + for sound in files: + with open(sound, "r") as f: + d = json.load(f) + s = Sound(d.get("filename"), d.get("name"), d.get("route")) + print(f"Registering sound: {s.name}") + self.sounds.append(s) + + def lookup(self, route): + for s in self.sounds: + if s.route == route: + return s + + return None + + def add(self, name, route, filename, mp3_file): + if self.lookup(route) is not None: + return (False, "Route already exists") + # Check if name clashes + json_path = f"{self.config.application_basedir}/sounds/{route}.json" + if Path(f"{json_path}").exists(): + return (False, f"JSON file exists: {route}.json") + # Check if the file already exists + mp3_filepath = f"{self.config.application_basedir}{filename}" + if Path(mp3_filepath).exists(): + return (False, f"MP3 file exists on disk: {filename}") + # Check if the file is actually an MP3 + try: + mp3_decoded = b64decode(mp3_file) + except Exception as e: + return (False, e) + + mp3_temp = tempfile.NamedTemporaryFile( + mode="w+b", + buffering=0, + encoding=None, + newline=None, + prefix="mp3tmp_", + dir="tmp", + ) + + mp3_temp.write(mp3_decoded) + + m = magic.open(magic.MAGIC_MIME) + m.load() + if m.file(mp3_temp.name) != "audio/mpeg": + return (False, "File is not of type mp3") + # Save the mp3 file to disk + shutil.copy2(mp3_temp.name, mp3_filepath) + mp3_temp.close() + # Generate the JSON file + # Save the JSON file to disk + with open(json_path, "w") as f: + json.dump({"name": name, "filename": filename, "route": route}, f) + + # Reload all the sounds + self.load_sounds() + return (True, "Successfully added sound") + + def list(self): + return "

".join([sound.name for sound in self.sounds]) + + +class Sound: + def __init__(self, filename, name, route): + self.filename = filename + self.name = name + self.route = route + + def json(self): + data = { + "filename": self.filename, + "name": self.name, + "route": self.route, + } + return data + + def save(self): + config = Config.load("~/.config/soundboard.ini") + path = Path(f"{config.application_basedir}/sounds/{self.route}.json") + if not path.exists(): + with open( + f"{config.application_basedir}/sounds/{self.route}.json", "w" + ) as f: + json.dump(self.json(), f) + else: + print(f"Sound already exists with name {self.route}") diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..3785397 --- /dev/null +++ b/requirements.txt @@ -0,0 +1,2 @@ +flask +pygame diff --git a/soundboard b/soundboard new file mode 100644 index 0000000..9045751 --- /dev/null +++ b/soundboard @@ -0,0 +1,3 @@ +#!/bin/bash + +curl -q http://localhost:5000/$1 \ No newline at end of file diff --git a/soundboard.py b/soundboard.py new file mode 100644 index 0000000..5c94396 --- /dev/null +++ b/soundboard.py @@ -0,0 +1,90 @@ +from pygame import mixer +from pygame import error as pygame_error +from flask import Flask, render_template, request +from config import Config + +import lookup_sounds + +app = Flask(__name__) +config = Config.load("~/.config/soundboard.ini") + +try: + mixer.init(devicename="soundboard Audio/Sink sink") +except pygame_error: + print("Failed to bind to sink") + exit(1) + +sounds = lookup_sounds.Sounds() +sounds.load_sounds() + + +@app.route("/") +def index(): + return render_template("sounds.html", data=sounds.sounds) + + +@app.route("/") +def play(sound): + l_sound = sounds.lookup(sound) + channel = mixer.find_channel() + if channel is None: + return "Out of usable audio channels", 418 + if l_sound is None: + return "False", 404 + m_sound = mixer.Sound(f"{config.application_basedir}{l_sound.filename}") + channel.set_volume(config.current_volume) + + channel.play(m_sound) + return "True" + + +@app.route("/vol", methods=["GET", "POST"]) +def volume(): + match (request.method): + case "GET": + return str(config.current_volume) + case "POST": + try: + float(request.data) + config.current_volume = float(request.data) + return request.data + except Exception as e: + print(e) + return str(e) + case _: + return "Method not implemented", 418 + + +@app.route("/settings") +def settings(): + return render_template("settings.html", config=config) + + +@app.route("/new", methods=["POST"]) +def new_sound(): + match (request.method): + case "POST": + data = request.json + # Validate we have the required data to add a new sound + for field in ["name", "filename", "route", "file"]: + if data.get(field, None) is None: + return f"Bad data missing field {field}", 400 + + ret, message = sounds.add(**data) + print(ret) + if ret is False: + return f"Failed to add sound: {message}" + + return "Successfully Added the sound", 200 + case _: + return "Method not implemented", 418 + + +@app.route("/reload") +def reload(): + sounds.load_sounds() + return "True" + + +if __name__ == "__main__": + app.run(debug=True, host="0.0.0.0") diff --git a/sounds/.gitkeep b/sounds/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/sounds/tina.json_old b/sounds/tina.json_old new file mode 100644 index 0000000..bf5baf6 --- /dev/null +++ b/sounds/tina.json_old @@ -0,0 +1,5 @@ +{ + "name": "Tina come get some dinner", + "filename": "sounds/tina.mp3", + "route": "tina" +} diff --git a/static/imgs/button.png b/static/imgs/button.png new file mode 100644 index 0000000..50fdb50 Binary files /dev/null and b/static/imgs/button.png differ diff --git a/static/imgs/button_old.png b/static/imgs/button_old.png new file mode 100644 index 0000000..a6425ea Binary files /dev/null and b/static/imgs/button_old.png differ diff --git a/stderr.txt b/stderr.txt new file mode 100644 index 0000000..98f5738 --- /dev/null +++ b/stderr.txt @@ -0,0 +1,10 @@ ++ VIRTUAL_SINK_NAME=soundboard ++ VIRTUAL_MIC_NAME=mic-soundboard ++ PHYS_MIC_L=alsa_input.usb-R__DE_Microphones_R__DE_NT-USB_Mini_4B14FBA6-00.mono-fallback:capture_MONO ++ PHYS_MIC_R=alsa_input.usb-R__DE_Microphones_R__DE_NT-USB_Mini_4B14FBA6-00.mono-fallback:capture_MONO ++ pactl load-module module-null-sink media.class=Audio/Sink sink_name=soundboard channel_map=stereo ++ pactl load-module module-null-sink media.class=Audio/Source/Virtual sink_name=mic-soundboard channel_map=front-left,front-right ++ pw-link alsa_input.usb-R__DE_Microphones_R__DE_NT-USB_Mini_4B14FBA6-00.mono-fallback:capture_MONO soundboard:playback_FL ++ pw-link alsa_input.usb-R__DE_Microphones_R__DE_NT-USB_Mini_4B14FBA6-00.mono-fallback:capture_MONO soundboard:playback_FR ++ pw-link soundboard:monitor_FL mic-soundboard:input_FL ++ pw-link soundboard:monitor_FR mic-soundboard:input_FR diff --git a/stdout.txt b/stdout.txt new file mode 100644 index 0000000..9427808 --- /dev/null +++ b/stdout.txt @@ -0,0 +1,2 @@ +536870916 +536870917 diff --git a/templates/settings.html b/templates/settings.html new file mode 100644 index 0000000..c134bff --- /dev/null +++ b/templates/settings.html @@ -0,0 +1,55 @@ + + + + + + + Soundboard settings + + + + +

Settings:

+
+ +
+ + + + \ No newline at end of file diff --git a/templates/sounds.html b/templates/sounds.html new file mode 100644 index 0000000..e4c2c71 --- /dev/null +++ b/templates/sounds.html @@ -0,0 +1,75 @@ + + + + + + + Sounds + + + + +
+ {% for item in data %} +
+

{{item['name']}}

+ Play sound +
+ + {% endfor %} +
+ + + + \ No newline at end of file diff --git a/test.sh b/test.sh new file mode 100644 index 0000000..6e2cff8 --- /dev/null +++ b/test.sh @@ -0,0 +1,8 @@ +curl http://localhost:5000/$1 +curl http://localhost:5000/$1 +curl http://localhost:5000/$1 +curl http://localhost:5000/$1 +curl http://localhost:5000/$1 +curl http://localhost:5000/$1 +curl http://localhost:5000/$1 +curl http://localhost:5000/$1