diff --git a/app/routes/api.py b/app/routes/api.py index fff10e6..5fc99ff 100644 --- a/app/routes/api.py +++ b/app/routes/api.py @@ -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: diff --git a/app/routes/main.py b/app/routes/main.py index 871fb70..a69b6e5 100644 --- a/app/routes/main.py +++ b/app/routes/main.py @@ -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 diff --git a/app/state.py b/app/state.py index 11dc05d..ae556e9 100644 --- a/app/state.py +++ b/app/state.py @@ -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 \ No newline at end of file + current_device = None diff --git a/app/utils/helpers.py b/app/utils/helpers.py index 65d61c9..22262d2 100644 --- a/app/utils/helpers.py +++ b/app/utils/helpers.py @@ -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 diff --git a/config.py b/config.py index 7dcc683..731bdb0 100644 --- a/config.py +++ b/config.py @@ -12,6 +12,7 @@ class Config: CONFIG_DIR = os.path.join(BASE_DIR, 'configs') LOG_DIR = os.path.join(BASE_DIR, 'logs') SOUNDS_DIR = os.path.join(BASE_DIR, 'sounds') + SOUNDBOARD_CONFIG_DIR = os.path.join(BASE_DIR, 'soundboards') # Audio-Parameter (für pygame.mixer) AUDIO_FREQ = 44100 diff --git a/configs/Schottische_Museumsbahn.webp b/configs/Schottische_Museumsbahn.webp deleted file mode 100644 index 80aff74..0000000 Binary files a/configs/Schottische_Museumsbahn.webp and /dev/null differ diff --git a/configs/moc-130550-sierra-railway-no-3-locomotivelesdiylesdiy-125742.webp b/configs/moc-130550-sierra-railway-no-3-locomotivelesdiylesdiy-125742.webp deleted file mode 100644 index 674d738..0000000 Binary files a/configs/moc-130550-sierra-railway-no-3-locomotivelesdiylesdiy-125742.webp and /dev/null differ diff --git a/configs/moc-130550-sierra-railway-no-3-locomotivelesdiylesdiy-286276.webp b/configs/moc-130550-sierra-railway-no-3-locomotivelesdiylesdiy-286276.webp deleted file mode 100644 index 524f5e5..0000000 Binary files a/configs/moc-130550-sierra-railway-no-3-locomotivelesdiylesdiy-286276.webp and /dev/null differ diff --git a/configs/moc-130550-sierra-railway-no-3-locomotivelesdiylesdiy-655080.webp b/configs/moc-130550-sierra-railway-no-3-locomotivelesdiylesdiy-655080.webp deleted file mode 100644 index f0318b7..0000000 Binary files a/configs/moc-130550-sierra-railway-no-3-locomotivelesdiylesdiy-655080.webp and /dev/null differ diff --git a/configs/moc-130550-sierra-railway-no-3-locomotivelesdiylesdiy-706969.webp b/configs/moc-130550-sierra-railway-no-3-locomotivelesdiylesdiy-706969.webp deleted file mode 100644 index 80ce7ac..0000000 Binary files a/configs/moc-130550-sierra-railway-no-3-locomotivelesdiylesdiy-706969.webp and /dev/null differ diff --git a/soundboards/wilder-westen/theme.json b/soundboards/wilder-westen/theme.json new file mode 100644 index 0000000..761699b --- /dev/null +++ b/soundboards/wilder-westen/theme.json @@ -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"} + ] +} diff --git a/sounds/wilder-westen/dragon-studio-horse-galloping-sfx-339726.mp3 b/sounds/wilder-westen/dragon-studio-horse-galloping-sfx-339726.mp3 new file mode 100644 index 0000000..33ca703 Binary files /dev/null and b/sounds/wilder-westen/dragon-studio-horse-galloping-sfx-339726.mp3 differ diff --git a/sounds/wilder-westen/dragon-studio-horse-galloping-sfx-339732.mp3 b/sounds/wilder-westen/dragon-studio-horse-galloping-sfx-339732.mp3 new file mode 100644 index 0000000..d13afc2 Binary files /dev/null and b/sounds/wilder-westen/dragon-studio-horse-galloping-sfx-339732.mp3 differ diff --git a/sounds/wilder-westen/dragon-studio-horse-whinny-sound-effect-339727.mp3 b/sounds/wilder-westen/dragon-studio-horse-whinny-sound-effect-339727.mp3 new file mode 100644 index 0000000..7a5e6c8 Binary files /dev/null and b/sounds/wilder-westen/dragon-studio-horse-whinny-sound-effect-339727.mp3 differ diff --git a/sounds/freesound_community-wind-western-64661.mp3 b/sounds/wilder-westen/freesound_community-wind-western-64661.mp3 similarity index 100% rename from sounds/freesound_community-wind-western-64661.mp3 rename to sounds/wilder-westen/freesound_community-wind-western-64661.mp3 diff --git a/sounds/wilder-westen/magiaz-revolver_shots-407325.mp3 b/sounds/wilder-westen/magiaz-revolver_shots-407325.mp3 new file mode 100644 index 0000000..038b6e0 Binary files /dev/null and b/sounds/wilder-westen/magiaz-revolver_shots-407325.mp3 differ diff --git a/templates/soundboard.html b/templates/soundboard.html index ab96e94..739d264 100644 --- a/templates/soundboard.html +++ b/templates/soundboard.html @@ -1,117 +1,150 @@ {% extends "base.html" %} -{% block title %}Soundboard – {{ config.name }}{% endblock %} +{% block title %}Soundboard – Themen{% endblock %} {% block content %}
-

Soundboard – {{ config.name }}

-

Wähle einen Sound aus – wird direkt über den Raspberry Pi ausgegeben.

+

Soundboard

+

Themenbezogene Soundsets, unabhängig vom Hub.

- {% set has_local = sounds_local and sounds_local|length > 0 %} - {% set has_global = sounds_global and sounds_global|length > 0 %} - - {% if has_local %} -

Lok-spezifische Sounds

-
- {% for sound in sounds_local %} -
-
-
-
{{ sound.name }}
- {% if sound.description %} -

{{ sound.description }}

- {% endif %} - -
-
-
- {% endfor %} +
+
+ +
- {% endif %} - - {% if has_global %} -

Standard-Sounds

-
- {% for sound in sounds_global %} -
-
-
-
{{ sound.name }}
- {% if sound.description %} -

{{ sound.description }}

- {% endif %} - -
-
-
- {% endfor %} +
+
+ + +
- {% endif %} +
- {% if not has_local and not has_global %} -
- In dieser Konfiguration sind keine Sounds definiert und keine Default-Sounds vorhanden. -
- {% endif %} + - - {% endblock %} {% block scripts %} {% endblock %}