added themes to soundboard

This commit is contained in:
2026-02-18 22:04:02 +01:00
parent 5f9920f096
commit 21c0b13b8d
17 changed files with 320 additions and 142 deletions
+115 -37
View File
@@ -3,19 +3,24 @@ import os
import time
import threading
import logging
import random
from collections import defaultdict, deque
import pygame
from flask import Blueprint, jsonify, request, current_app
import app.state as state
from app.bluetooth.manager import MouldKing, advertiser, tracer
from app.utils.helpers import load_default_sounds
from app.utils.helpers import load_default_sounds, load_soundboard_configs, load_soundboard_config
logger = logging.getLogger(__name__)
audio_lock = threading.Lock()
# Sound-Cache: path -> pygame.mixer.Sound
loaded_sounds = {}
random_history = defaultdict(deque) # sound_id -> deque[timestamps]
MAX_PER_HOUR = 2
WINDOW_SECONDS = 3600
def _ensure_mixer():
from config import Config
@@ -34,6 +39,55 @@ def _load_sound(file_path):
loaded_sounds[file_path] = snd
return snd
def _play_sound_entry(sound_entry, channel_req=None, loop_req=0):
sounds_dir = current_app.config['SOUNDS_DIR']
base_path = sound_entry.get('base_path') or sounds_dir
file_path = os.path.join(base_path, sound_entry['file'])
if not os.path.exists(file_path):
logger.error(f"Sound-Datei nicht gefunden: {file_path}")
return jsonify({"success": False, "message": "Sound-Datei nicht gefunden"}), 404
# Abspielen (serialisiert, aber mehrere Channels erlaubt)
with audio_lock:
_ensure_mixer()
# Sound laden (Cache)
snd = _load_sound(file_path)
# Ziel-Channel bestimmen
ch = None
if channel_req not in [None, ""]:
try:
ch_id = int(channel_req)
ch = pygame.mixer.Channel(ch_id)
except Exception as e:
logger.error(f"Ungültiger channel-Wert '{channel_req}': {e}")
return jsonify({"success": False, "message": "Ungültiger Channel"}), 400
else:
ch = pygame.mixer.find_channel(True) # zwingend freien nehmen
if ch is None:
return jsonify({"success": False, "message": "Kein freier Audio-Channel verfügbar"}), 503
# Loop-Wert interpretieren
loops = -1 if loop_req in [True, "true", "True", -1, "loop", "1", 1] else 0
try:
ch.play(snd, loops=loops)
except Exception as e:
logger.exception(f"Fehler beim Abspielen von {file_path}")
return jsonify({"success": False, "message": str(e)}), 500
return None # Erfolg
def _prune_history(sound_id, now):
dq = random_history[sound_id]
while dq and now - dq[0] > WINDOW_SECONDS:
dq.popleft()
return dq
api_bp = Blueprint('api', __name__)
@api_bp.route('/connect', methods=['POST'])
@@ -237,42 +291,9 @@ def api_play_sound():
if not sound_entry:
return jsonify({"success": False, "message": f"Sound mit ID '{sound_id}' nicht gefunden"}), 404
sounds_dir = current_app.config['SOUNDS_DIR']
file_path = os.path.join(sounds_dir, sound_entry['file'])
if not os.path.exists(file_path):
logger.error(f"Sound-Datei nicht gefunden: {file_path}")
return jsonify({"success": False, "message": "Sound-Datei nicht gefunden"}), 404
# Abspielen (serialisiert, aber mehrere Channels erlaubt)
with audio_lock:
_ensure_mixer()
# Sound laden (Cache)
snd = _load_sound(file_path)
# Ziel-Channel bestimmen
ch = None
if channel_req not in [None, ""]:
try:
ch_id = int(channel_req)
ch = pygame.mixer.Channel(ch_id)
except Exception as e:
logger.error(f"Ungültiger channel-Wert '{channel_req}': {e}")
return jsonify({"success": False, "message": "Ungültiger Channel"}), 400
else:
ch = pygame.mixer.find_channel(True) # zwingend freien nehmen
if ch is None:
return jsonify({"success": False, "message": "Kein freier Audio-Channel verfügbar"}), 503
# Loop-Wert interpretieren
loops = -1 if loop_req in [True, "true", "True", -1, "loop", "1", 1] else 0
try:
ch.play(snd, loops=loops)
except Exception as e:
logger.exception(f"Fehler beim Abspielen von {file_path}")
return jsonify({"success": False, "message": str(e)}), 500
res = _play_sound_entry(sound_entry, channel_req, loop_req)
if res is not None:
return res
logger.info(f"Spiele Sound: {sound_entry['name']} ({sound_id})")
return jsonify({"success": True, "message": f"Spiele: {sound_entry['name']}"})
@@ -283,6 +304,63 @@ def api_play_sound():
pass
# ---------- Soundboard (themenbezogen, hub-unabhängig) ----------
@api_bp.route('/soundboard/configs', methods=['GET'])
def api_soundboard_configs():
configs = load_soundboard_configs(current_app.config['SOUNDBOARD_CONFIG_DIR'])
return jsonify(configs)
@api_bp.route('/soundboard/load', methods=['POST'])
def api_soundboard_load():
data = request.get_json()
filename = data.get('filename')
if not filename:
return jsonify({"success": False, "message": "filename fehlt"}), 400
try:
sb = load_soundboard_config(filename,
current_app.config['SOUNDBOARD_CONFIG_DIR'],
current_app.config['SOUNDS_DIR'])
state.current_soundboard = sb
logger.info(f"Soundboard geladen: {filename}")
return jsonify({"success": True, "soundboard": sb})
except Exception as e:
logger.exception("Soundboard laden fehlgeschlagen")
return jsonify({"success": False, "message": str(e)}), 500
@api_bp.route('/soundboard/play_random', methods=['POST'])
def api_soundboard_play_random():
if state.current_soundboard is None:
return jsonify({"success": False, "message": "Kein Soundboard geladen"}), 400
rnd_list = state.current_soundboard.get('random_pool') or state.current_soundboard.get('sounds', [])
if not rnd_list:
return jsonify({"success": False, "message": "Keine Random-Sounds definiert"}), 400
now = time.time()
candidates = []
for sound in rnd_list:
sid = sound.get('id') or sound.get('file')
dq = _prune_history(sid, now)
if len(dq) < MAX_PER_HOUR:
candidates.append(sound)
if not candidates:
return jsonify({"success": False, "message": "Limit erreicht (2x pro Stunde)"}), 429
sound_entry = random.choice(candidates)
sid = sound_entry.get('id') or sound_entry.get('file')
res = _play_sound_entry(sound_entry)
if res is not None:
return res # already Response
random_history[sid].append(now)
return jsonify({"success": True, "message": f"Random: {sound_entry.get('name', sid)}"})
@api_bp.route('/stop_sound', methods=['POST'])
def api_stop_sound():
try:
+4 -9
View File
@@ -4,7 +4,7 @@ import logging
import json
from flask import Blueprint, render_template, redirect, url_for, request, jsonify, send_from_directory, current_app
from app.utils.helpers import load_configs, load_default_sounds
from app.utils.helpers import load_configs, load_default_sounds, load_soundboard_configs
import app.state as state
from config import Config
@@ -78,13 +78,8 @@ def control_page():
@main_bp.route('/soundboard')
def soundboard():
if state.current_config is None:
return redirect(url_for('main.index'))
sounds_local = state.current_config.get('sounds', [])
sounds_global = load_default_sounds(current_app.config['CONFIG_DIR'])
# Soundboard ist bewusst unabhängig vom Hub; es werden Sound-Themen geladen
sb_configs = load_soundboard_configs(current_app.config['SOUNDBOARD_CONFIG_DIR'])
return render_template('soundboard.html',
sounds_local=sounds_local,
sounds_global=sounds_global,
config=state.current_config)
soundboard_configs=sb_configs)
pass
+2 -1
View File
@@ -4,6 +4,7 @@ current_config = None
current_hub = None
current_module = None
current_device = None
current_soundboard = None # aktives Soundboard-Thema
# Optional: Funktionen zum Setzen/Resetten (für Klarheit)
def reset_state():
@@ -11,4 +12,4 @@ def reset_state():
current_config = None
current_hub = None
current_module = None
current_device = None
current_device = None
+57
View File
@@ -87,3 +87,60 @@ def load_default_sounds(config_dir=None):
except Exception as e:
logger.error(f"Fehler beim Laden default_sounds.json: {e}")
return []
# ---------- Soundboard-Themen ----------
def load_soundboard_configs(config_dir=None):
"""
Lädt alle Soundboard-Themen aus einem separaten Ordner (z. B. soundboards/).
Unterstützt Unterordner. Jede *.json gilt als Thema (z. B. soundboards/station/theme.json).
Erwartet Schema mit keys wie backgrounds / random_pool / sounds.
"""
config_dir = config_dir or _resolve_config_dir(None)
configs = []
if not os.path.exists(config_dir):
logger.warning(f"Soundboard-Verzeichnis nicht gefunden: {config_dir}")
return configs
for root, _, files in os.walk(config_dir):
for filename in files:
if not filename.lower().endswith('.json'):
continue
rel_path = os.path.relpath(os.path.join(root, filename), config_dir)
try:
with open(os.path.join(config_dir, rel_path), 'r', encoding='utf-8') as f:
data = json.load(f)
data['filename'] = rel_path # relativer Pfad ab soundboards/
data.setdefault('name', filename.replace('.json', '').replace('_', ' ').title())
configs.append(data)
except Exception as e:
logger.error(f"Fehler beim Laden Soundboard {rel_path}: {e}")
return sorted(configs, key=lambda x: x.get('name', ''))
def load_soundboard_config(filename, config_dir, sounds_dir=None):
"""
Lädt ein Soundboard-Theme. Falls die Theme-Datei in Unterordnern liegt,
wird ein basis Sound-Pfad auf denselben relativen Unterordner unterhalb von sounds/ gesetzt.
Alle Einträge (backgrounds/sounds/random_pool) erhalten ein 'base_path'.
"""
sounds_dir = sounds_dir or 'sounds'
path = os.path.normpath(os.path.join(config_dir, filename))
if not path.startswith(os.path.abspath(config_dir)):
raise ValueError("Ungültiger Pfad")
rel_dir = os.path.dirname(filename) # z.B. "station" oder "urban/night"
base_sound_dir = os.path.normpath(os.path.join(sounds_dir, rel_dir)) if rel_dir else sounds_dir
with open(path, 'r', encoding='utf-8') as f:
data = json.load(f)
data['filename'] = filename # relative Angabe beibehalten
data.setdefault('name', filename.replace('.json', '').replace('_', ' ').title())
data['base_sound_dir'] = base_sound_dir
for key in ('backgrounds', 'sounds', 'random_pool'):
if key in data and isinstance(data[key], list):
for item in data[key]:
item.setdefault('base_path', base_sound_dir)
return data