42 Commits

Author SHA1 Message Date
pober 15ec3aaef6 removes host file to make mdns working. 2026-05-18 15:26:18 +02:00
pober de6294837b link local does not crash anymore. On connection the behaviour still needs to be improved. 2026-05-18 11:58:38 +02:00
pober 6375b215cb Autofocus password box on login screen. 2026-05-07 15:39:01 +02:00
pober 25df79eef5 Persists settings on refresh and power cycle. 2026-05-07 15:38:41 +02:00
pober 9c251b7a66 Fixes activation bug by adding the activation folder. 2026-04-28 09:05:39 +02:00
pober c24be9f366 Changed cpu pinning; more headroom for audiopipeline; dep and asrc on core 3 2026-04-28 09:01:58 +02:00
pober c659d632b0 Adds DEP service; gitignore for license; audiopipeline as its own thread with higher prio than http requests. 2026-04-27 15:35:20 +02:00
pober 14827288e7 relaxes asrc to not flip flop bang bang; use pyalsaaudio for dante. 2026-04-27 10:27:54 +02:00
pober 2410b01f15 Fixes loopback startup and makes it persistent with naming. 2026-04-27 10:26:04 +02:00
pober 67c774204a Dante buffers and cpu affinity. 2026-04-24 10:57:43 +02:00
pober c56012134c Fixes that an interface has both a local link and dhcp address - confuses dante. 2026-04-20 15:46:39 +02:00
pober 6d54e72f1d Add new reset mechanism with sleep. 2026-04-10 12:22:18 +02:00
pober df6c85d9ff Add new reset mechanism 2. 2026-04-10 11:58:16 +02:00
pober 8106f61d6a Add new reset mechanism. 2026-04-10 10:57:12 +02:00
pober 0a8dc74d5c Fixes script error in systemupdate. 2026-04-09 15:00:35 +02:00
pober 8475e4d068 New system update logic. 2026-04-09 14:46:17 +02:00
pober 3f01ef5968 Adds openocd with nrf support build to the server update function. Adds 2bad8ad2cd889d8c8d255b8e0dc0e7a187b98c9a hci_uart_beacon commit build hex file to project. (#26)
Co-authored-by: Pbopbo <p.obernesser@freenet.de>
Reviewed-on: #26
2026-04-09 12:04:18 +00:00
pober 67992e65ec Updates poetry lock. 2026-04-09 11:59:30 +02:00
pober 0b12323921 fix/gain-4dbU (#25)
Co-authored-by: Pbopbo <p.obernesser@freenet.de>
Reviewed-on: #25
2026-04-09 09:54:14 +00:00
Pbopbo 6e633d2880 Merge branch 'wip_alsaaudio' TODO poetry lock 2026-04-09 11:51:37 +02:00
pstruebi 7bdf6f8417 feature/blue_led (#23)
Co-authored-by: pstruebi <office@summitwave.eu>
Co-authored-by: pober <paul.obernesser@summitwave.eu>
Co-authored-by: Pbopbo <p.obernesser@freenet.de>
Reviewed-on: #23
Co-authored-by: pstruebi <struebin.patrick@gmail.com>
Co-committed-by: pstruebi <struebin.patrick@gmail.com>
2026-04-07 14:34:11 +00:00
Pbopbo 291d75b137 stereo seems to work, NEEDS RADIO FIRMWARE WITH 2 TX BUFFERS. 2026-04-07 14:36:15 +02:00
Pbopbo a126613739 First working version of two monos at the same time. 2026-04-02 18:56:17 +02:00
pober 036b5f80dd Updates poetry lock. 2026-04-02 18:10:23 +02:00
Pbopbo e818765b4f Adds sw_pyalsaaudio repo so our custom function works. 2026-04-02 17:37:38 +02:00
Pbopbo 3d59a6dabf ASRC: Adds NONBLOCK read from ALSA buffer; controls the amount of frames in the ALSA buffer; Adds resampling to get rid of audio glitches; no latency buildup anymore. 2026-04-01 14:00:26 +02:00
Pbopbo cf69ad2957 134ms constant delay, no build up, seems to be no glitches, bang bang control. 2026-03-30 14:45:25 +02:00
Pbopbo cdfecaf5eb delay method wip save to test no thread method. 2026-03-24 13:14:56 +01:00
pstruebi 4036fee1f5 Randomize Broadcast ID per stream instead of using static values 2026-03-24 12:09:16 +01:00
Pbopbo 1687a2b790 Latency lowered. 2026-03-18 17:37:34 +01:00
Pbopbo a605195646 First good audio with alsaaudio. 2026-03-18 16:55:55 +01:00
pober e1d717ed5c Adds DHCP/static IP toggle for both ports in the UI. 2026-03-03 15:50:19 +01:00
pober 540d8503ac Some corrections for Activates link local for both ports, removes fallback IP. 2026-03-03 15:35:13 +01:00
pober c82f375539 Activates link local for both ports, removes fallback IP. 2026-03-03 15:02:55 +01:00
pstruebi 70bde5295f Fixes mDNS issue; when DHCP IP is present use this for mDNS and not the static fallback IP. 2026-02-16 16:25:59 +01:00
pstruebi f5f93b4b8e analog_input_gain (#21)
- add input boost slider
- add level meter

Reviewed-on: https://gitea.pstruebi.xyz/auracaster/bumble-auracast/pulls/21
2026-02-12 17:09:46 +01:00
pstruebi 3322b9edf4 add 192.168.42.10 as default ip with update script 2026-02-12 17:08:23 +01:00
pstruebi d6230e7522 add software gain boost parameter for input signal amplification 2026-02-12 13:30:07 +01:00
pstruebi f2382470d8 add network information display showing hostname and IP address 2026-02-10 16:51:22 +01:00
pstruebi 7c2f0bf0cb add HTTP to HTTPS redirect server on port 80 2026-02-10 16:37:34 +01:00
pstruebi 184e9c84af impelement a gain slider 2026-01-20 18:00:37 +01:00
pstruebi 6852c74cd0 add a delete recordings button 2026-01-20 17:45:23 +01:00
28 changed files with 14881 additions and 325 deletions
+7
View File
@@ -52,3 +52,10 @@ src/auracast/server/stream_settings2.json
src/scripts/temperature_log* src/scripts/temperature_log*
src/auracast/server/recordings/ src/auracast/server/recordings/
src/auracast/server/led_settings.json
# Dante license files
*.lic
src/dep/dante_package/dante_data/activation/device.lic
src/dep/dante_package/dante_data/activation/manufacturer.cert
+4 -1
View File
@@ -4,8 +4,11 @@
- this projects uses poetry for package management - this projects uses poetry for package management
- if something should be run in a python env use 'poetry run' - if something should be run in a python env use 'poetry run'
# Environment
- this application normally runs on an embedded linux on a cm4
## Application ## Application
- this is a bluetooth Auracast transmitter application - this is a bluetooth Auracast transmitter application
- if you add a new parameter for a stream make sure it is saved to the settings.json so it is persisted
- it consists of multicast_frontend.py and multicast_server.py mainly which connect to each other via a rest api - it consists of multicast_frontend.py and multicast_server.py mainly which connect to each other via a rest api
- after you implemented something the user will mainly test it and you should call the update_and_run_server_and_frontend.sh script if the server and frontend were already running.
Generated
+33 -46
View File
@@ -270,51 +270,6 @@ optional = false
python-versions = ">=3.9" python-versions = ">=3.9"
groups = ["main"] groups = ["main"]
files = [ files = [
{file = "av-14.4.0-cp310-cp310-macosx_12_0_arm64.whl", hash = "sha256:10219620699a65b9829cfa08784da2ed38371f1a223ab8f3523f440a24c8381c"},
{file = "av-14.4.0-cp310-cp310-macosx_12_0_x86_64.whl", hash = "sha256:8bac981fde1c05e231df9f73a06ed9febce1f03fb0f1320707ac2861bba2567f"},
{file = "av-14.4.0-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:cc634ed5bdeb362f0523b73693b079b540418d35d7f3003654f788ae6c317eef"},
{file = "av-14.4.0-cp310-cp310-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:23973ed5c5bec9565094d2b3643f10a6996707ddffa5252e112d578ad34aa9ae"},
{file = "av-14.4.0-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0655f7207db6a211d7cedb8ac6a2f7ccc9c4b62290130e393a3fd99425247311"},
{file = "av-14.4.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:1edaab73319bfefe53ee09c4b1cf7b141ea7e6678a0a1c62f7bac1e2c68ec4e7"},
{file = "av-14.4.0-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:b54838fa17c031ffd780df07b9962fac1be05220f3c28468f7fe49474f1bf8d2"},
{file = "av-14.4.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:f4b59ac6c563b9b6197299944145958a8ec34710799fd851f1a889b0cbcd1059"},
{file = "av-14.4.0-cp310-cp310-win_amd64.whl", hash = "sha256:a0192a584fae9f6cedfac03c06d5bf246517cdf00c8779bc33414404796a526e"},
{file = "av-14.4.0-cp311-cp311-macosx_12_0_arm64.whl", hash = "sha256:5b21d5586a88b9fce0ab78e26bd1c38f8642f8e2aad5b35e619f4d202217c701"},
{file = "av-14.4.0-cp311-cp311-macosx_12_0_x86_64.whl", hash = "sha256:cf8762d90b0f94a20c9f6e25a94f1757db5a256707964dfd0b1d4403e7a16835"},
{file = "av-14.4.0-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c0ac9f08920c7bbe0795319689d901e27cb3d7870b9a0acae3f26fc9daa801a6"},
{file = "av-14.4.0-cp311-cp311-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:a56d9ad2afdb638ec0404e962dc570960aae7e08ae331ad7ff70fbe99a6cf40e"},
{file = "av-14.4.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6bed513cbcb3437d0ae47743edc1f5b4a113c0b66cdd4e1aafc533abf5b2fbf2"},
{file = "av-14.4.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:d030c2d3647931e53d51f2f6e0fcf465263e7acf9ec6e4faa8dbfc77975318c3"},
{file = "av-14.4.0-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:1cc21582a4f606271d8c2036ec7a6247df0831050306c55cf8a905701d0f0474"},
{file = "av-14.4.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:ce7c9cd452153d36f1b1478f904ed5f9ab191d76db873bdd3a597193290805d4"},
{file = "av-14.4.0-cp311-cp311-win_amd64.whl", hash = "sha256:fd261e31cc6b43ca722f80656c39934199d8f2eb391e0147e704b6226acebc29"},
{file = "av-14.4.0-cp312-cp312-macosx_12_0_arm64.whl", hash = "sha256:a53e682b239dd23b4e3bc9568cfb1168fc629ab01925fdb2e7556eb426339e94"},
{file = "av-14.4.0-cp312-cp312-macosx_12_0_x86_64.whl", hash = "sha256:5aa0b901751a32703fa938d2155d56ce3faf3630e4a48d238b35d2f7e49e5395"},
{file = "av-14.4.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a3b316fed3597675fe2aacfed34e25fc9d5bb0196dc8c0b014ae5ed4adda48de"},
{file = "av-14.4.0-cp312-cp312-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:a587b5c5014c3c0e16143a0f8d99874e46b5d0c50db6111aa0b54206b5687c81"},
{file = "av-14.4.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:10d53f75e8ac1ec8877a551c0db32a83c0aaeae719d05285281eaaba211bbc30"},
{file = "av-14.4.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:c8558cfde79dd8fc92d97c70e0f0fa8c94c7a66f68ae73afdf58598f0fe5e10d"},
{file = "av-14.4.0-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:455b6410dea0ab2d30234ffb28df7d62ca3cdf10708528e247bec3a4cdcced09"},
{file = "av-14.4.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:1661efbe9d975f927b8512d654704223d936f39016fad2ddab00aee7c40f412c"},
{file = "av-14.4.0-cp312-cp312-win_amd64.whl", hash = "sha256:fbbeef1f421a3461086853d6464ad5526b56ffe8ccb0ab3fd0a1f121dfbf26ad"},
{file = "av-14.4.0-cp313-cp313-macosx_12_0_arm64.whl", hash = "sha256:3d2aea7c602b105363903e4017103bc4b60336e7aff80e1c22e8b4ec09fd125f"},
{file = "av-14.4.0-cp313-cp313-macosx_12_0_x86_64.whl", hash = "sha256:38c18f036aeb6dc9abf5e867d998c867f9ec93a5f722b60721fdffc123bbb2ae"},
{file = "av-14.4.0-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:58c1e18c8be73b6eada2d9ec397852ec74ebe51938451bdf83644a807189d6c8"},
{file = "av-14.4.0-cp313-cp313-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:e4c32ff03a357feb030634f093089a73cb474b04efe7fbfba31f229cb2fab115"},
{file = "av-14.4.0-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:af31d16ae25964a6a02e09cc132b9decd5ee493c5dcb21bcdf0d71b2d6adbd59"},
{file = "av-14.4.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:e9fb297009e528f4851d25f3bb2781b2db18b59b10aed10240e947b77c582fb7"},
{file = "av-14.4.0-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:573314cb9eafec2827dc98c416c965330dc7508193adbccd281700d8673b9f0a"},
{file = "av-14.4.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:f82ab27ee57c3b80eb50a5293222307dfdc02f810ea41119078cfc85ea3cf9a8"},
{file = "av-14.4.0-cp313-cp313-win_amd64.whl", hash = "sha256:9f682003bbcaac620b52f68ff0e85830fff165dea53949e217483a615993ca20"},
{file = "av-14.4.0-cp39-cp39-macosx_12_0_arm64.whl", hash = "sha256:8ff683777e0bb3601f7cfb4545dca25db92817585330b773e897e1f6f9d612f7"},
{file = "av-14.4.0-cp39-cp39-macosx_12_0_x86_64.whl", hash = "sha256:fe372acf7b1814bc2b16d89161609db63f81dad88684da76d26dd32cd1c16f92"},
{file = "av-14.4.0-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:de869030eb8acfdfe39f39965de3a899dcde9b08df2db41f183c6166ca6f6d09"},
{file = "av-14.4.0-cp39-cp39-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:9117ed91fba6299b7d5233dd3e471770bab829f97e5a157f182761e9fb59254c"},
{file = "av-14.4.0-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:54e8f9209184098b7755e6250be8ffa48a8aa5b554a02555406120583da17373"},
{file = "av-14.4.0-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:38ea51e62a014663caec7f621d6601cf269ef450f3c8705f5e3225e5623fd15d"},
{file = "av-14.4.0-cp39-cp39-musllinux_1_2_i686.whl", hash = "sha256:5d1d89842efe913448482573a253bd6955ce30a77f8a4cd04a1a3537cc919896"},
{file = "av-14.4.0-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:c3048e333da1367a2bca47e69593e10bc70f027d876adee9d1582c8cb818f36a"},
{file = "av-14.4.0-cp39-cp39-win_amd64.whl", hash = "sha256:5d6f25570d0782dd05640c7e1f71cb29857d94d915b5521a1e757ecae78a5a50"},
{file = "av-14.4.0.tar.gz", hash = "sha256:3ecbf803a7fdf67229c0edada0830d6bfaea4d10bfb24f0c3f4e607cd1064b42"}, {file = "av-14.4.0.tar.gz", hash = "sha256:3ecbf803a7fdf67229c0edada0830d6bfaea4d10bfb24f0c3f4e607cd1064b42"},
] ]
@@ -1849,6 +1804,22 @@ files = [
{file = "protobuf-6.30.2.tar.gz", hash = "sha256:35c859ae076d8c56054c25b59e5e59638d86545ed6e2b6efac6be0b6ea3ba048"}, {file = "protobuf-6.30.2.tar.gz", hash = "sha256:35c859ae076d8c56054c25b59e5e59638d86545ed6e2b6efac6be0b6ea3ba048"},
] ]
[[package]]
name = "pyalsaaudio"
version = "0.11.0"
description = "ALSA bindings"
optional = false
python-versions = "*"
groups = ["main"]
files = []
develop = false
[package.source]
type = "git"
url = "ssh://git@gitea.summitwave.work:222/auracaster/sw_pyalsaaudio.git"
reference = "b3d11582e03df6929b2e7acbaa1306afc7b8a6bc"
resolved_reference = "b3d11582e03df6929b2e7acbaa1306afc7b8a6bc"
[[package]] [[package]]
name = "pyarrow" name = "pyarrow"
version = "20.0.0" version = "20.0.0"
@@ -2443,6 +2414,22 @@ files = [
{file = "rpds_py-0.25.1.tar.gz", hash = "sha256:8960b6dac09b62dac26e75d7e2c4a22efb835d827a7278c34f72b2b84fa160e3"}, {file = "rpds_py-0.25.1.tar.gz", hash = "sha256:8960b6dac09b62dac26e75d7e2c4a22efb835d827a7278c34f72b2b84fa160e3"},
] ]
[[package]]
name = "rpi-gpio"
version = "0.7.1"
description = "A module to control Raspberry Pi GPIO channels"
optional = false
python-versions = "*"
groups = ["main"]
files = [
{file = "RPi.GPIO-0.7.1-cp27-cp27mu-linux_armv6l.whl", hash = "sha256:b86b66dc02faa5461b443a1e1f0c1d209d64ab5229696f32fb3b0215e0600c8c"},
{file = "RPi.GPIO-0.7.1-cp310-cp310-linux_armv6l.whl", hash = "sha256:57b6c044ef5375a78c8dda27cdfadf329e76aa6943cd6cffbbbd345a9adf9ca5"},
{file = "RPi.GPIO-0.7.1-cp37-cp37m-linux_armv6l.whl", hash = "sha256:77afb817b81331ce3049a4b8f94a85e41b7c404d8e56b61ac0f1eb75c3120868"},
{file = "RPi.GPIO-0.7.1-cp38-cp38-linux_armv6l.whl", hash = "sha256:29226823da8b5ccb9001d795a944f2e00924eeae583490f0bc7317581172c624"},
{file = "RPi.GPIO-0.7.1-cp39-cp39-linux_armv6l.whl", hash = "sha256:15311d3b063b71dee738cd26570effc9985a952454d162937c34e08c0fc99902"},
{file = "RPi.GPIO-0.7.1.tar.gz", hash = "sha256:cd61c4b03c37b62bba4a5acfea9862749c33c618e0295e7e90aa4713fb373b70"},
]
[[package]] [[package]]
name = "samplerate" name = "samplerate"
version = "0.2.2" version = "0.2.2"
@@ -2976,4 +2963,4 @@ test = ["pytest", "pytest-asyncio"]
[metadata] [metadata]
lock-version = "2.1" lock-version = "2.1"
python-versions = ">=3.11" python-versions = ">=3.11"
content-hash = "3c9f92c7a5af40f98da9c7824d9c2a6f7eb809e91e43cfef4995761b2e887256" content-hash = "7bccf2978170ead195e1e8cff151823a5276823195a239622186fcec830154d9"
+3 -1
View File
@@ -17,7 +17,9 @@ dependencies = [
"sounddevice (>=0.5.2,<0.6.0)", "sounddevice (>=0.5.2,<0.6.0)",
"python-dotenv (>=1.1.1,<2.0.0)", "python-dotenv (>=1.1.1,<2.0.0)",
"smbus2 (>=0.5.0,<0.6.0)", "smbus2 (>=0.5.0,<0.6.0)",
"samplerate (>=0.2.2,<0.3.0)" "samplerate (>=0.2.2,<0.3.0)",
"rpi-gpio (>=0.7.1,<0.8.0)",
"pyalsaaudio @ git+ssh://git@gitea.summitwave.work:222/auracaster/sw_pyalsaaudio.git@b3d11582e03df6929b2e7acbaa1306afc7b8a6bc"
] ]
[project.optional-dependencies] [project.optional-dependencies]
+2
View File
@@ -111,3 +111,5 @@ class AuracastConfigGroup(AuracastGlobalConfig):
bigs: List[AuracastBigConfig] = [ bigs: List[AuracastBigConfig] = [
AuracastBigConfigDeu(), AuracastBigConfigDeu(),
] ]
analog_gain_db_left: float = 0.0 # ADC gain level for analog mode left channel (-12 to 18 dB)
analog_gain_db_right: float = 0.0 # ADC gain level for analog mode right channel (-12 to 18 dB)
+136 -70
View File
@@ -30,6 +30,7 @@ import time
import threading import threading
import numpy as np # for audio down-mix import numpy as np # for audio down-mix
import samplerate
import os import os
import lc3 # type: ignore # pylint: disable=E0401 import lc3 # type: ignore # pylint: disable=E0401
@@ -56,7 +57,7 @@ from auracast.utils.webrtc_audio_input import WebRTCAudioInput
# Patch sounddevice.InputStream globally to use low-latency settings # Patch sounddevice.InputStream globally to use low-latency settings
import sounddevice as sd import alsaaudio
from collections import deque from collections import deque
@@ -139,96 +140,146 @@ class AlsaArecordAudioInput(audio_io.AudioInput):
self._proc = None self._proc = None
class ModSoundDeviceAudioInput(audio_io.SoundDeviceAudioInput): class PyAlsaAudioInput(audio_io.ThreadedAudioInput):
"""Patched SoundDeviceAudioInput with low-latency capture and adaptive resampling.""" """PyALSA audio input with non-blocking reads - supports mono/stereo."""
def _open(self): def __init__(self, device, pcm_format: audio_io.PcmFormat):
"""Create RawInputStream with low-latency parameters and initialize ring buffer.""" super().__init__()
dev_info = sd.query_devices(self._device) logging.info("PyALSA: device = %s", device)
hostapis = sd.query_hostapis() self._device = str(device) if not isinstance(device, str) else device
api_index = dev_info.get('hostapi') if self._device.isdigit():
api_name = hostapis[api_index]['name'] if isinstance(api_index, int) and 0 <= api_index < len(hostapis) else 'unknown' self._device = 'default' if self._device == '0' else f'hw:{self._device}'
pa_ver = sd.get_portaudio_version() self._pcm_format = pcm_format
self._pcm = None
self._actual_channels = None
self._periodsize = None
self._hw_channels = None
self._first_read = True
self._resampler = None
self._resampler_buffer = np.empty(0, dtype=np.float32)
logging.info( def _open(self) -> audio_io.PcmFormat:
"SoundDevice backend=%s device='%s' (id=%s) ch=%s default_low_input_latency=%.4f default_high_input_latency=%.4f portaudio=%s", ALSA_PERIODSIZE = 240
api_name, ALSA_PERIODS = 4
dev_info.get('name'), ALSA_MODE = alsaaudio.PCM_NONBLOCK
self._device,
dev_info.get('max_input_channels'),
float(dev_info.get('default_low_input_latency') or 0.0),
float(dev_info.get('default_high_input_latency') or 0.0),
pa_ver[1] if isinstance(pa_ver, tuple) and len(pa_ver) >= 2 else pa_ver,
)
# Create RawInputStream with injected low-latency parameters
# Target ~2 ms blocksize (48 kHz -> 96 frames). For other rates, keep ~2 ms.
_sr = int(self._pcm_format.sample_rate)
self.counter=0 requested_rate = int(self._pcm_format.sample_rate)
self.max_avail=0 requested_channels = int(self._pcm_format.channels)
self.logfile_name="available_samples.txt" self._periodsize = ALSA_PERIODSIZE
self.blocksize = 120
if os.path.exists(self.logfile_name): self._pcm = alsaaudio.PCM(
os.remove(self.logfile_name) type=alsaaudio.PCM_CAPTURE,
mode=ALSA_MODE,
self._stream = sd.RawInputStream(
samplerate=self._pcm_format.sample_rate,
device=self._device, device=self._device,
channels=self._pcm_format.channels, periods=ALSA_PERIODS,
dtype='int16',
blocksize=self.blocksize,
latency=0.004,
) )
self._stream.start()
self._pcm.setchannels(requested_channels)
self._pcm.setformat(alsaaudio.PCM_FORMAT_S16_LE)
actual_rate = self._pcm.setrate(requested_rate)
self._pcm.setperiodsize(ALSA_PERIODSIZE)
logging.info("PyALSA: device=%s rate=%d ch=%d periodsize=%d (%.1fms) periods=%d mode=%s",
self._device, actual_rate, requested_channels, ALSA_PERIODSIZE,
(ALSA_PERIODSIZE / actual_rate) * 1000, ALSA_PERIODS, ALSA_MODE)
if actual_rate != requested_rate:
logging.warning("PyALSA: Sample rate mismatch! requested=%d actual=%d", requested_rate, actual_rate)
self._actual_channels = requested_channels
self._resampler = samplerate.Resampler('sinc_fastest', channels=requested_channels)
self._resampler_buffer = np.empty(0, dtype=np.float32)
self._bang_bang = 0
return audio_io.PcmFormat( return audio_io.PcmFormat(
audio_io.PcmFormat.Endianness.LITTLE, audio_io.PcmFormat.Endianness.LITTLE,
audio_io.PcmFormat.SampleType.INT16, audio_io.PcmFormat.SampleType.INT16,
self._pcm_format.sample_rate, actual_rate,
1, requested_channels,
) )
def _read(self, frame_size: int) -> bytes: def _read(self, frame_size: int) -> bytes:
"""Read PCM samples from the stream.""" try:
avail = self._pcm.avail()
logging.debug("PyALSA: avail before read: %d", avail)
length, data = self._pcm.read_sw(frame_size + self._bang_bang)
avail = self._pcm.avail()
SETPOINT = 120
TOLERANCE = 80
if avail < SETPOINT - TOLERANCE:
self._bang_bang = -1
elif avail > SETPOINT + TOLERANCE:
self._bang_bang = 1
else:
self._bang_bang = 0
#if self.counter % 50 == 0:
frame_size = frame_size + 1 # consume samples a little faster to avoid latency akkumulation
pcm_buffer, overflowed = self._stream.read(frame_size) logging.debug("PyALSA: read length=%d, data length=%d, avail=%d, bang_bang=%d", length, len(data), avail, self._bang_bang)
if overflowed:
logging.warning("SoundDeviceAudioInput: overflowed")
n_available = self._stream.read_available if length > 0:
if self._first_read:
expected_mono = self._periodsize * 2
expected_stereo = self._periodsize * 2 * 2
# self._hw_channels = 2 if len(data) == expected_stereo else 1
self._hw_channels = self._actual_channels
logging.info("PyALSA first read: bytes=%d detected_hw_channels=%d requested_channels=%d",
len(data), self._hw_channels, self._actual_channels)
self._first_read = False
# adapt = n_available > 20 if self._hw_channels == 2 and self._actual_channels == 1:
# if adapt: pcm_stereo = np.frombuffer(data, dtype=np.int16)
# pcm_extra, overflowed = self._stream.read(3) pcm_mono = pcm_stereo[::2]
# logging.info('consuming extra samples, available was %d', n_available) data = pcm_mono.tobytes()
# if overflowed:
# logging.warning("SoundDeviceAudioInput: overflowed")
# out = bytes(pcm_buffer) + bytes(pcm_extra) actual_samples = len(data) // (2 * self._actual_channels)
# else: ratio = frame_size / actual_samples
out = bytes(pcm_buffer) pcm_f32 = np.frombuffer(data, dtype=np.int16).astype(np.float32) / 32768.0
if self._actual_channels > 1:
pcm_f32 = pcm_f32.reshape(-1, self._actual_channels)
resampled = self._resampler.process(pcm_f32, ratio, end_of_input=False)
if self._actual_channels > 1:
resampled = resampled.reshape(-1)
self._resampler_buffer = np.concatenate([self._resampler_buffer, resampled])
else:
logging.warning("PyALSA: No data read from ALSA")
self._resampler_buffer = np.concatenate([
self._resampler_buffer,
np.zeros(frame_size * self._actual_channels, dtype=np.float32),
])
except alsaaudio.ALSAAudioError as e:
logging.error("PyALSA: ALSA read error: %s", e)
self._resampler_buffer = np.concatenate([
self._resampler_buffer,
np.zeros(frame_size * self._actual_channels, dtype=np.float32),
])
except Exception as e:
logging.error("PyALSA: Unexpected error in _read: %s", e, exc_info=True)
self._resampler_buffer = np.concatenate([
self._resampler_buffer,
np.zeros(frame_size * self._actual_channels, dtype=np.float32),
])
self.max_avail = max(self.max_avail, n_available) needed = frame_size * self._actual_channels
if len(self._resampler_buffer) < needed:
pad = np.zeros(needed - len(self._resampler_buffer), dtype=np.float32)
self._resampler_buffer = np.concatenate([self._resampler_buffer, pad])
logging.debug("PyALSA: padded buffer with %d samples", needed - len(self._resampler_buffer))
#Diagnostics output = self._resampler_buffer[:needed]
#with open(self.logfile_name, "a", encoding="utf-8") as f: self._resampler_buffer = self._resampler_buffer[needed:]
# f.write(f"{n_available}, {adapt}, {round(self._runavg, 2)}, {overflowed}\n")
if self.counter % 500 == 0: logging.debug("PyALSA: resampler_buffer remaining=%d", len(self._resampler_buffer))
logging.info( return np.clip(output * 32767.0, -32768, 32767).astype(np.int16).tobytes()
"read available=%d, max=%d, latency:%d",
n_available, self.max_avail, self._stream.latency
)
self.max_avail = 0
self.counter += 1
return out
audio_io.SoundDeviceAudioInput = ModSoundDeviceAudioInput
def _close(self) -> None:
if self._pcm:
self._pcm.close()
self._pcm = None
audio_io.SoundDeviceAudioInput = PyAlsaAudioInput
# modified from bumble # modified from bumble
class ModWaveAudioInput(audio_io.ThreadedAudioInput): class ModWaveAudioInput(audio_io.ThreadedAudioInput):
@@ -538,7 +589,7 @@ async def init_broadcast(
def on_flow(): def on_flow():
data_packet_queue = iso_queue.data_packet_queue data_packet_queue = iso_queue.data_packet_queue
print( logging.info(
f'\rPACKETS: pending={data_packet_queue.pending}, ' f'\rPACKETS: pending={data_packet_queue.pending}, '
f'queued={data_packet_queue.queued}, ' f'queued={data_packet_queue.queued}, '
f'completed={data_packet_queue.completed}', f'completed={data_packet_queue.completed}',
@@ -638,6 +689,12 @@ class Streamer():
except Exception: except Exception:
pass pass
def get_audio_levels(self) -> list[float]:
"""Return current RMS audio levels (0.0-1.0) for each BIG."""
if not self.bigs:
return []
return [big.get('_audio_level_rms', 0.0) for big in self.bigs.values()]
async def stream(self): async def stream(self):
bigs = self.bigs bigs = self.bigs
@@ -754,7 +811,11 @@ class Streamer():
if input_format == 'auto': if input_format == 'auto':
raise ValueError('input format details required for alsa input') raise ValueError('input format details required for alsa input')
pcm = audio_io.PcmFormat.from_str(input_format) 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: else:
audio_input = await audio_io.create_audio_input(audio_source, input_format) audio_input = await audio_io.create_audio_input(audio_source, input_format)
# Store early so stop_streaming can close even if open() fails # Store early so stop_streaming can close even if open() fails
@@ -852,6 +913,11 @@ class Streamer():
stream_finished[i] = True stream_finished[i] = True
continue continue
# Compute RMS audio level (normalized 0.0-1.0) for level monitoring
pcm_samples = np.frombuffer(pcm_frame, dtype=np.int16).astype(np.float32)
rms = np.sqrt(np.mean(pcm_samples ** 2)) / 32768.0 if len(pcm_samples) > 0 else 0.0
big['_audio_level_rms'] = float(rms)
# Measure LC3 encoding time # Measure LC3 encoding time
t1 = time.perf_counter() t1 = time.perf_counter()
num_bis = big.get('num_bis', 1) num_bis = big.get('num_bis', 1)
+10
View File
@@ -38,6 +38,12 @@ class Multicaster:
'is_streaming': streaming, 'is_streaming': streaming,
} }
def get_audio_levels(self) -> list[float]:
"""Return current RMS audio levels (0.0-1.0) for each BIG."""
if self.streamer is not None and self.streamer.is_streaming:
return self.streamer.get_audio_levels()
return []
async def init_broadcast(self): async def init_broadcast(self):
self.device_acm = multicast.create_device(self.global_conf) self.device_acm = multicast.create_device(self.global_conf)
@@ -137,6 +143,10 @@ async def main():
level=os.environ.get('LOG_LEVEL', logging.DEBUG), level=os.environ.get('LOG_LEVEL', logging.DEBUG),
format='%(module)s.py:%(lineno)d %(levelname)s: %(message)s' format='%(module)s.py:%(lineno)d %(levelname)s: %(message)s'
) )
# Enable debug logging for bumble
# logging.getLogger('bumble').setLevel(logging.DEBUG)
os.chdir(os.path.dirname(__file__)) os.chdir(os.path.dirname(__file__))
global_conf = auracast_config.AuracastGlobalConfig( global_conf = auracast_config.AuracastGlobalConfig(
@@ -0,0 +1,42 @@
"""Minimal HTTP server that redirects all requests to HTTPS (port 443).
Run on port 80 alongside the HTTPS Streamlit frontend so that users who
type a bare IP address into their browser are automatically forwarded.
"""
import http.server
import sys
class RedirectHandler(http.server.BaseHTTPRequestHandler):
def do_GET(self):
host = self.headers.get("Host", "").split(":")[0] or self.server.server_address[0]
target = f"https://{host}{self.path}"
self.send_response(301)
self.send_header("Location", target)
self.end_headers()
# Handle every method the same way
do_POST = do_GET
do_PUT = do_GET
do_DELETE = do_GET
do_HEAD = do_GET
def log_message(self, format, *args):
# Keep logging minimal
sys.stderr.write(f"[http-redirect] {self.address_string()} -> https {args[0] if args else ''}\n")
def main():
port = int(sys.argv[1]) if len(sys.argv) > 1 else 80
server = http.server.HTTPServer(("0.0.0.0", port), RedirectHandler)
print(f"HTTP->HTTPS redirect server listening on 0.0.0.0:{port}")
try:
server.serve_forever()
except KeyboardInterrupt:
pass
server.server_close()
if __name__ == "__main__":
main()
+513 -64
View File
@@ -1,6 +1,7 @@
# frontend/app.py # frontend/app.py
import os import os
import time import time
import math
import logging as log import logging as log
from PIL import Image from PIL import Image
@@ -70,6 +71,10 @@ if not is_pw_disabled():
with st.form("signin_form"): with st.form("signin_form"):
pw = st.text_input("Password", type="password") pw = st.text_input("Password", type="password")
submitted = st.form_submit_button("Sign in") submitted = st.form_submit_button("Sign in")
st.components.v1.html(
"<script>setTimeout(()=>window.parent.document.querySelector('input[type=\"password\"]')?.focus(),100)</script>",
height=0
)
if submitted: if submitted:
if verify_password(pw, pw_rec): if verify_password(pw, pw_rec):
st.session_state['frontend_authenticated'] = True st.session_state['frontend_authenticated'] = True
@@ -196,6 +201,116 @@ if audio_mode == "Demo":
else: else:
start_stream, stop_stream = render_stream_controls(is_streaming, "Start Auracast", "Stop Auracast", running_mode, secondary_is_streaming) start_stream, stop_stream = render_stream_controls(is_streaming, "Start Auracast", "Stop Auracast", running_mode, secondary_is_streaming)
# Analog gain control (only for Analog mode, placed below start button)
analog_gain_db_left = 0 # default (dB)
analog_gain_db_right = 0 # default (dB)
if audio_mode == "Analog":
if '_analog_gain_db_left' not in st.session_state:
st.session_state['_analog_gain_db_left'] = max(-12, min(18, int(saved_settings.get('analog_gain_db_left', 0))))
if '_analog_gain_db_right' not in st.session_state:
st.session_state['_analog_gain_db_right'] = max(-12, min(18, int(saved_settings.get('analog_gain_db_right', 0))))
if '_gain_link_channels' not in st.session_state:
st.session_state['_gain_link_channels'] = True
link_channels = st.checkbox(
"Link audio channel gain",
key='_gain_link_channels',
help="When enabled, Ch 2 mirrors Ch 1."
)
_gain_col1, _gain_col2 = st.columns(2)
with _gain_col1:
analog_gain_db_left = st.slider(
"Ch 1 Input Gain",
min_value=-12,
max_value=18,
key='_analog_gain_db_left',
step=1,
format="%d dB",
help="ADC gain for channel 1 (-12 to 18 dB). Default is 0 dB."
)
with _gain_col2:
if link_channels:
st.session_state['_analog_gain_db_right'] = analog_gain_db_left
elif st.session_state.get('_prev_gain_link_channels', True):
# Transition: just unlinked — seed Ch 2 from Ch 1 so it doesn't jump to min
st.session_state['_analog_gain_db_right'] = analog_gain_db_left
st.session_state['_prev_gain_link_channels'] = link_channels
analog_gain_db_right = st.slider(
"Ch 2 Input Gain",
min_value=-12,
max_value=18,
key='_analog_gain_db_right',
step=1,
format="%d dB",
disabled=link_channels,
help="Uncheck 'Link audio channel gain' to adjust Ch 2 independently." if link_channels else "ADC gain for channel 2 (-12 to 18 dB). Default is 0 dB."
)
# Apply gain live while streaming whenever either slider value changes
if is_streaming:
prev_left = st.session_state.get('_prev_analog_gain_db_left')
prev_right = st.session_state.get('_prev_analog_gain_db_right')
if prev_left != analog_gain_db_left or prev_right != analog_gain_db_right:
try:
requests.post(
f"{BACKEND_URL}/adc_gain",
json={"gain_db_left": analog_gain_db_left, "gain_db_right": analog_gain_db_right},
timeout=1,
)
except Exception:
pass
st.session_state['_prev_analog_gain_db_left'] = analog_gain_db_left
st.session_state['_prev_analog_gain_db_right'] = analog_gain_db_right
# Audio level monitor (checkbox, not persisted across reloads)
show_level_monitor = st.checkbox("Audio level monitor", value=False, disabled=not is_streaming,
help="Show real-time audio level meters for active radios. Only works while streaming.")
if show_level_monitor and is_streaming:
@st.fragment(run_every=0.2)
def _audio_level_fragment():
cols = st.columns(2)
# Radio 1
with cols[0]:
try:
r = requests.get(f"{BACKEND_URL}/audio_level", timeout=0.2)
levels = r.json().get("levels", []) if r.ok else []
except Exception:
levels = []
if levels:
rms = max(levels)
db = max(-60.0, 20.0 * (math.log10(rms) if rms > 0 else -3.0))
pct = int(max(0, min(100, (db + 60) * 100 / 60)))
st.markdown(
f"**Radio 1**"
f'<div style="background:#333;border-radius:4px;height:18px;width:100%;margin-top:4px;">'
f'<div style="background:#2ecc71;height:100%;width:{pct}%;border-radius:4px;transition:width 0.15s;"></div>'
f'</div>',
unsafe_allow_html=True,
)
else:
st.markdown("**Radio 1** &nbsp; --")
# Radio 2
with cols[1]:
try:
r2 = requests.get(f"{BACKEND_URL}/audio_level2", timeout=0.2)
levels2 = r2.json().get("levels", []) if r2.ok else []
except Exception:
levels2 = []
if levels2:
rms2 = max(levels2)
db2 = max(-60.0, 20.0 * (math.log10(rms2) if rms2 > 0 else -3.0))
pct2 = int(max(0, min(100, (db2 + 60) * 100 / 60)))
st.markdown(
f"**Radio 2**"
f'<div style="background:#333;border-radius:4px;height:18px;width:100%;margin-top:4px;">'
f'<div style="background:#2ecc71;height:100%;width:{pct2}%;border-radius:4px;transition:width 0.15s;"></div>'
f'</div>',
unsafe_allow_html=True,
)
else:
st.markdown("**Radio 2** &nbsp; --")
_audio_level_fragment()
# Placeholder for validation errors (will be filled in later) # Placeholder for validation errors (will be filled in later)
validation_error_placeholder = st.empty() validation_error_placeholder = st.empty()
@@ -322,13 +437,43 @@ else:
disabled=is_streaming 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()) 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( quality1 = st.selectbox(
"Stream Quality (Radio 1)", "Stream Quality (Radio 1)",
quality_options, quality_options,
@@ -339,7 +484,7 @@ else:
stream_passwort1 = st.text_input( stream_passwort1 = st.text_input(
"Stream Passwort (Radio 1)", "Stream Passwort (Radio 1)",
value="", value=saved_pwd,
type="password", type="password",
disabled=is_streaming, disabled=is_streaming,
help="Optional: Set a broadcast code for Radio 1." help="Optional: Set a broadcast code for Radio 1."
@@ -446,7 +591,10 @@ else:
input_device1 = None input_device1 = None
else: else:
# Mono mode: show all available channels # Mono mode: show all available channels
saved_input_device = saved_settings.get('input_device')
default_r1_idx = 0 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_device1 = st.selectbox(
"Input Device (Radio 1)", "Input Device (Radio 1)",
analog_names, analog_names,
@@ -495,22 +643,53 @@ else:
) )
if radio2_enabled and not stereo_enabled: if radio2_enabled and not stereo_enabled:
# Use analog-specific defaults for Radio 2 # Use saved settings if audio_mode matches, otherwise use analog-specific defaults for Radio 2
default_name_r2 = "Analog_Radio_2" secondary_settings = saved_settings.get('secondary', {})
default_program_info_r2 = "Analog Radio Broadcast" saved_audio_mode = saved_settings.get('audio_mode')
default_lang_r2 = "deu" 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( quality2 = st.selectbox(
"Stream Quality (Radio 2)", "Stream Quality (Radio 2)",
quality_options, quality_options,
index=quality_options.index(default_quality), index=quality_options.index(default_quality_r2),
disabled=is_streaming, disabled=is_streaming,
help="Select the audio sampling rate for Radio 2." help="Select the audio sampling rate for Radio 2."
) )
stream_passwort2 = st.text_input( stream_passwort2 = st.text_input(
"Stream Passwort (Radio 2)", "Stream Passwort (Radio 2)",
value="", value=saved_pwd_r2,
type="password", type="password",
disabled=is_streaming, disabled=is_streaming,
help="Optional: Set a broadcast code for Radio 2." help="Optional: Set a broadcast code for Radio 2."
@@ -571,7 +750,11 @@ else:
if not is_streaming: if not is_streaming:
if analog_names: 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 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_device2 = st.selectbox(
"Input Device (Radio 2)", "Input Device (Radio 2)",
analog_names, analog_names,
@@ -602,6 +785,8 @@ else:
'immediate_rendering': immediate_rendering2, 'immediate_rendering': immediate_rendering2,
'presentation_delay_ms': presentation_delay_ms2, 'presentation_delay_ms': presentation_delay_ms2,
'qos_preset': qos_preset2, 'qos_preset': qos_preset2,
'analog_gain_db_left': analog_gain_db_left,
'analog_gain_db_right': analog_gain_db_right,
} }
radio1_cfg = { radio1_cfg = {
@@ -616,7 +801,9 @@ else:
'immediate_rendering': immediate_rendering1, 'immediate_rendering': immediate_rendering1,
'presentation_delay_ms': presentation_delay_ms1, 'presentation_delay_ms': presentation_delay_ms1,
'qos_preset': qos_preset1, 'qos_preset': qos_preset1,
'stereo_mode': stereo_enabled, # Add stereo mode setting 'stereo_mode': stereo_enabled,
'analog_gain_db_left': analog_gain_db_left,
'analog_gain_db_right': analog_gain_db_right,
} }
if audio_mode == "Network - Dante": if audio_mode == "Network - Dante":
@@ -659,10 +846,15 @@ else:
) )
# Dante stereo mode toggle # 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( dante_stereo_enabled = st.checkbox(
"🎧 Stereo Mode", "🎧 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.", 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 disabled=is_streaming
) )
@@ -675,9 +867,19 @@ else:
"dante_asrc_ch4", "dante_asrc_ch5", "dante_asrc_ch6"] "dante_asrc_ch4", "dante_asrc_ch5", "dante_asrc_ch6"]
dante_channel_labels = ["CH1", "CH2", "CH3", "CH4", "CH5", "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_<left>_<right>
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) col_left, col_right = st.columns(2)
with col_left: 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 left_idx = dante_channel_options.index(saved_left) if saved_left in dante_channel_options else 0
dante_left_channel = st.selectbox( dante_left_channel = st.selectbox(
"Left Channel", "Left Channel",
@@ -688,7 +890,6 @@ else:
help="Select the Dante ASRC channel for the left stereo channel" help="Select the Dante ASRC channel for the left stereo channel"
) )
with col_right: 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 right_idx = dante_channel_options.index(saved_right) if saved_right in dante_channel_options else 1
dante_right_channel = st.selectbox( dante_right_channel = st.selectbox(
"Right Channel", "Right Channel",
@@ -706,7 +907,22 @@ else:
# Stream count dropdown for Radio 1 (disabled in stereo mode - forced to 1 stream at 48kHz) # Stream count dropdown for Radio 1 (disabled in stereo mode - forced to 1 stream at 48kHz)
r1_stream_options = list(dante_stream_options.keys()) 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 default_r1_idx = r1_stream_options.index(saved_r1_streams) if saved_r1_streams in r1_stream_options else 0
if dante_stereo_enabled: if dante_stereo_enabled:
@@ -741,7 +957,17 @@ else:
(r1_max_quality == "Fair (16kHz)" and quality == "Fair (16kHz)")): (r1_max_quality == "Fair (16kHz)" and quality == "Fair (16kHz)")):
r1_available_qualities.append(quality) 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: if saved_r1_quality not in r1_available_qualities:
saved_r1_quality = r1_max_quality saved_r1_quality = r1_max_quality
@@ -760,7 +986,7 @@ else:
with col_r1_flags1: with col_r1_flags1:
r1_assisted_listening = st.checkbox( r1_assisted_listening = st.checkbox(
"Assistive (R1)", "Assistive (R1)",
value=bool(saved_r1_config.get('assisted_listening', False)), value=bool(saved_settings.get('assisted_listening_stream', False)),
disabled=is_streaming, disabled=is_streaming,
help="Assistive listening stream" help="Assistive listening stream"
) )
@@ -768,13 +994,13 @@ else:
with col_r1_flags2: with col_r1_flags2:
r1_immediate_rendering = st.checkbox( r1_immediate_rendering = st.checkbox(
"Immediate (R1)", "Immediate (R1)",
value=bool(saved_r1_config.get('immediate_rendering', False)), value=bool(saved_settings.get('immediate_rendering', False)),
disabled=is_streaming, disabled=is_streaming,
help="Ignore presentation delay" help="Ignore presentation delay"
) )
with col_r1_pdelay: 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)) default_pdelay_ms = max(10, min(200, default_pdelay // 1000))
r1_presentation_delay_ms = st.number_input( r1_presentation_delay_ms = st.number_input(
"Delay (ms, R1)", "Delay (ms, R1)",
@@ -785,7 +1011,7 @@ else:
with col_r1_qos: with col_r1_qos:
qos_options = list(QOS_PRESET_MAP.keys()) 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 default_qos_idx = qos_options.index(saved_qos) if saved_qos in qos_options else 0
r1_qos_preset = st.selectbox( r1_qos_preset = st.selectbox(
"QoS (R1)", options=qos_options, index=default_qos_idx, "QoS (R1)", options=qos_options, index=default_qos_idx,
@@ -803,8 +1029,15 @@ else:
if dante_stereo_enabled: if dante_stereo_enabled:
# Stereo mode: single stream with combined L+R channels # Stereo mode: single stream with combined L+R channels
with st.expander("Stereo Stream - Radio 1", expanded=True): with st.expander("Stereo Stream - Radio 1", expanded=True):
saved_streams = saved_r1_config.get('streams', []) # Read from flat settings structure
saved_stream = saved_streams[0] if saved_streams else {} 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 # First row: Channel name and password
col_name, col_pwd = st.columns([2, 1]) col_name, col_pwd = st.columns([2, 1])
@@ -812,7 +1045,7 @@ else:
with col_name: with col_name:
stream_name = st.text_input( stream_name = st.text_input(
"Channel Name", "Channel Name",
value=saved_stream.get('name', 'Dante_Stereo'), value=saved_name,
disabled=is_streaming, disabled=is_streaming,
key="r1_stereo_name" key="r1_stereo_name"
) )
@@ -820,7 +1053,7 @@ else:
with col_pwd: with col_pwd:
stream_password = st.text_input( stream_password = st.text_input(
"Stream Password", "Stream Password",
value=saved_stream.get('stream_password', ''), value=saved_password,
type="password", type="password",
disabled=is_streaming, disabled=is_streaming,
key="r1_stereo_password", key="r1_stereo_password",
@@ -833,7 +1066,7 @@ else:
with col_prog: with col_prog:
program_info = st.text_input( program_info = st.text_input(
"Program Info", "Program Info",
value=saved_stream.get('program_info', 'Dante Stereo Broadcast'), value=saved_program_info,
disabled=is_streaming, disabled=is_streaming,
key="r1_stereo_program" key="r1_stereo_program"
) )
@@ -841,7 +1074,7 @@ else:
with col_lang_code: with col_lang_code:
language = st.text_input( language = st.text_input(
"Language", "Language",
value=saved_stream.get('language', 'eng'), value=saved_language,
disabled=is_streaming, disabled=is_streaming,
key="r1_stereo_lang", key="r1_stereo_lang",
help="ISO 639-3 language code" help="ISO 639-3 language code"
@@ -875,10 +1108,21 @@ else:
}) })
else: else:
# Normal mono mode: multiple streams with individual channels # 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): for i in range(r1_num_streams):
with st.expander(f"Stream {i+1} - Radio 1", expanded=True): with st.expander(f"Stream {i+1} - Radio 1", expanded=True):
saved_streams = saved_r1_config.get('streams', []) # Get saved values from flat structure
saved_stream = saved_streams[i] if i < len(saved_streams) else {} 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 # First row: Channel name and language
col_name, col_lang = st.columns([2, 1]) col_name, col_lang = st.columns([2, 1])
@@ -886,7 +1130,7 @@ else:
with col_name: with col_name:
stream_name = st.text_input( stream_name = st.text_input(
f"Channel Name", f"Channel Name",
value=saved_stream.get('name', f'Dante_R1_S{i+1}'), value=saved_name,
disabled=is_streaming, disabled=is_streaming,
key=f"r1_stream_{i}_name" key=f"r1_stream_{i}_name"
) )
@@ -894,7 +1138,7 @@ else:
with col_lang: with col_lang:
stream_password = st.text_input( stream_password = st.text_input(
f"Stream Password", f"Stream Password",
value=saved_stream.get('stream_password', ''), value=saved_password,
type="password", type="password",
disabled=is_streaming, disabled=is_streaming,
key=f"r1_stream_{i}_password", key=f"r1_stream_{i}_password",
@@ -907,7 +1151,7 @@ else:
with col_prog: with col_prog:
program_info = st.text_input( program_info = st.text_input(
f"Program Info", f"Program Info",
value=saved_stream.get('program_info', f'Dante Radio 1 Stream {i+1}'), value=saved_program_info,
disabled=is_streaming, disabled=is_streaming,
key=f"r1_stream_{i}_program" key=f"r1_stream_{i}_program"
) )
@@ -915,7 +1159,7 @@ else:
with col_lang_code: with col_lang_code:
language = st.text_input( language = st.text_input(
f"Language", f"Language",
value=saved_stream.get('language', 'eng'), value=saved_language,
disabled=is_streaming, disabled=is_streaming,
key=f"r1_stream_{i}_lang", key=f"r1_stream_{i}_lang",
help="ISO 639-3 language code" help="ISO 639-3 language code"
@@ -930,7 +1174,7 @@ else:
if not is_streaming and input_options: if not is_streaming and input_options:
# Get default from session state first, then from saved settings # 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 default_input_label = None
for label, name in option_name_map.items(): for label, name in option_name_map.items():
if name == default_input_name: if name == default_input_name:
@@ -951,7 +1195,7 @@ else:
st.session_state[device_session_key] = input_device st.session_state[device_session_key] = input_device
else: else:
# When streaming, get the device from session state # 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 # Convert internal name to display label
display_label = current_device display_label = current_device
@@ -982,13 +1226,17 @@ else:
st.subheader("Radio 2") st.subheader("Radio 2")
# Disable Radio 2 in stereo mode # Disable Radio 2 in stereo mode
saved_r2_config = saved_settings.get('dante_radio2', {}) secondary_settings = saved_settings.get('secondary', {})
if dante_stereo_enabled: if dante_stereo_enabled:
st.info("🎧 Radio 2 is automatically disabled in stereo mode") st.info("🎧 Radio 2 is automatically disabled in stereo mode")
radio2_enabled = False radio2_enabled = False
else: else:
# Enable/disable checkbox for Radio 2 # Enable/disable checkbox for Radio 2
# Use saved settings or streaming state to determine default
radio2_enabled_default = secondary_is_streaming 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( radio2_enabled = st.checkbox(
"Enable Radio 2", "Enable Radio 2",
value=radio2_enabled_default, value=radio2_enabled_default,
@@ -999,7 +1247,22 @@ else:
if radio2_enabled: if radio2_enabled:
# Stream count dropdown for Radio 2 # Stream count dropdown for Radio 2
r2_stream_options = r1_stream_options 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 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( r2_stream_config = st.selectbox(
@@ -1022,7 +1285,16 @@ else:
(r2_max_quality == "Fair (16kHz)" and quality == "Fair (16kHz)")): (r2_max_quality == "Fair (16kHz)" and quality == "Fair (16kHz)")):
r2_available_qualities.append(quality) 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: if saved_r2_quality not in r2_available_qualities:
saved_r2_quality = r2_max_quality saved_r2_quality = r2_max_quality
@@ -1035,13 +1307,12 @@ else:
) )
# Radio-level settings for Radio 2 # 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") 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: with col_r2_flags1:
r2_assisted_listening = st.checkbox( r2_assisted_listening = st.checkbox(
"Assistive (R2)", "Assistive (R2)",
value=bool(saved_r2_config.get('assisted_listening', False)), value=bool(secondary_settings.get('assisted_listening_stream', False)),
disabled=is_streaming, disabled=is_streaming,
help="Assistive listening stream" help="Assistive listening stream"
) )
@@ -1049,13 +1320,13 @@ else:
with col_r2_flags2: with col_r2_flags2:
r2_immediate_rendering = st.checkbox( r2_immediate_rendering = st.checkbox(
"Immediate (R2)", "Immediate (R2)",
value=bool(saved_r2_config.get('immediate_rendering', False)), value=bool(secondary_settings.get('immediate_rendering', False)),
disabled=is_streaming, disabled=is_streaming,
help="Ignore presentation delay" help="Ignore presentation delay"
) )
with col_r2_pdelay: 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)) default_pdelay_ms = max(10, min(200, default_pdelay // 1000))
r2_presentation_delay_ms = st.number_input( r2_presentation_delay_ms = st.number_input(
"Delay (ms, R2)", "Delay (ms, R2)",
@@ -1066,10 +1337,10 @@ else:
with col_r2_qos: with col_r2_qos:
qos_options = list(QOS_PRESET_MAP.keys()) qos_options = list(QOS_PRESET_MAP.keys())
saved_qos = saved_r2_config.get('qos_preset', 'Fast') saved_qos = secondary_settings.get('qos_preset', 'Fast')
default_qos_idx = qos_options.index(saved_qos) if saved_qos in qos_options else 0 default_qos_idx2 = qos_options.index(saved_qos) if saved_qos in qos_options else 0
r2_qos_preset = st.selectbox( 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, disabled=is_streaming,
help="Quality of Service preset for Radio 2" help="Quality of Service preset for Radio 2"
) )
@@ -1078,10 +1349,21 @@ else:
st.write("**Stream Configuration (Radio 2)**") st.write("**Stream Configuration (Radio 2)**")
r2_streams = [] 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): for i in range(r2_num_streams):
with st.expander(f"Stream {i+1} - Radio 2", expanded=True): with st.expander(f"Stream {i+1} - Radio 2", expanded=True):
saved_streams = saved_r2_config.get('streams', []) # Get saved values from flat secondary structure
saved_stream = saved_streams[i] if i < len(saved_streams) else {} 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 # First row: Channel name and password
col_name, col_pwd = st.columns([2, 1]) col_name, col_pwd = st.columns([2, 1])
@@ -1089,7 +1371,7 @@ else:
with col_name: with col_name:
stream_name = st.text_input( stream_name = st.text_input(
f"Channel Name", f"Channel Name",
value=saved_stream.get('name', f'Dante_R2_S{i+1}'), value=saved_name2,
disabled=is_streaming, disabled=is_streaming,
key=f"r2_stream_{i}_name" key=f"r2_stream_{i}_name"
) )
@@ -1097,7 +1379,7 @@ else:
with col_pwd: with col_pwd:
stream_password = st.text_input( stream_password = st.text_input(
f"Stream Password", f"Stream Password",
value=saved_stream.get('stream_password', ''), value=saved_password2,
type="password", type="password",
disabled=is_streaming, disabled=is_streaming,
key=f"r2_stream_{i}_password", key=f"r2_stream_{i}_password",
@@ -1110,7 +1392,7 @@ else:
with col_prog: with col_prog:
program_info = st.text_input( program_info = st.text_input(
f"Program Info", f"Program Info",
value=saved_stream.get('program_info', f'Dante Radio 2 Stream {i+1}'), value=saved_program_info2,
disabled=is_streaming, disabled=is_streaming,
key=f"r2_stream_{i}_program" key=f"r2_stream_{i}_program"
) )
@@ -1118,7 +1400,7 @@ else:
with col_lang: with col_lang:
language = st.text_input( language = st.text_input(
f"Language", f"Language",
value=saved_stream.get('language', 'eng'), value=saved_language2,
disabled=is_streaming, disabled=is_streaming,
key=f"r2_stream_{i}_lang", key=f"r2_stream_{i}_lang",
help="ISO 639-3 language code" help="ISO 639-3 language code"
@@ -1133,7 +1415,7 @@ else:
if not is_streaming and input_options: if not is_streaming and input_options:
# Get default from session state first, then from saved settings # 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 default_input_label = None
for label, name in option_name_map.items(): for label, name in option_name_map.items():
if name == default_input_name: if name == default_input_name:
@@ -1154,7 +1436,7 @@ else:
st.session_state[device_session_key] = input_device st.session_state[device_session_key] = input_device
else: else:
# When streaming, get the device from session state # 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 # Convert internal name to display label
display_label = current_device display_label = current_device
@@ -1239,8 +1521,30 @@ else:
if audio_mode in ("USB", "Network"): if audio_mode in ("USB", "Network"):
# USB/Network: single set of controls shared with the single channel # 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()) 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( quality = st.selectbox(
"Stream Quality (Sampling Rate)", "Stream Quality (Sampling Rate)",
quality_options, quality_options,
@@ -1251,7 +1555,7 @@ else:
stream_passwort = st.text_input( stream_passwort = st.text_input(
"Stream Passwort", "Stream Passwort",
value="", value=saved_pwd,
type="password", type="password",
disabled=is_streaming, disabled=is_streaming,
help="Optional: Set a broadcast code to protect your stream. Leave empty for an open (uncoded) broadcast." help="Optional: Set a broadcast code to protect your stream. Leave empty for an open (uncoded) broadcast."
@@ -1499,10 +1803,11 @@ if start_stream:
immediate_rendering=bool(cfg['immediate_rendering']), immediate_rendering=bool(cfg['immediate_rendering']),
presentation_delay_us=int(cfg['presentation_delay_ms'] * 1000), presentation_delay_us=int(cfg['presentation_delay_ms'] * 1000),
qos_config=QOS_PRESET_MAP[cfg['qos_preset']], qos_config=QOS_PRESET_MAP[cfg['qos_preset']],
analog_gain_db_left=cfg.get('analog_gain_db_left', 0.0),
analog_gain_db_right=cfg.get('analog_gain_db_right', 0.0),
bigs=[ bigs=[
auracast_config.AuracastBigConfig( auracast_config.AuracastBigConfig(
id=cfg.get('id', 123456), code=((cfg['stream_passwort'] or '').strip() or None),
code=(cfg['stream_passwort'].strip() or None),
name=cfg['name'], name=cfg['name'],
program_info=cfg['program_info'], program_info=cfg['program_info'],
language=cfg['language'], language=cfg['language'],
@@ -1555,8 +1860,6 @@ if start_stream:
if not stream.get('input_device'): if not stream.get('input_device'):
continue continue
stream_id = radio_id * 1000 + i + 1 # Unique ID per stream
# Check if this specific stream uses stereo (dante_stereo_X_Y device) # Check if this specific stream uses stereo (dante_stereo_X_Y device)
input_device = stream['input_device'] input_device = stream['input_device']
stream_is_stereo = is_stereo_mode or input_device.startswith('dante_stereo_') stream_is_stereo = is_stereo_mode or input_device.startswith('dante_stereo_')
@@ -1564,7 +1867,6 @@ if start_stream:
num_channels = 2 if stream_is_stereo else 1 num_channels = 2 if stream_is_stereo else 1
bigs.append(auracast_config.AuracastBigConfig( bigs.append(auracast_config.AuracastBigConfig(
id=stream_id,
code=(stream.get('stream_password', '').strip() or None), code=(stream.get('stream_password', '').strip() or None),
name=stream['name'], name=stream['name'],
program_info=stream['program_info'], program_info=stream['program_info'],
@@ -1668,6 +1970,20 @@ if is_started or is_stopped:
############################ ############################
with st.expander("System control", expanded=False): with st.expander("System control", expanded=False):
st.subheader("Status LED")
led_enabled_current = bool(saved_settings.get("led_enabled", True))
led_enabled = st.checkbox(
"Blue LED on while transmitting",
value=led_enabled_current,
help="When enabled, the blue LED on GPIO pin 12 lights up while the stream is active."
)
if led_enabled != led_enabled_current:
try:
requests.post(f"{BACKEND_URL}/set_led_enabled", json={"led_enabled": led_enabled}, timeout=2)
except Exception as e:
st.error(f"Failed to update LED setting: {e}")
st.rerun()
st.subheader("System temperatures") st.subheader("System temperatures")
temp_col1, temp_col2, temp_col3 = st.columns([1, 1, 1]) temp_col1, temp_col2, temp_col3 = st.columns([1, 1, 1])
with temp_col1: with temp_col1:
@@ -1682,6 +1998,123 @@ with st.expander("System control", expanded=False):
except Exception as e: except Exception as e:
st.warning(f"Could not read temperatures: {e}") st.warning(f"Could not read temperatures: {e}")
st.subheader("Network Information")
try:
import subprocess, socket
device_hostname = socket.gethostname()
st.write(f"Hostname: **{device_hostname}**")
network_info_resp = requests.get(f"{BACKEND_URL}/network_info", timeout=5)
if network_info_resp.status_code == 200:
network_data = network_info_resp.json()
interfaces = network_data.get("interfaces", {})
port_mapping = network_data.get("port_mapping", {})
for port_name in ["port1", "port2"]:
if port_name not in port_mapping:
continue
interface_name = port_mapping[port_name]
interface_data = interfaces.get(interface_name, {})
port_label = "Port 1" if port_name == "port1" else "Port 2"
st.markdown(f"### {port_label}")
ip_address = interface_data.get("ip_address", "N/A")
is_dhcp = interface_data.get("is_dhcp", True)
st.write(f"Interface: **{interface_name}**")
st.write(f"IP Address: **{ip_address}**")
col1, col2 = st.columns([1, 3])
with col1:
toggle_key = f"{port_name}_dhcp_toggle"
current_mode = "DHCP" if is_dhcp else "Static IP"
new_mode = st.radio(
"Mode",
options=["DHCP", "Static IP"],
index=0 if is_dhcp else 1,
key=toggle_key,
horizontal=True
)
with col2:
if new_mode == "Static IP":
ip_input_key = f"{port_name}_ip_input"
default_ip = ip_address if ip_address != "N/A" and not is_dhcp else ""
new_ip = st.text_input(
"Static IP Address",
value=default_ip,
key=ip_input_key,
placeholder="192.168.1.100"
)
if st.button(f"Apply", key=f"{port_name}_apply_btn"):
if not new_ip:
st.error("Please enter an IP address")
else:
import re
ip_pattern = re.compile(r'^(\d{1,3}\.){3}\d{1,3}$')
if not ip_pattern.match(new_ip):
st.error("Invalid IP address format")
else:
octets = new_ip.split('.')
if not all(0 <= int(octet) <= 255 for octet in octets):
st.error("IP address octets must be between 0 and 255")
else:
try:
config_payload = {
"interface": interface_name,
"is_dhcp": False,
"ip_address": new_ip,
"netmask": "24"
}
config_resp = requests.post(
f"{BACKEND_URL}/set_network_config",
json=config_payload,
timeout=10
)
if config_resp.status_code == 200:
st.success(f"Static IP {new_ip} applied to {interface_name}")
time.sleep(2)
st.rerun()
else:
st.error(f"Failed to apply configuration: {config_resp.text}")
except Exception as e:
st.error(f"Error applying configuration: {e}")
else:
if new_mode != current_mode:
if st.button(f"Apply DHCP", key=f"{port_name}_dhcp_apply_btn"):
try:
config_payload = {
"interface": interface_name,
"is_dhcp": True
}
config_resp = requests.post(
f"{BACKEND_URL}/set_network_config",
json=config_payload,
timeout=10
)
if config_resp.status_code == 200:
st.success(f"DHCP enabled for {interface_name}")
time.sleep(2)
st.rerun()
else:
st.error(f"Failed to apply configuration: {config_resp.text}")
except Exception as e:
st.error(f"Error applying configuration: {e}")
st.markdown("---")
else:
result = subprocess.run(["hostname", "-I"], capture_output=True, text=True, timeout=2)
ips = [ip for ip in result.stdout.strip().split() if not ip.startswith('127.') and ':' not in ip]
if ips:
st.write(f"IP Address: **{ips[0]}**")
else:
st.warning("No valid IP address found.")
except Exception as e:
st.warning(f"Could not determine network info: {e}")
st.subheader("CA Certificate") st.subheader("CA Certificate")
st.caption("Download the CA certificate to trust this device's HTTPS connection.") st.caption("Download the CA certificate to trust this device's HTTPS connection.")
try: try:
@@ -1842,7 +2275,7 @@ with st.expander("Record", expanded=False):
selected_device_name = None selected_device_name = None
# Recording controls # Recording controls
col_record, col_download = st.columns([1, 1]) col_record, col_download, col_delete = st.columns([1, 1, 1])
with col_record: with col_record:
if st.button("Start Recording (5s)", disabled=not selected_device_name): if st.button("Start Recording (5s)", disabled=not selected_device_name):
@@ -1880,6 +2313,22 @@ with st.expander("Record", expanded=False):
else: else:
st.button("Download Last Recording", disabled=True, help="No recording available yet") st.button("Download Last Recording", disabled=True, help="No recording available yet")
with col_delete:
if st.button("Delete Recordings", type="secondary"):
try:
r = requests.delete(f"{BACKEND_URL}/delete_recordings", timeout=10)
if r.ok:
result = r.json()
if result.get('success'):
st.success(f"Deleted {result.get('deleted_count', 0)} recording(s)")
st.session_state['last_recording'] = None
else:
st.error("Failed to delete recordings")
else:
st.error(f"Failed to delete recordings: {r.status_code}")
except Exception as e:
st.error(f"Error deleting recordings: {e}")
log.basicConfig( log.basicConfig(
level=os.environ.get('LOG_LEVEL', log.DEBUG), level=os.environ.get('LOG_LEVEL', log.DEBUG),
format='%(module)s.py:%(lineno)d %(levelname)s: %(message)s' format='%(module)s.py:%(lineno)d %(levelname)s: %(message)s'
+645 -72
View File
@@ -3,12 +3,14 @@ TODO: in the future the multicaster objects should run in their own threads or e
""" """
import os import os
import re
import logging as log import logging as log
import json import json
from datetime import datetime from datetime import datetime
import asyncio import asyncio
import random import random
import subprocess import subprocess
import threading
from dotenv import load_dotenv from dotenv import load_dotenv
from fastapi import FastAPI, HTTPException from fastapi import FastAPI, HTTPException
@@ -26,6 +28,57 @@ from auracast.utils.sounddevice_utils import (
) )
load_dotenv() load_dotenv()
# Blue LED on GPIO pin 12 (BCM) turns on while transmitting
LED_PIN = 12
try:
import RPi.GPIO as _GPIO
_GPIO.setmode(_GPIO.BCM)
_GPIO.setup(LED_PIN, _GPIO.OUT)
_GPIO_AVAILABLE = True
except Exception:
_GPIO_AVAILABLE = False
_GPIO = None # type: ignore
_LED_ENABLED: bool = True # toggled via /set_led_enabled
_LED_SETTINGS_FILE = os.path.join(os.path.dirname(__file__), 'led_settings.json')
def _load_led_settings() -> None:
global _LED_ENABLED
try:
if os.path.exists(_LED_SETTINGS_FILE):
with open(_LED_SETTINGS_FILE, 'r', encoding='utf-8') as f:
data = json.load(f)
_LED_ENABLED = bool(data.get('led_enabled', True))
except Exception:
_LED_ENABLED = True
def _save_led_settings() -> None:
try:
os.makedirs(os.path.dirname(_LED_SETTINGS_FILE), exist_ok=True)
with open(_LED_SETTINGS_FILE, 'w', encoding='utf-8') as f:
json.dump({'led_enabled': _LED_ENABLED}, f)
except Exception:
pass
def _led_on():
if _GPIO_AVAILABLE and _LED_ENABLED:
try:
_GPIO.output(LED_PIN, _GPIO.LOW)
except Exception:
pass
def _led_off():
if _GPIO_AVAILABLE:
try:
_GPIO.output(LED_PIN, _GPIO.HIGH)
except Exception:
pass
# Configure bumble debug logging
# log.getLogger('bumble').setLevel(log.DEBUG)
# make sure pipewire sets latency # make sure pipewire sets latency
# Primary and secondary persisted settings files # Primary and secondary persisted settings files
STREAM_SETTINGS_FILE1 = os.path.join(os.path.dirname(__file__), 'stream_settings.json') STREAM_SETTINGS_FILE1 = os.path.join(os.path.dirname(__file__), 'stream_settings.json')
@@ -133,6 +186,10 @@ def save_settings(persisted: dict, secondary: bool = False) -> None:
def gen_random_add() -> str: def gen_random_add() -> str:
return ':'.join(['%02X' % random.randint(0, 255) for _ in range(6)]) return ':'.join(['%02X' % random.randint(0, 255) for _ in range(6)])
def gen_random_broadcast_id() -> int:
"""Generate a random 24-bit Broadcast ID (1..0xFFFFFF)."""
return random.randint(1, 0xFFFFFF)
app = FastAPI() app = FastAPI()
# Allow CORS for frontend on localhost # Allow CORS for frontend on localhost
@@ -152,6 +209,28 @@ multicaster1: multicast_control.Multicaster | None = None
multicaster2: multicast_control.Multicaster | None = None multicaster2: multicast_control.Multicaster | None = None
_stream_lock = asyncio.Lock() # serialize initialize/stop_audio on API side _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: async def _init_i2c_on_startup() -> None:
# Ensure i2c-dev kernel module is loaded (required for /dev/i2c-* access) # Ensure i2c-dev kernel module is loaded (required for /dev/i2c-* access)
@@ -238,12 +317,18 @@ async def _init_i2c_on_startup() -> None:
log.warning("Exception running i2cget (%s): %s", " ".join(read_cmd), e, exc_info=True) log.warning("Exception running i2cget (%s): %s", " ".join(read_cmd), e, exc_info=True)
async def _set_adc_level_on_startup() -> None: async def _set_adc_level(gain_db_left: float = 0.0, gain_db_right: float = 0.0) -> None:
"""Ensure ADC mixer level is set at startup. """Set ADC mixer gain in dB for left and right channels independently.
Runs: amixer -c 2 set 'ADC' x% Runs: amixer -c 2 sset ADC {gain_db_left}dB,{gain_db_right}dB
Args:
gain_db_left: Left channel gain in dB (-12 to 18), default 0
gain_db_right: Right channel gain in dB (-12 to 18), default 0
""" """
cmd = ["amixer", "-c", "2", "set", "ADC", "80%"] gain_db_left = max(-12.0, min(18.0, gain_db_left))
gain_db_right = max(-12.0, min(18.0, gain_db_right))
cmd = ["amixer", "-c", "2", "sset", "ADC", "--", f"{int(gain_db_left)}dB,{int(gain_db_right)}dB"]
try: try:
proc = await asyncio.create_subprocess_exec( proc = await asyncio.create_subprocess_exec(
*cmd, *cmd,
@@ -252,15 +337,46 @@ async def _set_adc_level_on_startup() -> None:
) )
stdout, stderr = await proc.communicate() stdout, stderr = await proc.communicate()
if proc.returncode != 0: if proc.returncode != 0:
log.warning( log.error(
"amixer ADC level command failed (rc=%s): %s", "amixer ADC level command failed (rc=%s): %s",
proc.returncode, proc.returncode,
(stderr or b"" ).decode(errors="ignore").strip(), (stderr or b"" ).decode(errors="ignore").strip(),
) )
else: else:
log.info("amixer ADC level set successfully: %s", (stdout or b"" ).decode(errors="ignore").strip()) log.info("amixer ADC level set successfully: %s", (stdout or b"" ).decode(errors="ignore").strip())
read_proc = await asyncio.create_subprocess_exec(
"amixer", "-c", "2", "sget", "ADC",
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.PIPE,
)
read_stdout, read_stderr = await read_proc.communicate()
if read_proc.returncode != 0:
log.error(
"amixer ADC sget failed (rc=%s): %s",
read_proc.returncode,
(read_stderr or b"").decode(errors="ignore").strip(),
)
else:
sget_output = (read_stdout or b"").decode(errors="ignore")
actual = {}
for line in sget_output.splitlines():
for ch_key, ch_name in (("left", "Front Left"), ("right", "Front Right")):
if ch_name in line:
m = re.search(r'\[(-?\d+(?:\.\d+)?)dB\]', line)
if m:
actual[ch_key] = round(float(m.group(1)))
expected_left = int(gain_db_left)
expected_right = int(gain_db_right)
if actual.get("left") != expected_left or actual.get("right") != expected_right:
mismatch = (
f"ADC level mismatch after set: expected L={expected_left}dB R={expected_right}dB, "
f"got L={actual.get('left')}dB R={actual.get('right')}dB"
)
log.error(mismatch)
else:
log.info("ADC level set successfully: L=%sdB R=%sdB", expected_left, expected_right)
except Exception as e: except Exception as e:
log.warning("Exception running amixer ADC level command: %s", e, exc_info=True) log.error("Exception running amixer ADC level command: %s", e, exc_info=True)
async def _stop_all() -> bool: async def _stop_all() -> bool:
@@ -280,6 +396,7 @@ async def _stop_all() -> bool:
was_running = True was_running = True
finally: finally:
multicaster2 = None multicaster2 = None
_led_off()
return was_running return was_running
async def _status_primary() -> dict: async def _status_primary() -> dict:
@@ -328,6 +445,11 @@ async def init_radio(transport: str, conf: auracast_config.AuracastConfigGroup,
first_source = conf.bigs[0].audio_source if conf.bigs else '' first_source = conf.bigs[0].audio_source if conf.bigs else ''
input_device_name = None input_device_name = None
audio_mode_persist = 'Demo' 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 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:'): if isinstance(first_source, str) and first_source.startswith('device:'):
input_device_name = first_source.split(':', 1)[1] if ':' in first_source else None input_device_name = first_source.split(':', 1)[1] if ':' in first_source else None
@@ -338,6 +460,10 @@ async def init_radio(transport: str, conf: auracast_config.AuracastConfigGroup,
if input_device_name in ('ch1', 'ch2'): if input_device_name in ('ch1', 'ch2'):
audio_mode_persist = 'Analog' audio_mode_persist = 'Analog'
# Set ADC gain level for analog mode
analog_gain_db_left = getattr(conf, 'analog_gain_db_left', 0.0)
analog_gain_db_right = getattr(conf, 'analog_gain_db_right', 0.0)
await _set_adc_level(analog_gain_db_left, analog_gain_db_right)
elif input_device_name in dante_channels: elif input_device_name in dante_channels:
audio_mode_persist = 'Network - Dante' audio_mode_persist = 'Network - Dante'
else: else:
@@ -380,18 +506,15 @@ async def init_radio(transport: str, conf: auracast_config.AuracastConfigGroup,
if is_stereo and sel == 'ch1': if is_stereo and sel == 'ch1':
# Stereo mode: use ALSA directly to capture both channels from hardware # Stereo mode: use ALSA directly to capture both channels from hardware
# ch1=left (channel 0), ch2=right (channel 1) # ch1=left (channel 0), ch2=right (channel 1)
big.audio_source = 'alsa:hw:CARD=i2s,DEV=0' big.audio_source = 'device:hw:2'
big.input_format = f"int16le,{hardware_capture_rate},2" big.input_format = f"int16le,{hardware_capture_rate},2"
log.info("Configured analog stereo input: using ALSA hw:CARD=i2s,DEV=0 with ch1=left, ch2=right") log.info("Configured analog stereo input: using ALSA hw:CARD=i2s,DEV=0 with ch1=left, ch2=right")
elif is_stereo and sel == 'ch2': elif is_stereo and sel == 'ch2':
# Skip ch2 in stereo mode as it's already captured as part of stereo pair # Skip ch2 in stereo mode as it's already captured as part of stereo pair
continue continue
else: else:
# Mono mode: individual channel capture # Mono mode: use dsnoop virtual device directly (ch1=left, ch2=right)
device_index = resolve_input_device_index(sel) big.audio_source = f'device:{sel}'
if device_index is None:
raise HTTPException(status_code=400, detail=f"Audio device '{sel}' not found.")
big.audio_source = f'device:{device_index}'
big.input_format = f"int16le,{hardware_capture_rate},1" big.input_format = f"int16le,{hardware_capture_rate},1"
continue continue
@@ -447,10 +570,12 @@ async def init_radio(transport: str, conf: auracast_config.AuracastConfigGroup,
conf.qos_config.max_transport_latency_ms = int(conf.qos_config.number_of_retransmissions) * 10 + 3 conf.qos_config.max_transport_latency_ms = int(conf.qos_config.number_of_retransmissions) * 10 + 3
# Only generate a new random_address if the BIG is still at the model default. # Generate fresh random_address and broadcast ID for any BIG still at model defaults.
for big in conf.bigs: for big in conf.bigs:
if not getattr(big, 'random_address', None) or big.random_address == DEFAULT_RANDOM_ADDRESS: if not getattr(big, 'random_address', None) or big.random_address == DEFAULT_RANDOM_ADDRESS:
big.random_address = gen_random_add() big.random_address = gen_random_add()
if big.id == DEFAULT_BIG_ID:
big.id = gen_random_broadcast_id()
# Log the final, fully-updated configuration just before creating the Multicaster # Log the final, fully-updated configuration just before creating the Multicaster
log.info('Final multicaster config (transport=%s):\n %s', transport, conf.model_dump_json(indent=2)) log.info('Final multicaster config (transport=%s):\n %s', transport, conf.model_dump_json(indent=2))
@@ -461,6 +586,7 @@ async def init_radio(transport: str, conf: auracast_config.AuracastConfigGroup,
auto_started = False auto_started = False
if any(isinstance(big.audio_source, str) and (big.audio_source.startswith("device:") or big.audio_source.startswith("alsa:") or big.audio_source.startswith("file:")) for big in conf.bigs): if any(isinstance(big.audio_source, str) and (big.audio_source.startswith("device:") or big.audio_source.startswith("alsa:") or big.audio_source.startswith("file:")) for big in conf.bigs):
await mc.start_streaming() await mc.start_streaming()
_led_on()
auto_started = True auto_started = True
demo_count = sum(1 for big in conf.bigs if isinstance(big.audio_source, str) and big.audio_source.startswith('file:')) demo_count = sum(1 for big in conf.bigs if isinstance(big.audio_source, str) and big.audio_source.startswith('file:'))
@@ -476,6 +602,7 @@ async def init_radio(transport: str, conf: auracast_config.AuracastConfigGroup,
'languages': [big.language for big in conf.bigs], 'languages': [big.language for big in conf.bigs],
'audio_mode': audio_mode_persist, 'audio_mode': audio_mode_persist,
'input_device': input_device_name, 'input_device': input_device_name,
'input_devices': original_input_devices,
'program_info': [getattr(big, 'program_info', None) for big in conf.bigs], 'program_info': [getattr(big, 'program_info', None) for big in conf.bigs],
'gain': [getattr(big, 'input_gain', 1.0) 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, 'auracast_sampling_rate_hz': conf.auracast_sampling_rate_hz,
@@ -485,6 +612,8 @@ async def init_radio(transport: str, conf: auracast_config.AuracastConfigGroup,
'immediate_rendering': getattr(conf, 'immediate_rendering', False), 'immediate_rendering': getattr(conf, 'immediate_rendering', False),
'assisted_listening_stream': getattr(conf, 'assisted_listening_stream', False), 'assisted_listening_stream': getattr(conf, 'assisted_listening_stream', False),
'analog_stereo_mode': getattr(conf.bigs[0], 'analog_stereo_mode', False) if conf.bigs else False, 'analog_stereo_mode': getattr(conf.bigs[0], 'analog_stereo_mode', False) if conf.bigs else False,
'analog_gain_db_left': getattr(conf, 'analog_gain_db_left', 0.0),
'analog_gain_db_right': getattr(conf, 'analog_gain_db_right', 0.0),
'stream_password': (conf.bigs[0].code if conf.bigs and getattr(conf.bigs[0], 'code', None) else None), 'stream_password': (conf.bigs[0].code if conf.bigs and getattr(conf.bigs[0], 'code', None) else None),
'big_ids': [getattr(big, 'id', DEFAULT_BIG_ID) for big in conf.bigs], 'big_ids': [getattr(big, 'id', DEFAULT_BIG_ID) for big in conf.bigs],
'big_random_addresses': [getattr(big, 'random_address', DEFAULT_RANDOM_ADDRESS) for big in conf.bigs], 'big_random_addresses': [getattr(big, 'random_address', DEFAULT_RANDOM_ADDRESS) for big in conf.bigs],
@@ -502,7 +631,10 @@ async def init_radio(transport: str, conf: auracast_config.AuracastConfigGroup,
@app.post("/init") @app.post("/init")
async def initialize(conf: auracast_config.AuracastConfigGroup): 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: async with _stream_lock:
global multicaster1, global_config_group global multicaster1, global_config_group
mc, persisted = await init_radio(TRANSPORT1, conf, multicaster1) mc, persisted = await init_radio(TRANSPORT1, conf, multicaster1)
@@ -512,16 +644,33 @@ async def initialize(conf: auracast_config.AuracastConfigGroup):
@app.post("/init2") @app.post("/init2")
async def initialize2(conf: auracast_config.AuracastConfigGroup): 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: async with _stream_lock:
global multicaster2 global multicaster2
mc, persisted = await init_radio(TRANSPORT2, conf, multicaster2) mc, persisted = await init_radio(TRANSPORT2, conf, multicaster2)
multicaster2 = mc multicaster2 = mc
save_settings(persisted, secondary=True) save_settings(persisted, secondary=True)
@app.post("/set_led_enabled")
async def set_led_enabled(body: dict):
"""Enable or disable the blue status LED. Persisted across restarts."""
global _LED_ENABLED
_LED_ENABLED = bool(body.get("led_enabled", True))
_save_led_settings()
if not _LED_ENABLED:
_led_off()
return {"led_enabled": _LED_ENABLED}
@app.post("/stop_audio") @app.post("/stop_audio")
async def 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: try:
was_running = await _stop_all() was_running = await _stop_all()
@@ -547,11 +696,33 @@ async def stop_audio():
log.error("Exception in /stop_audio: %s", traceback.format_exc()) log.error("Exception in /stop_audio: %s", traceback.format_exc())
raise HTTPException(status_code=500, detail=str(e)) raise HTTPException(status_code=500, detail=str(e))
@app.post("/adc_gain")
async def set_adc_gain(payload: dict):
"""Set ADC gain in dB for left and right channels without restarting the stream.
Body: {"gain_db_left": float, "gain_db_right": float}
"""
try:
gain_db_left = float(payload.get("gain_db_left", 0.0))
gain_db_right = float(payload.get("gain_db_right", 0.0))
await _set_adc_level(gain_db_left, gain_db_right)
# Persist the new values so they survive a restart
for load_fn, save_fn in [(load_stream_settings, save_stream_settings), (load_stream_settings2, save_stream_settings2)]:
s = load_fn() or {}
if s:
s['analog_gain_db_left'] = gain_db_left
s['analog_gain_db_right'] = gain_db_right
save_fn(s)
return {"status": "ok", "gain_db_left": gain_db_left, "gain_db_right": gain_db_right}
except Exception as e:
log.error("Exception in /adc_gain: %s", traceback.format_exc())
raise HTTPException(status_code=500, detail=str(e))
@app.post("/stream_lc3") @app.post("/stream_lc3")
async def send_audio(audio_data: dict[str, str]): 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: 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"} return {"status": "audio_sent"}
except Exception as e: except Exception as e:
raise HTTPException(status_code=500, detail=str(e)) raise HTTPException(status_code=500, detail=str(e))
@@ -582,9 +753,24 @@ async def get_status():
secondary.update(secondary_persisted) secondary.update(secondary_persisted)
status["secondary"] = secondary status["secondary"] = secondary
status["secondary_is_streaming"] = bool(secondary.get("is_streaming", False)) status["secondary_is_streaming"] = bool(secondary.get("is_streaming", False))
status["led_enabled"] = _LED_ENABLED
return status return status
@app.get("/audio_level")
async def get_audio_level():
"""Return current RMS audio levels for primary radio (lightweight, for polling)."""
if multicaster1 is None:
return {"levels": []}
return {"levels": multicaster1.get_audio_levels()}
@app.get("/audio_level2")
async def get_audio_level2():
"""Return current RMS audio levels for secondary radio (lightweight, for polling)."""
if multicaster2 is None:
return {"levels": []}
return {"levels": multicaster2.get_audio_levels()}
async def _autostart_from_settings(): async def _autostart_from_settings():
settings1 = load_stream_settings() or {} settings1 = load_stream_settings() or {}
settings2 = load_stream_settings2() or {} settings2 = load_stream_settings2() or {}
@@ -737,6 +923,8 @@ async def _autostart_from_settings():
immediate_rendering=immediate_rendering, immediate_rendering=immediate_rendering,
assisted_listening_stream=assisted_listening_stream, assisted_listening_stream=assisted_listening_stream,
presentation_delay_us=pres_delay if pres_delay is not None else 40000, presentation_delay_us=pres_delay if pres_delay is not None else 40000,
analog_gain_db_left=settings.get('analog_gain_db_left', 0.0),
analog_gain_db_right=settings.get('analog_gain_db_right', 0.0),
bigs=bigs, bigs=bigs,
) )
# Set num_bis for stereo mode if needed # Set num_bis for stereo mode if needed
@@ -890,6 +1078,8 @@ async def _autostart_from_settings():
immediate_rendering=immediate_rendering, immediate_rendering=immediate_rendering,
assisted_listening_stream=assisted_listening_stream, assisted_listening_stream=assisted_listening_stream,
presentation_delay_us=pres_delay if pres_delay is not None else 40000, presentation_delay_us=pres_delay if pres_delay is not None else 40000,
analog_gain_db_left=settings.get('analog_gain_db_left', 0.0),
analog_gain_db_right=settings.get('analog_gain_db_right', 0.0),
bigs=bigs, bigs=bigs,
) )
conf.qos_config = QOS_PRESET_MAP.get(saved_qos_preset, QOS_PRESET_MAP["Fast"]) conf.qos_config = QOS_PRESET_MAP.get(saved_qos_preset, QOS_PRESET_MAP["Fast"])
@@ -907,13 +1097,27 @@ async def _autostart_from_settings():
await do_primary() await do_primary()
await do_secondary() 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") @app.on_event("startup")
async def _startup_autostart_event(): async def _startup_autostart_event():
# Spawn the autostart task without blocking startup # Spawn the autostart task without blocking startup
log.info("[STARTUP] Auracast multicast server startup: initializing settings cache, I2C, and PipeWire cache") log.info("[STARTUP] Auracast multicast server startup: initializing settings cache, I2C, and PipeWire cache")
_led_off()
# Run install_asoundconf.sh script # Run install_asoundconf.sh script
script_path = os.path.join(os.path.dirname(__file__), '..', 'misc', 'install_asoundconf.sh') script_path = os.path.join(os.path.dirname(__file__), '..', '..', 'misc', 'install_asoundconf.sh')
try: try:
log.info("[STARTUP] Running install_asoundconf.sh script") log.info("[STARTUP] Running install_asoundconf.sh script")
result = subprocess.run(['bash', script_path], capture_output=True, text=True, check=True) result = subprocess.run(['bash', script_path], capture_output=True, text=True, check=True)
@@ -924,13 +1128,13 @@ async def _startup_autostart_event():
log.error(f"[STARTUP] Error running install_asoundconf.sh: {str(e)}") log.error(f"[STARTUP] Error running install_asoundconf.sh: {str(e)}")
# Hydrate settings cache once to avoid disk I/O during /status # Hydrate settings cache once to avoid disk I/O during /status
_load_led_settings()
_init_settings_cache_from_disk() _init_settings_cache_from_disk()
await _init_i2c_on_startup()
# Ensure ADC mixer level is set at startup
await _set_adc_level_on_startup()
refresh_pw_cache() refresh_pw_cache()
log.info("[STARTUP] Scheduling autostart task") # I2C init, ADC setup and the autostart task must run on the BLE loop so
asyncio.create_task(_autostart_from_settings()) # 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") @app.get("/audio_inputs_pw_usb")
async def audio_inputs_pw_usb(): async def audio_inputs_pw_usb():
@@ -1001,6 +1205,9 @@ async def refresh_audio_devices():
@app.post("/shutdown") @app.post("/shutdown")
async def shutdown(): async def shutdown():
"""Stops broadcasting and releases all audio/Bluetooth resources.""" """Stops broadcasting and releases all audio/Bluetooth resources."""
return await _on_ble_loop(_shutdown_impl())
async def _shutdown_impl():
try: try:
await _stop_all() await _stop_all()
return {"status": "stopped"} return {"status": "stopped"}
@@ -1013,6 +1220,9 @@ async def system_reboot():
Requires the service user to have passwordless sudo permissions to run '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: try:
# Best-effort: stop any active streaming cleanly WITHOUT persisting state # Best-effort: stop any active streaming cleanly WITHOUT persisting state
try: try:
@@ -1036,46 +1246,26 @@ async def system_reboot():
@app.post("/restart_dep") @app.post("/restart_dep")
async def 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: try:
# Get the dep directory path (dep.sh is in dante_package subdirectory) log.info("Restarting DEP via systemctl...")
dep_dir = os.path.join(os.path.dirname(__file__), '..', '..', 'dep', 'dante_package') proc = await asyncio.create_subprocess_exec(
"sudo", "systemctl", "restart", "dep.service",
# Run dep.sh stop first
log.info("Stopping DEP...")
stop_process = await asyncio.create_subprocess_exec(
"sudo", "bash", "dep.sh", "stop",
cwd=dep_dir,
stdout=asyncio.subprocess.PIPE, stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.PIPE stderr=asyncio.subprocess.PIPE
) )
stop_stdout, stop_stderr = await stop_process.communicate() stdout, stderr = await proc.communicate()
if stop_process.returncode != 0: if proc.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:
log.info("DEP restarted successfully") log.info("DEP restarted successfully")
return {"status": "success", "message": "DEP restarted successfully"} return {"status": "success", "message": "DEP restarted successfully"}
else: else:
error_msg = start_stderr.decode() if start_stderr else "Unknown error" error_msg = stderr.decode() if stderr else "Unknown error"
log.error(f"Failed to start DEP: {error_msg}") log.error(f"Failed to restart DEP: {error_msg}")
raise HTTPException(status_code=500, detail=f"Failed to start DEP: {error_msg}") raise HTTPException(status_code=500, detail=f"Failed to restart DEP: {error_msg}")
except HTTPException: except HTTPException:
raise raise
@@ -1169,6 +1359,9 @@ async def check_update():
@app.post("/system_update") @app.post("/system_update")
async def system_update(): async def system_update():
"""Update application: git pull main branch (latest tag), poetry install, restart services.""" """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: try:
# Best-effort: stop any active streaming cleanly # Best-effort: stop any active streaming cleanly
try: try:
@@ -1218,26 +1411,12 @@ async def system_update():
log.error("git checkout failed: %s", stderr.decode()) log.error("git checkout failed: %s", stderr.decode())
raise HTTPException(status_code=500, detail=f"git checkout failed: {stderr.decode()}") raise HTTPException(status_code=500, detail=f"git checkout failed: {stderr.decode()}")
# 2. Run poetry install (use full path as poetry is in user's ~/.local/bin) # 2. Hand off remaining work to the (now-updated) system_update.sh script
poetry_path = os.path.expanduser("~/.local/bin/poetry") update_script = os.path.join(os.path.dirname(__file__), 'system_update.sh')
proc = await asyncio.create_subprocess_exec( log.info("Handing off to system_update.sh...")
poetry_path, "install", await asyncio.create_subprocess_exec(
cwd=project_root,
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.PIPE
)
stdout, stderr = await proc.communicate()
if proc.returncode != 0:
log.error("poetry install failed: %s", stderr.decode())
raise HTTPException(status_code=500, detail=f"poetry install failed: {stderr.decode()}")
# 3. Restart services via the update script
update_script = os.path.join(project_root, 'src', 'service', 'update_and_run_server_and_frontend.sh')
proc = await asyncio.create_subprocess_exec(
"bash", update_script, "bash", update_script,
cwd=project_root, cwd=project_root,
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.PIPE
) )
# Don't wait for completion as we'll be restarted # Don't wait for completion as we'll be restarted
await asyncio.sleep(0.5) await asyncio.sleep(0.5)
@@ -1413,6 +1592,235 @@ async def download_recording(filename: str):
raise HTTPException(status_code=500, detail=str(e)) raise HTTPException(status_code=500, detail=str(e))
@app.delete("/delete_recordings")
async def delete_recordings():
"""Delete all recordings in the recordings folder."""
try:
deleted_count = 0
for filename in os.listdir(RECORDINGS_DIR):
filepath = os.path.join(RECORDINGS_DIR, filename)
if os.path.isfile(filepath):
try:
os.remove(filepath)
deleted_count += 1
log.info("Deleted recording: %s", filename)
except Exception as e:
log.warning("Failed to delete %s: %s", filename, e)
log.info("Deleted %d recordings", deleted_count)
return {"success": True, "deleted_count": deleted_count}
except Exception as e:
log.error("Exception in /delete_recordings: %s", e, exc_info=True)
raise HTTPException(status_code=500, detail=str(e))
@app.get("/network_info")
async def get_network_info():
"""Get network information for all ethernet interfaces."""
try:
interfaces = {}
hardcoded_devices = ["eth0", "eth1"]
proc = await asyncio.create_subprocess_exec(
"nmcli", "-t", "-f", "NAME,DEVICE", "connection", "show",
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.PIPE
)
stdout, stderr = await proc.communicate()
device_to_connection = {}
if proc.returncode == 0:
for line in stdout.decode().strip().split('\n'):
if not line:
continue
parts = line.split(':')
if len(parts) >= 2:
connection_name = parts[0]
device_name = parts[1]
if device_name in hardcoded_devices:
device_to_connection[device_name] = connection_name
for device in hardcoded_devices:
ip_address = None
proc = await asyncio.create_subprocess_exec(
"nmcli", "-t", "-f", "IP4.ADDRESS", "device", "show", device,
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.PIPE
)
stdout, stderr = await proc.communicate()
if proc.returncode == 0:
for line in stdout.decode().strip().split('\n'):
if line.startswith('IP4.ADDRESS'):
ip_parts = line.split(':')
if len(ip_parts) >= 2:
full_ip = ip_parts[1]
ip_address = full_ip.split('/')[0]
if not ip_address.startswith('169.254.'):
break
method = "auto"
connection_name = device_to_connection.get(device)
if connection_name:
proc = await asyncio.create_subprocess_exec(
"nmcli", "-t", "-f", "ipv4.method", "connection", "show", connection_name,
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.PIPE
)
stdout, stderr = await proc.communicate()
if proc.returncode == 0:
for line in stdout.decode().strip().split('\n'):
if line.startswith('ipv4.method:'):
method = line.split(':')[1]
break
is_dhcp = method == "auto"
interfaces[device] = {
"ip_address": ip_address or "N/A",
"is_dhcp": is_dhcp,
"method": method,
"connection_name": connection_name
}
port_mapping = {
"port1": "eth0",
"port2": "eth1"
}
return {
"interfaces": interfaces,
"port_mapping": port_mapping
}
except HTTPException:
raise
except Exception as e:
log.error("Exception in /network_info: %s", e, exc_info=True)
raise HTTPException(status_code=500, detail=str(e))
@app.post("/set_network_config")
async def set_network_config(config: dict):
"""Set network configuration (DHCP or Static IP) for a specific interface.
Expected payload:
{
"interface": "eth0",
"is_dhcp": true/false,
"ip_address": "192.168.1.100" (required if is_dhcp is false),
"netmask": "24" (optional, defaults to 24)
}
"""
try:
interface = config.get("interface")
is_dhcp = config.get("is_dhcp", True)
ip_address = config.get("ip_address")
netmask = config.get("netmask", "24")
if not interface:
raise HTTPException(status_code=400, detail="Interface name is required")
proc = await asyncio.create_subprocess_exec(
"nmcli", "-t", "-f", "NAME,DEVICE", "connection", "show",
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.PIPE
)
stdout, stderr = await proc.communicate()
if proc.returncode != 0:
raise HTTPException(status_code=500, detail="Failed to get network connections")
connection_name = None
for line in stdout.decode().strip().split('\n'):
if not line:
continue
parts = line.split(':')
if len(parts) >= 2 and parts[1] == interface:
connection_name = parts[0]
break
if not connection_name:
log.info(f"No connection found for {interface}, creating new connection")
proc = await asyncio.create_subprocess_exec(
"sudo", "nmcli", "con", "add", "type", "ethernet",
"ifname", interface, "con-name", f"Wired connection {interface}",
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.PIPE
)
stdout, stderr = await proc.communicate()
if proc.returncode != 0:
raise HTTPException(status_code=500, detail=f"Failed to create connection for {interface}: {stderr.decode()}")
connection_name = f"Wired connection {interface}"
if is_dhcp:
proc = await asyncio.create_subprocess_exec(
"sudo", "nmcli", "con", "modify", connection_name, "ipv4.method", "auto",
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.PIPE
)
await proc.communicate()
if proc.returncode != 0:
raise HTTPException(status_code=500, detail="Failed to set DHCP mode")
proc = await asyncio.create_subprocess_exec(
"sudo", "nmcli", "con", "modify", connection_name, "ipv4.addresses", "",
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.PIPE
)
await proc.communicate()
else:
if not ip_address:
raise HTTPException(status_code=400, detail="IP address is required for static configuration")
import re
ip_pattern = re.compile(r'^(\d{1,3}\.){3}\d{1,3}$')
if not ip_pattern.match(ip_address):
raise HTTPException(status_code=400, detail="Invalid IP address format")
octets = ip_address.split('.')
if not all(0 <= int(octet) <= 255 for octet in octets):
raise HTTPException(status_code=400, detail="IP address octets must be between 0 and 255")
proc = await asyncio.create_subprocess_exec(
"sudo", "nmcli", "con", "modify", connection_name,
"ipv4.method", "manual",
"ipv4.addresses", f"{ip_address}/{netmask}",
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.PIPE
)
await proc.communicate()
if proc.returncode != 0:
raise HTTPException(status_code=500, detail="Failed to set static IP")
proc = await asyncio.create_subprocess_exec(
"sudo", "nmcli", "con", "up", connection_name,
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.PIPE
)
stdout, stderr = await proc.communicate()
if proc.returncode != 0:
log.info("Connection activation returned non-zero (may be expected if no cable): %s", stderr.decode())
return {"status": "success", "message": f"Network configuration updated for {interface}"}
except HTTPException:
raise
except Exception as e:
log.error("Exception in /set_network_config: %s", e, exc_info=True)
raise HTTPException(status_code=500, detail=str(e))
if __name__ == '__main__': if __name__ == '__main__':
import os import os
os.chdir(os.path.dirname(__file__)) os.chdir(os.path.dirname(__file__))
@@ -1421,5 +1829,170 @@ if __name__ == '__main__':
level=os.environ.get('LOG_LEVEL', log.INFO), level=os.environ.get('LOG_LEVEL', log.INFO),
format='%(module)s.py:%(lineno)d %(levelname)s: %(message)s' 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 # 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) uvicorn.run(app, host="127.0.0.1", port=5000, access_log=False)
@@ -33,5 +33,12 @@ echo "Using Avahi domain: $AVAHI_DOMAIN"
# Path to poetry binary # Path to poetry binary
POETRY_BIN="/home/caster/.local/bin/poetry" POETRY_BIN="/home/caster/.local/bin/poetry"
# Start HTTP->HTTPS redirect server on port 80 (background)
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
python3 "$SCRIPT_DIR/http_to_https_redirect.py" 80 &
REDIRECT_PID=$!
echo "HTTP->HTTPS redirect server started (PID $REDIRECT_PID)"
trap "kill $REDIRECT_PID 2>/dev/null" EXIT
# Start Streamlit HTTPS server (port 443) # Start Streamlit HTTPS server (port 443)
$POETRY_BIN run streamlit run multicast_frontend.py --server.port 443 --server.address 0.0.0.0 --server.enableCORS false --server.enableXsrfProtection false --server.headless true --server.sslCertFile "$CERT" --server.sslKeyFile "$KEY" --browser.gatherUsageStats false $POETRY_BIN run streamlit run multicast_frontend.py --server.port 443 --server.address 0.0.0.0 --server.enableCORS false --server.enableXsrfProtection false --server.headless true --server.sslCertFile "$CERT" --server.sslKeyFile "$KEY" --browser.gatherUsageStats false
+90
View File
@@ -0,0 +1,90 @@
#!/bin/bash
# system_update.sh - Runs after git checkout in the Python system_update endpoint.
# Called with the current working directory = project root.
# All output is also written to /tmp/system_update.log for debugging.
exec > >(tee -a /tmp/system_update.log) 2>&1
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
PROJECT_ROOT="$(cd "$SCRIPT_DIR/../../.." && pwd)"
POETRY="$HOME/.local/bin/poetry"
OPENOCD_SRC="$HOME/sw_openocd"
OPENOCD_REPO="ssh://git@gitea.summitwave.work:222/auracaster/sw_openocd.git"
OPENOCD_BRANCH="change-8818"
OPENOCD_MARKER="$OPENOCD_SRC/.last_built_commit"
OPENOCD_DIR="$PROJECT_ROOT/src/openocd"
echo "[system_update] Starting post-checkout update. project_root=$PROJECT_ROOT"
# 1. poetry install
echo "[system_update] Running poetry install..."
(cd "$PROJECT_ROOT" && "$POETRY" install)
if [ $? -ne 0 ]; then
echo "[system_update] ERROR: poetry install failed"
exit 1
fi
# 2. Clone/update and build sw_openocd if needed
if [ ! -d "$OPENOCD_SRC" ]; then
echo "[system_update] Installing sw_openocd build dependencies..."
sudo apt install -y git build-essential libtool autoconf texinfo \
libusb-1.0-0-dev libftdi1-dev libhidapi-dev pkg-config || \
echo "[system_update] WARNING: apt install deps had errors, continuing"
sudo apt-get install -y pkg-config libjim-dev || \
echo "[system_update] WARNING: apt-get install libjim-dev had errors, continuing"
echo "[system_update] Cloning sw_openocd branch $OPENOCD_BRANCH..."
git clone --branch "$OPENOCD_BRANCH" --single-branch "$OPENOCD_REPO" "$OPENOCD_SRC"
if [ $? -ne 0 ]; then
echo "[system_update] ERROR: git clone sw_openocd failed"
exit 1
fi
else
echo "[system_update] Updating sw_openocd..."
git -C "$OPENOCD_SRC" fetch origin "$OPENOCD_BRANCH"
git -C "$OPENOCD_SRC" checkout "$OPENOCD_BRANCH"
git -C "$OPENOCD_SRC" pull
fi
OPENOCD_COMMIT=$(git -C "$OPENOCD_SRC" rev-parse HEAD)
LAST_BUILT=""
[ -f "$OPENOCD_MARKER" ] && LAST_BUILT=$(cat "$OPENOCD_MARKER")
if [ "$OPENOCD_COMMIT" != "$LAST_BUILT" ]; then
echo "[system_update] Building sw_openocd (commit $OPENOCD_COMMIT)..."
(cd "$OPENOCD_SRC" && ./bootstrap)
if [ $? -ne 0 ]; then echo "[system_update] ERROR: openocd bootstrap failed"; exit 1; fi
(cd "$OPENOCD_SRC" && ./configure --enable-bcm2835gpio --enable-sysfsgpio)
if [ $? -ne 0 ]; then echo "[system_update] ERROR: openocd configure failed"; exit 1; fi
(cd "$OPENOCD_SRC" && make)
if [ $? -ne 0 ]; then echo "[system_update] ERROR: openocd make failed"; exit 1; fi
(cd "$OPENOCD_SRC" && sudo make install)
if [ $? -ne 0 ]; then echo "[system_update] ERROR: openocd make install failed"; exit 1; fi
echo "$OPENOCD_COMMIT" > "$OPENOCD_MARKER"
echo "[system_update] sw_openocd built and installed (commit $OPENOCD_COMMIT)"
else
echo "[system_update] sw_openocd up to date (commit $OPENOCD_COMMIT), skipping build"
fi
# 3. Flash firmware to both SWD interfaces
FLASH_SCRIPT="$OPENOCD_DIR/flash.sh"
HEX_FILE="$OPENOCD_DIR/merged.hex"
for IFACE in swd0 swd1; do
echo "[system_update] Flashing $IFACE..."
(cd "$OPENOCD_DIR" && bash "$FLASH_SCRIPT" -i "$IFACE" -f "$HEX_FILE")
if [ $? -ne 0 ]; then
echo "[system_update] ERROR: flash $IFACE failed"
exit 1
fi
echo "[system_update] Flash $IFACE complete"
done
# 4. Restart services (this will kill this process too)
echo "[system_update] Restarting services..."
bash "$PROJECT_ROOT/src/service/update_and_run_server_and_frontend.sh"
@@ -0,0 +1 @@
Placeholder file to have the activation folder available, otherwise dante activation script fails with 'Unable to write'.
@@ -1,5 +1,5 @@
{ {
"trialMode": true, "trialMode": false,
"$schema": "./dante.json_schema.json", "$schema": "./dante.json_schema.json",
"platform": "platform":
{ {
@@ -16,7 +16,7 @@
48000 48000
], ],
"samplesPerPeriod" : 16, "samplesPerPeriod" : 16,
"periodsPerBuffer" : 300, "periodsPerBuffer" : 150,
"networkLatencyMinMs" : 2, "networkLatencyMinMs" : 2,
"networkLatencyDefaultMs" : 5, "networkLatencyDefaultMs" : 5,
"supportedEncodings" : "supportedEncodings" :
@@ -24,7 +24,10 @@
"PCM16" "PCM16"
], ],
"defaultEncoding" : "PCM16", "defaultEncoding" : "PCM16",
"numDepCores" : 1 "numDepCores" :
[
3
]
}, },
"network" : "network" :
{ {
@@ -50,31 +53,32 @@
"alsaAsrc": "alsaAsrc":
{ {
"enableAlsaAsrc": true, "enableAlsaAsrc": true,
"cpuAffinity": 3,
"deviceConfigurations": [ "deviceConfigurations": [
{ {
"deviceIdentifier": "hw:0,0", "deviceIdentifier": "hw:6,0,0",
"direction": "playback", "direction": "playback",
"bitDepth": 16, "bitDepth": 16,
"numOpenChannels": 6, "numOpenChannels": 6,
"alsaChannelRange": "0-5", "alsaChannelRange": "0-5",
"danteChannelRange": "0-5", "danteChannelRange": "0-5",
"bufferSize": 4800, "bufferSize": 960,
"samplesPerPeriod": 16 "samplesPerPeriod": 16
} }
] ]
}, },
"product" : "product" :
{ {
"manfId" : "Audinate", "manfId" : "SummitFC",
"manfName" : "Audinate Pty Ltd", "manfName" : "Summitwave FlexCo",
"modelId" : "OEMDEP", "modelId" : "TX",
"modelName" : "Linux Dante Embedded Platform", "modelName" : "Summitwave TX",
"modelVersion" : "modelVersion" :
{ {
"major" : 9, "major" : 1,
"minor" : 9, "minor" : 0,
"bugfix" : 99 "bugfix" : 0
}, },
"devicePrefix" : "DEP" "devicePrefix" : "SW-TX"
} }
} }
+4 -4
View File
@@ -6,8 +6,8 @@ pcm.ch1 {
channels 2 channels 2
rate 48000 rate 48000
format S16_LE format S16_LE
period_size 120 period_size 240
buffer_size 240 buffer_size 960
} }
bindings.0 0 bindings.0 0
} }
@@ -21,8 +21,8 @@ pcm.ch2 {
channels 2 channels 2
rate 48000 rate 48000
format S16_LE format S16_LE
period_size 120 period_size 240
buffer_size 240 buffer_size 960
} }
bindings.0 1 bindings.0 1
} }
+2 -1
View File
@@ -1 +1,2 @@
sudo cp src/misc/asound.conf /etc/asound.conf SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
sudo cp "$SCRIPT_DIR/asound.conf" /etc/asound.conf
+56
View File
@@ -0,0 +1,56 @@
#!/bin/bash
set -e
INTERFACE="swd0"
HEX_FILE=""
usage() {
echo "Usage: $0 -f <hex_file> [-i swd0|swd1]"
exit 1
}
while getopts "f:i:h" opt; do
case "$opt" in
f) HEX_FILE="$OPTARG" ;;
i)
if [[ "$OPTARG" == "swd0" || "$OPTARG" == "swd1" ]]; then
INTERFACE="$OPTARG"
else
usage
fi
;;
h) usage ;;
*) usage ;;
esac
done
[[ -n "$HEX_FILE" ]] || usage
[[ -f "$HEX_FILE" ]] || { echo "HEX file not found: $HEX_FILE"; exit 1; }
sudo openocd \
-f ./raspberrypi-${INTERFACE}.cfg \
-c "init" \
-c "reset init" \
-c "flash banks" \
-c "flash write_image $HEX_FILE" \
-c "verify_image $HEX_FILE" \
-c "reset run" \
-c "shutdown"
sudo openocd \
-f ./raspberrypi-${INTERFACE}.cfg \
-c "init" \
-c "nrf54l.dap apreg 2 0x000 0x1" \
-c "sleep 100" \
-c "nrf54l.dap apreg 2 0x000 0x0" \
-c "shutdown"
sudo openocd \
-f ./raspberrypi-${INTERFACE}.cfg \
-c "init" \
-c "nrf54l.dap apreg 2 0x000 0x4" \
-c "sleep 100" \
-c "nrf54l.dap apreg 2 0x000 0x0" \
-c "shutdown"
echo "Flashing complete."
+13111
View File
File diff suppressed because it is too large Load Diff
+5
View File
@@ -5,4 +5,9 @@ adapter gpio swdio 26
#adapter gpio trst 26 #adapter gpio trst 26
#reset_config trst_only #reset_config trst_only
source [find target/nordic/nrf54l.cfg]
flash bank $_CHIPNAME.flash nrf54 0x00000000 0 0 0 $_TARGETNAME
adapter speed 1000 adapter speed 1000
+5
View File
@@ -5,4 +5,9 @@ adapter gpio swdio 24
#adapter gpio trst 27 #adapter gpio trst 27
#reset_config trst_only #reset_config trst_only
source [find target/nordic/nrf54l.cfg]
flash bank $_CHIPNAME.flash nrf54 0x00000000 0 0 0 $_TARGETNAME
adapter speed 1000 adapter speed 1000
+64
View File
@@ -0,0 +1,64 @@
#!/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.
# Avahi is reloaded on each event — no /etc/avahi/hosts file, avahi uses
# natural per-interface advertisement so each segment gets the right IP.
#
# 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"
# Only handle ethernet interfaces
if [[ ! "$INTERFACE" =~ ^eth ]]; then
exit 0
fi
reload_avahi() {
systemctl reload avahi-daemon 2>/dev/null || systemctl restart avahi-daemon 2>/dev/null
logger -t nm-link-local "[$INTERFACE] $ACTION — avahi reloaded"
}
case "$ACTION" in
up)
# On 'up' the interface may still carry a stale DHCP address from the previous
# session (NM hasn't cleaned it up yet). Reading ip-addr here is unreliable.
# Always re-enable link-local as a clean slate; let dhcp4-change suppress it
# later if a real DHCP lease is obtained.
logger -t nm-link-local "[$INTERFACE] Up — ensuring link-local active (clean slate)"
(sleep 2 && nmcli device modify "$INTERFACE" ipv4.link-local enabled 2>/dev/null \
&& logger -t nm-link-local "[$INTERFACE] Link-local explicitly enabled on up") &
reload_avahi
;;
dhcp4-change)
# dhcp4-change fires only when DHCP actually succeeds (new/renewed lease).
# At this point the DHCP IP is reliably present — safe to read and suppress link-local.
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 confirmed — suppressing link-local (session only)"
# 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") &
fi
reload_avahi
;;
down)
# NOTE: a carrier-change does NOT fully reset session-level 'device modify' state.
# The re-enable is therefore handled in the 'up' handler when no DHCP is detected.
logger -t nm-link-local "[$INTERFACE] Down — link-local will be re-enabled on next up without DHCP"
reload_avahi
;;
esac
+2
View File
@@ -10,6 +10,8 @@ WorkingDirectory=/home/caster/bumble-auracast/src/auracast/server
ExecStart=/home/caster/bumble-auracast/src/auracast/server/start_frontend_https.sh ExecStart=/home/caster/bumble-auracast/src/auracast/server/start_frontend_https.sh
Restart=on-failure Restart=on-failure
Environment=LOG_LEVEL=INFO Environment=LOG_LEVEL=INFO
AllowedCPUs=0
CPUAffinity=0
[Install] [Install]
WantedBy=multi-user.target WantedBy=multi-user.target
+2
View File
@@ -9,6 +9,8 @@ ExecStart=/home/caster/bumble-auracast/.venv/bin/python src/auracast/multicast_s
Restart=on-failure Restart=on-failure
Environment=PYTHONUNBUFFERED=1 Environment=PYTHONUNBUFFERED=1
Environment=LOG_LEVEL=INFO Environment=LOG_LEVEL=INFO
AllowedCPUs=0
CPUAffinity=0
[Install] [Install]
WantedBy=default.target WantedBy=default.target
+5 -2
View File
@@ -1,6 +1,7 @@
[Unit] [Unit]
Description=Auracast Backend Server Description=Auracast Backend Server
After=network.target After=network.target dep.service
Wants=dep.service
[Service] [Service]
Type=simple Type=simple
@@ -10,8 +11,10 @@ Restart=on-failure
Environment=PYTHONUNBUFFERED=1 Environment=PYTHONUNBUFFERED=1
Environment=LOG_LEVEL=INFO Environment=LOG_LEVEL=INFO
CPUSchedulingPolicy=fifo CPUSchedulingPolicy=fifo
CPUSchedulingPriority=99 CPUSchedulingPriority=10
LimitRTPRIO=99 LimitRTPRIO=99
AllowedCPUs=0,1,2
CPUAffinity=0,1,2
[Install] [Install]
WantedBy=default.target WantedBy=default.target
+13
View File
@@ -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
+2
View File
@@ -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 ExecStart=/usr/bin/pipewire-aes67 -c /home/caster/bumble-auracast/src/service/aes67/pipewire-aes67.conf
Restart=always Restart=always
RestartSec=5s RestartSec=5s
AllowedCPUs=0
CPUAffinity=0
# Avoid StartLimitHit on quick failures during boot; let RestartSec handle pacing # Avoid StartLimitHit on quick failures during boot; let RestartSec handle pacing
StartLimitIntervalSec=0 StartLimitIntervalSec=0
+2
View File
@@ -6,6 +6,8 @@ After=network.target
Type=simple Type=simple
ExecStart=/usr/sbin/ptp4l -i eth0 -f /home/caster/bumble-auracast/src/service/aes67/ptp_aes67_1.conf ExecStart=/usr/sbin/ptp4l -i eth0 -f /home/caster/bumble-auracast/src/service/aes67/ptp_aes67_1.conf
Restart=on-failure Restart=on-failure
AllowedCPUs=0
CPUAffinity=0
StandardOutput=journal StandardOutput=journal
StandardError=journal StandardError=journal
+49 -2
View File
@@ -4,6 +4,48 @@ set -e
# This script installs, enables, and restarts the auracast-server and auracast-frontend services # This script installs, enables, and restarts the auracast-server and auracast-frontend services
# Requires sudo privileges # Requires sudo privileges
# Ensure static link local is activated (for direct laptop connection)
# Enable link-local for all wired ethernet connections
while IFS=: read -r name type; do
if [[ "$type" == *"ethernet"* ]]; then
echo "Configuring connection: $name"
# link-local: always enabled so direct-connect (no DHCP) works immediately
sudo nmcli connection modify "$name" ipv4.link-local enabled 2>/dev/null || echo "Failed to modify $name"
# may-fail=yes: do NOT tear down the connection when DHCP times out.
# Without this, NM declares ip-config-unavailable after the 45s DHCP timeout
# and enters a reconnect loop that causes ~1.5 min outages every ~45 seconds.
sudo nmcli connection modify "$name" ipv4.may-fail yes 2>/dev/null || echo "Failed to set may-fail on $name"
# Infinite DHCP timeout: NM keeps retrying DHCP in the background but never
# declares ip-config-unavailable. This prevents the 45s reconnect loop that
# kills the link-local address in direct-connect (no DHCP server) scenarios.
sudo nmcli connection modify "$name" ipv4.dhcp-timeout infinity 2>/dev/null || echo "Failed to set dhcp-timeout on $name"
sudo nmcli connection up "$name" 2>/dev/null || echo "Failed to bring up $name"
fi
done < <(nmcli -t -f NAME,TYPE connection show)
# Remove stale avahi hosts pin — this file overrides per-interface advertisement
# and causes mDNS to always resolve to eth0's IP regardless of which interface
# the query arrived on, breaking eth1 mDNS entirely.
sudo rm -f /etc/avahi/hosts
sudo systemctl restart avahi-daemon
# 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
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 # Copy system service file for frontend
sudo cp /home/caster/bumble-auracast/src/service/auracast-frontend.service /etc/systemd/system/auracast-frontend.service sudo cp /home/caster/bumble-auracast/src/service/auracast-frontend.service /etc/systemd/system/auracast-frontend.service
@@ -11,20 +53,25 @@ sudo cp /home/caster/bumble-auracast/src/service/auracast-frontend.service /etc/
mkdir -p /home/caster/.config/systemd/user 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 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 sudo systemctl daemon-reload
# Reload user systemd for server # Reload user systemd for server
systemctl --user daemon-reload systemctl --user daemon-reload
# Enable DEP to start on boot (system)
sudo systemctl enable dep.service
# Enable frontend to start on boot (system) # Enable frontend to start on boot (system)
sudo systemctl enable auracast-frontend.service sudo systemctl enable auracast-frontend.service
# Enable server to start on boot (user) # Enable server to start on boot (user)
systemctl --user enable auracast-server.service systemctl --user enable auracast-server.service
# Restart both # Restart all
sudo systemctl restart dep.service
sudo systemctl restart auracast-frontend.service sudo systemctl restart auracast-frontend.service
systemctl --user restart auracast-server.service systemctl --user restart auracast-server.service
#print status #print status
sudo systemctl status dep.service --no-pager
sudo systemctl status auracast-frontend.service --no-pager sudo systemctl status auracast-frontend.service --no-pager
systemctl --user status auracast-server.service --no-pager systemctl --user status auracast-server.service --no-pager