diff --git a/.gitignore b/.gitignore index 6d6cf83..68ac071 100644 --- a/.gitignore +++ b/.gitignore @@ -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 diff --git a/README.md b/README.md index 7795ec3..fbe9dc3 100644 --- a/README.md +++ b/README.md @@ -61,6 +61,20 @@ sudo ./provision_domain_hostname.sh - 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 `.` (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 diff --git a/poetry.lock b/poetry.lock index ef35d0e..70213c8 100644 --- a/poetry.lock +++ b/poetry.lock @@ -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" diff --git a/pyproject.toml b/pyproject.toml index 77c2ed9..dd44d25 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -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", diff --git a/src/auracast/auracast_config.py b/src/auracast/auracast_config.py index dfeb1f9..2971dea 100644 --- a/src/auracast/auracast_config.py +++ b/src/auracast/auracast_config.py @@ -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, " diff --git a/src/auracast/multicast.py b/src/auracast/multicast.py index abcc4f3..9a00846 100644 --- a/src/auracast/multicast.py +++ b/src/auracast/multicast.py @@ -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 diff --git a/src/auracast/server/multicast_frontend.py b/src/auracast/server/multicast_frontend.py index 2d383f7..e63fc25 100644 --- a/src/auracast/server/multicast_frontend.py +++ b/src/auracast/server/multicast_frontend.py @@ -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: 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' diff --git a/src/auracast/server/multicast_server.py b/src/auracast/server/multicast_server.py index 7e4e588..434859a 100644 --- a/src/auracast/server/multicast_server.py +++ b/src/auracast/server/multicast_server.py @@ -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 don’t 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") diff --git a/src/auracast/utils/favicon.ico b/src/auracast/utils/favicon.ico new file mode 100644 index 0000000..a24f0b2 Binary files /dev/null and b/src/auracast/utils/favicon.ico differ diff --git a/src/auracast/utils/frontend_auth.py b/src/auracast/utils/frontend_auth.py new file mode 100644 index 0000000..e1d4111 --- /dev/null +++ b/src/auracast/utils/frontend_auth.py @@ -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 diff --git a/src/auracast/utils/sounddevice_utils.py b/src/auracast/utils/sounddevice_utils.py index f1f4f05..35dbea0 100644 --- a/src/auracast/utils/sounddevice_utils.py +++ b/src/auracast/utils/sounddevice_utils.py @@ -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() diff --git a/src/service/aes67/pipewire-aes67.conf b/src/service/aes67/pipewire-aes67.conf index 001c24a..3f64320 100644 --- a/src/service/aes67/pipewire-aes67.conf +++ b/src/service/aes67/pipewire-aes67.conf @@ -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 } } diff --git a/src/service/aes67/ptp_aes67_1.conf b/src/service/aes67/ptp_aes67_1.conf index c4c1283..4e1b070 100644 --- a/src/service/aes67/ptp_aes67_1.conf +++ b/src/service/aes67/ptp_aes67_1.conf @@ -15,5 +15,4 @@ dscp_general 0 # QoS for general messages step_threshold 1 # Fast convergence on time jumps - -tx_timestamp_timeout 20 \ No newline at end of file +tx_timestamp_timeout 100 \ No newline at end of file diff --git a/src/service/pipewire-aes67.service b/src/service/pipewire-aes67.service index b800101..d3eeb30 100644 --- a/src/service/pipewire-aes67.service +++ b/src/service/pipewire-aes67.service @@ -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 diff --git a/src/service/stop_server_and_frontend.sh b/src/service/stop_server_and_frontend.sh index e6cf15b..d467b10 100644 --- a/src/service/stop_server_and_frontend.sh +++ b/src/service/stop_server_and_frontend.sh @@ -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 diff --git a/src/service/update_and_run_pw_aes67.sh b/src/service/update_and_run_pw_aes67.sh index 30da008..2ddbe0c 100644 --- a/src/service/update_and_run_pw_aes67.sh +++ b/src/service/update_and_run_pw_aes67.sh @@ -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 diff --git a/src/service/update_and_run_server_and_frontend.sh b/src/service/update_and_run_server_and_frontend.sh index 5130bb2..7dc42af 100644 --- a/src/service/update_and_run_server_and_frontend.sh +++ b/src/service/update_and_run_server_and_frontend.sh @@ -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."