added multi sound-channel

This commit is contained in:
oberon 2026-02-16 21:46:32 +01:00
parent 434abad282
commit bb71f893e5
5 changed files with 102 additions and 18 deletions

View File

@ -32,7 +32,8 @@ def create_app():
# pygame initialisieren (einmalig) # pygame initialisieren (einmalig)
pygame.mixer.init(frequency=44100, size=-16, channels=2, buffer=512) pygame.mixer.init(frequency=44100, size=-16, channels=2, buffer=512)
pygame.mixer.music.set_volume(0.8) # Standard-Lautstärke pygame.mixer.set_num_channels(16) # genug parallele Kanäle für BG + Effekte
pygame.mixer.music.set_volume(0.8) # Standard-Lautstärke (für Legacy-Fälle)
app.logger.info("pygame initialisiert") app.logger.info("pygame initialisiert")
# Blueprints registrieren # Blueprints registrieren

View File

@ -12,6 +12,22 @@ from app.bluetooth.manager import MouldKing, advertiser, tracer
from app.utils.helpers import load_default_sounds from app.utils.helpers import load_default_sounds
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
audio_lock = threading.Lock()
# Sound-Cache: path -> pygame.mixer.Sound
loaded_sounds = {}
def _ensure_mixer():
if not pygame.mixer.get_init():
pygame.mixer.init(frequency=44100, size=-16, channels=2, buffer=512)
pygame.mixer.set_num_channels(16)
def _load_sound(file_path):
if file_path in loaded_sounds:
return loaded_sounds[file_path]
snd = pygame.mixer.Sound(file_path)
loaded_sounds[file_path] = snd
return snd
api_bp = Blueprint('api', __name__) api_bp = Blueprint('api', __name__)
@ -198,6 +214,8 @@ def api_play_sound():
try: try:
data = request.get_json() data = request.get_json()
sound_id = data.get('sound_id') sound_id = data.get('sound_id')
channel_req = data.get('channel') # optional: spezifischer Channel
loop_req = data.get('loop', 0) # optional: -1 oder true für Endlosschleife
if not sound_id: if not sound_id:
return jsonify({"success": False, "message": "Kein sound_id angegeben"}), 400 return jsonify({"success": False, "message": "Kein sound_id angegeben"}), 400
@ -220,13 +238,36 @@ def api_play_sound():
logger.error(f"Sound-Datei nicht gefunden: {file_path}") logger.error(f"Sound-Datei nicht gefunden: {file_path}")
return jsonify({"success": False, "message": "Sound-Datei nicht gefunden"}), 404 return jsonify({"success": False, "message": "Sound-Datei nicht gefunden"}), 404
# Abspielen (non-blocking, kein eigener Thread nötig) # Abspielen (serialisiert, aber mehrere Channels erlaubt)
try: with audio_lock:
pygame.mixer.music.load(file_path) _ensure_mixer()
pygame.mixer.music.play()
except Exception as e: # Sound laden (Cache)
logger.exception(f"Fehler beim Abspielen von {file_path}") snd = _load_sound(file_path)
return jsonify({"success": False, "message": str(e)}), 500
# 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']}"})
@ -240,8 +281,26 @@ def api_play_sound():
@api_bp.route('/stop_sound', methods=['POST']) @api_bp.route('/stop_sound', methods=['POST'])
def api_stop_sound(): def api_stop_sound():
try: try:
pygame.mixer.music.stop() data = request.get_json(silent=True) or {}
logger.info("Aktueller Sound gestoppt") channel_req = data.get('channel')
with audio_lock:
_ensure_mixer()
if channel_req not in [None, ""]:
try:
ch_id = int(channel_req)
pygame.mixer.Channel(ch_id).stop()
except Exception as e:
logger.error(f"Stop-Sound-Fehler (channel {channel_req}): {e}")
return jsonify({"success": False, "message": "Ungültiger Channel"}), 400
else:
# Alle Kanäle stoppen
for ch_id in range(pygame.mixer.get_num_channels()):
pygame.mixer.Channel(ch_id).stop()
# Zur Sicherheit auch music stoppen
pygame.mixer.music.stop()
logger.info("Sound(s) gestoppt")
return jsonify({"success": True}) return jsonify({"success": True})
except Exception as e: except Exception as e:
logger.error(f"Stop-Sound-Fehler: {e}") logger.error(f"Stop-Sound-Fehler: {e}")
@ -255,7 +314,17 @@ def api_set_volume():
data = request.get_json() data = request.get_json()
vol = float(data.get('volume', 0.8)) vol = float(data.get('volume', 0.8))
vol = max(0.0, min(1.0, vol)) vol = max(0.0, min(1.0, vol))
pygame.mixer.music.set_volume(vol) channel_req = data.get('channel')
with audio_lock:
_ensure_mixer()
if channel_req not in [None, ""]:
ch_id = int(channel_req)
pygame.mixer.Channel(ch_id).set_volume(vol)
else:
# global: alle Kanäle + music
for ch_id in range(pygame.mixer.get_num_channels()):
pygame.mixer.Channel(ch_id).set_volume(vol)
pygame.mixer.music.set_volume(vol)
logger.info(f"Volume auf {vol} gesetzt") logger.info(f"Volume auf {vol} gesetzt")
return jsonify({"success": True}) return jsonify({"success": True})
except Exception as e: except Exception as e:

View File

@ -10,8 +10,10 @@
{ {
"id": "wind_western1", "id": "wind_western1",
"file": "freesound_community-wind-western-64661.mp3", "file": "freesound_community-wind-western-64661.mp3",
"name": "Wind Western", "name": "Wind",
"description": "einfache Windgeräusche" "description": "einfache Windgeräusche",
"loop": true,
"channel": 0
} }
] ]
} }

View File

@ -60,6 +60,8 @@ function initSoundboard() {
playButtons.forEach(btn => { playButtons.forEach(btn => {
btn.addEventListener('click', async () => { btn.addEventListener('click', async () => {
const soundId = btn.dataset.soundId; const soundId = btn.dataset.soundId;
const channel = btn.dataset.channel || null;
const loopFlag = btn.dataset.loop === '1' || btn.dataset.loop === 'true';
// Alten Play-Button zurücksetzen // Alten Play-Button zurücksetzen
if (currentSoundButton && currentSoundButton !== btn) { if (currentSoundButton && currentSoundButton !== btn) {
@ -77,7 +79,7 @@ function initSoundboard() {
const res = await fetch('/api/play_sound', { const res = await fetch('/api/play_sound', {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ sound_id: soundId }) body: JSON.stringify({ sound_id: soundId, channel, loop: loopFlag })
}); });
const data = await res.json(); const data = await res.json();
@ -111,10 +113,12 @@ function initSoundboard() {
// ── Stop-Button für aktuellen Sound ──────────────────────────────────────── // ── Stop-Button für aktuellen Sound ────────────────────────────────────────
document.querySelectorAll('.stop-sound-btn').forEach(btn => { document.querySelectorAll('.stop-sound-btn').forEach(btn => {
btn.addEventListener('click', async () => { btn.addEventListener('click', async () => {
const channel = btn.dataset.channel || null;
try { try {
const res = await fetch('/api/stop_sound', { const res = await fetch('/api/stop_sound', {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json' } headers: { 'Content-Type': 'application/json' }
,body: JSON.stringify({ channel })
}); });
const data = await res.json(); const data = await res.json();

View File

@ -118,13 +118,17 @@
{% for sound in config.sounds %} {% for sound in config.sounds %}
<div class="d-flex gap-2"> <div class="d-flex gap-2">
<button class="btn btn-outline-primary flex-grow-1 play-sound-btn" <button class="btn btn-outline-primary flex-grow-1 play-sound-btn"
data-sound-id="{{ sound.id }}"> data-sound-id="{{ sound.id }}"
data-channel="{{ sound.channel | default('') }}"
data-loop="{{ 1 if sound.loop else 0 }}">
{{ sound.name }} {{ sound.name }}
{% if sound.description %} {% if sound.description %}
<small class="d-block text-muted">{{ sound.description }}</small> <small class="d-block text-muted">{{ sound.description }}</small>
{% endif %} {% endif %}
</button> </button>
<button class="btn btn-outline-danger stop-sound-btn" title="Aktuellen Sound stoppen"> <button class="btn btn-outline-danger stop-sound-btn"
data-channel="{{ sound.channel | default('') }}"
title="Aktuellen Sound stoppen">
<i class="bi bi-stop-fill"></i> <i class="bi bi-stop-fill"></i>
</button> </button>
</div> </div>
@ -139,13 +143,17 @@
{% for sound in global_sounds %} {% for sound in global_sounds %}
<div class="d-flex gap-2"> <div class="d-flex gap-2">
<button class="btn btn-outline-secondary flex-grow-1 play-sound-btn" <button class="btn btn-outline-secondary flex-grow-1 play-sound-btn"
data-sound-id="{{ sound.id }}"> data-sound-id="{{ sound.id }}"
data-channel="{{ sound.channel | default('') }}"
data-loop="{{ 1 if sound.loop else 0 }}">
{{ sound.name }} {{ sound.name }}
{% if sound.description %} {% if sound.description %}
<small class="d-block text-muted">{{ sound.description }}</small> <small class="d-block text-muted">{{ sound.description }}</small>
{% endif %} {% endif %}
</button> </button>
<button class="btn btn-outline-danger stop-sound-btn" title="Aktuellen Sound stoppen"> <button class="btn btn-outline-danger stop-sound-btn"
data-channel="{{ sound.channel | default('') }}"
title="Aktuellen Sound stoppen">
<i class="bi bi-stop-fill"></i> <i class="bi bi-stop-fill"></i>
</button> </button>
</div> </div>