initial
This commit is contained in:
commit
3eb800ac70
9
.gitignore
vendored
Normal file
9
.gitignore
vendored
Normal file
@ -0,0 +1,9 @@
|
|||||||
|
.vscode/
|
||||||
|
venv/
|
||||||
|
tmp/
|
||||||
|
sounds/*.mp3
|
||||||
|
sounds/*.json
|
||||||
|
*.pyc
|
||||||
|
*.m4a
|
||||||
|
*.opus
|
||||||
|
staging/
|
||||||
35
audio_setup.bash
Normal file
35
audio_setup.bash
Normal 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
35
config.py
Normal 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
16
create_sound.py
Normal 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
0
example_request.json
Normal file
11
init.bash
Normal file
11
init.bash
Normal 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
104
lookup_sounds.py
Normal 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
2
requirements.txt
Normal file
@ -0,0 +1,2 @@
|
|||||||
|
flask
|
||||||
|
pygame
|
||||||
3
soundboard
Normal file
3
soundboard
Normal file
@ -0,0 +1,3 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
|
||||||
|
curl -q http://localhost:5000/$1
|
||||||
90
soundboard.py
Normal file
90
soundboard.py
Normal 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
0
sounds/.gitkeep
Normal file
5
sounds/tina.json_old
Normal file
5
sounds/tina.json_old
Normal file
@ -0,0 +1,5 @@
|
|||||||
|
{
|
||||||
|
"name": "Tina come get some dinner",
|
||||||
|
"filename": "sounds/tina.mp3",
|
||||||
|
"route": "tina"
|
||||||
|
}
|
||||||
BIN
static/imgs/button.png
Normal file
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
BIN
static/imgs/button_old.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 13 KiB |
10
stderr.txt
Normal file
10
stderr.txt
Normal 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
2
stdout.txt
Normal file
@ -0,0 +1,2 @@
|
|||||||
|
536870916
|
||||||
|
536870917
|
||||||
55
templates/settings.html
Normal file
55
templates/settings.html
Normal 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
75
templates/sounds.html
Normal 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>
|
||||||
Loading…
x
Reference in New Issue
Block a user