added themes to soundboard

This commit is contained in:
oberon 2026-02-18 22:04:02 +01:00
parent 5f9920f096
commit 21c0b13b8d
17 changed files with 320 additions and 142 deletions

View File

@ -3,19 +3,24 @@ import os
import time import time
import threading import threading
import logging import logging
import random
from collections import defaultdict, deque
import pygame import pygame
from flask import Blueprint, jsonify, request, current_app from flask import Blueprint, jsonify, request, current_app
import app.state as state import app.state as state
from app.bluetooth.manager import MouldKing, advertiser, tracer 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__) logger = logging.getLogger(__name__)
audio_lock = threading.Lock() audio_lock = threading.Lock()
# Sound-Cache: path -> pygame.mixer.Sound # Sound-Cache: path -> pygame.mixer.Sound
loaded_sounds = {} loaded_sounds = {}
random_history = defaultdict(deque) # sound_id -> deque[timestamps]
MAX_PER_HOUR = 2
WINDOW_SECONDS = 3600
def _ensure_mixer(): def _ensure_mixer():
from config import Config from config import Config
@ -34,6 +39,55 @@ def _load_sound(file_path):
loaded_sounds[file_path] = snd loaded_sounds[file_path] = snd
return 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 = Blueprint('api', __name__)
@api_bp.route('/connect', methods=['POST']) @api_bp.route('/connect', methods=['POST'])
@ -237,42 +291,9 @@ def api_play_sound():
if not sound_entry: if not sound_entry:
return jsonify({"success": False, "message": f"Sound mit ID '{sound_id}' nicht gefunden"}), 404 return jsonify({"success": False, "message": f"Sound mit ID '{sound_id}' nicht gefunden"}), 404
sounds_dir = current_app.config['SOUNDS_DIR'] res = _play_sound_entry(sound_entry, channel_req, loop_req)
file_path = os.path.join(sounds_dir, sound_entry['file']) if res is not None:
if not os.path.exists(file_path): return res
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
logger.info(f"Spiele Sound: {sound_entry['name']} ({sound_id})") logger.info(f"Spiele Sound: {sound_entry['name']} ({sound_id})")
return jsonify({"success": True, "message": f"Spiele: {sound_entry['name']}"}) return jsonify({"success": True, "message": f"Spiele: {sound_entry['name']}"})
@ -283,6 +304,63 @@ def api_play_sound():
pass 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']) @api_bp.route('/stop_sound', methods=['POST'])
def api_stop_sound(): def api_stop_sound():
try: try:

View File

@ -4,7 +4,7 @@ import logging
import json import json
from flask import Blueprint, render_template, redirect, url_for, request, jsonify, send_from_directory, current_app 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 import app.state as state
from config import Config from config import Config
@ -78,13 +78,8 @@ def control_page():
@main_bp.route('/soundboard') @main_bp.route('/soundboard')
def soundboard(): def soundboard():
if state.current_config is None: # Soundboard ist bewusst unabhängig vom Hub; es werden Sound-Themen geladen
return redirect(url_for('main.index')) sb_configs = load_soundboard_configs(current_app.config['SOUNDBOARD_CONFIG_DIR'])
sounds_local = state.current_config.get('sounds', [])
sounds_global = load_default_sounds(current_app.config['CONFIG_DIR'])
return render_template('soundboard.html', return render_template('soundboard.html',
sounds_local=sounds_local, soundboard_configs=sb_configs)
sounds_global=sounds_global,
config=state.current_config)
pass pass

View File

@ -4,6 +4,7 @@ current_config = None
current_hub = None current_hub = None
current_module = None current_module = None
current_device = None current_device = None
current_soundboard = None # aktives Soundboard-Thema
# Optional: Funktionen zum Setzen/Resetten (für Klarheit) # Optional: Funktionen zum Setzen/Resetten (für Klarheit)
def reset_state(): def reset_state():

View File

@ -87,3 +87,60 @@ def load_default_sounds(config_dir=None):
except Exception as e: except Exception as e:
logger.error(f"Fehler beim Laden default_sounds.json: {e}") logger.error(f"Fehler beim Laden default_sounds.json: {e}")
return [] 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

View File

@ -12,6 +12,7 @@ class Config:
CONFIG_DIR = os.path.join(BASE_DIR, 'configs') CONFIG_DIR = os.path.join(BASE_DIR, 'configs')
LOG_DIR = os.path.join(BASE_DIR, 'logs') LOG_DIR = os.path.join(BASE_DIR, 'logs')
SOUNDS_DIR = os.path.join(BASE_DIR, 'sounds') SOUNDS_DIR = os.path.join(BASE_DIR, 'sounds')
SOUNDBOARD_CONFIG_DIR = os.path.join(BASE_DIR, 'soundboards')
# Audio-Parameter (für pygame.mixer) # Audio-Parameter (für pygame.mixer)
AUDIO_FREQ = 44100 AUDIO_FREQ = 44100

Binary file not shown.

Before

Width:  |  Height:  |  Size: 31 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 50 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 93 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 54 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 80 KiB

View File

@ -0,0 +1,13 @@
{
"name": "Wilder Westen",
"backgrounds": [
{"id": "wind", "file": "freesound_community-wind-western-64661.mp3", "loop": true, "channel": 0}
],
"sounds": [
{"id": "Pferdegalopp", "file": "dragon-studio-horse-galloping-sfx-339732.mp3", "channel": 2}
],
"random_pool": [
{"id": "Gunshot1", "file": "magiaz-revolver_shots-407325.mp3"},
{"id": "Horse2", "file": "dragon-studio-horse-whinny-sound-effect-339727.mp3"}
]
}

Binary file not shown.

View File

@ -1,117 +1,150 @@
{% extends "base.html" %} {% extends "base.html" %}
{% block title %}Soundboard {{ config.name }}{% endblock %} {% block title %}Soundboard Themen{% endblock %}
{% block content %} {% block content %}
<div class="container my-5"> <div class="container my-5">
<h1>Soundboard {{ config.name }}</h1> <h1>Soundboard</h1>
<p class="lead mb-4">Wähle einen Sound aus wird direkt über den Raspberry Pi ausgegeben.</p> <p class="lead mb-4">Themenbezogene Soundsets, unabhängig vom Hub.</p>
{% set has_local = sounds_local and sounds_local|length > 0 %} <div class="row mb-4">
{% set has_global = sounds_global and sounds_global|length > 0 %} <div class="col-md-6">
<label class="form-label fw-bold">Thema wählen</label>
{% if has_local %} <select id="soundboard-select" class="form-select">
<h4 class="mt-3 mb-3">Lok-spezifische Sounds</h4> {% for sb in soundboard_configs %}
<div class="row g-3"> <option value="{{ sb.filename }}">{{ sb.name }}</option>
{% for sound in sounds_local %} {% endfor %}
<div class="col-md-4 col-lg-3"> </select>
<div class="card h-100 shadow-sm">
<div class="card-body text-center">
<h5 class="card-title">{{ sound.name }}</h5>
{% if sound.description %}
<p class="card-text text-muted small">{{ sound.description }}</p>
{% endif %}
<button class="btn btn-primary mt-3 play-sound-btn"
data-sound-id="{{ sound.id }}"
data-channel="{{ sound.channel | default('') }}"
data-loop="{{ 1 if sound.loop else 0 }}">
<i class="bi bi-play-fill me-2"></i> Abspielen
</button>
</div>
</div>
</div>
{% endfor %}
</div> </div>
{% endif %} <div class="col-md-6 d-flex align-items-end">
<div class="btn-group">
{% if has_global %} <button id="btn-load-sb" class="btn btn-primary">Laden</button>
<h4 class="mt-5 mb-3">Standard-Sounds</h4> <button id="btn-play-random" class="btn btn-outline-secondary" disabled>Zufällig (max 2/h pro Datei)</button>
<div class="row g-3"> </div>
{% for sound in sounds_global %}
<div class="col-md-4 col-lg-3">
<div class="card h-100 shadow-sm">
<div class="card-body text-center">
<h5 class="card-title">{{ sound.name }}</h5>
{% if sound.description %}
<p class="card-text text-muted small">{{ sound.description }}</p>
{% endif %}
<button class="btn btn-outline-secondary mt-3 play-sound-btn"
data-sound-id="{{ sound.id }}"
data-channel="{{ sound.channel | default('') }}"
data-loop="{{ 1 if sound.loop else 0 }}">
<i class="bi bi-play-fill me-2"></i> Abspielen
</button>
</div>
</div>
</div>
{% endfor %}
</div> </div>
{% endif %} </div>
{% if not has_local and not has_global %} <div id="sb-content" style="display:none">
<div class="alert alert-info"> <div id="sb-backgrounds" class="mb-4"></div>
In dieser Konfiguration sind keine Sounds definiert und keine Default-Sounds vorhanden. <div id="sb-sounds" class="mb-4"></div>
</div> <div id="sb-random" class="mb-4"></div>
{% endif %} </div>
<div class="mt-5 text-center"> <div class="mt-4">
<a href="{{ url_for('main.control_page') }}" class="btn btn-secondary">Zurück zur Steuerung</a> <a href="{{ url_for('main.index') }}" class="btn btn-secondary">Zurück</a>
</div> </div>
</div> </div>
{% endblock %} {% endblock %}
{% block scripts %} {% block scripts %}
<script> <script>
document.querySelectorAll('.play-sound-btn').forEach(btn => { const selectEl = document.getElementById('soundboard-select');
btn.addEventListener('click', async () => { const loadBtn = document.getElementById('btn-load-sb');
const soundId = btn.dataset.soundId; const randBtn = document.getElementById('btn-play-random');
const channel = btn.dataset.channel || null; const content = document.getElementById('sb-content');
const loopFlag = btn.dataset.loop === '1' || btn.dataset.loop === 'true';
btn.disabled = true;
btn.innerHTML = '<span class="spinner-border spinner-border-sm me-2"></span> Spielt...';
try { let currentSB = null;
const res = await fetch('/api/play_sound', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ sound_id: soundId, channel, loop: loopFlag })
});
const data = await res.json(); async function loadSoundboard(filename) {
loadBtn.disabled = true;
try {
const res = await fetch('/api/soundboard/load', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ filename })
});
const data = await res.json();
if (!data.success) throw new Error(data.message || 'Load failed');
currentSB = data.soundboard;
renderSoundboard(currentSB);
content.style.display = 'block';
randBtn.disabled = false;
} catch (e) {
alert('Konnte Soundboard nicht laden: ' + e.message);
} finally {
loadBtn.disabled = false;
}
}
if (data.success) { function renderSoundboard(sb) {
btn.classList.remove('btn-primary'); renderBackgrounds(sb.backgrounds || []);
btn.classList.add('btn-success'); renderSounds(sb.sounds || []);
btn.innerHTML = '<i class="bi bi-check-lg me-2"></i> Gespielt'; renderRandom(sb.random_pool || []);
setTimeout(() => { }
btn.classList.remove('btn-success');
btn.classList.add('btn-primary'); function renderBackgrounds(list) {
btn.innerHTML = '<i class="bi bi-play-fill me-2"></i> Abspielen'; const container = document.getElementById('sb-backgrounds');
btn.disabled = false; if (!list.length) { container.innerHTML=''; return; }
}, 2000); container.innerHTML = '<h4>Hintergrund (Loop)</h4><div class="d-flex flex-wrap gap-2"></div>';
} else { const wrap = container.querySelector('div');
alert('Fehler: ' + (data.message || 'Unbekannt')); list.forEach(s => {
btn.disabled = false; const btn = document.createElement('button');
btn.innerHTML = '<i class="bi bi-play-fill me-2"></i> Abspielen'; btn.className = 'btn btn-outline-secondary';
} btn.textContent = s.name || s.id || s.file;
} catch (err) { btn.onclick = () => playSound(s);
console.error('Sound-Play-Fehler:', err); const stop = document.createElement('button');
alert('Netzwerkfehler beim Abspielen'); stop.className = 'btn btn-outline-danger';
btn.disabled = false; stop.textContent = 'Stop';
btn.innerHTML = '<i class="bi bi-play-fill me-2"></i> Abspielen'; stop.onclick = () => stopSound(s.channel);
} wrap.append(btn, stop);
}); });
}); }
function renderSounds(list) {
const container = document.getElementById('sb-sounds');
if (!list.length) { container.innerHTML=''; return; }
container.innerHTML = '<h4>Einzelsounds</h4><div class="d-flex flex-wrap gap-2"></div>';
const wrap = container.querySelector('div');
list.forEach(s => {
const btn = document.createElement('button');
btn.className = 'btn btn-primary';
btn.textContent = s.name || s.id || s.file;
btn.onclick = () => playSound(s);
wrap.append(btn);
});
}
function renderRandom(list) {
const container = document.getElementById('sb-random');
if (!list.length) { container.innerHTML=''; randBtn.disabled=true; return; }
container.innerHTML = '<p class="text-muted">Zufallspool: ' + list.length + ' Dateien, max 2x pro Stunde je Datei.</p>';
}
async function playSound(sound) {
const payload = {
sound_id: sound.id || sound.file,
channel: sound.channel ?? null,
loop: !!sound.loop
};
const res = await fetch('/api/play_sound', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload)
});
const data = await res.json();
if (!data.success) alert(data.message || 'Fehler beim Abspielen');
}
async function stopSound(channel) {
const res = await fetch('/api/stop_sound', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ channel })
});
const data = await res.json();
if (!data.success) alert(data.message || 'Fehler beim Stoppen');
}
loadBtn.onclick = () => loadSoundboard(selectEl.value);
randBtn.onclick = async () => {
const res = await fetch('/api/soundboard/play_random', { method: 'POST' });
const data = await res.json();
if (!data.success) alert(data.message || 'Random fehlgeschlagen');
};
// Autoload erstes Thema (falls vorhanden)
if (selectEl && selectEl.value) {
loadSoundboard(selectEl.value);
}
</script> </script>
{% endblock %} {% endblock %}