From c56012134c92c3af3d6bd611a03555ba73817326 Mon Sep 17 00:00:00 2001 From: pober Date: Mon, 20 Apr 2026 15:46:39 +0200 Subject: [PATCH 1/9] Fixes that an interface has both a local link and dhcp address - confuses dante. --- src/service/10-link-local-mgmt | 83 +++++++++++++++++++ .../update_and_run_server_and_frontend.sh | 22 +---- 2 files changed, 87 insertions(+), 18 deletions(-) create mode 100644 src/service/10-link-local-mgmt diff --git a/src/service/10-link-local-mgmt b/src/service/10-link-local-mgmt new file mode 100644 index 0000000..3908060 --- /dev/null +++ b/src/service/10-link-local-mgmt @@ -0,0 +1,83 @@ +#!/bin/bash +# NetworkManager dispatcher script: 10-link-local-mgmt +# +# Temporarily suppresses IPv4 link-local when a DHCP address is available, +# using nmcli device modify (active session only, NOT saved to the profile). +# The persistent profile always keeps ipv4.link-local=enabled so that +# direct-connect (no DHCP) plug-ins always activate and trigger events. +# +# Triggers: up, down, dhcp4-change on ethernet interfaces +# Install to: /etc/NetworkManager/dispatcher.d/10-link-local-mgmt +# Permissions: root:root 0755 + +INTERFACE="$1" +ACTION="$2" +CONNECTION_NAME="${CONNECTION_ID:-}" + +# Only handle ethernet interfaces +if [[ ! "$INTERFACE" =~ ^eth ]]; then + exit 0 +fi + +# If CONNECTION_ID env var is not set, look up the active connection for this interface +if [ -z "$CONNECTION_NAME" ]; then + CONNECTION_NAME=$(nmcli -t -f NAME,DEVICE connection show --active 2>/dev/null \ + | grep ":${INTERFACE}$" | cut -d: -f1 | head -n1) + [ -z "$CONNECTION_NAME" ] && exit 0 +fi + +# Update /etc/avahi/hosts to point mDNS hostname at the best available DHCP address +# across all ethernet interfaces (so Avahi doesn't advertise a link-local address). +update_avahi() { + local hostname + hostname=$(hostname) + # Find first non-link-local IPv4 across all ethernet interfaces + local dhcp_ip + dhcp_ip=$(ip -4 addr show 2>/dev/null \ + | grep -A5 ': eth' \ + | grep -oP '(?<=inet\s)\d+(\.\d+){3}' \ + | grep -v '^127\.' \ + | grep -v '^169\.254\.' \ + | head -n1) + + if [ -n "$dhcp_ip" ]; then + mkdir -p /etc/avahi + echo "$dhcp_ip $hostname $hostname.local" > /etc/avahi/hosts + logger -t nm-link-local "Avahi: pinned $hostname -> $dhcp_ip" + else + rm -f /etc/avahi/hosts + logger -t nm-link-local "Avahi: removed hosts pin, using all addresses" + fi + systemctl restart avahi-daemon 2>/dev/null +} + +case "$ACTION" in + up|dhcp4-change) + DHCP_IP=$(ip -4 addr show "$INTERFACE" 2>/dev/null \ + | grep -oP '(?<=inet\s)\d+(\.\d+){3}' \ + | grep -v '^127\.' \ + | grep -v '^169\.254\.' \ + | head -n1) + + if [ -n "$DHCP_IP" ]; then + logger -t nm-link-local "[$INTERFACE] DHCP $DHCP_IP detected — suppressing link-local (session only)" + # Use device modify (not connection modify) so the persistent profile keeps + # ipv4.link-local=enabled. This ensures direct-connect plug-ins always activate. + # Run in background after a delay — nmcli blocks on NM, which is waiting for + # this dispatcher to return, causing a deadlock if called synchronously. + (sleep 2 && nmcli device modify "$INTERFACE" ipv4.link-local disabled 2>/dev/null \ + && logger -t nm-link-local "[$INTERFACE] Link-local suppressed for current session") & + else + logger -t nm-link-local "[$INTERFACE] No DHCP on $INTERFACE — keeping link-local active" + fi + update_avahi + ;; + + down) + # Profile always has ipv4.link-local=enabled so no action needed here. + # The suppression from device modify was session-only and is gone when the + # connection goes down. + logger -t nm-link-local "[$INTERFACE] Down — link-local will be active on next connect" + update_avahi + ;; +esac diff --git a/src/service/update_and_run_server_and_frontend.sh b/src/service/update_and_run_server_and_frontend.sh index a193b5c..7e552ec 100755 --- a/src/service/update_and_run_server_and_frontend.sh +++ b/src/service/update_and_run_server_and_frontend.sh @@ -14,24 +14,10 @@ while IFS=: read -r name type; do fi done < <(nmcli -t -f NAME,TYPE connection show) -# Configure Avahi to prefer DHCP address over static fallback for mDNS -# Get the DHCP-assigned IP (first non-localhost, non-192.168.42.10 IP) -DHCP_IP=$(ip -4 addr show eth0 2>/dev/null | grep -oP '(?<=inet\s)\d+(\.\d+){3}' | grep -v '^127\.' | grep -v '^169\.254\.' | head -n1) -HOSTNAME=$(hostname) - -if [ -n "$DHCP_IP" ]; then - echo "DHCP address detected: $DHCP_IP, configuring Avahi to prefer it for mDNS." - # Add entry to /etc/avahi/hosts to explicitly map hostname to DHCP IP - sudo mkdir -p /etc/avahi - echo "$DHCP_IP $HOSTNAME $HOSTNAME.local" | sudo tee /etc/avahi/hosts > /dev/null - # Restart avahi to apply the hosts file - sudo systemctl restart avahi-daemon -else - echo "No DHCP address detected, mDNS will use link local" - # Remove hosts file to let Avahi advertise all IPs - sudo rm -f /etc/avahi/hosts - sudo systemctl restart avahi-daemon -fi +# Install NetworkManager dispatcher script for link-local / Avahi management +sudo cp /home/caster/bumble-auracast/src/service/10-link-local-mgmt /etc/NetworkManager/dispatcher.d/10-link-local-mgmt +sudo chown root:root /etc/NetworkManager/dispatcher.d/10-link-local-mgmt +sudo chmod 755 /etc/NetworkManager/dispatcher.d/10-link-local-mgmt # Copy system service file for frontend sudo cp /home/caster/bumble-auracast/src/service/auracast-frontend.service /etc/systemd/system/auracast-frontend.service -- 2.52.0 From 67c774204a19e5964ec4e04a4e7cd0bfc4024553 Mon Sep 17 00:00:00 2001 From: pober Date: Fri, 24 Apr 2026 10:57:43 +0200 Subject: [PATCH 2/9] Dante buffers and cpu affinity. --- .../dante_package/dante_data/capability/dante.json | 12 ++++++++---- src/service/auracast-frontend.service | 2 ++ src/service/auracast-script.service | 2 ++ src/service/auracast-server.service | 4 +++- src/service/pipewire-aes67.service | 2 ++ src/service/ptp_aes67.service | 2 ++ 6 files changed, 19 insertions(+), 5 deletions(-) diff --git a/src/dep/dante_package/dante_data/capability/dante.json b/src/dep/dante_package/dante_data/capability/dante.json index 9840222..be0639b 100644 --- a/src/dep/dante_package/dante_data/capability/dante.json +++ b/src/dep/dante_package/dante_data/capability/dante.json @@ -16,7 +16,7 @@ 48000 ], "samplesPerPeriod" : 16, - "periodsPerBuffer" : 300, + "periodsPerBuffer" : 150, "networkLatencyMinMs" : 2, "networkLatencyDefaultMs" : 5, "supportedEncodings" : @@ -24,7 +24,10 @@ "PCM16" ], "defaultEncoding" : "PCM16", - "numDepCores" : 1 + "numDepCores" : + [ + 2 + ] }, "network" : { @@ -50,15 +53,16 @@ "alsaAsrc": { "enableAlsaAsrc": true, + "cpuAffinity": 3, "deviceConfigurations": [ { - "deviceIdentifier": "hw:0,0", + "deviceIdentifier": "hw:3,0,0", "direction": "playback", "bitDepth": 16, "numOpenChannels": 6, "alsaChannelRange": "0-5", "danteChannelRange": "0-5", - "bufferSize": 4800, + "bufferSize": 960, "samplesPerPeriod": 16 } ] diff --git a/src/service/auracast-frontend.service b/src/service/auracast-frontend.service index dca1b9e..fd3b46f 100644 --- a/src/service/auracast-frontend.service +++ b/src/service/auracast-frontend.service @@ -10,6 +10,8 @@ WorkingDirectory=/home/caster/bumble-auracast/src/auracast/server ExecStart=/home/caster/bumble-auracast/src/auracast/server/start_frontend_https.sh Restart=on-failure Environment=LOG_LEVEL=INFO +AllowedCPUs=0 +CPUAffinity=0 [Install] WantedBy=multi-user.target diff --git a/src/service/auracast-script.service b/src/service/auracast-script.service index 3bad0d9..5a3b0f5 100644 --- a/src/service/auracast-script.service +++ b/src/service/auracast-script.service @@ -9,6 +9,8 @@ ExecStart=/home/caster/bumble-auracast/.venv/bin/python src/auracast/multicast_s Restart=on-failure Environment=PYTHONUNBUFFERED=1 Environment=LOG_LEVEL=INFO +AllowedCPUs=0 +CPUAffinity=0 [Install] WantedBy=default.target diff --git a/src/service/auracast-server.service b/src/service/auracast-server.service index 06395bf..b7cbea2 100644 --- a/src/service/auracast-server.service +++ b/src/service/auracast-server.service @@ -10,8 +10,10 @@ Restart=on-failure Environment=PYTHONUNBUFFERED=1 Environment=LOG_LEVEL=INFO CPUSchedulingPolicy=fifo -CPUSchedulingPriority=99 +CPUSchedulingPriority=10 LimitRTPRIO=99 +AllowedCPUs=1 +CPUAffinity=1 [Install] WantedBy=default.target diff --git a/src/service/pipewire-aes67.service b/src/service/pipewire-aes67.service index d3eeb30..05e0598 100644 --- a/src/service/pipewire-aes67.service +++ b/src/service/pipewire-aes67.service @@ -9,6 +9,8 @@ ExecStartPre=/bin/sh -lc 'for i in $(seq 1 60); do ip route show default >/dev/n ExecStart=/usr/bin/pipewire-aes67 -c /home/caster/bumble-auracast/src/service/aes67/pipewire-aes67.conf Restart=always RestartSec=5s +AllowedCPUs=0 +CPUAffinity=0 # Avoid StartLimitHit on quick failures during boot; let RestartSec handle pacing StartLimitIntervalSec=0 diff --git a/src/service/ptp_aes67.service b/src/service/ptp_aes67.service index 99097bf..3fb55d7 100644 --- a/src/service/ptp_aes67.service +++ b/src/service/ptp_aes67.service @@ -6,6 +6,8 @@ After=network.target Type=simple ExecStart=/usr/sbin/ptp4l -i eth0 -f /home/caster/bumble-auracast/src/service/aes67/ptp_aes67_1.conf Restart=on-failure +AllowedCPUs=0 +CPUAffinity=0 StandardOutput=journal StandardError=journal -- 2.52.0 From 2410b01f1588b5c865789b1cc5153b6e454feaa6 Mon Sep 17 00:00:00 2001 From: pober Date: Mon, 27 Apr 2026 10:26:04 +0200 Subject: [PATCH 3/9] Fixes loopback startup and makes it persistent with naming. --- src/dep/dante_package/dante_data/capability/dante.json | 2 +- src/service/update_and_run_server_and_frontend.sh | 9 +++++++++ 2 files changed, 10 insertions(+), 1 deletion(-) diff --git a/src/dep/dante_package/dante_data/capability/dante.json b/src/dep/dante_package/dante_data/capability/dante.json index be0639b..74fbae8 100644 --- a/src/dep/dante_package/dante_data/capability/dante.json +++ b/src/dep/dante_package/dante_data/capability/dante.json @@ -56,7 +56,7 @@ "cpuAffinity": 3, "deviceConfigurations": [ { - "deviceIdentifier": "hw:3,0,0", + "deviceIdentifier": "hw:6,0,0", "direction": "playback", "bitDepth": 16, "numOpenChannels": 6, diff --git a/src/service/update_and_run_server_and_frontend.sh b/src/service/update_and_run_server_and_frontend.sh index 7e552ec..c3eb28b 100755 --- a/src/service/update_and_run_server_and_frontend.sh +++ b/src/service/update_and_run_server_and_frontend.sh @@ -14,6 +14,15 @@ while IFS=: read -r name type; do fi done < <(nmcli -t -f NAME,TYPE connection show) + +# Ensure Loopback is loaded with a fixed name and index +# Needed for dante +# TODO image when we create the next image this should be part of it +echo "options snd-aloop index=6 id=Loopback pcm_substreams=6" | sudo tee /etc/modprobe.d/snd-aloop.conf +echo snd-aloop | sudo tee /etc/modules-load.d/snd-aloop.conf + + + # Install NetworkManager dispatcher script for link-local / Avahi management sudo cp /home/caster/bumble-auracast/src/service/10-link-local-mgmt /etc/NetworkManager/dispatcher.d/10-link-local-mgmt sudo chown root:root /etc/NetworkManager/dispatcher.d/10-link-local-mgmt -- 2.52.0 From 14827288e7a8c3248df7e679bda01845f112f499 Mon Sep 17 00:00:00 2001 From: pober Date: Mon, 27 Apr 2026 10:27:54 +0200 Subject: [PATCH 4/9] relaxes asrc to not flip flop bang bang; use pyalsaaudio for dante. --- src/auracast/multicast.py | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/src/auracast/multicast.py b/src/auracast/multicast.py index c4ae081..7ddc852 100644 --- a/src/auracast/multicast.py +++ b/src/auracast/multicast.py @@ -206,7 +206,7 @@ class PyAlsaAudioInput(audio_io.ThreadedAudioInput): length, data = self._pcm.read_sw(frame_size + self._bang_bang) avail = self._pcm.avail() SETPOINT = 120 - TOLERANCE = 40 + TOLERANCE = 80 if avail < SETPOINT - TOLERANCE: self._bang_bang = -1 elif avail > SETPOINT + TOLERANCE: @@ -811,7 +811,11 @@ class Streamer(): if input_format == 'auto': raise ValueError('input format details required for alsa input') pcm = audio_io.PcmFormat.from_str(input_format) - audio_input = AlsaArecordAudioInput(audio_source[5:], pcm) + device_name = audio_source[5:] + if device_name.startswith('dante_'): + audio_input = PyAlsaAudioInput(device_name, pcm) + else: + audio_input = AlsaArecordAudioInput(device_name, pcm) else: audio_input = await audio_io.create_audio_input(audio_source, input_format) # Store early so stop_streaming can close even if open() fails -- 2.52.0 From c659d632b07710e6bf5b2cc27885d0b89f43ef51 Mon Sep 17 00:00:00 2001 From: pober Date: Mon, 27 Apr 2026 15:35:20 +0200 Subject: [PATCH 5/9] Adds DEP service; gitignore for license; audiopipeline as its own thread with higher prio than http requests. --- .gitignore | 5 + src/auracast/server/multicast_server.py | 283 +++++++++++++++--- .../dante_data/capability/dante.json | 18 +- src/service/auracast-server.service | 3 +- src/service/dep.service | 13 + .../update_and_run_server_and_frontend.sh | 12 +- 6 files changed, 280 insertions(+), 54 deletions(-) create mode 100644 src/service/dep.service diff --git a/.gitignore b/.gitignore index f23cc0f..8e1b3d7 100644 --- a/.gitignore +++ b/.gitignore @@ -53,3 +53,8 @@ src/scripts/temperature_log* src/auracast/server/recordings/ src/auracast/server/led_settings.json + + +# Dante license files +*.lic +src/dep/dante_package/dante_data/activation/ diff --git a/src/auracast/server/multicast_server.py b/src/auracast/server/multicast_server.py index 3aba39f..d230aaa 100644 --- a/src/auracast/server/multicast_server.py +++ b/src/auracast/server/multicast_server.py @@ -10,6 +10,7 @@ from datetime import datetime import asyncio import random import subprocess +import threading from dotenv import load_dotenv from fastapi import FastAPI, HTTPException @@ -208,6 +209,28 @@ multicaster1: multicast_control.Multicaster | None = None multicaster2: multicast_control.Multicaster | None = None _stream_lock = asyncio.Lock() # serialize initialize/stop_audio on API side +# BLE / audio event loop – set in __main__ before uvicorn starts. +# All coroutines that touch Bumble objects or the audio pipeline MUST run +# on this loop. HTTP handlers call _on_ble_loop() to cross into it. +_ble_loop: asyncio.AbstractEventLoop | None = None + + +async def _on_ble_loop(coro): + """Submit *coro* to the BLE event loop and await the result. + + Called from uvicorn's event loop. Bridges HTTP handler coroutines into + the isolated BLE loop so that serial I/O (serial_asyncio / HCI) and the + audio pipeline are never preempted by HTTP accept/read/write callbacks. + + asyncio.run_coroutine_threadsafe() schedules the coroutine on _ble_loop + (thread-safe), returning a concurrent.futures.Future. + asyncio.wrap_future() adapts that into an asyncio.Future so the caller + can simply `await` it inside uvicorn's loop. + """ + assert _ble_loop is not None, "BLE loop not yet initialised" + future = asyncio.run_coroutine_threadsafe(coro, _ble_loop) + return await asyncio.wrap_future(future) + async def _init_i2c_on_startup() -> None: # Ensure i2c-dev kernel module is loaded (required for /dev/i2c-* access) @@ -602,7 +625,10 @@ async def init_radio(transport: str, conf: auracast_config.AuracastConfigGroup, @app.post("/init") async def initialize(conf: auracast_config.AuracastConfigGroup): - """Initializes the primary broadcaster on the streamer thread.""" + """Initializes the primary broadcaster on the BLE loop.""" + return await _on_ble_loop(_initialize_impl(conf)) + +async def _initialize_impl(conf: auracast_config.AuracastConfigGroup): async with _stream_lock: global multicaster1, global_config_group mc, persisted = await init_radio(TRANSPORT1, conf, multicaster1) @@ -612,7 +638,10 @@ async def initialize(conf: auracast_config.AuracastConfigGroup): @app.post("/init2") async def initialize2(conf: auracast_config.AuracastConfigGroup): - """Initializes the secondary broadcaster on the streamer thread.""" + """Initializes the secondary broadcaster on the BLE loop.""" + return await _on_ble_loop(_initialize2_impl(conf)) + +async def _initialize2_impl(conf: auracast_config.AuracastConfigGroup): async with _stream_lock: global multicaster2 mc, persisted = await init_radio(TRANSPORT2, conf, multicaster2) @@ -631,7 +660,11 @@ async def set_led_enabled(body: dict): @app.post("/stop_audio") async def stop_audio(): - """Stops streaming on both multicaster1 and multicaster2 (worker thread).""" + """Stops streaming on both multicasters via the BLE loop.""" + return await _on_ble_loop(_stop_audio_impl()) + +async def _stop_audio_impl(): + """Runs on BLE loop: stops all streamers and persists is_streaming=False.""" try: was_running = await _stop_all() @@ -681,9 +714,9 @@ async def set_adc_gain(payload: dict): @app.post("/stream_lc3") async def send_audio(audio_data: dict[str, str]): - """Sends a block of pre-coded LC3 audio via the worker.""" + """Sends a block of pre-coded LC3 audio via the BLE loop.""" try: - await _stream_lc3(audio_data, list(global_config_group.bigs)) + await _on_ble_loop(_stream_lc3(audio_data, list(global_config_group.bigs))) return {"status": "audio_sent"} except Exception as e: raise HTTPException(status_code=500, detail=str(e)) @@ -1058,6 +1091,19 @@ async def _autostart_from_settings(): await do_primary() await do_secondary() +async def _ble_startup(): + """I2C init, ADC level reset, and autostart task scheduling on the BLE loop. + + Bridged from _startup_autostart_event() so that these async subprocess + calls and the long-lived autostart coroutine all run on _ble_loop, never + on uvicorn's HTTP loop. + """ + await _init_i2c_on_startup() + await _set_adc_level(0.0, 0.0) + log.info("[STARTUP] Scheduling autostart task on BLE loop") + asyncio.create_task(_autostart_from_settings()) + + @app.on_event("startup") async def _startup_autostart_event(): # Spawn the autostart task without blocking startup @@ -1078,12 +1124,11 @@ async def _startup_autostart_event(): # Hydrate settings cache once to avoid disk I/O during /status _load_led_settings() _init_settings_cache_from_disk() - await _init_i2c_on_startup() - # Ensure ADC mixer level is set at startup (default 0 dB) - await _set_adc_level(0.0, 0.0) refresh_pw_cache() - log.info("[STARTUP] Scheduling autostart task") - asyncio.create_task(_autostart_from_settings()) + # I2C init, ADC setup and the autostart task must run on the BLE loop so + # they share the same event loop as the Bumble HCI transport. + log.info("[STARTUP] Bridging I2C init and autostart to BLE loop") + asyncio.run_coroutine_threadsafe(_ble_startup(), _ble_loop) @app.get("/audio_inputs_pw_usb") async def audio_inputs_pw_usb(): @@ -1154,6 +1199,9 @@ async def refresh_audio_devices(): @app.post("/shutdown") async def shutdown(): """Stops broadcasting and releases all audio/Bluetooth resources.""" + return await _on_ble_loop(_shutdown_impl()) + +async def _shutdown_impl(): try: await _stop_all() return {"status": "stopped"} @@ -1166,6 +1214,9 @@ async def system_reboot(): Requires the service user to have passwordless sudo permissions to run 'reboot'. """ + return await _on_ble_loop(_system_reboot_impl()) + +async def _system_reboot_impl(): try: # Best-effort: stop any active streaming cleanly WITHOUT persisting state try: @@ -1189,47 +1240,27 @@ async def system_reboot(): @app.post("/restart_dep") async def restart_dep(): - """Restart DEP by running dep.sh stop then dep.sh start in the dep directory. + """Restart DEP via systemctl restart dep.service. - Requires the service user to have passwordless sudo permissions to run dep.sh. + Requires the service user to have passwordless sudo permissions for systemctl. """ try: - # Get the dep directory path (dep.sh is in dante_package subdirectory) - dep_dir = os.path.join(os.path.dirname(__file__), '..', '..', 'dep', 'dante_package') - - # Run dep.sh stop first - log.info("Stopping DEP...") - stop_process = await asyncio.create_subprocess_exec( - "sudo", "bash", "dep.sh", "stop", - cwd=dep_dir, + log.info("Restarting DEP via systemctl...") + proc = await asyncio.create_subprocess_exec( + "sudo", "systemctl", "restart", "dep.service", stdout=asyncio.subprocess.PIPE, stderr=asyncio.subprocess.PIPE ) - stop_stdout, stop_stderr = await stop_process.communicate() - - if stop_process.returncode != 0: - error_msg = stop_stderr.decode() if stop_stderr else "Unknown error" - log.error(f"Failed to stop DEP: {error_msg}") - raise HTTPException(status_code=500, detail=f"Failed to stop DEP: {error_msg}") - - # Run dep.sh start after stop succeeds - log.info("Starting DEP...") - start_process = await asyncio.create_subprocess_exec( - "sudo", "bash", "dep.sh", "start", - cwd=dep_dir, - stdout=asyncio.subprocess.PIPE, - stderr=asyncio.subprocess.PIPE - ) - start_stdout, start_stderr = await start_process.communicate() - - if start_process.returncode == 0: + stdout, stderr = await proc.communicate() + + if proc.returncode == 0: log.info("DEP restarted successfully") return {"status": "success", "message": "DEP restarted successfully"} else: - error_msg = start_stderr.decode() if start_stderr else "Unknown error" - log.error(f"Failed to start DEP: {error_msg}") - raise HTTPException(status_code=500, detail=f"Failed to start DEP: {error_msg}") - + error_msg = stderr.decode() if stderr else "Unknown error" + log.error(f"Failed to restart DEP: {error_msg}") + raise HTTPException(status_code=500, detail=f"Failed to restart DEP: {error_msg}") + except HTTPException: raise except Exception as e: @@ -1322,6 +1353,9 @@ async def check_update(): @app.post("/system_update") async def system_update(): """Update application: git pull main branch (latest tag), poetry install, restart services.""" + return await _on_ble_loop(_system_update_impl()) + +async def _system_update_impl(): try: # Best-effort: stop any active streaming cleanly try: @@ -1789,5 +1823,170 @@ if __name__ == '__main__': level=os.environ.get('LOG_LEVEL', log.INFO), format='%(module)s.py:%(lineno)d %(levelname)s: %(message)s' ) + + # ── GIL switch interval ───────────────────────────────────────────────── + # CPython releases the GIL every sys.getswitchinterval() seconds (default + # 5 ms). The audio pipeline fires every 10 ms, so a 5 ms granularity + # means up to half a frame period can be wasted waiting for the GIL. + # Reducing to 1 ms gives the BLE thread much tighter access. + import sys + sys.setswitchinterval(0.001) + log.info("GIL switch interval set to 1 ms") + + # ── BLE / audio event loop ────────────────────────────────────────────── + # Bumble (serial_asyncio / HCI) and the audio pipeline run exclusively on + # this loop. Uvicorn's HTTP accept/read/write callbacks run on a separate + # asyncio loop in the main thread, so they can never stall BLE advertising + # or audio encoding. + # + # Route handlers that touch Bumble objects call _on_ble_loop(), which uses + # asyncio.run_coroutine_threadsafe() + asyncio.wrap_future() to submit the + # coroutine to _ble_loop and await the result back in uvicorn's loop. + # Hot-path read-only endpoints (/status, /audio_level*) access + # multicaster state directly – Python's GIL makes attribute reads safe. + + def _pthread_sched_lib(): + """Return a ctypes handle with correctly typed pthread scheduling symbols. + + Uses RTLD_DEFAULT (ctypes.CDLL(None)) to resolve symbols from all + currently loaded shared libraries. This handles both: + - glibc < 2.34: pthread_self/pthread_setschedparam live in libpthread.so.0 + - glibc >= 2.34: pthreads merged into libc.so.6 + using find_library("c") would miss libpthread on older glibc and cause + a NULL function pointer → SEGV when called. + + Explicit restype/argtypes are mandatory: pthread_t is c_ulong (64-bit + on ARM64/x86-64) but ctypes defaults to c_int (32-bit), truncating + the thread handle and causing a SEGV inside pthread_setschedparam. + """ + import ctypes + + SCHED_FIFO = 1 + SCHED_OTHER = 0 + + class SchedParam(ctypes.Structure): + _fields_ = [("sched_priority", ctypes.c_int)] + + lib = ctypes.CDLL(None, use_errno=True) # RTLD_DEFAULT + + lib.pthread_self.restype = ctypes.c_ulong + lib.pthread_self.argtypes = [] + + lib.pthread_getschedparam.restype = ctypes.c_int + lib.pthread_getschedparam.argtypes = [ + ctypes.c_ulong, + ctypes.POINTER(ctypes.c_int), + ctypes.POINTER(SchedParam), + ] + lib.pthread_setschedparam.restype = ctypes.c_int + lib.pthread_setschedparam.argtypes = [ + ctypes.c_ulong, + ctypes.c_int, + ctypes.POINTER(SchedParam), + ] + return lib, SchedParam, SCHED_FIFO, SCHED_OTHER + + def _configure_ble_thread_scheduling(): + """Confirm or establish SCHED_FIFO for the BLE/audio thread. + + When launched via the systemd unit (CPUSchedulingPolicy=fifo), new + threads inherit the process RT policy automatically – just log and + return. When run directly (development), attempt to elevate to + SCHED_FIFO/30 (requires CAP_SYS_NICE), falling back gracefully. + """ + import ctypes + try: + lib, SchedParam, SCHED_FIFO, _ = _pthread_sched_lib() + tid = lib.pthread_self() + policy = ctypes.c_int(-1) + param = SchedParam(0) + lib.pthread_getschedparam(tid, ctypes.byref(policy), ctypes.byref(param)) + + if policy.value == SCHED_FIFO: + log.info("[BLE-LOOP] Already SCHED_FIFO priority=%d (inherited from systemd)", + param.sched_priority) + return + + param.sched_priority = 30 + ret = lib.pthread_setschedparam(tid, SCHED_FIFO, ctypes.byref(param)) + if ret == 0: + log.info("[BLE-LOOP] SCHED_FIFO priority=30 set") + else: + err = ctypes.get_errno() + log.warning("[BLE-LOOP] SCHED_FIFO failed (errno=%d: %s) – " + "use systemd CPUSchedulingPolicy=fifo or grant CAP_SYS_NICE", + err, os.strerror(err)) + try: + os.setpriority(os.PRIO_PROCESS, 0, + os.getpriority(os.PRIO_PROCESS, 0) - 5) + except PermissionError: + pass + except Exception as exc: + log.warning("[BLE-LOOP] Scheduling setup error: %s", exc) + + def _configure_http_thread_scheduling(): + """Demote the HTTP (uvicorn) thread to SCHED_OTHER + nice=+10. + + When systemd sets CPUSchedulingPolicy=fifo, every thread in the + process – including uvicorn's main loop – inherits SCHED_FIFO. + We demote the HTTP thread back to SCHED_OTHER so the BLE thread + always wins CPU arbitration when both are runnable. + Lowering scheduling policy never requires special privileges. + """ + import ctypes + try: + lib, SchedParam, SCHED_FIFO, SCHED_OTHER = _pthread_sched_lib() + tid = lib.pthread_self() + policy = ctypes.c_int(-1) + param = SchedParam(0) + lib.pthread_getschedparam(tid, ctypes.byref(policy), ctypes.byref(param)) + + if policy.value == SCHED_FIFO: + param.sched_priority = 0 + ret = lib.pthread_setschedparam(tid, SCHED_OTHER, ctypes.byref(param)) + if ret == 0: + log.info("[HTTP] Demoted SCHED_FIFO → SCHED_OTHER") + else: + err = ctypes.get_errno() + log.warning("[HTTP] Could not demote from SCHED_FIFO (errno=%d)", err) + else: + log.info("[HTTP] Already SCHED_OTHER, no demotion needed") + except Exception as exc: + log.warning("[HTTP] Scheduling demotion error: %s", exc) + + try: + os.nice(10) + log.info("[HTTP] nice=+10 (lower priority)") + except Exception as exc: + log.debug("[HTTP] os.nice: %s", exc) + + _ble_loop_ready = threading.Event() + + def _run_ble_loop(): + # Confirm or establish RT scheduling before entering the event loop. + _configure_ble_thread_scheduling() + + async def _ble_runner(): + global _ble_loop + _ble_loop = asyncio.get_running_loop() + _ble_loop_ready.set() + # Keep the loop alive; it is stopped when the process exits because + # this is a daemon thread. + await asyncio.Event().wait() + + asyncio.run(_ble_runner()) + + _ble_thread = threading.Thread(target=_run_ble_loop, name="ble-loop", daemon=True) + _ble_thread.start() + if not _ble_loop_ready.wait(timeout=5): + log.error("BLE event loop failed to start within 5 s – aborting") + raise RuntimeError("BLE event loop startup timeout") + log.info("BLE event loop started on thread '%s'", _ble_thread.name) + + # ── HTTP / uvicorn event loop (main thread) ───────────────────────────── + # Demote the HTTP thread from SCHED_FIFO (if set by systemd) to + # SCHED_OTHER + nice=+10 so the BLE thread always preempts it. + _configure_http_thread_scheduling() + # Bind to localhost only for security: prevents network access, only frontend on same machine can connect uvicorn.run(app, host="127.0.0.1", port=5000, access_log=False) \ No newline at end of file diff --git a/src/dep/dante_package/dante_data/capability/dante.json b/src/dep/dante_package/dante_data/capability/dante.json index 74fbae8..7c38c3f 100644 --- a/src/dep/dante_package/dante_data/capability/dante.json +++ b/src/dep/dante_package/dante_data/capability/dante.json @@ -1,5 +1,5 @@ { - "trialMode": true, + "trialMode": false, "$schema": "./dante.json_schema.json", "platform": { @@ -69,16 +69,16 @@ }, "product" : { - "manfId" : "Audinate", - "manfName" : "Audinate Pty Ltd", - "modelId" : "OEMDEP", - "modelName" : "Linux Dante Embedded Platform", + "manfId" : "SummitFC", + "manfName" : "Summitwave FlexCo", + "modelId" : "TX", + "modelName" : "Summitwave TX", "modelVersion" : { - "major" : 9, - "minor" : 9, - "bugfix" : 99 + "major" : 1, + "minor" : 0, + "bugfix" : 0 }, - "devicePrefix" : "DEP" + "devicePrefix" : "SW-TX" } } diff --git a/src/service/auracast-server.service b/src/service/auracast-server.service index b7cbea2..90903a7 100644 --- a/src/service/auracast-server.service +++ b/src/service/auracast-server.service @@ -1,6 +1,7 @@ [Unit] Description=Auracast Backend Server -After=network.target +After=network.target dep.service +Wants=dep.service [Service] Type=simple diff --git a/src/service/dep.service b/src/service/dep.service new file mode 100644 index 0000000..05e7f11 --- /dev/null +++ b/src/service/dep.service @@ -0,0 +1,13 @@ +[Unit] +Description=DEP (Dante Embedded Platform) Container +After=network.target + +[Service] +Type=oneshot +RemainAfterExit=yes +WorkingDirectory=/home/caster/bumble-auracast/src/dep/dante_package +ExecStart=/bin/bash dep.sh start +ExecStop=/bin/bash dep.sh stop + +[Install] +WantedBy=multi-user.target diff --git a/src/service/update_and_run_server_and_frontend.sh b/src/service/update_and_run_server_and_frontend.sh index c3eb28b..f6b8159 100755 --- a/src/service/update_and_run_server_and_frontend.sh +++ b/src/service/update_and_run_server_and_frontend.sh @@ -28,6 +28,9 @@ sudo cp /home/caster/bumble-auracast/src/service/10-link-local-mgmt /etc/Network sudo chown root:root /etc/NetworkManager/dispatcher.d/10-link-local-mgmt sudo chmod 755 /etc/NetworkManager/dispatcher.d/10-link-local-mgmt +# Copy system service file for DEP +sudo cp /home/caster/bumble-auracast/src/service/dep.service /etc/systemd/system/dep.service + # Copy system service file for frontend sudo cp /home/caster/bumble-auracast/src/service/auracast-frontend.service /etc/systemd/system/auracast-frontend.service @@ -35,20 +38,25 @@ sudo cp /home/caster/bumble-auracast/src/service/auracast-frontend.service /etc/ mkdir -p /home/caster/.config/systemd/user cp /home/caster/bumble-auracast/src/service/auracast-server.service /home/caster/.config/systemd/user/auracast-server.service -# Reload systemd for frontend +# Reload systemd for frontend and dep sudo systemctl daemon-reload # Reload user systemd for server systemctl --user daemon-reload +# Enable DEP to start on boot (system) +sudo systemctl enable dep.service # Enable frontend to start on boot (system) sudo systemctl enable auracast-frontend.service # Enable server to start on boot (user) systemctl --user enable auracast-server.service -# Restart both +# Restart all +sudo systemctl restart dep.service + sudo systemctl restart auracast-frontend.service systemctl --user restart auracast-server.service #print status +sudo systemctl status dep.service --no-pager sudo systemctl status auracast-frontend.service --no-pager systemctl --user status auracast-server.service --no-pager -- 2.52.0 From c24be9f366da78d98c640960cb9b5ebab60280bb Mon Sep 17 00:00:00 2001 From: pober Date: Tue, 28 Apr 2026 09:01:58 +0200 Subject: [PATCH 6/9] Changed cpu pinning; more headroom for audiopipeline; dep and asrc on core 3 --- src/dep/dante_package/dante_data/capability/dante.json | 2 +- src/service/auracast-server.service | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/src/dep/dante_package/dante_data/capability/dante.json b/src/dep/dante_package/dante_data/capability/dante.json index 7c38c3f..76632b1 100644 --- a/src/dep/dante_package/dante_data/capability/dante.json +++ b/src/dep/dante_package/dante_data/capability/dante.json @@ -26,7 +26,7 @@ "defaultEncoding" : "PCM16", "numDepCores" : [ - 2 + 3 ] }, "network" : diff --git a/src/service/auracast-server.service b/src/service/auracast-server.service index 90903a7..dca4ba8 100644 --- a/src/service/auracast-server.service +++ b/src/service/auracast-server.service @@ -13,8 +13,8 @@ Environment=LOG_LEVEL=INFO CPUSchedulingPolicy=fifo CPUSchedulingPriority=10 LimitRTPRIO=99 -AllowedCPUs=1 -CPUAffinity=1 +AllowedCPUs=0,1,2 +CPUAffinity=0,1,2 [Install] WantedBy=default.target -- 2.52.0 From 9c251b7a669c29d51c9fc72216f2ffe16c68d248 Mon Sep 17 00:00:00 2001 From: pober Date: Tue, 28 Apr 2026 09:05:39 +0200 Subject: [PATCH 7/9] Fixes activation bug by adding the activation folder. --- .gitignore | 3 ++- src/dep/dante_package/dante_data/activation/.gitkeep | 1 + 2 files changed, 3 insertions(+), 1 deletion(-) create mode 100644 src/dep/dante_package/dante_data/activation/.gitkeep diff --git a/.gitignore b/.gitignore index 8e1b3d7..fd2e417 100644 --- a/.gitignore +++ b/.gitignore @@ -57,4 +57,5 @@ src/auracast/server/led_settings.json # Dante license files *.lic -src/dep/dante_package/dante_data/activation/ +src/dep/dante_package/dante_data/activation/device.lic +src/dep/dante_package/dante_data/activation/manufacturer.cert diff --git a/src/dep/dante_package/dante_data/activation/.gitkeep b/src/dep/dante_package/dante_data/activation/.gitkeep new file mode 100644 index 0000000..bdcebc1 --- /dev/null +++ b/src/dep/dante_package/dante_data/activation/.gitkeep @@ -0,0 +1 @@ +Placeholder file to have the activation folder available, otherwise dante activation script fails with 'Unable to write'. -- 2.52.0 From 25df79eef549fbc670fe210eac76fa6e071b1881 Mon Sep 17 00:00:00 2001 From: pober Date: Thu, 7 May 2026 15:38:41 +0200 Subject: [PATCH 8/9] Persists settings on refresh and power cycle. --- src/auracast/server/multicast_frontend.py | 381 ++++++++++++++++------ src/auracast/server/multicast_server.py | 6 + 2 files changed, 289 insertions(+), 98 deletions(-) diff --git a/src/auracast/server/multicast_frontend.py b/src/auracast/server/multicast_frontend.py index da2de01..345918d 100644 --- a/src/auracast/server/multicast_frontend.py +++ b/src/auracast/server/multicast_frontend.py @@ -433,13 +433,43 @@ else: disabled=is_streaming ) - # Use analog-specific defaults (not from saved settings which may have Dante values) - default_name = "Analog_Radio_1" - default_program_info = "Analog Radio Broadcast" - default_lang = "deu" - quality_options = list(QUALITY_MAP.keys()) - default_quality = "Medium (24kHz)" if "Medium (24kHz)" in quality_options else quality_options[0] + + # Use saved settings if audio_mode matches, otherwise use analog-specific defaults + saved_audio_mode = saved_settings.get('audio_mode') + if saved_audio_mode == 'Analog': + default_name = saved_settings.get('channel_names', ["Analog_Radio_1"])[0] + raw_program_info = saved_settings.get('program_info', default_name) + if isinstance(raw_program_info, list) and raw_program_info: + default_program_info = raw_program_info[0] + else: + default_program_info = raw_program_info + default_lang = saved_settings.get('languages', ["deu"])[0] + + # Map saved sampling rate to quality label + saved_rate = saved_settings.get('auracast_sampling_rate_hz') + if saved_rate == 48000: + default_quality = "High (48kHz)" + elif saved_rate == 32000: + default_quality = "Good (32kHz)" + elif saved_rate == 24000: + default_quality = "Medium (24kHz)" + elif saved_rate == 16000: + default_quality = "Fair (16kHz)" + else: + default_quality = "Medium (24kHz)" + + saved_pwd = saved_settings.get('stream_password', '') + else: + # Use analog-specific defaults when switching from another mode + default_name = "Analog_Radio_1" + default_program_info = "Analog Radio Broadcast" + default_lang = "deu" + default_quality = "Medium (24kHz)" if "Medium (24kHz)" in quality_options else quality_options[0] + saved_pwd = '' + + if default_quality not in quality_options: + default_quality = quality_options[0] quality1 = st.selectbox( "Stream Quality (Radio 1)", quality_options, @@ -450,7 +480,7 @@ else: stream_passwort1 = st.text_input( "Stream Passwort (Radio 1)", - value="", + value=saved_pwd, type="password", disabled=is_streaming, help="Optional: Set a broadcast code for Radio 1." @@ -557,7 +587,10 @@ else: input_device1 = None else: # Mono mode: show all available channels + saved_input_device = saved_settings.get('input_device') default_r1_idx = 0 + if saved_input_device in analog_names: + default_r1_idx = analog_names.index(saved_input_device) input_device1 = st.selectbox( "Input Device (Radio 1)", analog_names, @@ -606,22 +639,53 @@ else: ) if radio2_enabled and not stereo_enabled: - # Use analog-specific defaults for Radio 2 - default_name_r2 = "Analog_Radio_2" - default_program_info_r2 = "Analog Radio Broadcast" - default_lang_r2 = "deu" + # Use saved settings if audio_mode matches, otherwise use analog-specific defaults for Radio 2 + secondary_settings = saved_settings.get('secondary', {}) + saved_audio_mode = saved_settings.get('audio_mode') + if saved_audio_mode == 'Analog' and secondary_settings: + default_name_r2 = secondary_settings.get('channel_names', ["Analog_Radio_2"])[0] if isinstance(secondary_settings.get('channel_names'), list) else secondary_settings.get('channel_names', "Analog_Radio_2") + raw_program_info_r2 = secondary_settings.get('program_info', default_name_r2) + if isinstance(raw_program_info_r2, list) and raw_program_info_r2: + default_program_info_r2 = raw_program_info_r2[0] + else: + default_program_info_r2 = raw_program_info_r2 + default_lang_r2 = secondary_settings.get('languages', ["deu"])[0] if isinstance(secondary_settings.get('languages'), list) else secondary_settings.get('languages', 'deu') + + # Map saved sampling rate to quality label + saved_rate_r2 = secondary_settings.get('auracast_sampling_rate_hz') + if saved_rate_r2 == 48000: + default_quality_r2 = "High (48kHz)" + elif saved_rate_r2 == 32000: + default_quality_r2 = "Good (32kHz)" + elif saved_rate_r2 == 24000: + default_quality_r2 = "Medium (24kHz)" + elif saved_rate_r2 == 16000: + default_quality_r2 = "Fair (16kHz)" + else: + default_quality_r2 = "Medium (24kHz)" + + saved_pwd_r2 = secondary_settings.get('stream_password', '') + else: + # Use analog-specific defaults when switching from another mode + default_name_r2 = "Analog_Radio_2" + default_program_info_r2 = "Analog Radio Broadcast" + default_lang_r2 = "deu" + default_quality_r2 = "Medium (24kHz)" if "Medium (24kHz)" in quality_options else quality_options[0] + saved_pwd_r2 = '' + if default_quality_r2 not in quality_options: + default_quality_r2 = quality_options[0] quality2 = st.selectbox( "Stream Quality (Radio 2)", quality_options, - index=quality_options.index(default_quality), + index=quality_options.index(default_quality_r2), disabled=is_streaming, help="Select the audio sampling rate for Radio 2." ) stream_passwort2 = st.text_input( "Stream Passwort (Radio 2)", - value="", + value=saved_pwd_r2, type="password", disabled=is_streaming, help="Optional: Set a broadcast code for Radio 2." @@ -682,7 +746,11 @@ else: if not is_streaming: if analog_names: + secondary_settings = saved_settings.get('secondary', {}) + saved_input_device2 = secondary_settings.get('input_device') default_r2_idx = 1 if len(analog_names) > 1 else 0 + if saved_input_device2 in analog_names: + default_r2_idx = analog_names.index(saved_input_device2) input_device2 = st.selectbox( "Input Device (Radio 2)", analog_names, @@ -774,10 +842,15 @@ else: ) # Dante stereo mode toggle - saved_r1_config = saved_settings.get('dante_radio1', {}) + saved_audio_mode = saved_settings.get('audio_mode') + dante_stereo_enabled = False + if saved_audio_mode == 'Network - Dante': + # Check if any input device starts with dante_stereo_ to detect stereo mode + input_device = saved_settings.get('input_device', '') + dante_stereo_enabled = input_device.startswith('dante_stereo_') dante_stereo_enabled = st.checkbox( "🎧 Stereo Mode", - value=bool(saved_r1_config.get('dante_stereo_mode', False)), + value=dante_stereo_enabled, help="Enable stereo streaming for Dante inputs. Select left and right channels from ASRC channels 1-6. Radio 2 and multi-stream configurations will be disabled in stereo mode.", disabled=is_streaming ) @@ -786,13 +859,23 @@ else: dante_left_channel = None dante_right_channel = None if dante_stereo_enabled: - dante_channel_options = ["dante_asrc_ch1", "dante_asrc_ch2", "dante_asrc_ch3", + dante_channel_options = ["dante_asrc_ch1", "dante_asrc_ch2", "dante_asrc_ch3", "dante_asrc_ch4", "dante_asrc_ch5", "dante_asrc_ch6"] dante_channel_labels = ["CH1", "CH2", "CH3", "CH4", "CH5", "CH6"] - + + # Parse saved stereo device name to extract left and right channels + input_device = saved_settings.get('input_device', '') + saved_left = 'dante_asrc_ch1' + saved_right = 'dante_asrc_ch2' + if input_device.startswith('dante_stereo_'): + # Format: dante_stereo__ + parts = input_device.split('_') + if len(parts) >= 4: + saved_left = f"dante_asrc_ch{parts[2]}" + saved_right = f"dante_asrc_ch{parts[3]}" + col_left, col_right = st.columns(2) with col_left: - saved_left = saved_r1_config.get('dante_stereo_left', 'dante_asrc_ch1') left_idx = dante_channel_options.index(saved_left) if saved_left in dante_channel_options else 0 dante_left_channel = st.selectbox( "Left Channel", @@ -803,7 +886,6 @@ else: help="Select the Dante ASRC channel for the left stereo channel" ) with col_right: - saved_right = saved_r1_config.get('dante_stereo_right', 'dante_asrc_ch2') right_idx = dante_channel_options.index(saved_right) if saved_right in dante_channel_options else 1 dante_right_channel = st.selectbox( "Right Channel", @@ -813,7 +895,7 @@ else: disabled=is_streaming, help="Select the Dante ASRC channel for the right stereo channel" ) - + if dante_left_channel == dante_right_channel: st.warning("⚠️ Left and right channels are the same. Select different channels for true stereo.") else: @@ -821,7 +903,22 @@ else: # Stream count dropdown for Radio 1 (disabled in stereo mode - forced to 1 stream at 48kHz) r1_stream_options = list(dante_stream_options.keys()) - saved_r1_streams = saved_r1_config.get('stream_config', '1x48') + # Infer stream configuration from saved sampling rate + saved_rate = saved_settings.get('auracast_sampling_rate_hz') + saved_r1_streams = '1 × 48kHz' # default + if saved_rate: + if saved_rate == 48000: + channel_names = saved_settings.get('channel_names', []) + if len(channel_names) == 2: + saved_r1_streams = '2 × 24kHz' + elif len(channel_names) == 3: + saved_r1_streams = '3 × 16kHz' + else: + saved_r1_streams = '1 × 48kHz' + elif saved_rate == 24000: + saved_r1_streams = '2 × 24kHz' + elif saved_rate == 16000: + saved_r1_streams = '3 × 16kHz' default_r1_idx = r1_stream_options.index(saved_r1_streams) if saved_r1_streams in r1_stream_options else 0 if dante_stereo_enabled: @@ -851,15 +948,25 @@ else: r1_available_qualities = [] for quality in ["High (48kHz)", "Good (32kHz)", "Medium (24kHz)", "Fair (16kHz)"]: # Check if this quality is equal to or lower than the max - if (r1_max_quality == "High (48kHz)" or + if (r1_max_quality == "High (48kHz)" or (r1_max_quality == "Medium (24kHz)" and quality in ["Medium (24kHz)", "Fair (16kHz)"]) or (r1_max_quality == "Fair (16kHz)" and quality == "Fair (16kHz)")): r1_available_qualities.append(quality) - - saved_r1_quality = saved_r1_config.get('radio_quality', r1_max_quality) + + # Map saved sampling rate to quality label + saved_r1_quality = r1_max_quality + saved_rate = saved_settings.get('auracast_sampling_rate_hz') + if saved_rate == 48000: + saved_r1_quality = "High (48kHz)" + elif saved_rate == 32000: + saved_r1_quality = "Good (32kHz)" + elif saved_rate == 24000: + saved_r1_quality = "Medium (24kHz)" + elif saved_rate == 16000: + saved_r1_quality = "Fair (16kHz)" if saved_r1_quality not in r1_available_qualities: saved_r1_quality = r1_max_quality - + r1_radio_quality = st.selectbox( "Stream Quality (Radio 1)", r1_available_qualities, @@ -867,29 +974,29 @@ else: disabled=is_streaming, help=f"Select stream quality for Radio 1. Maximum quality based on configuration: {r1_max_quality}" ) - + # Radio-level settings for Radio 1 # First row: Assistive listening, immediate rendering, presentation delay, QoS col_r1_flags1, col_r1_flags2, col_r1_pdelay, col_r1_qos = st.columns([1, 1, 0.7, 0.6], gap="small") - + with col_r1_flags1: r1_assisted_listening = st.checkbox( "Assistive (R1)", - value=bool(saved_r1_config.get('assisted_listening', False)), + value=bool(saved_settings.get('assisted_listening_stream', False)), disabled=is_streaming, help="Assistive listening stream" ) - + with col_r1_flags2: r1_immediate_rendering = st.checkbox( "Immediate (R1)", - value=bool(saved_r1_config.get('immediate_rendering', False)), + value=bool(saved_settings.get('immediate_rendering', False)), disabled=is_streaming, help="Ignore presentation delay" ) with col_r1_pdelay: - default_pdelay = int(saved_r1_config.get('presentation_delay_us', 40000) or 40000) + default_pdelay = int(saved_settings.get('presentation_delay_us', 40000) or 40000) default_pdelay_ms = max(10, min(200, default_pdelay // 1000)) r1_presentation_delay_ms = st.number_input( "Delay (ms, R1)", @@ -897,10 +1004,10 @@ else: disabled=is_streaming, help="Presentation delay for Radio 1" ) - + with col_r1_qos: qos_options = list(QOS_PRESET_MAP.keys()) - saved_qos = saved_r1_config.get('qos_preset', 'Fast') + saved_qos = saved_settings.get('qos_preset', 'Fast') default_qos_idx = qos_options.index(saved_qos) if saved_qos in qos_options else 0 r1_qos_preset = st.selectbox( "QoS (R1)", options=qos_options, index=default_qos_idx, @@ -918,24 +1025,31 @@ else: if dante_stereo_enabled: # Stereo mode: single stream with combined L+R channels with st.expander("Stereo Stream - Radio 1", expanded=True): - saved_streams = saved_r1_config.get('streams', []) - saved_stream = saved_streams[0] if saved_streams else {} - + # Read from flat settings structure + channel_names = saved_settings.get('channel_names', []) + program_infos = saved_settings.get('program_info', []) + languages = saved_settings.get('languages', []) + + saved_name = channel_names[0] if channel_names else 'Dante_Stereo' + saved_program_info = program_infos[0] if program_infos else saved_name + saved_language = languages[0] if languages else 'eng' + saved_password = saved_settings.get('stream_password', '') + # First row: Channel name and password col_name, col_pwd = st.columns([2, 1]) - + with col_name: stream_name = st.text_input( "Channel Name", - value=saved_stream.get('name', 'Dante_Stereo'), + value=saved_name, disabled=is_streaming, key="r1_stereo_name" ) - + with col_pwd: stream_password = st.text_input( "Stream Password", - value=saved_stream.get('stream_password', ''), + value=saved_password, type="password", disabled=is_streaming, key="r1_stereo_password", @@ -944,19 +1058,19 @@ else: # Second row: Program info and language col_prog, col_lang_code = st.columns([2, 1]) - + with col_prog: program_info = st.text_input( "Program Info", - value=saved_stream.get('program_info', 'Dante Stereo Broadcast'), + value=saved_program_info, disabled=is_streaming, key="r1_stereo_program" ) - + with col_lang_code: language = st.text_input( "Language", - value=saved_stream.get('language', 'eng'), + value=saved_language, disabled=is_streaming, key="r1_stereo_lang", help="ISO 639-3 language code" @@ -990,47 +1104,58 @@ else: }) else: # Normal mono mode: multiple streams with individual channels + # Read from flat settings structure + channel_names = saved_settings.get('channel_names', []) + program_infos = saved_settings.get('program_info', []) + languages = saved_settings.get('languages', []) + input_devices = saved_settings.get('input_devices', []) + stream_passwords = saved_settings.get('stream_passwords', []) if 'stream_passwords' in saved_settings else [] + for i in range(r1_num_streams): with st.expander(f"Stream {i+1} - Radio 1", expanded=True): - saved_streams = saved_r1_config.get('streams', []) - saved_stream = saved_streams[i] if i < len(saved_streams) else {} - + # Get saved values from flat structure + saved_name = channel_names[i] if i < len(channel_names) else f'Dante_R1_S{i+1}' + saved_program_info = program_infos[i] if i < len(program_infos) else f'Dante Radio 1 Stream {i+1}' + saved_language = languages[i] if i < len(languages) else 'eng' + saved_password = stream_passwords[i] if i < len(stream_passwords) else '' + saved_input_device = input_devices[i] if i < len(input_devices) else None + # First row: Channel name and language col_name, col_lang = st.columns([2, 1]) - + with col_name: stream_name = st.text_input( f"Channel Name", - value=saved_stream.get('name', f'Dante_R1_S{i+1}'), + value=saved_name, disabled=is_streaming, key=f"r1_stream_{i}_name" ) - + with col_lang: stream_password = st.text_input( f"Stream Password", - value=saved_stream.get('stream_password', ''), + value=saved_password, type="password", disabled=is_streaming, key=f"r1_stream_{i}_password", help="Optional: Set a broadcast code for this stream" ) - + # Second row: Program info and language col_prog, col_lang_code = st.columns([2, 1]) - + with col_prog: program_info = st.text_input( f"Program Info", - value=saved_stream.get('program_info', f'Dante Radio 1 Stream {i+1}'), + value=saved_program_info, disabled=is_streaming, key=f"r1_stream_{i}_program" ) - + with col_lang_code: language = st.text_input( f"Language", - value=saved_stream.get('language', 'eng'), + value=saved_language, disabled=is_streaming, key=f"r1_stream_{i}_lang", help="ISO 639-3 language code" @@ -1042,10 +1167,10 @@ else: with col_device: # Session state key for persisting the selection device_session_key = f"r1_stream_{i}_device_saved" - + if not is_streaming and input_options: # Get default from session state first, then from saved settings - default_input_name = st.session_state.get(device_session_key, saved_stream.get('input_device')) + default_input_name = st.session_state.get(device_session_key, saved_input_device) default_input_label = None for label, name in option_name_map.items(): if name == default_input_name: @@ -1053,7 +1178,7 @@ else: break if default_input_label not in input_options and input_options: default_input_label = input_options[0] - + selected_option = st.selectbox( f"Input Device", input_options, @@ -1066,7 +1191,7 @@ else: st.session_state[device_session_key] = input_device else: # When streaming, get the device from session state - current_device = st.session_state.get(device_session_key, saved_stream.get('input_device', 'No device')) + current_device = st.session_state.get(device_session_key, saved_input_device or 'No device') # Convert internal name to display label display_label = current_device @@ -1095,26 +1220,45 @@ else: # --- Radio 2 Section --- with st.container(border=True): st.subheader("Radio 2") - + # Disable Radio 2 in stereo mode - saved_r2_config = saved_settings.get('dante_radio2', {}) + secondary_settings = saved_settings.get('secondary', {}) if dante_stereo_enabled: st.info("🎧 Radio 2 is automatically disabled in stereo mode") radio2_enabled = False else: # Enable/disable checkbox for Radio 2 + # Use saved settings or streaming state to determine default radio2_enabled_default = secondary_is_streaming + # Check if secondary radio has saved settings (indicates it was enabled) + if secondary_settings.get('auracast_sampling_rate_hz') or secondary_settings.get('channel_names'): + radio2_enabled_default = True radio2_enabled = st.checkbox( "Enable Radio 2", value=radio2_enabled_default, disabled=is_streaming, help="Activate a second Dante radio with its own quality and timing settings." ) - + if radio2_enabled: # Stream count dropdown for Radio 2 r2_stream_options = r1_stream_options - saved_r2_streams = saved_r2_config.get('stream_config', '1x48') + # Infer stream configuration from saved secondary sampling rate + saved_rate2 = secondary_settings.get('auracast_sampling_rate_hz') + saved_r2_streams = '1 × 48kHz' # default + if saved_rate2: + if saved_rate2 == 48000: + channel_names2 = secondary_settings.get('channel_names', []) + if len(channel_names2) == 2: + saved_r2_streams = '2 × 24kHz' + elif len(channel_names2) == 3: + saved_r2_streams = '3 × 16kHz' + else: + saved_r2_streams = '1 × 48kHz' + elif saved_rate2 == 24000: + saved_r2_streams = '2 × 24kHz' + elif saved_rate2 == 16000: + saved_r2_streams = '3 × 16kHz' default_r2_idx = r2_stream_options.index(saved_r2_streams) if saved_r2_streams in r2_stream_options else 0 r2_stream_config = st.selectbox( @@ -1136,11 +1280,20 @@ else: (r2_max_quality == "Medium (24kHz)" and quality in ["Medium (24kHz)", "Fair (16kHz)"]) or (r2_max_quality == "Fair (16kHz)" and quality == "Fair (16kHz)")): r2_available_qualities.append(quality) - - saved_r2_quality = saved_r2_config.get('radio_quality', r2_max_quality) + + # Map saved secondary sampling rate to quality label + saved_r2_quality = r2_max_quality + if saved_rate2 == 48000: + saved_r2_quality = "High (48kHz)" + elif saved_rate2 == 32000: + saved_r2_quality = "Good (32kHz)" + elif saved_rate2 == 24000: + saved_r2_quality = "Medium (24kHz)" + elif saved_rate2 == 16000: + saved_r2_quality = "Fair (16kHz)" if saved_r2_quality not in r2_available_qualities: saved_r2_quality = r2_max_quality - + r2_radio_quality = st.selectbox( "Stream Quality (Radio 2)", r2_available_qualities, @@ -1148,29 +1301,28 @@ else: disabled=is_streaming, help=f"Select stream quality for Radio 2. Maximum quality based on configuration: {r2_max_quality}" ) - + # Radio-level settings for Radio 2 - # First row: Assistive listening, immediate rendering, presentation delay, QoS col_r2_flags1, col_r2_flags2, col_r2_pdelay, col_r2_qos = st.columns([1, 1, 0.7, 0.6], gap="small") - + with col_r2_flags1: r2_assisted_listening = st.checkbox( "Assistive (R2)", - value=bool(saved_r2_config.get('assisted_listening', False)), + value=bool(secondary_settings.get('assisted_listening_stream', False)), disabled=is_streaming, help="Assistive listening stream" ) - + with col_r2_flags2: r2_immediate_rendering = st.checkbox( "Immediate (R2)", - value=bool(saved_r2_config.get('immediate_rendering', False)), + value=bool(secondary_settings.get('immediate_rendering', False)), disabled=is_streaming, help="Ignore presentation delay" ) - + with col_r2_pdelay: - default_pdelay = int(saved_r2_config.get('presentation_delay_us', 40000) or 40000) + default_pdelay = int(secondary_settings.get('presentation_delay_us', 40000) or 40000) default_pdelay_ms = max(10, min(200, default_pdelay // 1000)) r2_presentation_delay_ms = st.number_input( "Delay (ms, R2)", @@ -1178,13 +1330,13 @@ else: disabled=is_streaming, help="Presentation delay for Radio 2" ) - + with col_r2_qos: qos_options = list(QOS_PRESET_MAP.keys()) - saved_qos = saved_r2_config.get('qos_preset', 'Fast') - default_qos_idx = qos_options.index(saved_qos) if saved_qos in qos_options else 0 + saved_qos = secondary_settings.get('qos_preset', 'Fast') + default_qos_idx2 = qos_options.index(saved_qos) if saved_qos in qos_options else 0 r2_qos_preset = st.selectbox( - "QoS (R2)", options=qos_options, index=default_qos_idx, + "QoS (R2)", options=qos_options, index=default_qos_idx2, disabled=is_streaming, help="Quality of Service preset for Radio 2" ) @@ -1192,48 +1344,59 @@ else: # Per-stream configuration for Radio 2 st.write("**Stream Configuration (Radio 2)**") r2_streams = [] - + + # Read from flat secondary settings structure + channel_names2 = secondary_settings.get('channel_names', []) + program_infos2 = secondary_settings.get('program_info', []) + languages2 = secondary_settings.get('languages', []) + input_devices2 = secondary_settings.get('input_devices', []) + stream_passwords2 = secondary_settings.get('stream_passwords', []) if 'stream_passwords' in secondary_settings else [] + for i in range(r2_num_streams): with st.expander(f"Stream {i+1} - Radio 2", expanded=True): - saved_streams = saved_r2_config.get('streams', []) - saved_stream = saved_streams[i] if i < len(saved_streams) else {} + # Get saved values from flat secondary structure + saved_name2 = channel_names2[i] if i < len(channel_names2) else f'Dante_R2_S{i+1}' + saved_program_info2 = program_infos2[i] if i < len(program_infos2) else f'Dante Radio 2 Stream {i+1}' + saved_language2 = languages2[i] if i < len(languages2) else 'eng' + saved_password2 = stream_passwords2[i] if i < len(stream_passwords2) else '' + saved_input_device2 = input_devices2[i] if i < len(input_devices2) else None # First row: Channel name and password col_name, col_pwd = st.columns([2, 1]) - + with col_name: stream_name = st.text_input( f"Channel Name", - value=saved_stream.get('name', f'Dante_R2_S{i+1}'), + value=saved_name2, disabled=is_streaming, key=f"r2_stream_{i}_name" ) - + with col_pwd: stream_password = st.text_input( f"Stream Password", - value=saved_stream.get('stream_password', ''), + value=saved_password2, type="password", disabled=is_streaming, key=f"r2_stream_{i}_password", help="Optional: Set a broadcast code for this stream" ) - + # Second row: Program info and language col_prog, col_lang = st.columns([2, 1]) - + with col_prog: program_info = st.text_input( f"Program Info", - value=saved_stream.get('program_info', f'Dante Radio 2 Stream {i+1}'), + value=saved_program_info2, disabled=is_streaming, key=f"r2_stream_{i}_program" ) - + with col_lang: language = st.text_input( f"Language", - value=saved_stream.get('language', 'eng'), + value=saved_language2, disabled=is_streaming, key=f"r2_stream_{i}_lang", help="ISO 639-3 language code" @@ -1245,10 +1408,10 @@ else: with col_device: # Session state key for persisting the selection device_session_key = f"r2_stream_{i}_device_saved" - + if not is_streaming and input_options: # Get default from session state first, then from saved settings - default_input_name = st.session_state.get(device_session_key, saved_stream.get('input_device')) + default_input_name = st.session_state.get(device_session_key, saved_input_device2) default_input_label = None for label, name in option_name_map.items(): if name == default_input_name: @@ -1256,7 +1419,7 @@ else: break if default_input_label not in input_options and input_options: default_input_label = input_options[0] - + selected_option = st.selectbox( f"Input Device", input_options, @@ -1269,7 +1432,7 @@ else: st.session_state[device_session_key] = input_device else: # When streaming, get the device from session state - current_device = st.session_state.get(device_session_key, saved_stream.get('input_device', 'No device')) + current_device = st.session_state.get(device_session_key, saved_input_device2 or 'No device') # Convert internal name to display label display_label = current_device @@ -1354,8 +1517,30 @@ else: if audio_mode in ("USB", "Network"): # USB/Network: single set of controls shared with the single channel + # Use saved settings if audio_mode matches, otherwise use defaults + saved_audio_mode = saved_settings.get('audio_mode') + if saved_audio_mode in ("USB", "Network"): + # Map saved sampling rate to quality label + saved_rate = saved_settings.get('auracast_sampling_rate_hz') + if saved_rate == 48000: + default_quality = "High (48kHz)" + elif saved_rate == 32000: + default_quality = "Good (32kHz)" + elif saved_rate == 24000: + default_quality = "Medium (24kHz)" + elif saved_rate == 16000: + default_quality = "Fair (16kHz)" + else: + default_quality = "Medium (24kHz)" + saved_pwd = saved_settings.get('stream_password', '') + else: + # Use defaults when switching from another mode + default_quality = "Medium (24kHz)" if "Medium (24kHz)" in quality_options else quality_options[0] + saved_pwd = '' + quality_options = list(QUALITY_MAP.keys()) - default_quality = "Medium (24kHz)" if "Medium (24kHz)" in quality_options else quality_options[0] + if default_quality not in quality_options: + default_quality = quality_options[0] quality = st.selectbox( "Stream Quality (Sampling Rate)", quality_options, @@ -1366,7 +1551,7 @@ else: stream_passwort = st.text_input( "Stream Passwort", - value="", + value=saved_pwd, type="password", disabled=is_streaming, help="Optional: Set a broadcast code to protect your stream. Leave empty for an open (uncoded) broadcast." @@ -1618,7 +1803,7 @@ if start_stream: analog_gain_db_right=cfg.get('analog_gain_db_right', 0.0), bigs=[ auracast_config.AuracastBigConfig( - code=(cfg['stream_passwort'].strip() or None), + code=((cfg['stream_passwort'] or '').strip() or None), name=cfg['name'], program_info=cfg['program_info'], language=cfg['language'], diff --git a/src/auracast/server/multicast_server.py b/src/auracast/server/multicast_server.py index d230aaa..84c4b6e 100644 --- a/src/auracast/server/multicast_server.py +++ b/src/auracast/server/multicast_server.py @@ -445,6 +445,11 @@ async def init_radio(transport: str, conf: auracast_config.AuracastConfigGroup, first_source = conf.bigs[0].audio_source if conf.bigs else '' input_device_name = None audio_mode_persist = 'Demo' + # Capture original per-BIG device names before transformation + original_input_devices = [ + big.audio_source.split(':', 1)[1] if (isinstance(big.audio_source, str) and big.audio_source.startswith('device:')) else None + for big in conf.bigs + ] if any(isinstance(b.audio_source, str) and b.audio_source.startswith('device:') for b in conf.bigs): if isinstance(first_source, str) and first_source.startswith('device:'): input_device_name = first_source.split(':', 1)[1] if ':' in first_source else None @@ -597,6 +602,7 @@ async def init_radio(transport: str, conf: auracast_config.AuracastConfigGroup, 'languages': [big.language for big in conf.bigs], 'audio_mode': audio_mode_persist, 'input_device': input_device_name, + 'input_devices': original_input_devices, 'program_info': [getattr(big, 'program_info', None) for big in conf.bigs], 'gain': [getattr(big, 'input_gain', 1.0) for big in conf.bigs], 'auracast_sampling_rate_hz': conf.auracast_sampling_rate_hz, -- 2.52.0 From 6375b215cbd34894e1893fbb00d96f43ecc3a121 Mon Sep 17 00:00:00 2001 From: pober Date: Thu, 7 May 2026 15:39:01 +0200 Subject: [PATCH 9/9] Autofocus password box on login screen. --- src/auracast/server/multicast_frontend.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/src/auracast/server/multicast_frontend.py b/src/auracast/server/multicast_frontend.py index 345918d..04def58 100644 --- a/src/auracast/server/multicast_frontend.py +++ b/src/auracast/server/multicast_frontend.py @@ -71,6 +71,10 @@ if not is_pw_disabled(): with st.form("signin_form"): pw = st.text_input("Password", type="password") submitted = st.form_submit_button("Sign in") + st.components.v1.html( + "", + height=0 + ) if submitted: if verify_password(pw, pw_rec): st.session_state['frontend_authenticated'] = True -- 2.52.0