Merge branch 'main' of ssh://ssh.pstruebi.xyz:222/auracaster/bumble-auracast

This commit is contained in:
2025-10-14 13:46:06 +02:00
23 changed files with 1378 additions and 512 deletions

5
.gitignore vendored
View File

@@ -35,10 +35,11 @@ env/
__pycache__/
# Exclude .env file from all platforms
*/.env
*.env
wg_config/wg_confs/
records/
records/DISABLE_FRONTEND_PW
src/auracast/server/stream_settings.json
src/auracast/server/certs/per_device/
src/auracast/.env
src/auracast/server/certs/ca/ca_cert.srl

View File

@@ -61,6 +61,22 @@ sudo ./provision_domain_hostname.sh <new_hostname> <new_domain>
- If you have issues with mDNS name resolution, check for conflicting mDNS stacks (e.g., systemd-resolved, Bonjour, or other daemons).
- Some Linux clients may not resolve multi-label mDNS names via NSS—test with `avahi-resolve-host-name` and try from another device if needed.
# record audio and save to file for debugging
pw-record --target="AVIOUSB-8f6326 : 2:receive_Left" --rate=48000 --channels=1 --format=s24 /tmp/aes67_test.wav &
RECORD_PID=$!
sleep 30
kill $RECORD_PID
# uart reset over hci does not work:
stty -F /dev/ttyAMA3 -hupcl
stty -F /dev/ttyAMA3 -a | grep -o 'hupcl' || echo "-hupcl is set"
# Audio latency
if there is hearable audio error with aes67, tune sess.latency.msec in pipewire-aes67.conf
if latency is piling up something may be blocking the event loop in multicast_server.py - the event loop must never block at any time
---
After completing these steps, your device will be discoverable as `<hostname>.<domain>` (e.g., `box1.auracast.local`) on the local network via mDNS.

108
poetry.lock generated
View File

@@ -332,10 +332,10 @@ files = [
[[package]]
name = "bumble"
version = "0.0.209.dev2+g12bcdb7"
version = "0.0.218.dev6+g32d448edf"
description = "Bluetooth Stack for Apps, Emulation, Test and Experimentation"
optional = false
python-versions = ">=3.8"
python-versions = ">=3.9"
groups = ["main"]
files = []
develop = false
@@ -344,7 +344,7 @@ develop = false
aiohttp = {version = ">=3.8,<4.0", markers = "platform_system != \"Emscripten\""}
appdirs = {version = ">=1.4", markers = "platform_system != \"Emscripten\""}
click = {version = ">=8.1.3", markers = "platform_system != \"Emscripten\""}
cryptography = ">=39"
cryptography = ">=44.0.3"
grpcio = {version = ">=1.62.1", markers = "platform_system != \"Emscripten\""}
humanize = {version = ">=4.6.0", markers = "platform_system != \"Emscripten\""}
libusb-package = {version = "1.0.26.1", markers = "platform_system != \"Emscripten\""}
@@ -353,7 +353,7 @@ platformdirs = {version = ">=3.10.0", markers = "platform_system != \"Emscripten
prettytable = {version = ">=3.6.0", markers = "platform_system != \"Emscripten\""}
prompt_toolkit = {version = ">=3.0.16", markers = "platform_system != \"Emscripten\""}
protobuf = {version = ">=3.12.4", markers = "platform_system != \"Emscripten\""}
pyee = ">=8.2.2"
pyee = ">=13.0.0"
pyserial = {version = ">=3.5", markers = "platform_system != \"Emscripten\""}
pyserial-asyncio = {version = ">=0.5", markers = "platform_system != \"Emscripten\""}
pyusb = {version = ">=1.2", markers = "platform_system != \"Emscripten\""}
@@ -363,7 +363,7 @@ websockets = {version = "13.1", markers = "platform_system != \"Emscripten\""}
auracast = ["lc3py (>=1.1.3) ; python_version >= \"3.10\" and (platform_system == \"Linux\" and platform_machine == \"x86_64\" or platform_system == \"Darwin\" and platform_machine == \"arm64\")", "sounddevice (>=0.5.1)"]
avatar = ["pandora-avatar (==0.0.10)", "rootcanal (==1.11.1) ; python_version >= \"3.10\""]
build = ["build (>=0.7)"]
development = ["black (==24.3)", "bt-test-interfaces (>=0.0.6)", "grpcio-tools (>=1.62.1)", "invoke (>=1.7.3)", "mobly (>=1.12.2)", "mypy (==1.12.0)", "nox (>=2022)", "pylint (==3.3.1)", "pyyaml (>=6.0)", "types-appdirs (>=1.4.3)", "types-invoke (>=1.7.3)", "types-protobuf (>=4.21.0)"]
development = ["black (>=25.1,<26.0)", "bt-test-interfaces (>=0.0.6)", "grpcio-tools (>=1.62.1)", "invoke (>=1.7.3)", "isort (>=5.13.2,<5.14.0)", "mobly (>=1.12.2)", "mypy (==1.12.0)", "nox (>=2022)", "pylint (==3.3.1)", "pyyaml (>=6.0)", "types-appdirs (>=1.4.3)", "types-invoke (>=1.7.3)", "types-protobuf (>=4.21.0)"]
documentation = ["mkdocs (>=1.6.0)", "mkdocs-material (>=9.6)", "mkdocstrings[python] (>=0.27.0)"]
pandora = ["bt-test-interfaces (>=0.0.6)"]
test = ["coverage (>=6.4)", "pytest (>=8.2)", "pytest-asyncio (>=0.23.5)", "pytest-html (>=3.2.0)"]
@@ -371,8 +371,8 @@ test = ["coverage (>=6.4)", "pytest (>=8.2)", "pytest-asyncio (>=0.23.5)", "pyte
[package.source]
type = "git"
url = "ssh://git@ssh.pstruebi.xyz:222/auracaster/bumble_mirror.git"
reference = "12bcdb7770c0d57a094bc0a96cd52e701f97fece"
resolved_reference = "12bcdb7770c0d57a094bc0a96cd52e701f97fece"
reference = "32d448edf3276f6b9056765a12879054d8a01fd8"
resolved_reference = "32d448edf3276f6b9056765a12879054d8a01fd8"
[[package]]
name = "cachetools"
@@ -610,60 +610,62 @@ files = [
[[package]]
name = "cryptography"
version = "44.0.2"
version = "45.0.7"
description = "cryptography is a package which provides cryptographic recipes and primitives to Python developers."
optional = false
python-versions = "!=3.9.0,!=3.9.1,>=3.7"
groups = ["main"]
files = [
{file = "cryptography-44.0.2-cp37-abi3-macosx_10_9_universal2.whl", hash = "sha256:efcfe97d1b3c79e486554efddeb8f6f53a4cdd4cf6086642784fa31fc384e1d7"},
{file = "cryptography-44.0.2-cp37-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:29ecec49f3ba3f3849362854b7253a9f59799e3763b0c9d0826259a88efa02f1"},
{file = "cryptography-44.0.2-cp37-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:bc821e161ae88bfe8088d11bb39caf2916562e0a2dc7b6d56714a48b784ef0bb"},
{file = "cryptography-44.0.2-cp37-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:3c00b6b757b32ce0f62c574b78b939afab9eecaf597c4d624caca4f9e71e7843"},
{file = "cryptography-44.0.2-cp37-abi3-manylinux_2_28_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:7bdcd82189759aba3816d1f729ce42ffded1ac304c151d0a8e89b9996ab863d5"},
{file = "cryptography-44.0.2-cp37-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:4973da6ca3db4405c54cd0b26d328be54c7747e89e284fcff166132eb7bccc9c"},
{file = "cryptography-44.0.2-cp37-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:4e389622b6927d8133f314949a9812972711a111d577a5d1f4bee5e58736b80a"},
{file = "cryptography-44.0.2-cp37-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:f514ef4cd14bb6fb484b4a60203e912cfcb64f2ab139e88c2274511514bf7308"},
{file = "cryptography-44.0.2-cp37-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:1bc312dfb7a6e5d66082c87c34c8a62176e684b6fe3d90fcfe1568de675e6688"},
{file = "cryptography-44.0.2-cp37-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:3b721b8b4d948b218c88cb8c45a01793483821e709afe5f622861fc6182b20a7"},
{file = "cryptography-44.0.2-cp37-abi3-win32.whl", hash = "sha256:51e4de3af4ec3899d6d178a8c005226491c27c4ba84101bfb59c901e10ca9f79"},
{file = "cryptography-44.0.2-cp37-abi3-win_amd64.whl", hash = "sha256:c505d61b6176aaf982c5717ce04e87da5abc9a36a5b39ac03905c4aafe8de7aa"},
{file = "cryptography-44.0.2-cp39-abi3-macosx_10_9_universal2.whl", hash = "sha256:8e0ddd63e6bf1161800592c71ac794d3fb8001f2caebe0966e77c5234fa9efc3"},
{file = "cryptography-44.0.2-cp39-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:81276f0ea79a208d961c433a947029e1a15948966658cf6710bbabb60fcc2639"},
{file = "cryptography-44.0.2-cp39-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9a1e657c0f4ea2a23304ee3f964db058c9e9e635cc7019c4aa21c330755ef6fd"},
{file = "cryptography-44.0.2-cp39-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:6210c05941994290f3f7f175a4a57dbbb2afd9273657614c506d5976db061181"},
{file = "cryptography-44.0.2-cp39-abi3-manylinux_2_28_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:d1c3572526997b36f245a96a2b1713bf79ce99b271bbcf084beb6b9b075f29ea"},
{file = "cryptography-44.0.2-cp39-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:b042d2a275c8cee83a4b7ae30c45a15e6a4baa65a179a0ec2d78ebb90e4f6699"},
{file = "cryptography-44.0.2-cp39-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:d03806036b4f89e3b13b6218fefea8d5312e450935b1a2d55f0524e2ed7c59d9"},
{file = "cryptography-44.0.2-cp39-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:c7362add18b416b69d58c910caa217f980c5ef39b23a38a0880dfd87bdf8cd23"},
{file = "cryptography-44.0.2-cp39-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:8cadc6e3b5a1f144a039ea08a0bdb03a2a92e19c46be3285123d32029f40a922"},
{file = "cryptography-44.0.2-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:6f101b1f780f7fc613d040ca4bdf835c6ef3b00e9bd7125a4255ec574c7916e4"},
{file = "cryptography-44.0.2-cp39-abi3-win32.whl", hash = "sha256:3dc62975e31617badc19a906481deacdeb80b4bb454394b4098e3f2525a488c5"},
{file = "cryptography-44.0.2-cp39-abi3-win_amd64.whl", hash = "sha256:5f6f90b72d8ccadb9c6e311c775c8305381db88374c65fa1a68250aa8a9cb3a6"},
{file = "cryptography-44.0.2-pp310-pypy310_pp73-macosx_10_9_x86_64.whl", hash = "sha256:af4ff3e388f2fa7bff9f7f2b31b87d5651c45731d3e8cfa0944be43dff5cfbdb"},
{file = "cryptography-44.0.2-pp310-pypy310_pp73-manylinux_2_28_aarch64.whl", hash = "sha256:0529b1d5a0105dd3731fa65680b45ce49da4d8115ea76e9da77a875396727b41"},
{file = "cryptography-44.0.2-pp310-pypy310_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:7ca25849404be2f8e4b3c59483d9d3c51298a22c1c61a0e84415104dacaf5562"},
{file = "cryptography-44.0.2-pp310-pypy310_pp73-manylinux_2_34_aarch64.whl", hash = "sha256:268e4e9b177c76d569e8a145a6939eca9a5fec658c932348598818acf31ae9a5"},
{file = "cryptography-44.0.2-pp310-pypy310_pp73-manylinux_2_34_x86_64.whl", hash = "sha256:9eb9d22b0a5d8fd9925a7764a054dca914000607dff201a24c791ff5c799e1fa"},
{file = "cryptography-44.0.2-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:2bf7bf75f7df9715f810d1b038870309342bff3069c5bd8c6b96128cb158668d"},
{file = "cryptography-44.0.2-pp311-pypy311_pp73-manylinux_2_28_aarch64.whl", hash = "sha256:909c97ab43a9c0c0b0ada7a1281430e4e5ec0458e6d9244c0e821bbf152f061d"},
{file = "cryptography-44.0.2-pp311-pypy311_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:96e7a5e9d6e71f9f4fca8eebfd603f8e86c5225bb18eb621b2c1e50b290a9471"},
{file = "cryptography-44.0.2-pp311-pypy311_pp73-manylinux_2_34_aarch64.whl", hash = "sha256:d1b3031093a366ac767b3feb8bcddb596671b3aaff82d4050f984da0c248b615"},
{file = "cryptography-44.0.2-pp311-pypy311_pp73-manylinux_2_34_x86_64.whl", hash = "sha256:04abd71114848aa25edb28e225ab5f268096f44cf0127f3d36975bdf1bdf3390"},
{file = "cryptography-44.0.2.tar.gz", hash = "sha256:c63454aa261a0cf0c5b4718349629793e9e634993538db841165b3df74f37ec0"},
{file = "cryptography-45.0.7-cp311-abi3-macosx_10_9_universal2.whl", hash = "sha256:3be4f21c6245930688bd9e162829480de027f8bf962ede33d4f8ba7d67a00cee"},
{file = "cryptography-45.0.7-cp311-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:67285f8a611b0ebc0857ced2081e30302909f571a46bfa7a3cc0ad303fe015c6"},
{file = "cryptography-45.0.7-cp311-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:577470e39e60a6cd7780793202e63536026d9b8641de011ed9d8174da9ca5339"},
{file = "cryptography-45.0.7-cp311-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:4bd3e5c4b9682bc112d634f2c6ccc6736ed3635fc3319ac2bb11d768cc5a00d8"},
{file = "cryptography-45.0.7-cp311-abi3-manylinux_2_28_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:465ccac9d70115cd4de7186e60cfe989de73f7bb23e8a7aa45af18f7412e75bf"},
{file = "cryptography-45.0.7-cp311-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:16ede8a4f7929b4b7ff3642eba2bf79aa1d71f24ab6ee443935c0d269b6bc513"},
{file = "cryptography-45.0.7-cp311-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:8978132287a9d3ad6b54fcd1e08548033cc09dc6aacacb6c004c73c3eb5d3ac3"},
{file = "cryptography-45.0.7-cp311-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:b6a0e535baec27b528cb07a119f321ac024592388c5681a5ced167ae98e9fff3"},
{file = "cryptography-45.0.7-cp311-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:a24ee598d10befaec178efdff6054bc4d7e883f615bfbcd08126a0f4931c83a6"},
{file = "cryptography-45.0.7-cp311-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:fa26fa54c0a9384c27fcdc905a2fb7d60ac6e47d14bc2692145f2b3b1e2cfdbd"},
{file = "cryptography-45.0.7-cp311-abi3-win32.whl", hash = "sha256:bef32a5e327bd8e5af915d3416ffefdbe65ed975b646b3805be81b23580b57b8"},
{file = "cryptography-45.0.7-cp311-abi3-win_amd64.whl", hash = "sha256:3808e6b2e5f0b46d981c24d79648e5c25c35e59902ea4391a0dcb3e667bf7443"},
{file = "cryptography-45.0.7-cp37-abi3-macosx_10_9_universal2.whl", hash = "sha256:bfb4c801f65dd61cedfc61a83732327fafbac55a47282e6f26f073ca7a41c3b2"},
{file = "cryptography-45.0.7-cp37-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:81823935e2f8d476707e85a78a405953a03ef7b7b4f55f93f7c2d9680e5e0691"},
{file = "cryptography-45.0.7-cp37-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:3994c809c17fc570c2af12c9b840d7cea85a9fd3e5c0e0491f4fa3c029216d59"},
{file = "cryptography-45.0.7-cp37-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:dad43797959a74103cb59c5dac71409f9c27d34c8a05921341fb64ea8ccb1dd4"},
{file = "cryptography-45.0.7-cp37-abi3-manylinux_2_28_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:ce7a453385e4c4693985b4a4a3533e041558851eae061a58a5405363b098fcd3"},
{file = "cryptography-45.0.7-cp37-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:b04f85ac3a90c227b6e5890acb0edbaf3140938dbecf07bff618bf3638578cf1"},
{file = "cryptography-45.0.7-cp37-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:48c41a44ef8b8c2e80ca4527ee81daa4c527df3ecbc9423c41a420a9559d0e27"},
{file = "cryptography-45.0.7-cp37-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:f3df7b3d0f91b88b2106031fd995802a2e9ae13e02c36c1fc075b43f420f3a17"},
{file = "cryptography-45.0.7-cp37-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:dd342f085542f6eb894ca00ef70236ea46070c8a13824c6bde0dfdcd36065b9b"},
{file = "cryptography-45.0.7-cp37-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:1993a1bb7e4eccfb922b6cd414f072e08ff5816702a0bdb8941c247a6b1b287c"},
{file = "cryptography-45.0.7-cp37-abi3-win32.whl", hash = "sha256:18fcf70f243fe07252dcb1b268a687f2358025ce32f9f88028ca5c364b123ef5"},
{file = "cryptography-45.0.7-cp37-abi3-win_amd64.whl", hash = "sha256:7285a89df4900ed3bfaad5679b1e668cb4b38a8de1ccbfc84b05f34512da0a90"},
{file = "cryptography-45.0.7-pp310-pypy310_pp73-macosx_10_9_x86_64.whl", hash = "sha256:de58755d723e86175756f463f2f0bddd45cc36fbd62601228a3f8761c9f58252"},
{file = "cryptography-45.0.7-pp310-pypy310_pp73-manylinux_2_28_aarch64.whl", hash = "sha256:a20e442e917889d1a6b3c570c9e3fa2fdc398c20868abcea268ea33c024c4083"},
{file = "cryptography-45.0.7-pp310-pypy310_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:258e0dff86d1d891169b5af222d362468a9570e2532923088658aa866eb11130"},
{file = "cryptography-45.0.7-pp310-pypy310_pp73-manylinux_2_34_aarch64.whl", hash = "sha256:d97cf502abe2ab9eff8bd5e4aca274da8d06dd3ef08b759a8d6143f4ad65d4b4"},
{file = "cryptography-45.0.7-pp310-pypy310_pp73-manylinux_2_34_x86_64.whl", hash = "sha256:c987dad82e8c65ebc985f5dae5e74a3beda9d0a2a4daf8a1115f3772b59e5141"},
{file = "cryptography-45.0.7-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:c13b1e3afd29a5b3b2656257f14669ca8fa8d7956d509926f0b130b600b50ab7"},
{file = "cryptography-45.0.7-pp311-pypy311_pp73-macosx_10_9_x86_64.whl", hash = "sha256:4a862753b36620af6fc54209264f92c716367f2f0ff4624952276a6bbd18cbde"},
{file = "cryptography-45.0.7-pp311-pypy311_pp73-manylinux_2_28_aarch64.whl", hash = "sha256:06ce84dc14df0bf6ea84666f958e6080cdb6fe1231be2a51f3fc1267d9f3fb34"},
{file = "cryptography-45.0.7-pp311-pypy311_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:d0c5c6bac22b177bf8da7435d9d27a6834ee130309749d162b26c3105c0795a9"},
{file = "cryptography-45.0.7-pp311-pypy311_pp73-manylinux_2_34_aarch64.whl", hash = "sha256:2f641b64acc00811da98df63df7d59fd4706c0df449da71cb7ac39a0732b40ae"},
{file = "cryptography-45.0.7-pp311-pypy311_pp73-manylinux_2_34_x86_64.whl", hash = "sha256:f5414a788ecc6ee6bc58560e85ca624258a55ca434884445440a810796ea0e0b"},
{file = "cryptography-45.0.7-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:1f3d56f73595376f4244646dd5c5870c14c196949807be39e79e7bd9bac3da63"},
{file = "cryptography-45.0.7.tar.gz", hash = "sha256:4b1654dfc64ea479c242508eb8c724044f1e964a47d1d1cacc5132292d851971"},
]
[package.dependencies]
cffi = {version = ">=1.12", markers = "platform_python_implementation != \"PyPy\""}
cffi = {version = ">=1.14", markers = "platform_python_implementation != \"PyPy\""}
[package.extras]
docs = ["sphinx (>=5.3.0)", "sphinx-rtd-theme (>=3.0.0) ; python_version >= \"3.8\""]
docs = ["sphinx (>=5.3.0)", "sphinx-inline-tabs ; python_full_version >= \"3.8.0\"", "sphinx-rtd-theme (>=3.0.0) ; python_full_version >= \"3.8.0\""]
docstest = ["pyenchant (>=3)", "readme-renderer (>=30.0)", "sphinxcontrib-spelling (>=7.3.1)"]
nox = ["nox (>=2024.4.15)", "nox[uv] (>=2024.3.2) ; python_version >= \"3.8\""]
pep8test = ["check-sdist ; python_version >= \"3.8\"", "click (>=8.0.1)", "mypy (>=1.4)", "ruff (>=0.3.6)"]
nox = ["nox (>=2024.4.15)", "nox[uv] (>=2024.3.2) ; python_full_version >= \"3.8.0\""]
pep8test = ["check-sdist ; python_full_version >= \"3.8.0\"", "click (>=8.0.1)", "mypy (>=1.4)", "ruff (>=0.3.6)"]
sdist = ["build (>=1.0.0)"]
ssh = ["bcrypt (>=3.1.5)"]
test = ["certifi (>=2024)", "cryptography-vectors (==44.0.2)", "pretend (>=0.7)", "pytest (>=7.4.0)", "pytest-benchmark (>=4.0)", "pytest-cov (>=2.10.1)", "pytest-xdist (>=3.5.0)"]
test = ["certifi (>=2024)", "cryptography-vectors (==45.0.7)", "pretend (>=0.7)", "pytest (>=7.4.0)", "pytest-benchmark (>=4.0)", "pytest-cov (>=2.10.1)", "pytest-xdist (>=3.5.0)"]
test-randomorder = ["pytest-randomly"]
[[package]]
@@ -1080,8 +1082,8 @@ files = [
referencing = ">=0.31.0"
[[package]]
name = "lc3"
version = "0.0.1"
name = "lc3py"
version = "1.1.3"
description = "LC3 Codec library wrapper"
optional = false
python-versions = ">=3.10"
@@ -1095,8 +1097,8 @@ dev = ["pytest"]
[package.source]
type = "git"
url = "ssh://git@ssh.pstruebi.xyz:222/auracaster/liblc3.git"
reference = "7558637303106c7ea971e7bb8cedf379d3e08bcc"
resolved_reference = "7558637303106c7ea971e7bb8cedf379d3e08bcc"
reference = "ce2e41faf8c06d038df9f32504c61109a14130be"
resolved_reference = "ce2e41faf8c06d038df9f32504c61109a14130be"
[[package]]
name = "libusb-package"
@@ -2950,4 +2952,4 @@ test = ["pytest", "pytest-asyncio"]
[metadata]
lock-version = "2.1"
python-versions = ">=3.11"
content-hash = "9fe0e4746a6fca45e5aa9117ca177a5587c3a7b83cacb9427bdb960c4f0c7036"
content-hash = "6b5300c349ed045e8fd3e617e6262bbd7e5c48c518e4c62cedf7c17da50ce8c0"

View File

@@ -4,8 +4,8 @@ version = "0.0.1"
requires-python = ">=3.11"
dependencies = [
"bumble @ git+ssh://git@ssh.pstruebi.xyz:222/auracaster/bumble_mirror.git@12bcdb7770c0d57a094bc0a96cd52e701f97fece",
"lc3 @ git+ssh://git@ssh.pstruebi.xyz:222/auracaster/liblc3.git@7558637303106c7ea971e7bb8cedf379d3e08bcc",
"bumble @ git+ssh://git@ssh.pstruebi.xyz:222/auracaster/bumble_mirror.git@32d448edf3276f6b9056765a12879054d8a01fd8",
"lc3py @ git+ssh://git@ssh.pstruebi.xyz:222/auracaster/liblc3.git@ce2e41faf8c06d038df9f32504c61109a14130be",
"aioconsole",
"fastapi==0.115.11",
"uvicorn==0.34.0",

View File

@@ -39,6 +39,7 @@ class AuracastGlobalConfig(BaseModel):
# When true, include a zero-length LTV with type 0x09 in the subgroup metadata
# so receivers may render earlier than the presentation delay for lower latency.
immediate_rendering: bool = False
assisted_listening_stream: bool = False
# "Audio input. "
# "'device' -> use the host's default sound input device, "

View File

@@ -45,7 +45,7 @@ import bumble.device
import bumble.transport
import bumble.utils
import numpy as np # for audio down-mix
from bumble.device import Host, BIGInfoAdvertisement, AdvertisingChannelMap
from bumble.device import Host, AdvertisingChannelMap
from bumble.audio import io as audio_io
from auracast import auracast_config
@@ -101,6 +101,24 @@ class ModWaveAudioInput(audio_io.ThreadedAudioInput):
audio_io.WaveAudioInput = ModWaveAudioInput
def broadcast_code_bytes(broadcast_code: str) -> bytes:
"""
Convert a broadcast code string to a 16-byte value.
If `broadcast_code` is `0x` followed by 32 hex characters, it is interpreted as a
raw 16-byte raw broadcast code in big-endian byte order.
Otherwise, `broadcast_code` is converted to a 16-byte value as specified in
BLUETOOTH CORE SPECIFICATION Version 6.0 | Vol 3, Part C , section 3.2.6.3
"""
if broadcast_code.startswith("0x") and len(broadcast_code) == 34:
return bytes.fromhex(broadcast_code[2:])[::-1]
broadcast_code_utf8 = broadcast_code.encode("utf-8")
if len(broadcast_code_utf8) > 16:
raise ValueError("broadcast code must be <= 16 bytes in utf-8 encoding")
padding = bytes(16 - len(broadcast_code_utf8))
return broadcast_code_utf8 + padding
# -----------------------------------------------------------------------------
# Logging
# -----------------------------------------------------------------------------
@@ -170,7 +188,15 @@ async def init_broadcast(
# Broadcast Audio Immediate Rendering flag (type 0x09), zero-length value
le_audio.Metadata.Entry(tag = le_audio.Metadata.Tag.BROADCAST_AUDIO_IMMEDIATE_RENDERING_FLAG, data=b"")
]
if global_config.immediate_rendering #TODO: verify this
if global_config.immediate_rendering
else []
)
+ (
[
# Assisted Listening Stream tag expects a 1-octet value. Use 0x01 to indicate enabled.
le_audio.Metadata.Entry(tag = le_audio.Metadata.Tag.ASSISTED_LISTENING_STREAM, data=b"\x01")
]
if global_config.assisted_listening_stream
else []
)
)
@@ -269,7 +295,7 @@ async def init_broadcast(
max_transport_latency=global_config.qos_config.max_transport_latency_ms,
rtn=global_config.qos_config.number_of_retransmissions,
broadcast_code=(
bytes.fromhex(conf.code) if conf.code else None
broadcast_code_bytes(conf.code) if conf.code else None
),
framing=frame_enable # needed if iso interval is not frame interval of codedc
),
@@ -287,8 +313,8 @@ async def init_broadcast(
bigs[f'big{i}']['iso_queue'] = iso_queue
logging.debug(f'big{i} parameters are:')
logging.debug('%s', pprint.pformat(vars(big)))
logging.info(f'big{i} parameters are:')
logging.info('%s', pprint.pformat(vars(big)))
logging.info(f'Finished setup of big{i}.')
await asyncio.sleep(i+1) # Wait for advertising to set up
@@ -349,18 +375,51 @@ class Streamer():
if self.task is not None:
self.task.cancel()
# Let cancellation propagate to the stream() coroutine
await asyncio.sleep(0.01)
self.task = None
# Close audio inputs (await to ensure ALSA devices are released)
close_tasks = []
async_closers = []
sync_closers = []
for big in self.bigs.values():
ai = big.get("audio_input")
if ai and hasattr(ai, "close"):
close_tasks.append(ai.close())
if not ai:
continue
# First close any frames generator backed by the input to stop reads
frames_gen = big.get("frames_gen")
if frames_gen and hasattr(frames_gen, "aclose"):
try:
await frames_gen.aclose()
except Exception:
pass
big.pop("frames_gen", None)
if hasattr(ai, "aclose") and callable(getattr(ai, "aclose")):
async_closers.append(ai.aclose())
elif hasattr(ai, "close") and callable(getattr(ai, "close")):
sync_closers.append(ai.close)
# Remove reference so a fresh one is created next time
big.pop("audio_input", None)
if close_tasks:
await asyncio.gather(*close_tasks, return_exceptions=True)
if async_closers:
await asyncio.gather(*async_closers, return_exceptions=True)
for fn in sync_closers:
try:
fn()
except Exception:
pass
# Reset PortAudio to drop lingering PipeWire capture nodes
try:
import sounddevice as _sd
if hasattr(_sd, "_terminate"):
_sd._terminate()
await asyncio.sleep(0.05)
if hasattr(_sd, "_initialize"):
_sd._initialize()
except Exception:
pass
async def stream(self):
@@ -388,6 +447,8 @@ class Streamer():
big['audio_input'] = audio_source
big['encoder'] = encoder
big['precoded'] = False
# Prepare frames generator for graceful shutdown
big['frames_gen'] = big['audio_input'].frames(lc3_frame_samples)
elif audio_source == 'webrtc':
big['audio_input'] = WebRTCAudioInput()
@@ -403,6 +464,8 @@ class Streamer():
big['lc3_bytes_per_frame'] = global_config.octets_per_frame
big['encoder'] = encoder
big['precoded'] = False
# Prepare frames generator for graceful shutdown
big['frames_gen'] = big['audio_input'].frames(lc3_frame_samples)
# precoded lc3 from ram
elif isinstance(big_config[i].audio_source, bytes):
@@ -573,7 +636,12 @@ class Streamer():
stream_finished[i] = True
continue
else: # code lc3 on the fly
pcm_frame = await anext(big['audio_input'].frames(big['lc3_frame_samples']), None)
# Use stored frames generator when available so we can aclose() it on stop
frames_gen = big.get('frames_gen')
if frames_gen is None:
frames_gen = big['audio_input'].frames(big['lc3_frame_samples'])
big['frames_gen'] = frames_gen
pcm_frame = await anext(frames_gen, None)
if pcm_frame is None: # Not all streams may stop at the same time
stream_finished[i] = True
@@ -674,7 +742,7 @@ if __name__ == "__main__":
# TODO: encrypted streams are not working
for big in config.bigs:
#big.code = 'ff'*16 # returns hci/HCI_ENCRYPTION_MODE_NOT_ACCEPTABLE_ERROR
#big.code = 'abcd'
#big.code = '78 e5 dc f1 34 ab 42 bf c1 92 ef dd 3a fd 67 ae'
big.precode_wav = False
#big.audio_source = big.audio_source.replace('.wav', '_10_16_32.lc3') #lc3 precoded files

View File

@@ -91,6 +91,8 @@ class Multicaster:
for big in self.bigs.values():
if big.get('advertising_set'):
await big['advertising_set'].stop()
# Explicitly power off the device to ensure a clean state before closing the transport
await self.device.power_off()
await self.device_acm.__aexit__(None, None, None) # Manually triggering teardown

View File

@@ -44,7 +44,11 @@ import time
from dotenv import load_dotenv
from auracast import multicast
from auracast import auracast_config
from auracast.utils.sounddevice_utils import list_usb_pw_inputs, list_network_pw_inputs
from auracast.utils.sounddevice_utils import (
get_usb_pw_inputs,
get_network_pw_inputs,
refresh_pw_cache,
)
if __name__ == "__main__":
@@ -59,12 +63,14 @@ if __name__ == "__main__":
os.environ.setdefault("PULSE_LATENCY_MSEC", "3")
usb_inputs = list_usb_pw_inputs()
# Refresh device cache and list inputs
refresh_pw_cache()
usb_inputs = get_usb_pw_inputs()
logging.info("USB pw inputs:")
for i, d in usb_inputs:
logging.info(f"{i}: {d['name']} in={d['max_input_channels']}")
aes67_inputs = list_network_pw_inputs()
aes67_inputs = get_network_pw_inputs()
logging.info("AES67 pw inputs:")
for i, d in aes67_inputs:
logging.info(f"{i}: {d['name']} in={d['max_input_channels']}")
@@ -85,7 +91,8 @@ if __name__ == "__main__":
if iface_substr:
# Loop until a matching AES67 input becomes available
while True:
current = list_network_pw_inputs()
refresh_pw_cache()
current = get_network_pw_inputs()
sel = next(((i, d) for i, d in current if iface_substr in (d.get('name','').lower())), None)
if sel:
input_sel = sel[0]
@@ -100,7 +107,8 @@ if __name__ == "__main__":
else:
# Loop until a USB input becomes available (mirror AES67 retry behavior)
while True:
current = list_usb_pw_inputs()
refresh_pw_cache()
current = get_usb_pw_inputs()
if current:
input_sel, selected_dev = current[0]
logging.info(f"Selected first USB input: index={input_sel}, device={selected_dev['name']}")
@@ -146,11 +154,12 @@ if __name__ == "__main__":
presentation_delay_us=40000,
qos_config=auracast_config.AuracastQosHigh(),
auracast_sampling_rate_hz = LC3_SRATE,
octets_per_frame = OCTETS_PER_FRAME, # 32kbps@16kHz
octets_per_frame = OCTETS_PER_FRAME,
transport=TRANSPORT1
)
#config.debug = True
logging.info(config.model_dump_json(indent=2))
multicast.run_async(
multicast.broadcast(
config,

View File

@@ -1,23 +1,88 @@
# frontend/app.py
import os
import time
import streamlit as st
import requests
from auracast import auracast_config
import logging as log
from PIL import Image
import requests
from dotenv import load_dotenv
import streamlit as st
from auracast import auracast_config
from auracast.utils.frontend_auth import (
is_pw_disabled,
load_pw_record,
save_pw_record,
hash_password,
verify_password,
)
# Set page configuration (tab title and icon) before using other Streamlit APIs
# Always use the favicon from the utils folder relative to this file
_THIS_DIR = os.path.dirname(__file__)
_FAVICON_PATH = os.path.abspath(os.path.join(_THIS_DIR, '..', 'utils', 'favicon.ico'))
favicon = Image.open(_FAVICON_PATH)
st.set_page_config(page_title="Castbox", page_icon=favicon, layout="centered")
# Load environment variables from a .env file if present
load_dotenv()
# Track whether WebRTC stream is active across Streamlit reruns
if 'stream_started' not in st.session_state:
st.session_state['stream_started'] = False
# Frontend authentication gate is controlled via env using shared utils
if 'frontend_authenticated' not in st.session_state:
st.session_state['frontend_authenticated'] = False
if not is_pw_disabled():
pw_rec = load_pw_record()
# First-time setup: no password set -> force user to choose one
if pw_rec is None:
st.header("Set up your frontend password")
st.info("For security, you must set a password on first access.")
with st.form("first_setup_form"):
new_pw = st.text_input("New password", type="password")
new_pw2 = st.text_input("Confirm password", type="password")
submitted = st.form_submit_button("Save password")
if submitted:
if len(new_pw) < 6:
st.error("Password should be at least 6 characters.")
elif new_pw != new_pw2:
st.error("Passwords do not match.")
else:
salt, key = hash_password(new_pw)
try:
save_pw_record(salt, key)
st.success("Password saved. You can now sign in.")
st.rerun()
except Exception as e:
st.error(f"Failed to save password: {e}")
st.stop()
# Normal sign-in gate
if not st.session_state['frontend_authenticated']:
st.header("Sign in")
with st.form("signin_form"):
pw = st.text_input("Password", type="password")
submitted = st.form_submit_button("Sign in")
if submitted:
if verify_password(pw, pw_rec):
st.session_state['frontend_authenticated'] = True
st.success("Signed in.")
st.rerun()
else:
st.error("Incorrect password. Please try again.")
# Stop rendering the rest of the app until authenticated
if not st.session_state['frontend_authenticated']:
st.stop()
# Global: desired packetization time in ms for Opus (should match backend)
PTIME = 40
BACKEND_URL = "http://localhost:5000"
#TRANSPORT1 = "serial:/dev/serial/by-id/usb-ZEPHYR_Zephyr_HCI_UART_sample_B53C372677E14460-if00,115200,rtscts"
#TRANSPORT2 = "serial:/dev/serial/by-id/usb-ZEPHYR_Zephyr_HCI_UART_sample_CC69A2912F84AE5E-if00,115200,rtscts"
TRANSPORT1 = 'serial:/dev/ttyAMA3,1000000,rtscts' # transport for raspberry pi gpio header
TRANSPORT2 = 'serial:/dev/ttyAMA4,1000000,rtscts' # transport for raspberry pi gpio header
QUALITY_MAP = {
"High (48kHz)": {"rate": 48000, "octets": 120},
"Good (32kHz)": {"rate": 32000, "octets": 80},
@@ -34,19 +99,34 @@ try:
except Exception:
saved_settings = {}
st.title("🎙️ Auracast Audio Mode Control")
# Define is_streaming early from the fetched status for use throughout the UI
is_streaming = bool(saved_settings.get("is_streaming", False))
st.title("Auracast Audio Mode Control")
# Audio mode selection with persisted default
options = ["Webapp", "USB/Network", "Demo"]
saved_audio_mode = saved_settings.get("audio_mode", "Webapp")
# Note: backend persists 'USB' for any device:<name> source (including AES67). We default to 'USB' in that case.
options = [
"Demo",
"USB",
"AES67",
# "Webapp"
]
saved_audio_mode = saved_settings.get("audio_mode", "Demo")
if saved_audio_mode not in options:
saved_audio_mode = "Webapp"
# Map legacy/unknown modes to closest
mapping = {"USB/Network": "USB", "Network": "AES67"}
saved_audio_mode = mapping.get(saved_audio_mode, "Demo")
audio_mode = st.selectbox(
"Audio Mode",
options,
index=options.index(saved_audio_mode),
help="Select the audio input source. Choose 'Webapp' for browser microphone, 'USB/Network' for a connected hardware device, or 'Demo' for a simulated stream."
index=options.index(saved_audio_mode) if saved_audio_mode in options else options.index("Demo"),
help=(
"Select the audio input source. Choose 'Webapp' for browser microphone, "
"'USB' for a connected USB audio device (via PipeWire), 'AES67' for network RTP/AES67 sources, "
"or 'Demo' for a simulated stream."
)
)
if audio_mode == "Demo":
@@ -66,15 +146,48 @@ if audio_mode == "Demo":
index=0,
help="Select the demo stream configuration."
)
# Stream password and flags (same as USB/AES67)
saved_pwd = saved_settings.get('stream_password', '') or ''
stream_passwort = st.text_input(
"Stream Passwort",
value=saved_pwd,
type=("password"),
help="Optional: Set a broadcast code to protect your stream. Leave empty for an open (uncoded) broadcast."
)
col_flags1, col_flags2, col_pdelay, col_rtn = st.columns([1, 1, 1, 1], gap="small")
with col_flags1:
assisted_listening = st.checkbox(
"Assistive listening",
value=bool(saved_settings.get('assisted_listening_stream', False))
)
with col_flags2:
immediate_rendering = st.checkbox(
"Immediate rendering",
value=bool(saved_settings.get('immediate_rendering', False))
)
# QoS/presentation controls inline with flags
default_pdelay = int(saved_settings.get('presentation_delay_us', 40000) or 40000)
with col_pdelay:
presentation_delay_us = st.number_input(
"Presentation delay (µs)",
min_value=10000, max_value=200000, step=1000, value=default_pdelay,
help="Delay between capture and presentation for receivers."
)
default_rtn = int(saved_settings.get('rtn', 4) or 4)
with col_rtn:
rtn = st.selectbox(
"Retransmissions (RTN)", options=[0,1,2,3,4], index=[0,1,2,3,4].index(default_rtn),
help="Number of ISO retransmissions (higher improves robustness at cost of airtime)."
)
#st.info(f"Demo mode selected: {demo_selected} (Streams: {demo_stream_map[demo_selected]['streams']}, Rate: {demo_stream_map[demo_selected]['rate']} Hz)")
# Start/Stop buttons for demo mode
if 'demo_stream_started' not in st.session_state:
st.session_state['demo_stream_started'] = False
col1, col2 = st.columns(2)
with col1:
start_demo = st.button("Start Demo Stream")
start_demo = st.button("Start Demo Stream", disabled=is_streaming)
with col2:
stop_demo = st.button("Stop Demo Stream")
stop_demo = st.button("Stop Demo Stream", disabled=not is_streaming)
if start_demo:
# Always stop any running stream for clean state
try:
@@ -99,6 +212,7 @@ if audio_mode == "Demo":
for i in range(demo_cfg['streams']):
cfg_cls, lang = lang_cfgs[i % len(lang_cfgs)]
bigs1.append(cfg_cls(
code=(stream_passwort.strip() or None),
audio_source=f'file:../testdata/wave_particle_5min_{lang}_{int(q["rate"]/1000)}kHz_mono.wav',
iso_que_len=32,
sampling_frequency=q['rate'],
@@ -115,7 +229,15 @@ if audio_mode == "Demo":
config1 = auracast_config.AuracastConfigGroup(
auracast_sampling_rate_hz=q['rate'],
octets_per_frame=q['octets'],
transport=TRANSPORT1,
transport='', # is set in baccol_qoskend
assisted_listening_stream=assisted_listening,
immediate_rendering=immediate_rendering,
presentation_delay_us=presentation_delay_us,
qos_config=auracast_config.AuracastQoSConfig(
iso_int_multiple_10ms=1,
number_of_retransmissions=int(rtn),
max_transport_latency_ms=int(rtn)*10 + 3,
),
bigs=bigs1
)
config2 = None
@@ -123,7 +245,15 @@ if audio_mode == "Demo":
config2 = auracast_config.AuracastConfigGroup(
auracast_sampling_rate_hz=q['rate'],
octets_per_frame=q['octets'],
transport=TRANSPORT2,
transport='', # is set in backend
assisted_listening_stream=assisted_listening,
immediate_rendering=immediate_rendering,
presentation_delay_us=presentation_delay_us,
qos_config=auracast_config.AuracastQoSConfig(
iso_int_multiple_10ms=1,
number_of_retransmissions=int(rtn),
max_transport_latency_ms=int(rtn)*10 + 3,
),
bigs=bigs2
)
# Call /init and /init2
@@ -151,6 +281,7 @@ if audio_mode == "Demo":
st.session_state['demo_stream_started'] = False
if r.get('was_running'):
st.info("Demo stream stopped.")
st.rerun()
else:
st.info("Demo stream was not running.")
except Exception as e:
@@ -194,22 +325,63 @@ else:
value=default_lang,
help="Three-letter language code (e.g., 'eng' for English, 'deu' for German). Used by receivers to display the language of the stream. See: https://en.wikipedia.org/wiki/List_of_ISO_639-3_codes"
)
# Optional broadcast code for coded streams
stream_passwort = st.text_input(
"Stream Passwort",
value="",
type="password",
help="Optional: Set a broadcast code to protect your stream. Leave empty for an open (uncoded) broadcast."
)
# Flags and QoS row (compact, four columns)
col_flags1, col_flags2, col_pdelay, col_rtn = st.columns([1, 1, 1, 1], gap="small")
with col_flags1:
assisted_listening = st.checkbox(
"Assistive listening",
value=bool(saved_settings.get('assisted_listening_stream', False))
)
with col_flags2:
immediate_rendering = st.checkbox(
"Immediate rendering",
value=bool(saved_settings.get('immediate_rendering', False))
)
# QoS/presentation controls inline with flags
default_pdelay = int(saved_settings.get('presentation_delay_us', 40000) or 40000)
with col_pdelay:
presentation_delay_us = st.number_input(
"Presentation delay (µs)",
min_value=10000, max_value=200000, step=1000, value=default_pdelay,
help="Delay between capture and presentation for receivers."
)
default_rtn = int(saved_settings.get('rtn', 4) or 4)
with col_rtn:
rtn = st.selectbox(
"Retransmissions (RTN)", options=[0,1,2,3,4], index=[0,1,2,3,4].index(default_rtn),
help="Number of ISO retransmissions (higher improves robustness at cost of airtime)."
)
# Gain slider for Webapp mode
if audio_mode == "Webapp":
mic_gain = st.slider("Microphone Gain", 0.0, 2.0, 1.0, 0.1, help="Adjust microphone volume sent to Auracast")
else:
mic_gain = 1.0
# Input device selection for USB mode
if audio_mode == "USB/Network":
resp = requests.get(f"{BACKEND_URL}/audio_inputs")
# Input device selection for USB or AES67 mode
if audio_mode in ("USB", "AES67"):
if not is_streaming:
# Only query device lists when NOT streaming to avoid extra backend calls
try:
endpoint = "/audio_inputs_pw_usb" if audio_mode == "USB" else "/audio_inputs_pw_network"
resp = requests.get(f"{BACKEND_URL}{endpoint}")
device_list = resp.json().get('inputs', [])
except Exception as e:
st.error(f"Failed to fetch devices: {e}")
device_list = []
# Display "name [id]" but use name as value
input_options = [f"{d['name']} [{d['id']}]" for d in device_list]
option_name_map = {f"{d['name']} [{d['id']}]": d['name'] for d in device_list}
device_names = [d['name'] for d in device_list]
# Determine default input by name
# Determine default input by name (from persisted server state)
default_input_name = saved_settings.get('input_device')
if default_input_name not in device_names and device_names:
default_input_name = device_names[0]
@@ -219,10 +391,17 @@ else:
default_input_label = label
break
if not input_options:
st.warning("No hardware audio input devices found. Plug in a USB input device and click Refresh.")
if st.button("Refresh"):
warn_text = (
"No USB audio input devices found. Connect a USB input and click Refresh."
if audio_mode == "USB" else
"No AES67/Network inputs found."
)
st.warning(warn_text)
if st.button("Refresh", disabled=is_streaming):
try:
requests.post(f"{BACKEND_URL}/refresh_audio_inputs", timeout=3)
r = requests.post(f"{BACKEND_URL}/refresh_audio_devices", timeout=8)
if not r.ok:
st.error(f"Failed to refresh: {r.text}")
except Exception as e:
st.error(f"Failed to refresh devices: {e}")
st.rerun()
@@ -236,19 +415,43 @@ else:
index=input_options.index(default_input_label) if default_input_label in input_options else 0
)
with col2:
if st.button("Refresh"):
if st.button("Refresh", disabled=is_streaming):
try:
requests.post(f"{BACKEND_URL}/refresh_audio_inputs", timeout=3)
r = requests.post(f"{BACKEND_URL}/refresh_audio_devices", timeout=8)
if not r.ok:
st.error(f"Failed to refresh: {r.text}")
except Exception as e:
st.error(f"Failed to refresh devices: {e}")
st.rerun()
# Send only the device name to backend
input_device = option_name_map[selected_option] if selected_option in option_name_map else None
input_device = option_name_map.get(selected_option)
else:
# When streaming, keep showing the current selection but lock editing.
input_device = saved_settings.get('input_device')
current_label = input_device or "No device selected"
st.selectbox(
"Input Device",
[current_label],
index=0,
disabled=True,
help="Stop the stream to change the input device."
)
else:
input_device = None
start_stream = st.button("Start Auracast")
stop_stream = st.button("Stop Auracast")
# Buttons and status on a single row (4 columns: start, stop, spacer, status)
c_start, c_stop, c_spacer, c_status = st.columns([1, 1, 1, 2], gap="small", vertical_alignment="center")
with c_start:
start_stream = st.button("Start Auracast", disabled=is_streaming)
with c_stop:
stop_stream = st.button("Stop Auracast", disabled=not is_streaming)
# c_spacer intentionally left empty to push status to the far right
with c_status:
# Fetch current status from backend and render using Streamlit widgets (no HTML)
# The is_streaming variable is now defined at the top of the script.
# We only need to re-fetch here if we want the absolute latest status for the display,
# but for UI consistency, we can just use the value from the top of the script run.
st.write("🟢 Streaming" if is_streaming else "🔴 Stopped")
# If gain slider moved while streaming, send update to JS without restarting
if audio_mode == "Webapp" and st.session_state.get('stream_started'):
@@ -265,6 +468,7 @@ else:
r = requests.post(f"{BACKEND_URL}/stop_audio").json()
if r['was_running']:
st.success("Stream Stopped!")
st.rerun()
else:
st.success("Stream was not running.")
except Exception as e:
@@ -287,8 +491,8 @@ else:
if start_stream:
# Always send stop to ensure backend is in a clean state, regardless of current status
r = requests.post(f"{BACKEND_URL}/stop_audio").json()
if r['was_running']:
st.success("Stream Stopped!")
#if r['was_running']:
# st.success("Stream Stopped!")
# Small pause lets backend fully release audio devices before re-init
time.sleep(1)
@@ -297,18 +501,27 @@ else:
config = auracast_config.AuracastConfigGroup(
auracast_sampling_rate_hz=q['rate'],
octets_per_frame=q['octets'],
transport=TRANSPORT1, # transport for raspberry pi gpio header
transport='', # is set in backend
assisted_listening_stream=assisted_listening,
immediate_rendering=immediate_rendering,
presentation_delay_us=presentation_delay_us,
qos_config=auracast_config.AuracastQoSConfig(
iso_int_multiple_10ms=1,
number_of_retransmissions=int(rtn),
max_transport_latency_ms=int(rtn)*10 + 3,
),
bigs = [
auracast_config.AuracastBigConfig(
code=(stream_passwort.strip() or None),
name=stream_name,
program_info=program_info,
language=language,
audio_source=(
f"device:{input_device}" if audio_mode == "USB/Network" else (
f"device:{input_device}" if audio_mode in ("USB", "AES67") else (
"webrtc" if audio_mode == "Webapp" else "network"
)
),
input_format=(f"int16le,{q['rate']},1" if audio_mode == "USB/Network" else "auto"),
input_format=(f"int16le,{q['rate']},1" if audio_mode in ("USB", "AES67") else "auto"),
iso_que_len=1,
sampling_frequency=q['rate'],
octets_per_frame=q['octets'],
@@ -320,6 +533,7 @@ else:
r = requests.post(f"{BACKEND_URL}/init", json=config.model_dump())
if r.status_code == 200:
st.success("Stream Started!")
st.rerun()
else:
st.error(f"Failed to initialize: {r.text}")
except Exception as e:
@@ -393,6 +607,47 @@ else:
# else:
# st.error("Could not fetch advertised streams.")
############################
# System expander (collapsed)
############################
with st.expander("System control", expanded=False):
st.subheader("Change password")
if is_pw_disabled():
st.info("Frontend password protection is disabled via DISABLE_FRONTEND_PW.")
else:
with st.form("change_pw_form"):
cur = st.text_input("Current password", type="password")
new1 = st.text_input("New password", type="password")
new2 = st.text_input("Confirm new password", type="password")
submit_change = st.form_submit_button("Change password")
if submit_change:
rec = load_pw_record()
if not rec or not verify_password(cur, rec):
st.error("Current password is incorrect.")
elif len(new1) < 6:
st.error("New password should be at least 6 characters.")
elif new1 != new2:
st.error("New passwords do not match.")
else:
salt, key = hash_password(new1)
try:
save_pw_record(salt, key)
st.success("Password updated.")
except Exception as e:
st.error(f"Failed to update password: {e}")
st.subheader("Reboot")
if st.button("Reboot now", type="primary"):
try:
r = requests.post(f"{BACKEND_URL}/system_reboot", timeout=1)
if r.ok:
st.success("Reboot initiated. The UI will become unreachable shortly.")
else:
st.error(f"Failed to reboot: {r.status_code} {r.text}")
except Exception as e:
st.error(f"Error calling reboot: {e}")
log.basicConfig(
level=os.environ.get('LOG_LEVEL', log.DEBUG),
format='%(module)s.py:%(lineno)d %(levelname)s: %(message)s'

View File

@@ -1,12 +1,20 @@
import glob
""" the main server where our multicaster objects live.
TODO: in the future the multicaster objects should run in their own threads or even make a second server since everything thats blocking the main event loop leads to inceased latency.
"""
import os
import logging as log
import uuid
import json
import sys
import threading
from concurrent.futures import Future
from datetime import datetime
import time
import asyncio
import numpy as np
from dotenv import load_dotenv
from pydantic import BaseModel
from fastapi import FastAPI, HTTPException
from fastapi.middleware.cors import CORSMiddleware
@@ -15,44 +23,80 @@ from aiortc import RTCPeerConnection, RTCSessionDescription, MediaStreamTrack
import av
import av.audio.layout
import sounddevice as sd # type: ignore
from typing import Set, List, Dict, Any
from typing import Set
import traceback
from auracast.utils.sounddevice_utils import (
get_usb_pw_inputs,
get_network_pw_inputs,
refresh_pw_cache,
)
from auracast.utils.reset_utils import reset_nrf54l
load_dotenv()
# make sure pipewire sets latency
STREAM_SETTINGS_FILE = os.path.join(os.path.dirname(__file__), 'stream_settings.json')
# Raspberry Pi UART transports
TRANSPORT1 = os.getenv('TRANSPORT1', 'serial:/dev/ttyAMA3,1000000,rtscts') # transport for raspberry pi gpio header
TRANSPORT2 = os.getenv('TRANSPORT2', 'serial:/dev/ttyAMA4,1000000,rtscts') # transport for raspberry pi gpio header
PTIME = 40 # TODO: seems to have no effect at all
PTIME = 40 # seems to have no effect at all
pcs: Set[RTCPeerConnection] = set() # keep refs so they dont GC early
AUDIO_INPUT_DEVICES_CACHE: List[Dict[str, Any]] = []
os.environ["PULSE_LATENCY_MSEC"] = "3"
# In-memory cache to avoid disk I/O on hot paths like /status
SETTINGS_CACHE: dict = {}
class Offer(BaseModel):
sdp: str
type: str
def get_device_index_by_name(name: str):
"""Return the device index for a given device name, or None if not found."""
for d in AUDIO_INPUT_DEVICES_CACHE:
if d["name"] == name:
return d["id"]
"""Return the device index for a given device name, or None if not found.
Queries the current sounddevice list directly (no cache).
"""
try:
devs = sd.query_devices()
for idx, d in enumerate(devs):
if d.get("name") == name and d.get("max_input_channels", 0) > 0:
return idx
except Exception:
pass
return None
# Path to persist stream settings
STREAM_SETTINGS_FILE = os.path.join(os.path.dirname(__file__), 'stream_settings.json')
def _hydrate_settings_cache_from_disk() -> None:
"""Populate SETTINGS_CACHE once from disk at startup.
Safe to call multiple times; errors fall back to empty dict.
"""
global SETTINGS_CACHE
try:
if os.path.exists(STREAM_SETTINGS_FILE):
with open(STREAM_SETTINGS_FILE, 'r', encoding='utf-8') as f:
SETTINGS_CACHE = json.load(f)
else:
SETTINGS_CACHE = {}
except Exception:
SETTINGS_CACHE = {}
def load_stream_settings() -> dict:
"""Load persisted stream settings if available."""
if os.path.exists(STREAM_SETTINGS_FILE):
try:
with open(STREAM_SETTINGS_FILE, 'r', encoding='utf-8') as f:
return json.load(f)
except Exception:
return {}
return {}
"""Return stream settings from in-memory cache.
The cache is hydrated once at startup and updated by save_stream_settings().
No disk I/O occurs here.
"""
global SETTINGS_CACHE
return SETTINGS_CACHE
def save_stream_settings(settings: dict):
"""Save stream settings to disk."""
"""Update in-memory settings cache and persist to disk."""
global SETTINGS_CACHE
SETTINGS_CACHE = dict(settings)
try:
with open(STREAM_SETTINGS_FILE, 'w', encoding='utf-8') as f:
json.dump(settings, f, indent=2)
json.dump(SETTINGS_CACHE, f, indent=2)
except Exception as e:
log.error('Unable to persist stream settings: %s', e)
@@ -71,344 +115,607 @@ app.add_middleware(
# Initialize global configuration
global_config_group = auracast_config.AuracastConfigGroup()
# Create multicast controller
multicaster1: multicast_control.Multicaster | None = None
multicaster2: multicast_control.Multicaster | None = None
class StreamerWorker:
"""Owns multicaster(s) on a dedicated asyncio loop in a background thread."""
@app.post("/init")
async def initialize(conf: auracast_config.AuracastConfigGroup):
"""Initializes the primary broadcaster (multicaster1)."""
global global_config_group
global multicaster1
def __init__(self) -> None:
self._thread: threading.Thread | None = None
self._loop: asyncio.AbstractEventLoop | None = None
# These live only on the worker loop
self._multicaster1: multicast_control.Multicaster | None = None
self._multicaster2: multicast_control.Multicaster | None = None
self._started = threading.Event()
# ---------- Thread/loop management ----------
def start(self) -> None:
if self._thread and self._thread.is_alive():
return
self._thread = threading.Thread(target=self._run, name="StreamerWorker", daemon=True)
self._thread.start()
self._started.wait(timeout=5)
def _run(self) -> None:
loop = asyncio.new_event_loop()
self._loop = loop
asyncio.set_event_loop(loop)
self._started.set()
try:
if conf.transport == 'auto':
serial_devices = glob.glob('/dev/serial/by-id/*')
log.info('Found serial devices: %s', serial_devices)
for device in serial_devices:
if 'usb-ZEPHYR_Zephyr_HCI_UART_sample' in device:
log.info('Using: %s', device)
conf.transport = f'serial:{device},115200,rtscts'
break
if conf.transport == 'auto':
raise HTTPException(status_code=500, detail='No suitable transport found.')
# Derive audio_mode and input_device from first BIG audio_source
loop.run_forever()
finally:
try:
pending = asyncio.all_tasks(loop)
for t in pending:
t.cancel()
loop.run_until_complete(asyncio.gather(*pending, return_exceptions=True))
except Exception:
pass
loop.close()
def _ensure_loop(self) -> asyncio.AbstractEventLoop:
if not self._loop:
raise RuntimeError("StreamerWorker loop not started")
return self._loop
async def call(self, coro_func, *args, **kwargs):
"""Schedule a coroutine on the worker loop and await its result from the API loop."""
loop = self._ensure_loop()
fut: Future = asyncio.run_coroutine_threadsafe(coro_func(*args, **kwargs), loop)
return await asyncio.wrap_future(fut)
# ---------- Worker-loop coroutines ----------
async def _w_init_primary(self, conf: auracast_config.AuracastConfigGroup) -> dict:
# Clean any previous
if self._multicaster1 is not None:
try:
await self._multicaster1.shutdown()
except Exception:
pass
self._multicaster1 = None
conf.transport = TRANSPORT1
# Derive device name and input mode
first_source = conf.bigs[0].audio_source if conf.bigs else ''
input_device_name = None
audio_mode_persist = 'Demo'
if first_source.startswith('device:'):
audio_mode_persist = 'USB'
input_device_name = first_source.split(':', 1)[1] if ':' in first_source else None
# Map device name to current index for use with sounddevice
device_index = get_device_index_by_name(input_device_name) if input_device_name else None
# Patch config to use index for sounddevice (but persist name)
if device_index is not None:
try:
usb_names = {d.get('name') for _, d in get_usb_pw_inputs()}
net_names = {d.get('name') for _, d in get_network_pw_inputs()}
except Exception:
usb_names, net_names = set(), set()
audio_mode_persist = 'AES67' if (input_device_name in net_names) else 'USB'
# Map device name to index and configure input_format
device_index = int(input_device_name) if (input_device_name and input_device_name.isdigit()) else get_device_index_by_name(input_device_name or '')
if device_index is None:
raise HTTPException(status_code=400, detail=f"Audio device '{input_device_name}' not found.")
for big in conf.bigs:
if big.audio_source.startswith('device:'):
big.audio_source = f'device:{device_index}'
else:
log.error(f"Device name '{input_device_name}' not found in current device list.")
raise HTTPException(status_code=400, detail=f"Audio device '{input_device_name}' not found.")
elif first_source == 'webrtc':
audio_mode_persist = 'Webapp'
input_device_name = None
elif first_source.startswith('file:'):
audio_mode_persist = 'Demo'
input_device_name = None
else:
audio_mode_persist = 'Network'
input_device_name = None
save_stream_settings({
devinfo = sd.query_devices(device_index)
capture_rate = int(devinfo.get('default_samplerate') or 48000)
max_in = int(devinfo.get('max_input_channels') or 1)
channels = max(1, min(2, max_in))
for big in conf.bigs:
big.input_format = f"int16le,{capture_rate},{channels}"
# Coerce QoS: compute max_transport_latency from RTN if qos_config present
if getattr(conf, 'qos_config', None) and getattr(conf.qos_config, 'number_of_retransmissions', None) is not None:
conf.qos_config.max_transport_latency_ms = int(conf.qos_config.number_of_retransmissions) * 10 + 3
# Create and init multicaster1
self._multicaster1 = multicast_control.Multicaster(conf, conf.bigs)
await reset_nrf54l(1)
await self._multicaster1.init_broadcast()
auto_started = False
if any(big.audio_source.startswith("device:") or big.audio_source.startswith("file:") for big in conf.bigs):
await self._multicaster1.start_streaming()
auto_started = True
# Return proposed settings to persist on API side
return {
'channel_names': [big.name for big in conf.bigs],
'languages': [big.language for big in conf.bigs],
'audio_mode': audio_mode_persist,
'input_device': input_device_name,
'program_info': [getattr(big, 'program_info', None) for big in conf.bigs],
'gain': [getattr(big, 'input_gain', 1.0) for big in conf.bigs],
'timestamp': datetime.utcnow().isoformat()
})
global_config_group = conf
if multicaster1 is not None:
'auracast_sampling_rate_hz': conf.auracast_sampling_rate_hz,
'octets_per_frame': conf.octets_per_frame,
'presentation_delay_us': getattr(conf, 'presentation_delay_us', None),
'rtn': getattr(getattr(conf, 'qos_config', None), 'number_of_retransmissions', None),
'immediate_rendering': getattr(conf, 'immediate_rendering', False),
'assisted_listening_stream': getattr(conf, 'assisted_listening_stream', False),
'stream_password': (conf.bigs[0].code if conf.bigs and getattr(conf.bigs[0], 'code', None) else None),
'is_streaming': auto_started,
}
async def _w_init_secondary(self, conf: auracast_config.AuracastConfigGroup) -> None:
if self._multicaster2 is not None:
try:
await multicaster1.shutdown()
await self._multicaster2.shutdown()
except Exception:
log.warning("Failed to shutdown previous multicaster", exc_info=True)
log.info('Initializing multicaster1 with config:\n %s', conf.model_dump_json(indent=2))
multicaster1 = multicast_control.Multicaster(conf, conf.bigs)
await multicaster1.init_broadcast()
pass
self._multicaster2 = None
conf.transport = TRANSPORT2
for big in conf.bigs:
if big.audio_source.startswith('device:'):
device_name = big.audio_source.split(':', 1)[1]
device_index = get_device_index_by_name(device_name)
if device_index is None:
raise HTTPException(status_code=400, detail=f"Audio device '{device_name}' not found.")
big.audio_source = f'device:{device_index}'
# Coerce QoS: compute max_transport_latency from RTN if qos_config present
if getattr(conf, 'qos_config', None) and getattr(conf.qos_config, 'number_of_retransmissions', None) is not None:
conf.qos_config.max_transport_latency_ms = int(conf.qos_config.number_of_retransmissions) * 10 + 3
self._multicaster2 = multicast_control.Multicaster(conf, conf.bigs)
await reset_nrf54l(0)
await self._multicaster2.init_broadcast()
if any(big.audio_source.startswith("device:") or big.audio_source.startswith("file:") for big in conf.bigs):
log.info("Auto-starting streaming on multicaster1")
await multicaster1.start_streaming()
await self._multicaster2.start_streaming()
async def _w_stop_all(self) -> bool:
was_running = False
if self._multicaster1 is not None:
try:
await self._multicaster1.stop_streaming()
await self._multicaster1.shutdown()
was_running = True
finally:
self._multicaster1 = None
if self._multicaster2 is not None:
try:
await self._multicaster2.stop_streaming()
await self._multicaster2.shutdown()
was_running = True
finally:
self._multicaster2 = None
return was_running
async def _w_status_primary(self) -> dict:
if self._multicaster1 is None:
return {'is_initialized': False, 'is_streaming': False}
try:
return self._multicaster1.get_status()
except Exception:
return {'is_initialized': True, 'is_streaming': False}
async def _w_stream_lc3(self, audio_data: dict[str, str], bigs_template: list) -> None:
if self._multicaster1 is None:
raise HTTPException(status_code=500, detail='Auracast endpoint was never intialized')
# Update bigs audio_source with provided bytes and start
for big in bigs_template:
if big.language not in audio_data:
raise HTTPException(status_code=500, detail='language len missmatch')
big.audio_source = audio_data[big.language].encode('latin-1')
self._multicaster1.big_conf = bigs_template
await self._multicaster1.start_streaming()
# Create the worker singleton and a route-level lock
streamer = StreamerWorker()
# multicaster1: multicast_control.Multicaster | None = None # kept for legacy references, do not use on API loop
# multicaster2: multicast_control.Multicaster | None = None
_stream_lock = asyncio.Lock() # serialize initialize/stop_audio on API side
@app.post("/init")
async def initialize(conf: auracast_config.AuracastConfigGroup):
"""Initializes the primary broadcaster on the streamer thread."""
global global_config_group
async with _stream_lock:
try:
global_config_group = conf
log.info('Initializing multicaster1 with config:\n %s', conf.model_dump_json(indent=2))
persisted = await streamer.call(streamer._w_init_primary, conf)
# Persist returned settings (avoid touching from worker thread)
persisted['timestamp'] = datetime.utcnow().isoformat()
save_stream_settings(persisted)
except Exception as e:
log.error("Exception in /init: %s", traceback.format_exc())
raise HTTPException(status_code=500, detail=str(e))
@app.post("/init2")
async def initialize2(conf: auracast_config.AuracastConfigGroup):
"""Initializes the secondary broadcaster (multicaster2). Does NOT persist stream settings."""
global multicaster2
"""Initializes the secondary broadcaster on the streamer thread."""
try:
if conf.transport == 'auto':
serial_devices = glob.glob('/dev/serial/by-id/*')
log.info('Found serial devices: %s', serial_devices)
for device in serial_devices:
if 'usb-ZEPHYR_Zephyr_HCI_UART_sample' in device:
log.info('Using: %s', device)
conf.transport = f'serial:{device},115200,rtscts'
break
if conf.transport == 'auto':
raise HTTPException(status_code=500, detail='No suitable transport found.')
# Patch device name to index for sounddevice
for big in conf.bigs:
if big.audio_source.startswith('device:'):
device_name = big.audio_source.split(':', 1)[1]
device_index = get_device_index_by_name(device_name)
if device_index is not None:
big.audio_source = f'device:{device_index}'
else:
log.error(f"Device name '{device_name}' not found in current device list.")
raise HTTPException(status_code=400, detail=f"Audio device '{device_name}' not found.")
log.info('Initializing multicaster2 with config:\n %s', conf.model_dump_json(indent=2))
multicaster2 = multicast_control.Multicaster(conf, conf.bigs)
await multicaster2.init_broadcast()
if any(big.audio_source.startswith("device:") or big.audio_source.startswith("file:") for big in conf.bigs):
log.info("Auto-starting streaming on multicaster2")
await multicaster2.start_streaming()
await streamer.call(streamer._w_init_secondary, conf)
except Exception as e:
log.error("Exception in /init2: %s", traceback.format_exc())
raise HTTPException(status_code=500, detail=str(e))
@app.post("/stream_lc3")
async def send_audio(audio_data: dict[str, str]):
"""Sends a block of pre-coded LC3 audio."""
if multicaster1 is None:
raise HTTPException(status_code=500, detail='Auracast endpoint was never intialized')
try:
for big in global_config_group.bigs:
assert big.language in audio_data, HTTPException(status_code=500, detail='language len missmatch')
log.info('Received a send audio request for %s', big.language)
big.audio_source = audio_data[big.language].encode('latin-1') # TODO: use base64 encoding
multicaster1.big_conf = global_config_group.bigs
await multicaster1.start_streaming()
return {"status": "audio_sent"}
except Exception as e:
raise HTTPException(status_code=500, detail=str(e))
@app.post("/stop_audio")
async def stop_audio():
"""Stops streaming on both multicaster1 and multicaster2."""
"""Stops streaming on both multicaster1 and multicaster2 (worker thread)."""
try:
# First close any active WebRTC peer connections so their track loops finish cleanly
# Close any active PeerConnections
close_tasks = [pc.close() for pc in list(pcs)]
pcs.clear()
if close_tasks:
await asyncio.gather(*close_tasks, return_exceptions=True)
# Now shut down both multicasters and release audio devices
running = False
if multicaster1 is not None:
await multicaster1.stop_streaming()
await multicaster1.reset() # Fully reset controller and advertising
running = True
if multicaster2 is not None:
await multicaster2.stop_streaming()
await multicaster2.reset() # Fully reset controller and advertising
running = True
was_running = await streamer.call(streamer._w_stop_all)
return {"status": "stopped", "was_running": running}
# Persist is_streaming=False
try:
settings = load_stream_settings() or {}
if settings.get('is_streaming'):
settings['is_streaming'] = False
settings['timestamp'] = datetime.utcnow().isoformat()
save_stream_settings(settings)
except Exception:
log.warning("Failed to persist is_streaming=False during stop_audio", exc_info=True)
await asyncio.sleep(0.2)
return {"status": "stopped", "was_running": was_running}
except Exception as e:
log.error("Exception in /stop_audio: %s", traceback.format_exc())
raise HTTPException(status_code=500, detail=str(e))
@app.post("/stream_lc3")
async def send_audio(audio_data: dict[str, str]):
"""Sends a block of pre-coded LC3 audio via the worker."""
try:
await streamer.call(streamer._w_stream_lc3, audio_data, list(global_config_group.bigs))
return {"status": "audio_sent"}
except Exception as e:
raise HTTPException(status_code=500, detail=str(e))
@app.get("/status")
async def get_status():
"""Gets the current status of the multicaster together with persisted stream info."""
status = multicaster1.get_status() if multicaster1 else {
'is_initialized': False,
'is_streaming': False,
}
"""Gets current status (worker) merged with persisted settings cache."""
status = await streamer.call(streamer._w_status_primary)
status.update(load_stream_settings())
return status
async def scan_audio_devices():
"""Scans for available audio devices and updates the cache."""
global AUDIO_INPUT_DEVICES_CACHE
log.info("Scanning for audio input devices...")
async def _autostart_from_settings():
"""Background task: auto-start last selected device-based input at server startup.
Skips Webapp (webrtc) and Demo (file) modes. Polls every 2 seconds until the
saved device name appears in either USB or Network lists, then builds a config
and initializes streaming.
"""
try:
if sys.platform == 'linux':
log.info("Re-initializing sounddevice to scan for new devices")
sd._terminate()
sd._initialize()
settings = load_stream_settings() or {}
audio_mode = settings.get('audio_mode')
input_device_name = settings.get('input_device')
rate = settings.get('auracast_sampling_rate_hz')
octets = settings.get('octets_per_frame')
pres_delay = settings.get('presentation_delay_us')
saved_rtn = settings.get('rtn')
immediate_rendering = settings.get('immediate_rendering', False)
assisted_listening_stream = settings.get('assisted_listening_stream', False)
channel_names = settings.get('channel_names') or ["Broadcast0"]
program_info = settings.get('program_info') or channel_names
languages = settings.get('languages') or ["deu"]
stream_password = settings.get('stream_password')
original_ts = settings.get('timestamp')
previously_streaming = bool(settings.get('is_streaming'))
devs = sd.query_devices()
inputs = [
dict(d, id=idx)
for idx, d in enumerate(devs)
if d.get("max_input_channels", 0) > 0
# Only auto-start if the previous state was streaming and it's a device-based input.
if not previously_streaming:
return
if not input_device_name:
return
if rate is None or octets is None:
# Not enough info to reconstruct stream reliably
return
# Avoid duplicate start if already streaming
current = await streamer.call(streamer._w_status_primary)
if current.get('is_streaming'):
return
while True:
# Do not interfere if user started a stream manually in the meantime
current = await streamer.call(streamer._w_status_primary)
if current.get('is_streaming'):
return
# Abort if saved settings changed to a different target while we were polling
current_settings = load_stream_settings() or {}
if current_settings.get('timestamp') != original_ts:
# Settings were updated (likely by user via /init)
# If the target device or mode changed, stop autostart
if (
current_settings.get('input_device') != input_device_name or
current_settings.get('audio_mode') != audio_mode
):
return
# Check against the cached device lists
usb = [d for _, d in get_usb_pw_inputs()]
net = [d for _, d in get_network_pw_inputs()]
names = {d.get('name') for d in usb} | {d.get('name') for d in net}
if input_device_name in names:
# Build a minimal config based on saved fields
bigs = [
auracast_config.AuracastBigConfig(
code=stream_password,
name=channel_names[0] if channel_names else "Broadcast0",
program_info=program_info[0] if isinstance(program_info, list) and program_info else program_info,
language=languages[0] if languages else "deu",
audio_source=f"device:{input_device_name}",
# input_format is intentionally omitted to use the default
iso_que_len=1,
sampling_frequency=rate,
octets_per_frame=octets,
)
]
log.info('Found %d audio input devices: %s', len(inputs), inputs)
AUDIO_INPUT_DEVICES_CACHE = inputs
except Exception:
log.error("Exception while scanning audio devices:", exc_info=True)
# Do not clear cache on error, keep the last known good list
conf = auracast_config.AuracastConfigGroup(
auracast_sampling_rate_hz=rate,
octets_per_frame=octets,
transport=TRANSPORT1,
immediate_rendering=immediate_rendering,
assisted_listening_stream=assisted_listening_stream,
presentation_delay_us=pres_delay if pres_delay is not None else 40000,
bigs=bigs,
)
# Attach QoS if saved_rtn present
conf.qos_config = auracast_config.AuracastQoSConfig(
iso_int_multiple_10ms=1,
number_of_retransmissions=int(saved_rtn),
max_transport_latency_ms=int(saved_rtn) * 10 + 3,
)
# Initialize and start
await asyncio.sleep(2)
await initialize(conf)
return
await asyncio.sleep(2)
except Exception:
log.warning("Autostart task failed", exc_info=True)
@app.on_event("startup")
async def startup_event():
"""Pre-scans audio devices on startup."""
await scan_audio_devices()
async def _startup_autostart_event():
# Spawn the autostart task without blocking startup
log.info("Refreshing PipeWire device cache.")
# Hydrate settings cache once to avoid disk I/O during /status
_hydrate_settings_cache_from_disk()
refresh_pw_cache()
# Start the streamer worker thread
streamer.start()
asyncio.create_task(_autostart_from_settings())
@app.get("/audio_inputs")
async def list_audio_inputs():
"""Return available hardware audio input devices from cache (by name, for selection)."""
# Only expose name and id for frontend
return {"inputs": AUDIO_INPUT_DEVICES_CACHE}
@app.post("/refresh_audio_inputs")
async def refresh_audio_inputs():
"""Triggers a re-scan of audio devices."""
await scan_audio_devices()
return {"status": "ok", "inputs": AUDIO_INPUT_DEVICES_CACHE}
@app.post("/offer")
async def offer(offer: Offer):
log.info("/offer endpoint called")
# If a previous PeerConnection is still alive, close it so we only ever keep one active.
if pcs:
log.info("Closing %d existing PeerConnection(s) before creating a new one", len(pcs))
close_tasks = [p.close() for p in list(pcs)]
await asyncio.gather(*close_tasks, return_exceptions=True)
pcs.clear()
pc = RTCPeerConnection() # No STUN needed for localhost
pcs.add(pc)
id_ = uuid.uuid4().hex[:8]
log.info(f"{id_}: new PeerConnection")
# create directory for records - only for testing
os.makedirs("./records", exist_ok=True)
# Do NOT start the streamer yet we'll start it lazily once we actually
# receive the first audio frame, ensuring WebRTCAudioInput is ready and
# avoiding race-conditions on restarts.
@pc.on("track")
async def on_track(track: MediaStreamTrack):
log.info(f"{id_}: track {track.kind} received")
@app.get("/audio_inputs_pw_usb")
async def audio_inputs_pw_usb():
"""List PipeWire USB input nodes from cache."""
try:
first = True
while True:
frame: av.audio.frame.AudioFrame = await track.recv() # RTP audio frame (already decrypted)
if first:
log.info(f"{id_}: frame layout={frame.layout}")
log.info(f"{id_}: frame format={frame.format}")
log.info(
f"{id_}: frame sample_rate={frame.sample_rate}, samples_per_channel={frame.samples}, planes={frame.planes}"
)
# Lazily start the streamer now that we know a track exists.
if multicaster1.streamer is None:
await multicaster1.start_streaming()
# Yield control so the Streamer coroutine has a chance to
# create the WebRTCAudioInput before we push samples.
await asyncio.sleep(0)
first = False
# in stereo case this is interleaved data format
frame_array = frame.to_ndarray()
log.info(f"array.shape{frame_array.shape}")
log.info(f"array.dtype{frame_array.dtype}")
log.info(f"frame.to_ndarray(){frame_array}")
samples = frame_array.reshape(-1)
log.info(f"samples.shape: {samples.shape}")
if frame.layout.name == 'stereo':
# Interleaved stereo: [L0, R0, L1, R1, ...]
mono_array = samples[::2] # Take left channel
else:
mono_array = samples
log.info(f"mono_array.shape: {mono_array.shape}")
frame_array = frame.to_ndarray()
# Flatten in case it's (1, N) or (N,)
samples = frame_array.reshape(-1)
if frame.layout.name == 'stereo':
# Interleaved stereo: [L0, R0, L1, R1, ...]
mono_array = samples[::2] # Take left channel
else:
mono_array = samples
# Get current WebRTC audio input (streamer may have been restarted)
big0 = list(multicaster1.bigs.values())[0]
audio_input = big0.get('audio_input')
# Wait until the streamer has instantiated the WebRTCAudioInput
if audio_input is None or getattr(audio_input, 'closed', False):
continue
# Feed mono PCM samples to the global WebRTC audio input
await audio_input.put_samples(mono_array.astype(np.int16))
# Save to WAV file - only for testing
# if not hasattr(pc, 'wav_writer'):
# import wave
# wav_path = f"./records/auracast_{id_}.wav"
# pc.wav_writer = wave.open(wav_path, "wb")
# pc.wav_writer.setnchannels(1) # mono
# pc.wav_writer.setsampwidth(2) # 16-bit PCM
# pc.wav_writer.setframerate(frame.sample_rate)
# pcm_data = mono_array.astype(np.int16).tobytes()
# pc.wav_writer.writeframes(pcm_data)
devices = [
{"id": idx, "name": dev.get("name"), "max_input_channels": dev.get("max_input_channels", 0)}
for idx, dev in get_usb_pw_inputs()
]
return {"inputs": devices}
except Exception as e:
log.error(f"{id_}: Exception in on_track: {e}")
finally:
# Always close the wav file when the track ends or on error
if hasattr(pc, 'wav_writer'):
log.error("Exception in /audio_inputs_pw_usb: %s", traceback.format_exc())
raise HTTPException(status_code=500, detail=str(e))
@app.get("/audio_inputs_pw_network")
async def audio_inputs_pw_network():
"""List PipeWire Network/AES67 input nodes from cache."""
try:
pc.wav_writer.close()
devices = [
{"id": idx, "name": dev.get("name"), "max_input_channels": dev.get("max_input_channels", 0)}
for idx, dev in get_network_pw_inputs()
]
return {"inputs": devices}
except Exception as e:
log.error("Exception in /audio_inputs_pw_network: %s", traceback.format_exc())
raise HTTPException(status_code=500, detail=str(e))
@app.post("/refresh_audio_devices")
async def refresh_audio_devices():
"""Triggers a re-scan of audio devices, but only if no stream is active."""
streaming = False
try:
status = await streamer.call(streamer._w_status_primary)
streaming = bool(status.get('is_streaming'))
except Exception:
pass
del pc.wav_writer
pass # Ignore errors, default to not refreshing
# --- SDP negotiation ---
log.info(f"{id_}: setting remote description")
await pc.setRemoteDescription(RTCSessionDescription(**offer.model_dump()))
if streaming:
log.warning("Ignoring refresh request: an audio stream is active.")
raise HTTPException(status_code=409, detail="An audio stream is active. Stop the stream before refreshing devices.")
log.info(f"{id_}: creating answer")
answer = await pc.createAnswer()
sdp = answer.sdp
# Insert a=ptime using the global PTIME variable
ptime_line = f"a=ptime:{PTIME}"
if "a=sendrecv" in sdp:
sdp = sdp.replace("a=sendrecv", f"a=sendrecv\n{ptime_line}")
else:
sdp += f"\n{ptime_line}"
new_answer = RTCSessionDescription(sdp=sdp, type=answer.type)
await pc.setLocalDescription(new_answer)
log.info(f"{id_}: sending answer with {ptime_line}")
return {"sdp": pc.localDescription.sdp,
"type": pc.localDescription.type}
try:
log.info("Refreshing PipeWire device cache.")
refresh_pw_cache()
return {"status": "ok"}
except Exception as e:
log.error("Exception during device refresh: %s", traceback.format_exc())
raise HTTPException(status_code=500, detail=f"Failed to refresh devices: {e}")
# async def offer(offer: Offer):
# @app.post("/offer") #webrtc endpoint
# log.info("/offer endpoint called")
# # If a previous PeerConnection is still alive, close it so we only ever keep one active.
# if pcs:
# log.info("Closing %d existing PeerConnection(s) before creating a new one", len(pcs))
# close_tasks = [p.close() for p in list(pcs)]
# await asyncio.gather(*close_tasks, return_exceptions=True)
# pcs.clear()
# pc = RTCPeerConnection() # No STUN needed for localhost
# pcs.add(pc)
# id_ = uuid.uuid4().hex[:8]
# log.info(f"{id_}: new PeerConnection")
# # create directory for records - only for testing
# os.makedirs("./records", exist_ok=True)
# # Do NOT start the streamer yet we'll start it lazily once we actually
# # receive the first audio frame, ensuring WebRTCAudioInput is ready and
# # avoiding race-conditions on restarts.
# @pc.on("track")
# async def on_track(track: MediaStreamTrack):
# log.info(f"{id_}: track {track.kind} received")
# try:
# first = True
# while True:
# frame: av.audio.frame.AudioFrame = await track.recv() # RTP audio frame (already decrypted)
# if first:
# log.info(f"{id_}: frame layout={frame.layout}")
# log.info(f"{id_}: frame format={frame.format}")
# log.info(
# f"{id_}: frame sample_rate={frame.sample_rate}, samples_per_channel={frame.samples}, planes={frame.planes}"
# )
# # Lazily start the streamer now that we know a track exists.
# if multicaster1.streamer is None:
# await multicaster1.start_streaming()
# # Yield control so the Streamer coroutine has a chance to
# # create the WebRTCAudioInput before we push samples.
# await asyncio.sleep(0)
# # Persist is_streaming=True for Webapp mode
# try:
# settings = load_stream_settings() or {}
# settings['is_streaming'] = True
# settings['timestamp'] = datetime.utcnow().isoformat()
# save_stream_settings(settings)
# except Exception:
# log.warning("Failed to persist is_streaming=True on WebRTC start", exc_info=True)
# first = False
# # in stereo case this is interleaved data format
# frame_array = frame.to_ndarray()
# log.info(f"array.shape{frame_array.shape}")
# log.info(f"array.dtype{frame_array.dtype}")
# log.info(f"frame.to_ndarray(){frame_array}")
# samples = frame_array.reshape(-1)
# log.info(f"samples.shape: {samples.shape}")
# if frame.layout.name == 'stereo':
# # Interleaved stereo: [L0, R0, L1, R1, ...]
# mono_array = samples[::2] # Take left channel
# else:
# mono_array = samples
# log.info(f"mono_array.shape: {mono_array.shape}")
# frame_array = frame.to_ndarray()
# # Flatten in case it's (1, N) or (N,)
# samples = frame_array.reshape(-1)
# if frame.layout.name == 'stereo':
# # Interleaved stereo: [L0, R0, L1, R1, ...]
# mono_array = samples[::2] # Take left channel
# else:
# mono_array = samples
# # Get current WebRTC audio input (streamer may have been restarted)
# big0 = list(multicaster1.bigs.values())[0]
# audio_input = big0.get('audio_input')
# # Wait until the streamer has instantiated the WebRTCAudioInput
# if audio_input is None or getattr(audio_input, 'closed', False):
# continue
# # Feed mono PCM samples to the global WebRTC audio input
# await audio_input.put_samples(mono_array.astype(np.int16))
# # Save to WAV file - only for testing
# # if not hasattr(pc, 'wav_writer'):
# # import wave
# # wav_path = f"./records/auracast_{id_}.wav"
# # pc.wav_writer = wave.open(wav_path, "wb")
# # pc.wav_writer.setnchannels(1) # mono
# # pc.wav_writer.setsampwidth(2) # 16-bit PCM
# # pc.wav_writer.setframerate(frame.sample_rate)
# # pcm_data = mono_array.astype(np.int16).tobytes()
# # pc.wav_writer.writeframes(pcm_data)
# except Exception as e:
# log.error(f"{id_}: Exception in on_track: {e}")
# finally:
# # Always close the wav file when the track ends or on error
# if hasattr(pc, 'wav_writer'):
# try:
# pc.wav_writer.close()
# except Exception:
# pass
# del pc.wav_writer
# # --- SDP negotiation ---
# log.info(f"{id_}: setting remote description")
# await pc.setRemoteDescription(RTCSessionDescription(**offer.model_dump()))
# log.info(f"{id_}: creating answer")
# answer = await pc.createAnswer()
# sdp = answer.sdp
# # Insert a=ptime using the global PTIME variable
# ptime_line = f"a=ptime:{PTIME}"
# if "a=sendrecv" in sdp:
# sdp = sdp.replace("a=sendrecv", f"a=sendrecv\n{ptime_line}")
# else:
# sdp += f"\n{ptime_line}"
# new_answer = RTCSessionDescription(sdp=sdp, type=answer.type)
# await pc.setLocalDescription(new_answer)
# log.info(f"{id_}: sending answer with {ptime_line}")
# return {"sdp": pc.localDescription.sdp,
# "type": pc.localDescription.type}
@app.post("/shutdown")
async def shutdown():
"""Stops broadcasting and releases all audio/Bluetooth resources."""
try:
await multicaster1.shutdown()
await streamer.call(streamer._w_stop_all)
return {"status": "stopped"}
except Exception as e:
raise HTTPException(status_code=500, detail=str(e))
@app.post("/system_reboot")
async def system_reboot():
"""Stop audio and request a system reboot via sudo.
Requires the service user to have passwordless sudo permissions to run 'reboot'.
"""
try:
# Best-effort: stop any active streaming cleanly WITHOUT persisting state
try:
# Close any WebRTC peer connections
close_tasks = [pc.close() for pc in list(pcs)]
pcs.clear()
if close_tasks:
await asyncio.gather(*close_tasks, return_exceptions=True)
# Stop streaming on worker but DO NOT touch stream_settings.json
try:
await streamer.call(streamer._w_stop_all)
except Exception:
pass
except Exception:
log.warning("Non-fatal: failed to stop streams before reboot", exc_info=True)
# Launch reboot without waiting for completion
try:
await asyncio.create_subprocess_exec("sudo", "reboot")
except Exception as e:
log.error("Failed to invoke reboot: %s", e, exc_info=True)
raise HTTPException(status_code=500, detail=f"Failed to invoke reboot: {e}")
return {"status": "rebooting"}
except HTTPException:
raise
except Exception as e:
log.error("Exception in /system_reboot: %s", e, exc_info=True)
raise HTTPException(status_code=500, detail=str(e))
if __name__ == '__main__':
import os
os.chdir(os.path.dirname(__file__))
@@ -418,4 +725,4 @@ if __name__ == '__main__':
format='%(module)s.py:%(lineno)d %(levelname)s: %(message)s'
)
# 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)
uvicorn.run(app, host="127.0.0.1", port=5000, access_log=False)

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

View File

@@ -0,0 +1,96 @@
import os
import json
import base64
import hashlib
import hmac
from pathlib import Path
from typing import Optional, Tuple, Dict
__all__ = [
"is_pw_disabled",
"state_dir",
"pw_file_path",
"ensure_state_dir",
"hash_password",
"save_pw_record",
"load_pw_record",
"verify_password",
]
# Environment-controlled bypass
def is_pw_disabled() -> bool:
val = os.getenv("DISABLE_FRONTEND_PW", "")
return str(val).strip().lower() in ("1", "true", "yes", "on")
# Storage paths and permissions
def state_dir() -> Path:
custom = os.getenv("AURACAST_STATE_DIR")
if custom:
return Path(custom).expanduser()
return Path.home() / ".config" / "auracast"
def pw_file_path() -> Path:
return state_dir() / "frontend_pw.json"
def ensure_state_dir() -> None:
d = state_dir()
d.mkdir(parents=True, exist_ok=True)
try:
os.chmod(d, 0o700)
except Exception:
pass
# Hashing and verification
def hash_password(password: str, salt: Optional[bytes] = None) -> Tuple[bytes, bytes]:
if salt is None:
salt = os.urandom(16)
key = hashlib.pbkdf2_hmac("sha256", password.encode("utf-8"), salt, 150_000, dklen=32)
return salt, key
def save_pw_record(salt: bytes, key: bytes) -> None:
ensure_state_dir()
rec = {
"salt": base64.b64encode(salt).decode("ascii"),
"key": base64.b64encode(key).decode("ascii"),
"kdf": "pbkdf2_sha256",
"iterations": 150000,
}
p = pw_file_path()
p.write_text(json.dumps(rec))
try:
os.chmod(p, 0o600)
except Exception:
pass
def load_pw_record() -> Optional[Dict]:
p = pw_file_path()
if not p.exists():
return None
try:
rec = json.loads(p.read_text())
if "salt" in rec and "key" in rec:
return rec
except Exception:
return None
return None
def verify_password(password: str, rec: Dict) -> bool:
try:
salt = base64.b64decode(rec["salt"])
expected = base64.b64decode(rec["key"])
iters = int(rec.get("iterations", 150000))
key = hashlib.pbkdf2_hmac("sha256", password.encode("utf-8"), salt, iters, dklen=32)
return hmac.compare_digest(key, expected)
except Exception:
return False

View File

@@ -0,0 +1,77 @@
import os
import asyncio
import logging as log
async def reset_nrf54l(slot: int = 0, timeout: float = 8.0):
"""
Reset the nRF54L target using OpenOCD before starting broadcast.
Looks for interface config files in the project at `src/openocd/` relative to this module only.
Accepts filename variants per slot:
- slot 0: raspberrypi-swd0.cfg or swd0.cfg
- slot 1: raspberrypi-swd1.cfg or swd1.cfg
Executes the equivalent of:
openocd \
-f ./raspberrypi-${INTERFACE}.cfg \
-f target/nordic/nrf54l.cfg \
-c "init" \
-c "reset run" \
-c "shutdown"
Best-effort: if OpenOCD is unavailable, logs a warning and continues.
"""
try:
# Resolve project directory and filenames
proj_dir = os.path.abspath(os.path.join(os.path.dirname(__file__), '..', '..', 'openocd'))
names = ['raspberrypi-swd0.cfg', 'swd0.cfg'] if slot == 0 else ['raspberrypi-swd1.cfg', 'swd1.cfg']
cfg = None
for n in names:
p = os.path.join(proj_dir, n)
if os.path.exists(p):
cfg = p
break
if not cfg:
log.warning("reset_nrf54l: no interface CFG found in project dir %s; skipping reset", proj_dir)
return
# Build openocd command (no sudo required as per project setup).
cmd = ['openocd', '-f', cfg, '-f', 'target/nordic/nrf54l.cfg',
'-c', 'init', '-c', 'reset run', '-c', 'shutdown']
async def _run(cmd):
proc = await asyncio.create_subprocess_exec(
*cmd, stdout=asyncio.subprocess.PIPE, stderr=asyncio.subprocess.STDOUT
)
try:
out, _ = await asyncio.wait_for(proc.communicate(), timeout=timeout)
except asyncio.TimeoutError:
log.error("reset_nrf54l: %s timed out; terminating", cmd[0])
proc.kill()
return False
rc = proc.returncode
if rc != 0:
log.error("reset_nrf54l: %s exited with code %s; output: %s", cmd[0], rc, (out or b'').decode(errors='ignore'))
return False
return True
ok = await _run(cmd)
if ok:
log.info("reset_nrf54l: reset succeeded (slot %d) using %s", slot, cfg)
except FileNotFoundError:
log.error("reset_nrf54l: openocd not found; skipping reset")
except Exception:
log.error("reset_nrf54l failed", exc_info=True)
if __name__ == '__main__':
# Basic logging setup
log.basicConfig(
level=os.environ.get('LOG_LEVEL', log.INFO),
format='%(asctime)s.%(msecs)03d %(levelname)s: %(message)s',
datefmt='%Y-%m-%d %H:%M:%S'
)
slot_to_reset = 1
log.info(f"Executing reset for slot {slot_to_reset}")
asyncio.run(reset_nrf54l(slot=slot_to_reset))

View File

@@ -42,58 +42,47 @@ def _sd_matches_from_names(pa_idx, names):
if d["hostapi"] != pa_idx or d["max_input_channels"] <= 0:
continue
dn = d["name"].lower()
# Exclude monitor devices (e.g., "Monitor of ...") to avoid false positives
if "monitor" in dn:
continue
if any(n in dn for n in names_l):
out.append((i, d))
return out
def list_usb_pw_inputs():
# Module-level caches for device lists
_usb_inputs_cache = []
_network_inputs_cache = []
def get_usb_pw_inputs():
"""Return cached list of USB PipeWire inputs."""
return _usb_inputs_cache
def get_network_pw_inputs():
"""Return cached list of Network/AES67 PipeWire inputs."""
return _network_inputs_cache
def refresh_pw_cache():
"""
Return [(device_index, device_dict), ...] for PipeWire **input** nodes
backed by **USB** devices (excludes monitor sources).
Performs a full device scan and updates the internal caches for both USB
and Network audio devices. This is a heavy operation and should not be
called frequently or during active streams.
"""
# Refresh PortAudio so we see newly added nodes before mapping
global _usb_inputs_cache, _network_inputs_cache
# Force PortAudio to re-enumerate devices
_sd_refresh()
pa_idx = _pa_like_hostapi_index()
pw = _pw_dump()
# Map device.id -> device.bus ("usb"/"pci"/"platform"/"network"/...)
# --- Pass 1: Map device.id to device.bus ---
device_bus = {}
for obj in pw:
if obj.get("type") == "PipeWire:Interface:Device":
props = (obj.get("info") or {}).get("props") or {}
device_bus[obj["id"]] = (props.get("device.bus") or "").lower()
# Collect names/descriptions of USB input nodes
# --- Pass 2: Identify all USB and Network nodes ---
usb_input_names = set()
for obj in pw:
if obj.get("type") != "PipeWire:Interface:Node":
continue
props = (obj.get("info") or {}).get("props") or {}
media = (props.get("media.class") or "").lower()
if "source" not in media and "stream/input" not in media:
continue
# skip monitor sources ("Monitor of ..." or *.monitor)
nname = (props.get("node.name") or "").lower()
ndesc = (props.get("node.description") or "").lower()
if ".monitor" in nname or "monitor" in ndesc:
continue
bus = (props.get("device.bus") or device_bus.get(props.get("device.id")) or "").lower()
if bus == "usb":
usb_input_names.add(props.get("node.description") or props.get("node.name"))
# Map to sounddevice devices on PipeWire host API
return _sd_matches_from_names(pa_idx, usb_input_names)
def list_network_pw_inputs():
"""
Return [(device_index, device_dict), ...] for PipeWire **input** nodes that
look like network/AES67/RTP sources (excludes monitor sources).
"""
# Refresh PortAudio so we see newly added nodes before mapping
_sd_refresh()
pa_idx = _pa_like_hostapi_index()
pw = _pw_dump()
network_input_names = set()
for obj in pw:
if obj.get("type") != "PipeWire:Interface:Node":
@@ -102,26 +91,29 @@ def list_network_pw_inputs():
media = (props.get("media.class") or "").lower()
if "source" not in media and "stream/input" not in media:
continue
nname = (props.get("node.name") or "")
ndesc = (props.get("node.description") or "")
# skip monitor sources
# Skip all monitor sources
if ".monitor" in nname.lower() or "monitor" in ndesc.lower():
continue
# Heuristics for network/AES67/RTP
# Check for USB
bus = (props.get("device.bus") or device_bus.get(props.get("device.id")) or "").lower()
if bus == "usb":
usb_input_names.add(ndesc or nname)
continue # A device is either USB or Network, not both
# Heuristics for Network/AES67/RTP
text = (nname + " " + ndesc).lower()
media_name = (props.get("media.name") or "").lower()
node_group = (props.get("node.group") or "").lower()
# Presence flags/keys that strongly indicate network RTP/AES67 sources
node_network_flag = bool(props.get("node.network"))
has_rtp_keys = any(k in props for k in (
"rtp.session", "rtp.source.ip", "rtp.source.port", "rtp.fmtp", "rtp.rate"
))
has_sess_keys = any(k in props for k in (
"sess.name", "sess.media", "sess.latency.msec"
))
has_rtp_keys = any(k in props for k in ("rtp.session", "rtp.source.ip"))
has_sess_keys = any(k in props for k in ("sess.name", "sess.media"))
is_network = (
(props.get("device.bus") or "").lower() == "network" or
bus == "network" or
node_network_flag or
"rtp" in media_name or
any(k in text for k in ("rtp", "sap", "aes67", "network", "raop", "airplay")) or
@@ -132,7 +124,13 @@ def list_network_pw_inputs():
if is_network:
network_input_names.add(ndesc or nname)
return _sd_matches_from_names(pa_idx, network_input_names)
# --- Final Step: Update caches ---
_usb_inputs_cache = _sd_matches_from_names(pa_idx, usb_input_names)
_network_inputs_cache = _sd_matches_from_names(pa_idx, network_input_names)
# Populate cache on initial module load
refresh_pw_cache()
# Example usage:
# for i, d in list_usb_pw_inputs():

View File

@@ -0,0 +1,8 @@
adapter driver bcm2835gpio
transport select swd
adapter gpio swclk 17
adapter gpio swdio 18
#adapter gpio trst 26
#reset_config trst_only
adapter speed 1000

View File

@@ -0,0 +1,8 @@
adapter driver bcm2835gpio
transport select swd
adapter gpio swclk 24
adapter gpio swdio 23
#adapter gpio trst 27
#reset_config trst_only
adapter speed 1000

26
src/scripts/hit_status.sh Normal file
View File

@@ -0,0 +1,26 @@
#!/usr/bin/env bash
# Usage: ./hit_status.sh [COUNT] [SLEEP_SECONDS]
# Always targets http://127.0.0.1:5000/status
# Defaults: COUNT=100 SLEEP_SECONDS=0
# Example: ./hit_status.sh 100 0.05
set -euo pipefail
URL="http://127.0.0.1:5000/status"
COUNT="${1:-100}"
SLEEP_SECS="${2:-0}"
# Ensure COUNT is an integer
if ! [[ "$COUNT" =~ ^[0-9]+$ ]]; then
echo "COUNT must be an integer, got: $COUNT" >&2
exit 1
fi
for i in $(seq 1 "$COUNT"); do
echo "[$i/$COUNT] GET $URL"
curl -sS "$URL" > /dev/null || echo "Request $i failed"
# Sleep if non-zero (supports floats, no bc needed)
if [[ "$SLEEP_SECS" != "0" && "$SLEEP_SECS" != "0.0" && "$SLEEP_SECS" != "" ]]; then
sleep "$SLEEP_SECS"
fi
done

View File

@@ -21,6 +21,7 @@ context.properties = {
#log.level = 2
#default.clock.quantum-limit = 8192
default.clock.node = "PTP0-Driver"
}
context.spa-libs = {
@@ -28,7 +29,7 @@ context.spa-libs = {
}
context.objects = [
# An example clock reading from /dev/ptp0. You can also specify the network interface name,
# An example clock reading f16rom /dev/ptp0. You can also specify the network interface name,
# pipewire will query the interface for the current active PHC index. Another option is to
# sync the ptp clock to CLOCK_TAI and then set clock.id = tai, keep in mind that tai may
# also be synced by a NTP client.
@@ -46,8 +47,8 @@ context.objects = [
clock.interface = "eth0"
#clock.device = "/dev/ptp0"
#clock.id = tai
# Lower this in case of periodic out-of-sync
resync.ms = 1.5
# Lower this in case16 of periodic out-of-sync
resync.ms = 3
object.export = true
}
}
@@ -92,8 +93,8 @@ context.modules = [
media.class = "Audio/Source"
device.api = aes67
# You can adjust the latency buffering here. Use integer values only
sess.latency.msec = 6
node.latency = "144/48000"
sess.latency.msec = 12
#node.latency = "192/48000"
node.group = pipewire.ptp0
}
}

View File

@@ -15,5 +15,4 @@ dscp_general 0
# QoS for general messages
step_threshold 1
# Fast convergence on time jumps
tx_timestamp_timeout 20
tx_timestamp_timeout 100

View File

@@ -1,11 +1,16 @@
[Unit]
Description=PipeWire AES67 Service
After=network.target
After=default.target network-online.target
Wants=network-online.target
[Service]
Type=simple
ExecStartPre=/bin/sh -lc 'for i in $(seq 1 60); do ip route show default >/dev/null 2>&1 && break; sleep 2; done'
ExecStart=/usr/bin/pipewire-aes67 -c /home/caster/bumble-auracast/src/service/aes67/pipewire-aes67.conf
Restart=on-failure
Restart=always
RestartSec=5s
# Avoid StartLimitHit on quick failures during boot; let RestartSec handle pacing
StartLimitIntervalSec=0
[Install]
WantedBy=default.target

View File

@@ -1,22 +1,16 @@
# This script stops and disables the auracast-server and auracast-frontend services
# Requires sudo privileges
echo "Stopping auracast-server.service..."
systemctl --user stop auracast-server.service
echo "Disabling auracast-server.service (user)..."
systemctl --user disable auracast-server.service
echo "Stopping auracast-frontend.service ..."
sudo systemctl stop auracast-frontend.service
echo "Disabling auracast-frontend.service ..."
sudo systemctl kill auracast-frontend.service
sudo systemctl disable auracast-frontend.service
echo "\n--- auracast-server.service status ---"
systemctl --user status auracast-server.service --no-pager
echo "Stopping auracast-server.service..."
systemctl --user kill auracast-server.service
systemctl --user disable auracast-server.service
sudo systemctl kill auracast-server.service
sudo systemctl disable auracast-server.service
sudo rm -f /etc/systemd/system/auracast-server.service
echo "\n--- auracast-frontend.service status ---"
sudo systemctl status auracast-frontend.service --no-pager
echo "auracast-server and auracast-frontend services stopped, disabled, and status printed successfully."
systemctl --user status auracast-server.service --no-pager

View File

@@ -11,7 +11,7 @@ sudo cp /home/caster/bumble-auracast/src/service/ptp_aes67.service /etc/systemd/
mkdir -p /home/caster/.config/systemd/user
cp /home/caster/bumble-auracast/src/service/pipewire-aes67.service /home/caster/.config/systemd/user/pipewire-aes67.service
# Install PipeWire user config to persist 3ms@48kHz (default.clock.quantum=144)
# Install PipeWire user config to persist
mkdir -p /home/caster/.config/pipewire/pipewire.conf.d
cp /home/caster/bumble-auracast/src/service/pipewire/99-lowlatency.conf /home/caster/.config/pipewire/pipewire.conf.d/99-lowlatency.conf
@@ -25,16 +25,12 @@ systemctl --user enable pipewire-aes67.service
# Restart services
systemctl --user restart pipewire.service pipewire-pulse.service
sudo systemctl restart ptp_aes67.service
systemctl --user restart pipewire-aes67.service
sudo systemctl restart ptp_aes67.service
echo "\n--- pipewire.service status (user) ---"
systemctl --user status pipewire.service --no-pager
echo "\n--- ptp_aes67.service status ---"
# print status
sudo systemctl status ptp_aes67.service --no-pager
echo "\n--- pipewire-aes67.service status (user) ---"
systemctl --user status pipewire.service --no-pager
systemctl --user status pipewire-aes67.service --no-pager

View File

@@ -7,7 +7,7 @@ set -e
# Copy system service file for frontend
sudo cp /home/caster/bumble-auracast/src/service/auracast-frontend.service /etc/systemd/system/auracast-frontend.service
# Copy user service file for backend (now using WantedBy=default.target)
# Copy user service file for backend
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
@@ -25,9 +25,6 @@ systemctl --user enable auracast-server.service
sudo systemctl restart auracast-frontend.service
systemctl --user restart auracast-server.service
echo "\n--- auracast-frontend.service status ---"
#print status
sudo systemctl status auracast-frontend.service --no-pager
echo "\n--- auracast-server.service status---"
systemctl --user status auracast-server.service --no-pager
echo "auracast-server and auracast-frontend services updated, enabled, restarted, and status printed successfully."