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