feature/autostart (#7)

implement auto restart of last stream
fix aes67 streaming
basic webinterface
and many more

Co-authored-by: pstruebi <struebin.patrick.com>
Reviewed-on: https://gitea.pstruebi.xyz/auracaster/bumble-auracast/pulls/7
This commit was merged in pull request #7.
This commit is contained in:
2025-09-24 17:06:46 +02:00
parent 26cd6d5c56
commit a9ea04ed87
17 changed files with 676 additions and 197 deletions

4
.gitignore vendored
View File

@@ -35,10 +35,10 @@ 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

View File

@@ -61,6 +61,20 @@ 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
---
After completing these steps, your device will be discoverable as `<hostname>.<domain>` (e.g., `box1.auracast.local`) on the local network via mDNS.
@@ -199,7 +213,7 @@ sudo ldconfig # refresh linker cache
- sudo modprobe i2c-dev
- echo i2c_bcm2835 | sudo tee -a /etc/modules
- echo i2c-dev | sudo tee -a /etc/modules
- read temp /src/scripts/temp
- read temp /src/scripts/temp
# Known issues:
- When running on a laptop there might be issues switching between usb and browser audio input since they use the same audio device

108
poetry.lock generated
View File

@@ -332,10 +332,10 @@ files = [
[[package]]
name = "bumble"
version = "0.0.209.dev2+g12bcdb7"
version = "0.0.216.dev1+g6eba81e3d"
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 = "6eba81e3ddb8ac0e4c336ca244892a0a8d43ba1c"
resolved_reference = "6eba81e3ddb8ac0e4c336ca244892a0a8d43ba1c"
[[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 = "3afe565be2664b3d7f1cfdb1c5a73d931c14e97d3622aef24ba2f06f78e00e2b"

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@6eba81e3ddb8ac0e4c336ca244892a0a8d43ba1c",
"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
),
@@ -674,7 +700,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

@@ -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,26 @@ try:
except Exception:
saved_settings = {}
st.title("🎙️ Auracast Audio Mode Control")
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,6 +138,25 @@ 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_placeholder = st.columns([1, 1, 2])
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))
)
#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:
@@ -99,6 +190,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 +207,9 @@ 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 backend
assisted_listening_stream=assisted_listening,
immediate_rendering=immediate_rendering,
bigs=bigs1
)
config2 = None
@@ -123,7 +217,9 @@ 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,
bigs=bigs2
)
# Call /init and /init2
@@ -151,6 +247,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 +291,47 @@ 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: Assistive Listening and Immediate Rendering (one row)
col_flags1, col_flags2, col_placeholder = st.columns([1, 1, 2])
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))
)
# 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")
device_list = resp.json().get('inputs', [])
# Input device selection for USB or AES67 mode
if audio_mode in ("USB", "AES67"):
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 +341,20 @@ 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.")
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"):
# For completeness, refresh the general audio cache as well
try:
requests.post(f"{BACKEND_URL}/refresh_audio_inputs", timeout=3)
r = requests.post(f"{BACKEND_URL}/refresh_audio_inputs", json={"force": True}, timeout=5)
if r.ok:
jr = r.json()
if jr.get('stopped_stream'):
st.info("An active stream was stopped to perform a full device refresh.")
except Exception as e:
st.error(f"Failed to refresh devices: {e}")
st.rerun()
@@ -238,17 +370,35 @@ else:
with col2:
if st.button("Refresh"):
try:
requests.post(f"{BACKEND_URL}/refresh_audio_inputs", timeout=3)
r = requests.post(f"{BACKEND_URL}/refresh_audio_inputs", json={"force": True}, timeout=5)
if r.ok:
jr = r.json()
if jr.get('stopped_stream'):
st.info("An active stream was stopped to perform a full device refresh.")
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:
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")
with c_stop:
stop_stream = st.button("Stop Auracast")
# 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)
try:
status_resp = requests.get(f"{BACKEND_URL}/status", timeout=0.8)
status_json = status_resp.json() if status_resp.ok else {}
except Exception:
status_json = {}
is_streaming = bool(status_json.get("is_streaming", False))
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 +415,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:
@@ -297,18 +448,21 @@ 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,
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 +474,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 +548,35 @@ else:
# else:
# st.error("Could not fetch advertised streams.")
############################
# System expander (collapsed)
############################
with st.expander("System", expanded=False):
if is_pw_disabled():
st.info("Frontend password protection is disabled via DISABLE_FRONTEND_PW.")
else:
st.subheader("Change password")
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}")
log.basicConfig(
level=os.environ.get('LOG_LEVEL', log.DEBUG),
format='%(module)s.py:%(lineno)d %(levelname)s: %(message)s'

View File

@@ -7,6 +7,8 @@ import sys
from datetime import datetime
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,29 +17,42 @@ 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 (
list_usb_pw_inputs,
list_network_pw_inputs,
)
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]] = []
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 load_stream_settings() -> dict:
"""Load persisted stream settings if available."""
if os.path.exists(STREAM_SETTINGS_FILE):
@@ -81,21 +96,25 @@ async def initialize(conf: auracast_config.AuracastConfigGroup):
global global_config_group
global multicaster1
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.')
conf.transport = TRANSPORT1
# Derive audio_mode and input_device from first BIG audio_source
first_source = conf.bigs[0].audio_source if conf.bigs else ''
if first_source.startswith('device:'):
audio_mode_persist = 'USB'
input_device_name = first_source.split(':', 1)[1] if ':' in first_source else None
# Determine if the device is a USB or Network(AES67) PipeWire input
try:
usb_names = {d.get('name') for _, d in list_usb_pw_inputs(refresh=False)}
net_names = {d.get('name') for _, d in list_network_pw_inputs(refresh=False)}
except Exception:
usb_names, net_names = set(), set()
if input_device_name in net_names:
audio_mode_persist = 'AES67'
os.environ.setdefault("PULSE_LATENCY_MSEC", "6")
else:
audio_mode_persist = 'USB'
os.environ.setdefault("PULSE_LATENCY_MSEC", "3")
# 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)
@@ -122,6 +141,11 @@ async def initialize(conf: auracast_config.AuracastConfigGroup):
'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],
'auracast_sampling_rate_hz': conf.auracast_sampling_rate_hz,
'octets_per_frame': conf.octets_per_frame,
'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),
'timestamp': datetime.utcnow().isoformat()
})
global_config_group = conf
@@ -145,16 +169,7 @@ async def initialize2(conf: auracast_config.AuracastConfigGroup):
"""Initializes the secondary broadcaster (multicaster2). Does NOT persist stream settings."""
global multicaster2
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.')
conf.transport = TRANSPORT2
# Patch device name to index for sounddevice
for big in conf.bigs:
if big.audio_source.startswith('device:'):
@@ -232,47 +247,183 @@ async def get_status():
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()
devs = sd.query_devices()
inputs = [
dict(d, id=idx)
for idx, d in enumerate(devs)
if d.get("max_input_channels", 0) > 0
]
log.info('Found %d audio input devices: %s', len(inputs), inputs)
AUDIO_INPUT_DEVICES_CACHE = inputs
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')
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')
try:
usb_names = {d.get('name') for _, d in list_usb_pw_inputs(refresh=False)}
net_names = {d.get('name') for _, d in list_network_pw_inputs(refresh=False)}
except Exception:
usb_names, net_names = set(), set()
if input_device_name in net_names:
os.environ.setdefault("PULSE_LATENCY_MSEC", "6")
else:
os.environ.setdefault("PULSE_LATENCY_MSEC", "3")
# Only auto-start device-based inputs; Webapp and Demo require external sources/UI
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
if multicaster1 and multicaster1.get_status().get('is_streaming'):
return
while True:
# Do not interfere if user started a stream manually in the meantime
if multicaster1 and multicaster1.get_status().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
# Avoid refreshing PortAudio while we poll
usb = [d for _, d in list_usb_pw_inputs(refresh=False)]
net = [d for _, d in list_network_pw_inputs(refresh=False)]
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=f"int16le,{rate},1",
iso_que_len=1,
sampling_frequency=rate,
octets_per_frame=octets,
)
]
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,
bigs=bigs,
)
# Initialize and start
await initialize(conf)
return
await asyncio.sleep(2)
except Exception:
log.error("Exception while scanning audio devices:", exc_info=True)
# Do not clear cache on error, keep the last known good list
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()
@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}
#TODO: enable and test this
@app.on_event("startup")
async def _startup_autostart_event():
# Spawn the autostart task without blocking startup
asyncio.create_task(_autostart_from_settings())
@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}
async def refresh_audio_inputs(force: bool = False):
"""Triggers a re-scan of audio devices.
If force is True and a stream is active, the stream(s) will be stopped to allow
a full re-initialization of the sounddevice backend. The response will include
'stopped_stream': True if any running stream was stopped.
"""
stopped = False
if force:
try:
# Stop active streams before forcing sounddevice re-init
if multicaster1 is not None and multicaster1.get_status().get('is_streaming'):
await multicaster1.stop_streaming()
stopped = True
if multicaster2 is not None and multicaster2.get_status().get('is_streaming'):
await multicaster2.stop_streaming()
stopped = True
except Exception:
log.warning("Failed to stop stream(s) before force refresh", exc_info=True)
# Reinitialize sounddevice backend if requested
try:
if sys.platform == 'linux' and force:
log.info("Force re-initializing sounddevice backend")
sd._terminate()
sd._initialize()
except Exception:
log.error("Exception while force-refreshing audio devices:", exc_info=True)
return {"status": "ok", "inputs": [], "stopped_stream": stopped}
@app.get("/audio_inputs_pw_usb")
async def audio_inputs_pw_usb():
"""List PipeWire USB input nodes mapped to sounddevice indices.
Returns a list of dicts: [{id, name, max_input_channels}].
"""
try:
# Do not refresh PortAudio if we are currently streaming to avoid termination
streaming = False
try:
if multicaster1 is not None:
status = multicaster1.get_status()
streaming = bool(status.get('is_streaming'))
except Exception:
streaming = False
devices = [
{"id": idx, "name": dev.get("name"), "max_input_channels": dev.get("max_input_channels", 0)}
for idx, dev in list_usb_pw_inputs(refresh=not streaming)
]
return {"inputs": devices}
except Exception as e:
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 mapped to sounddevice indices.
Returns a list of dicts: [{id, name, max_input_channels}].
"""
try:
# Do not refresh PortAudio if we are currently streaming to avoid termination
streaming = False
try:
if multicaster1 is not None:
status = multicaster1.get_status()
streaming = bool(status.get('is_streaming'))
except Exception:
streaming = False
devices = [
{"id": idx, "name": dev.get("name"), "max_input_channels": dev.get("max_input_channels", 0)}
for idx, dev in list_network_pw_inputs(refresh=not streaming)
]
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("/offer")

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

@@ -42,17 +42,25 @@ 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():
def list_usb_pw_inputs(refresh: bool = True):
"""
Return [(device_index, device_dict), ...] for PipeWire **input** nodes
backed by **USB** devices (excludes monitor sources).
Parameters:
- refresh (bool): If True (default), force PortAudio to re-enumerate devices
before mapping. Set to False to avoid disrupting active streams.
"""
# Refresh PortAudio so we see newly added nodes before mapping
_sd_refresh()
if refresh:
_sd_refresh()
pa_idx = _pa_like_hostapi_index()
pw = _pw_dump()
@@ -84,13 +92,18 @@ def list_usb_pw_inputs():
# Map to sounddevice devices on PipeWire host API
return _sd_matches_from_names(pa_idx, usb_input_names)
def list_network_pw_inputs():
def list_network_pw_inputs(refresh: bool = True):
"""
Return [(device_index, device_dict), ...] for PipeWire **input** nodes that
look like network/AES67/RTP sources (excludes monitor sources).
Parameters:
- refresh (bool): If True (default), force PortAudio to re-enumerate devices
before mapping. Set to False to avoid disrupting active streams.
"""
# Refresh PortAudio so we see newly added nodes before mapping
_sd_refresh()
if refresh:
_sd_refresh()
pa_idx = _pa_like_hostapi_index()
pw = _pw_dump()

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
ExecStart=/usr/bin/pipewire-aes67 -c /home/caster/bumble-auracast/src/service/aes67/pipewire-aes67.conf
Restart=on-failure
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=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."