This commit is contained in:
Benjamyn Love 2025-09-20 20:06:01 +10:00
commit 3eb800ac70
19 changed files with 460 additions and 0 deletions

9
.gitignore vendored Normal file
View File

@ -0,0 +1,9 @@
.vscode/
venv/
tmp/
sounds/*.mp3
sounds/*.json
*.pyc
*.m4a
*.opus
staging/

35
audio_setup.bash Normal file
View File

@ -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

35
config.py Normal file
View File

@ -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

16
create_sound.py Normal file
View File

@ -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()

0
example_request.json Normal file
View File

11
init.bash Normal file
View File

@ -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

104
lookup_sounds.py Normal file
View File

@ -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 "<p/> ".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}")

2
requirements.txt Normal file
View File

@ -0,0 +1,2 @@
flask
pygame

3
soundboard Normal file
View File

@ -0,0 +1,3 @@
#!/bin/bash
curl -q http://localhost:5000/$1

90
soundboard.py Normal file
View File

@ -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("/<string:sound>")
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")

0
sounds/.gitkeep Normal file
View File

5
sounds/tina.json_old Normal file
View File

@ -0,0 +1,5 @@
{
"name": "Tina come get some dinner",
"filename": "sounds/tina.mp3",
"route": "tina"
}

BIN
static/imgs/button.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

BIN
static/imgs/button_old.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

10
stderr.txt Normal file
View File

@ -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

2
stdout.txt Normal file
View File

@ -0,0 +1,2 @@
536870916
536870917

55
templates/settings.html Normal file
View File

@ -0,0 +1,55 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Soundboard settings</title>
<style>
body {
background-color: dimgray;
}
</style>
</head>
<body>
<h1>Settings:</h2>
<div>
<ul style="list-style: none;">
<li>Volume: <span id="vol">{{config.current_volume * 100}}</span> <input type="range" min="0" max="100"
class="slider" id="volume_slider" onchange="update_volume_label()"
value="{{config.current_volume * 100}}"></input>
<button onclick="change_volume()">Change</button>
</li>
<li></li>
<li></li>
<li><button onclick="reload_sounds()">Reload Sounds</button></li>
</ul>
</div>
<script>
function change_volume() {
slider = document.getElementById('volume_slider')
vol_num = document.getElementById('vol')
volume = slider.value
fetch("/vol", { method: "POST", body: volume / 100, headers: { "Content-Type": "application/json" } })
.then(x => x.body)
.then(console.log("Changed_volume"))
vol_num.innerHTML = volume
}
function reload_sounds() {
fetch("/reload")
.then(x => x.body)
.then(console.log("Sounds reloaded"))
}
function update_volume_label() {
slider = document.getElementById('volume_slider')
vol_num = document.getElementById('vol')
volume = slider.value
vol_num.innerHTML = volume
}
</script>
</body>
</html>

75
templates/sounds.html Normal file
View File

@ -0,0 +1,75 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=80%, initial-scale=1.0">
<title>Sounds</title>
<style>
body {
background-color: dimgray;
}
img {
transition: transform .7s;
}
img:hover {
transform: rotate(360deg);
}
.container {
display: flex;
flex-wrap: wrap;
flex-direction: row;
justify-content: center;
width: 80%;
/* flex-flow: column wrap; */
}
.item {
display: flex;
width: 100%;
min-height: 4rem;
/* border-color: red;
border-radius: 1px;
border-width: 10px; */
/* border-style: dashed; */
justify-content: space-evenly
}
.card {
display: flex;
flex-direction: column;
flex-grow: 4;
flex-basis: 25%;
min-height: 256px;
max-width: 25%;
align-items: center;
}
.button {
width: 80%;
}
</style>
</head>
<body>
<div class="container" width="80%" height="100%">
{% for item in data %}
<div class="item card">
<h2 style="color: azure;">{{item['name']}}</h2>
<img src="static/imgs/button.png" class="button" onclick="play('{{item.route}}')" alt="Play sound"></img>
</div>
{% endfor %}
</div>
</body>
<script>
function play(sound) {
fetch("/" + sound)
console.log("Playing sound")
}
</script>
</html>

8
test.sh Normal file
View File

@ -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